From a7765477f2b80db5efe347a0e8577570d0cb212d Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 19:13:56 -0400 Subject: [PATCH 01/14] feat: reset ThreadBear to one local title write [BEAR-116] --- .github/workflows/ci.yml | 8 +- .github/workflows/release-smoke.yml | 10 +- .github/workflows/release.yml | 8 +- CHANGELOG.md | 15 + CLAUDE.md | 5 +- INSTALL.md | 171 ++- README.md | 31 +- assets/AGENTS.threadbear.md | 53 +- assets/help.txt | 50 +- assets/skill/SKILL.md | 124 +- cmd/threadbear/appserver_list.go | 299 +++++ cmd/threadbear/appserver_list_test.go | 364 ++++++ cmd/threadbear/core_test.go | 1166 ++++++----------- cmd/threadbear/hook.go | 206 --- cmd/threadbear/install.go | 1206 ++++++++++++------ cmd/threadbear/install_test.go | 1387 ++++++++++++++------- cmd/threadbear/launchagent.go | 180 +++ cmd/threadbear/lifecycle_test.go | 331 ----- cmd/threadbear/main.go | 96 +- cmd/threadbear/main_test.go | 39 + cmd/threadbear/maintenance.go | 219 ---- cmd/threadbear/maintenance_test.go | 317 ----- cmd/threadbear/migration.go | 142 --- cmd/threadbear/migration_test.go | 296 ----- cmd/threadbear/scan.go | 459 ++++--- cmd/threadbear/site_contract_test.go | 461 ++----- cmd/threadbear/state.go | 376 ++++-- cmd/threadbear/state_test.go | 381 +++--- cmd/threadbear/test_ids_test.go | 24 + cmd/threadbear/uninstall_protocol_test.go | 533 -------- cmd/threadbear/update.go | 124 +- cmd/threadbear/update_test.go | 231 ++-- docs/README.md | 10 +- docs/architecture.md | 58 +- docs/benchmark.md | 8 +- docs/compatibility.md | 14 +- docs/live-eval.md | 40 +- docs/release-checklist.md | 18 +- docs/status-convention.md | 38 +- go.mod | 17 +- go.sum | 51 - install.sh | 1 - scripts/release-smoke.sh | 1099 ++++++++++++++-- site/index.html | 29 +- site/install | 171 ++- site/install.sh | 1 - 46 files changed, 5615 insertions(+), 5252 deletions(-) create mode 100644 cmd/threadbear/appserver_list.go create mode 100644 cmd/threadbear/appserver_list_test.go delete mode 100644 cmd/threadbear/hook.go create mode 100644 cmd/threadbear/launchagent.go delete mode 100644 cmd/threadbear/lifecycle_test.go create mode 100644 cmd/threadbear/main_test.go delete mode 100644 cmd/threadbear/maintenance.go delete mode 100644 cmd/threadbear/maintenance_test.go delete mode 100644 cmd/threadbear/migration.go delete mode 100644 cmd/threadbear/migration_test.go create mode 100644 cmd/threadbear/test_ids_test.go delete mode 100644 cmd/threadbear/uninstall_protocol_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bd2714..5e03909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,16 +22,10 @@ jobs: with: go-version-file: go.mod cache: true - - name: Check Go formatting and shipped logic size + - name: Check Go formatting run: | files=$(find . -type f -name '*.go' ! -path './.git/*' ! -path './.worktrees/*' | sort) test -z "$(printf '%s\n' "$files" | xargs gofmt -l)" - production=$(printf '%s\n' "$files" | awk '!/_test\.go$/') - go_lines=$(printf '%s\n' "$production" | xargs wc -l | awk 'END { print $1 }') - shell_lines=$(wc -l < install.sh | tr -d ' ') - lines=$((go_lines + shell_lines)) - echo "shipped logic lines: $lines (1,500 target; 2,000 absolute ceiling)" - test "$lines" -le 2000 - run: go test ./... - run: go vet ./... - name: Validate experiment registry diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 08cbe51..b8334d5 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -4,13 +4,13 @@ on: workflow_call: inputs: tag: - description: Published stable-shaped release tag (vN.N.N) + description: Published current release tag (vN.N.N) required: true type: string workflow_dispatch: inputs: tag: - description: Published stable-shaped release tag (vN.N.N) + description: Published current release tag (vN.N.N) required: true type: string @@ -31,7 +31,7 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} run: | if ! printf '%s\n' "$RELEASE_TAG" | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { ok=1 } END { exit(ok ? 0 : 1) }'; then - echo "release smoke requires an already-published stable-shaped vN.N.N tag" >&2 + echo "release smoke requires the published current stable-shaped vN.N.N tag" >&2 exit 1 fi echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" @@ -51,8 +51,8 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: live release manifest, checksum, candidate self-test, binary install, native PreToolUse/PostToolUse title finalization, settled state and inventory, prepared active-task uninstall, fixture-simulated archived-control cleanup/restoration, completed-controller preservation, and binary-last teardown. Real native archive control remains a separate release canary." - echo "- Not proved: real Codex auth, rendered Desktop titles, Luna behavior, or architectures other than this runner." + echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; direct terminal title read/set/readback with exact subject preservation, local failure, and unconfirmed acknowledgement; a complete multi-page onboarding preview with deduplication and failure-before-writes; an uncapped serial confirmed pass with drift, skipped, updated, unchanged, and unconfirmed accounting; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." + echo "- Not proved: real Codex auth, native automation deletion or exact-task unpin, rendered Desktop repaint, clean-restart persistence, native timeout behavior, a newer-version partial update, or architectures other than this runner. Those seams require the recorded local Desktop and fault-injection canaries." echo "- Deployment timing: a Pages/CDN lag can make the live bootstrap older than the release commit; that red result still requires operator investigation." echo "- A red result marks the published release for operator action; this workflow does not delete, demote, or retry a release." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37c9ac4..cd489c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,16 +57,10 @@ jobs: in_section && /^## / { exit } in_section { print } ' CHANGELOG.md > release-notes.md - - name: Check Go formatting and shipped logic size + - name: Check Go formatting run: | files=$(find . -type f -name '*.go' ! -path './.git/*' ! -path './.worktrees/*' | sort) test -z "$(printf '%s\n' "$files" | xargs gofmt -l)" - production=$(printf '%s\n' "$files" | awk '!/_test\.go$/') - go_lines=$(printf '%s\n' "$production" | xargs wc -l | awk 'END { print $1 }') - shell_lines=$(wc -l < install.sh | tr -d ' ') - lines=$((go_lines + shell_lines)) - echo "shipped logic lines: $lines (1,500 target; 2,000 absolute ceiling)" - test "$lines" -le 2000 - run: go test ./... - run: go vet ./... - name: Validate experiment registry diff --git a/CHANGELOG.md b/CHANGELOG.md index d49e699..5489aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +## v3.0.0 - 2026-08-07 + +### Changed + +- Reset ThreadBear to one terminal local title command that preserves exact subjects, changes only the status icon, keeps actions in response prose, and leaves unsafe titles unchanged. +- Made one short-lived official Codex App Server the sole task read/write authority: exact current-title read, at most one `thread/name/set`, and exact readback, with no SQLite access, daemon, proxy, cache, fallback, or retry. +- Replaced controller migration with explicit, uncapped, serial onboarding that enumerates the complete catalog before writes, skips null, blank, drifted, or unsafe names, never adopts preview text, and reports honest updated, unchanged, skipped, and unconfirmed counts. +- Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. +- Separated title-core readiness from updater health and made successful updates report restart requirements; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. +- Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. + +### Removed + +- Removed the running-title call, Post hook, persistent ThreadBear task, migration/controller/classifier machinery, archive maintenance, pending title transactions, repair flows, and global title-failure states. + ## v2.2.1 - 2026-08-07 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index e3d8aa4..e44f519 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,12 @@ @docs/README.md -ThreadBear is a playful, token-conscious Codex title manager for macOS: one small on-demand Go binary and two native title hooks keep visible Desktop titles current without a daemon. +ThreadBear is a playful, token-conscious Codex title manager for macOS: one small Go binary uses the official local App Server to keep visible Desktop titles current without a daemon or model call. - Current work is tracked in live Linear issues. The evergreen product contract is `README.md` plus `docs/architecture.md`; dated files in `docs/plans/` are historical evidence, not current architecture. - Before title-path architecture or live experiments, run `python3 scripts/validate-experiments.py`, consult `docs/experiments/registry.json`, and satisfy the preflight in `docs/experiments/README.md`. Contradictory records remain conditional until one changed variable is isolated. Automation proves mechanical integrity; the active issue and pull-request review judge whether the unknown and changed variable are meaningful. - Private eval corpus: `ericlitman/threadbear-eval` (real user messages — must never enter this public tree). - Voice: playful, bear-themed, never at the expense of operational clarity. -- Shipped-logic target is 1,500 physical lines and CI rejects more than the 2,000-line absolute ceiling; stay as small as the product permits without compressing code to game the count. Size-policy changes require a live issue to record the measured baseline and ratify the new bounds before implementation. -- The only scheduler is the consented `threadbear-maintenance` Codex heartbeat attached to the persistent task. Do not add a LaunchAgent, second schedule, pending-title queue, detached title writer, or background classifier. +- The only scheduler is the daily `sh.threadbear.update` LaunchAgent. It may run only the verified update command; it never reads tasks, invokes a model, onboards titles, or archives anything. Do not add another schedule, persistent ThreadBear task, pending-title queue, detached title writer, or background classifier. - Changelog: every PR with user-visible changes must append a concise entry under `CHANGELOG.md`'s `Unreleased` section. Release preparation renames that section to `vN.N.N - YYYY-MM-DD` and adds a fresh `Unreleased` section; the release workflow rejects stable tags without the matching version section. - Shipping evidence: unit and fixture tests are necessary but never sufficient. Before calling a native lifecycle or release path shippable, exercise the reviewed candidate end to end against the real supported native control or official release service in an isolated, recoverable canary, record exact candidate/ref/results, and clean up the canary without visual inspection. diff --git a/INSTALL.md b/INSTALL.md index eaec693..15dd0a9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,6 +1,6 @@ # Install ThreadBear with Codex -Open a new Codex Desktop task with Luna at medium effort and paste: +Open a new Codex Desktop task and paste: ```text Install ThreadBear — follow https://threadbear.sh/install @@ -8,23 +8,21 @@ Install ThreadBear — follow https://threadbear.sh/install ## Agent brief -You are the ThreadBear guide in this task. Make installation feel like a short, thoughtful product experience. Keep commands, paths, task IDs, and raw JSON backstage unless they explain a failure or the user asks for them. +You are the ThreadBear guide in this task. Keep the experience short, warm, and concrete. Explain visible outcomes before commands and keep raw JSON, paths, and task IDs backstage unless they explain a failure. Do not use screenshots, computer control, or ask the user to inspect private files during an ordinary install. -Open with this complete orientation before running a command: +Open with this orientation: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps Codex Desktop task titles useful with a running mark while work is underway and a clear outcome when each response finishes. It uses two native title calls per ordinary turn, two small deterministic hooks, and one quiet Luna helper for housekeeping. +> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command reads and updates the title through Codex's official App Server. > -> I'll check this Mac, show you exactly what will change, and answer questions before installing anything. After you consent, I'll install it and update native-addressable local Codex task titles without taking you away from this ThreadBear task. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. +> I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. -Codex collapses commentary after a turn finishes. The welcome may appear there while checks run, but commentary copies do not satisfy this contract. Every terminal final answer in this first turn must be self-contained. If every check and the dry run succeeds, `phase: final_answer` must include the complete orientation above, the readiness sentence, the full recommendation card, and the consent question. Do not end a successful turn with only the consent question. If any check fails, keep the complete orientation and truthful failure visible in `phase: final_answer`; do not claim readiness, show the recommendation card, or ask for consent. +Codex collapses commentary after a turn finishes, so the final answer that asks for consent must repeat the orientation, readiness result, complete recommendation, and question. If a check fails, report it plainly and do not ask for install consent. -Keep the tone warm, calm, capable, and lightly playful. Explain visible outcomes first. Show the complete recommendation before asking for consent. A clear yes to an unchanged complete recommendation is installation consent; ask again only if the effect changed, the answer was ambiguous, or a reinstall changes the recommendation. +## 1. Check without changing anything -## 1. Check this Mac - -Run compatibility checks without mutation: +Run: ```sh sw_vers -productVersion @@ -39,160 +37,143 @@ if [ -x "$HOME/.local/bin/threadbear" ]; then fi ``` -ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop, and HTTPS access to the official guide and GitHub Releases. Do not use `sudo`, grant Full Disk Access, or edit Codex private UI storage. - -Resolve the canonical ID of this calling task with supported Codex task tooling and keep it as `MAIN_TASK_ID`. This initiating task becomes ThreadBear's persistent home. On a reinstall, use the persisted ThreadBear task ID from `status --json`; never adopt whichever task happened to launch the reinstall. +ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop, and HTTPS access to the official guide and GitHub Releases. It needs no `sudo` or Full Disk Access. It never opens Codex SQLite or edits Desktop storage. For an official release, run the verified bootstrap preview: ```sh -curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --control-task-id "$MAIN_TASK_ID" --dry-run --json +curl -fsSL https://threadbear.sh/install.sh | sh -s -- --dry-run --json ``` For an already-built local candidate, run: ```sh -/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --dry-run --json +/path/to/threadbear install --dry-run --json ``` -Require a successful candidate self-test and a dry-run limited to adopting the initiating task, the binary, one small private state file, one managed AGENTS block, one installed skill, and two hook entries. Preserve unrelated AGENTS content and hook definitions in their existing order. +The preview must pass candidate self-test and be limited to the binary, private subject records, managed AGENTS block, installed skill, and one daily update-only LaunchAgent. It must preserve unrelated AGENTS content, skills, settings, files, and LaunchAgents. + +If the preview returns `legacy_reset_required:true`, require `legacy_main_task_id` plus `legacy_automation_id`, `legacy_automation_name`, `legacy_automation_kind`, and `legacy_automation_target_thread_id`. The target must equal the main-task ID. This is a clean 2.2.1 reset, not an in-place migration. Through supported native controls, verify the exact automation and former persistent task before proposing mutation. A collision, missing target, or uncertain owner stops the reset. The reset also removes only exact obsolete ThreadBear Pre/Post title-interception entries and preserves every foreign entry and its order. Import no old state and reinterpret no legacy title. ## 2. Show the recommendation -Only after every check and the dry run succeeds, compose one terminal final answer with no later tool call or commentary. Repeat the complete orientation, say “This Mac and Codex are ready for ThreadBear,” then continue with the full card: +Only after the checks and dry run succeed, present this complete card in the same final answer as the consent question: > ## Recommended setup > -> - Status icon in each native-addressable local Codex task title. -> - Next action added to the thread title → like this. -> - Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed. -> - Small local footprint: one binary in ~/.local/bin, a skill, and two hooks. -> - One persistent thread, ThreadBear, for changing config and uninstalling; its title never receives a status prefix. -> - Deterministic classification and Luna-medium ambiguity checks run in parallel. -> - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. -> - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. -> - ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat. +> - One status icon in each task title, updated once immediately before the final response. +> - Your exact subject stays intact; owners and actions remain in response prose. +> - Unsafe, ambiguous, active, drifted, or overlong titles are left alone. +> - Existing tasks can be previewed completely and onboarded serially, with no item cap. +> - Small local footprint: one binary, tiny subject records, one skill, and one managed instruction block. +> - One daily LaunchAgent installs only verified official updates and never reads tasks or changes titles. +> - No persistent ThreadBear task, controller, classifier, archive automation, queue, or background repair. +> - Codex restarts once after install so open tasks load the new guidance. > > Install ThreadBear with this recommended setup? -The welcome heading, orientation, readiness sentence, every recommendation bullet, and consent question must all be present in `phase: final_answer` when the completed task is read back. Do not send the card only as commentary and do not follow it with a question-only final answer. +For a 2.2.1 reset, add one sentence: the exact old maintenance automation will be deleted, its exact former persistent task will be unpinned but not renamed, managed artifacts will be replaced, old state will not be imported, and ambiguous historical icons may remain. -Answer questions without inventing options or flags. A clear yes to this unchanged recommendation advances directly to installation. +A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. If the user does not want historical onboarding, accept that preference and add `--no-onboard` to the confirmed install. ## 3. Install after consent -For the verified official release, run: +Before a 2.2.1 reset, delete the exact fingerprinted `threadbear-maintenance` automation through supported native control and verify it is absent. Then unpin the preview's exact legacy main-task ID and verify the returned and reread task ID match with `pinned:false`. Do not rename that task. Any mismatch aborts before filesystem reset. The confirmed candidate command must include `--reset`. + +For the official release, run: ```sh curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --control-task-id "$MAIN_TASK_ID" \ --noninteractive --confirm --json ``` -For the verified local candidate, run: +For a local candidate, run: ```sh -/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --noninteractive --confirm --json +/path/to/threadbear install --noninteractive --confirm --json ``` -Then verify the installed surfaces: +Add `--no-onboard` only when the user opted out. Add `--reset` only after the exact legacy cleanup is verified. Then run: ```sh ~/.local/bin/threadbear version --json ~/.local/bin/threadbear self-test --json ~/.local/bin/threadbear status --json -~/.local/bin/threadbear inventory --json ``` -The install result must show `installed:true`, the exact `main_task_id`, and `phase:migration_pending` unless a prior migration state is being preserved. Pending means the background controller has not started; it is never described as running. `ready:true` means `migration_complete`, not merely that artifacts were written. Do not claim the hooks work merely because files were written. - -Inventory and migration cover local Codex Desktop and CLI tasks that the native explicit-target title setter can address. They do not enumerate or rename older signed-in ChatGPT chat-history rows that may also appear in the Desktop sidebar. Disclose that boundary before migration and never describe zero local inventory rows as proof that every visible sidebar row changed. - -Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” On creation omit `id`; Codex derives it from the exact name, and the returned `automationId` must equal `threadbear-maintenance`. If it differs, delete only that returned ID, verify it is absent, report the partial install, and stop. Never treat the create request's status as proof: immediately update that exact returned ID with the complete expected fields and `status:"PAUSED"`, then verify the full exact match before creating the controller. Pause and verify an existing exact match before migration too. Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. If creation, pause, or verification fails, delete a just-created automation only after an exact ownership read, verify it is absent, report the partial install, and stop. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. - -If the native automation call fails, say that ThreadBear's local title helper is installed but its housekeeping helper is not, and stop before claiming completion. Do not ask the user to create or repair the automation manually. - -## 4. Migrate without leaving this task - -Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install** and **Migration controller** sections. The installed skill is the canonical operation guide. - -Before migration, tell the user: - -> ThreadBear will stay selected while one background controller updates native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. This usually takes several minutes, and a large or ambiguous local history can take longer. `migration_running` means the controller is actively working; I'll report every 25 applied titles or phase change and won't finish this installation turn until it reaches `migration_complete` or `migration_failed`. - -Before any bulk work, follow **Install** step 5 in the canonical installed skill exactly: make its one exact untagged home-title call, require the returned task ID/title, then pin the initiating task and keep it selected. Do not add a nonce or make a second title call. For an ordinary guided installation, do not use visual inspection, computer control, screenshots, or Codex `/hooks`, and do not ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate; visual hook verification is outside this ordinary installation flow. - -Create exactly one projectless background migration-controller task with `codex_app__create_thread` using `model:"gpt-5.6-terra"`, `thinking:"medium"`, and a prompt containing the controller protocol from the installed ThreadBear skill. Begin its delegated input with the exact sentence `ThreadBear controller registration.` Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. Retain the task ID returned by creation only as a supervision handle; Codex may expose a different native runtime ID. The controller's required first current-task running-title call atomically registers that runtime ID. Before any migration write, require fresh status to show `migration_running` with a nonempty controller ID matching the controller's supported current-task context. If creation or registration fails, leave the truthful `migration_pending` phase, stop the controller before it writes any target title, and report that nothing is running plus the exact retry action. - -The controller is the only migration title writer. It processes explicit targets in bounded concurrent waves of at most eight distinct task IDs and skips only rows already reporting `applied:true`. Immediately before each title wave it concurrently calls `codex_app__read_thread` for every target as a read-only activation gate, requires each exact task ID and inventory-planned title, and starts the setter wave within 15 seconds without navigating or selecting; a failed or mismatched read is reconciled through fresh inventory and never followed by a blind write. From the first inventory snapshot, it starts the first stable deterministic batch of at most 25 and one bounded concurrent spawn wave of fresh read-only Luna-medium workers in stable batches of at most 10 genuinely ambiguous tasks at the same time, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. Worker creation uses the fixed `codex_app__create_thread` surface with `model:"gpt-5.6-luna"` and `thinking:"medium"`, records every settled successful handle, and never lets a failed spawn hide an earlier success. The controller derives every assigned list mechanically from the parsed inventory `task_id` fields, never retypes or synthesizes an ID, and asserts exact membership in the same inventory snapshot before spawning. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; retained classifier IDs are excluded from title waves, and all currently validated workers are archived together in one concurrent native explicit-target call before their batches are applied. A missing, mismatched, or unconfirmed worker archive fails closed. Classifier results may arrive out of order. The classifier's `phase:"final_answer"` item must contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block; separate worker commentary is not part of the result grammar. Because a `wait_threads` snapshot may normalize the one separator newline into a space, the controller locates the array's closing bracket and accepts one or more whitespace characters before the exact footer instead of requiring a physical newline; it still rejects any other prose. The controller ignores the footer only while parsing the array. Any other final-answer prose, malformed content, or mismatched batch is discarded and retried once without writing titles. The controller passes the compact footer grammar—not a subject, visible status icon, or pre-rendered title—so the Pre hook alone expands the authoritative subject. Every footer setter value begins with the full literal `🧵🐻` mark (`title:"🧵🐻 complete"`, for example); bare classification labels such as `complete`, `blocked`, or `next_steps` are never setter values. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Every title wave awaits all native results without a client-created `Promise.race` or other synthetic timeout; the trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so the controller never compares the native return title with the compact input, requires the exact returned task ID, treats an expected hook-expanded title as provisional success, and uses fresh inventory as the authoritative applied result. It reconciles the whole wave through inventory before continuing; every ready stable queue runs in one orchestrated loop with concurrent gates and setters plus fresh inventory after each max-eight wave, without commentary or a model round trip between settled waves. Only an explicit timeout from the native tool is a timeout. A target that explicitly becomes inactive or not found may be dropped only after a fresh authoritative inventory no longer contains its exact ID and every other wave target reconciles as applied; it naturally left the addressable catalog and is not counted as applied. Any other native failure, or an ID that remains present, fails closed. Each worker has an eight-minute deadline, a timed-out or invalid read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also fails, it reports failure and complete accounting to the home. It never runs migration phase commands itself. An explicit timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. - -For classifier results whose status does not consume an action (`complete`, `automation`, and `unknown`), the controller accepts and ignores a surplus `action` field. It still fails closed for wrong, missing, duplicate, or unassigned task IDs; invalid statuses; missing or empty required actions for `blocked`, `needs_input`, or `next_steps`; disallowed prose; and malformed JSON. - -For a failed title wave, the controller leaves pending proposals fail-closed and reports complete accounting to the home, including whether every admitted native call returned a terminal result. It never runs a migration phase command or uses `--settled` itself. +Core `ready` is healthy when the installed binary, private subject state, managed guidance, and skill match the candidate. Report the daily updater separately; missing automatic updates do not make title handling globally unready. Core readiness does not depend on historical title counts. -A bounded `wait_threads` response with `timedOut:true` is normal polling, not a classifier timeout. Continue polling every retained worker until its own eight-minute deadline; only that per-worker deadline can trigger the documented retry or failure path. +No controller, worker, migration phase, persistent task, or hidden onboarding job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. -The controller finishes only when a final inventory reports zero remaining rows and returns that exact result to the home. It remains visible until the home records the terminal phase. +## 4. Restart and onboard -Keep this ThreadBear task selected and supervise the controller with compact task waits. Report only each 25-title milestone or phase change. When the controller returns, first run fresh inventory. Only at zero remaining rows with no unresolved native call does the home record `migration_complete` with the status-reported controller ID; otherwise it records `migration_failed`, adding `--settled` only when complete controller accounting proves every admitted native call returned a terminal result and omitting it for any timeout, unknown result, or interruption. Then run `status --json` and verify the terminal state. Only after the successful transition may the home archive the controller. `migration_pending` always means no controller was recorded; status repairs an older running-without-controller state to pending. `migration_running` always names the active native runtime controller. Status reconciles a missing controller or a terminal lifecycle event from the current attempt from stale `migration_running` to `migration_failed`; it never infers failure from age, slow progress, or a prior attempt's terminal event. If the controller or this turn is interrupted, begin the next turn with status so the durable phase is truthful. +After a successful install say: -Do not send a final installation answer while status still says `migration_pending` or `migration_running`. At `migration_pending`, say that migration has not started and nothing is running, then give the exact start action. At `migration_failed`, say plainly that migration stopped and is not still working, give the applied and remaining counts, name the cause, and give one exact resume action. Keep maintenance paused in every non-complete phase. At `migration_complete`, require zero remaining native-addressable local rows, activate the exact owned heartbeat, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. - -## 5. Close precisely +> ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. +> +> After restart, open any task and say: **ThreadBear onboard** -On complete success, use this shape in natural prose: +When that request arrives, read the installed skill and follow this protocol: -> ## ThreadBear is installed -> -> Everything passed: ThreadBear VERSION is installed, its managed guidance, two hooks, verified updates, and hourly Luna helper are healthy, this task is its persistent home, and the migration controller completed with zero remaining native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows were outside this migration and may remain unchanged in the sidebar. -> -> From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” +1. Run `~/.local/bin/threadbear status --json`, then `~/.local/bin/threadbear onboard --dry-run --json`. +2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any write. If enumeration fails, make zero writes. +3. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. +4. Ask for explicit consent unless unchanged install consent covered this first pass. +5. After consent, run exactly: -Replace `VERSION` with the verified version. Follow the current task's active response guidance; do not append a ThreadBear footer merely because installation wrote future-task guidance. +```sh +~/.local/bin/threadbear onboard --noninteractive --confirm --json +``` -If official-download verification fails before mutation, say that installation stopped, nothing changed, and you are checking the verified download. If a failure occurs after mutation began, name exactly what completed, what stopped, whether anything is still running, and the one safe resume action. +The confirmed command starts from a fresh complete catalog and handles every safe target serially with no item cap, waves, worker tasks, or resume machinery. It rereads each target before a possible write; drift, absence, ambiguity, or unreadability is skipped. It attempts each write once and counts `updated` only after exact readback. An acknowledgement without exact readback is `unconfirmed` and is never retried. -## Help and status +Report `updated`, `unchanged`, `skipped`, and `unconfirmed` honestly and account for the returned target set. Call onboarding complete only when `plan_complete:true` and `onboarding_complete:true`. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan and safely continues. -For later help, lead with a short capability card instead of a command dump. Verify the artifact and migration phase before saying ThreadBear is ready: +## Commands and updater ```sh -~/.local/bin/threadbear status --json ~/.local/bin/threadbear help +~/.local/bin/threadbear status --json +~/.local/bin/threadbear title --status complete --json +~/.local/bin/threadbear onboard --dry-run --json ~/.local/bin/threadbear update --json ``` -The installed binary's help is the authoritative public command list. Run `update --json` only for an explicit check-now request or from the owned maintenance heartbeat after archive work is reconciled. Cross-check the exact `threadbear-maintenance` heartbeat with the native automation control before describing hourly housekeeping as healthy. +The managed guidance runs `title --status --json` exactly once immediately before an ordinary final response. The enum changes only the icon. The binary reads the exact current title, preserves the safe subject, makes at most one App Server name update, and performs exact readback. If the command fails or does not finish in its bounded terminal moment, deliver the response without polling, retrying, or reconciling. -## Uninstall - -You can uninstall from any active native Codex task—even when the ThreadBear home is archived. Do not ask the user to open, select, navigate to, or unarchive the ThreadBear home. +`update` verifies the official manifest, release URLs, architecture, checksum, embedded version, and candidate self-test before replacement. Network or verification failure leaves the old installation untouched. A later managed-surface write can truthfully leave a rerunnable partial; the binary is written last. Every successful update reports `restart_required`. The daily LaunchAgent runs only this command and never reads tasks or changes titles. -Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, resolve this initiating task's exact ID plus the persisted main and distinct controller IDs, inspect the exact owned automation, then ask: - -> Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. If the ThreadBear home is archived, I'll briefly bring it out for cleanup and tuck it back exactly where it was. Your other archived tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. -> -> Should I go ahead? +## Uninstall -After consent, pause the exact owned `threadbear-maintenance` heartbeat; refuse an ID, kind, name, or target mismatch. Prepare the durable operation before changing a title or archive state: +Preview first: ```sh -~/.local/bin/threadbear uninstall --prepare --initiator-task-id INITIATOR_ID --json +~/.local/bin/threadbear uninstall --dry-run --json ``` -If the returned original main state is archived, unarchive that exact task once through native archive control and verify it without opening, selecting, navigating to, or waking the task. The prepared initiating task cleans active titles serially through explicit native target calls, including an active distinct controller, then cleans `main_task_id` last. A quiescent pre-controller `migration_pending` uninstall leaves every inventory task untouched and cleans only the persistent home when it is decorated or its stripped title is the exact install sentinel. Every title mutation gets one four-second attempt, exact authoritative readback, and no blind retry. A clean non-sentinel title on resume is already settled and must not be rewritten. +Explain: + +> Want me to uninstall ThreadBear? I'll remove only its binary, private subject records, managed guidance, installed skill, and daily updater. Existing title icons may remain until those tasks are renamed. Other Codex settings and files stay untouched. When removal finishes, you'll restart Codex. -Restore and verify the main task's exact original archive state before deleting the owned automation. On a recoverable interruption, leave ThreadBear installed; the same initiating task resumes without duplicating settled mutations, and drifted or missing title targets remain user-owned. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, and require `aborted:true`. Once the exact automation is deleted and verified absent, cross the local commit boundary and run: +After consent: ```sh -~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json +~/.local/bin/threadbear uninstall --noninteractive --confirm --json ``` -Commit refuses unless the prepared owner, clean main title, restored archive state, eligible stopped phase, and settled native operations all match. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill file, private state, and binary; partial local teardown is rerunnable and the installed binary is removed last. It preserves unrelated content—including user-created files beside the managed skill—hook order, unrelated automations, and archive states, and removes the skill directory only when empty. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. +Require committed removal and verify unrelated AGENTS content, skills, settings, files, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. + +## Release proof + +Before release, run unit and integration tests, race tests, both Darwin builds, shell checks, experiment validation, installer/guide parity, and the focused fixture smoke. -## Maintainer verification +Release acceptance additionally requires one reviewed candidate live-tested end to end in Codex Desktop: -A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, bounded write-wave overlap, and controller resume, interruption, and failure cases. +- the terminal `title` command changes only the status icon and preserves the exact subject; +- App Server acknowledgement and exact readback agree; +- the rendered sidebar shows the expected title before and after a clean restart; +- a full onboarding preview enumerates every local task, and a consented serial pass updates every safe target with honest counts; +- failure and unconfirmed cases never block the substantive response or trigger retries; +- automatic update and uninstall preserve neighboring user content. -Tests and fixtures alone are not shipping proof. Also execute every lifecycle command printed here against the reviewed release candidate and exercise each changed native lifecycle against a real, recoverable Codex test task through the supported native control. Exercise changed download/update behavior against the official release service in an isolated installation. Record exact candidate SHA, task/release IDs, before/after state, results, and cleanup without visual inspection. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. +If the direct writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. diff --git a/README.md b/README.md index 0ba2b84..dca792a 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,48 @@ # ThreadBear -ThreadBear keeps Codex Desktop task titles useful in the turn that is doing the work. Managed guidance asks each ordinary turn to make two native title calls: one before work starts and one immediately before the final status footer. Two small hooks preserve the task's user-owned subject and expand those compact calls into the visible title. +ThreadBear is a small local title decorator for Codex Desktop. Immediately before each ordinary final response, managed guidance runs one local command. ThreadBear keeps the exact user-owned subject and changes only the leading status icon. | Mark | Meaning | | --- | --- | -| ⏳ | running | | 🚨 | blocked | | 🙋 | needs input | | 🤖 | healthy automation | | ➡️ | next steps | | ✅ | complete | -| ❔ | unknown legacy state | +| 🐻 | existing task onboarded, status not yet known | -The canonical shape is ` [ → ]`. ThreadBear owns only decoration it previously committed. User renames are adopted intact, and every rendered title is bounded to Codex Desktop's 60 UTF-16-unit limit. When next steps do not fit, ThreadBear preserves the standalone subject display and truncates or omits only the action. +The visible shape is ` `. Owners and actions stay in the response prose, not the title. ThreadBear never normalizes, strips, or truncates a safe subject. A title it cannot handle safely stays unchanged. ## Install -Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, persistent-home setup, and supervised controller migration. +Open [INSTALL.md](INSTALL.md) in a Codex task and follow the guided preview and consent flow. There is no persistent ThreadBear task or controller. After installation, restart Codex so open tasks load the new managed guidance, then ask for **ThreadBear onboard** if you want existing local titles updated. -ThreadBear installs a standalone Go binary, one small private state file, managed guidance, two Codex hook entries, and one consented hourly Luna heartbeat. The initiating task becomes the persistent `ThreadBear` home and never receives a status title; one ephemeral controller owns installation migration so that home returns promptly, while the heartbeat later handles quiet housekeeping from that task. +ThreadBear installs one Go binary, tiny private per-task subject records, one managed instruction block, one skill, and one daily update-only LaunchAgent. A consented reset from 2.2.1 deletes the exact old automation, unpins the exact former persistent task without renaming it, replaces managed artifacts, imports no old state, and does not guess at legacy title cleanup. ## Commands ```text threadbear install -threadbear inventory -threadbear migration -threadbear maintenance -threadbear update +threadbear title --status complete +threadbear onboard --dry-run +threadbear onboard --noninteractive --confirm threadbear status threadbear self-test +threadbear update threadbear uninstall threadbear version ``` -Every command accepts `--json`. `inventory` is read-only and includes every native-addressable unarchived local Codex Desktop or CLI task, including projectless tasks, excluding the persisted main and controller tasks. Rollout-only internal records and older signed-in ChatGPT chat-history rows that Codex's native title setter cannot enumerate or rename are excluded. Those chat-history rows may remain unchanged in the Desktop sidebar even after local migration completes. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. +Every command accepts `--json`; the installed binary's `help` output is authoritative. -From the persistent ThreadBear task, ask to “strip title icons” or “check for updates now” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. The same task's hourly Luna helper can archive only deterministically eligible, ThreadBear-owned complete user tasks after 14 quiet days, restore only archives recorded in its private ownership ledger, and run the deterministic verified update check last. Guided uninstall may start from any active native task, persists that task as the sole operation owner, temporarily restores an archived ThreadBear home only for final title cleanup, puts it back in its original archive state, and removes the binary only after native settlement and every other ThreadBear-owned local artifact while preserving user-created files beside the managed skill. +The terminal `title` command accepts exactly `complete`, `next_steps`, `needs_input`, `blocked`, or `automation`. The enum controls only the icon. The binary opens one short-lived official Codex App Server, reads the exact current title, resolves the safe subject, makes at most one `thread/name/set` request, and rereads the title. A failure or unconfirmed result stays local to that turn and never blocks the response or triggers a retry. + +`onboard --dry-run --json` enumerates the complete unarchived App Server catalog before any write and reports a full plan. Explicit consent runs `onboard --noninteractive --confirm --json`, which processes every safe target serially with no arbitrary item cap. It rereads each target, skips drift or uncertainty, attempts one write, and counts it only after exact readback. The receipt accounts honestly for updated, unchanged, skipped, and unconfirmed tasks. A fresh rerun safely continues after an interruption. ## Boundaries -ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, reconciles ownership, and chooses the exact Darwin asset from the official release manifest. Luna calls supported native controls and communicates typed results; it never edits private UI storage, interprets prose to add targets, or chooses/downloads/checksums a release. Updates refuse while archive work is pending, verify repository URLs, SHA-256, embedded version, candidate self-test, candidate install, and installed status, and never downgrade. ThreadBear adds no token counts, model call, or narration to ordinary turns. Installation uses one Terra-medium controller, bounded concurrent native-title waves, and bounded read-only Luna-medium classifier waves only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. +The short-lived official App Server is ThreadBear's only task read/write authority. ThreadBear does not open or edit Codex SQLite, Desktop caches, or task prose. It does not archive tasks, classify in the background, retry title writes, or maintain a queue, controller, repair pass, or persistent management task. + +The daily LaunchAgent does one job: check for a verified official update. Network and candidate-verification failures leave the old install untouched. A later managed-surface write can produce a truthful rerunnable partial, with the binary written last. Successful updates report whether Codex must restart. Updater health is separate from title-core `ready`; it never reads tasks or changes titles. -See [architecture](docs/architecture.md), [compatibility](docs/compatibility.md), and the [status footer convention](docs/status-convention.md). +See [architecture](docs/architecture.md), [compatibility](docs/compatibility.md), and the [status convention](docs/status-convention.md). diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index aab8dcb..c45eb00 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -2,48 +2,29 @@ For every ordinary interactive turn in a main Codex Desktop task: -1. Your first action must be one bounded `functions.exec` cell containing the native current-task title call. Its `title` begins exactly `⏳ ThreadBear is working: ` followed by a concise 2–6 word subject for this task. Keep the complete title to one line and at most 58 UTF-16 units after the colon. Omit `threadId`. Do not send commentary or call another tool first. -2. End the response with exactly one compact status footer chosen from the forms below. -3. Immediately before the final response, use the same bounded cell with `title` exactly equal to that footer line and no `threadId`, then deliver the response. - -For both title moments, replace the title literal and execute this exact shape: +1. Write the substantive response first. Keep any owner or next action in that prose. +2. Choose exactly one status: `complete`, `next_steps`, `needs_input`, `blocked`, or `automation`. +3. Immediately before the final response, run one local title command. Replace only `STATUS` in this exact cell with the exact enum: ```js -const attempt = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); -const result = await Promise.race([ - tools.codex_app__set_thread_title({title:"REPLACE WITH THE REQUIRED TITLE" + "⁣" + attempt}) - .then(value => ({status:"returned", value})) - .catch(error => ({status:"failed", error:String(error)})), - new Promise(resolve => setTimeout(() => resolve({status:"timeout"}), 4000)) -]); -if (result.status === "failed") { - const recovery = await tools.exec_command({ - cmd: `THREADBEAR_TITLE_ATTEMPT='${attempt}' ~/.local/bin/threadbear maintenance --cancel "$CODEX_THREAD_ID" --json`, - yield_time_ms: 4000, max_output_tokens: 1000 - }); - result.recovery = recovery.exit_code === 0 ? "settled" : "failed"; -} +const result = await tools.exec_command({ + cmd:"\"$HOME/.local/bin/threadbear\" title --status STATUS --json", + yield_time_ms:4000, + max_output_tokens:1000 +}); text(result); ``` -Make exactly one native attempt. The hook strips the opaque attempt suffix before the title reaches Codex. The four-second timer is the native wait budget. If it wins, the write result is unknown: end the cell, never retry or await that promise, never cancel it, and continue the turn. Only an explicit returned failure runs the local recovery command; it clears solely the exact caller's proposal carrying that same attempt ID while native readback still equals its recorded prior title. Continue even when that guarded recovery fails. Do not call the native title tool directly outside this bounded cell. - -This bounded current-task shape applies only to the two ordinary title moments above, where `threadId` is omitted. Explicit-target lifecycle mutations are governed by the installed ThreadBear skill instead. Follow that lifecycle's batching and wait rules; do not add this `Promise.race` unless the skill explicitly requires a four-second attempt. - -The persistent ThreadBear home is the one exception: every status input leaves its visible title exactly `ThreadBear`. For other tasks, ThreadBear uses the first-call subject only when a fresh task still exposes its raw first message; explicit names, generated short titles, committed subjects, and later user renames remain authoritative. Its hooks expand the compact inputs into the stable visible title. They do not repair stopped turns. If the user stops a turn, the running title remains until the next real turn replaces it. - -## Status footer +Make exactly one attempt at that terminal moment. Never run it as a progress update. If it fails or has not returned after the bounded yield, do not poll, retry, reconcile, or delay the response. The failure is local to this turn. -Use the matching literal example as the footer's shape: +The status controls only the visible icon. ThreadBear preserves the task's exact safe subject and user-authored emoji. It never puts an owner or action in the title. Use: -- Finished with no warranted follow-up: `🧵🐻 complete` -- Finished with one concrete action for the user: `🧵🐻 next steps (you): approve the release plan` -- Finished with one concrete action for the agent: `🧵🐻 next steps (agent): implement the approved plan` -- Finished with one concrete action for someone or something external: `🧵🐻 next steps (external): review the security exception` -- Waiting for required user input: `🧵🐻 needs input (you): choose the release region` -- Unable to continue because of an external condition: `🧵🐻 blocked (external): restore the signing service` -- Healthy scheduled or automated work with nothing pending: `🧵🐻 automation` +- `complete` when the work is finished with no warranted follow-up. +- `next_steps` when the response establishes one concrete next action for the user, agent, or an external party. +- `needs_input` when required user input is blocking progress. +- `blocked` when an external condition prevents progress. +- `automation` for healthy scheduled or automated work with nothing pending. -The footer must be the final non-empty line. Never write the literal placeholder words `STATUS`, `OWNER`, or `ACTION`. Report the turn's actual disposition. Use `complete` unless the substantive response already establishes one clear, concrete, warranted next step. Generic offers, speculative possibilities, and mentions of recorded work do not qualify. +Use `complete` unless the response itself establishes another disposition. Generic offers and speculative possibilities are not next steps. -A prepared uninstall suspends this turn protocol from the moment `uninstall --prepare` succeeds until that operation is aborted or committed: make no running-title or footer call while it is pending. After commit has removed the managed hooks and guidance, respond without another title call or ThreadBear footer so the clean control-task title is not decorated again. After abort, ordinary title calls resume on the next turn. +After a confirmed uninstall removes ThreadBear and this guidance, do not run the title command. Ask the user to restart Codex so open tasks stop using their snapshotted guidance. diff --git a/assets/help.txt b/assets/help.txt index 241df2b..50e8295 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -1,35 +1,41 @@ -ThreadBear keeps Codex task titles useful with native per-turn updates. +ThreadBear keeps Codex task titles useful with one local update at the end of each turn. Usage: threadbear [flags] Commands: install Preview or install ThreadBear - inventory Classify native-addressable tasks for guided setup - maintenance Plan and reconcile safe native task archives - migration Record the single installation migration controller phase - status Check the installed helper, hooks, and migration phase + title Set this task's status icon while preserving its exact subject + onboard Preview or apply existing-task onboarding + status Check title-core readiness and updater health separately self-test Validate a release candidate - update Install the latest verified release - uninstall Remove ThreadBear after control-task title cleanup + update Install the latest verified release and report restart need + uninstall Remove ThreadBear-owned local artifacts version Show the installed version -Install flags: - --control-task-id ID Persist the initiating task as ThreadBear's home - --debug-canaries Run guided Desktop canaries after installation +Every command accepts --json. + +Title: + threadbear title --status --json + +The managed guidance runs title exactly once immediately before a final response. +ThreadBear reads the exact current title, writes one icon plus the safe subject, and verifies the result through Codex App Server. +A local failure never blocks the response and is never retried. -Migration flags: - --phase PHASE migration_running, migration_complete, or migration_failed - --controller-task-id ID - --settled Confirm a failed wave has no unknown native calls +Onboard: + threadbear onboard --dry-run --json + threadbear onboard --noninteractive --confirm --json -Maintenance flags: - --archive TASK_ID Stage or reconcile one eligible archive - --restore TASK_ID Stage or reconcile one ThreadBear-owned restore - --cancel TASK_ID Clear one known-unapplied pending operation - --archive-after-days N Quiet completed days required (default 14) +The preview enumerates the complete local catalog before any write. The confirmed pass handles every safe target serially with no item cap, skips drift or uncertainty, and reports honest updated, unchanged, skipped, and unconfirmed counts. + +Install flags: + --dry-run Preview without changing the machine + --noninteractive Do not prompt + --confirm Confirm the described install or reset + --reset Replace a consented, native-cleaned legacy 2.2.1 installation + --no-onboard Install without offering historical onboarding Uninstall flags: - --prepare Persist the active initiating task and original home state - --abort Abandon a prepared uninstall after restoring the home archive state - --initiator-task-id ID Active task that owns preparation, cleanup, and commit + --dry-run Preview without changing the machine + --noninteractive Do not prompt + --confirm Confirm removal diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 22ac2e7..5f8295b 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,124 +1,52 @@ --- name: threadbear -description: Install, inspect, migrate, maintain, verify, or uninstall the local ThreadBear title manager for Codex Desktop on macOS. +description: Install, inspect, onboard, update, or uninstall the local ThreadBear title decorator for Codex Desktop on macOS. --- # ThreadBear -Be warm, brief, and lightly bear-themed. Explain visible outcomes before commands, keep task IDs and raw JSON backstage, and never claim a rendered title from state or command success alone. +Be brief, warm, and lightly bear-themed. Explain effects before commands and get explicit consent before install, reset, historical title mutation, manual update, or uninstall. -## Help +ThreadBear's managed guidance runs one local `title` command immediately before an ordinary final response. The status enum changes only the icon; the exact safe subject remains intact and any owner or action stays in response prose. There is no persistent task, running call, controller, classifier, archive system, or repair job. -For a help-shaped request, start with a short capability card: ThreadBear keeps Codex Desktop titles useful through two native title calls per ordinary turn, while its hooks deterministically preserve each task's subject. One hourly Luna helper can quietly tuck away owned, completed tasks after 14 inactive days and install verified ThreadBear releases. ThreadBear adds no extra model call or narration to ordinary turns. +## Help and status -Run `~/.local/bin/threadbear status --json` before saying ThreadBear is installed or healthy. Use `~/.local/bin/threadbear help` as the authoritative public command reference. +Run `~/.local/bin/threadbear status --json` before calling ThreadBear installed or healthy. `threadbear help` is authoritative. -Show a command before running it. Ask for explicit consent before any lifecycle mutation. +- “How is ThreadBear?” — run `status --json`. +- “ThreadBear onboard” — follow **Onboard existing tasks**. +- “Check for updates” — run `update --json` after consent. +- “Uninstall ThreadBear” — follow **Uninstall**. -| Plain-language request | Command | -| --- | --- | -| "How are you?" | `~/.local/bin/threadbear status --json` | -| "What tasks do you see?" | `~/.local/bin/threadbear inventory --json` | -| "Run maintenance now" | Follow **Maintenance** below. | -| "Check for updates now" | Run the update-last step in **Maintenance**. | -| "Bring back archived task TASK_ID" | Follow the restore path in **Maintenance**. | -| "Strip title icons" | Follow **Title cleanup** below from the persisted ThreadBear task. | -| "Install ThreadBear" | Follow **Install** below. | -| "Uninstall ThreadBear" | Follow **Uninstall** below. | +`ready` covers the title core. Report updater health separately; a missing updater does not make title handling globally unready. Onboarding has its own receipt. -## Install +## Install or reset -1. Read the current install guide and the candidate's help output. Check macOS, architecture, Codex, HTTPS access, and candidate self-test without changing the machine. Resolve the exact current task ID with supported Codex task tooling. -2. Run the exact dry run with `--control-task-id CURRENT_TASK_ID`. Explain the complete effect: adopting that task as the persistent home, the local binary, one small private state file, one managed AGENTS block, this skill, two hook entries, and one owned hourly Luna heartbeat. Explain that migration covers native-addressable local Codex tasks; older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and stay unchanged. -3. Show the recommended setup and include: “A small Luna helper checks in hourly, then stays quiet when there is nothing to do.”, “Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.”, and “ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat.” Ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. -4. Run the confirmed install with the same ID and verify `version`, `self-test`, and `inventory`. A fresh result is `migration_pending`: no controller has started and nothing is running yet. On reinstall, omit the flag only when `status --json` already reports the persisted main task; never replace it with the launching task. -5. Before any migration, call `codex_app__set_thread_title` once for the initiating task with title exactly `🧵🐻 ThreadBear 🐻🧵` and no attempt suffix. Await the native result normally and require its exact task ID and exact title. This fixed, input-independent sentinel deliberately passes through without a staged proposal, so first installs and same-task reinstalls remain byte-exact even when Codex has not reloaded the newly written hook. On a failure or unknown result, report the partial install and stop; never retry. Then use `codex_app__set_thread_pinned` to pin the task and keep it selected. -6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” On creation omit `id`; Codex derives it from the exact name. Require the returned `automationId` to equal `threadbear-maintenance`. If it differs, delete only that returned ID, verify it is absent, report the partial install, and stop. A create request's status is not proof of persisted state: immediately update that exact returned ID with the complete expected fields and `status:"PAUSED"`, then verify the full exact match before creating the controller. For an existing exact match, likewise pause and verify it before migration. Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If creation, pause, or verification fails, delete a just-created automation only after an exact ownership read, verify it is absent, report the partial install, and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. -7. For an ordinary guided installation, never use visual inspection, computer control, screenshots, or Codex `/hooks`, and never ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate. Visual hook verification belongs only to **Debug canaries** when the install result explicitly contains `debug_canaries:true`. -8. Create exactly one projectless background migration controller with `codex_app__create_thread` using `model:"gpt-5.6-terra"` and `thinking:"medium"`, without opening, selecting, or navigating to it. Begin its delegated input with the exact sentence `ThreadBear controller registration.` Its required first current-task running-title call atomically registers the native runtime task ID, so retain the create result only as a supervision handle and never persist or compare it as controller identity. Require fresh status to reach `migration_running` with a nonempty controller ID before any migration work. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation or registration fails, leave `migration_pending` unchanged, stop the controller before it writes any target title, and report that nothing is running plus the exact retry action. -9. Give the controller the **Migration controller** protocol below. Supervise it from the persistent task with compact task waits, reporting only each 25-title milestone or phase change. Do not end the installation turn while durable status is `migration_pending` or `migration_running`. -10. The projectless controller performs native title work but does not run migration phase commands. After it returns, the home first runs fresh inventory and records `migration_complete` with the status-reported controller ID only at zero remaining with no unresolved call; otherwise it records `migration_failed`, adding `--settled` only when the controller's complete wave accounting proves every admitted native call returned a terminal result and omitting it for any timeout, unknown result, or interruption. Then run `status --json`, verify, and activate the exact owned heartbeat with native automation controls. Claim success only at `migration_complete` with zero remaining native-addressable local rows and one active healthy maintenance automation, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. Leave the heartbeat paused at `migration_pending`, `migration_running`, or `migration_failed`. At `migration_failed`, say migration stopped and is not still working, report applied and remaining counts, name the cause, and give one exact resume action using the same controller ID. +Follow `https://threadbear.sh/install` and the candidate help. Run checks, self-test, and dry run first. Explain the binary, private subject state, managed block, skill, and daily updater. -For a large existing workspace, say this before migration: +For a 2.2.1 reset, require the preview's legacy main-task ID and complete automation fingerprint. Consent covers deleting only that exact automation, unpinning that exact former ThreadBear task without renaming it, and removing only exact obsolete ThreadBear Pre/Post title-interception entries. Preserve foreign entries and order. Verify both native results before `install --reset`. Any mismatch stops. Import no old state and guess at no legacy title. -> ThreadBear will stay selected while one background controller updates native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. This usually takes several minutes, and a large or ambiguous local history can take longer. `migration_running` means the controller is actively working; I'll report every 25 applied titles or phase change and won't finish this installation turn until it reaches `migration_complete` or `migration_failed`. +After consent, install and verify `version --json`, `self-test --json`, and `status --json`. Ask for a Codex restart. Unless the user opted out, give this exact request: -Do not claim success from installed files, a native setter return, or partial counts. +> Open any task after restart and say: **ThreadBear onboard** -## Maintenance +## Onboard existing tasks -The owned `threadbear-maintenance` heartbeat runs this section from the persistent ThreadBear task. Luna orchestrates; the CLI alone chooses archive eligibility and records ownership. Never inspect task prose to add a target, and never edit Codex's SQLite archive field. +1. Run `status --json`, then `onboard --dry-run --json`. The preview must enumerate and deduplicate the full unarchived App Server catalog before any write. Enumeration failure means zero writes. +2. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Never adopt preview text. Ask for explicit consent unless unchanged install consent already covered this first pass. +3. After consent, run exactly `~/.local/bin/threadbear onboard --noninteractive --confirm --json`. It processes the complete plan serially with no item cap. Each candidate is reread; missing, unreadable, drifted, or uncertain tasks are skipped. Each write is attempted once and counted only after exact readback. Never retry an unconfirmed result. +4. Report the returned totals: `Updated X of N existing tasks; Y were unchanged or skipped; Z could not be confirmed.` Do not claim completion unless `plan_complete` and `onboarding_complete` are true and every target is accounted for. -1. Run `~/.local/bin/threadbear maintenance --json`. If it reports a pending operation, reconcile that operation before any new archive. If it reports no pending operation and no candidates, stay silent. -2. For each candidate in stable order, run `~/.local/bin/threadbear maintenance --archive TASK_ID --json` immediately before mutation and require that exact ID with `action:"archive"` and `pending:true`. Call native `codex_app__set_thread_archived` once with that task ID and `archived:true`, without opening or selecting the task. Rerun the exact maintenance command and require `reconciled:true` before continuing. -3. If the native result is unknown or the reconciliation still says pending, do not repeat the mutation. Read the task with native task controls, rerun maintenance, and stop with the pending transaction intact unless the CLI authoritatively reconciles it. If the native call returned a definite failure and a native read confirms the original archive state, run `maintenance --cancel TASK_ID --json` to clear that known-unapplied operation. Never cancel an unknown or in-flight result. Title, footer, activity, kind, identity, or archive drift makes the CLI fail closed. If the CLI reports that an applied archive drifted, it remains pending and unowned: do not adopt it. Report the task for manual recovery; only after the user restores it and a native read confirms it is unarchived may the exact guarded cancel clear the operation. -4. Process one native archive operation at a time. Finish with a no-target maintenance pass and require no pending operation. Report only archived task subjects/counts or an error; do not narrate healthy no-op runs. -5. Only after the closing archive pass proves there is no pending native operation, run `~/.local/bin/threadbear update --json` last. The command alone fetches the exact official manifest, chooses the Darwin architecture, verifies HTTPS repository URLs and SHA-256, checks the embedded version and candidate self-test, and invokes the candidate's existing install path. Luna never browses for, chooses, downloads, checksums, or approves an asset. -6. Stay silent for `current:true`. For `updated:true`, report the old and new versions once in the persistent ThreadBear task. For `repaired:true`, say that the same verified version repaired managed files. On failure, report its typed `stage` and error once; never bypass verification, run a remote script, downgrade, or retry an unknown partial install blindly. +An interruption may leave valid partial decoration. A later **ThreadBear onboard** starts a fresh complete plan and safely continues. Never create a controller, queue, hidden resume state, product cap, or persistent ThreadBear task. -For a user-requested restore, verify the request from the persistent ThreadBear task, then run `maintenance --restore TASK_ID --json`. Continue only for `action:"restore"`, call the native archive control once with `archived:false`, and rerun the same command until `reconciled:true`. The CLI accepts only ThreadBear-owned archives and restarts that task's 14-day quiet clock. A user-archived task is never adopted. A task manually restored through Codex is detected on the next pass, removed from the ownership ledger, and receives the same fresh quiet clock. +## Update -To change the quiet window, pass the requested positive `--archive-after-days N` consistently to planning and staging and update the owned automation prompt. To disable archival, update the owned heartbeat prompt to omit this archive protocol while preserving the update-last step. To pause updates, omit only the update-last step while preserving enabled archival. A check-now request runs the same deterministic update command after proving no pending archive work; there is no separate scheduler or release channel. +The daily LaunchAgent runs only `threadbear update`. Network and candidate-verification failures leave the old installation untouched. A later local managed-surface write can leave a truthful rerunnable partial; the binary is written last. The updater never reads tasks or changes titles. -## Status and inventory - -`status --json` checks the installed binary, managed files, hooks, and state readability. It reports the expected `maintenance_automation_id`, pending native archive state, and owned archive count, but the agent must cross-check the exact heartbeat through native automation controls before calling it healthy. It reports `installed:true` while artifacts are present, but `ready:true` only for `phase:migration_complete`. It does not mutate titles. `migration_pending` means no controller was recorded and returns the exact start action; status also repairs an older running-without-controller state to pending. `migration_running` always names the active controller. If that controller is missing or has a terminal lifecycle event from the current attempt, status atomically records `migration_failed`, explains that the controller stopped, and returns the exact resume action. It never infers failure from age, slow progress, or a prior attempt's terminal event. - -`inventory --json` reads every native-addressable, unarchived local Codex Desktop or CLI task, including projectless tasks, excluding the persisted main and controller IDs. It excludes rollout-only internal records and older signed-in ChatGPT chat-history rows that Codex's native title setter cannot enumerate or rename. Treat its deterministic classifications, `status`, `action`, and `applied` evidence as authoritative only for that local catalog. Never describe zero inventory rows as proof that every visible sidebar row changed, and do not infer ThreadBear ownership from an icon or arrow alone. - -## Migration controller - -The controller is the only installation-migration title writer and is rerunnable under one persisted native runtime ID. Its first action after the required current-task title call is `status --json`; require `phase:"migration_running"` and `controller_task_id` equal to its supported current-task context before reading inventory. It never runs migration phase commands; the supervising home records the terminal phase. - -1. Run `~/.local/bin/threadbear inventory --json` and use its `status`, `action`, `task_id`, and `applied` fields. The main and controller tasks are already excluded. If any unapplied deterministic rows exist, immediately start the first stable batch of at most 25 through the bounded title-wave path. Do not wait for that batch to reconcile before launching classifier workers: start the initial bounded worker-spawn wave concurrently with the first deterministic activation-and-setter wave from the same inventory snapshot. Worker setup must never delay the first in-scope title mutation, which is issued within 60 seconds of controller start and within 15 seconds of the inventory result. -2. Accept exact historical footers deterministically. Split only genuinely ambiguous rows into stable batches of at most 10 tasks. Derive each assigned list mechanically from the parsed inventory `task_id` fields in stable order; never retype, transform, or synthesize an ID, and before spawning assert that every assigned ID is an exact member of that same inventory snapshot. Workers classify and never write titles. Give each worker the exact assigned ID list and require its `phase:"final_answer"` item to contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block. Validate only that final-answer item; separate worker commentary is not part of the result grammar. A `wait_threads` snapshot may normalize the one separator newline before the footer into a space, so locate the JSON array's closing bracket and accept one or more whitespace characters followed by the exact required footer; do not require a physical newline. Ignore that required footer only while parsing the array; any other final-answer prose or malformed content invalidates the result. For statuses that do not consume an action (`complete`, `automation`, and `unknown`), accept and ignore a surplus `action` field. Continue failing closed for wrong, missing, duplicate, or unassigned task IDs; invalid statuses; missing or empty required actions for `blocked`, `needs_input`, or `next_steps`; disallowed prose; and malformed JSON. When a completed classification remains ambiguous, call the native setter with title exactly `❔ ThreadBear could not classify`; never invent a compact unknown footer. -3. Classify ambiguous batches with `codex_app__create_thread` using `model:"gpt-5.6-luna"`, `thinking:"medium"`, and a projectless background target. Do not inspect or compare alternative agent surfaces at runtime. Launch the mechanically derived initial batches as one bounded concurrent wave of fresh, read-only Luna-medium workers, with at most eight workers active, using settled results so one failed spawn cannot hide successful handles. Immediately record every successful handle with its assigned task IDs. Retained classifier worker IDs are installation internals: exclude them from every migration title wave. At an agent-capacity error, account for every earlier success and stop adding to that wave. Never reinterpret that error as zero workers when earlier spawns succeeded. -4. Account for every retained worker even when results arrive out of order. Wait in bounded snapshots and give each worker eight minutes from spawn. A `wait_threads` response with `timedOut:true` is only a normal polling snapshot and is never a worker timeout; continue polling every retained worker until it completes, needs attention, or reaches its own eight-minute deadline. As soon as workers complete, validate each exact assigned ID set and response grammar from step 2, record the results, and archive all currently validated workers together in one concurrent `Promise.all` call through their distinct native explicit targets; never serialize those archives. A missing, mismatched, or unconfirmed archive result fails closed. Immediately process those archived batches in stable order through bounded title waves while other read-only workers continue; never wait for every worker before applying an already validated and archived batch. If a worker reaches its deadline or returns malformed JSON, disallowed prose, a duplicate, an omission, or an unassigned ID, discard only that batch's uncommitted classifications, finish accounting for the rest of the wave, archive the accounted worker, and retry that read-only batch once in the next wave. A second invalid result or actual worker deadline reports failure and complete accounting to the home. -5. Process native title writes in bounded waves of at most eight distinct targets. Translate each classification to the native `title` exactly as follows: status `complete` requires `title:"🧵🐻 complete"`; status `automation` requires `title:"🧵🐻 automation"`; status `needs_input` requires `title:"🧵🐻 needs input (you): ACTION"`; status `blocked` requires `title:"🧵🐻 blocked (external): ACTION"`; status `next_steps` requires `title:"🧵🐻 next steps (agent): ACTION"`; a completed ambiguous classification requires the exact unknown marker from step 2. The status labels are classification names only and are never setter values: every footer setter value begins with the full literal `🧵🐻` mark, and bare inputs such as `complete`, `blocked`, or `next_steps` are invalid. Never prepend the visible status icon, insert the word `ThreadBear`, include the task subject, or pre-render the visible title; the Pre hook alone expands the compact input around the authoritative subject. Immediately before a title wave, call `codex_app__read_thread` concurrently for every target as a bounded read-only activation gate; require each response's exact task ID and the inventory-planned title, do not navigate or select, and begin the setter wave within 15 seconds. A failed or mismatched activation read is reconciled through fresh inventory under step 7 and is never followed by a blind write. Call the native setter once with that compact `title` and explicit `threadId`; never include the same target twice in a wave. Launch the wave concurrently and await every native result without a client-created `Promise.race` or other synthetic timeout. Only an explicit timeout from the native tool is a timeout. The trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so never compare the native return title with the compact input; require the exact returned task ID, treat an expected hook-expanded title as provisional success, and use fresh inventory as the authoritative applied result. Account for the whole wave before reconciling it with inventory, and do not start another write wave until every target is applied, is definitively inactive under step 7, or the migration has failed closed. Execute every ready stable queue in one orchestrated script loop: for each max-eight wave, concurrently gate, concurrently set, run fresh inventory, and validate before the loop advances; do not add commentary, model deliberation, or a separate outer tool round trip between settled waves. After the first fast batch, continue deterministic waves while Luna workers classify; never wait for ambiguous classification before exhausting deterministic progress. Each Pre hook briefly serializes target read-and-stage work, preserves any newer user rename, and releases before the native calls run; Post accepts only the exact target/title result and commits the per-target proposal. Re-run inventory after every write wave and skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. -6. Do not start another wave or return while a retained worker is still active or unaccounted for. If zero workers can start, wait 30 seconds and retry for at most two minutes; then report failure and complete accounting to the home. Do not degrade unclassified rows to unknown merely because worker capacity is temporarily unavailable. -7. After a write wave, an explicit native timeout, unknown result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Finish accounting for the wave. There is one narrow stale-snapshot exception: when the native result explicitly says a target is inactive or not found, immediately refresh authoritative inventory. Continue without that target only when its exact ID is absent from the refreshed inventory and every other target in the wave reconciles as applied; the task naturally left the addressable catalog and is not counted as applied. If that ID remains, the result is ambiguous, or any other failure occurred, fail closed and report the complete accounting to the home, including whether every admitted native call returned a terminal result; leave the controller visible. The failed phase denies every new title proposal, binding the home attestation to the complete existing pending set. On the next running transition, ThreadBear holds the title lock, commits an exact non-noop proposed title, clears an exact unchanged or disappeared proposal only after a settled attestation, and refuses every other drift. Unknown proposals remain pending for manual fail-closed recovery. -8. Report progress after each 25 newly applied rows or phase change. Finish only when a final inventory reports zero remaining rows, then report that exact result to the home. The home records `migration_complete` and archives the controller only after that command succeeds. A successful transition is the durable completion evidence. - -The native setter has no compare-and-set argument. Do not claim it can prevent a rename that races the setter itself. If the controller is interrupted before the home can record a terminal phase, the next `status --json` reconciles the definitively stopped task to `migration_failed`. - -## Debug canaries - -Run this section only when the immediately preceding install result contains `debug_canaries:true`. Never run it during an ordinary guided installation. - -Command success, state, and `read_thread` are not visual proof. In Codex Desktop, verify that: - -- a fresh foreground task shows the expanded running title in both the active header and sidebar before the response finishes; -- the terminal title appears in both places before the footer; -- one explicit-target migration repaints only the intended mounted sidebar row; -- a stopped turn leaves the running title and creates no additional ThreadBear turn. -- the persistent task remains exactly `🧵🐻 ThreadBear 🐻🧵` and pinned before migration begins; -- a completed controller leaves `status --json` at `ready:true`, `phase:migration_complete`, with no remaining inventory rows. - -Capture privacy-safe evidence when preparing a release. - -## Title cleanup - -Title cleanup is an on-demand, idempotent control-task operation. It removes every consecutive leading ThreadBear status icon while preserving ordinary emoji and every remaining title byte. A later ordinary turn may add one current status icon to an ordinary task again; the persistent home remains exactly `ThreadBear`, and cleanup prevents old decoration from becoming part of any durable subject. - -1. Run `status --json`. For ordinary cleanup, verify this task's exact ID equals `main_task_id`; no other task may request it. During uninstall, the exact prepared uninstall task may request cleanup while its persisted operation is active. -2. Run `inventory --json`. For a quiescent pre-controller `migration_pending` uninstall, do not clean inventory tasks because migration never owned them; inspect only the persistent home and continue at step 4 when it is decorated or its stripped title is the exact install sentinel. Otherwise add the active persisted controller task, if any, to the target set; the inventory intentionally excludes it and the main task. -3. Select every active title beginning with one or more exact ThreadBear status icons: `⏳`, `🚨`, `🙋`, `🤖`, `➡️`, `✅`, or `❔`. Ordinary leading emoji are not decoration. -4. In stable order, re-read one target and require its exact planned title. Call the native title setter with that explicit `threadId` and title exactly `🧵🐻 strip title icons`. The Pre hook re-reads the target, strips every leading ThreadBear status icon, uses `Untitled task` only when no subject remains, and stages the result through normal ownership state. Require the exact returned task ID/title and re-read the live title before continuing. Never retry an unknown result blindly. -5. Re-run the complete inventory plus controller read. Finish only when every selected title has no leading ThreadBear status icon and every native result reconciles. On drift, mismatch, or inaccessible state, stop with artifacts and private ownership state intact so the same control task can safely resume. - -For ordinary on-demand cleanup, do not target the active control task: it is already exactly `ThreadBear`, and its running and terminal status inputs leave that title unchanged. For uninstall, target the control task last, immediately before removing ThreadBear, and use the uninstall-turn exception in the managed guidance. +For a requested check, show `~/.local/bin/threadbear update --json`, get consent, run it, and report `restart_required`. If true, say open tasks keep their snapshot until Codex restarts. ## Uninstall -You can uninstall from any active native Codex task—even when the ThreadBear home is archived. Do not ask the user to open, select, navigate to, or unarchive the ThreadBear home. - -1. Run `help`, `status --json`, `inventory --json`, and inspect the exact owned `threadbear-maintenance` heartbeat. Resolve the exact current task ID, `main_task_id`, and distinct `controller_task_id`. A stopped `migration_failed` installation is uninstallable; running migration, pending archive work, and unsettled title work must still stop for reconciliation. Refuse any automation ID/name/kind/target mismatch. -2. Ask: “Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. If the ThreadBear home is archived, I'll briefly bring it out of the archive for cleanup and tuck it back exactly where it was. Your other archived tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. Should I go ahead?” Continue only after a clear yes. -3. Pause the exact owned heartbeat so it cannot race cleanup. Run `~/.local/bin/threadbear uninstall --prepare --initiator-task-id INITIATOR_ID --json`. Require the returned IDs to match the current task, persisted main task, and distinct controller snapshot. Re-running this command from the same initiating task resumes the same operation; a different task must stop. -4. If the returned original main state is archived, call `codex_app__set_thread_archived` once with that exact `threadId` and `archived:false`, then verify the task through `codex_app__read_thread` without opening it or sending it a turn. The prepared uninstall task remains the only writer. -5. Follow **Title cleanup** serially for every decorated active regular task and an active distinct controller, except that a quiescent pre-controller `migration_pending` uninstall leaves every inventory task untouched. Re-read `main_task_id` last and clean it through one explicit-target native call when it is decorated or its stripped title is the exact install sentinel. Every mutation gets one four-second attempt, exact readback, and no blind retry after an unknown result. -6. Require the persisted main title to be clean. Restore its exact original archive state with one `codex_app__set_thread_archived` call and verify it through `codex_app__read_thread` before continuing. On a recoverable interruption, leave the automation and local artifacts intact; the same task resumes through step 3 without repeating settled mutations. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, verify `aborted:true`, and leave ThreadBear installed. A missing, drifted, or no-op pending title remains fail-closed until its exact supported settlement; never clear or retry it from a single title snapshot. -7. Delete the exact owned heartbeat and verify it is absent. This is the commit boundary: make no later native task or automation mutation. Run `~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json`. It revalidates the clean main title and restored archive state, tolerates already-removed owned local entries, preserves unrelated config order and user-created files beside the managed skill, removes the skill directory only when empty, and removes the installed binary last. -8. From successful prepare through commit, make no running-title or terminal-title call and append no ThreadBear footer. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. +Run `status --json` and show the uninstall dry run. Explain the owned artifacts removed and that historical icons may remain. Ask for consent. -Thank the user and invite optional feedback at `eric@litman.org`. Never remove artifacts before title cleanup has completed. +After consent, run the confirmed uninstall. Preserve unrelated AGENTS content, skills, settings, files, and LaunchAgents. Once removal commits, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. diff --git a/cmd/threadbear/appserver_list.go b/cmd/threadbear/appserver_list.go new file mode 100644 index 0000000..51ca12f --- /dev/null +++ b/cmd/threadbear/appserver_list.go @@ -0,0 +1,299 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "sort" + "strings" + "time" +) + +const ( + appServerCurrentLimit = 25 + appServerCurrentTimeout = 3 * time.Second + appServerListLimit = 100 + appServerListTimeout = 30 * time.Second + appServerOnboardingTimeout = 10 * time.Minute +) + +var ( + appServerCurrentBudget = appServerCurrentTimeout + appServerListBudget = appServerListTimeout + appServerOnboardingBudget = appServerOnboardingTimeout +) + +type appServerRPCMessage struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` +} + +type appServerRPCError struct { + Code int `json:"code"` +} + +type appServerThread struct { + ID *string `json:"id"` + Name *string `json:"name"` +} + +type appServerThreadPage struct { + Data json.RawMessage `json:"data"` + NextCursor json.RawMessage `json:"nextCursor"` +} + +type appServerClient struct { + ctx context.Context + cancel context.CancelFunc + cmd *exec.Cmd + stdin io.WriteCloser + encoder *json.Encoder + decoder *json.Decoder + waited bool +} + +func startAppServer(ctx context.Context, timeout time.Duration) (_ *appServerClient, err error) { + runCtx, cancel := context.WithTimeout(ctx, timeout) + cmd := exec.CommandContext(runCtx, "codex", "app-server", "--stdio") + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("open Codex App Server input: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("open Codex App Server output: %w", err) + } + cmd.Stderr = io.Discard + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start Codex App Server: %w", err) + } + client := &appServerClient{ + ctx: runCtx, cancel: cancel, cmd: cmd, stdin: stdin, + encoder: json.NewEncoder(stdin), decoder: json.NewDecoder(stdout), + } + defer func() { + if err != nil { + client.abort() + } + }() + if err := client.encoder.Encode(map[string]any{ + "id": 1, "method": "initialize", + "params": map[string]any{"clientInfo": map[string]string{"name": "threadbear", "version": version}}, + }); err != nil { + return nil, client.ioError("initialize Codex App Server", err) + } + initialized, err := readAppServerResponse(client.decoder, 1) + if err != nil { + return nil, client.ioError("initialize Codex App Server", err) + } + var initializeResult map[string]json.RawMessage + if json.Unmarshal(initialized, &initializeResult) != nil || initializeResult == nil { + return nil, errors.New("initialize Codex App Server: invalid result") + } + if err := client.encoder.Encode(map[string]any{"method": "initialized", "params": map[string]any{}}); err != nil { + return nil, client.ioError("notify Codex App Server initialization", err) + } + return client, nil +} + +func (client *appServerClient) request(id int, method string, params map[string]any, operation string) (json.RawMessage, error) { + if err := client.encoder.Encode(map[string]any{"id": id, "method": method, "params": params}); err != nil { + return nil, client.ioError(operation, err) + } + result, err := readAppServerResponse(client.decoder, id) + if err != nil { + return nil, client.ioError(operation, err) + } + return result, nil +} + +func (client *appServerClient) close() { + _ = client.stdin.Close() + client.cancel() + _ = client.cmd.Wait() + client.waited = true +} + +func (client *appServerClient) abort() { + if client.waited { + return + } + client.cancel() + _ = client.stdin.Close() + _ = client.cmd.Wait() + client.waited = true +} + +func (client *appServerClient) ioError(operation string, err error) error { + if client.ctx.Err() != nil { + return fmt.Errorf("%s: %w", operation, client.ctx.Err()) + } + return fmt.Errorf("%s: %w", operation, err) +} + +func (client *appServerClient) currentTask(requestID int, id string) (indexedTask, error) { + result, err := client.request(requestID, "thread/list", map[string]any{ + "archived": false, "limit": appServerCurrentLimit, + "sortKey": "recency_at", "sortDirection": "desc", + }, "read Codex App Server current thread/list page") + if err != nil { + return indexedTask{}, err + } + page, _, err := decodeAppServerThreadPage(result) + if err != nil { + return indexedTask{}, fmt.Errorf("read Codex App Server current thread/list page: %w", err) + } + for index := range page { + if page[index].ID != nil && *page[index].ID == id { + return indexedTaskFromAppServer(page[index]) + } + } + return indexedTask{}, errors.New("read Codex App Server current thread/list page: current task is absent") +} + +func (client *appServerClient) inventory(nextRequestID *int) ([]indexedTask, error) { + all := make([]appServerThread, 0) + seenCursors := make(map[string]struct{}) + var cursor *string + for pageNumber := 1; ; pageNumber++ { + params := map[string]any{"archived": false, "limit": appServerListLimit} + if cursor != nil { + params["cursor"] = *cursor + } + requestID := *nextRequestID + *nextRequestID = requestID + 1 + result, err := client.request(requestID, "thread/list", params, + fmt.Sprintf("read Codex App Server thread/list page %d", pageNumber)) + if err != nil { + return nil, err + } + page, next, err := decodeAppServerThreadPage(result) + if err != nil { + return nil, fmt.Errorf("read Codex App Server thread/list page %d: %w", pageNumber, err) + } + all = append(all, page...) + if next == nil { + break + } + if *next == "" { + return nil, fmt.Errorf("read Codex App Server thread/list page %d: empty next cursor", pageNumber) + } + if _, repeated := seenCursors[*next]; repeated { + return nil, fmt.Errorf("read Codex App Server thread/list page %d: repeated next cursor", pageNumber) + } + seenCursors[*next] = struct{}{} + cursor = next + } + return finishAppServerInventory(all) +} + +func (client *appServerClient) readTask(requestID int, id string) (indexedTask, error) { + result, err := client.request(requestID, "thread/read", map[string]any{ + "threadId": id, "includeTurns": false, + }, "read Codex App Server task") + if err != nil { + return indexedTask{}, err + } + var response struct { + Thread appServerThread `json:"thread"` + } + if json.Unmarshal(result, &response) != nil || response.Thread.ID == nil || *response.Thread.ID != id { + return indexedTask{}, errors.New("read Codex App Server task: invalid thread/read result") + } + return indexedTaskFromAppServer(response.Thread) +} + +func (client *appServerClient) setName(requestID int, id, name string) error { + _, err := client.request(requestID, "thread/name/set", map[string]any{ + "threadId": id, "name": name, + }, "set Codex App Server task name") + return err +} + +func indexedTaskFromAppServer(thread appServerThread) (indexedTask, error) { + if thread.ID == nil || *thread.ID == "" { + return indexedTask{}, errors.New("Codex App Server returned an invalid task") + } + task := indexedTask{ID: *thread.ID} + if thread.Name == nil || strings.TrimSpace(*thread.Name) == "" { + task.RawFallback = true + } else { + task.Title = *thread.Name + } + return task, nil +} + +func readAppServerResponse(decoder *json.Decoder, wantID int) (json.RawMessage, error) { + for { + var message appServerRPCMessage + if err := decoder.Decode(&message); err != nil { + return nil, err + } + if len(message.ID) == 0 { + if message.Method == "" { + return nil, errors.New("invalid Codex App Server message") + } + continue + } + var gotID int + if json.Unmarshal(message.ID, &gotID) != nil || gotID != wantID { + return nil, errors.New("unexpected Codex App Server response ID") + } + if len(message.Error) != 0 && string(message.Error) != "null" { + var responseError appServerRPCError + if json.Unmarshal(message.Error, &responseError) != nil { + return nil, errors.New("invalid Codex App Server error response") + } + return nil, fmt.Errorf("Codex App Server request failed with code %d", responseError.Code) + } + if len(message.Result) == 0 || string(message.Result) == "null" { + return nil, errors.New("Codex App Server response has no result") + } + return message.Result, nil + } +} + +func decodeAppServerThreadPage(result json.RawMessage) ([]appServerThread, *string, error) { + var page appServerThreadPage + if json.Unmarshal(result, &page) != nil || len(page.Data) == 0 || !strings.HasPrefix(strings.TrimSpace(string(page.Data)), "[") { + return nil, nil, errors.New("invalid thread/list result") + } + var threads []appServerThread + if err := json.Unmarshal(page.Data, &threads); err != nil { + return nil, nil, errors.New("invalid thread/list data") + } + if len(page.NextCursor) == 0 || string(page.NextCursor) == "null" { + return threads, nil, nil + } + var next string + if json.Unmarshal(page.NextCursor, &next) != nil { + return nil, nil, errors.New("invalid thread/list next cursor") + } + return threads, &next, nil +} + +func finishAppServerInventory(all []appServerThread) ([]indexedTask, error) { + tasks := make([]indexedTask, 0, len(all)) + seen := make(map[string]struct{}, len(all)) + for _, thread := range all { + task, err := indexedTaskFromAppServer(thread) + if err != nil { + return nil, err + } + if _, duplicate := seen[task.ID]; duplicate { + continue + } + seen[task.ID] = struct{}{} + tasks = append(tasks, task) + } + sort.SliceStable(tasks, func(left, right int) bool { return tasks[left].ID < tasks[right].ID }) + return tasks, nil +} diff --git a/cmd/threadbear/appserver_list_test.go b/cmd/threadbear/appserver_list_test.go new file mode 100644 index 0000000..4c79fea --- /dev/null +++ b/cmd/threadbear/appserver_list_test.go @@ -0,0 +1,364 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" +) + +func TestAppServerCurrentBudgetFitsTerminalCall(t *testing.T) { + if appServerCurrentTimeout != 3*time.Second { + t.Fatalf("App Server current-task timeout = %s", appServerCurrentTimeout) + } +} + +func TestAppServerInventoryExhaustsPagesBeforeDedupe(t *testing.T) { + _, _ = testIndex(t) + installAppServerFixture(t, "multipage") + result, err := runOnboarding(t.Context(), false, "") + if err != nil { + t.Fatal(err) + } + tasks := result.Items + if len(tasks) != 4 { + t.Fatalf("inventory = %#v", tasks) + } + for index, id := range []string{testAlphaID, testRawID, testDelegatedID, testDuplicateID} { + if tasks[index].TaskID != id { + t.Fatalf("inventory order = %#v", tasks) + } + } + if tasks[3].Title != "First duplicate title" || tasks[1].Safe { + t.Fatalf("inventory authority = %#v", tasks) + } +} + +func TestAppServerInventoryFailsBeforeStateWrites(t *testing.T) { + _, _ = testIndex(t) + installAppServerFixture(t, "page-failure") + if result, err := runOnboarding(t.Context(), false, ""); err == nil || result.PlanComplete || !strings.Contains(err.Error(), "page 2") { + t.Fatalf("failed inventory = %#v, %v", result, err) + } + entries, err := os.ReadDir(newStore(stateDir()).subjectDir()) + if err != nil || len(entries) != 0 { + t.Fatalf("failed inventory wrote state: %#v, %v", entries, err) + } +} + +func TestAppServerNonzeroExitCannotOverturnCompleteProof(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Stable subject") + installAppServerFixture(t, "close-nonzero") + if result, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err != nil || !result.Ready || result.Title != "✅ Stable subject" { + t.Fatalf("current proof = %#v, %v", result, err) + } + clearFixtureRequests(t) + index.setTitle(t, testAlphaID, "Alpha") + if result, err := runOnboarding(t.Context(), true, testActiveID); err != nil || !result.Ready || !result.OnboardingComplete || result.Updated != 2 { + t.Fatalf("onboarding proof = %#v, %v", result, err) + } +} + +func TestAppServerCurrentFailuresStartOnlyOnce(t *testing.T) { + for _, test := range []struct { + name, scenario, contains string + }{ + {"missing", "current-missing", "current task is absent"}, + {"protocol", "current-protocol", "current thread/list page"}, + {"response ID", "current-response-id", "unexpected Codex App Server response ID"}, + {"timeout", "current-timeout", "context deadline exceeded"}, + {"unclean", "current-unclean", "set Codex App Server task name"}, + } { + t.Run(test.name, func(t *testing.T) { + _, index := testIndex(t) + if test.scenario == "current-unclean" { + index.setTitle(t, testTaskID, "Stable subject") + } + if test.scenario == "current-timeout" { + setAppServerCurrentBudget(t, 150*time.Millisecond) + } + starts := installAppServerFixture(t, test.scenario) + if _, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err == nil || !strings.Contains(err.Error(), test.contains) { + t.Fatalf("runCurrentTitle err=%v", err) + } + data, err := os.ReadFile(starts) + if test.scenario == "current-timeout" && os.IsNotExist(err) { + return // CommandContext may kill the wrapper before its one start marker. + } + if err != nil || string(data) != "x" { + t.Fatalf("App Server starts = %q, %v", data, err) + } + }) + } +} + +func installAppServerFixture(t testing.TB, scenario string) string { + t.Helper() + dir := t.TempDir() + path, starts := filepath.Join(dir, "codex"), filepath.Join(dir, "starts") + requests := filepath.Join(dir, "requests.jsonl") + raceMarker := filepath.Join(dir, "concurrent-rename") + script := "#!/bin/sh\nprintf x >> \"$THREADBEAR_APP_SERVER_STARTS\"\nexec \"$THREADBEAR_TEST_BINARY\" -test.run=^TestAppServerFixtureProcess$ -- \"$@\"\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("THREADBEAR_TEST_BINARY", os.Args[0]) + t.Setenv("THREADBEAR_APP_SERVER_FIXTURE", scenario) + t.Setenv("THREADBEAR_APP_SERVER_STARTS", starts) + t.Setenv("THREADBEAR_APP_SERVER_REQUESTS", requests) + t.Setenv("THREADBEAR_APP_SERVER_RACE_MARKER", raceMarker) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return starts +} + +func TestAppServerFixtureProcess(t *testing.T) { + scenario := os.Getenv("THREADBEAR_APP_SERVER_FIXTURE") + if scenario == "" { + return + } + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "app-server" || os.Args[len(os.Args)-1] != "--stdio" { + t.Fatalf("fixture args = %#v", os.Args) + } + decoder, encoder := json.NewDecoder(os.Stdin), json.NewEncoder(os.Stdout) + initialize := readFixtureMessage(t, decoder) + if initialize.ID != 1 || initialize.Method != "initialize" { + t.Fatalf("initialize = %#v", initialize) + } + fixtureLogRequest(t, initialize) + if err := encoder.Encode(map[string]any{"id": 1, "result": map[string]any{"serverInfo": map[string]any{"name": "fixture"}}}); err != nil { + t.Fatal(err) + } + if err := encoder.Encode(map[string]any{"method": "server/notification", "params": map[string]any{}}); err != nil { + t.Fatal(err) + } + initialized := readFixtureMessage(t, decoder) + fixtureLogRequest(t, initialized) + if initialized.ID != 0 || initialized.Method != "initialized" { + t.Fatalf("initialized = %#v", initialized) + } + setCalls := make(map[string]int) + listCalls := 0 + for { + request, err := decodeFixtureMessage(decoder) + if err == io.EOF { + if scenario == "close-nonzero" { + os.Exit(7) + } + return + } + if err != nil { + t.Fatal(err) + } + fixtureLogRequest(t, request) + switch request.Method { + case "thread/list": + listCalls++ + serveFixtureList(t, scenario, listCalls, request, encoder) + case "thread/read": + serveFixtureRead(t, scenario, setCalls, request, encoder) + case "thread/name/set": + serveFixtureSet(t, scenario, setCalls, request, encoder) + default: + t.Fatalf("unexpected fixture method %q", request.Method) + } + } +} + +func serveFixtureList(t testing.TB, scenario string, call int, request fixtureMessage, encoder *json.Encoder) { + t.Helper() + switch scenario { + case "current-timeout": + time.Sleep(2 * time.Second) + return + case "current-protocol": + _, _ = fmt.Fprint(os.Stdout, `{"id":2,"result":`) + os.Exit(0) + case "current-response-id": + _ = encoder.Encode(map[string]any{"id": request.ID + 7, "result": map[string]any{"data": []any{}, "nextCursor": nil}}) + return + case "current-missing": + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "data": []map[string]any{{"id": testOtherID, "name": "Other", "preview": "private"}}, "nextCursor": nil, + }}) + return + case "multipage", "page-failure": + serveFixturePages(t, scenario, request, encoder) + return + } + tasks := fixtureReadTasks(t) + ids := make([]string, 0, len(tasks)) + for id := range tasks { + ids = append(ids, id) + } + sort.Strings(ids) + rows := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + rows = append(rows, map[string]any{"id": id, "name": tasks[id].Name, "preview": tasks[id].Preview}) + } + if err := encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": rows, "nextCursor": nil}}); err != nil { + t.Fatal(err) + } + if scenario == "current-concurrent-rename" && call == 1 { + // The initial title is already on the wire. Apply the user's rename before + // reading ThreadBear's next (and only) set request. + rename := "User rename during delayed write" + task := tasks[testTaskID] + task.Name = &rename + tasks[testTaskID] = task + fixtureWriteTasks(t, tasks) + if err := os.WriteFile(os.Getenv("THREADBEAR_APP_SERVER_RACE_MARKER"), []byte(rename+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + if scenario == "current-unclean" && call == 1 { + os.Exit(7) + } +} + +func serveFixturePages(t testing.TB, scenario string, request fixtureMessage, encoder *json.Encoder) { + t.Helper() + var cursor string + _ = json.Unmarshal(request.Params["cursor"], &cursor) + if cursor == "" { + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "data": []map[string]any{{"id": testRawID, "name": nil}, {"id": testDuplicateID, "name": "First duplicate title"}}, + "nextCursor": "page-2", + }}) + return + } + if scenario == "page-failure" { + _ = encoder.Encode(map[string]any{"id": request.ID, "error": map[string]any{"code": -32000}}) + return + } + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "data": []map[string]any{ + {"id": testDelegatedID, "name": "Delegated task"}, {"id": testAlphaID, "name": "Alpha"}, + {"id": testDuplicateID, "name": "Later duplicate title"}, + }, + "nextCursor": nil, + }}) +} + +func serveFixtureRead(t testing.TB, scenario string, setCalls map[string]int, request fixtureMessage, encoder *json.Encoder) { + t.Helper() + id := fixtureStringParam(t, request, "threadId") + tasks := fixtureReadTasks(t) + task, ok := tasks[id] + if !ok { + _ = encoder.Encode(map[string]any{"id": request.ID, "error": map[string]any{"code": -32004}}) + return + } + name := task.Name + if scenario == "onboarding-slow-readback" && setCalls[id] > 0 { + time.Sleep(300 * time.Millisecond) + } + if scenario == "onboarding-races" { + switch id { + case testDriftID: + value := "Renamed after snapshot" + name = &value + case testBlankAfterID: + name = nil + case testUnconfirmedID: + // The setter acknowledges this target but its exact readback never changes. + _ = setCalls[id] + } + } + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "thread": map[string]any{"id": id, "name": name, "preview": task.Preview}, + }}) +} + +func serveFixtureSet(t testing.TB, scenario string, setCalls map[string]int, request fixtureMessage, encoder *json.Encoder) { + t.Helper() + id, name := fixtureStringParam(t, request, "threadId"), fixtureStringParam(t, request, "name") + setCalls[id]++ + tasks := fixtureReadTasks(t) + if scenario != "current-readback-mismatch" && !(scenario == "onboarding-races" && id == testUnconfirmedID) { + task := tasks[id] + task.Name = &name + tasks[id] = task + fixtureWriteTasks(t, tasks) + } + if scenario == "current-set-error" { + _ = encoder.Encode(map[string]any{"id": request.ID, "error": map[string]any{"code": -32001}}) + return + } + // The real response is an empty object. Its contents are never title proof. + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{}}) +} + +type fixtureMessage struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]json.RawMessage `json:"params"` +} + +func readFixtureMessage(t testing.TB, decoder *json.Decoder) fixtureMessage { + t.Helper() + message, err := decodeFixtureMessage(decoder) + if err != nil { + t.Fatal(err) + } + return message +} + +func decodeFixtureMessage(decoder *json.Decoder) (fixtureMessage, error) { + var message fixtureMessage + err := decoder.Decode(&message) + return message, err +} + +func fixtureStringParam(t testing.TB, request fixtureMessage, key string) string { + t.Helper() + var value string + if json.Unmarshal(request.Params[key], &value) != nil { + t.Fatalf("%s param = %s", key, request.Params[key]) + } + return value +} + +func fixtureLogRequest(t testing.TB, request fixtureMessage) { + t.Helper() + file, err := os.OpenFile(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(file).Encode(request); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func fixtureReadTasks(t testing.TB) map[string]appServerFixtureTask { + t.Helper() + data, err := os.ReadFile(os.Getenv("THREADBEAR_APP_SERVER_TASKS")) + if err != nil { + t.Fatal(err) + } + var tasks map[string]appServerFixtureTask + if json.Unmarshal(data, &tasks) != nil || tasks == nil { + t.Fatal("invalid fixture task registry") + } + return tasks +} + +func fixtureWriteTasks(t testing.TB, tasks map[string]appServerFixtureTask) { + t.Helper() + data, err := json.Marshal(tasks) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("THREADBEAR_APP_SERVER_TASKS"), data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index e7f4c9a..10b530a 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -1,949 +1,575 @@ package main import ( - "bytes" "context" - "database/sql" "encoding/json" "errors" - "fmt" + "io" "os" "path/filepath" "strings" "testing" "time" - _ "modernc.org/sqlite" + "golang.org/x/sys/unix" ) -func testIndex(t testing.TB) (string, *sql.DB) { +type testTaskIndex struct { + path string + tasks map[string]appServerFixtureTask +} + +type appServerFixtureTask struct { + Name *string `json:"name"` + Preview string `json:"preview"` +} + +func testIndex(t testing.TB) (string, *testTaskIndex) { t.Helper() + setAppServerCurrentBudget(t, appServerCurrentTimeout) root := t.TempDir() codex := filepath.Join(root, "codex") if err := os.Mkdir(codex, 0o700); err != nil { t.Fatal(err) } - db, err := sql.Open("sqlite", filepath.Join(codex, "state_1.sqlite")) - if err != nil { + t.Setenv("HOME", root) + t.Setenv("CODEX_HOME", codex) + if err := os.MkdirAll(newStore(stateDir()).subjectDir(), 0o700); err != nil { t.Fatal(err) } - t.Cleanup(func() { db.Close() }) - _, err = db.Exec(`CREATE TABLE threads ( - id TEXT PRIMARY KEY, updated_at_ms INTEGER, title TEXT, name TEXT, archived INTEGER, - source TEXT, thread_source TEXT, rollout_path TEXT, first_user_message TEXT, preview TEXT)`) - if err != nil { + if err := os.WriteFile(filepath.Join(stateDir(), "lifecycle.lock"), nil, 0o600); err != nil { t.Fatal(err) } - t.Setenv("HOME", root) - t.Setenv("CODEX_HOME", codex) - return root, db + index := &testTaskIndex{path: filepath.Join(root, "appserver-tasks.json"), tasks: make(map[string]appServerFixtureTask)} + index.write(t) + t.Setenv("THREADBEAR_APP_SERVER_TASKS", index.path) + installAppServerFixture(t, "registry") + return root, index } -func addTask(t testing.TB, db *sql.DB, root, id, title string, name any, source string, archived int) string { +func setAppServerCurrentBudget(t testing.TB, budget time.Duration) { t.Helper() - rollout := filepath.Join(root, id+".jsonl") - if err := os.WriteFile(rollout, nil, 0o600); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`INSERT INTO threads VALUES (?,1,?,?,?,?,'',?,'',?)`, id, title, name, archived, source, rollout, id); err != nil { - t.Fatal(err) - } - return rollout + previous := appServerCurrentBudget + appServerCurrentBudget = budget + t.Cleanup(func() { appServerCurrentBudget = previous }) } -func TestInventoryMatchesNativeAddressableTasks(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "desktop", "generated", "renamed", "vscode", 0) - addTask(t, db, root, "cli", "", nil, "cli", 0) - addTask(t, db, root, "mcp", "mcp title", nil, "mcp", 0) - addTask(t, db, root, "exec", "exec title", nil, "exec", 0) - addTask(t, db, root, "empty", "empty preview", nil, "vscode", 0) - addTask(t, db, root, "archived", "old", nil, "vscode", 1) - if _, err := db.Exec(`UPDATE threads SET preview='' WHERE id='empty'`); err != nil { - t.Fatal(err) - } - tasks, err := inventory(context.Background()) - if err != nil || len(tasks) != 2 || tasks[0].ID != "cli" || tasks[1].ID != "desktop" || tasks[1].Title != "renamed" { - t.Fatalf("inventory = %#v, %v", tasks, err) - } - got, found, err := oneTask(context.Background(), "desktop") - if err != nil || !found || got.Title != "renamed" { - t.Fatalf("oneTask = %#v, %v, %v", got, found, err) - } - for _, id := range []string{"mcp", "exec", "empty", "archived"} { - if _, found, _ := oneTask(context.Background(), id); found { - t.Fatalf("%s task was addressable", id) - } - } - readOnly, err := openIndex() +func (index *testTaskIndex) write(t testing.TB) { + t.Helper() + data, err := json.Marshal(index.tasks) if err != nil { t.Fatal(err) } - defer readOnly.Close() - if _, err := readOnly.Exec(`INSERT INTO threads (id,archived) VALUES ('write',0)`); err == nil { - t.Fatal("read-only index accepted a write") - } -} - -func TestSQLiteHomeFollowsCodexTOML(t *testing.T) { - base := t.TempDir() - t.Setenv("CODEX_HOME", base) - if err := os.WriteFile(filepath.Join(base, "config.toml"), []byte(`sqlite_home = "state" # local database`), 0o600); err != nil { + if err := os.WriteFile(index.path, data, 0o600); err != nil { t.Fatal(err) } - got, err := sqliteHome() - if err != nil || got != filepath.Join(base, "state") { - t.Fatalf("sqliteHome = %q, %v", got, err) - } } -func TestRolloutFooterUsesLatestExactTerminalMessage(t *testing.T) { - path := filepath.Join(t.TempDir(), "rollout.jsonl") - data := rolloutLine("response_item", map[string]any{"type": "message", "role": "assistant", "phase": "final_answer", "content": []map[string]string{{"text": "old\n\n🧵🐻 complete"}}}) + - rolloutLine("response_item", map[string]any{"type": "message", "role": "assistant", "phase": "final_answer", "content": []map[string]string{{"text": "pick\n\n🧵🐻 needs input (you): choose the release region"}}}) - if err := os.WriteFile(path, []byte(data), 0o600); err != nil { - t.Fatal(err) - } - got, ok := rolloutFooter(path) - if !ok || got.Status != "needs_input" || got.Action != "choose the release region" { - t.Fatalf("footer = %#v, %v", got, ok) - } - if err := os.WriteFile(path, []byte(rolloutLine("response_item", map[string]any{"type": "message", "role": "assistant", "phase": "final_answer", "content": []map[string]string{{"text": "legacy prose"}}})), 0o600); err != nil { - t.Fatal(err) - } - if _, ok := rolloutFooter(path); ok { - t.Fatal("legacy prose classified deterministically") - } +func (index *testTaskIndex) setTitle(t testing.TB, id, title string) { + t.Helper() + index.tasks[id] = appServerFixtureTask{Name: &title, Preview: "private raw preview"} + index.write(t) } -func TestOrdinaryHooksRewriteVerifyAndBlockLostPost(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - pre := hookPayload("PreToolUse", "task", "call-1", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - proposed := rewrittenTitle(t, output.Bytes()) - if proposed != "⏳ Stable subject" { - t.Fatalf("rewritten title = %q", proposed) - } - saved, _ := newStore(stateDir()).read() - if saved.Tasks["task"].Pending == nil { - t.Fatal("Pre did not record a proposal") - } - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) - post := hookPayload("PostToolUse", "task", "call-1", map[string]any{"title": proposed}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - t.Fatal(err) - } - saved, _ = newStore(stateDir()).read() - if saved.Tasks["task"].Last != proposed || saved.Tasks["task"].Subject != "Stable subject" || saved.Tasks["task"].Pending != nil { - t.Fatalf("committed state = %#v", saved.Tasks["task"]) - } +func (index *testTaskIndex) setRaw(t testing.TB, id string) { + t.Helper() + index.tasks[id] = appServerFixtureTask{Preview: "private raw preview"} + index.write(t) +} - // A setter success with a lost Post remains the sole admitted proposal. - pre = hookPayload("PreToolUse", "task", "call-2", map[string]any{"title": "🧵🐻 next steps (agent): finish the tests"}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { +func (index *testTaskIndex) title(t testing.TB, id string) string { + t.Helper() + data, err := os.ReadFile(index.path) + if err != nil { t.Fatal(err) } - proposed = rewrittenTitle(t, output.Bytes()) - if _, err := db.Exec(`UPDATE threads SET name=? WHERE id='task'`, proposed); err != nil { + var tasks map[string]appServerFixtureTask + if err := json.Unmarshal(data, &tasks); err != nil { t.Fatal(err) } - pre = hookPayload("PreToolUse", "task", "call-3", map[string]any{"title": runningMarker + ": Changed model seed"}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("lost-Post proposal was not kept fail-closed: %q, %v", output.String(), err) + if tasks[id].Name == nil { + return "" } + return *tasks[id].Name } -func TestPlainTitlePassThroughStagesAndSettles(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - requested := "User⁣ rename" - pre := hookPayload("PreToolUse", "task", "plain", map[string]any{"title": requested}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || output.Len() != 0 { - t.Fatalf("plain Pre = %q, %v", output.String(), err) - } - saved, _ := newStore(stateDir()).read() - if pending := saved.Tasks["task"].Pending; pending == nil || pending.Prior != "Stable subject" || pending.Proposed != requested || pending.Attempt != "" { - t.Fatalf("plain proposal = %#v", pending) - } - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": requested}) - post := hookPayload("PostToolUse", "task", "plain", map[string]any{"title": requested}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { +func addTask(t testing.TB, index *testTaskIndex, root, id, title string, name any, source string, archived int) string { + t.Helper() + rollout := filepath.Join(root, id+".jsonl") + if err := os.WriteFile(rollout, nil, 0o600); err != nil { t.Fatal(err) } - saved, _ = newStore(stateDir()).read() - if got := saved.Tasks["task"]; got.Subject != "User⁣ rename" || got.Last != requested || got.Pending != nil { - t.Fatalf("plain committed state = %#v", got) - } -} - -func BenchmarkOrdinaryPreToolUse(b *testing.B) { - root, db := testIndex(b) - addTask(b, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - b.Fatal(err) - } - payload := hookPayload("PreToolUse", "task", "benchmark-pre", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - var output bytes.Buffer - b.ResetTimer() - for b.Loop() { - output.Reset() - if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil { - b.Fatal(err) - } + visible := title + if value, ok := name.(string); ok { + visible = value } + index.setTitle(t, id, visible) + _ = source + _ = archived + return rollout } -func TestPreToolUseWaitsWhileTitleLifecycleIsLocked(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - lock, err := newStore(stateDir()).titleLock() +func TestCurrentTitleUsesOneWriterAndExactReadback(t *testing.T) { + root, index := testIndex(t) + addTask(t, index, root, testTaskID, "stale SQLite title", "Stable subject", "vscode", 0) + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") if err != nil { t.Fatal(err) } - payload := hookPayload("PreToolUse", "task", "locked", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - type result struct { - output string - err error + if !result.Ready || !result.Updated || result.Unchanged || result.Unconfirmed || + result.PreviousTitle != "Stable subject" || result.DesiredTitle != "✅ Stable subject" || result.Title != result.DesiredTitle { + t.Fatalf("title result = %#v", result) } - done := make(chan result, 1) - go func() { - var output bytes.Buffer - err := hook(context.Background(), strings.NewReader(payload), &output) - done <- result{output: output.String(), err: err} - }() - select { - case got := <-done: - unlock(lock) - t.Fatalf("locked PreToolUse returned early: %q, %v", got.output, got.err) - case <-time.After(25 * time.Millisecond): + if got := index.title(t, testTaskID); got != "✅ Stable subject" { + t.Fatalf("native name = %q", got) } - unlock(lock) - var got result - select { - case got = <-done: - case <-time.After(2 * time.Second): - t.Fatal("PreToolUse did not continue after title lock released") + if record, err := newStore(stateDir()).readTask(testTaskID); err != nil || record.Subject != "Stable subject" { + t.Fatalf("subject record = %#v, %v", record, err) } - if got.err != nil || !strings.Contains(got.output, `"permissionDecision":"allow"`) { - t.Fatalf("released PreToolUse = %q, %v", got.output, got.err) + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 1 || countFixtureMethod(requests, "thread/list") != 2 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("RPC sequence = %#v", requests) } - saved, err := newStore(stateDir()).read() - if err != nil || saved.Tasks["task"].Pending == nil { - t.Fatalf("released PreToolUse did not stage state: %#v, %v", saved.Tasks["task"], err) + set := fixtureMethod(requests, "thread/name/set", 0) + if fixtureStringParam(t, set, "threadId") != testTaskID || fixtureStringParam(t, set, "name") != result.DesiredTitle { + t.Fatalf("setter = %#v", set) } } -func TestPreToolUseQueuedBehindTeardownCannotRecreateState(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { +func TestCurrentTitlePreservesFiniteOwnershipAndUserRename(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Initial subject") + if _, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err != nil { t.Fatal(err) } - lock, err := newStore(stateDir()).titleLock() - if err != nil { + if _, err := runCurrentTitle(t.Context(), testTaskID, "automation"); err != nil { t.Fatal(err) } - payload := hookPayload("PreToolUse", "task", "teardown", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - done := make(chan string, 1) - go func() { - var output bytes.Buffer - _ = hook(context.Background(), strings.NewReader(payload), &output) - done <- output.String() - }() - select { - case output := <-done: - unlock(lock) - t.Fatalf("queued PreToolUse returned before teardown: %q", output) - case <-time.After(25 * time.Millisecond): + if got := index.title(t, testTaskID); got != "🤖 Initial subject" { + t.Fatalf("owned rendering stacked: %q", got) } - if err := os.RemoveAll(stateDir()); err != nil { - unlock(lock) - t.Fatal(err) + index.setTitle(t, testTaskID, "✅ Quarterly close ") + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err != nil || result.Title != "✅ ✅ Quarterly close " { + t.Fatalf("verbatim rename = %#v, %v", result, err) } - unlock(lock) - select { - case output := <-done: - if !strings.Contains(output, `"permissionDecision":"deny"`) { - t.Fatalf("post-teardown PreToolUse = %q", output) - } - case <-time.After(2 * time.Second): - t.Fatal("post-teardown PreToolUse did not return") - } - if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("queued PreToolUse recreated state: %v", err) + if record, err := newStore(stateDir()).readTask(testTaskID); err != nil || record.Subject != "✅ Quarterly close " { + t.Fatalf("renamed record = %#v, %v", record, err) } } -func TestPostToolUseQueuedBehindTeardownCannotRecreateState(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "task", "delayed-post", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - proposed := rewrittenTitle(t, output.Bytes()) - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) - post := hookPayload("PostToolUse", "task", "delayed-post", map[string]any{"title": proposed}, string(response)) - lock, err := newStore(stateDir()).titleLock() - if err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}) }() - select { - case err := <-done: - unlock(lock) - t.Fatalf("queued PostToolUse returned before teardown: %v", err) - case <-time.After(25 * time.Millisecond): - } - if err := os.RemoveAll(stateDir()); err != nil { - unlock(lock) +func TestCurrentTitleAlreadyExactDoesNotCallSetter(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "✅ Stable subject") + if err := newStore(stateDir()).updateTask(testTaskID, func(record *taskState) (bool, error) { + record.Subject = "Stable subject" + return true, nil + }); err != nil { t.Fatal(err) } - unlock(lock) - select { - case err := <-done: - if err == nil { - t.Fatal("post-teardown PostToolUse unexpectedly succeeded") - } - case <-time.After(2 * time.Second): - t.Fatal("post-teardown PostToolUse did not return") + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err != nil || !result.Ready || result.Updated || !result.Unchanged || result.Title != "✅ Stable subject" { + t.Fatalf("unchanged result = %#v, %v", result, err) } - if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("queued PostToolUse recreated state: %v", err) + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 2 { + t.Fatalf("unchanged RPCs = %#v", requests) } } -func TestDeniedSecondExplicitCallCannotClearFirstProposal(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "target", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "controller", "failed-call", map[string]any{"threadId": "target", "title": "🧵🐻 complete"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - output.Reset() - second := hookPayload("PreToolUse", "controller", "denied-call", map[string]any{"threadId": "target", "title": "🧵🐻 next steps (agent): retry"}, nil) - if err := hook(context.Background(), strings.NewReader(second), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("second proposal = %q, %v", output.String(), err) - } - saved, err := newStore(stateDir()).read() - if pending := saved.Tasks["target"].Pending; err != nil || pending == nil || pending.ToolUseID != "failed-call" || pending.CallerTaskID != "controller" { - t.Fatalf("denied second call changed the first proposal: %#v, %v", saved.Tasks["target"], err) +func TestCurrentTitleDoesNotWriteUnsafeOrBlankNativeNames(t *testing.T) { + for name, setup := range map[string]func(testing.TB, *testTaskIndex){ + "blank": func(t testing.TB, index *testTaskIndex) { index.setRaw(t, testTaskID) }, + "internal": func(t testing.TB, index *testTaskIndex) { + index.setTitle(t, testTaskID, "private") + }, + } { + t.Run(name, func(t *testing.T) { + _, index := testIndex(t) + setup(t, index) + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err == nil || result.PreviousTitle != "" || result.DesiredTitle != "" { + t.Fatalf("unsafe result = %#v, %v", result, err) + } + if countFixtureMethod(fixtureRequests(t), "thread/name/set") != 0 { + t.Fatal("unsafe task reached native setter") + } + if _, err := newStore(stateDir()).readTask(testTaskID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unsafe task wrote subject state: %v", err) + } + }) } } -func TestConcurrentMigrationTitleWaveCommitsDistinctTargets(t *testing.T) { - root, db := testIndex(t) - const size = 8 - for i := range size { - id := fmt.Sprintf("target-%d", i) - addTask(t, db, root, id, "Subject "+id, nil, "vscode", 0) - } - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.MainTaskID, saved.ControllerTaskID, saved.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - lock, err := newStore(stateDir()).titleLock() - if err != nil { - t.Fatal(err) - } - unlock(lock) - type result struct { - id, title, output string - err error - } - prepared := make(chan result, size) - for i := range size { - id := fmt.Sprintf("target-%d", i) - go func() { - var output bytes.Buffer - payload := hookPayload("PreToolUse", "controller", "call-"+id, map[string]any{"threadId": id, "title": "🧵🐻 complete"}, nil) - err := hook(context.Background(), strings.NewReader(payload), &output) - if err != nil { - prepared <- result{id: id, output: output.String(), err: err} - return +func TestCurrentTitleNeverRetriesUnknownNativeResult(t *testing.T) { + for _, scenario := range []string{"current-readback-mismatch", "current-set-error"} { + t.Run(scenario, func(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Stable subject") + starts := installAppServerFixture(t, scenario) + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err == nil || !result.Unconfirmed || result.Ready || result.Title != "" { + t.Fatalf("unknown result = %#v, %v", result, err) } - var value struct { - Hook struct { - Updated map[string]json.RawMessage `json:"updatedInput"` - } `json:"hookSpecificOutput"` + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 1 { + t.Fatalf("setter attempts = %#v", requests) } - err = json.Unmarshal(output.Bytes(), &value) - var title string - if err == nil { - err = json.Unmarshal(value.Hook.Updated["title"], &title) + if data, err := os.ReadFile(starts); err != nil || string(data) != "x" { + t.Fatalf("App Server starts = %q, %v", data, err) } - prepared <- result{id: id, title: title, output: output.String(), err: err} - }() - } - posts := make(chan result, size) - items := make([]result, 0, size) - for range size { - items = append(items, <-prepared) - } - for _, item := range items { - if item.err != nil || item.title != "✅ Subject "+item.id { - t.Fatalf("prepared %s = title %q, output %q, %v", item.id, item.title, item.output, item.err) - } - } - for _, item := range items { - go func() { - response, _ := json.Marshal(map[string]string{"threadId": item.id, "title": item.title}) - payload := hookPayload("PostToolUse", "controller", "call-"+item.id, map[string]any{"threadId": item.id, "title": item.title}, string(response)) - posts <- result{id: item.id, title: item.title, err: hook(context.Background(), strings.NewReader(payload), &bytes.Buffer{})} - }() - } - var postFailures []string - for range size { - if item := <-posts; item.err != nil { - postFailures = append(postFailures, fmt.Sprintf("%s: %v", item.id, item.err)) - } - } - if len(postFailures) > 0 { - t.Fatalf("commit failures: %s", strings.Join(postFailures, "; ")) - } - saved, err := newStore(stateDir()).read() - if err != nil { - t.Fatal(err) - } - for i := range size { - id := fmt.Sprintf("target-%d", i) - if got := saved.Tasks[id]; got.Pending != nil || got.Last != "✅ Subject "+id || got.Status != "complete" { - t.Fatalf("target state %s = %#v", id, got) - } + }) } } -func TestPreToolUseContinuesWhileMaintenanceOperationIsLocked(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - lock, err := newStore(stateDir()).operationLock() - if err != nil { - t.Fatal(err) - } - defer unlock(lock) - payload := hookPayload("PreToolUse", "task", "update-overlap", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"allow"`) { - t.Fatalf("operation-overlap PreToolUse = %q, %v", output.String(), err) - } -} +func TestCurrentTitleAcceptsBoundedNoCASConcurrentRename(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Stable subject") + starts := installAppServerFixture(t, "current-concurrent-rename") -func BenchmarkOrdinaryPostToolUse(b *testing.B) { - root, db := testIndex(b) - addTask(b, db, root, "task", "Stable subject", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - b.Fatal(err) - } - pre := hookPayload("PreToolUse", "task", "benchmark-post", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - b.Fatal(err) - } - proposed := rewrittenTitle(b, output.Bytes()) - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) - post := hookPayload("PostToolUse", "task", "benchmark-post", map[string]any{"title": proposed}, string(response)) - b.ResetTimer() - for b.Loop() { - b.StopTimer() - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - b.Fatal(err) - } - b.StartTimer() - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - b.Fatal(err) - } + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err != nil || !result.Ready || !result.Updated || result.Unconfirmed || + result.PreviousTitle != "Stable subject" || result.Title != "✅ Stable subject" { + t.Fatalf("concurrent rename result = %#v, %v", result, err) } -} - -func TestFreshRunningSubjectSeedClosesFirstTitleRace(t *testing.T) { - root, db := testIndex(t) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) + marker, err := os.ReadFile(os.Getenv("THREADBEAR_APP_SERVER_RACE_MARKER")) + if err != nil || string(marker) != "User rename during delayed write\n" { + t.Fatalf("concurrent rename marker = %q, %v", marker, err) } - first := "Fix the login redirect. First call the title tool, then inspect the failure." - addTask(t, db, root, "raw", first, nil, "vscode", 0) - addTask(t, db, root, "short", "Fix login redirect", nil, "vscode", 0) - addTask(t, db, root, "named", first, "Customer login", "vscode", 0) - addTask(t, db, root, "truncated", truncateUTF16(first, 60), nil, "vscode", 0) - addTask(t, db, root, "delegated", " private Fix login", nil, "vscode", 0) - if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id IN ('raw','short','named','truncated')`, first); err != nil { - t.Fatal(err) + if got := index.title(t, testTaskID); got != result.DesiredTitle { + t.Fatalf("exact native readback = %q, want %q", got, result.DesiredTitle) } - if _, err := db.Exec(`UPDATE threads SET first_user_message=title WHERE id='delegated'`); err != nil { - t.Fatal(err) + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 1 || + countFixtureMethod(requests, "thread/list") != 2 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("no-CAS RPC sequence = %#v", requests) } - for id, want := range map[string]string{ - "raw": "⏳ Model subject seed", "short": "⏳ Fix login redirect", "named": "⏳ Customer login", - "truncated": "⏳ Model subject seed", "delegated": "⏳ Model subject seed", - } { - var output bytes.Buffer - pre := hookPayload("PreToolUse", id, "call-"+id, map[string]any{"title": runningMarker + ": Model subject seed"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != want { - t.Fatalf("%s rewrite = %q, %v", id, output.String(), err) - } + if data, err := os.ReadFile(starts); err != nil || string(data) != "x" { + t.Fatalf("App Server starts = %q, %v", data, err) } } -func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { - root, db := testIndex(t) - if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { - t.Fatal(err) - } - first := "Investigate the first title race and preserve the stable subject." - addTask(t, db, root, "task", first, nil, "vscode", 0) - if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='task'`, first); err != nil { - t.Fatal(err) - } - indexed, found, err := oneTask(context.Background(), "task") - if err != nil || !found || indexed.Title != first || indexed.FirstMessage != first || indexed.Name != "" { - t.Fatalf("fresh task index = %#v, %v, %v", indexed, found, err) - } - stateBefore, err := currentStateOrEmpty() - if err != nil || len(stateBefore.Tasks) != 0 { - t.Fatalf("fresh state = %#v, %v", stateBefore, err) - } - var homeOutput bytes.Buffer - homePre := hookPayload("PreToolUse", "task", "home", map[string]any{"title": homeTitle}, nil) - if err := hook(context.Background(), strings.NewReader(homePre), &homeOutput); err != nil || rewrittenTitle(t, homeOutput.Bytes()) != homeTitle { - t.Fatalf("persistent home title was not passed through: %q, %v", homeOutput.String(), err) - } - homeResponse, _ := json.Marshal(map[string]string{"threadId": "task", "title": homeTitle}) - homePost := hookPayload("PostToolUse", "task", "home", map[string]any{"title": homeTitle}, string(homeResponse)) - if err := hook(context.Background(), strings.NewReader(homePost), &bytes.Buffer{}); err != nil { - t.Fatal(err) - } - homeState, _ := currentStateOrEmpty() - if got := homeState.Tasks["task"]; len(homeState.Tasks) != 1 || got.Original != first || got.Subject != "" || got.Last != homeTitle || got.Pending != nil { - t.Fatalf("persistent home title did not retain its prior subject: %#v", homeState.Tasks) - } - for _, marker := range []string{runningMarker, runningMarker + ":", runningMarker + ": ", runningMarker + ": bad spacing", runningMarker + ": " + strings.Repeat("x", 59), homeTitle + " extra"} { - var output bytes.Buffer - pre := hookPayload("PreToolUse", "task", marker, map[string]any{"title": marker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("marker %q did not fail closed: %q, %v", marker, output.String(), err) - } - } - stateAfter, err := currentStateOrEmpty() - if err != nil || len(stateAfter.Tasks) != 1 { - t.Fatalf("denied markers changed state: %#v, %v", stateAfter, err) - } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "task", "seed", map[string]any{"title": runningMarker + ": First title race"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { +func TestOnboardingPlanMatchesConfirmedCorpusAndSkipsActiveTask(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testActiveID, "Active task") + index.setTitle(t, testAlphaID, "Alpha") + index.setTitle(t, testAlreadyID, "🐻 Beta") + index.setRaw(t, testRawID) + if err := newStore(stateDir()).updateTask(testAlreadyID, func(record *taskState) (bool, error) { + record.Subject = "Beta" + return true, nil + }); err != nil { t.Fatal(err) } - proposed := rewrittenTitle(t, output.Bytes()) - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) - post := hookPayload("PostToolUse", "task", "seed", map[string]any{"title": proposed}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - t.Fatal(err) + + plan, err := runOnboarding(t.Context(), false, testActiveID) + if err != nil || !plan.Ready || !plan.PlanComplete || !plan.ReadOnly || plan.OnboardingComplete || + plan.Total != 4 || plan.Safe != 3 || plan.NeedsUpdate != 1 || plan.Unchanged != 2 || plan.Skipped != 1 { + t.Fatalf("onboarding plan = %#v, %v", plan, err) } - if _, err := db.Exec(`UPDATE threads SET title=? WHERE id='task'`, proposed); err != nil { - t.Fatal(err) + active := onboardingItemByID(t, plan.Items, testActiveID) + if active.Outcome != onboardingUnchanged || active.Applied || active.Reason != "active task is handled by the terminal title writer" { + t.Fatalf("active plan item = %#v", active) } - output.Reset() - pre = hookPayload("PreToolUse", "task", "later", map[string]any{"title": runningMarker + ": Ignore this replacement"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != "⏳ First title race" { - t.Fatalf("owned subject changed: %q, %v", output.String(), err) + if countFixtureMethod(fixtureRequests(t), "thread/read") != 0 || countFixtureMethod(fixtureRequests(t), "thread/name/set") != 0 { + t.Fatal("read-only plan performed target RPCs") } -} + clearFixtureRequests(t) -func TestPersistentHomeNeverReceivesStatusTitle(t *testing.T) { - root, db := testIndex(t) - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.MainTaskID, saved.Phase = "task", phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) + applied, err := runOnboarding(t.Context(), true, testActiveID) + if err != nil || !applied.Ready || applied.ReadOnly || !applied.OnboardingComplete || + applied.Updated != 1 || applied.Unchanged != 2 || applied.Skipped != 1 || applied.Unconfirmed != 0 { + t.Fatalf("onboarding apply = %#v, %v", applied, err) } - addTask(t, db, root, "task", "fresh", nil, "vscode", 0) - if _, err := db.Exec(`UPDATE threads SET first_user_message='fresh' WHERE id='task'`); err != nil { - t.Fatal(err) + if index.title(t, testActiveID) != "Active task" || index.title(t, testAlphaID) != "🐻 Alpha" { + t.Fatalf("native titles: active=%q alpha=%q", index.title(t, testActiveID), index.title(t, testAlphaID)) } - for _, item := range [][2]string{{"running-home", runningMarker + ": Replacement seed"}, {"complete-home", "🧵🐻 complete"}} { - call, marker := item[0], item[1] - var output bytes.Buffer - pre := hookPayload("PreToolUse", "task", call, map[string]any{"title": marker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != mainTitle { - t.Fatalf("%s rewrite = %q, %v", call, output.String(), err) - } - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": mainTitle}) - post := hookPayload("PostToolUse", "task", call, map[string]any{"title": mainTitle}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - t.Fatal(err) - } + requests := fixtureRequests(t) + if countFixtureTarget(requests, "thread/name/set", testActiveID) != 0 || countFixtureTarget(requests, "thread/read", testActiveID) != 0 || + countFixtureTarget(requests, "thread/name/set", testAlphaID) != 1 { + t.Fatalf("onboarding RPCs = %#v", requests) } - saved, _ := currentStateOrEmpty() - if got := saved.Tasks["task"]; got.Pending != nil || got.Subject != mainTitle || got.Last != mainTitle || got.Status != "complete" { - t.Fatalf("persistent home state = %#v", got) + if _, err := newStore(stateDir()).readTask(testActiveID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("active task wrote state: %v", err) } } -func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { - root, db := testIndex(t) - first := "Restarted task exposes this long raw first message before Codex restores the committed title." - delegation := "privateFix login" - addTask(t, db, root, "raw", first, nil, "vscode", 0) - addTask(t, db, root, "truncated", truncateUTF16(first, 60), nil, "vscode", 0) - addTask(t, db, root, "pending", first, nil, "vscode", 0) - addTask(t, db, root, "fresh", first, nil, "vscode", 0) - addTask(t, db, root, "delegated", delegation, nil, "vscode", 0) - addTask(t, db, root, "renamed", "Manual user rename", nil, "vscode", 0) - addTask(t, db, root, "named", first, "Explicit name", "vscode", 0) - if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id IN ('raw','truncated','pending','fresh','renamed','named')`, first); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='delegated'`, delegation); err != nil { - t.Fatal(err) +func TestOnboardingSeriallySkipsDriftAndReportsUnconfirmed(t *testing.T) { + _, index := testIndex(t) + for id, title := range map[string]string{ + testActiveID: "Active", testAlphaID: "Alpha", testAlreadyID: "🐻 Beta", testBlankAfterID: "Blank later", + testDriftID: "Drift", testUnconfirmedID: "Unconfirmed", + } { + index.setTitle(t, id, title) } - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.Tasks["raw"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} - saved.Tasks["truncated"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} - saved.Tasks["pending"] = taskState{Pending: &pendingProposal{BaseSubject: "Pending owner", Proposed: first}} + index.setRaw(t, testRawID) + if err := newStore(stateDir()).updateTask(testAlreadyID, func(record *taskState) (bool, error) { + record.Subject = "Beta" return true, nil }); err != nil { t.Fatal(err) } - for id, call := range map[string][2]string{ - "raw": {runningMarker + ": Replacement seed", "⏳ Committed owner"}, - "truncated": {"🧵🐻 complete", "✅ Committed owner"}, - "renamed": {runningMarker + ": Replacement seed", "⏳ Manual user rename"}, - "named": {runningMarker + ": Replacement seed", "⏳ Explicit name"}, - } { - var output bytes.Buffer - pre := hookPayload("PreToolUse", id, "call-"+id, map[string]any{"title": call[0]}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != call[1] { - t.Fatalf("%s restart rewrite = %q, %v", id, output.String(), err) - } + installAppServerFixture(t, "onboarding-races") + result, err := runOnboarding(t.Context(), true, testActiveID) + if err == nil || result.Ready || result.OnboardingComplete || result.Total != 7 || result.Safe != 6 || + result.Updated != 1 || result.Unchanged != 2 || result.Skipped != 3 || result.Unconfirmed != 1 { + t.Fatalf("raced onboarding = %#v, %v", result, err) } - var pendingOutput bytes.Buffer - pendingPre := hookPayload("PreToolUse", "pending", "call-pending", map[string]any{"title": runningMarker + ": Replacement seed"}, nil) - if err := hook(context.Background(), strings.NewReader(pendingPre), &pendingOutput); err != nil || !strings.Contains(pendingOutput.String(), `"permissionDecision":"deny"`) { - t.Fatalf("unsettled proposal was not kept fail-closed: %q, %v", pendingOutput.String(), err) + requests := fixtureRequests(t) + if countFixtureTarget(requests, "thread/name/set", testAlphaID) != 1 || + countFixtureTarget(requests, "thread/name/set", testUnconfirmedID) != 1 || + countFixtureTarget(requests, "thread/name/set", testDriftID) != 0 || + countFixtureTarget(requests, "thread/name/set", testBlankAfterID) != 0 || + countFixtureTarget(requests, "thread/read", testRawID) != 0 { + t.Fatalf("raced setter calls = %#v", requests) } - for _, id := range []string{"fresh", "delegated"} { - var output bytes.Buffer - pre := hookPayload("PreToolUse", id, "terminal-"+id, map[string]any{"title": "🧵🐻 complete"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("%s ownerless terminal did not fail closed: %q, %v", id, output.String(), err) + for _, id := range []string{testActiveID, testDriftID, testBlankAfterID} { + if _, err := newStore(stateDir()).readTask(id); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("%s wrote subject state: %v", id, err) } } - saved, err := currentStateOrEmpty() - if err != nil || saved.Tasks["fresh"].Pending != nil || saved.Tasks["delegated"].Pending != nil { - t.Fatalf("ownerless terminal changed state: %#v, %v", saved, err) + if item := onboardingItemByID(t, result.Items, testUnconfirmedID); item.Outcome != onboardingUnconfirmed { + t.Fatalf("unconfirmed item = %#v", item) } } -func TestRunningMigrationControllerOwnsHistoricalFirstMessage(t *testing.T) { - root, db := testIndex(t) - first := "✅ ❔ echo hello" - addTask(t, db, root, "target", first, nil, "vscode", 0) - if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='target'`, first); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.MainTaskID, saved.ControllerTaskID, saved.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) +func TestUnsafeActiveOnboardingTaskDoesNotInflateSafeCount(t *testing.T) { + _, index := testIndex(t) + index.setRaw(t, testActiveID) + result, err := runOnboarding(t.Context(), false, testActiveID) + if err != nil || !result.Ready || result.Safe != 0 || result.NeedsUpdate != 0 || result.Unchanged != 1 || !result.OnboardingComplete { + t.Fatalf("unsafe active plan = %#v, %v", result, err) } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "other", "denied", map[string]any{"threadId": "target", "title": unknownMarker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("non-controller ownerless migration was not denied: %q, %v", output.String(), err) + item := onboardingItemByID(t, result.Items, testActiveID) + if item.Safe || item.Title != "" || item.Subject != "" || item.DesiredTitle != "" || item.Outcome != onboardingUnchanged { + t.Fatalf("unsafe active item = %#v", item) } - output.Reset() - pre = hookPayload("PreToolUse", "controller", "allowed", map[string]any{"threadId": "target", "title": unknownMarker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { +} + +func TestCurrentWriterCannotOutliveLifecycleFence(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Stable subject") + path := filepath.Join(stateDir(), "lifecycle.lock") + lifecycle, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { t.Fatal(err) } - proposed := rewrittenTitle(t, output.Bytes()) - if proposed != "❔ echo hello" { - t.Fatalf("controller migration title = %q", proposed) - } - response, _ := json.Marshal(map[string]string{"threadId": "target", "title": proposed}) - post := hookPayload("PostToolUse", "controller", "allowed", map[string]any{"threadId": "target", "title": proposed}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { + if err := unix.Flock(int(lifecycle.Fd()), unix.LOCK_EX); err != nil { t.Fatal(err) } - saved, _ := newStore(stateDir()).read() - if got := saved.Tasks["target"]; got.Subject != "echo hello" || got.Last != proposed || got.Pending != nil { - t.Fatalf("controller migration ownership = %#v", got) + done := make(chan error, 1) + go func() { + _, err := runCurrentTitle(context.Background(), testTaskID, "complete") + done <- err + }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "lifecycle is busy") { + unlock(lifecycle) + t.Fatalf("writer with exclusive lifecycle fence = %v", err) + } + case <-time.After(250 * time.Millisecond): + unlock(lifecycle) + t.Fatal("writer waited behind lifecycle teardown") + } + unlock(lifecycle) + if _, err := os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); err == nil { + requests := fixtureRequests(t) + if len(requests) != 0 { + t.Fatalf("busy writer started App Server: %#v", requests) + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) } } -func TestPendingMigrationRegistersExactRuntimeControllerFromMarkedHomeDelegation(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "runtime-controller", "Migration controller", nil, "vscode", 0) - first := "\nmain\n" + controllerMarker + " Follow the migration protocol.\n" - if _, err := db.Exec(`UPDATE threads SET thread_source='subagent', first_user_message=? WHERE id='runtime-controller'`, first); err != nil { - t.Fatal(err) - } - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) +func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPass(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testAlphaID, "Alpha") + index.setTitle(t, testOtherID, "Other") + installAppServerFixture(t, "onboarding-slow-readback") + + done := make(chan error, 1) + go func() { + _, err := runOnboarding(context.Background(), true, testActiveID) + done <- err + }() + deadline := time.Now().Add(time.Second) + for _, err := os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); errors.Is(err, os.ErrNotExist) && time.Now().Before(deadline); _, err = os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")) { + time.Sleep(10 * time.Millisecond) } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "runtime-controller", "register", map[string]any{"title": runningMarker + ": Migration controller"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("controller registration = %q, %v", output.String(), err) + for countFixtureMethod(fixtureRequests(t), "thread/name/set") == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) } - saved, err := newStore(stateDir()).read() - if err != nil || saved.Phase != phaseMigrationRunning || saved.ControllerTaskID != "runtime-controller" { - t.Fatalf("registered controller state = %#v, %v", saved, err) + if countFixtureMethod(fixtureRequests(t), "thread/name/set") == 0 { + t.Fatal("onboarding did not reach its first write") } -} -func TestPendingMigrationRejectsUnmarkedControllerClaim(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "other", "Other task", nil, "vscode", 0) - if _, err := db.Exec(`UPDATE threads SET first_user_message=' main Other work' WHERE id='other'`); err != nil { - t.Fatal(err) + locked := make(chan *os.File, 1) + lockErr := make(chan error, 1) + go func() { + lock, err := existingLifecycleLock("lifecycle.lock") + if err != nil { + lockErr <- err + return + } + locked <- lock + }() + select { + case lock := <-locked: + unlock(lock) + t.Fatal("replacement lifecycle entered during onboarding") + case err := <-lockErr: + t.Fatalf("replacement lifecycle failed while waiting: %v", err) + case <-time.After(50 * time.Millisecond): } - if _, err := install("main", false, true, false); err != nil { + if err := <-done; err != nil { t.Fatal(err) } - pre := hookPayload("PreToolUse", "other", "ordinary", map[string]any{"title": runningMarker + ": Other work"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &bytes.Buffer{}); err != nil { + select { + case lock := <-locked: + unlock(lock) + case err := <-lockErr: t.Fatal(err) - } - saved, _ := newStore(stateDir()).read() - if saved.Phase != phaseMigrationPending || saved.ControllerTaskID != "" { - t.Fatalf("unmarked task claimed controller: %#v", saved) + case <-time.After(time.Second): + t.Fatal("replacement lifecycle did not resume after onboarding") } } -func TestPendingMigrationRejectsMarkedOrdinaryTaskClaim(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "ordinary", "Ordinary task", nil, "vscode", 0) - first := "\nmain\n" + controllerMarker + " Follow the migration protocol.\n" - if _, err := db.Exec(`UPDATE threads SET thread_source='user', first_user_message=? WHERE id='ordinary'`, first); err != nil { - t.Fatal(err) - } - if _, err := install("main", false, true, false); err != nil { +func TestOnboardingDryRunDoesNotTakeLifecycleFence(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testAlphaID, "Alpha") + path := filepath.Join(stateDir(), "lifecycle.lock") + lifecycle, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { t.Fatal(err) } - pre := hookPayload("PreToolUse", "ordinary", "forged", map[string]any{"title": runningMarker + ": Migration controller"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &bytes.Buffer{}); err != nil { + if err := unix.Flock(int(lifecycle.Fd()), unix.LOCK_EX); err != nil { t.Fatal(err) } - saved, _ := newStore(stateDir()).read() - if saved.Phase != phaseMigrationPending || saved.ControllerTaskID != "" { - t.Fatalf("ordinary task claimed controller: %#v", saved) + result, err := runOnboarding(t.Context(), false, testActiveID) + unlock(lifecycle) + if err != nil || !result.Ready || !result.ReadOnly || !result.PlanComplete || result.NeedsUpdate != 1 { + t.Fatalf("dry-run under lifecycle operation = %#v, %v", result, err) } } -func TestControlTaskCleanupStagesAndCommitsStrippedSubject(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "target", "✅ ✅ ❔ hello", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.MainTaskID = "main" - return true, nil - }); err != nil { +func TestConfirmedOnboardingRefusesBusyLifecycleBeforeWrites(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testAlphaID, "Alpha") + path := filepath.Join(stateDir(), "lifecycle.lock") + lifecycle, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { t.Fatal(err) } - var output bytes.Buffer - denied := hookPayload("PreToolUse", "other", "denied", map[string]any{"threadId": "target", "title": cleanupMarker}, nil) - if err := hook(context.Background(), strings.NewReader(denied), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("non-control cleanup was not denied: %q, %v", output.String(), err) - } - output.Reset() - pre := hookPayload("PreToolUse", "main", "cleanup", map[string]any{"threadId": "target", "title": cleanupMarker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + if err := unix.Flock(int(lifecycle.Fd()), unix.LOCK_EX); err != nil { t.Fatal(err) } - proposed := rewrittenTitle(t, output.Bytes()) - if proposed != "hello" { - t.Fatalf("cleanup title = %q", proposed) + started := time.Now() + result, err := runOnboarding(t.Context(), true, testActiveID) + unlock(lifecycle) + if err == nil || !strings.Contains(err.Error(), "lifecycle is busy") || result.Ready || result.PlanComplete { + t.Fatalf("confirmed onboarding under lifecycle operation = %#v, %v", result, err) } - response, _ := json.Marshal(map[string]string{"threadId": "target", "title": proposed}) - post := hookPayload("PostToolUse", "main", "cleanup", map[string]any{"threadId": "target", "title": proposed}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - t.Fatal(err) + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("confirmed onboarding waited behind lifecycle operation for %s", elapsed) } - saved, _ := newStore(stateDir()).read() - if got := saved.Tasks["target"]; got.Subject != "hello" || got.Last != "hello" || got.Pending != nil { - t.Fatalf("cleanup ownership = %#v", got) + if _, err := os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); err == nil { + if requests := fixtureRequests(t); len(requests) != 0 { + t.Fatalf("busy confirmed onboarding started App Server: %#v", requests) + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) } } -func TestControlTaskCleanupHandlesIconOnlyAndLiteralEmoji(t *testing.T) { - root, db := testIndex(t) - for id, title := range map[string]string{"icons": "❔ ❔ ❔", "emoji": "🎉 ✅ user title", "main": "✅ ✅ ThreadBear"} { - addTask(t, db, root, id, title, nil, "vscode", 0) - } - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.MainTaskID = "main" - return true, nil - }); err != nil { - t.Fatal(err) - } - for id, want := range map[string]string{"icons": "Untitled task", "emoji": "🎉 ✅ user title", "main": "ThreadBear"} { - var output bytes.Buffer - input := map[string]any{"title": cleanupMarker} - if id != "main" { - input["threadId"] = id - } - pre := hookPayload("PreToolUse", "main", "cleanup-"+id, input, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != want { - t.Fatalf("%s cleanup = %q, %v", id, output.String(), err) +func onboardingItemByID(t testing.TB, items []onboardingItem, id string) onboardingItem { + t.Helper() + for _, item := range items { + if item.TaskID == id { + return item } } + t.Fatalf("missing onboarding item %q", id) + return onboardingItem{} } -func TestMigrationControllerStripsLegacyIconsFromNamedSubject(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "target", "ignored", "✅ ✅ Named subject", "vscode", 0) - if err := newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.MainTaskID, saved.ControllerTaskID, saved.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { +func fixtureRequests(t testing.TB) []fixtureMessage { + t.Helper() + file, err := os.Open(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")) + if err != nil { t.Fatal(err) } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "controller", "migration", map[string]any{"threadId": "target", "title": unknownMarker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != "❔ Named subject" { - t.Fatalf("migration cleanup = %q, %v", output.String(), err) + defer file.Close() + decoder := json.NewDecoder(file) + var requests []fixtureMessage + for { + var request fixtureMessage + if err := decoder.Decode(&request); err == io.EOF { + return requests + } else if err != nil { + t.Fatal(err) + } + requests = append(requests, request) } } -func TestPostMismatchFailsClosed(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", "Subject", nil, "vscode", 0) - _ = db - _ = newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }) - var output bytes.Buffer - pre := hookPayload("PreToolUse", "task", "call", map[string]any{"title": runningMarker + ": Subject"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { +func clearFixtureRequests(t testing.TB) { + t.Helper() + if err := os.Remove(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); err != nil { t.Fatal(err) } - title := rewrittenTitle(t, output.Bytes()) - wrong, _ := json.Marshal(map[string]string{"threadId": "other", "title": title}) - post := hookPayload("PostToolUse", "task", "call", map[string]any{"title": title}, string(wrong)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err == nil { - t.Fatal("mismatched native result was accepted") - } - saved, _ := newStore(stateDir()).read() - if saved.Tasks["task"].Pending == nil || saved.Tasks["task"].Last != "" { - t.Fatalf("mismatch committed state: %#v", saved.Tasks["task"]) - } - extra := `{"threadId":"task","title":` + string(mustJSON(title)) + `,"extra":true}` - post = hookPayload("PostToolUse", "task", "call", map[string]any{"title": title}, extra) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err == nil { - t.Fatal("native result with extra fields was accepted") - } } -func TestBulkMarkerRereadsExplicitTargetAndAdoptsRename(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "target", "Bulk subject", nil, "vscode", 0) - _ = newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }) - pre := hookPayload("PreToolUse", "installer", "bulk-1", map[string]any{"threadId": "target", "title": "🧵🐻 complete"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - desired := rewrittenTitle(t, output.Bytes()) - if desired != "✅ Bulk subject" { - t.Fatalf("bulk title = %q", desired) - } - saved, _ := newStore(stateDir()).read() - if pending := saved.Tasks["target"].Pending; pending.ToolUseID != "bulk-1" || pending.CallerTaskID != "installer" { - t.Fatal("bulk proposal was not bound to native call") - } - response, _ := json.Marshal(map[string]string{"threadId": "target", "title": desired}) - post := hookPayload("PostToolUse", "installer", "bulk-1", map[string]any{"threadId": "target", "title": desired}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET name='User renamed' WHERE id='target'`); err != nil { - t.Fatal(err) - } - pre = hookPayload("PreToolUse", "installer", "bulk-2", map[string]any{"threadId": "target", "title": "🧵🐻 blocked (external): restore the signing service"}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - if got := rewrittenTitle(t, output.Bytes()); got != "🚨 User renamed → restore the signing service" { - t.Fatalf("bulk rename was not adopted: %q", got) +func countFixtureMethod(requests []fixtureMessage, method string) int { + count := 0 + for _, request := range requests { + if request.Method == method { + count++ + } } + return count } -func TestHookRejectsOversizedInput(t *testing.T) { - if err := hook(context.Background(), strings.NewReader(strings.Repeat("x", maxHookBytes+1)), &bytes.Buffer{}); err == nil { - t.Fatal("oversized hook input accepted") +func fixtureMethod(requests []fixtureMessage, method string, at int) fixtureMessage { + for _, request := range requests { + if request.Method == method { + if at == 0 { + return request + } + at-- + } } + return fixtureMessage{} } -func hookPayload(event, session, call string, input map[string]any, response any) string { - value := map[string]any{ - "cwd": "/tmp", "hook_event_name": event, "model": "test", "permission_mode": "bypassPermissions", - "session_id": session, "tool_input": input, "tool_name": titleTool, "tool_use_id": call, - "transcript_path": "/tmp/rollout.jsonl", "turn_id": "turn", - } - if response != nil { - value["tool_response"] = response +func countFixtureTarget(requests []fixtureMessage, method, taskID string) int { + count := 0 + for _, request := range requests { + if request.Method != method { + continue + } + var id string + _ = json.Unmarshal(request.Params["threadId"], &id) + if id == taskID { + count++ + } } - data, _ := json.Marshal(value) - return string(data) + return count } -func rewrittenTitle(t testing.TB, data []byte) string { - t.Helper() - var value struct { - Hook struct { - Updated map[string]json.RawMessage `json:"updatedInput"` - } `json:"hookSpecificOutput"` - } - if err := json.Unmarshal(data, &value); err != nil { +func TestNoSQLiteDependency(t *testing.T) { + root, index := testIndex(t) + index.setTitle(t, testTaskID, "Visible") + if _, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err != nil { t.Fatal(err) } - title, err := stringField(value.Hook.Updated, "title", true) - if err != nil { - t.Fatal(err) + if matches, err := filepath.Glob(filepath.Join(root, "**", "state_*.sqlite")); err != nil || len(matches) != 0 { + t.Fatalf("SQLite appeared: %#v, %v", matches, err) + } + if strings.Contains(index.title(t, testTaskID), "state_") { + t.Fatal("unexpected fixture corruption") } - return title -} - -func rolloutLine(kind string, payload any) string { - data, _ := json.Marshal(map[string]any{"type": kind, "payload": payload}) - return string(data) + "\n" -} - -func mustJSON(value any) []byte { - data, _ := json.Marshal(value) - return data } diff --git a/cmd/threadbear/hook.go b/cmd/threadbear/hook.go deleted file mode 100644 index dd7abd0..0000000 --- a/cmd/threadbear/hook.go +++ /dev/null @@ -1,206 +0,0 @@ -package main - -import ( - "cmp" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "strings" -) - -const titleTool, runningMarker, homeTitle, mainTitle, cleanupMarker, unknownMarker, controllerMarker, maxHookBytes = "codex_appset_thread_title", "⏳ ThreadBear is working", "🧵🐻 ThreadBear 🐻🧵", "ThreadBear", "🧵🐻 strip title icons", "❔ ThreadBear could not classify", "ThreadBear controller registration.", 1 << 20 - -type hookInput struct { - Event string `json:"hook_event_name"` - SessionID string `json:"session_id"` - ToolName string `json:"tool_name"` - ToolUseID string `json:"tool_use_id"` - ToolInput map[string]json.RawMessage `json:"tool_input"` - ToolResponse json.RawMessage `json:"tool_response"` -} - -func readBoundedJSON(r io.Reader, value any) error { - data, err := io.ReadAll(io.LimitReader(r, maxHookBytes+1)) - if len(data) > maxHookBytes { - return errors.Join(err, errors.New("input exceeds 1 MiB")) - } - return errors.Join(err, json.Unmarshal(data, value)) -} -func stringField(values map[string]json.RawMessage, key string, required bool) (string, error) { - raw, ok := values[key] - if !ok && !required { - return "", nil - } - var value string - if !ok || strings.TrimSpace(string(raw)) == "null" || json.Unmarshal(raw, &value) != nil { - return "", fmt.Errorf("tool input %s must be a string", key) - } - return value, nil -} -func titleTarget(event hookInput) (string, string, error) { - title, titleErr := stringField(event.ToolInput, "title", true) - target, targetErr := stringField(event.ToolInput, "threadId", false) - return title, cmp.Or(target, event.SessionID), errors.Join(titleErr, targetErr) -} -func hook(ctx context.Context, in io.Reader, out io.Writer) error { - var event hookInput - if err := readBoundedJSON(in, &event); err != nil { - return err - } - if event.ToolName != titleTool { - return nil - } - store := newStore(stateDir()) - titleLock, err := store.titleLock() - if err != nil { - return err - } - defer unlock(titleLock) - if _, err = store.read(); err != nil && event.Event != "PreToolUse" { - return err - } - switch event.Event { - case "PreToolUse": - if err == nil { - err = preTitle(ctx, event, out) - } - if err != nil { - return json.NewEncoder(out).Encode(map[string]any{"hookSpecificOutput": map[string]any{ - "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "ThreadBear could not safely prepare this title: " + err.Error(), - }}) - } - return nil - case "PostToolUse": - return postTitle(event) - default: - return fmt.Errorf("unsupported hook event %q", event.Event) - } -} -func preTitle(ctx context.Context, event hookInput, out io.Writer) error { - raw, target, err := titleTarget(event) - title, attempt, tagged := strings.Cut(raw, "⁣") - if err != nil { - return err - } - result, terminal := parseFooter(title) - seed, seeded := strings.CutPrefix(title, runningMarker+": ") - if seeded && (seed == "" || seed != strings.Join(strings.Fields(seed), " ") || seed != truncateUTF16(seed, 58)) { - return errors.New("invalid running subject seed") - } - if seeded { - result, terminal = footer{Status: "running"}, true - } else if title == unknownMarker { - result, terminal = footer{Status: "unknown"}, true - } else if title == cleanupMarker { - result, terminal = footer{Status: "cleanup"}, true - } else if title == homeTitle { - terminal, seed = true, homeTitle - } - if !terminal { - if tagged { - title, attempt = raw, "" - } - if title != homeTitle && (strings.HasPrefix(title, runningMarker) || strings.HasPrefix(title, "🧵🐻 ")) { - return errors.New("invalid ThreadBear marker") - } - _, err = stageTitle(ctx, target, "", "", title, event.SessionID, event.ToolUseID, "") - return err - } - proposed, err := stageTitle(ctx, target, result.Status, result.Action, seed, event.SessionID, event.ToolUseID, attempt) - if err != nil { - return err - } - event.ToolInput["title"], _ = json.Marshal(proposed) - return json.NewEncoder(out).Encode(map[string]any{"hookSpecificOutput": map[string]any{ - "hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": event.ToolInput, - }}) -} -func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID, attempt string) (string, error) { - task, found, err := oneTask(ctx, id) - if status == "" { - known, ok, readErr := archiveTaskByID(ctx, id) - task, found, err = indexedTask{Title: known.Title}, ok, readErr - } - if err != nil || !found { - return "", errors.Join(err, errors.New("task is not active in Codex")) - } - first := strings.Join(strings.Fields(task.FirstMessage), " ") - var proposed string - err = newStore(stateDir()).update(func(saved *state) (bool, error) { - if saved.Phase == phaseMigrationPending && saved.ControllerTaskID == "" && id == caller && status == "running" && task.ThreadSource == "subagent" && strings.HasPrefix(first, " "+saved.MainTaskID+" "+controllerMarker) { - saved.ControllerTaskID, saved.Phase = caller, phaseMigrationRunning - } - if pending := saved.UninstallPending; saved.Phase == phaseMigrationFailed && pending == nil || pending != nil && (pending.InitiatorTaskID != caller || status != "cleanup") { - return false, errors.New("title changes are paused for failed migration or prepared uninstall") - } - record := saved.Tasks[id] - if record.Pending != nil { - return false, errors.New("native title operation is already pending") - } - current := strings.Join(strings.Fields(task.Title), " ") - subject := canonicalSubject(task.Title, record) - if status == "" { - subject = strings.Join(strings.Fields(seed), " ") - } - if record.Subject == "" && saved.Phase == phaseMigrationRunning && saved.ControllerTaskID == caller && caller != id { - subject = stripStatusIcons(subject) - } - if status == "cleanup" { - owner := saved.UninstallPending != nil && saved.UninstallPending.InitiatorTaskID == caller - if saved.MainTaskID != caller && !owner { - return false, errors.New("title cleanup requires the ThreadBear control task") - } - subject = cmp.Or(map[bool]string{true: record.Original}[stripStatusIcons(task.Title) == homeTitle], stripStatusIcons(task.Title), "Untitled task") - } else if status != "" && task.Name == "" && first != "" && (current == first || current == truncateUTF16(first, 60)) { - subject = record.Subject - if record.Pending != nil && record.Pending.BaseSubject != "" { - subject = record.Pending.BaseSubject - } - if subject == "" && status == "running" { - subject = seed - } - if subject == "" && saved.Phase == phaseMigrationRunning && saved.ControllerTaskID == caller && caller != id { - subject = stripStatusIcons(current) - } - if subject == "" { - return false, errors.New("fresh task has no subject owner") - } - } - proposed = map[bool]string{true: mainTitle, false: map[bool]string{true: seed, false: renderTitle(status, subject, action)}[status == ""]}[id == saved.MainTaskID && status != ""] - if status == "" && seed == homeTitle && attempt == "" { - record.Original, record.Last = cmp.Or(record.Original, stripStatusIcons(current)), homeTitle - saved.Tasks[id] = record - return true, nil - } - record.Pending = &pendingProposal{CallerTaskID: caller, ToolUseID: toolUseID, BaseSubject: map[bool]string{true: mainTitle, false: subject}[id == saved.MainTaskID && status != ""], Prior: task.Title, Proposed: proposed, Status: status, Action: action, Attempt: attempt} - saved.Tasks[id] = record - return true, nil - }) - return proposed, err -} -func postTitle(event hookInput) error { - title, target, err := titleTarget(event) - if err != nil { - return err - } - return newStore(stateDir()).update(func(saved *state) (bool, error) { - record := saved.Tasks[target] - if record.Pending == nil { - return false, nil - } - pending := record.Pending - if pending.ToolUseID != event.ToolUseID || pending.CallerTaskID != "" && pending.CallerTaskID != event.SessionID || pending.Proposed != title { - return false, errors.New("native title call does not match its proposal") - } - result, encoded := map[string]string{}, "" - if json.Unmarshal(event.ToolResponse, &encoded) != nil || json.Unmarshal([]byte(encoded), &result) != nil || len(result) != 2 || result["threadId"] != target || result["title"] != title { - return false, errors.New("native title result mismatch") - } - record.Subject, record.Last, record.Status, record.Action, record.Pending = pending.BaseSubject, pending.Proposed, pending.Status, pending.Action, nil - saved.Tasks[target] = record - return true, nil - }) -} diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index bd10b90..cc2e880 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -1,489 +1,993 @@ package main import ( - "cmp" + "bytes" "context" "encoding/json" "errors" "fmt" - "github.com/ericlitman/threadbear/assets" - "maps" "os" "path/filepath" "runtime" - "slices" "strings" "syscall" + + "github.com/ericlitman/threadbear/assets" + "golang.org/x/sys/unix" ) -const blockStart, blockEnd, managedHeading, managedProtocol = "", "", "# ThreadBear", "For every ordinary interactive turn" +const ( + blockStart = "" + blockEnd = "" + legacyAutomationID = "threadbear-maintenance" + legacyAutomationName = "ThreadBear maintenance" + legacyAutomationKind = "heartbeat" + legacyTitleTool = "codex_appset_thread_title" +) + +type lifecyclePaths struct { + binary, agents, skill, launchAgent, updateReceipt string +} + +type installOptions struct { + DryRun, Confirmed, Reset, NoOnboard, Automatic bool + SelectedVersion string +} + +type uninstallOptions struct { + DryRun, Confirmed bool +} + +type legacyInstall struct{ MainTaskID string } -type lifecyclePaths struct{ binary, agents, skill, hooks string } -type rawObject map[string]json.RawMessage +var postResetStatus = status -func hasPendingTitle(value state) bool { - return slices.ContainsFunc(slices.Collect(maps.Values(value.Tasks)), func(task taskState) bool { return task.Pending != nil }) +func codexHome() string { + if value := strings.TrimSpace(os.Getenv("CODEX_HOME")); value != "" { + return value + } + return filepath.Join(homeDir(), ".codex") } -func codexHome() string { return cmp.Or(os.Getenv("CODEX_HOME"), filepath.Join(homeDir(), ".codex")) } -func homeDir() string { home, _ := os.UserHomeDir(); return home } -func stateDir() string { return filepath.Join(homeDir(), ".local", "share", "threadbear") } + +func homeDir() string { home, _ := os.UserHomeDir(); return home } + +func stateDir() string { return filepath.Join(homeDir(), ".local", "share", "threadbear") } + +func legacyHooksPath() string { return filepath.Join(codexHome(), "hooks.json") } + func installPaths() lifecyclePaths { - return lifecyclePaths{filepath.Join(homeDir(), ".local/bin/threadbear"), filepath.Join(codexHome(), "AGENTS.md"), filepath.Join(codexHome(), "skills/threadbear/SKILL.md"), filepath.Join(codexHome(), "hooks.json")} + return lifecyclePaths{ + binary: filepath.Join(homeDir(), ".local", "bin", "threadbear"), + agents: filepath.Join(codexHome(), "AGENTS.md"), + skill: filepath.Join(codexHome(), "skills", "threadbear", "SKILL.md"), + launchAgent: updateAgentPath(), + updateReceipt: filepath.Join(stateDir(), "update.json"), + } } -func install(controlTaskID string, dry, confirmed, debugCanaries bool) (any, error) { - controlTaskID = strings.TrimSpace(controlTaskID) - value, err := currentStateOrEmpty() - if err != nil { + +func install(ctx context.Context, options installOptions) (any, error) { + if _, err := selfTest(); err != nil { return nil, err } - mainTaskID := value.MainTaskID - if mainTaskID != "" && controlTaskID != "" && controlTaskID != mainTaskID { - return nil, fmt.Errorf("install would replace persisted ThreadBear task %q with %q", mainTaskID, controlTaskID) + if options.DryRun && options.Confirmed { + return nil, errors.New("install preview cannot also be confirmed") } - if mainTaskID == "" { - if controlTaskID == "" { - return nil, errors.New("first install requires --control-task-id for the active ThreadBear task") - } - mainTaskID = controlTaskID + if options.Automatic && (options.DryRun || options.Reset) { + return nil, errors.New("automatic install accepts neither preview nor legacy reset") } - p := installPaths() - hooks, write, err := editHooks(p.hooks, p.binary, true) + if options.SelectedVersion != "" && options.SelectedVersion != version { + return nil, fmt.Errorf("installer selected version %q but candidate is %q", options.SelectedVersion, version) + } + if options.Automatic { + options.NoOnboard = true + } + source, err := os.Executable() if err != nil { return nil, err } - if dry { - phase := cmp.Or(value.Phase, phaseMigrationPending) - result := map[string]any{"ready": true, "dry_run": true, "main_task_id": mainTaskID, "phase": phase, "controller_task_id": value.ControllerTaskID, "controller_required": phase == phaseMigrationPending, "debug_canaries": true} - maps.DeleteFunc(result, func(key string, _ any) bool { return key == "debug_canaries" && !debugCanaries }) - return result, nil + binary, err := os.ReadFile(source) + if err != nil { + return nil, err } - if !confirmed { - return nil, errors.New("install requires --noninteractive --confirm after its preview") + p := installPaths() + legacy, legacyFound, err := readLegacyInstall() + if err != nil { + return nil, err } - titleLock, lockErr := newStore(stateDir()).installLock() - if lockErr != nil { - return nil, lockErr + preview := installResult(options, legacy, legacyFound, options.DryRun) + if err := preflightInstall(ctx, p, binary, legacyFound); err != nil { + return preview, err } - defer unlock(titleLock) - if value, err = currentStateOrEmpty(); err != nil { - return nil, err + if options.DryRun { + return preview, nil } - if value.MainTaskID != "" && value.MainTaskID != mainTaskID { - return nil, errors.New("persisted ThreadBear task changed during install") + if !options.Confirmed { + return preview, errors.New("install requires --noninteractive --confirm after its preview") } - if hasPendingTitle(value) { - return nil, errors.New("install requires all native title operations to settle") + + var updateLock *os.File + if !options.Automatic { + updateLock, err = lifecycleLock("update.lock") + if err != nil { + return preview, err + } + defer unlock(updateLock) } - main, found, taskErr := archiveTaskByID(context.Background(), mainTaskID) - if taskErr != nil || !found || !main.User { - return nil, errors.Join(taskErr, errors.New("persisted ThreadBear control task is not available in Codex")) + var lock *os.File + if options.Automatic { + lock, err = existingLifecycleLock("lifecycle.lock") + } else { + lock, err = lifecycleLock("lifecycle.lock") } - source, err := os.Executable() if err != nil { - return nil, err + return preview, err } - binary, err := os.ReadFile(source) + defer unlock(lock) + legacy, legacyFound, err = readLegacyInstall() if err != nil { - return nil, err + return preview, err } - if err = writeAtomic(p.binary, binary, 0o755); err == nil { - err = newStore(stateDir()).update(func(saved *state) (bool, error) { - changed := saved.MainTaskID != mainTaskID || saved.Format != stateFormat - record := saved.Tasks[mainTaskID] - if record.Original == "" { - record.Original = map[bool]string{true: main.Title, false: cmp.Or(map[bool]string{true: cmp.Or(map[bool]string{true: stripStatusIcons(record.Subject)}[stripStatusIcons(record.Subject) != homeTitle], "ThreadBear")}[stripStatusIcons(main.Title) == homeTitle], stripStatusIcons(main.Title))}[saved.MainTaskID == ""] - saved.Tasks[mainTaskID] = record - } - saved.MainTaskID, saved.Format = mainTaskID, stateFormat - if saved.Phase == "" { - saved.Phase, changed = phaseMigrationPending, true - } - value = *saved - return changed, nil - }) + if legacyFound && !options.Reset { + return preview, errors.New("legacy 2.2.1 state requires guided removal of threadbear-maintenance and install --reset") } - if err == nil { - err = manageBlock(p.agents, assets.AgentsManagedContent) + if !legacyFound && options.Reset { + return preview, errors.New("--reset is only valid for an exact legacy native.json installation") } - if err == nil { - err = writeAtomic(p.skill, []byte(assets.SkillManagedContent), 0o600) + if options.Automatic { + if err := requireCurrentFormatInstall(p); err != nil { + return preview, fmt.Errorf("automatic install refused because the current installation disappeared or is legacy: %w", err) + } } - if err == nil && write { - err = writeAtomic(p.hooks, hooks, 0o600) + if err := preflightInstall(ctx, p, binary, legacyFound); err != nil { + return preview, err } - result := map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete, "installed": err == nil, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "phase": value.Phase, "controller_required": err == nil && value.Phase == phaseMigrationPending, "debug_canaries": true} - maps.DeleteFunc(result, func(key string, _ any) bool { return key == "debug_canaries" && !debugCanaries }) - return result, err -} -func prepareUninstall(ctx context.Context, initiatorTaskID string) (any, error) { - if initiatorTaskID = strings.TrimSpace(initiatorTaskID); initiatorTaskID == "" { - return nil, errors.New("uninstall prepare requires the active initiating task ID") + agents, removeAgents, agentsChanged, err := editManagedBlock(p.agents, true, legacyFound || currentInstallPresent(p)) + if err != nil || removeAgents { + return preview, errors.Join(err, errors.New("managed AGENTS block could not be prepared")) } - return withUninstallLocks(func() (any, error) { - value, err := newStore(stateDir()).read() - if err != nil { - return nil, err - } - if value.Phase != phaseMigrationComplete && value.Phase != phaseMigrationFailed && (value.Phase != phaseMigrationPending || value.ControllerTaskID != "" || hasPendingTitle(value)) || value.ArchivePending != nil { - return nil, errors.New("uninstall requires a completed, stopped failed, or quiescent pre-controller installation with no pending archive") + if err := os.MkdirAll(newStore(stateDir()).subjectDir(), 0o700); err != nil { + return installPartial(preview, "subject_state", false, options), err + } + if agentsChanged { + if err := writeAtomic(p.agents, agents, 0o600); err != nil { + return installPartial(preview, "managed_guidance", true, options), err } - initiator, found, err := archiveTaskByID(ctx, initiatorTaskID) - if err != nil || !found || !initiator.User || !initiator.Visible || initiator.Archived { - return nil, errors.Join(err, errors.New("uninstall initiator is not an active user task in Codex")) + } + if err := writeAtomic(p.skill, []byte(assets.SkillManagedContent), 0o600); err != nil { + return installPartial(preview, "skill", true, options), err + } + if legacyFound { + hooks, changed, remove, cleanupErr := removeLegacyHooks(legacyHooksPath(), p.binary) + if cleanupErr != nil { + return installPartial(preview, "legacy_hook", true, options), cleanupErr } - if pending := value.UninstallPending; pending != nil { - if pending.InitiatorTaskID != initiatorTaskID || pending.MainTaskID != value.MainTaskID || pending.ControllerTaskID != value.ControllerTaskID { - return nil, errors.New("uninstall is already owned by another task or installation identity") + if changed { + if remove { + cleanupErr = os.Remove(legacyHooksPath()) + } else { + cleanupErr = writeAtomic(legacyHooksPath(), hooks, 0o600) } - reconciled, err := reconcileTitles(ctx, "") - if err != nil { - return nil, err + if cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) { + return installPartial(preview, "legacy_hook", true, options), cleanupErr } - return map[string]any{"ready": true, "prepared": true, "resumed": true, "reconciled_titles": reconciled, "initiator_task_id": pending.InitiatorTaskID, "main_task_id": pending.MainTaskID, "main_archived": pending.MainArchived, "controller_task_id": pending.ControllerTaskID}, nil - } - reconciled, err := reconcileTitles(ctx, "") - if err != nil { - return nil, err } - main, found, err := archiveTaskByID(ctx, value.MainTaskID) - if err != nil || !found || !main.User { - return nil, errors.Join(err, errors.New("persisted ThreadBear control task is not available in Codex")) + } + if err := installUpdateAgent(ctx, p.launchAgent, p.binary); err != nil { + return installPartial(preview, "updater", true, options), err + } + // Replacement is last: any earlier failure leaves the previously installed + // executable active, and a fresh non-RunAtLoad job cannot invoke a partial + // installation. + if err := writeAtomic(p.binary, binary, 0o755); err != nil { + return installPartial(preview, "binary", true, options), err + } + + checked, checkErr := statusAllowingLegacy(ctx, options.Reset) + legacyCleanupCommitted := false + if checkErr == nil && options.Reset { + if checkErr = clearLegacyState(); checkErr == nil { + legacyCleanupCommitted = true + checked, checkErr = postResetStatus(ctx) } - pending := &uninstallOperation{InitiatorTaskID: initiatorTaskID, MainTaskID: value.MainTaskID, MainArchived: main.Archived, ControllerTaskID: value.ControllerTaskID} - err = newStore(stateDir()).update(func(saved *state) (bool, error) { - saved.UninstallPending = pending - return true, nil - }) - if err != nil { - return nil, err + } + result := installResult(options, legacyInstall{}, false, false) + for key, value := range checked.(map[string]any) { + if key == "ready" || key == "installed" || key == "automatic_updates_enabled" { + result[key] = value } - return map[string]any{"ready": true, "prepared": true, "resumed": false, "reconciled_titles": reconciled, "initiator_task_id": pending.InitiatorTaskID, "main_task_id": pending.MainTaskID, "main_archived": pending.MainArchived, "controller_task_id": pending.ControllerTaskID}, nil - }) -} -func reconcileTitles(ctx context.Context, caller string) (count int, err error) { - err = newStore(stateDir()).update(func(value *state) (bool, error) { - current, attempt := caller != "" && (value.Phase == phaseMigrationPending || value.Phase == phaseMigrationRunning || value.Phase == phaseMigrationComplete) && value.UninstallPending == nil && os.Getenv("CODEX_THREAD_ID") == caller, os.Getenv("THREADBEAR_TITLE_ATTEMPT") - if attempt != "" && !current { - return false, errors.New("title recovery requires its exact active current task") + } + result["restart_required"] = checkErr == nil + if checkErr != nil { + stage := "status" + if options.Reset && !legacyCleanupCommitted { + stage = "legacy_cleanup" + result["legacy_reset_required"] = true + result["legacy_main_task_id"] = legacy.MainTaskID + result["legacy_automation_id"] = legacyAutomationID + result["legacy_automation_name"] = legacyAutomationName + result["legacy_automation_kind"] = legacyAutomationKind + result["legacy_automation_target_thread_id"] = legacy.MainTaskID } - for id, record := range value.Tasks { - pending := record.Pending - if pending == nil { - continue - } - if current && id != caller { - continue - } - if caller != "" && (pending.CallerTaskID != caller || value.Phase == phaseMigrationFailed && os.Getenv("CODEX_THREAD_ID") != caller && (value.MigrationFailure != "controller reported a settled migration failure" || os.Getenv("CODEX_THREAD_ID") != value.MainTaskID)) { - return false, fmt.Errorf("pending native title operation for task %q is not owned by this migration controller", id) - } - task, found, readErr := archiveTaskByID(ctx, id) - if readErr != nil { - return false, readErr - } - applied := found && task.User && task.Visible && pending.Prior != pending.Proposed && task.Title == pending.Proposed - cleared := pending.CallerTaskID == value.ControllerTaskID && value.MigrationFailure == "controller reported a settled migration failure" && (!found || task.Title == pending.Prior) || current && pending.Attempt != "" && pending.Attempt == attempt && found && task.User && task.Visible && !task.Archived && task.Title == pending.Prior - if !applied && !cleared { - return false, fmt.Errorf("native title operation for task %q has not settled; wait for its exact PostToolUse result", id) - } - if applied { - record.Subject, record.Last, record.Status, record.Action = pending.BaseSubject, pending.Proposed, pending.Status, pending.Action - } - count, record.Pending = count+1, nil - value.Tasks[id] = record + partial := installPartial(result, stage, true, options) + if legacyCleanupCommitted { + partial["safe_rerun"] = confirmedInstallRerun(p, options.NoOnboard) } - return count > 0, map[bool]error{true: errors.New("title recovery found no matching pending operation")}[attempt != "" && count == 0] - }) - return count, err + return partial, checkErr + } + return result, nil +} + +func installResult(options installOptions, legacy legacyInstall, legacyFound, dry bool) map[string]any { + onboarding := !options.NoOnboard && !options.Automatic + result := map[string]any{ + "ready": dry, + "installed": false, + "version": version, + "dry_run": dry, + "legacy_reset_required": legacyFound, + "reset": options.Reset, + "onboarding_requested": onboarding, + "automatic_updates_enabled": false, + "restart_required": false, + "partial": false, + "planned_changes": installChanges(installPaths(), legacyFound), + } + if legacyFound { + result["legacy_main_task_id"] = legacy.MainTaskID + result["legacy_automation_id"] = legacyAutomationID + result["legacy_automation_name"] = legacyAutomationName + result["legacy_automation_kind"] = legacyAutomationKind + result["legacy_automation_target_thread_id"] = legacy.MainTaskID + } + if onboarding { + result["next_request"] = "threadbear onboard --dry-run --json" + } + return result } -func completeUninstall(ctx context.Context, initiatorTaskID string, confirmed, abort bool) (any, error) { - if !confirmed && !abort { - return nil, errors.New("uninstall requires --noninteractive --confirm") + +func installPartial(result map[string]any, stage string, restart bool, options installOptions) map[string]any { + rerun := "repeat the same confirmed install command" + if options.Automatic { + rerun = quoteArgument(installPaths().binary) + " update --json" } - if initiatorTaskID = strings.TrimSpace(initiatorTaskID); initiatorTaskID == "" { - return nil, errors.New("uninstall requires the initiating task ID") + return partialResult(result, stage, restart, rerun) +} + +func confirmedInstallRerun(p lifecyclePaths, noOnboard bool) string { + command := quoteArgument(p.binary) + " install" + if noOnboard { + command += " --no-onboard" } - if !abort { - if committed, err := finishCommittedUninstall(); err != nil || committed { - return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err - } + return command + " --noninteractive --confirm --json" +} + +func installChanges(p lifecyclePaths, legacy bool) []string { + changes := []string{} + if legacy { + changes = append(changes, + "remove legacy state "+filepath.Join(stateDir(), "native.json"), + "remove legacy locks native.lock, title.lock, and operation.lock under "+stateDir(), + "remove exact legacy ThreadBear title hooks from "+legacyHooksPath()) + } + changes = append(changes, + "manage subject records under "+newStore(stateDir()).subjectDir(), + "manage update receipt "+p.updateReceipt, + "replace managed AGENTS block in "+p.agents, + "write skill "+p.skill, + "install "+updateAgentLabel+" LaunchAgent "+p.launchAgent, + "write binary "+p.binary) + return changes +} + +func onboard(ctx context.Context, dryRun, confirmed bool) (any, error) { + if dryRun && confirmed { + return nil, errors.New("onboarding preview cannot also be confirmed") + } + if !dryRun && !confirmed { + return nil, errors.New("onboarding requires --dry-run or --noninteractive --confirm") + } + p := installPaths() + if err := requireCurrentFormatInstall(p); err != nil { + return nil, fmt.Errorf("onboarding requires the current ThreadBear installation: %w", err) + } + return runOnboarding(ctx, confirmed, os.Getenv("CODEX_THREAD_ID")) +} + +func uninstall(ctx context.Context, options uninstallOptions) (any, error) { + if options.DryRun && options.Confirmed { + return nil, errors.New("uninstall preview cannot also be confirmed") + } + preview := map[string]any{ + "ready": true, "dry_run": options.DryRun, "uninstalled": false, + "icons_may_remain": true, "restart_required": false, "partial": false, + "warning": "Existing ThreadBear title icons may remain until renamed.", + "planned_changes": uninstallChanges(installPaths()), + } + p := installPaths() + partialAdmission, err := preflightUninstall(ctx, p) + if err != nil { + return preview, err + } + if options.DryRun { + return preview, nil } - return withUninstallLocks(func() (any, error) { - value, err := newStore(stateDir()).read() + if !options.Confirmed { + return preview, errors.New("uninstall requires --noninteractive --confirm after its preview") + } + var lock *os.File + if !partialAdmission { + lock, err = existingLifecycleLock("lifecycle.lock") if err != nil { - return nil, err + return preview, err } - pending := value.UninstallPending - if pending == nil || pending.InitiatorTaskID != initiatorTaskID || pending.MainTaskID != value.MainTaskID || pending.ControllerTaskID != value.ControllerTaskID { - return nil, errors.New("uninstall commit requires the exact prepared initiating task") + } + defer func() { + if lock != nil { + unlock(lock) } - main, found, err := archiveTaskByID(ctx, pending.MainTaskID) - if err != nil || !found || !main.User { - return nil, errors.Join(err, errors.New("persisted ThreadBear control task is not available in Codex")) + }() + confirmedPartial, err := preflightUninstall(ctx, p) + if err != nil { + return preview, err + } + if confirmedPartial != partialAdmission { + return preview, errors.New("uninstall state changed during admission") + } + agents, removeAgents, agentsChanged, err := editManagedBlock(p.agents, false, false) + if err != nil { + return preview, err + } + + // Stop the only background entry point before removing any executable or + // instruction surface. The binary is deliberately removed last. + if err := removeUpdateAgent(ctx, p.launchAgent, p.binary); err != nil { + return partialResult(preview, "updater", true, uninstallRerun(p)), err + } + if agentsChanged { + if removeAgents { + err = os.Remove(p.agents) + } else { + err = writeAtomic(p.agents, agents, 0o600) } - if main.Archived != pending.MainArchived { - return nil, errors.New("control task archive state must be restored before uninstall completion") + if err != nil && !errors.Is(err, os.ErrNotExist) { + return partialResult(preview, "managed_guidance", true, uninstallRerun(p)), err } - if abort { - err = newStore(stateDir()).update(func(saved *state) (bool, error) { - for id, record := range saved.Tasks { - record.Pending = nil - saved.Tasks[id] = record - } - saved.UninstallPending = nil - return true, nil - }) - return map[string]any{"ready": err == nil, "aborted": err == nil, "main_archived": pending.MainArchived}, err + } + if err := os.Remove(p.skill); err != nil && !errors.Is(err, os.ErrNotExist) { + return partialResult(preview, "skill", true, uninstallRerun(p)), err + } + skillDir := filepath.Dir(p.skill) + if info, _ := os.Lstat(skillDir); info == nil || info.Mode()&os.ModeSymlink == 0 { + if err := os.Remove(skillDir); err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) { + return partialResult(preview, "skill", true, uninstallRerun(p)), err } - if stripStatusIcons(main.Title) != main.Title || stripStatusIcons(main.Title) == homeTitle { - return nil, errors.New("uninstall requires title cleanup from the ThreadBear control task") + } + if err := removeOwnedState(); err != nil { + return partialResult(preview, "state", true, uninstallRerun(p)), err + } + if err := os.Remove(filepath.Join(stateDir(), "lifecycle.lock")); err != nil && !errors.Is(err, os.ErrNotExist) { + return partialResult(preview, "state", true, uninstallRerun(p)), err + } + if lock != nil { + unlock(lock) + lock = nil + } + if err := os.Remove(stateDir()); err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) { + return partialResult(preview, "state", true, uninstallRerun(p)), err + } + if err := os.Remove(p.binary); err != nil && !errors.Is(err, os.ErrNotExist) { + return partialResult(preview, "binary", true, uninstallRerun(p)), err + } + return map[string]any{ + "ready": true, "dry_run": false, "uninstalled": true, + "icons_may_remain": true, "restart_required": true, "partial": false, + "warning": "Existing ThreadBear title icons may remain until renamed.", + "planned_changes": uninstallChanges(p), + }, nil +} + +func uninstallRerun(p lifecyclePaths) string { + return quoteArgument(p.binary) + " uninstall --noninteractive --confirm --json" +} + +func partialResult(result map[string]any, stage string, restart bool, rerun string) map[string]any { + result["ready"], result["dry_run"], result["partial"] = false, false, true + result["stage"], result["restart_required"], result["safe_rerun"] = stage, restart, rerun + return result +} + +func uninstallChanges(p lifecyclePaths) []string { + return []string{ + "boot out and remove " + updateAgentLabel + " LaunchAgent " + p.launchAgent, + "remove managed AGENTS block from " + p.agents, + "remove skill " + p.skill, + "remove owned subject records under " + newStore(stateDir()).subjectDir(), + "remove update receipt " + p.updateReceipt, + "remove binary last " + p.binary, + } +} + +func status(ctx context.Context) (any, error) { return statusAllowingLegacy(ctx, false) } + +func statusAllowingLegacy(ctx context.Context, allowLegacy bool) (any, error) { + p := installPaths() + stateErr := validateRuntimeState() + legacy, legacyErr := legacyStatePresent() + legacyClear := legacyErr == nil && !legacy + if allowLegacy && legacy && legacyErr == nil { + legacyClear = true + } + artifacts := map[string]bool{ + "binary": regularExecutable(p.binary), "agents": managedBlockExact(p.agents), + "skill": exactFile(p.skill, []byte(assets.SkillManagedContent)), + "subjects": stateErr == nil, "legacy_state_absent": legacyClear, + } + var problems []error + for name, healthy := range artifacts { + if !healthy { + problems = append(problems, fmt.Errorf("managed %s surface is missing or changed", name)) } - return uninstallLocked(ctx, value) - }) + } + updater, _, updaterErr := inspectUpdateAgent(ctx, p.launchAgent, p.binary) + automaticUpdates := updaterErr == nil && updater.Exact && updater.Loaded + if !legacyClear { + legacyErr = errors.Join(legacyErr, errors.New("legacy or unsupported native.json state is present")) + } + coreErr := errors.Join(errors.Join(problems...), stateErr, legacyErr) + ready := coreErr == nil + binaryPresent, binaryPresenceErr := regularLeaf(p.binary, false) + if binaryPresenceErr != nil { + binaryPresent = false + } + result := map[string]any{ + "ready": ready, "installed": binaryPresent, "version": version, + "automatic_updates_enabled": automaticUpdates, + "artifacts": artifacts, "updater": updater, + } + if updaterErr != nil { + result["updater_error"] = updaterErr.Error() + } + if receipt, err := readUpdateReceipt(p.updateReceipt); err == nil { + result["latest_update"] = receipt + } else if !errors.Is(err, os.ErrNotExist) { + result["update_receipt_error"] = err.Error() + } + return result, coreErr } -func withUninstallLocks(action func() (any, error)) (any, error) { - store := newStore(stateDir()) - operationLock, err := store.operationLock() + +func selfTest() (any, error) { + if runtime.GOOS != "darwin" || assets.AgentsManagedContent == "" || assets.SkillManagedContent == "" || version == "" { + return nil, errors.New("candidate is incomplete or unsupported") + } + return map[string]any{"ready": true, "version": version}, nil +} + +func lifecycleLock(name string) (*os.File, error) { return openLifecycleLock(name, true, true) } +func existingLifecycleLock(name string) (*os.File, error) { + return openLifecycleLock(name, false, false) +} +func updateCheckLock() (*os.File, error) { return openLifecycleLock("update.lock", false, true) } +func openLifecycleLock(name string, createDir, createFile bool) (*os.File, error) { + if createDir { + if err := os.MkdirAll(stateDir(), 0o700); err != nil { + return nil, err + } + } + dir, err := unix.Open(stateDir(), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) if err != nil { return nil, err } - defer unlock(operationLock) - titleLock, err := store.titleLock() + defer unix.Close(dir) + if err := unix.Fchmod(dir, 0o700); err != nil { + return nil, err + } + flags := unix.O_RDWR | unix.O_NOFOLLOW + if createFile { + flags |= unix.O_CREAT + } + fd, err := unix.Openat(dir, name, flags, 0o600) if err != nil { return nil, err } - defer unlock(titleLock) - return action() + file := os.NewFile(uintptr(fd), filepath.Join(stateDir(), name)) + info, statErr := file.Stat() + if statErr != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return nil, errors.Join(errors.New("ThreadBear lifecycle lock is not a private regular file"), statErr, file.Close()) + } + if err := unix.Flock(fd, unix.LOCK_EX); err != nil { + return nil, errors.Join(err, file.Close()) + } + current, pathErr := os.Lstat(file.Name()) + if pathErr != nil || !current.Mode().IsRegular() || current.Mode().Perm() != 0o600 || !os.SameFile(info, current) { + unlock(file) + return nil, errors.Join(errors.New("ThreadBear lifecycle changed while the operation was waiting"), pathErr) + } + return file, nil } -func finishCommittedUninstall() (bool, error) { - p := installPaths() - if _, err := os.Stat(newStore(stateDir()).path()); !errors.Is(err, os.ErrNotExist) { - return false, err + +func readLegacyInstall() (legacyInstall, bool, error) { + path := filepath.Join(stateDir(), "native.json") + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return legacyInstall{}, false, nil } - _, skillErr := os.Stat(p.skill) - agents, agentsErr := os.ReadFile(p.agents) - _, hooksChanged, hooksErr := editHooks(p.hooks, p.binary, false) - if !errors.Is(skillErr, os.ErrNotExist) || agentsErr == nil && (strings.Contains(string(agents), blockStart) || strings.Contains(string(agents), blockEnd) || strings.Contains(string(agents), managedHeading) || strings.Contains(string(agents), managedProtocol)) || agentsErr != nil && !errors.Is(agentsErr, os.ErrNotExist) || hooksErr != nil || hooksChanged { - return false, errors.Join(agentsErr, hooksErr, errors.New("uninstall state is missing before local artifacts were settled")) + if err != nil { + return legacyInstall{}, false, err } - if err := errors.Join(removeFiles(filepath.Dir(p.skill), p.skill, filepath.Dir(p.skill)), os.RemoveAll(stateDir())); err != nil { - return false, err + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return legacyInstall{}, false, errors.New("legacy native.json is not a private regular file") } - return true, removeFiles("", p.binary) + data, err := os.ReadFile(path) + if err != nil { + return legacyInstall{}, false, err + } + var value struct { + Format int `json:"format"` + MainTaskID string `json:"main_task_id"` + Tasks map[string]json.RawMessage `json:"tasks"` + } + if json.Unmarshal(data, &value) != nil || value.Format != 4 || value.Tasks == nil || !taskIDPattern.MatchString(value.MainTaskID) { + return legacyInstall{}, false, errors.New("native.json is not exact supported 2.2.1 state") + } + return legacyInstall{MainTaskID: value.MainTaskID}, true, nil } -func uninstallLocked(ctx context.Context, value state) (any, error) { - for _, record := range value.Tasks { - if record.Pending != nil { - return nil, errors.New("cannot uninstall while a native title operation is pending; reconcile it first") + +func legacyStatePresent() (bool, error) { _, found, err := readLegacyInstall(); return found, err } + +func clearLegacyState() error { + // Keep native.json until every other obsolete leaf is gone so --reset is + // still admissible after any interrupted or failed cleanup. + for _, name := range []string{"native.lock", "title.lock", "operation.lock", "update.json", "native.json"} { + if err := os.Remove(filepath.Join(stateDir(), name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err } } - if value.MainTaskID != "" && value.Phase != phaseMigrationPending { - tasks, scanErr := inventory(ctx) - if scanErr != nil { - return nil, scanErr + return nil +} + +func requireCurrentFormatInstall(p lifecyclePaths) error { + legacy, err := legacyStatePresent() + if err != nil || legacy { + return errors.Join(err, map[bool]error{true: errors.New("legacy native.json is present")}[legacy]) + } + if !regularExecutable(p.binary) { + return errors.New("installed binary is absent") + } + return validateRuntimeState() +} + +func currentInstallPresent(p lifecyclePaths) bool { + legacy, err := legacyStatePresent() + if err != nil || legacy || !regularExecutable(p.binary) { + return false + } + return validateRuntimeState() == nil +} + +func removeOwnedState() error { + if err := validateRemovableState(); err != nil { + return err + } + subjectDir := newStore(stateDir()).subjectDir() + if entries, err := os.ReadDir(subjectDir); err == nil { + for _, entry := range entries { + ext := filepath.Ext(entry.Name()) + if (ext == ".json" || ext == ".lock") && taskIDPattern.MatchString(strings.TrimSuffix(entry.Name(), ext)) { + if err := os.Remove(filepath.Join(subjectDir, entry.Name())); err != nil { + return err + } + } } - if slices.ContainsFunc(tasks, func(task indexedTask) bool { return stripStatusIcons(task.Title) != task.Title }) { - return nil, errors.New("uninstall requires title cleanup from the ThreadBear control task") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.Remove(subjectDir); err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) { + return err + } + for _, name := range []string{"update.json", "update.lock"} { + if err := os.Remove(filepath.Join(stateDir(), name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err } } - p := installPaths() - hooks, write, err := editHooks(p.hooks, p.binary, false) - if err != nil { - return nil, err + return nil +} + +func validateRuntimeState() error { + found, err := privateDirectory(stateDir()) + if err != nil || !found { + return errors.Join(err, errors.New("state root is missing or not private")) } - err = validateFile(p.skill, assets.SkillManagedContent) - if err == nil { - err = manageBlock(p.agents, "") + found, err = privateDirectory(newStore(stateDir()).subjectDir()) + if err != nil || !found { + return errors.Join(err, errors.New("subject store is missing or not private")) } - if err == nil && write { - if len(hooks) == 0 { - err = os.Remove(p.hooks) - } else { - err = writeAtomic(p.hooks, hooks, 0o600) + found, err = privateRegular(filepath.Join(stateDir(), "lifecycle.lock")) + if err != nil || !found { + return errors.Join(err, errors.New("lifecycle fence is missing or not private")) + } + return nil +} + +func preflightInstall(ctx context.Context, p lifecyclePaths, candidate []byte, legacy bool) error { + if err := validateManagedParents(p); err != nil { + return err + } + if found, err := privateDirectory(stateDir()); err != nil { + return err + } else if found { + if _, err := privateDirectory(newStore(stateDir()).subjectDir()); err != nil { + return err } } - if err == nil { - err = removeFiles(filepath.Dir(p.skill), p.skill, filepath.Dir(p.skill)) + if legacy { + if _, err := regularLeaf(legacyHooksPath(), false); err != nil { + return err + } + if _, _, _, err := removeLegacyHooks(legacyHooksPath(), p.binary); err != nil { + return err + } } - if err == nil { - err = os.RemoveAll(stateDir()) + current, owned := currentInstallPresent(p), legacy + owned = owned || current + if exists, err := regularLeaf(p.binary, false); err != nil { + return err + } else if exists { + data, readErr := os.ReadFile(p.binary) + if readErr != nil || !regularExecutable(p.binary) || !owned && !bytes.Equal(data, candidate) { + return errors.Join(readErr, errors.New("binary path contains non-ThreadBear content")) + } } - if err == nil { - err = removeFiles("", p.binary) + if _, err := regularLeaf(p.agents, false); err != nil { + return err } - return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err -} -func status(ctx context.Context) (any, error) { - p := installPaths() - _, changed, err := editHooks(p.hooks, p.binary, true) - if err == nil && changed { - err = errors.New("native title hooks are incomplete") + if exists, err := regularLeaf(p.skill, false); err != nil { + return err + } else if exists && !owned && !exactFile(p.skill, []byte(assets.SkillManagedContent)) { + return errors.New("skill path contains non-ThreadBear content") } - for _, path := range []string{p.binary, p.agents, p.skill} { - if err == nil { - _, err = os.Stat(path) + if _, _, _, err := editManagedBlock(p.agents, true, owned); err != nil { + return err + } + if _, _, err := inspectUpdateAgent(ctx, p.launchAgent, p.binary); err != nil { + return err + } + if _, err := privateRegular(p.updateReceipt); err != nil { + return err + } + for _, name := range []string{"lifecycle.lock", "update.lock"} { + if _, err := privateRegular(filepath.Join(stateDir(), name)); err != nil { + return err } } - value, stateErr := reconcileMigration(ctx) - err = errors.Join(err, validateFile(p.skill, assets.SkillManagedContent), validateFile(p.agents, blockStart+"\n"+strings.TrimSpace(assets.AgentsManagedContent)+"\n"+blockEnd), stateErr) - result := map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "maintenance_automation_id": "threadbear-maintenance", "archive_pending": value.ArchivePending != nil, "uninstall_pending": value.UninstallPending != nil, "owned_archives": len(value.Archives)} - if value.MigrationFailure != "" { - result["migration_failure"] = value.MigrationFailure - result["next_action"] = "resume migration from the ThreadBear task" - } else if value.Phase == phaseMigrationPending { - result["next_action"] = "start migration from the ThreadBear task" + return nil +} + +func preflightUninstall(ctx context.Context, p lifecyclePaths) (bool, error) { + currentErr := preflightCurrentUninstall(ctx, p) + if currentErr == nil { + return false, nil + } + if partialErr := preflightPartialUninstall(ctx, p); partialErr != nil { + return false, errors.Join(currentErr, fmt.Errorf("partial uninstall admission refused: %w", partialErr)) } - return result, err + return true, nil } -func selfTest() (any, error) { - if runtime.GOOS != "darwin" || assets.AgentsManagedContent == "" || assets.SkillManagedContent == "" || version == "" { - return nil, errors.New("candidate is incomplete or unsupported") + +func preflightCurrentUninstall(ctx context.Context, p lifecyclePaths) error { + if err := validateManagedParents(p); err != nil { + return err } - return map[string]any{"ready": true, "version": version}, nil + if err := requireCurrentFormatInstall(p); err != nil { + return fmt.Errorf("uninstall requires a valid current installation: %w", err) + } + if err := validateOwnedState(); err != nil { + return err + } + for _, path := range []string{p.agents, p.skill} { + if _, err := regularLeaf(path, false); err != nil { + return err + } + } + if _, _, _, err := editManagedBlock(p.agents, false, false); err != nil { + return err + } + _, _, err := inspectUpdateAgent(ctx, p.launchAgent, p.binary) + return err } -func editHooks(path, binary string, add bool) ([]byte, bool, error) { - data, err := os.ReadFile(path) - missing := errors.Is(err, os.ErrNotExist) - if missing && !add { - return nil, false, nil + +func preflightPartialUninstall(ctx context.Context, p lifecyclePaths) error { + if err := validateManagedParents(p); err != nil { + return err } - if err != nil && !missing { - return nil, false, err + if err := runningInstalledBinary(p.binary); err != nil { + return err } - root, events := rawObject{}, rawObject{} - if !missing && (json.Unmarshal(data, &root) != nil || root == nil) { - return nil, false, errors.New("hooks.json must contain an object") + if legacy, err := legacyStatePresent(); err != nil || legacy { + return errors.Join(err, map[bool]error{true: errors.New("legacy native.json is present")}[legacy]) } - if raw, ok := root["hooks"]; ok && (json.Unmarshal(raw, &events) != nil || events == nil) { - return nil, false, errors.New("hooks.json hooks must be an object") + if err := validateRemovableState(); err != nil { + return err } - before := encodedJSON(decodedJSON(data)) - owner, removed := encodedJSON(map[string]any{"matcher": "codex_appset_thread_title", "hooks": []any{map[string]any{"type": "command", "command": quoteCommand(binary), "timeout": 1}}}), false - for _, event := range []string{"PreToolUse", "PostToolUse"} { - var groups []json.RawMessage - if raw, ok := events[event]; ok && (json.Unmarshal(raw, &groups) != nil || groups == nil) { - return nil, false, fmt.Errorf("hooks.json %s must be an array", event) + if _, err := regularLeaf(p.agents, false); err != nil { + return err + } + if exists, err := regularLeaf(p.skill, false); err != nil { + return err + } else if exists && !exactFile(p.skill, []byte(assets.SkillManagedContent)) { + return errors.New("partial uninstall found replacement content at the skill path") + } + if _, _, _, err := editManagedBlock(p.agents, false, false); err != nil { + return err + } + _, _, err := inspectUpdateAgent(ctx, p.launchAgent, p.binary) + return err +} + +func runningInstalledBinary(path string) error { + running, err := os.Executable() + if err != nil { + return err + } + runningInfo, runningErr := os.Stat(running) + installedInfo, installedErr := os.Lstat(path) + if runningErr != nil || installedErr != nil || !installedInfo.Mode().IsRegular() || installedInfo.Mode().Perm()&0o111 == 0 || !os.SameFile(runningInfo, installedInfo) { + return errors.Join(runningErr, installedErr, errors.New("uninstall rerun must execute the exact installed binary")) + } + return nil +} + +func validateManagedParents(p lifecyclePaths) error { + paths := []string{p.binary, p.agents, p.skill, p.launchAgent, p.updateReceipt, + newStore(stateDir()).subjectDir(), filepath.Join(stateDir(), "lifecycle.lock"), filepath.Join(stateDir(), "update.lock")} + for _, path := range paths { + anchor := homeDir() + if rel, err := filepath.Rel(anchor, path); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + anchor = filepath.Dir(codexHome()) } - kept := slices.DeleteFunc(groups, func(group json.RawMessage) bool { - owned := ownedHookGroup(group, binary) - removed = removed || owned - return owned - }) - if add { - kept = append(kept, owner) + current := anchor + rel, err := filepath.Rel(anchor, filepath.Dir(path)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return errors.New("managed path is outside its trusted parent") } - if raw, _ := json.Marshal(kept); len(kept) == 0 { - delete(events, event) - } else { - events[event] = raw + for _, part := range append([]string{"."}, strings.Split(rel, string(os.PathSeparator))...) { + if part != "." { + current = filepath.Join(current, part) + } + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + break + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.Join(err, fmt.Errorf("managed parent is not a real directory: %s", current)) + } } } - if !add && !removed { - return data, false, nil - } - if !add && len(events) == 0 { - delete(root, "hooks") - } else { - root["hooks"], _ = json.Marshal(events) + return nil +} + +func regularLeaf(path string, private bool) (bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil } - if !add && len(root) == 0 { - return nil, true, nil + if err != nil || !info.Mode().IsRegular() || private && info.Mode().Perm() != 0o600 { + return false, errors.Join(err, fmt.Errorf("managed path is not a%s regular file: %s", map[bool]string{true: " private", false: ""}[private], path)) } - updated, err := json.MarshalIndent(root, "", " ") - return append(updated, '\n'), string(before) != string(encodedJSON(decodedJSON(updated))), err -} -func encodedJSON(value any) json.RawMessage { data, _ := json.Marshal(value); return data } -func ownedHookGroup(group json.RawMessage, binary string) bool { - value, hooks := rawObject{}, []rawObject{} - return json.Unmarshal(group, &value) == nil && json.Unmarshal(value["hooks"], &hooks) == nil && len(hooks) == 1 && string(value["matcher"]) == `"codex_appset_thread_title"` && string(hooks[0]["type"]) == `"command"` && string(hooks[0]["command"]) == string(encodedJSON(quoteCommand(binary))) + return true, nil } -func decodedJSON(data []byte) any { var value any; _ = json.Unmarshal(data, &value); return value } -func quoteCommand(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "' hook" } -func validateFile(path, content string) error { - data, err := os.ReadFile(path) + +func privateRegular(path string) (bool, error) { return regularLeaf(path, true) } +func privateDirectory(path string) (bool, error) { + info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { - return nil + return false, nil } - text := string(data) - valid := text == content - if strings.HasPrefix(content, blockStart) { - valid = strings.Count(text, blockStart) == 1 && strings.Count(text, blockEnd) == 1 && strings.Contains(text, content) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0o700 { + return false, errors.Join(err, fmt.Errorf("managed path is not a private directory: %s", path)) } - if err == nil && !valid { - return errors.New("managed file was modified: " + path) + return true, nil +} + +func validateOwnedState() error { + if err := validateRuntimeState(); err != nil { + return err + } + if err := validateOwnedSubjectLeaves(); err != nil { + return err + } + if _, err := privateRegular(filepath.Join(stateDir(), "update.lock")); err != nil { + return err } + _, err := privateRegular(installPaths().updateReceipt) return err } -func manageBlock(path, content string) error { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) && content == "" { - return nil + +func validateRemovableState() error { + found, err := privateDirectory(stateDir()) + if err != nil || !found { + return err } - if err != nil && !errors.Is(err, os.ErrNotExist) { + found, err = privateDirectory(newStore(stateDir()).subjectDir()) + if err != nil { return err } - text := string(data) - start, end := strings.Index(text, blockStart), strings.Index(text, blockEnd) - block := blockStart + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockEnd - if strings.Count(text, blockStart) > 1 || strings.Count(text, blockEnd) > 1 || start < 0 != (end < 0) || end >= 0 && end < start { - return errors.New("invalid ThreadBear managed block") + if found { + if err := validateOwnedSubjectLeaves(); err != nil { + return err + } + } + for _, path := range []string{ + filepath.Join(stateDir(), "lifecycle.lock"), + filepath.Join(stateDir(), "update.lock"), + installPaths().updateReceipt, + } { + if _, err := privateRegular(path); err != nil { + return err + } + } + return nil +} + +func validateOwnedSubjectLeaves() error { + dir := newStore(stateDir()).subjectDir() + entries, err := os.ReadDir(dir) + if err != nil { + return err } - if content == "" && (start < 0 && (strings.Contains(text, managedHeading) || strings.Contains(text, managedProtocol)) || start >= 0 && !strings.Contains(text, block)) { - return errors.New("managed file was modified: " + path) + for _, entry := range entries { + ext := filepath.Ext(entry.Name()) + if ext != ".json" && ext != ".lock" || !taskIDPattern.MatchString(strings.TrimSuffix(entry.Name(), ext)) { + continue + } + info, err := os.Lstat(filepath.Join(dir, entry.Name())) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return errors.Join(err, fmt.Errorf("owned subject path is not a private regular file: %s", entry.Name())) + } } - if content == "" && start < 0 { - return nil + return nil +} + +func regularExecutable(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 +} +func exactFile(path string, want []byte) bool { + data, err := os.ReadFile(path) + return err == nil && bytes.Equal(data, want) +} + +func managedBlockExact(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + block := blockStart + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockEnd + return strings.Count(string(data), blockStart) == 1 && strings.Count(string(data), blockEnd) == 1 && strings.Contains(string(data), block) +} + +func editManagedBlock(path string, add, replace bool) ([]byte, bool, bool, error) { + data, err := os.ReadFile(path) + missing := errors.Is(err, os.ErrNotExist) + if err != nil && !missing { + return nil, false, false, err + } + text := string(data) + start, end := strings.Index(text, blockStart), strings.Index(text, blockEnd) + if strings.Count(text, blockStart) > 1 || strings.Count(text, blockEnd) > 1 || (start < 0) != (end < 0) || (end >= 0 && end < start) { + return nil, false, false, errors.New("invalid ThreadBear managed block") } - if content != "" { + block := blockStart + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockEnd + before := text + if add { if start >= 0 { + if text[start:end+len(blockEnd)] != block && !replace { + return nil, false, false, errors.New("managed AGENTS block was modified; refusing to replace it") + } text = text[:start] + block + text[end+len(blockEnd):] + } else if text == "" { + text = block } else { - if text != "" && !strings.HasSuffix(text, "\n") { - text += "\n" + text += "\n" + block + } + } else { + if start < 0 { + return data, false, false, nil + } + beforeBlock, after := text[:start], text[end+len(blockEnd):] + if strings.HasSuffix(beforeBlock, "\n") { + beforeBlock = strings.TrimSuffix(beforeBlock, "\n") + } + text = beforeBlock + after + } + remove := text == "" + return []byte(text), remove, text != before, nil +} + +// removeLegacyHooks exists only for the explicit 2.2.1 --reset transition. +// Current-format install, status, and uninstall are deliberately hook-blind. +func removeLegacyHooks(path, binary string) ([]byte, bool, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, false, nil + } + if err != nil { + return nil, false, false, err + } + root, events := map[string]json.RawMessage{}, map[string]json.RawMessage{} + if json.Unmarshal(data, &root) != nil || root == nil { + return nil, false, false, errors.New("legacy hooks.json must contain an object") + } + if raw, ok := root["hooks"]; ok && (json.Unmarshal(raw, &events) != nil || events == nil) { + return nil, false, false, errors.New("legacy hooks.json hooks must be an object") + } + removed := false + for _, event := range []string{"PreToolUse", "PostToolUse"} { + var groups []json.RawMessage + if raw, ok := events[event]; ok && (json.Unmarshal(raw, &groups) != nil || groups == nil) { + return nil, false, false, fmt.Errorf("legacy hooks.json %s must be an array", event) + } + kept := groups[:0] + for _, group := range groups { + if ownedLegacyHookGroup(group, binary) { + removed = true + continue } - text += block + "\n" + kept = append(kept, group) } - } else if start >= 0 { - after := text[end+len(blockEnd):] - if strings.HasSuffix(text[:start], "\n") && strings.HasPrefix(after, "\n") { - after = after[1:] + if len(kept) == 0 { + delete(events, event) + } else { + events[event], _ = json.Marshal(kept) } - text = text[:start] + after } - if strings.TrimSpace(text) == "" { - return os.Remove(path) + if !removed { + return data, false, false, nil + } + if len(events) == 0 { + delete(root, "hooks") + } else { + root["hooks"], _ = json.Marshal(events) + } + if len(root) == 0 { + return nil, true, true, nil + } + updated, err := json.MarshalIndent(root, "", " ") + updated = append(updated, '\n') + return updated, true, false, err +} + +func ownedLegacyHookGroup(group json.RawMessage, binary string) bool { + var value struct { + Matcher string `json:"matcher"` + Hooks []struct { + Type, Command string + } `json:"hooks"` + } + if json.Unmarshal(group, &value) != nil || value.Matcher != legacyTitleTool || len(value.Hooks) != 1 { + return false } - return writeAtomic(path, []byte(text), 0o600) + return value.Hooks[0].Type == "command" && value.Hooks[0].Command == quoteArgument(binary)+" hook" } + +func quoteArgument(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } + func writeAtomic(path string, data []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - f, err := os.CreateTemp(filepath.Dir(path), ".threadbear-*") + file, err := os.CreateTemp(filepath.Dir(path), ".threadbear-*") if err != nil { return err } - defer os.Remove(f.Name()) - err = f.Chmod(mode) - if err == nil { - _, err = f.Write(data) + defer os.Remove(file.Name()) + if err = file.Chmod(mode); err == nil { + _, err = file.Write(data) } if err == nil { - err = f.Sync() + err = file.Sync() } - if err = errors.Join(err, f.Close()); err != nil { + if err = errors.Join(err, file.Close()); err != nil { return err } - return os.Rename(f.Name(), path) -} -func removeFiles(nonEmptyOK string, paths ...string) error { - for _, path := range paths { - if info, _ := os.Lstat(path); path == nonEmptyOK && info != nil && !info.IsDir() { - continue - } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) && !(path == nonEmptyOK && errors.Is(err, syscall.ENOTEMPTY)) { - return err - } - } - return nil + return os.Rename(file.Name(), path) } diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 3d2d226..27475a2 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -1,678 +1,1183 @@ package main import ( + "bytes" "context" - "encoding/json" + "errors" "os" "os/exec" "path/filepath" - "reflect" "strings" + "sync" "testing" "time" "github.com/ericlitman/threadbear/assets" ) -func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { +type fakeLaunchctl struct { + mu sync.Mutex + loaded bool + bootstraps, bootouts int + calls [][]string + bootstrapErr error + printErr error + printOutput []byte + bootoutStarted chan struct{} + continueBootout <-chan struct{} +} + +func (fake *fakeLaunchctl) run(_ context.Context, args ...string) ([]byte, error) { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.calls = append(fake.calls, append([]string(nil), args...)) + if len(args) == 0 { + return nil, errors.New("missing launchctl operation") + } + switch args[0] { + case "print": + if fake.printErr != nil { + return nil, fake.printErr + } + if !fake.loaded { + return nil, errLaunchAgentNotLoaded + } + if fake.printOutput != nil { + return append([]byte(nil), fake.printOutput...), nil + } + return managedLaunchctlPrint(updateAgentPath(), installPaths().binary), nil + case "bootstrap": + fake.bootstraps++ + if fake.bootstrapErr != nil { + return nil, fake.bootstrapErr + } + fake.loaded = true + return nil, nil + case "bootout": + if !fake.loaded { + return nil, errLaunchAgentNotLoaded + } + if fake.bootoutStarted != nil { + close(fake.bootoutStarted) + } + if fake.continueBootout != nil { + fake.mu.Unlock() + <-fake.continueBootout + fake.mu.Lock() + } + fake.bootouts++ + fake.loaded = false + return nil, nil + default: + return nil, errors.New("unexpected launchctl operation") + } +} + +func managedLaunchctlPrint(path, binary string) []byte { + var output strings.Builder + output.WriteString(updateAgentTarget() + " = {\n\tpath = " + path + "\n\tprogram = " + binary + "\n\targuments = {\n") + for _, argument := range updateAgentArguments(binary) { + output.WriteString("\t\t" + argument + "\n") + } + output.WriteString("\t}\n}\n") + return []byte(output.String()) +} + +func TestInstallPreviewConfirmationAndOnboardingReceipt(t *testing.T) { p := isolatedLifecycle(t) - foreignAgents := "# Mine\nkeep this exactly\n" - mustWrite(t, p.agents, foreignAgents) - preA := json.RawMessage(`{"matcher":"Bash","hooks":[{"type":"command","command":"a"}],"extension":{"n":1}}`) - preB := json.RawMessage(`{"hooks":[{"command":"b","timeout":99,"type":"command"}]}`) - postA := json.RawMessage(`{"matcher":"","hooks":[{"type":"command","command":"c"}]}`) - writeHookFixture(t, p.hooks, preA, preB, postA) - if _, err := install("installer", false, true, false); err != nil { + preview, err := install(context.Background(), installOptions{DryRun: true}) + if err != nil { t.Fatal(err) } - assertHookOrder(t, p.hooks, "PreToolUse", []json.RawMessage{preA, preB}, p.binary) - assertHookOrder(t, p.hooks, "PostToolUse", []json.RawMessage{postA}, p.binary) - skill, _ := os.ReadFile(p.skill) - if !strings.HasPrefix(string(skill), "---\n") { - t.Fatalf("installed skill lost YAML frontmatter: %q", skill) + got := preview.(map[string]any) + if got["installed"] != false || got["onboarding_requested"] != true || got["next_request"] != "threadbear onboard --dry-run --json" || got["automatic_updates_enabled"] != false { + t.Fatalf("preview = %#v", got) + } + planned := got["planned_changes"].([]string) + wantPlanned := []string{ + "manage subject records under " + newStore(stateDir()).subjectDir(), + "manage update receipt " + p.updateReceipt, + "replace managed AGENTS block in " + p.agents, + "write skill " + p.skill, + "install " + updateAgentLabel + " LaunchAgent " + p.launchAgent, + "write binary " + p.binary, + } + if strings.Join(planned, "\n") != strings.Join(wantPlanned, "\n") { + t.Fatalf("planned changes = %#v", planned) + } + for _, path := range []string{p.binary, p.agents, p.skill, p.launchAgent, stateDir()} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preview created %s: %v", path, err) + } } - firstHooks, _ := os.ReadFile(p.hooks) - if _, err := status(context.Background()); err != nil { - t.Fatalf("installed status: %v", err) + if _, err := install(context.Background(), installOptions{}); err == nil { + t.Fatal("unconfirmed install succeeded") } - if err := manageBlock(p.agents, ""); err != nil { + result, err := install(context.Background(), installOptions{Confirmed: true}) + if err != nil { t.Fatal(err) } - if err := manageBlock(p.agents, assets.AgentsManagedContent); err != nil { - t.Fatal(err) + got = result.(map[string]any) + if got["ready"] != true || got["installed"] != true || got["version"] != version || got["next_request"] != "threadbear onboard --dry-run --json" || got["restart_required"] != true || got["automatic_updates_enabled"] != true { + t.Fatalf("install = %#v", got) } - - if _, err := install("installer", false, true, false); err != nil { + without, err := install(context.Background(), installOptions{Confirmed: true, NoOnboard: true}) + if err != nil { t.Fatal(err) } - secondHooks, _ := os.ReadFile(p.hooks) - if !reflect.DeepEqual(firstHooks, secondHooks) { - t.Fatal("reinstall rewrote an already-correct hooks.json") + if value := without.(map[string]any); value["onboarding_requested"] != false || value["next_request"] != nil { + t.Fatalf("no-onboard install = %#v", value) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { +} + +func TestLifecycleNeverTouchesCodexHooks(t *testing.T) { + p := isolatedLifecycle(t) + foreignAgents := "# Mine\nkeep this exactly\n" + mustWrite(t, p.agents, foreignAgents) + hooks := filepath.Join(codexHome(), "hooks.json") + wantHooks := []byte(`{"owner":"user","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"mine"}]}]}}` + "\n") + mustWrite(t, hooks, string(wantHooks)) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - userSkillFile := filepath.Join(filepath.Dir(p.skill), "user-notes.md") - mustWrite(t, userSkillFile, "preserve me") - if _, err := uninstall(context.Background(), true); err != nil { + first, _ := os.ReadFile(hooks) + if !bytes.Equal(first, wantHooks) { + t.Fatalf("install changed hooks.json: %q", first) + } + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if _, err := uninstall(context.Background(), true); err != nil { - t.Fatalf("repeated uninstall: %v", err) + second, _ := os.ReadFile(hooks) + if !bytes.Equal(first, second) { + t.Fatal("idempotent reinstall changed hooks.json") } - assertHookOrder(t, p.hooks, "PreToolUse", []json.RawMessage{preA, preB}, "") - assertHookOrder(t, p.hooks, "PostToolUse", []json.RawMessage{postA}, "") agents, _ := os.ReadFile(p.agents) - if string(agents) != foreignAgents { - t.Fatalf("foreign AGENTS content changed: %q", agents) + if !strings.HasPrefix(string(agents), foreignAgents) || !managedBlockExact(p.agents) { + t.Fatalf("managed AGENTS content = %q", agents) } - if got, err := os.ReadFile(userSkillFile); err != nil || string(got) != "preserve me" { - t.Fatalf("uninstall changed user skill content: %q, %v", got, err) + if !exactFile(p.skill, []byte(assets.SkillManagedContent)) { + t.Fatal("managed skill is not exact") } - for _, path := range []string{p.binary, p.skill, stateDir()} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("uninstall left %s: %v", path, err) - } + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(hooks) + if !bytes.Equal(after, wantHooks) { + t.Fatalf("uninstall changed hooks.json: %q", after) } } -func TestUninstallPreservesUserOwnedSkillDirectorySymlink(t *testing.T) { +func TestCurrentLifecycleIgnoresMalformedCodexHooks(t *testing.T) { p := isolatedLifecycle(t) - target := filepath.Join(t.TempDir(), "skill-target") - if err := os.MkdirAll(target, 0o700); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Dir(filepath.Dir(p.skill)), 0o700); err != nil { - t.Fatal(err) + hooks := legacyHooksPath() + want := []byte("this is user-owned and not JSON\n") + mustWrite(t, hooks, string(want)) + if result, err := status(context.Background()); err != nil || !result.(map[string]any)["ready"].(bool) { + t.Fatalf("status depended on hooks.json: %#v, %v", result, err) } - if err := os.Symlink(target, filepath.Dir(p.skill)); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatalf("reinstall depended on hooks.json: %v", err) + } + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + t.Fatalf("uninstall depended on hooks.json: %v", err) + } + if got, err := os.ReadFile(hooks); err != nil || !bytes.Equal(got, want) { + t.Fatalf("current lifecycle changed hooks.json = %q, %v", got, err) + } + if _, err := os.Stat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall left binary: %v", err) + } +} + +func TestManagedAgentsRoundTripPreservesMissingTrailingNewline(t *testing.T) { + p := isolatedLifecycle(t) + original := []byte("# Mine\nkeep the missing final newline") + mustWrite(t, p.agents, string(original)) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - userFile := filepath.Join(target, "user-notes.md") - mustWrite(t, userFile, "preserve me") - if _, err := install("installer", false, true, false); err != nil { + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { + if got, err := os.ReadFile(p.agents); err != nil || !bytes.Equal(got, original) { + t.Fatalf("AGENTS round trip = %q, %v", got, err) + } +} + +func TestInstallReplacesExactLegacyStateOnlyWithReset(t *testing.T) { + p := isolatedLifecycle(t) + mainID := "019fdcbf-d225-7e00-9779-2472e54532e3" + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":4,"main_task_id":"`+mainID+`","tasks":{}}`) + hooks := legacyHooksPath() + legacyCommand := quoteArgument(p.binary) + " hook" + legacyHooks := `{"owner":"keep","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"mine"}]},{"matcher":"codex_appset_thread_title","hooks":[{"type":"command","command":"` + legacyCommand + `","timeout":3}]}],"PostToolUse":[{"matcher":"codex_appset_thread_title","hooks":[{"type":"command","command":"` + legacyCommand + `","timeout":3}]}],"Stop":[{"hooks":[{"type":"command","command":"stop"}]}]}}` + "\n" + mustWrite(t, hooks, legacyHooks) + preview, err := install(context.Background(), installOptions{DryRun: true}) + if err != nil { t.Fatal(err) } - if _, err := uninstall(context.Background(), true); err != nil { + if value := preview.(map[string]any); value["legacy_reset_required"] != true || value["legacy_main_task_id"] != mainID || value["legacy_automation_id"] != legacyAutomationID || value["legacy_automation_name"] != legacyAutomationName || value["legacy_automation_kind"] != legacyAutomationKind || value["legacy_automation_target_thread_id"] != mainID { + t.Fatalf("legacy preview = %#v", value) + } + if got, err := os.ReadFile(hooks); err != nil || string(got) != legacyHooks { + t.Fatalf("legacy preview changed hooks = %q, %v", got, err) + } + if _, err := install(context.Background(), installOptions{Confirmed: true}); err == nil || !strings.Contains(err.Error(), "--reset") { + t.Fatalf("legacy install without reset = %v", err) + } + if _, err := os.Stat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("refused reset wrote binary: %v", err) + } + if _, err := install(context.Background(), installOptions{Confirmed: true, Reset: true}); err != nil { t.Fatal(err) } - if _, err := os.Stat(p.skill); !os.IsNotExist(err) { - t.Fatalf("uninstall left managed skill: %v", err) + if _, err := os.Stat(filepath.Join(stateDir(), "native.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("legacy state remains: %v", err) } - if info, err := os.Lstat(filepath.Dir(p.skill)); err != nil || info.Mode()&os.ModeSymlink == 0 { - t.Fatalf("uninstall changed user-owned skill symlink: %#v, %v", info, err) + if got, err := os.ReadFile(hooks); err != nil || strings.Contains(string(got), legacyCommand) || !strings.Contains(string(got), `"command": "mine"`) || !strings.Contains(string(got), `"command": "stop"`) { + t.Fatalf("legacy hook cleanup = %q, %v", got, err) + } + if _, err := install(context.Background(), installOptions{Confirmed: true, Reset: true}); err == nil { + t.Fatal("reset without legacy state succeeded") + } + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":3,"tasks":{}}`) + if _, err := install(context.Background(), installOptions{Confirmed: true, Reset: true}); err == nil || !strings.Contains(err.Error(), "not exact") { + t.Fatalf("unsupported legacy reset = %v", err) } - if got, err := os.ReadFile(userFile); err != nil || string(got) != "preserve me" { - t.Fatalf("uninstall changed symlinked user skill content: %q, %v", got, err) + if _, err := os.Stat(filepath.Join(stateDir(), "native.json")); err != nil { + t.Fatalf("refused reset deleted unknown state: %v", err) + } +} + +func TestInstallRejectsLegacyStateWithoutMainTaskIdentity(t *testing.T) { + p := isolatedLifecycle(t) + native := filepath.Join(stateDir(), "native.json") + mustWrite(t, native, `{"format":4,"main_task_id":"","tasks":{}}`) + for _, options := range []installOptions{{DryRun: true}, {Confirmed: true, Reset: true}} { + if _, err := install(context.Background(), options); err == nil || !strings.Contains(err.Error(), "not exact") { + t.Fatalf("legacy state without task identity = %v", err) + } + if _, err := os.Stat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("refused reset wrote binary: %v", err) + } + if _, err := os.Stat(native); err != nil { + t.Fatalf("refused reset removed legacy state: %v", err) + } } } -func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { +func TestResetRerunsAfterPartialInstallWithoutDeletingNewSubjects(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("installer", true, false, false); err != nil { + mainID := "019fdcbf-d225-7e00-9779-2472e54532e3" + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":4,"main_task_id":"`+mainID+`","tasks":{}}`) + fake := currentFakeLaunchctl(t) + fake.bootstrapErr = errors.New("bootstrap unavailable") + result, err := install(context.Background(), installOptions{Confirmed: true, Reset: true}) + partial := result.(map[string]any) + if err == nil || partial["dry_run"] != false || partial["partial"] != true || partial["stage"] != "updater" || partial["restart_required"] != true || partial["safe_rerun"] != "repeat the same confirmed install command" { + t.Fatalf("partial reset = %#v, %v", result, err) + } + if _, err := os.Stat(filepath.Join(stateDir(), "native.json")); err != nil { + t.Fatalf("partial reset removed legacy admission state: %v", err) + } + subjectID := "019fc53a-4aa6-7221-ad51-165301675116" + subjectPath := filepath.Join(newStore(stateDir()).subjectDir(), subjectID+".json") + mustWrite(t, subjectPath, `{"subject":"Keep this subject"}`+"\n") + fake.bootstrapErr = nil + if _, err := install(context.Background(), installOptions{Confirmed: true, Reset: true}); err != nil { t.Fatal(err) } - if _, err := install("installer", false, false, false); err == nil { - t.Fatal("unconfirmed install succeeded") + if got, err := os.ReadFile(subjectPath); err != nil || string(got) != `{"subject":"Keep this subject"}`+"\n" { + t.Fatalf("rerun subject = %q, %v", got, err) } - for _, path := range []string{p.binary, p.agents, p.skill, p.hooks, stateDir()} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("non-mutating install created %s: %v", path, err) - } + if _, err := os.Stat(filepath.Join(stateDir(), "native.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("completed reset retained legacy state: %v", err) + } + if !regularExecutable(p.binary) { + t.Fatal("completed reset did not install binary") } } -func TestConcurrentFirstInstallsLockBeforeBinaryReplacement(t *testing.T) { +func TestResetPostCleanupFailureUsesOrdinaryConfirmedRerun(t *testing.T) { p := isolatedLifecycle(t) - lock, err := newStore(stateDir()).installLock() + mainID := "019fdcbf-d225-7e00-9779-2472e54532e3" + native := filepath.Join(stateDir(), "native.json") + mustWrite(t, native, `{"format":4,"main_task_id":"`+mainID+`","tasks":{}}`) + oldPostResetStatus := postResetStatus + postResetStatus = func(ctx context.Context) (any, error) { + result, _ := status(ctx) + return result, errors.New("post-cleanup status unavailable") + } + t.Cleanup(func() { postResetStatus = oldPostResetStatus }) + + result, err := install(context.Background(), installOptions{Confirmed: true, Reset: true, NoOnboard: true}) + partial := result.(map[string]any) + wantRerun := confirmedInstallRerun(p, true) + if err == nil || partial["partial"] != true || partial["stage"] != "status" || partial["legacy_reset_required"] != false || partial["safe_rerun"] != wantRerun { + t.Fatalf("post-cleanup reset partial = %#v, %v; rerun want %q", partial, err, wantRerun) + } + if _, err := os.Stat(native); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("committed reset retained legacy admission state: %v", err) + } + if !regularExecutable(p.binary) { + t.Fatal("post-cleanup reset failure removed the installed binary") + } + + postResetStatus = oldPostResetStatus + if _, err := install(context.Background(), installOptions{Confirmed: true, NoOnboard: true}); err != nil { + t.Fatalf("ordinary confirmed rerun failed: %v", err) + } + if result, err := status(context.Background()); err != nil || result.(map[string]any)["ready"] != true { + t.Fatalf("ordinary confirmed rerun status = %#v, %v", result, err) + } +} + +func TestInstallSerializesBehindUpdateCheckLock(t *testing.T) { + isolatedLifecycle(t) + lock, err := lifecycleLock("update.lock") if err != nil { t.Fatal(err) } - type outcome struct { - id string - err error - } - done := make(chan outcome, 2) - start := make(chan struct{}) - for _, id := range []string{"first", "second"} { - go func(id string) { - <-start - _, err := install(id, false, true, false) - done <- outcome{id, err} - }(id) - } - close(start) + done := make(chan error, 1) + go func() { + _, installErr := install(context.Background(), installOptions{Confirmed: true}) + done <- installErr + }() select { - case result := <-done: + case err := <-done: unlock(lock) - t.Fatalf("first install %q bypassed the lifecycle lock: %v", result.id, result.err) - case <-time.After(100 * time.Millisecond): - } - if _, err := os.Stat(p.binary); !os.IsNotExist(err) { + t.Fatalf("manual install bypassed update.lock: %v", err) + case <-time.After(50 * time.Millisecond): unlock(lock) - t.Fatalf("first install replaced the binary before ownership serialization: %v", err) + if err := <-done; err != nil { + t.Fatal(err) + } } - unlock(lock) - results := []outcome{<-done, <-done} - winner := "" - for _, result := range results { - if result.err == nil { - if winner != "" { - t.Fatalf("both first installers succeeded: %#v", results) +} + +func TestInstallDryRunRefusesCollidingLeaves(t *testing.T) { + tests := map[string]func(*testing.T, lifecyclePaths){ + "foreign binary": func(t *testing.T, p lifecyclePaths) { + mustWrite(t, p.binary, "#!/bin/sh\nexit 0\n") + if err := os.Chmod(p.binary, 0o755); err != nil { + t.Fatal(err) } - winner = result.id - } else if !strings.Contains(result.err.Error(), "persisted ThreadBear task changed") { - t.Fatalf("losing install %q = %v", result.id, result.err) - } + }, + "agents symlink": func(t *testing.T, p lifecyclePaths) { + target := filepath.Join(t.TempDir(), "AGENTS.md") + mustWrite(t, target, "mine") + if err := os.Symlink(target, p.agents); err != nil { + t.Fatal(err) + } + }, + "skill directory leaf": func(t *testing.T, p lifecyclePaths) { + if err := os.MkdirAll(p.skill, 0o700); err != nil { + t.Fatal(err) + } + }, } - value, err := newStore(stateDir()).read() - if err != nil || winner == "" || value.MainTaskID != winner { - t.Fatalf("serialized ownership = winner %q, state %#v, err %v", winner, value, err) + for name, prepare := range tests { + t.Run(name, func(t *testing.T) { + p := isolatedLifecycle(t) + prepare(t, p) + if _, err := install(context.Background(), installOptions{DryRun: true}); err == nil { + t.Fatal("colliding dry run succeeded") + } + if _, err := os.Stat(p.launchAgent); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("dry run mutated updater: %v", err) + } + }) } } -func TestReinstallRefusesLegacyPendingTitle(t *testing.T) { +func TestAutomaticInstallRefusesLegacyAndPostUninstallState(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("installer", false, true, false); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } before, _ := os.ReadFile(p.binary) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Tasks["legacy"] = taskState{Pending: &pendingProposal{Prior: "Old", Proposed: "New"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := install("installer", false, true, false); err == nil || !strings.Contains(err.Error(), "title operations") { - t.Fatalf("reinstall with legacy pending = %v", err) + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":4,"tasks":{}}`) + if _, err := install(context.Background(), installOptions{Confirmed: true, Automatic: true}); err == nil { + t.Fatal("automatic install accepted legacy state") } after, _ := os.ReadFile(p.binary) - if !reflect.DeepEqual(before, after) { - t.Fatal("blocked reinstall replaced the binary") - } -} - -func TestReinstallUpgradesLegacyFormatBeforeOldHookCanWrite(t *testing.T) { - root, db := testIndex(t) - for _, id := range []string{"installer", "main", "first", "second"} { - addTask(t, db, root, id, "Original "+id+" title", nil, "vscode", 0) + if !bytes.Equal(before, after) { + t.Fatal("refused automatic install replaced binary") } - if _, err := install("installer", false, true, false); err != nil { + if err := os.Remove(filepath.Join(stateDir(), "native.json")); err != nil { t.Fatal(err) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Format = 3 - record := value.Tasks["installer"] - record.Original, record.Subject, record.Last = "", homeTitle, homeTitle - value.Tasks["installer"] = record - return true, nil - }); err != nil { + + lock, err := lifecycleLock("lifecycle.lock") + if err != nil { t.Fatal(err) } - if _, err := db.Exec(`UPDATE threads SET title=? WHERE id='installer'`, "⏳ "+homeTitle); err != nil { - t.Fatal(err) + done := make(chan error, 1) + go func() { + _, installErr := install(context.Background(), installOptions{Confirmed: true, Automatic: true}) + done <- installErr + }() + select { + case err := <-done: + unlock(lock) + t.Fatalf("automatic install bypassed lifecycle lock: %v", err) + case <-time.After(50 * time.Millisecond): } - data, _ := os.ReadFile(newStore(stateDir()).path()) - var onDisk state - if json.Unmarshal(data, &onDisk) != nil || onDisk.Format != 3 { - t.Fatalf("legacy fixture = %#v", onDisk) + if err := os.RemoveAll(stateDir()); err != nil { + unlock(lock) + t.Fatal(err) } - if _, err := install("installer", false, true, false); err != nil { + if err := os.Remove(p.binary); err != nil { + unlock(lock) t.Fatal(err) } - data, _ = os.ReadFile(newStore(stateDir()).path()) - if json.Unmarshal(data, &onDisk) != nil || onDisk.Format != stateFormat || onDisk.Tasks["installer"].Original != "ThreadBear" { - t.Fatalf("upgraded state = %#v", onDisk) + unlock(lock) + if err := <-done; err == nil || !strings.Contains(err.Error(), "lifecycle changed while the operation was waiting") { + t.Fatalf("post-uninstall automatic install = %v", err) } - if onDisk.Format == 3 { - t.Fatal("an already-queued v2.2.0 hook would still accept the replaced state") + if _, err := os.Stat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("automatic update reinstalled binary: %v", err) } - if _, err := prepareUninstall(context.Background(), "installer"); err != nil { +} + +func TestAutomaticInstallFailureLeavesOldBinaryAndIsRerunnable(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if _, err := db.Exec(`UPDATE threads SET title='ThreadBear' WHERE id='installer'`); err != nil { + oldBinary := []byte("#!/bin/sh\necho old\n") + if err := os.WriteFile(p.binary, oldBinary, 0o755); err != nil { t.Fatal(err) } - if _, err := completeUninstall(context.Background(), "installer", true, false); err != nil { + mustWrite(t, p.skill, "old managed skill\n") + fake := currentFakeLaunchctl(t) + fake.loaded = false + fake.bootstrapErr = errors.New("bootstrap unavailable") + result, err := install(context.Background(), installOptions{Confirmed: true, Automatic: true}) + partial := result.(map[string]any) + if err == nil || partial["dry_run"] != false || partial["partial"] != true || partial["stage"] != "updater" || partial["restart_required"] != true || partial["safe_rerun"] != "'"+p.binary+"' update --json" { + t.Fatalf("automatic partial install = %#v, %v", result, err) + } + if got, err := os.ReadFile(p.binary); err != nil || !bytes.Equal(got, oldBinary) { + t.Fatalf("failed automatic install binary = %q, %v", got, err) + } + fake.bootstrapErr = nil + if _, err := install(context.Background(), installOptions{Confirmed: true, Automatic: true}); err != nil { t.Fatal(err) } + if got, err := os.ReadFile(p.binary); err != nil || bytes.Equal(got, oldBinary) { + t.Fatalf("rerun did not replace binary: %v", err) + } } -func TestFailedLegacyReinstallLeavesOldReadableState(t *testing.T) { +func TestStatusDoesNotReadCodexDatabase(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("installer", false, true, false); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Format = 3 - return true, nil - }); err != nil { - t.Fatal(err) + matches, _ := filepath.Glob(filepath.Join(codexHome(), "state_*.sqlite")) + for _, match := range matches { + if err := os.Remove(match); err != nil { + t.Fatal(err) + } } - before, _ := os.ReadFile(p.binary) - binDir := filepath.Dir(p.binary) - if err := os.Chmod(binDir, 0o500); err != nil { - t.Fatal(err) + result, err := status(context.Background()) + if err != nil || result.(map[string]any)["ready"] != true || result.(map[string]any)["automatic_updates_enabled"] != true { + t.Fatalf("DB-independent status = %#v, %v", result, err) } - t.Cleanup(func() { _ = os.Chmod(binDir, 0o700) }) - if _, err := install("installer", false, true, false); err == nil { - t.Fatal("reinstall unexpectedly replaced a binary in a read-only directory") + if !regularExecutable(p.binary) { + t.Fatal("installed binary disappeared") } - if err := os.Chmod(binDir, 0o700); err != nil { + mustWrite(t, filepath.Join(newStore(stateDir()).subjectDir(), "corrupt.json"), "not-json") + mustWrite(t, p.updateReceipt, "not-json") + fake := currentFakeLaunchctl(t) + fake.mu.Lock() + fake.loaded = false + fake.mu.Unlock() + if err := os.Remove(p.launchAgent); err != nil { t.Fatal(err) } - value, err := newStore(stateDir()).read() - after, _ := os.ReadFile(p.binary) - if err != nil || value.Format != 3 || !reflect.DeepEqual(before, after) { - t.Fatalf("failed replacement changed old-readable installation: format=%d binary_equal=%v err=%v", value.Format, reflect.DeepEqual(before, after), err) + result, err = status(context.Background()) + value := result.(map[string]any) + if err != nil || value["ready"] != true || value["installed"] != true || value["automatic_updates_enabled"] != false || value["update_receipt_error"] == nil { + t.Fatalf("local status isolation = %#v, %v", value, err) } } -func TestUninstallWaitsForOperationLockBeforeDeleting(t *testing.T) { - p := isolatedLifecycle(t) - sibling := filepath.Join(filepath.Dir(filepath.Dir(p.skill)), "other-skill", "sentinel") - mustWrite(t, sibling, "keep") - if _, err := install("installer", false, true, false); err != nil { +func TestStatusRequiresPrivateRuntimeFenceAndNoLegacyState(t *testing.T) { + isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { + lock := filepath.Join(stateDir(), "lifecycle.lock") + if err := os.Remove(lock); err != nil { t.Fatal(err) } - operationLock, err := newStore(stateDir()).operationLock() - if err != nil { - t.Fatal(err) + if result, err := status(context.Background()); err == nil || result.(map[string]any)["ready"] != false || result.(map[string]any)["installed"] != true { + t.Fatalf("status accepted missing lifecycle fence: %#v, %v", result, err) } - done := make(chan error, 1) - go func() { - _, err := uninstall(context.Background(), true) - done <- err - }() - select { - case err := <-done: - unlock(operationLock) - t.Fatalf("uninstall returned while operation lock was held: %v", err) - case <-time.After(100 * time.Millisecond): - } - for _, path := range []string{p.binary, p.skill, stateDir()} { - if _, err := os.Stat(path); err != nil { - unlock(operationLock) - t.Fatalf("uninstall deleted %s while operation lock was held: %v", path, err) - } + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatalf("reinstall did not repair lifecycle fence: %v", err) } - unlock(operationLock) - select { - case err := <-done: - if err != nil { - t.Fatal(err) - } - case <-time.After(2 * time.Second): - t.Fatal("uninstall did not resume after operation lock was released") + if err := os.Chmod(stateDir(), 0o755); err != nil { + t.Fatal(err) } - for _, path := range []string{p.binary, filepath.Dir(p.skill), stateDir()} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("uninstall left %s after operation lock release: %v", path, err) - } + if result, err := status(context.Background()); err == nil || result.(map[string]any)["ready"] != false { + t.Fatalf("status accepted public state root: %#v, %v", result, err) } - if got, err := os.ReadFile(sibling); err != nil || string(got) != "keep" { - t.Fatalf("uninstall changed sibling skill: %q, %v", got, err) + if err := os.Chmod(stateDir(), 0o700); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":4,"main_task_id":"019fdcbf-d225-7e00-9779-2472e54532e3","tasks":{}}`) + if result, err := status(context.Background()); err == nil || result.(map[string]any)["ready"] != false || result.(map[string]any)["artifacts"].(map[string]bool)["legacy_state_absent"] { + t.Fatalf("status accepted legacy state: %#v, %v", result, err) } } -func TestOperationLockDoesNotRecreateRemovedInstallation(t *testing.T) { - dir := filepath.Join(t.TempDir(), "state") - store := newStore(dir) - operationLock, err := store.waitLock() - if err != nil { +func TestStatusSeparatesPhysicalBinaryPresenceFromReadiness(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - defer unlock(operationLock) - if err := os.RemoveAll(dir); err != nil { + if err := os.Chmod(p.binary, 0o600); err != nil { t.Fatal(err) } - if lock, err := store.operationLock(); err == nil { - unlock(lock) - t.Fatal("operation lock recreated an installation while uninstall held the removed lock inode") + result, err := status(context.Background()) + value := result.(map[string]any) + if err == nil || value["ready"] != false || value["installed"] != true || value["artifacts"].(map[string]bool)["binary"] { + t.Fatalf("non-executable binary status = %#v, %v", value, err) } - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Fatalf("operation lock left a replacement state directory: %v", err) + if err := os.Remove(p.binary); err != nil { + t.Fatal(err) + } + result, err = status(context.Background()) + value = result.(map[string]any) + if err == nil || value["ready"] != false || value["installed"] != false { + t.Fatalf("absent binary status = %#v, %v", value, err) } } -func TestInstallDebugCanariesAreExplicitOptIn(t *testing.T) { +func TestOnboardReturnsCompleteReadOnlyPlan(t *testing.T) { isolatedLifecycle(t) - ordinary, err := install("installer", true, false, false) + requests := stubPagedAppServer(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + result, err := onboard(context.Background(), true, false) if err != nil { t.Fatal(err) } - if _, ok := ordinary.(map[string]any)["debug_canaries"]; ok { - t.Fatal("ordinary install result disclosed debug canaries") + value := result.(onboardingResult) + if !value.Ready || !value.ReadOnly || !value.PlanComplete || value.OnboardingComplete || value.Total != 3 || value.Safe != 1 || value.NeedsUpdate != 1 || value.Updated != 0 || value.Unchanged != 0 || value.Skipped != 2 || value.Unconfirmed != 0 { + t.Fatalf("onboard plan = %#v", value) + } + items := value.Items + if items[2].TaskID != testSafeID || items[2].DesiredTitle != "🐻 Exact subject" { + t.Fatalf("onboard items = %#v", items) } - debug, err := install("installer", true, false, true) + t.Setenv("CODEX_THREAD_ID", testSafeID) + activeResult, err := onboard(context.Background(), true, false) if err != nil { t.Fatal(err) } - if debug.(map[string]any)["debug_canaries"] != true { - t.Fatalf("debug install result = %#v", debug) + active := activeResult.(onboardingResult) + if !active.OnboardingComplete || active.NeedsUpdate != 0 || active.Unchanged != 1 || active.Items[2].Outcome != onboardingUnchanged || active.Items[2].Applied || active.Items[2].Reason != "active task is handled by the terminal title writer" { + t.Fatalf("active-task onboarding plan = %#v", active) + } + data, err := os.ReadFile(requests) + if err != nil || !strings.Contains(string(data), `"method":"initialize"`) || !strings.Contains(string(data), `"cursor":"next"`) { + t.Fatalf("App Server requests = %q, %v", data, err) + } + entries, err := os.ReadDir(newStore(stateDir()).subjectDir()) + if err != nil || len(entries) != 0 { + t.Fatalf("read-only onboard wrote subject state: %#v, %v", entries, err) + } +} + +func stubPagedAppServer(t *testing.T) string { + t.Helper() + dir, requests := t.TempDir(), filepath.Join(t.TempDir(), "requests.jsonl") + script := `#!/bin/sh +[ "$1" = app-server ] && [ "$2" = --stdio ] || exit 80 +count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> "$TB_APP_SERVER_REQUESTS" + count=$((count + 1)) + case "$count" in + 1) printf '%s\n' '{"id":1,"result":{"serverInfo":{"name":"fake"}}}' ;; + 2) ;; + 3) printf '%s\n' '{"method":"thread/started","params":{}}' + printf '%s\n' '{"id":2,"result":{"data":[{"id":"00000000-0000-0000-0000-00000000000d","name":"Exact subject","preview":"safe"},{"id":"00000000-0000-0000-0000-000000000006","name":null,"preview":" private"}],"nextCursor":"next"}}' ;; + 4) printf '%s\n' '{"id":3,"result":{"data":[{"id":"00000000-0000-0000-0000-00000000000c","name":"✅ Maybe owned","preview":"legacy"},{"id":"00000000-0000-0000-0000-00000000000d","name":"Exact subject","preview":"duplicate"}],"nextCursor":null}}' ;; + *) exit 81 ;; + esac +done +` + path := filepath.Join(dir, "codex") + mustWrite(t, path, script) + if err := os.Chmod(path, 0o755); err != nil { + t.Fatal(err) } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TB_APP_SERVER_REQUESTS", requests) + return requests } -func TestStatusRejectsModifiedManagedGuidance(t *testing.T) { +func TestLaunchAgentIsSilentDailyExactAndIdempotent(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("main", false, true, false); err != nil { + fake := currentFakeLaunchctl(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - agents, _ := os.ReadFile(p.agents) - skill, _ := os.ReadFile(p.skill) - for name, change := range map[string]func(){ - "agents": func() { - mustWrite(t, p.agents, strings.Replace(string(agents), "# ThreadBear", "# ThreadBear edited", 1)) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(p.launchAgent) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, required := range []string{updateAgentLabel, p.binary, "update", "--automatic", "--json", "StartCalendarInterval", "EnvironmentVariables", "HOME", homeDir(), "CODEX_HOME", codexHome(), "StandardOutPath", "StandardErrorPath", "/dev/null"} { + if !strings.Contains(text, required) { + t.Errorf("plist lacks %q", required) + } + } + for _, forbidden := range []string{"RunAtLoad", "KeepAlive", "onboard", "CODEX_THREAD_ID"} { + if strings.Contains(text, forbidden) { + t.Errorf("plist contains %q", forbidden) + } + } + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.bootstraps != 1 || fake.bootouts != 0 { + t.Fatalf("launchctl calls = bootstraps %d bootouts %d", fake.bootstraps, fake.bootouts) + } +} + +func TestLoadedUpdateAgentDriftIsReportedWithoutBreakingCoreReadiness(t *testing.T) { + tests := map[string]func(string, lifecyclePaths) string{ + "plist path": func(output string, p lifecyclePaths) string { + return strings.Replace(output, "path = "+p.launchAgent, "path = /tmp/foreign.plist", 1) }, - "skill": func() { mustWrite(t, p.skill, string(skill)+"edited\n") }, - } { + "program": func(output string, p lifecyclePaths) string { + return strings.Replace(output, "program = "+p.binary, "program = /tmp/foreign", 1) + }, + "arguments": func(output string, _ lifecyclePaths) string { + return strings.Replace(output, "\t\t--automatic\n", "\t\t--foreign\n", 1) + }, + } + for name, drift := range tests { t.Run(name, func(t *testing.T) { - change() - if _, err := status(context.Background()); err == nil { - t.Fatalf("status accepted modified %s", name) + p := isolatedLifecycle(t) + fake := currentFakeLaunchctl(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + fake.mu.Lock() + fake.printOutput = []byte(drift(string(managedLaunchctlPrint(p.launchAgent, p.binary)), p)) + fake.mu.Unlock() + + result, err := status(context.Background()) + value := result.(map[string]any) + if err != nil || value["ready"] != true || value["installed"] != true || value["automatic_updates_enabled"] != false || value["updater_error"] == nil { + t.Fatalf("status with loaded updater drift = %#v, %v", value, err) + } + if _, err := install(context.Background(), installOptions{DryRun: true}); err == nil { + t.Fatal("install preflight accepted a foreign loaded updater") + } + if _, err := uninstall(context.Background(), uninstallOptions{DryRun: true}); err == nil { + t.Fatal("uninstall preflight accepted a foreign loaded updater") + } + if !regularExecutable(p.binary) { + t.Fatal("refused lifecycle preflight removed the binary") + } + fake.mu.Lock() + bootouts := fake.bootouts + fake.mu.Unlock() + if bootouts != 0 { + t.Fatal("refused lifecycle preflight booted out the foreign job") } - mustWrite(t, p.agents, string(agents)) - mustWrite(t, p.skill, string(skill)) }) } } -func TestUninstallRejectsModifiedManagedGuidanceBeforeMutation(t *testing.T) { +func TestLoadedUpdateAgentOperationalPrintFailureIsNotAbsence(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("main", false, true, false); err != nil { + fake := currentFakeLaunchctl(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { + notFound := exec.Command("/bin/sh", "-c", "exit 113").Run() + fake.mu.Lock() + fake.printErr = notFound + fake.mu.Unlock() + if loaded, err := updateAgentLoaded(context.Background(), p.launchAgent, p.binary); err != nil || loaded { + t.Fatalf("launchctl service-not-found result = loaded %t, %v", loaded, err) + } + exitError := exec.Command("/bin/sh", "-c", "exit 1").Run() + fake.mu.Lock() + fake.printErr = exitError + fake.mu.Unlock() + result, err := status(context.Background()) + value := result.(map[string]any) + if err != nil || value["ready"] != true || value["automatic_updates_enabled"] != false || value["updater_error"] == nil { + t.Fatalf("status with launchctl failure = %#v, %v", value, err) + } + if _, err := uninstall(context.Background(), uninstallOptions{DryRun: true}); err == nil { + t.Fatal("uninstall treated an operational launchctl failure as an absent job") + } + if !regularExecutable(p.binary) { + t.Fatal("refused uninstall removed the binary") + } + fake.mu.Lock() + bootouts := fake.bootouts + fake.mu.Unlock() + if bootouts != 0 { + t.Fatal("refused uninstall tried to boot out an unverified job") + } +} + +func TestUninstallInvalidatesPreopenedLifecycleWaiter(t *testing.T) { + p := isolatedLifecycle(t) + fake := currentFakeLaunchctl(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - agents, _ := os.ReadFile(p.agents) - hooks, _ := os.ReadFile(p.hooks) - mustWrite(t, p.agents, strings.Replace(string(agents), "# ThreadBear", "# ThreadBear edited", 1)) - if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "managed file was modified") { - t.Fatalf("modified guidance uninstall = %v", err) + started, proceed := make(chan struct{}), make(chan struct{}) + fake.mu.Lock() + fake.bootoutStarted, fake.continueBootout = started, proceed + fake.mu.Unlock() + uninstalled := make(chan error, 1) + go func() { + _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + uninstalled <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("uninstall did not reach updater teardown") + } + waiter := make(chan error, 1) + go func() { + lock, err := existingLifecycleLock("lifecycle.lock") + if err == nil { + unlock(lock) + } + waiter <- err + }() + select { + case err := <-waiter: + t.Fatalf("lifecycle waiter bypassed uninstall: %v", err) + case <-time.After(50 * time.Millisecond): } - if got, _ := os.ReadFile(p.hooks); !reflect.DeepEqual(got, hooks) { - t.Fatal("blocked uninstall changed hooks") + close(proceed) + if err := <-uninstalled; err != nil { + t.Fatal(err) } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("blocked uninstall removed binary: %v", err) + if err := <-waiter; err == nil || !strings.Contains(err.Error(), "changed while the operation was waiting") { + t.Fatalf("preopened waiter = %v", err) + } + if _, err := os.Stat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall retained binary: %v", err) } + if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("normal uninstall retained state directory: %v", err) + } + lock, err := lifecycleLock("lifecycle.lock") + if err != nil { + t.Fatalf("fresh lifecycle could not start after teardown: %v", err) + } + unlock(lock) } -func TestUninstallRejectsMarkerlessManagedGuidance(t *testing.T) { +func TestUninstallRemovesOwnedArtifactsAndPreservesNeighbors(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("main", false, true, false); err != nil { + fake := currentFakeLaunchctl(t) + foreignAgents := "# Mine\nkeep\n" + mustWrite(t, p.agents, foreignAgents) + hooks := filepath.Join(codexHome(), "hooks.json") + wantHooks := []byte(`{"owner":"user","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"mine"}]}]}}` + "\n") + mustWrite(t, hooks, string(wantHooks)) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { + neighbor := filepath.Join(filepath.Dir(p.skill), "notes.md") + mustWrite(t, neighbor, "preserve") + stateNeighbor := filepath.Join(stateDir(), "user-note.txt") + subjectNeighbor := filepath.Join(newStore(stateDir()).subjectDir(), "user-note.txt") + ownedID := "019fc53a-4aa6-7221-ad51-165301675116" + ownedRecord := filepath.Join(newStore(stateDir()).subjectDir(), ownedID+".json") + ownedLock := filepath.Join(newStore(stateDir()).subjectDir(), ownedID+".lock") + mustWrite(t, stateNeighbor, "preserve state neighbor") + mustWrite(t, subjectNeighbor, "preserve subject neighbor") + mustWrite(t, ownedRecord, `{"subject":"Owned subject"}`+"\n") + mustWrite(t, ownedLock, "") + preview, err := uninstall(context.Background(), uninstallOptions{DryRun: true}) + if err != nil || preview.(map[string]any)["icons_may_remain"] != true { + t.Fatalf("uninstall preview = %#v, %v", preview, err) + } + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { t.Fatal(err) } - agents, _ := os.ReadFile(p.agents) - markerless := strings.ReplaceAll(strings.ReplaceAll(string(agents), blockStart, ""), blockEnd, "") - markerless = strings.Replace(markerless, "The footer must be", "The footer remains", 1) - mustWrite(t, p.agents, markerless) - if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "managed file was modified") { - t.Fatalf("markerless guidance uninstall = %v", err) + for _, path := range []string{p.binary, p.skill, p.launchAgent} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall left %s: %v", path, err) + } } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("blocked uninstall removed binary: %v", err) + for _, path := range []string{ownedRecord, ownedLock, p.updateReceipt, filepath.Join(stateDir(), "update.lock"), filepath.Join(stateDir(), "lifecycle.lock")} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall left owned state %s: %v", path, err) + } + } + if got, err := os.ReadFile(neighbor); err != nil || string(got) != "preserve" { + t.Fatalf("skill neighbor = %q, %v", got, err) + } + if got, err := os.ReadFile(stateNeighbor); err != nil || string(got) != "preserve state neighbor" { + t.Fatalf("state neighbor = %q, %v", got, err) + } + if got, err := os.ReadFile(subjectNeighbor); err != nil || string(got) != "preserve subject neighbor" { + t.Fatalf("subject neighbor = %q, %v", got, err) + } + if got, err := os.ReadFile(p.agents); err != nil || string(got) != foreignAgents { + t.Fatalf("foreign AGENTS = %q, %v", got, err) + } + if got, err := os.ReadFile(hooks); err != nil || !bytes.Equal(got, wantHooks) { + t.Fatalf("foreign hooks = %q, %v", got, err) + } + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.bootouts != 1 || fake.loaded { + t.Fatalf("updater not removed first: %#v", fake) } } -func TestUninstallKeepsBinaryUntilStateRemovalCommits(t *testing.T) { +func TestUninstallDryRunRequiresCurrentInstallWithoutMutation(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) + result, err := uninstall(context.Background(), uninstallOptions{DryRun: true}) + if err == nil || result.(map[string]any)["dry_run"] != true { + t.Fatalf("missing install preview = %#v, %v", result, err) + } + for _, path := range []string{p.binary, p.agents, p.skill, p.launchAgent, stateDir()} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall preview created %s: %v", path, err) + } } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { +} + +func TestLifecycleIsolatesCorruptOwnedStateAndPreservesNeighbors(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - stateParent := filepath.Dir(stateDir()) - if err := os.Chmod(stateParent, 0o500); err != nil { + subjectDir := newStore(stateDir()).subjectDir() + owned := filepath.Join(subjectDir, "019fc53a-4aa6-7221-ad51-165301675116.json") + unknownJSON := filepath.Join(subjectDir, "unknown.json") + unknownLock := filepath.Join(subjectDir, "unknown.lock") + link := filepath.Join(subjectDir, "note") + target := filepath.Join(t.TempDir(), "note") + mustWrite(t, owned, "not-json") + mustWrite(t, unknownJSON, `{}`) + mustWrite(t, unknownLock, "mine") + mustWrite(t, target, "mine") + mustWrite(t, p.updateReceipt, "not-json") + if err := os.Symlink(target, link); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(stateParent, 0o700) }) - if _, err := uninstall(context.Background(), true); err == nil { - t.Fatal("uninstall succeeded while state directory could not be removed") + + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatalf("isolated corruption blocked reinstall: %v", err) } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("failed state removal deleted retry binary: %v", err) + if _, err := uninstall(context.Background(), uninstallOptions{DryRun: true}); err != nil { + t.Fatalf("isolated corruption blocked uninstall preview: %v", err) } - if err := os.Chmod(stateParent, 0o700); err != nil { - t.Fatal(err) + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + t.Fatalf("isolated corruption blocked uninstall: %v", err) } - if _, err := uninstall(context.Background(), true); err != nil { - t.Fatalf("resumed teardown: %v", err) + for _, path := range []string{owned, p.updateReceipt, p.binary} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall retained owned path %s: %v", path, err) + } } - for _, path := range []string{p.binary, filepath.Dir(p.skill)} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("resumed teardown left %s: %v", path, err) + for path, want := range map[string]string{unknownJSON: `{}`, unknownLock: "mine", target: "mine"} { + if got, err := os.ReadFile(path); err != nil || string(got) != want { + t.Fatalf("preserved neighbor %s = %q, %v", path, got, err) } } + if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("preserved symlink = %v, %v", info, err) + } } -func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { +func TestUninstallRefusesUnsafeOwnedSubjectLeafBeforeMutation(t *testing.T) { p := isolatedLifecycle(t) - malformed := []byte(`{"hooks":{"PreToolUse":{"not":"an array"}}}`) - mustWrite(t, p.hooks, string(malformed)) - if _, err := install("installer", false, true, false); err == nil { - t.Fatal("install accepted wrong-shaped hooks") + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) } - got, _ := os.ReadFile(p.hooks) - if !reflect.DeepEqual(got, malformed) { - t.Fatal("failed install changed hooks.json") + target := filepath.Join(t.TempDir(), "mine") + mustWrite(t, target, "mine") + ownedLink := filepath.Join(newStore(stateDir()).subjectDir(), "019fc53a-4aa6-7221-ad51-165301675116.lock") + if err := os.Symlink(target, ownedLink); err != nil { + t.Fatal(err) } - if _, err := os.Stat(p.binary); !os.IsNotExist(err) { - t.Fatal("failed install copied the binary") + if _, err := uninstall(context.Background(), uninstallOptions{DryRun: true}); err == nil || !strings.Contains(err.Error(), "owned subject path") { + t.Fatalf("unsafe owned leaf preview = %v", err) } - mustWrite(t, p.binary, "sentinel") - if _, err := uninstall(context.Background(), true); err == nil { - t.Fatal("uninstall accepted wrong-shaped hooks") + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("failed preflight removed binary: %v", err) } - if got, _ := os.ReadFile(p.binary); string(got) != "sentinel" { - t.Fatal("failed uninstall mutated installation") + fake := currentFakeLaunchctl(t) + fake.mu.Lock() + loaded := fake.loaded + fake.mu.Unlock() + if !loaded { + t.Fatal("failed preflight booted out updater") } } -func TestOwnedHookQuotesBinaryPath(t *testing.T) { - binary := filepath.Join(t.TempDir(), "Eric O'Brien Bear", "threadbear") - mustWrite(t, binary, "#!/bin/sh\nprintf '%s:%s' \"$#\" \"$1\"\n") - if err := os.Chmod(binary, 0o700); err != nil { +func TestUninstallPartialNamesStageAndSafeRerun(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - hooks := filepath.Join(t.TempDir(), "hooks.json") - data, write, err := editHooks(hooks, binary, true) - if err != nil || !write { - t.Fatalf("edit hooks: write %v, err %v", write, err) + fake := currentFakeLaunchctl(t) + fake.bootoutStarted = make(chan struct{}) + continueBootout := make(chan struct{}) + fake.continueBootout = continueBootout + type outcome struct { + result any + err error } - mustWrite(t, hooks, string(data)) - assertHookOrder(t, hooks, "PreToolUse", nil, binary) - assertHookOrder(t, hooks, "PostToolUse", nil, binary) - output, err := exec.Command("sh", "-c", quoteCommand(binary)).CombinedOutput() - if err != nil || string(output) != "1:hook" { - t.Fatalf("quoted command invoked %q, err %v", output, err) + done := make(chan outcome, 1) + go func() { + result, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + done <- outcome{result: result, err: err} + }() + <-fake.bootoutStarted + backup := codexHome() + ".backup" + if err := os.Rename(codexHome(), backup); err != nil { + t.Fatal(err) } -} - -func TestUninstallRemovesOwnedOnlyHooksFile(t *testing.T) { - p := isolatedLifecycle(t) - data, _, err := editHooks(p.hooks, p.binary, true) - if err != nil { + mustWrite(t, codexHome(), "collision") + close(continueBootout) + got := <-done + partial := got.result.(map[string]any) + if got.err == nil || partial["partial"] != true || partial["stage"] != "managed_guidance" || partial["restart_required"] != true || partial["safe_rerun"] != "'"+p.binary+"' uninstall --noninteractive --confirm --json" { + t.Fatalf("uninstall partial = %#v, %v", partial, got.err) + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("partial uninstall removed binary: %v", err) + } + if err := os.Remove(codexHome()); err != nil { t.Fatal(err) } - mustWrite(t, p.hooks, string(data)) - if _, err := uninstall(context.Background(), true); err != nil { + if err := os.Rename(backup, codexHome()); err != nil { t.Fatal(err) } - if _, err := os.Stat(p.hooks); !os.IsNotExist(err) { - t.Fatalf("owned-only hooks file survived: %v", err) + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + t.Fatalf("safe rerun failed: %v", err) } } -func TestInstallUpgradesOwnedHookTimeoutAndUninstallRemovesVariants(t *testing.T) { +func TestUninstallLateBinaryFailureKeepsConfirmedRerunAdmissible(t *testing.T) { p := isolatedLifecycle(t) - foreignPreA := json.RawMessage(`{"matcher":"codex_appset_thread_title","hooks":[{"type":"command","command":"foreign","timeout":5}]}`) - foreignPreB := json.RawMessage(`{"matcher":"Bash","hooks":[{"type":"command","command":"pre"}]}`) - foreignPreC := encodedJSON(map[string]any{"matcher": titleTool, "hooks": []any{map[string]any{"type": "prompt", "command": quoteCommand(p.binary), "timeout": 5}}}) - foreignPost := json.RawMessage(`{"matcher":"Bash","hooks":[{"type":"command","command":"post"}]}`) - oldOwner := ownedHookWithTimeout(p.binary, 5) - fixture := map[string]any{"hooks": map[string]any{ - "PreToolUse": []json.RawMessage{oldOwner, foreignPreA, ownedHookJSON(p.binary), foreignPreB, foreignPreC, oldOwner}, - "PostToolUse": []json.RawMessage{foreignPost, oldOwner}, - }} - data, _ := json.MarshalIndent(fixture, "", " ") - mustWrite(t, p.hooks, string(data)) - - if _, err := install("installer", false, true, false); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } - assertHookOrder(t, p.hooks, "PreToolUse", []json.RawMessage{foreignPreA, foreignPreB, foreignPreC}, p.binary) - assertHookOrder(t, p.hooks, "PostToolUse", []json.RawMessage{foreignPost}, p.binary) - installed, _ := os.ReadFile(p.hooks) - if _, err := install("installer", false, true, false); err != nil { + running, err := os.Executable() + if err != nil { t.Fatal(err) } - reinstalled, _ := os.ReadFile(p.hooks) - if !reflect.DeepEqual(installed, reinstalled) { - t.Fatal("reinstall rewrote upgraded hooks.json") + if err := os.Remove(p.binary); err != nil { + t.Fatal(err) } - mustWrite(t, p.hooks, string(data)) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { + if err := os.Link(running, p.binary); err != nil { t.Fatal(err) } - if _, err := uninstall(context.Background(), true); err != nil { + binDir := filepath.Dir(p.binary) + if err := os.Chmod(binDir, 0o500); err != nil { t.Fatal(err) } - assertHookOrder(t, p.hooks, "PreToolUse", []json.RawMessage{foreignPreA, foreignPreB, foreignPreC}, "") - assertHookOrder(t, p.hooks, "PostToolUse", []json.RawMessage{foreignPost}, "") -} - -func ownedHookWithTimeout(binary string, timeout int) json.RawMessage { - data, _ := json.Marshal(map[string]any{"matcher": titleTool, "hooks": []any{map[string]any{"type": "command", "command": quoteCommand(binary), "timeout": timeout}}}) - return data -} + t.Cleanup(func() { _ = os.Chmod(binDir, 0o700) }) -func sameJSON(a, b []byte) bool { - var left, right any - return json.Unmarshal(a, &left) == nil && json.Unmarshal(b, &right) == nil && reflect.DeepEqual(left, right) -} + result, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + partial := result.(map[string]any) + if err == nil || partial["partial"] != true || partial["stage"] != "binary" || partial["safe_rerun"] != uninstallRerun(p) { + t.Fatalf("late uninstall partial = %#v, %v", partial, err) + } + if !regularExecutable(p.binary) { + t.Fatal("failed binary unlink left no callable rerun") + } + if partial, err := preflightUninstall(context.Background(), p); err != nil || !partial { + t.Fatalf("failed binary unlink was not admitted as a self-binary rerun: partial=%t, err=%v", partial, err) + } + for _, path := range []string{p.skill, p.launchAgent} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("late partial retained removed surface %s: %v", path, err) + } + } + if err := os.MkdirAll(filepath.Dir(p.skill), 0o700); err != nil { + t.Fatal(err) + } + mustWrite(t, p.skill, "foreign replacement") + if result, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err == nil || result.(map[string]any)["partial"] != false { + t.Fatalf("partial rerun removed a replacement skill: %#v, %v", result, err) + } + if got, err := os.ReadFile(p.skill); err != nil || string(got) != "foreign replacement" { + t.Fatalf("replacement skill = %q, %v", got, err) + } + if err := os.Remove(p.skill); err != nil { + t.Fatal(err) + } + _ = os.Remove(filepath.Dir(p.skill)) -func ownedHookJSON(binary string) json.RawMessage { - return encodedJSON(map[string]any{"matcher": "codex_appset_thread_title", "hooks": []any{map[string]any{"type": "command", "command": quoteCommand(binary), "timeout": 1}}}) + if err := os.Chmod(binDir, 0o700); err != nil { + t.Fatal(err) + } + result, err = uninstall(context.Background(), uninstallOptions{Confirmed: true}) + if err != nil || result.(map[string]any)["uninstalled"] != true { + t.Fatalf("confirmed uninstall rerun = %#v, %v", result, err) + } + for _, path := range []string{p.binary, stateDir()} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("confirmed rerun retained %s: %v", path, err) + } + } } -func isolatedLifecycle(t *testing.T) lifecyclePaths { - t.Helper() - root, db := testIndex(t) - for _, id := range []string{"installer", "main", "first", "second"} { - addTask(t, db, root, id, "Original "+id+" title", nil, "vscode", 0) +func TestUninstallRemovesDriftedOwnedSurfaceAndPreservesNeighbors(t *testing.T) { + p := isolatedLifecycle(t) + mustWrite(t, p.agents, "# Mine\nkeep\n") + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + mustWrite(t, p.skill, assets.SkillManagedContent+"edited\n") + neighbor := filepath.Join(filepath.Dir(p.skill), "notes.md") + mustWrite(t, neighbor, "preserve") + agents, err := os.ReadFile(p.agents) + if err != nil { + t.Fatal(err) + } + drifted := strings.Replace(string(agents), "For every ordinary interactive turn", "For every edited interactive turn", 1) + if drifted == string(agents) { + t.Fatal("managed AGENTS fixture did not contain expected text") + } + mustWrite(t, p.agents, drifted) + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + t.Fatalf("drifted uninstall = %v", err) + } + for _, path := range []string{p.binary, p.skill, p.launchAgent} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("drifted uninstall retained %s: %v", path, err) + } + } + if got, err := os.ReadFile(neighbor); err != nil || string(got) != "preserve" { + t.Fatalf("skill neighbor = %q, %v", got, err) + } + if got, err := os.ReadFile(p.agents); err != nil || string(got) != "# Mine\nkeep\n" { + t.Fatalf("AGENTS neighbor content = %q, %v", got, err) } - return installPaths() } -func writeHookFixture(t *testing.T, path string, preA, preB, postA json.RawMessage) { - t.Helper() - value := map[string]any{ - "owner": map[string]any{"preserve": true}, - "hooks": map[string]any{ - "PreToolUse": []json.RawMessage{preA, preB}, - "PostToolUse": []json.RawMessage{postA}, - "Stop": []any{map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "stop"}}}}, +func TestUninstallRefusesMalformedMarkersAndUnsafeSkillLeaf(t *testing.T) { + for name, mutate := range map[string]func(*testing.T, lifecyclePaths){ + "missing marker": func(t *testing.T, p lifecyclePaths) { + data, _ := os.ReadFile(p.agents) + mustWrite(t, p.agents, strings.Replace(string(data), blockEnd, "", 1)) + }, + "duplicate marker": func(t *testing.T, p lifecyclePaths) { + data, _ := os.ReadFile(p.agents) + mustWrite(t, p.agents, string(data)+"\n"+blockStart+"\n") }, + "skill symlink": func(t *testing.T, p lifecyclePaths) { + target := filepath.Join(t.TempDir(), "mine") + mustWrite(t, target, "mine") + if err := os.Remove(p.skill); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, p.skill); err != nil { + t.Fatal(err) + } + }, + } { + t.Run(name, func(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + mutate(t, p) + if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err == nil { + t.Fatal("unsafe uninstall preflight succeeded") + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("failed preflight removed binary: %v", err) + } + }) } - data, _ := json.MarshalIndent(value, "", " ") - mustWrite(t, path, string(data)) } -func assertHookOrder(t *testing.T, path, event string, foreign []json.RawMessage, binary string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { +func TestInstallPreflightRefusesSkillDirectorySymlink(t *testing.T) { + p := isolatedLifecycle(t) + target := t.TempDir() + userFile := filepath.Join(target, "notes.md") + mustWrite(t, userFile, "preserve") + if err := os.MkdirAll(filepath.Dir(filepath.Dir(p.skill)), 0o700); err != nil { t.Fatal(err) } - var root struct { - Hooks map[string][]json.RawMessage `json:"hooks"` + if err := os.Symlink(target, filepath.Dir(p.skill)); err != nil { + t.Fatal(err) } - if json.Unmarshal(data, &root) != nil { - t.Fatal("invalid hooks output") + if _, err := install(context.Background(), installOptions{DryRun: true}); err == nil || !strings.Contains(err.Error(), "managed parent") { + t.Fatalf("symlinked dry run = %v", err) } - wantLen := len(foreign) - if binary != "" { - wantLen++ + if _, err := install(context.Background(), installOptions{Confirmed: true}); err == nil || !strings.Contains(err.Error(), "managed parent") { + t.Fatalf("symlinked install = %v", err) } - if len(root.Hooks[event]) != wantLen { - t.Fatalf("%s groups = %d, want %d", event, len(root.Hooks[event]), wantLen) + if info, err := os.Lstat(filepath.Dir(p.skill)); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("skill directory symlink = %#v, %v", info, err) } - for i := range foreign { - if !sameJSON(root.Hooks[event][i], foreign[i]) { - t.Fatalf("%s foreign group %d changed: %s", event, i, root.Hooks[event][i]) - } + if got, err := os.ReadFile(userFile); err != nil || string(got) != "preserve" { + t.Fatalf("skill neighbor = %q, %v", got, err) } - if binary != "" && !sameJSON(root.Hooks[event][len(foreign)], ownedHookJSON(binary)) { - t.Fatalf("%s missing exact owned hook: %s", event, root.Hooks[event][len(foreign)]) +} + +func isolatedLifecycle(t *testing.T) lifecyclePaths { + t.Helper() + root, db := testIndex(t) + for _, id := range []string{testInstallerID, testMainID, testFirstID, testSecondID} { + addTask(t, db, root, id, "Original "+id+" title", nil, "vscode", 0) } - if binary != "" { - var owner struct { - Matcher string `json:"matcher"` - Hooks []struct { - Type, Command string - Timeout int - } `json:"hooks"` - } - if json.Unmarshal(root.Hooks[event][len(foreign)], &owner) != nil || owner.Matcher != titleTool || len(owner.Hooks) != 1 || owner.Hooks[0].Type != "command" || owner.Hooks[0].Command != quoteCommand(binary) || owner.Hooks[0].Timeout != 1 { - t.Fatalf("%s owned hook contract = %+v", event, owner) - } + if err := os.RemoveAll(stateDir()); err != nil { + t.Fatal(err) } + stubLaunchctl(t) + return installPaths() } -func TestManagedGuidanceBoundsEachNativeTitleCall(t *testing.T) { - guidance := assets.AgentsManagedContent - for _, required := range []string{ - "const attempt = Date.now().toString(36)", - "const result = await Promise.race([", - "tools.codex_app__set_thread_title({title:\"REPLACE WITH THE REQUIRED TITLE\" + \"⁣\" + attempt})", - "new Promise(resolve => setTimeout(() => resolve({status:\"timeout\"}), 4000))", - "if (result.status === \"failed\")", - "THREADBEAR_TITLE_ATTEMPT='${attempt}'", - "Make exactly one native attempt.", - "never retry or await that promise", - "Explicit-target lifecycle mutations are governed by the installed ThreadBear skill instead.", - "do not add this `Promise.race` unless the skill explicitly requires a four-second attempt", - } { - if !strings.Contains(guidance, required) { - t.Errorf("managed guidance is missing bounded-call contract %q", required) - } - } - if strings.Contains(guidance, "retry it once") { - t.Fatal("managed guidance still permits a second native attempt") +var fakeLaunchctlByTest sync.Map + +func stubLaunchctl(t *testing.T) *fakeLaunchctl { + t.Helper() + fake := &fakeLaunchctl{} + old := launchctlRunner + launchctlRunner = fake.run + fakeLaunchctlByTest.Store(t, fake) + t.Cleanup(func() { + launchctlRunner = old + fakeLaunchctlByTest.Delete(t) + }) + return fake +} + +func currentFakeLaunchctl(t *testing.T) *fakeLaunchctl { + t.Helper() + value, ok := fakeLaunchctlByTest.Load(t) + if !ok { + t.Fatal("launchctl was not stubbed") } + return value.(*fakeLaunchctl) } func mustWrite(t *testing.T, path, value string) { diff --git a/cmd/threadbear/launchagent.go b/cmd/threadbear/launchagent.go new file mode 100644 index 0000000..40a8b26 --- /dev/null +++ b/cmd/threadbear/launchagent.go @@ -0,0 +1,180 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "html" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" +) + +const ( + updateAgentLabel = "sh.threadbear.update" + launchctlServiceNotFoundExit = 113 +) + +var errLaunchAgentNotLoaded = errors.New("LaunchAgent is not loaded") +var launchctlRunner = func(ctx context.Context, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, "/bin/launchctl", args...).CombinedOutput() +} + +type updateAgentState struct { + Label string `json:"label"` + Path string `json:"path"` + Exact bool `json:"exact"` + Loaded bool `json:"loaded"` + ProgramArguments []string `json:"program_arguments"` +} + +func updateAgentPath() string { + return filepath.Join(homeDir(), "Library", "LaunchAgents", updateAgentLabel+".plist") +} +func updateAgentArguments(binary string) []string { + return []string{binary, "update", "--automatic", "--json"} +} +func updateAgentDomain() string { return "gui/" + strconv.Itoa(os.Getuid()) } +func updateAgentTarget() string { return updateAgentDomain() + "/" + updateAgentLabel } + +func updateAgentPlist(binary string) []byte { + var arguments strings.Builder + for _, argument := range updateAgentArguments(binary) { + fmt.Fprintf(&arguments, "%s", html.EscapeString(argument)) + } + return []byte(` + + +Label` + updateAgentLabel + ` +ProgramArguments` + arguments.String() + ` +StartCalendarIntervalHour12Minute0 +EnvironmentVariablesHOME` + html.EscapeString(homeDir()) + `CODEX_HOME` + html.EscapeString(codexHome()) + ` +StandardOutPath/dev/null +StandardErrorPath/dev/null + +`) +} + +func updateAgentLoaded(ctx context.Context, path, binary string) (bool, error) { + output, err := launchctlRunner(ctx, "print", updateAgentTarget()) + if err == nil { + if !exactLoadedUpdateAgent(output, path, binary) { + return true, errors.New("loaded update LaunchAgent does not match the exact managed plist") + } + return true, nil + } + var exitError *exec.ExitError + if errors.Is(err, errLaunchAgentNotLoaded) || errors.As(err, &exitError) && exitError.ExitCode() == launchctlServiceNotFoundExit { + return false, nil + } + return false, fmt.Errorf("inspect %s LaunchAgent: %w: %s", updateAgentLabel, err, strings.TrimSpace(string(output))) +} + +func exactLoadedUpdateAgent(output []byte, path, binary string) bool { + lines := strings.Split(string(output), "\n") + target, plist, program := false, false, false + var arguments []string + for index := 0; index < len(lines); index++ { + line := strings.TrimSpace(lines[index]) + switch line { + case updateAgentTarget() + " = {": + target = true + case "path = " + path: + plist = true + case "program = " + binary: + program = true + case "arguments = {": + for index++; index < len(lines); index++ { + argument := strings.TrimSpace(lines[index]) + if argument == "}" { + break + } + if argument != "" { + arguments = append(arguments, argument) + } + } + } + } + return target && plist && program && slices.Equal(arguments, updateAgentArguments(binary)) +} + +func inspectUpdateAgent(ctx context.Context, path, binary string) (updateAgentState, bool, error) { + info, statErr := os.Lstat(path) + exists := statErr == nil + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return updateAgentState{}, false, statErr + } + if exists && (!info.Mode().IsRegular() || info.Mode().Perm() != 0o600) { + return updateAgentState{}, true, errors.New("update LaunchAgent path is not a private regular file") + } + var data []byte + if exists { + var err error + if data, err = os.ReadFile(path); err != nil { + return updateAgentState{}, true, err + } + } + loaded, err := updateAgentLoaded(ctx, path, binary) + state := updateAgentState{ + Label: updateAgentLabel, Path: path, Exact: exists && bytes.Equal(data, updateAgentPlist(binary)), + Loaded: loaded, ProgramArguments: updateAgentArguments(binary), + } + if err != nil { + return state, exists, err + } + if exists && !state.Exact { + return state, true, errors.New("update LaunchAgent path contains non-ThreadBear content") + } + if loaded && !state.Exact { + return state, exists, errors.New("update LaunchAgent label is loaded without the exact managed plist") + } + return state, exists, nil +} + +func installUpdateAgent(ctx context.Context, path, binary string) error { + state, exists, err := inspectUpdateAgent(ctx, path, binary) + if err != nil { + return err + } + if state.Loaded { + return nil + } + if !exists { + if err := writeAtomic(path, updateAgentPlist(binary), 0o600); err != nil { + return err + } + } + output, err := launchctlRunner(ctx, "bootstrap", updateAgentDomain(), path) + if err != nil { + if !exists { + _ = os.Remove(path) + } + return fmt.Errorf("bootstrap %s LaunchAgent: %w: %s", updateAgentLabel, err, strings.TrimSpace(string(output))) + } + loaded, err := updateAgentLoaded(ctx, path, binary) + if err != nil || !loaded { + return errors.Join(err, errors.New("update LaunchAgent did not load")) + } + return nil +} + +func removeUpdateAgent(ctx context.Context, path, binary string) error { + state, exists, err := inspectUpdateAgent(ctx, path, binary) + if err != nil { + return err + } + if state.Loaded { + output, err := launchctlRunner(ctx, "bootout", updateAgentTarget()) + if err != nil { + return fmt.Errorf("bootout %s LaunchAgent: %w: %s", updateAgentLabel, err, strings.TrimSpace(string(output))) + } + } + if exists { + return os.Remove(path) + } + return nil +} diff --git a/cmd/threadbear/lifecycle_test.go b/cmd/threadbear/lifecycle_test.go deleted file mode 100644 index 6f5f36e..0000000 --- a/cmd/threadbear/lifecycle_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package main - -import ( - "bytes" - "context" - "os" - "strings" - "testing" -) - -func TestMigrationInventoryExcludesMainAndController(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - targetRollout := addTask(t, db, root, "target", "Target", nil, "vscode", 0) - writeMigrationRollout(t, targetRollout, "🧵🐻 complete") - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - items, _, _, err := migrationInventory(context.Background()) - if err != nil || len(items) != 1 || items[0].TaskID != "target" { - t.Fatalf("migration scope = %#v, %v", items, err) - } -} - -func TestMigrationControllerRequiresAppliedFinalConvergence(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - targetRollout := addTask(t, db, root, "target", "Target", nil, "vscode", 0) - writeMigrationRollout(t, targetRollout, "🧵🐻 complete") - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "other", false); err == nil { - t.Fatal("migration accepted a second controller") - } - if _, err := transitionMigration(context.Background(), phaseMigrationComplete, "controller", false); err == nil { - t.Fatal("migration completed before applied inventory convergence") - } - if _, err := db.Exec(`UPDATE threads SET name=? WHERE id='target'`, "✅ Target"); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Tasks["target"] = taskState{Subject: "Target", Last: "✅ Target", Status: "complete"} - return true, nil - }); err != nil { - t.Fatal(err) - } - result, err := transitionMigration(context.Background(), phaseMigrationComplete, "controller", false) - if err != nil { - t.Fatal(err) - } - if result.(map[string]any)["ready"] != true { - t.Fatalf("completion result = %#v", result) - } - value, err := newStore(stateDir()).read() - if err != nil || value.Phase != phaseMigrationComplete { - t.Fatalf("completed state = %#v, %v", value, err) - } -} - -func TestMigrationResumeReconcilesOnlyExactOwnedAppliedProposal(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - addTask(t, db, root, "target", "✅ Target", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationFailed - value.Tasks["target"] = taskState{Pending: &pendingProposal{CallerTaskID: "controller", BaseSubject: "Target", Prior: "Target", Proposed: "✅ Target", Status: "complete"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - t.Setenv("CODEX_THREAD_ID", "controller") - result, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false) - if err != nil { - t.Fatalf("resume = %#v, %v", result, err) - } - value, err := newStore(stateDir()).read() - if got := value.Tasks["target"]; err != nil || got.Pending != nil || got.Subject != "Target" || got.Last != "✅ Target" { - t.Fatalf("reconciled state = %#v, %v", got, err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationFailed - value.Tasks["target"] = taskState{Pending: &pendingProposal{BaseSubject: "Target", Prior: "Target", Proposed: "✅ Target", Status: "complete"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err == nil || !strings.Contains(err.Error(), "not owned") { - t.Fatalf("legacy pending resume = %v", err) - } - value, _ = newStore(stateDir()).read() - if value.Tasks["target"].Pending == nil { - t.Fatal("rejected legacy proposal was cleared") - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationComplete, "controller", false); err == nil { - t.Fatal("migration completed with an inactive pending title") - } -} - -func TestSettledMigrationFailureClearsOnlyKnownUnappliedProposal(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - addTask(t, db, root, "target", "Target", nil, "vscode", 0) - stage := func() { - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - value.Tasks["target"] = taskState{Pending: &pendingProposal{CallerTaskID: "controller", BaseSubject: "Target", Prior: "Target", Proposed: "✅ Target", Status: "complete"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - } - stage() - t.Setenv("CODEX_THREAD_ID", "other") - if _, err := transitionMigration(context.Background(), phaseMigrationFailed, "controller", true); err == nil { - t.Fatal("a different task attested to the controller's settled wave") - } - t.Setenv("CODEX_THREAD_ID", "main") - if _, err := transitionMigration(context.Background(), phaseMigrationFailed, "controller", true); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "controller", "after-attestation", map[string]any{"title": runningMarker + ": Migration controller"}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("post-attestation proposal = %q, %v", output.String(), err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err != nil { - t.Fatal(err) - } - value, _ := newStore(stateDir()).read() - if got := value.Tasks["target"]; got.Pending != nil || got.Last != "" { - t.Fatalf("known-unapplied reconciliation = %#v", got) - } - stage() - if _, err := transitionMigration(context.Background(), phaseMigrationFailed, "controller", false); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err == nil { - t.Fatal("unknown failure cleared an unchanged proposal") - } - if _, err := transitionMigration(context.Background(), phaseMigrationFailed, "controller", true); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err != nil { - t.Fatal(err) - } - value, _ = newStore(stateDir()).read() - if value.Tasks["target"].Pending != nil { - t.Fatal("settled disappeared target remained pending") - } -} - -func TestMigrationReadinessRequiresCompletePhase(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID = "main" - return true, nil - }); err != nil { - t.Fatal(err) - } - for _, phase := range []string{phaseMigrationRunning, phaseMigrationFailed, phaseMigrationRunning} { - result, err := transitionMigration(context.Background(), phase, "controller", false) - if err != nil { - t.Fatal(err) - } - if got := result.(map[string]any); got["ready"] != false || got["recorded"] != true { - t.Fatalf("%s result = %#v", phase, got) - } - } - value, err := newStore(stateDir()).read() - if err != nil || value.MigrationStarted == "" || value.MigrationFailure != "" { - t.Fatalf("resumed state = %#v, %v", value, err) - } -} - -func TestInstallMainTaskIdentityIsSticky(t *testing.T) { - isolatedLifecycle(t) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - value, err := newStore(stateDir()).read() - if err != nil || value.MainTaskID != "main" || value.Phase != phaseMigrationPending { - t.Fatalf("initial identity = %#v, %v", value, err) - } - if _, err := install("", false, true, false); err != nil { - t.Fatal(err) - } - if _, err := install("other", false, true, false); err == nil { - t.Fatal("reinstall replaced the persisted main task") - } -} - -func TestFreshInstallIsPendingUntilControllerIsRecorded(t *testing.T) { - isolatedLifecycle(t) - result, err := install("main", false, true, false) - if err != nil { - t.Fatal(err) - } - got := result.(map[string]any) - if got["phase"] != phaseMigrationPending || got["controller_required"] != true || got["ready"] != false { - t.Fatalf("install result = %#v", got) - } - statusResult, err := status(context.Background()) - if err != nil { - t.Fatal(err) - } - statusGot := statusResult.(map[string]any) - if statusGot["phase"] != phaseMigrationPending || statusGot["next_action"] != "start migration from the ThreadBear task" { - t.Fatalf("status result = %#v", statusGot) - } -} - -func TestUninstallRefusesActiveMigration(t *testing.T) { - p := isolatedLifecycle(t) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err != nil { - t.Fatal(err) - } - if _, err := uninstall(context.Background(), true); err == nil { - t.Fatal("uninstall accepted an active migration") - } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("blocked uninstall removed the binary: %v", err) - } -} - -func TestStatusReconcilesStoppedMigration(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - controller := addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(controller, []byte( - lifecycleLine("task_started", "2099-12-31T23:59:59Z")+ - lifecycleLine("task_complete", "2100-01-01T00:00:00Z"), - ), 0o600); err != nil { - t.Fatal(err) - } - - result, err := status(context.Background()) - if err != nil { - t.Fatal(err) - } - got := result.(map[string]any) - if got["phase"] != phaseMigrationFailed || got["migration_failure"] != "controller stopped before migration completed" || got["next_action"] != "resume migration from the ThreadBear task" { - t.Fatalf("status result = %#v", got) - } -} - -func TestUninstallRefusesDecoratedActiveTitles(t *testing.T) { - root, db := testIndex(t) - p := installPaths() - addTask(t, db, root, "main", "Control task", nil, "vscode", 0) - addTask(t, db, root, "target", "✅ ✅ Target", nil, "vscode", 0) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "requires title cleanup") { - t.Fatalf("decorated uninstall = %v", err) - } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("blocked uninstall removed the binary: %v", err) - } - if _, err := db.Exec(`UPDATE threads SET title='Target' WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := uninstall(context.Background(), true); err != nil { - t.Fatal(err) - } -} - -func TestUninstallRefusesDecoratedArchivedMainAndIgnoresArchivedController(t *testing.T) { - root, db := testIndex(t) - p := installPaths() - addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) - addTask(t, db, root, "controller", "⏳ Completed controller", nil, "vscode", 1) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.ControllerTaskID, value.Phase = "controller", phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "requires title cleanup") { - t.Fatalf("decorated archived main uninstall = %v", err) - } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("blocked uninstall removed the binary: %v", err) - } - if _, err := db.Exec(`UPDATE threads SET title='Control task' WHERE id='main'`); err != nil { - t.Fatal(err) - } - if _, err := uninstall(context.Background(), true); err != nil { - t.Fatalf("clean archived main with distinct archived controller: %v", err) - } -} diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index b4d2f52..121050b 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -6,15 +6,17 @@ import ( "errors" "flag" "fmt" - "github.com/ericlitman/threadbear/assets" "io" "os" + + "github.com/ericlitman/threadbear/assets" ) var version = "dev" func main() { os.Exit(run(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } -func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + +func run(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprint(stdout, assets.HelpText) return 2 @@ -24,75 +26,55 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. return 0 } command := args[0] - if command == "hook" { - if len(args) != 1 { - return 2 - } - if err := hook(ctx, stdin, stdout); err != nil { - fmt.Fprintln(stderr, "ThreadBear hook:", err) - return 1 - } - return 0 - } flags := flag.NewFlagSet(command, flag.ContinueOnError) flags.SetOutput(stderr) flags.Bool("json", false, "write JSON output") var action func() (any, error) + switch command { case "install": - controlTaskID := flags.String("control-task-id", "", "active task that becomes ThreadBear's persistent home") dry := flags.Bool("dry-run", false, "preview without mutation") noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm the previewed installation") - debugCanaries := flags.Bool("debug-canaries", false, "run guided Desktop canaries after installation") - flags.String("version", "", "installer-selected release version") - action = func() (any, error) { return install(*controlTaskID, *dry, *noninteractive && *confirm, *debugCanaries) } - case "inventory": + reset := flags.Bool("reset", false, "replace an exact legacy 2.2.1 installation") + noOnboard := flags.Bool("no-onboard", false, "skip guided historical onboarding") + automatic := flags.Bool("automatic", false, "internal verified-update installation") + selectedVersion := flags.String("version", "", "installer-selected release version") + action = func() (any, error) { + return install(ctx, installOptions{ + DryRun: *dry, Confirmed: *noninteractive && *confirm, Reset: *reset, + NoOnboard: *noOnboard, Automatic: *automatic, SelectedVersion: *selectedVersion, + }) + } + case "onboard": + dry := flags.Bool("dry-run", false, "return the complete read-only onboarding plan") + noninteractive := flags.Bool("noninteractive", false, "run without prompts") + confirm := flags.Bool("confirm", false, "confirm all safe onboarding title changes") + action = func() (any, error) { + return onboard(ctx, *dry, *noninteractive && *confirm) + } + case "title": + selectedStatus := flags.String("status", "", "set complete, next_steps, needs_input, blocked, or automation") action = func() (any, error) { - items, remaining, value, err := migrationInventory(ctx) - deterministic := 0 - for _, item := range items { - if item.Deterministic { - deterministic++ - } + if _, ok := statusIcons[*selectedStatus]; !ok { + return nil, fmt.Errorf("unsupported ThreadBear status %q", *selectedStatus) } - return map[string]any{"ready": err == nil && remaining == 0 && value.Phase == phaseMigrationComplete, "count": len(items), "deterministic": deterministic, "ambiguous": len(items) - deterministic, "applied": len(items) - remaining, "remaining": remaining, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "tasks": items}, err + return runCurrentTitle(ctx, os.Getenv("CODEX_THREAD_ID"), *selectedStatus) } - case "migration": - phase := flags.String("phase", "", "migration phase: migration_running, migration_complete, or migration_failed") - controllerTaskID := flags.String("controller-task-id", "", "ephemeral migration controller task") - settled := flags.Bool("settled", false, "confirm every admitted native call returned a terminal result") - action = func() (any, error) { return transitionMigration(ctx, *phase, *controllerTaskID, *settled) } - case "maintenance": - archive := flags.String("archive", "", "stage or reconcile one eligible task archive") - restore := flags.String("restore", "", "stage or reconcile one ThreadBear-owned restore") - cancel := flags.String("cancel", "", "cancel one exact known-unapplied native operation") - days := flags.Int("archive-after-days", 14, "quiet days before a completed task is eligible") - action = func() (any, error) { return maintenance(ctx, *archive, *restore, *cancel, *days) } - case "update": - action = func() (any, error) { return update(ctx) } case "status": action = func() (any, error) { return status(ctx) } case "self-test": flags.Bool("candidate", false, "validate this binary before installation") action = selfTest + case "update": + automatic := flags.Bool("automatic", false, "run from the update-only LaunchAgent") + action = func() (any, error) { return update(ctx, *automatic) } case "uninstall": - prepare := flags.Bool("prepare", false, "persist the initiating task and original control-task state") - abort := flags.Bool("abort", false, "abandon the prepared uninstall after restoring the control-task archive state") - initiatorTaskID := flags.String("initiator-task-id", "", "active task that owns this uninstall operation") + dry := flags.Bool("dry-run", false, "preview without mutation") noninteractive := flags.Bool("noninteractive", false, "run without prompts") - confirm := flags.Bool("confirm", false, "confirm the previewed uninstall") + confirm := flags.Bool("confirm", false, "confirm removal") action = func() (any, error) { - switch { - case *prepare && *abort: - return nil, errors.New("uninstall accepts only one of --prepare or --abort") - case *prepare: - return prepareUninstall(ctx, *initiatorTaskID) - case *abort: - return completeUninstall(ctx, *initiatorTaskID, false, true) - default: - return completeUninstall(ctx, *initiatorTaskID, *noninteractive && *confirm, false) - } + return uninstall(ctx, uninstallOptions{DryRun: *dry, Confirmed: *noninteractive && *confirm}) } case "version": action = func() (any, error) { return map[string]any{"version": version}, nil } @@ -100,12 +82,24 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, assets.HelpText) return 2 } + if flags.Parse(args[1:]) != nil || flags.NArg() != 0 { return 2 } result, err := action() if err != nil { failure := map[string]any{"ready": false, "error": err.Error()} + var details map[string]any + if encoded, encodeErr := json.Marshal(result); result != nil && encodeErr == nil { + _ = json.Unmarshal(encoded, &details) + } + if details != nil { + for key, value := range details { + failure[key] = value + } + failure["ready"] = false + failure["error"] = err.Error() + } var updateErr *updateError if errors.As(err, &updateErr) { failure["stage"] = updateErr.Stage diff --git a/cmd/threadbear/main_test.go b/cmd/threadbear/main_test.go new file mode 100644 index 0000000..d2f07ac --- /dev/null +++ b/cmd/threadbear/main_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" +) + +func TestRunHasNoHookCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"hook"}, strings.NewReader("{}"), &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), `unknown command "hook"`) { + t.Fatalf("removed hook command = code %d, stdout %q, stderr %q", code, stdout.String(), stderr.String()) + } +} + +func TestRunRejectsInvalidTitleStatusBeforeMutation(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"title", "--status", "waiting", "--json"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("invalid title status code = %d, stderr %q", code, stderr.String()) + } + var result map[string]any + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil || result["ready"] != false || !strings.Contains(result["error"].(string), `unsupported ThreadBear status "waiting"`) { + t.Fatalf("invalid title status = %#v, %v", result, err) + } +} + +func TestRunRequiresExplicitOnboardingMode(t *testing.T) { + for _, args := range [][]string{{"onboard", "--json"}, {"onboard", "--confirm", "--json"}} { + var stdout, stderr bytes.Buffer + code := run(context.Background(), args, strings.NewReader(""), &stdout, &stderr) + if code != 1 || !strings.Contains(stdout.String(), "onboarding requires --dry-run or --noninteractive --confirm") { + t.Fatalf("onboard mode %v = code %d, stdout %q, stderr %q", args, code, stdout.String(), stderr.String()) + } + } +} diff --git a/cmd/threadbear/maintenance.go b/cmd/threadbear/maintenance.go deleted file mode 100644 index 88846ac..0000000 --- a/cmd/threadbear/maintenance.go +++ /dev/null @@ -1,219 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "sort" - "time" -) - -var maintenanceNow = time.Now - -type archiveTask struct { - ID, Title, RolloutPath string - Archived, Visible, User bool -} - -func archiveTasks(ctx context.Context) ([]archiveTask, error) { - db, err := openIndex() - if err != nil { - return nil, err - } - defer db.Close() - rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(archived,0)<>0, COALESCE(preview,'')<>'', 1 - FROM threads WHERE source IN ('vscode','cli') AND COALESCE(thread_source,'') IN ('','user') ORDER BY id`) - if err != nil { - return nil, err - } - defer rows.Close() - var tasks []archiveTask - for rows.Next() { - var task archiveTask - if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Archived, &task.Visible, &task.User); err != nil { - return nil, err - } - tasks = append(tasks, task) - } - return tasks, rows.Err() -} -func archiveTaskByID(ctx context.Context, id string) (archiveTask, bool, error) { - db, err := openIndex() - if err != nil { - return archiveTask{}, false, err - } - defer db.Close() - var task archiveTask - err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(archived,0)<>0, - COALESCE(preview,'')<>'', COALESCE(source,'') IN ('vscode','cli') AND COALESCE(thread_source,'') IN ('','user') - FROM threads WHERE id=?`, id).Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Archived, &task.Visible, &task.User) - if errors.Is(err, sql.ErrNoRows) { - return archiveTask{}, false, nil - } - return task, err == nil, err -} -func archiveSnapshot(task archiveTask, value *state) (string, bool) { - if !task.User { - return "", false - } - record := value.Tasks[task.ID] - footer, exact := rolloutFooter(task.RolloutPath) - info, err := os.Stat(task.RolloutPath) - if err != nil || !exact || footer.Status != "complete" || record.Pending != nil || record.Status != "complete" || record.Last != task.Title { - return "", false - } - activity := info.ModTime().UTC() - if restored, err := time.Parse(time.RFC3339Nano, record.ArchiveActivity); err == nil && restored.After(activity) { - activity = restored - } - return activity.Format(time.RFC3339Nano), true -} -func archiveEligibility(task archiveTask, value *state, days int) (string, bool) { - if task.Archived || !task.Visible || task.ID == value.MainTaskID || task.ID == value.ControllerTaskID { - return "", false - } - activity, valid := archiveSnapshot(task, value) - parsed, err := time.Parse(time.RFC3339Nano, activity) - return activity, valid && err == nil && !parsed.After(maintenanceNow().UTC().AddDate(0, 0, -days)) -} -func maintenance(ctx context.Context, archiveID, restoreID, cancelID string, days int) (any, error) { - operationLock, err := newStore(stateDir()).operationLock() - if err != nil { - return nil, err - } - defer unlock(operationLock) - actions := 0 - for _, id := range []string{archiveID, restoreID, cancelID} { - if id != "" { - actions++ - } - } - if days < 1 || actions > 1 { - return nil, errors.New("maintenance requires positive archive days and at most one target action") - } - if cancelID != "" && os.Getenv("THREADBEAR_TITLE_ATTEMPT") != "" { - settled, settleErr := reconcileTitles(ctx, cancelID) - return map[string]any{"ready": settleErr == nil, "cancelled": settleErr == nil && settled > 0, "task_id": cancelID, "action": "title"}, settleErr - } - result := map[string]any{"ready": true, "automation_id": "threadbear-maintenance", "archive_after_days": days} - err = newStore(stateDir()).update(func(value *state) (bool, error) { - if value.MainTaskID == "" || value.Phase != phaseMigrationComplete || value.UninstallPending != nil { - return false, errors.New("maintenance requires a completed installation with no prepared uninstall") - } - changed := false - if value.Archives == nil { - value.Archives, changed = map[string]bool{}, true - } - requestedAction, requestedID := "archive", archiveID - if restoreID != "" { - requestedAction, requestedID = "restore", restoreID - } - if pending := value.ArchivePending; pending != nil { - task, found, err := archiveTaskByID(ctx, pending.TaskID) - if err != nil { - return changed, err - } - applied := found && pending.Action == "archive" && task.Archived || found && pending.Action == "restore" && !task.Archived - if applied { - if pending.Action == "archive" { - activity, valid := archiveSnapshot(task, value) - if !valid || task.Title != pending.Title || activity != pending.Activity { - return changed, errors.New("applied native archive drifted; restore the task before cancelling the pending operation") - } - value.Archives[pending.TaskID] = true - } else { - delete(value.Archives, pending.TaskID) - record := value.Tasks[pending.TaskID] - record.ArchiveActivity = maintenanceNow().UTC().Format(time.RFC3339Nano) - value.Tasks[pending.TaskID] = record - } - value.ArchivePending, changed = nil, true - if requestedID == pending.TaskID && requestedAction == pending.Action { - result["reconciled"], result["task_id"], result["action"] = true, pending.TaskID, pending.Action - return changed, nil - } - } else { - if cancelID != "" { - unapplied := found && pending.Action == "archive" && !task.Archived || found && pending.Action == "restore" && task.Archived - if cancelID != pending.TaskID || !unapplied { - return changed, errors.New("cancel requires the exact known-unapplied pending task") - } - value.ArchivePending = nil - result["cancelled"], result["task_id"], result["action"] = true, pending.TaskID, pending.Action - return true, nil - } - if requestedID != pending.TaskID || requestedAction != pending.Action { - result["pending"] = pending - return changed, nil - } - if pending.Action == "archive" { - activity, eligible := archiveEligibility(task, value, days) - if !eligible || task.Title != pending.Title || activity != pending.Activity { - return changed, errors.New("pending archive no longer matches an eligible task") - } - } else if !found || !task.Archived || !value.Archives[pending.TaskID] { - return changed, errors.New("pending restore no longer matches an owned archive") - } - result["pending"], result["task_id"], result["action"] = true, pending.TaskID, pending.Action - return changed, nil - } - } - manuallyRestored := false - for id := range value.Archives { - task, found, err := archiveTaskByID(ctx, id) - if err != nil { - return changed, err - } - if found && !task.Archived { - delete(value.Archives, id) - record := value.Tasks[id] - record.ArchiveActivity = maintenanceNow().UTC().Format(time.RFC3339Nano) - value.Tasks[id] = record - changed = true - manuallyRestored = manuallyRestored || restoreID == id - } - } - if manuallyRestored { - result["reconciled"], result["task_id"], result["action"] = true, restoreID, "restore" - return changed, nil - } - if cancelID != "" { - return changed, errors.New("no pending native archive operation to cancel") - } - if restoreID != "" { - task, found, err := archiveTaskByID(ctx, restoreID) - if err != nil || !found || !task.Archived || !value.Archives[restoreID] { - return changed, errors.Join(err, errors.New("restore requires a ThreadBear-owned archived task")) - } - value.ArchivePending = &archiveOperation{TaskID: restoreID, Action: "restore", Title: task.Title} - result["task_id"], result["action"], result["pending"] = restoreID, "restore", true - return true, nil - } - if archiveID != "" { - task, found, err := archiveTaskByID(ctx, archiveID) - activity, eligible := archiveEligibility(task, value, days) - if err != nil || !found || !eligible { - return changed, errors.Join(err, fmt.Errorf("task %q is not eligible for archive", archiveID)) - } - value.ArchivePending = &archiveOperation{TaskID: archiveID, Action: "archive", Title: task.Title, Activity: activity} - result["task_id"], result["action"], result["pending"] = archiveID, "archive", true - return true, nil - } - tasks, err := archiveTasks(ctx) - if err != nil { - return changed, err - } - candidates := []map[string]string{} - for _, task := range tasks { - if activity, eligible := archiveEligibility(task, value, days); eligible { - candidates = append(candidates, map[string]string{"task_id": task.ID, "inactive_since": activity}) - } - } - sort.Slice(candidates, func(i, j int) bool { return candidates[i]["task_id"] < candidates[j]["task_id"] }) - result["candidates"], result["owned_archives"] = candidates, len(value.Archives) - return changed, nil - }) - return result, err -} diff --git a/cmd/threadbear/maintenance_test.go b/cmd/threadbear/maintenance_test.go deleted file mode 100644 index a418ba9..0000000 --- a/cmd/threadbear/maintenance_test.go +++ /dev/null @@ -1,317 +0,0 @@ -package main - -import ( - "bytes" - "context" - "os" - "strings" - "testing" - "time" -) - -func TestMaintenancePlansOnlyInactiveOwnedCompleteUserTasks(t *testing.T) { - root, db := testIndex(t) - now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) - old := now.AddDate(0, 0, -14) - maintenanceNow = func() time.Time { return now } - t.Cleanup(func() { maintenanceNow = time.Now }) - for _, id := range []string{"eligible", "fresh", "blocked", "drift", "main", "controller", "worker", "automation", "archived"} { - path := addTask(t, db, root, id, "✅ "+id, nil, "vscode", 0) - writeMigrationRollout(t, path, "🧵🐻 complete") - if err := os.Chtimes(path, old, old); err != nil { - t.Fatal(err) - } - } - nullPath := addTask(t, db, root, "nulls", "✅ nulls", nil, "vscode", 0) - writeMigrationRollout(t, nullPath, "🧵🐻 complete") - if _, err := db.Exec(`UPDATE threads SET archived=NULL, preview=NULL WHERE id='nulls'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='archived'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET thread_source='subagent' WHERE id='worker'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET thread_source='automation' WHERE id='automation'`); err != nil { - t.Fatal(err) - } - if err := os.Chtimes(filepathForTask(root, "fresh"), now, now); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationComplete - for _, id := range []string{"eligible", "fresh", "drift", "main", "controller", "worker", "automation", "archived"} { - value.Tasks[id] = taskState{Subject: id, Last: "✅ " + id, Status: "complete"} - } - value.Tasks["blocked"] = taskState{Subject: "blocked", Last: "✅ blocked", Status: "blocked"} - value.Tasks["drift"] = taskState{Subject: "drift", Last: "✅ another title", Status: "complete"} - return true, nil - }); err != nil { - t.Fatal(err) - } - result, err := maintenance(context.Background(), "", "", "", 14) - if err != nil { - t.Fatal(err) - } - candidates := result.(map[string]any)["candidates"].([]map[string]string) - if len(candidates) != 1 || candidates[0]["task_id"] != "eligible" || candidates[0]["inactive_since"] != old.Format(time.RFC3339Nano) { - t.Fatalf("candidates = %#v", candidates) - } -} - -func TestMaintenanceArchiveRestoreAndInterruptionReconcile(t *testing.T) { - root, db := testIndex(t) - now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) - old := now.AddDate(0, 0, -30) - maintenanceNow = func() time.Time { return now } - t.Cleanup(func() { maintenanceNow = time.Now }) - path := addTask(t, db, root, "target", "✅ target", nil, "vscode", 0) - writeMigrationRollout(t, path, "🧵🐻 complete") - if err := os.Chtimes(path, old, old); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationComplete - value.Tasks["target"] = taskState{Subject: "target", Last: "✅ target", Status: "complete"} - return true, nil - }); err != nil { - t.Fatal(err) - } - staged, err := maintenance(context.Background(), "target", "", "", 14) - if err != nil || staged.(map[string]any)["action"] != "archive" { - t.Fatalf("stage archive = %#v, %v", staged, err) - } - if _, err := maintenance(context.Background(), "", "other", "", 14); err != nil { - t.Fatalf("pending plan should report, not fail: %v", err) - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET preview='' WHERE id='target'`); err != nil { - t.Fatal(err) - } - reconciled, err := maintenance(context.Background(), "target", "", "", 14) - if err != nil || reconciled.(map[string]any)["reconciled"] != true { - t.Fatalf("reconcile archive = %#v, %v", reconciled, err) - } - if _, err := maintenance(context.Background(), "", "target", "", 14); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='target'`); err != nil { - t.Fatal(err) - } - restored, err := maintenance(context.Background(), "", "target", "", 14) - if err != nil || restored.(map[string]any)["reconciled"] != true { - t.Fatalf("reconcile restore = %#v, %v", restored, err) - } - plan, err := maintenance(context.Background(), "", "", "", 14) - if err != nil || len(plan.(map[string]any)["candidates"].([]map[string]string)) != 0 { - t.Fatalf("restored task was immediately eligible: %#v, %v", plan, err) - } - value, _ := newStore(stateDir()).read() - if value.Archives["target"] || value.ArchivePending != nil || value.Tasks["target"].ArchiveActivity != now.Format(time.RFC3339Nano) { - t.Fatalf("archive state = %#v", value) - } -} - -func TestMaintenanceRefusesPreparedUninstall(t *testing.T) { - testIndex(t) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationComplete - value.UninstallPending = &uninstallOperation{InitiatorTaskID: "owner"} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := maintenance(context.Background(), "target", "", "", 14); err == nil || err.Error() != "maintenance requires a completed installation with no prepared uninstall" { - t.Fatalf("maintenance during uninstall = %v", err) - } - value, _ := newStore(stateDir()).read() - if value.ArchivePending != nil { - t.Fatal("blocked maintenance changed archive state") - } -} - -func TestMaintenanceRejectsDriftAndUninstallWithPendingArchive(t *testing.T) { - root, db := testIndex(t) - now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) - maintenanceNow = func() time.Time { return now } - t.Cleanup(func() { maintenanceNow = time.Now }) - path := addTask(t, db, root, "target", "✅ target", nil, "vscode", 0) - writeMigrationRollout(t, path, "🧵🐻 complete") - old := now.AddDate(0, 0, -15) - if err := os.Chtimes(path, old, old); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationComplete - value.Tasks["target"] = taskState{Subject: "target", Last: "✅ target", Status: "complete"} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := maintenance(context.Background(), "target", "", "", 14); err != nil { - t.Fatal(err) - } - t.Setenv("CODEX_THREAD_ID", "target") - t.Setenv("THREADBEAR_TITLE_ATTEMPT", "rejected-title-call") - if _, err := maintenance(context.Background(), "", "", "target", 14); err == nil || !strings.Contains(err.Error(), "no matching pending operation") { - t.Fatalf("title recovery fell through to archive cancellation: %v", err) - } - value, _ := newStore(stateDir()).read() - if value.ArchivePending == nil { - t.Fatal("title recovery cancelled an unrelated pending archive") - } - t.Setenv("CODEX_THREAD_ID", "other") - if _, err := maintenance(context.Background(), "", "", "target", 14); err == nil || !strings.Contains(err.Error(), "exact active current task") { - t.Fatalf("title recovery accepted another caller: %v", err) - } - t.Setenv("CODEX_THREAD_ID", "target") - t.Setenv("THREADBEAR_TITLE_ATTEMPT", "") - if _, err := db.Exec(`UPDATE threads SET name='User rename' WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET thread_source='subagent' WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := maintenance(context.Background(), "target", "", "", 14); err == nil { - t.Fatal("applied archive accepted title drift") - } - if _, err := uninstall(context.Background(), true); err == nil { - t.Fatal("uninstall accepted pending archive") - } - if _, err := maintenance(context.Background(), "", "", "target", 14); err == nil { - t.Fatal("cancel accepted a still-applied drifted archive") - } - if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='target'`); err != nil { - t.Fatal(err) - } - cancelled, err := maintenance(context.Background(), "", "", "target", 14) - if err != nil || cancelled.(map[string]any)["cancelled"] != true { - t.Fatalf("cancel pending archive = %#v, %v", cancelled, err) - } - value, _ = newStore(stateDir()).read() - if value.ArchivePending != nil { - t.Fatal("cancel left a pending archive") - } -} - -func TestMaintenanceCancelsOnlyExactKnownFailedCurrentTitle(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "task", homeTitle, nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "task", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - pre := hookPayload("PreToolUse", "task", "failed-noop", map[string]any{"title": homeTitle + "⁣attempt-one"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != homeTitle { - t.Fatal(err) - } - value, _ := newStore(stateDir()).read() - if pending := value.Tasks["task"].Pending; pending == nil || pending.Prior != pending.Proposed || pending.Attempt != "attempt-one" { - t.Fatalf("no-op failure fixture = %#v", pending) - } - t.Setenv("CODEX_THREAD_ID", "task") - t.Setenv("THREADBEAR_TITLE_ATTEMPT", value.Tasks["task"].Pending.Attempt) - result, err := maintenance(context.Background(), "", "", "task", 14) - if err != nil || result.(map[string]any)["action"] != "title" || result.(map[string]any)["cancelled"] != true { - t.Fatalf("known failed title cancellation = %#v, %v", result, err) - } - value, _ = newStore(stateDir()).read() - if value.Tasks["task"].Pending != nil { - t.Fatal("known failed title remained pending") - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { value.Phase = phaseMigrationComplete; return true, nil }); err != nil { - t.Fatal(err) - } - pre = hookPayload("PreToolUse", "task", "unknown", map[string]any{"title": runningMarker + ": Stable subject⁣attempt-two"}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("later title remained stranded: %q, %v", output.String(), err) - } - value, _ = newStore(stateDir()).read() - if pending := value.Tasks["task"].Pending; pending == nil || pending.Attempt != "attempt-two" || strings.Contains(pending.Proposed, "⁣") { - t.Fatalf("tagged running proposal = %#v", pending) - } - t.Setenv("THREADBEAR_TITLE_ATTEMPT", "different-attempt") - if _, err := maintenance(context.Background(), "", "", "task", 14); err == nil { - t.Fatal("new failed call cleared an older unknown proposal") - } - value, _ = newStore(stateDir()).read() - t.Setenv("THREADBEAR_TITLE_ATTEMPT", value.Tasks["task"].Pending.Attempt) - if _, err := db.Exec(`UPDATE threads SET name='Drifted title' WHERE id='task'`); err != nil { - t.Fatal(err) - } - if _, err := maintenance(context.Background(), "", "", "task", 14); err == nil { - t.Fatal("title cancellation accepted drift after an unknown result") - } - value, _ = newStore(stateDir()).read() - if value.Tasks["task"].Pending == nil { - t.Fatal("unknown result did not remain fail-closed") - } -} - -func TestMaintenanceCancelsExactFailedPersistentHomeTitle(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "fresh", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - pre := hookPayload("PreToolUse", "main", "failed-home", map[string]any{"title": runningMarker + ": Replacement seed⁣home-attempt"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != mainTitle { - t.Fatalf("home proposal = %q, %v", output.String(), err) - } - value, _ := newStore(stateDir()).read() - if pending := value.Tasks["main"].Pending; pending == nil || pending.Prior != "fresh" || pending.Proposed != mainTitle || pending.Attempt != "home-attempt" { - t.Fatalf("home failure fixture = %#v", pending) - } - t.Setenv("CODEX_THREAD_ID", "main") - t.Setenv("THREADBEAR_TITLE_ATTEMPT", "home-attempt") - result, err := maintenance(context.Background(), "", "", "main", 14) - if err != nil || result.(map[string]any)["cancelled"] != true { - t.Fatalf("home cancellation = %#v, %v", result, err) - } - value, _ = newStore(stateDir()).read() - if value.Tasks["main"].Pending != nil { - t.Fatal("failed home title remained pending") - } -} - -func TestMaintenanceDetectsManualNativeRestore(t *testing.T) { - root, db := testIndex(t) - now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) - maintenanceNow = func() time.Time { return now } - t.Cleanup(func() { maintenanceNow = time.Now }) - addTask(t, db, root, "target", "✅ target", nil, "vscode", 1) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationComplete - value.Tasks["target"] = taskState{Subject: "target", Last: "✅ target", Status: "complete"} - value.Archives = map[string]bool{"target": true} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='target'`); err != nil { - t.Fatal(err) - } - if _, err := maintenance(context.Background(), "", "", "", 14); err != nil { - t.Fatal(err) - } - value, _ := newStore(stateDir()).read() - if value.Archives["target"] || value.Tasks["target"].ArchiveActivity != now.Format(time.RFC3339Nano) { - t.Fatalf("manual restore state = %#v", value) - } -} - -func filepathForTask(root, id string) string { return root + "/" + id + ".jsonl" } diff --git a/cmd/threadbear/migration.go b/cmd/threadbear/migration.go deleted file mode 100644 index 4ed509b..0000000 --- a/cmd/threadbear/migration.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "os" - "time" -) - -func currentStateOrEmpty() (value state, err error) { - value, err = newStore(stateDir()).read() - if errors.Is(err, os.ErrNotExist) { - return state{Format: stateFormat, Tasks: map[string]taskState{}}, nil - } - return -} -func transitionMigration(ctx context.Context, phase, controllerID string, settled bool) (any, error) { - if phase != phaseMigrationRunning && phase != phaseMigrationComplete && phase != phaseMigrationFailed || controllerID == "" || settled && phase != phaseMigrationFailed { - return nil, errors.New("migration requires a valid phase and controller task ID") - } - store := newStore(stateDir()) - titleLock, err := store.titleLock() - if err != nil { - return nil, err - } - defer unlock(titleLock) - if phase == phaseMigrationRunning { - if _, err = reconcileTitles(ctx, controllerID); err != nil { - return nil, err - } - } - remaining := -1 - var known state - if phase == phaseMigrationComplete { - _, remaining, known, err = migrationInventory(ctx) - if err != nil { - return nil, err - } - if remaining != 0 || hasPendingTitle(known) { - return nil, errors.New("migration has unresolved tasks") - } - } - err = store.update(func(value *state) (bool, error) { - if value.MainTaskID == "" || controllerID == value.MainTaskID || value.ControllerTaskID != "" && value.ControllerTaskID != controllerID || phase != phaseMigrationRunning && value.ControllerTaskID != controllerID || value.Phase == phaseMigrationComplete && phase != phaseMigrationComplete || settled && os.Getenv("CODEX_THREAD_ID") != controllerID && os.Getenv("CODEX_THREAD_ID") != value.MainTaskID { - return false, errors.New("migration controller or main task changed") - } - value.ControllerTaskID, value.Phase = controllerID, phase - value.MigrationStarted, value.MigrationFailure = "", map[string]string{phaseMigrationFailed: map[bool]string{true: "controller reported a settled migration failure", false: "controller reported a migration failure"}[settled]}[phase] - if phase == phaseMigrationRunning { - value.MigrationStarted = time.Now().UTC().Format(time.RFC3339Nano) - } - return true, nil - }) - if err != nil { - return nil, err - } - result := map[string]any{"ready": phase == phaseMigrationComplete, "recorded": true, "phase": phase} - return result, nil -} -func reconcileMigration(ctx context.Context) (state, error) { - value, err := newStore(stateDir()).read() - if err != nil || value.Phase != phaseMigrationRunning { - return value, err - } - if value.ControllerTaskID == "" { - err = newStore(stateDir()).update(func(current *state) (bool, error) { - if current.Phase != phaseMigrationRunning || current.ControllerTaskID != "" { - return false, nil - } - current.Phase = phaseMigrationPending - current.MigrationStarted = "" - current.MigrationFailure = "" - return true, nil - }) - if err != nil { - return state{}, err - } - return newStore(stateDir()).read() - } - controller, found, lookupErr := oneTask(ctx, value.ControllerTaskID) - if lookupErr != nil { - return value, lookupErr - } - inactive := !found - if found { - lifecycle, eventTime, known, lifecycleErr := latestTaskLifecycle(controller.RolloutPath) - if lifecycleErr != nil { - return value, lifecycleErr - } - inactive = known && (lifecycle == "task_complete" || lifecycle == "turn_aborted") - if inactive && value.MigrationStarted != "" { - started, startErr := time.Parse(time.RFC3339Nano, value.MigrationStarted) - ended, endErr := time.Parse(time.RFC3339Nano, eventTime) - inactive = startErr == nil && endErr == nil && !ended.Before(started) - } - } - if !inactive { - return value, nil - } - err = newStore(stateDir()).update(func(current *state) (bool, error) { - if current.Phase != phaseMigrationRunning || current.ControllerTaskID != value.ControllerTaskID { - return false, nil - } - current.Phase = phaseMigrationFailed - current.MigrationStarted = "" - current.MigrationFailure = "controller stopped before migration completed" - return true, nil - }) - if err != nil { - return state{}, err - } - return newStore(stateDir()).read() -} -func latestTaskLifecycle(path string) (string, string, bool, error) { - f, err := os.Open(path) - if err != nil { - return "", "", false, err - } - defer f.Close() - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 64<<10), 8<<20) - latest, timestamp := "", "" - for scanner.Scan() { - var item struct { - Timestamp string `json:"timestamp"` - Type string `json:"type"` - Payload struct { - Type string `json:"type"` - } `json:"payload"` - } - if json.Unmarshal(scanner.Bytes(), &item) == nil && item.Type == "event_msg" && - (item.Payload.Type == "task_started" || item.Payload.Type == "task_complete" || item.Payload.Type == "turn_aborted") { - latest, timestamp = item.Payload.Type, item.Timestamp - } - } - if err := scanner.Err(); err != nil { - return "", "", false, err - } - return latest, timestamp, latest != "", nil -} diff --git a/cmd/threadbear/migration_test.go b/cmd/threadbear/migration_test.go deleted file mode 100644 index 954d950..0000000 --- a/cmd/threadbear/migration_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestMigrationInventoryAtZeroOneAndTwoHundredTasks(t *testing.T) { - t.Run("zero", func(t *testing.T) { - _, _ = testIndex(t) - items, _, _, err := migrationInventory(context.Background()) - if err != nil || len(items) != 0 { - t.Fatalf("migrationInventory() = %#v, %v", items, err) - } - }) - - t.Run("one", func(t *testing.T) { - root, db := testIndex(t) - path := addTask(t, db, root, "only", "Ship the release", nil, "vscode", 0) - writeMigrationRollout(t, path, "🧵🐻 next steps (you): approve the release") - items, _, _, err := migrationInventory(context.Background()) - if err != nil || len(items) != 1 { - t.Fatalf("migrationInventory() = %#v, %v", items, err) - } - got := items[0] - if got.TaskID != "only" || got.Subject != "Ship the release" || got.Status != "next_steps" || - got.Action != "approve the release" || !got.Deterministic || got.Applied { - t.Fatalf("one-task inventory = %#v", got) - } - }) - - t.Run("two hundred", func(t *testing.T) { - root, db := testIndex(t) - paths := make(map[string]string, 201) - for i := 0; i < 200; i++ { - id := fmt.Sprintf("task-%03d", i) - title := "Subject " + id - switch i { - case 2: - title = "➡️ Similar-looking user title → literal suffix" - case 3: - title = "➡️ Classified subject → approve the release" - case 4: - title = "✅ Owned subject" - } - paths[id] = addTask(t, db, root, id, title, nil, "vscode", 0) - if i == 0 || i == 4 || i >= 5 && i%2 == 0 { - writeMigrationRollout(t, paths[id], "🧵🐻 complete") - } - } - archived := addTask(t, db, root, "archived", "Archived subject", nil, "vscode", 1) - writeMigrationRollout(t, archived, "🧵🐻 complete") - writeMigrationState(t, map[string]any{ - "task-003": map[string]any{ - "subject": "Classified subject", "last": "➡️ Classified subject → approve the release", - "status": "next_steps", "action": "approve the release", - }, - "task-004": map[string]any{ - "subject": "Owned subject", "last": "✅ Owned subject", "status": "complete", - }, - }) - - before, err := os.ReadFile(newStore(stateDir()).path()) - if err != nil { - t.Fatal(err) - } - first, _, _, err := migrationInventory(context.Background()) - if err != nil || len(first) != 200 { - t.Fatalf("migrationInventory() count = %d, %v", len(first), err) - } - second, _, _, err := migrationInventory(context.Background()) - if err != nil || !reflect.DeepEqual(second, first) { - t.Fatalf("idempotent rerun differs: %v\nfirst: %#v\nsecond: %#v", err, first, second) - } - after, err := os.ReadFile(newStore(stateDir()).path()) - if err != nil || !reflect.DeepEqual(after, before) { - t.Fatalf("inventory mutated state: %v\nbefore: %s\nafter: %s", err, before, after) - } - - byID := make(map[string]inventoryItem, len(first)) - deterministic, applied := 0, 0 - for _, item := range first { - byID[item.TaskID] = item - if item.Deterministic { - deterministic++ - } - if item.Applied { - applied++ - } - } - if deterministic != 100 || applied != 2 { - t.Fatalf("inventory counts: deterministic=%d applied=%d", deterministic, applied) - } - if _, ok := byID["archived"]; ok { - t.Fatal("archived task entered migration inventory") - } - if got := byID["task-001"]; got.Status != "unknown" || got.Deterministic || got.Applied { - t.Fatalf("ambiguous task = %#v", got) - } - if got := byID["task-002"]; got.Subject != "➡️ Similar-looking user title → literal suffix" || got.Deterministic || got.Applied { - t.Fatalf("similar-looking user-owned title = %#v", got) - } - if got := byID["task-003"]; got.Subject != "Classified subject" || got.Status != "next_steps" || - got.Action != "approve the release" || !got.Deterministic || !got.Applied { - t.Fatalf("persisted ambiguous classification = %#v", got) - } - if got := byID["task-004"]; got.Subject != "Owned subject" || got.Status != "complete" || - !got.Deterministic || !got.Applied { - t.Fatalf("deterministic owned task = %#v", got) - } - }) -} - -func TestRolloutFooterStopsAtNewerUnsettledTurn(t *testing.T) { - old := rolloutLine("response_item", map[string]any{ - "type": "message", "role": "assistant", "phase": "final_answer", - "content": []map[string]string{{"text": "Done.\n\n🧵🐻 complete"}}, - }) - for name, newer := range map[string]string{ - "active user turn": rolloutLine("response_item", map[string]any{ - "type": "message", "role": "user", "content": []map[string]string{{"text": "One more change"}}, - }), - "aborted turn": rolloutLine("event_msg", map[string]any{"type": "turn_aborted"}), - } { - t.Run(name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "rollout.jsonl") - if err := os.WriteFile(path, []byte(old+newer), 0o600); err != nil { - t.Fatal(err) - } - if got, ok := rolloutFooter(path); ok { - t.Fatalf("older footer crossed an unsettled turn boundary: %#v", got) - } - }) - } -} - -func TestReconcileMigrationFailsStoppedController(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - controller := addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - data := []byte( - rolloutLine("event_msg", map[string]any{"type": "task_started"}) + - rolloutLine("event_msg", map[string]any{"type": "task_complete"})) - if err := os.WriteFile(controller, data, 0o600); err != nil { - t.Fatal(err) - } - - value, err := reconcileMigration(context.Background()) - if err != nil || value.Phase != phaseMigrationFailed || value.MigrationFailure != "controller stopped before migration completed" { - t.Fatalf("reconciled state = %#v, %v", value, err) - } -} - -func TestReconcileMigrationRestoresPendingWhenControllerWasNeverRecorded(t *testing.T) { - testIndex(t) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationRunning - value.MigrationStarted = "2026-08-03T12:00:00Z" - return true, nil - }); err != nil { - t.Fatal(err) - } - - value, err := reconcileMigration(context.Background()) - if err != nil || value.Phase != phaseMigrationPending || value.MigrationStarted != "" || value.ControllerTaskID != "" { - t.Fatalf("reconciled state = %#v, %v", value, err) - } -} - -func TestReconcileMigrationPreservesActiveOrUnknownController(t *testing.T) { - for name, rollout := range map[string]string{ - "active": lifecycleLine("task_started", "2100-01-01T00:00:00Z"), - "unknown": "not-json\n", - } { - t.Run(name, func(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - controller := addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if err := os.WriteFile(controller, []byte(rollout), 0o600); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - value, err := reconcileMigration(context.Background()) - if err != nil || value.Phase != phaseMigrationRunning || value.MigrationFailure != "" { - t.Fatalf("reconciled state = %#v, %v", value, err) - } - }) - } -} - -func TestReconcileMigrationIgnoresTerminalEventFromPriorAttempt(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - controller := addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID = "main" - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := transitionMigration(context.Background(), phaseMigrationRunning, "controller", false); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(controller, []byte(lifecycleLine("task_complete", "2000-01-01T00:00:00Z")), 0o600); err != nil { - t.Fatal(err) - } - value, err := reconcileMigration(context.Background()) - if err != nil || value.Phase != phaseMigrationRunning { - t.Fatalf("reconciled state = %#v, %v", value, err) - } -} - -func TestReconcileMigrationReportsControllerLookupFailure(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - t.Setenv("CODEX_HOME", filepath.Join(root, "missing-codex-home")) - - value, err := reconcileMigration(context.Background()) - if err == nil || value.Phase != phaseMigrationRunning { - t.Fatalf("reconciled state = %#v, %v; want running state plus lookup error", value, err) - } -} - -func TestReconcileMigrationReportsRolloutReadFailure(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - controller := addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning - return true, nil - }); err != nil { - t.Fatal(err) - } - if err := os.Remove(controller); err != nil { - t.Fatal(err) - } - - value, err := reconcileMigration(context.Background()) - if err == nil || value.Phase != phaseMigrationRunning { - t.Fatalf("reconciled state = %#v, %v; want running state plus rollout error", value, err) - } -} - -func writeMigrationRollout(t *testing.T, path, marker string) { - t.Helper() - line := rolloutLine("response_item", map[string]any{ - "type": "message", "role": "assistant", "phase": "final_answer", - "content": []map[string]string{{"text": "Result.\n\n" + marker}}, - }) - if err := os.WriteFile(path, []byte(line), 0o600); err != nil { - t.Fatal(err) - } -} - -func writeMigrationState(t *testing.T, tasks map[string]any) { - t.Helper() - data, err := json.Marshal(map[string]any{"format": stateFormat, "tasks": tasks}) - if err != nil { - t.Fatal(err) - } - path := newStore(stateDir()).path() - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { - t.Fatal(err) - } -} - -func lifecycleLine(kind, timestamp string) string { - data, _ := json.Marshal(map[string]any{"timestamp": timestamp, "type": "event_msg", "payload": map[string]any{"type": kind}}) - return string(data) + "\n" -} diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index cbb8520..fc072a5 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -1,202 +1,349 @@ package main import ( - "bytes" "context" - "database/sql" - "encoding/json" "errors" "fmt" - "github.com/BurntSushi/toml" - "io" - _ "modernc.org/sqlite" - "net/url" "os" - "path/filepath" - "strconv" - "strings" ) -type indexedTask struct{ ID, Title, RolloutPath, Name, FirstMessage, ThreadSource string } +const ( + onboardingNeedsUpdate = "needs_update" + onboardingUpdated = "updated" + onboardingUnchanged = "unchanged" + onboardingSkipped = "skipped" + onboardingUnconfirmed = "unconfirmed" +) -func inventory(ctx context.Context) ([]indexedTask, error) { - db, err := openIndex() - if err != nil { - return nil, err - } - defer db.Close() - rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,''), COALESCE(thread_source,'') - FROM threads WHERE archived=0 AND preview<>'' AND source IN ('vscode','cli') ORDER BY id`) - if err != nil { - return nil, fmt.Errorf("read Codex task index: %w", err) - } - defer rows.Close() - var tasks []indexedTask - for rows.Next() { - var task indexedTask - if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage, &task.ThreadSource); err != nil { - return nil, err - } - tasks = append(tasks, task) - } - return tasks, rows.Err() +type indexedTask struct { + ID string `json:"task_id"` + Title string `json:"title"` + RawFallback bool `json:"-"` } -func oneTask(ctx context.Context, id string) (indexedTask, bool, error) { - if strings.TrimSpace(id) == "" { - return indexedTask{}, false, errors.New("task ID is empty") - } - db, err := openIndex() - if err != nil { - return indexedTask{}, false, err + +type currentTitleResult struct { + Ready bool `json:"ready"` + TaskID string `json:"task_id,omitempty"` + Status string `json:"status,omitempty"` + PreviousTitle string `json:"previous_title,omitempty"` + DesiredTitle string `json:"desired_title,omitempty"` + Title string `json:"title,omitempty"` + Updated bool `json:"updated"` + Unchanged bool `json:"unchanged"` + Unconfirmed bool `json:"unconfirmed"` + Reason string `json:"reason,omitempty"` +} + +type onboardingItem struct { + TaskID string `json:"task_id"` + Title string `json:"title,omitempty"` + Subject string `json:"subject,omitempty"` + DesiredTitle string `json:"desired_title,omitempty"` + Safe bool `json:"safe"` + Applied bool `json:"applied"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` +} + +type onboardingResult struct { + Ready bool `json:"ready"` + PlanComplete bool `json:"plan_complete"` + ReadOnly bool `json:"read_only"` + OnboardingComplete bool `json:"onboarding_complete"` + Total int `json:"total"` + Safe int `json:"safe"` + NeedsUpdate int `json:"needs_update"` + Updated int `json:"updated"` + Unchanged int `json:"unchanged"` + Skipped int `json:"skipped"` + Unconfirmed int `json:"unconfirmed"` + Items []onboardingItem `json:"items"` +} + +func runCurrentTitle(ctx context.Context, taskID, status string) (currentTitleResult, error) { + result := currentTitleResult{TaskID: taskID, Status: status} + if !taskIDPattern.MatchString(taskID) { + return result, errors.New("CODEX_THREAD_ID is unavailable or invalid") } - defer db.Close() - var task indexedTask - err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,''), COALESCE(thread_source,'') - FROM threads WHERE id=? AND archived=0 AND preview<>'' AND source IN ('vscode','cli')`, id).Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage, &task.ThreadSource) - if errors.Is(err, sql.ErrNoRows) { - return indexedTask{}, false, nil + if _, ok := statusIcons[status]; !ok { + return result, fmt.Errorf("unsupported ThreadBear status %q", status) } + + disk := newStore(stateDir()) + fence, err := disk.lifecycleFence() if err != nil { - return indexedTask{}, false, fmt.Errorf("read Codex task index: %w", err) + return result, err } - return task, true, nil -} -func openIndex() (*sql.DB, error) { - home, err := sqliteHome() + defer unlock(fence) + client, err := startAppServer(ctx, appServerCurrentBudget) if err != nil { - return nil, err + return result, err } - matches, _ := filepath.Glob(filepath.Join(home, "state_*.sqlite")) - latest, latestNumber := "", -1 - for _, path := range matches { - value := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "state_"), ".sqlite") - if number, _ := strconv.Atoi(value); number > latestNumber { - latest, latestNumber = path, number + defer client.abort() + err = func() error { + task, err := client.currentTask(2, taskID) + if err != nil { + return err } + if task.RawFallback { + return errors.New("native task name is blank; task is raw or unowned") + } + + subject, err := persistSubjectUnderFence(disk, task.ID, task.Title) + if err != nil { + return err + } + desired, err := renderTitle(status, subject) + if err != nil { + return err + } + result.PreviousTitle, result.DesiredTitle = task.Title, desired + + attempted := desired != task.Title + nextRequestID := 3 + if attempted { + if err := client.setName(nextRequestID, taskID, desired); err != nil { + result.Unconfirmed = true + result.Reason = "the single native title write did not return a usable result" + return err + } + nextRequestID++ + } + readback, err := client.currentTask(nextRequestID, taskID) + if err != nil { + result.Unconfirmed = attempted + result.Reason = "the native title could not be read back" + return err + } + if readback.RawFallback || readback.Title != desired { + result.Unconfirmed = attempted + if attempted { + result.Reason = "the single native title write was not confirmed by exact readback" + } else { + result.Reason = "the native title changed during verification" + } + return errors.New(result.Reason) + } + result.Ready, result.Title = true, readback.Title + result.Updated, result.Unchanged = attempted, !attempted + return nil + }() + if err != nil { + return result, err } - if latest == "" { - return nil, errors.New("Codex state index not found") - } - dsn := (&url.URL{Scheme: "file", Path: latest, RawQuery: "mode=ro"}).String() - return sql.Open("sqlite", dsn) + + // Exact native readback is the success gate. A process exit error after that + // point cannot make an already-observed title uncertain. + client.close() + return result, nil } -func sqliteHome() (string, error) { - base := codexHome() - var config struct { - SQLiteHome string `toml:"sqlite_home"` - } - if _, err := toml.DecodeFile(filepath.Join(base, "config.toml"), &config); err != nil { - if errors.Is(err, os.ErrNotExist) { - return base, nil + +func persistSubjectUnderFence(disk store, taskID, currentTitle string) (string, error) { + var subject string + err := disk.updateTaskUnderFence(taskID, func(record *taskState) (bool, error) { + resolved, err := resolveSubject(currentTitle, *record) + if err != nil { + return false, err } - return "", fmt.Errorf("parse Codex config: %w", err) + subject = resolved + changed := record.Subject != resolved + *record = taskState{Subject: resolved} + return changed, nil + }) + return subject, err +} + +func runOnboarding(ctx context.Context, apply bool, activeTaskID string) (onboardingResult, error) { + if apply && !taskIDPattern.MatchString(activeTaskID) { + return onboardingResult{}, errors.New("CODEX_THREAD_ID is unavailable or invalid") } - value := strings.TrimSpace(config.SQLiteHome) - switch { - case value == "": - value = base - case !filepath.IsAbs(value): - value = filepath.Join(base, value) + disk := newStore(stateDir()) + var fence *os.File + var err error + if apply { + fence, err = disk.lifecycleFence() + if err != nil { + return onboardingResult{}, err + } + defer unlock(fence) } - return value, nil -} -func rolloutFooter(path string) (footer, bool) { - if path == "" { - return footer{}, false + budget := appServerListBudget + if apply { + budget = appServerOnboardingBudget } - f, err := os.Open(path) + client, err := startAppServer(ctx, budget) if err != nil { - return footer{}, false + return onboardingResult{}, err } - defer f.Close() - info, err := f.Stat() + defer client.abort() + nextRequestID := 2 + tasks, err := client.inventory(&nextRequestID) if err != nil { - return footer{}, false + return onboardingResult{}, err } - const limit = int64(256 << 10) - start := max(int64(0), info.Size()-limit) - data, err := io.ReadAll(io.NewSectionReader(f, start, info.Size()-start)) - if err != nil { - return footer{}, false - } - if len(data) == 0 || bytes.IndexByte(data, '\n') < 0 { - return footer{}, false - } - if start > 0 { - data = data[bytes.IndexByte(data, '\n')+1:] - } - lines := bytes.Split(data, []byte{'\n'}) - for i := len(lines) - 2; i >= 0; i-- { - var item struct { - Type string `json:"type"` - Payload struct { - Type, Role, Phase, Message string - Content []struct { - Text string `json:"text"` - } `json:"content"` - } `json:"payload"` - } - if json.Unmarshal(lines[i], &item) != nil { + items := prepareOnboardingItems(tasks) + excludeActiveOnboardingTask(items, activeTaskID) + result := summarizeOnboarding(items, !apply) + result.Ready, result.PlanComplete = true, true + if !apply { + client.close() + return result, nil + } + + var operationErr error + // thread/read is only a drift check and exact readback for rows admitted by + // the complete thread/list snapshot above. It never supplies a subject. + for index := range items { + item := &items[index] + if !item.Safe { continue } - if item.Type == "turn_context" || item.Type == "event_msg" && (item.Payload.Type == "turn_aborted" || item.Payload.Type == "task_started") || - item.Type == "response_item" && item.Payload.Type == "message" && item.Payload.Role == "user" { - return footer{}, false - } - if item.Type != "response_item" || item.Payload.Type != "message" || item.Payload.Role != "assistant" || item.Payload.Phase != "final_answer" { + if item.TaskID == activeTaskID { continue } - message := item.Payload.Message - for _, part := range item.Payload.Content { - message += part.Text + targetErr := func() error { + current, readErr := client.readTask(nextRequestID, item.TaskID) + nextRequestID++ + if readErr != nil { + item.Applied, item.Outcome = false, onboardingSkipped + item.Reason = "native task name could not be revalidated" + return nil + } + if current.RawFallback { + item.Applied, item.Outcome = false, onboardingSkipped + item.Reason = "native task name became blank during revalidation" + return nil + } + if current.Title != item.Title { + item.Applied, item.Outcome = false, onboardingSkipped + item.Reason = "native task name changed after the onboarding snapshot" + return nil + } + if item.DesiredTitle == current.Title { + item.Applied, item.Outcome = true, onboardingUnchanged + item.Reason = "already decorated" + return nil + } + if _, err := persistSubjectUnderFence(disk, item.TaskID, current.Title); err != nil { + item.Applied, item.Outcome = false, onboardingSkipped + item.Reason = "subject state could not be saved" + return fmt.Errorf("save subject state for %s: %w", item.TaskID, err) + } + if err := client.setName(nextRequestID, item.TaskID, item.DesiredTitle); err != nil { + nextRequestID++ + item.Applied, item.Outcome = false, onboardingUnconfirmed + item.Reason = "the single native title write did not return a usable result" + return fmt.Errorf("set title for %s: %w", item.TaskID, err) + } + nextRequestID++ + readback, err := client.readTask(nextRequestID, item.TaskID) + nextRequestID++ + if err != nil || readback.RawFallback || readback.Title != item.DesiredTitle { + item.Applied, item.Outcome = false, onboardingUnconfirmed + item.Reason = "the single native title write was not confirmed by exact readback" + if err == nil { + err = errors.New(item.Reason) + } + return fmt.Errorf("confirm title for %s: %w", item.TaskID, err) + } + item.Applied, item.Outcome, item.Reason = true, onboardingUpdated, "" + return nil + }() + if targetErr != nil { + if item.Outcome != onboardingUnconfirmed && item.Outcome != onboardingSkipped { + item.Applied, item.Outcome = false, onboardingSkipped + item.Reason = "the ThreadBear lifecycle changed before this task could be written" + } + operationErr = errors.Join(operationErr, targetErr) } - return parseFooter(message) } - return footer{}, false -} - -type inventoryItem struct { - TaskID string `json:"task_id"` - Title string `json:"title"` - Subject string `json:"subject"` - Status string `json:"status"` - Action string `json:"action,omitempty"` - Deterministic bool `json:"deterministic"` - Applied bool `json:"applied"` + // As with the current-task path, per-task exact readback is authoritative. + // Still reap the one long-lived process before returning the aggregate. + client.close() + result = summarizeOnboarding(items, false) + result.Ready, result.PlanComplete = operationErr == nil, true + return result, operationErr } -func migrationInventory(ctx context.Context) ([]inventoryItem, int, state, error) { - tasks, err := inventory(ctx) - if err != nil { - return nil, 0, state{}, err +func excludeActiveOnboardingTask(items []onboardingItem, activeTaskID string) { + if activeTaskID == "" { + return } - known, readErr := currentStateOrEmpty() - if readErr != nil { - return nil, 0, state{}, readErr + for index := range items { + if items[index].TaskID == activeTaskID { + items[index].Applied = false + items[index].Outcome = onboardingUnchanged + items[index].Reason = "active task is handled by the terminal title writer" + return + } } - items := make([]inventoryItem, 0, len(tasks)) - remaining := 0 +} + +func prepareOnboardingItems(tasks []indexedTask) []onboardingItem { + items := make([]onboardingItem, 0, len(tasks)) + disk := newStore(stateDir()) for _, task := range tasks { - if task.ID == known.MainTaskID || task.ID == known.ControllerTaskID { + item := onboardingItem{TaskID: task.ID, Outcome: onboardingSkipped} + if task.RawFallback { + item.Reason = "native task name is blank; task is raw or unowned" + items = append(items, item) continue } - record := known.Tasks[task.ID] - subject := canonicalSubject(task.Title, record) - result, ok := rolloutFooter(task.RolloutPath) - if !ok { - if record.Pending == nil && record.Last == task.Title && statusIcons[record.Status] != "" { - result = footer{Status: record.Status, Action: record.Action} - ok = true - } else { - result.Status = "unknown" + record, readErr := disk.readTask(task.ID) + if errors.Is(readErr, os.ErrNotExist) { + record = taskState{} + } else if readErr != nil { + item.Reason = "subject state is unreadable" + items = append(items, item) + continue + } + if record.Subject != "" && isOwnedRendering(task.Title, record.Subject) { + item.Title, item.Subject, item.DesiredTitle = task.Title, record.Subject, task.Title + item.Safe, item.Applied, item.Outcome, item.Reason = true, true, onboardingUnchanged, "already decorated" + items = append(items, item) + continue + } + subject, subjectErr := resolveSubject(task.Title, record) + if subjectErr != nil { + item.Reason = subjectErr.Error() + items = append(items, item) + continue + } + item.Title, item.Subject, item.DesiredTitle = task.Title, subject, "🐻 "+subject + item.Safe, item.Outcome = true, onboardingNeedsUpdate + items = append(items, item) + } + return items +} + +func summarizeOnboarding(items []onboardingItem, readOnly bool) onboardingResult { + result := onboardingResult{ReadOnly: readOnly, Total: len(items), Items: items} + allSafeConfirmed := true + for _, item := range items { + if item.Safe { + result.Safe++ + if item.Outcome == onboardingNeedsUpdate { + result.NeedsUpdate++ + } + if item.Outcome != onboardingUpdated && item.Outcome != onboardingUnchanged { + allSafeConfirmed = false } } - desired := renderTitle(result.Status, subject, result.Action) - applied := record.Pending == nil && record.Last == task.Title && record.Last == desired - items = append(items, inventoryItem{TaskID: task.ID, Title: task.Title, Subject: subject, Status: result.Status, Action: result.Action, Deterministic: ok, Applied: applied}) - if !applied { - remaining++ + switch item.Outcome { + case onboardingUpdated: + result.Updated++ + case onboardingUnchanged: + result.Unchanged++ + case onboardingSkipped: + result.Skipped++ + case onboardingUnconfirmed: + result.Unconfirmed++ } } - return items, remaining, known, nil + if readOnly { + result.OnboardingComplete = result.NeedsUpdate == 0 + } else { + result.OnboardingComplete = allSafeConfirmed && result.Unconfirmed == 0 + } + return result } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index e1ee23a..51ea6ef 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -1,356 +1,165 @@ package main import ( - "bytes" "os" "path/filepath" "strings" "testing" ) -func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { - root := filepath.Join("..", "..") - guide, err := os.ReadFile(filepath.Join(root, "INSTALL.md")) +func readRepoFile(t *testing.T, path ...string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(append([]string{"..", ".."}, path...)...)) if err != nil { t.Fatal(err) } - published, err := os.ReadFile(filepath.Join(root, "site", "install")) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(guide, published) { - t.Fatal("INSTALL.md and site/install must be byte-identical") - } - - text := string(guide) - for _, removed := range []string{ - "--archive-control-task", - "--archive-after-days", - "--auto-update", - "--classifier-model", - "--heartbeat-seconds", - "--status-guidance", - "--token-display", - "threadbear configure", - "threadbear disable", - "threadbear enable", - "threadbear inspect", - } { - if strings.Contains(text, removed) { - t.Errorf("published install guide contains removed CLI surface %q", removed) - } - } - for _, required := range []string{ - "## Hi. Let's install ThreadBear.", - "## Recommended setup", - "Status icon in each native-addressable local Codex task title.", - "Next action added to the thread title → like this.", - "Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed.", - "Small local footprint: one binary in ~/.local/bin, a skill, and two hooks.", - "One persistent thread, ThreadBear, for changing config and uninstalling; its title never receives a status prefix.", - "Deterministic classification and Luna-medium ambiguity checks run in parallel.", - "A small Luna helper checks in hourly, then stays quiet when there is nothing to do.", - "Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.", - "ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat.", - "run the verified update check last", - "threadbear-maintenance", - "native automation control", - "On creation omit `id`", - "returned `automationId` must equal `threadbear-maintenance`", - "Never treat the create request's status as proof", - "immediately update that exact returned ID", - "status:\"PAUSED\"", - "paused hourly heartbeat", - "activate the exact owned heartbeat", - "--control-task-id", - "--noninteractive --confirm --json", - "~/.local/bin/threadbear inventory --json", - "~/.local/bin/threadbear update --json", - "migration_pending", - "migration_running", - "migration_complete", - "migration_failed", - "exactly one projectless background migration-controller task", - "one exact untagged home-title call", - "Do not add a nonce or make a second title call.", - "do not use visual inspection, computer control, screenshots, or Codex `/hooks`", - "Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged.", - "never describe zero local inventory rows as proof that every visible sidebar row changed", - "dispatch it within 60 seconds of consent", - "using `model:\"gpt-5.6-terra\"`, `thinking:\"medium\"`", - "first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result", - "Worker creation uses the fixed `codex_app__create_thread` surface with `model:\"gpt-5.6-luna\"` and `thinking:\"medium\"`", - "❔ ThreadBear could not classify", - "Do not open, select, or navigate to it.", - "one bounded concurrent spawn wave of fresh read-only Luna-medium workers", - "accepts and ignores a surplus `action` field", - "missing or empty required actions for `blocked`, `needs_input`, or `next_steps`", - "Every successful worker handle is recorded and awaited", - "results may arrive out of order", - "bounded concurrent waves of at most eight distinct task IDs", - "without a client-created `Promise.race` or other synthetic timeout", - "Only an explicit timeout from the native tool is a timeout", - "eight-minute deadline", - "active or unaccounted for", - "status still says `migration_pending` or `migration_running`", - "migration stopped and is not still working", - "two native title calls per ordinary turn", - "~/.local/bin/threadbear uninstall --prepare --initiator-task-id", - "~/.local/bin/threadbear uninstall --initiator-task-id", - "You can uninstall from any active native Codex task—even when the ThreadBear home is archived.", - "Do not ask the user to open, select, navigate to, or unarchive the ThreadBear home.", - "same initiating task", - "Want me to uninstall ThreadBear?", - } { - if !strings.Contains(text, required) { - t.Errorf("published install guide is missing %q", required) - } - } - if strings.Contains(text, "foreground migration") { - t.Error("published install guide still assigns migration to the persistent task") - } - if strings.Contains(text, "Use Codex `/hooks` to inspect") { - t.Error("published install guide still asks end users to inspect Codex hooks") - } - for _, debugOnly := range []string{"canary", "--debug-canaries", "genuinely fresh Codex Desktop task"} { - if strings.Contains(strings.ToLower(text), strings.ToLower(debugOnly)) { - t.Errorf("published install guide discloses debug-only verification %q", debugOnly) - } - } + return string(data) } -func TestInstalledSkillDefinesAdaptiveMigrationWaves(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "assets", "skill", "SKILL.md")) - if err != nil { - t.Fatal(err) - } - protocol := string(data) - - for _, required := range []string{ - "never use visual inspection, computer control, screenshots, or Codex `/hooks`", - "dispatch it within 60 seconds of consent", - "first stable batch of at most 25", - "start the initial bounded worker-spawn wave concurrently with the first deterministic activation-and-setter wave", - "within 15 seconds of the inventory result", - "`codex_app__create_thread` using `model:\"gpt-5.6-luna\"`, `thinking:\"medium\"`", - "Do not inspect or compare alternative agent surfaces at runtime.", - "stable batches of at most 10 tasks", - "Derive each assigned list mechanically from the parsed inventory `task_id` fields in stable order", - "never retype, transform, or synthesize an ID", - "one JSON array with every assigned ID exactly once and no other ID", - "For statuses that do not consume an action (`complete`, `automation`, and `unknown`), accept and ignore a surplus `action` field.", - "missing or empty required actions for `blocked`, `needs_input`, or `next_steps`", - "Validate only that final-answer item; separate worker commentary is not part of the result grammar.", - "followed only by the one terminal ThreadBear status line required by the managed block", - "A `wait_threads` snapshot may normalize the one separator newline before the footer into a space", - "accept one or more whitespace characters followed by the exact required footer", - "do not require a physical newline", - "Ignore that required footer only while parsing the array", - "one bounded concurrent wave of fresh, read-only Luna-medium workers", - "archive all currently validated workers together", - "one concurrent `Promise.all` call", - "never serialize those archives", - "never wait for every worker before applying an already validated and archived batch", - "A `wait_threads` response with `timedOut:true` is only a normal polling snapshot and is never a worker timeout", - "Immediately record every successful handle with its assigned task IDs", - "Retained classifier worker IDs are installation internals: exclude them from every migration title wave.", - "A missing, mismatched, or unconfirmed archive result fails closed", - "At an agent-capacity error, account for every earlier success", - "Never reinterpret that error as zero workers when earlier spawns succeeded.", - "even when results arrive out of order", - "give each worker eight minutes from spawn", - "discard only that batch's uncommitted classifications", - "retry that read-only batch once in the next wave", - "A second invalid result or actual worker deadline reports failure and complete accounting to the home", - "bounded waves of at most eight distinct targets", - "call `codex_app__read_thread` concurrently for every target as a bounded read-only activation gate", - "require each response's exact task ID and the inventory-planned title", - "begin the setter wave within 15 seconds", - "never include the same target twice in a wave", - "without a client-created `Promise.race` or other synthetic timeout", - "Only an explicit timeout from the native tool is a timeout.", - "never compare the native return title with the compact input", - "use fresh inventory as the authoritative applied result", - "Account for the whole wave before reconciling it with inventory", - "Execute every ready stable queue in one orchestrated script loop", - "do not add commentary, model deliberation, or a separate outer tool round trip between settled waves", - "one narrow stale-snapshot exception", - "explicitly says a target is inactive or not found", - "its exact ID is absent from the refreshed inventory", - "the task naturally left the addressable catalog and is not counted as applied", - "status `complete` requires `title:\"🧵🐻 complete\"`", - "status `needs_input` requires `title:\"🧵🐻 needs input (you): ACTION\"`", - "status `next_steps` requires `title:\"🧵🐻 next steps (agent): ACTION\"`", - "bare inputs such as `complete`, `blocked`, or `next_steps` are invalid", - "Never prepend the visible status icon, insert the word `ThreadBear`, include the task subject, or pre-render the visible title", - "the Pre hook alone expands the compact input around the authoritative subject", - "Do not start another wave or return while a retained worker is still active or unaccounted for.", - "If zero workers can start, wait 30 seconds and retry for at most two minutes", - "ThreadBear controller registration.", - "retain the create result only as a supervision handle", - "adding `--settled` only when the controller's complete wave accounting proves every admitted native call returned a terminal result", - "The failed phase denies every new title proposal", - "Unknown proposals remain pending for manual fail-closed recovery", - } { - if !strings.Contains(protocol, required) { - t.Errorf("installed skill is missing adaptive migration invariant %q", required) +func requireText(t *testing.T, text string, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(text, value) { + t.Errorf("missing product contract %q", value) } } - if !strings.Contains(protocol, "`"+unknownMarker+"`") { - t.Errorf("installed skill does not name the hook-accepted unknown marker %q", unknownMarker) - } - if strings.Contains(protocol, "Use Codex `/hooks` to inspect") { - t.Error("installed skill still asks end users to inspect Codex hooks") - } - firstBatch := strings.Index(protocol, "first stable batch of at most 25") - workerSurface := strings.LastIndex(protocol, "`codex_app__create_thread` using") - if firstBatch < 0 || workerSurface < 0 || firstBatch > workerSurface { - t.Error("installed skill does not put prompt deterministic progress before Luna worker creation") - } } -func TestActiveReleaseDocsUseLunaMedium(t *testing.T) { - root := filepath.Join("..", "..") - paths := []string{ - "README.md", - "INSTALL.md", - filepath.Join("site", "install"), - filepath.Join("assets", "skill", "SKILL.md"), - filepath.Join("docs", "architecture.md"), - filepath.Join("docs", "live-eval.md"), - filepath.Join("docs", "release-checklist.md"), - } - for _, path := range paths { - data, err := os.ReadFile(filepath.Join(root, path)) - if err != nil { - t.Fatal(err) - } - for _, stale := range []string{"Luna-low", `thinking:"low"`} { - if strings.Contains(string(data), stale) { - t.Errorf("%s contains stale classifier setting %q", path, stale) - } +func rejectText(t *testing.T, text string, values ...string) { + t.Helper() + for _, value := range values { + if strings.Contains(text, value) { + t.Errorf("contains removed product contract %q", value) } } } -func TestPublishedInstallGuideKeepsFirstConsentTurnVisible(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "INSTALL.md")) - if err != nil { - t.Fatal(err) - } - guide := string(data) - - for _, required := range []string{ - "Codex collapses commentary after a turn finishes.", - "commentary copies do not satisfy this contract", - "Every terminal final answer in this first turn must be self-contained.", - "If every check and the dry run succeeds, `phase: final_answer` must include the complete orientation above, the readiness sentence, the full recommendation card, and the consent question.", - "Do not end a successful turn with only the consent question.", - "compose one terminal final answer with no later tool call or commentary", - "every recommendation bullet, and consent question must all be present in `phase: final_answer`", - "do not follow it with a question-only final answer", - } { - if !strings.Contains(guide, required) { - t.Errorf("published install guide is missing visible consent-turn contract %q", required) - } - } - - if strings.Contains(guide, "Continue in the same response with the full card") { - t.Error("published install guide retains the ambiguous response-boundary rule") - } -} - -func TestPublishedInstallGuideDoesNotRequestConsentAfterFailedChecks(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "INSTALL.md")) - if err != nil { - t.Fatal(err) - } - guide := string(data) - - for _, required := range []string{ - "If any check fails, keep the complete orientation and truthful failure visible in `phase: final_answer`", - "do not claim readiness, show the recommendation card, or ask for consent", - "Only after every check and the dry run succeeds, compose one terminal final answer", - } { - if !strings.Contains(guide, required) { - t.Errorf("published install guide is missing truthful failure-turn contract %q", required) - } +func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { + guide := readRepoFile(t, "INSTALL.md") + published := readRepoFile(t, "site", "install") + if guide != published { + t.Fatal("INSTALL.md and site/install must be byte-identical") } + requireText(t, guide, + "small local command reads and updates the title through Codex's official App Server", + "--dry-run --json", + "--noninteractive --confirm --json", + "--no-onboard", + "ThreadBear onboard", + "onboard --dry-run --json", + "onboard --noninteractive --confirm --json", + "entire unarchived App Server catalog before any write", + "handles every safe target serially with no item cap", + "attempts each write once", + "acknowledgement without exact readback is `unconfirmed`", + "`updated`, `unchanged`, `skipped`, and `unconfirmed`", + "`legacy_main_task_id` plus `legacy_automation_id`, `legacy_automation_name`, `legacy_automation_kind`, and `legacy_automation_target_thread_id`", + "unpin the preview's exact legacy main-task ID", + "never opens Codex SQLite", + "binary is written last", + "Every successful update reports `restart_required`", + "daily update-only LaunchAgent", + "do not run the title command", + ) + rejectText(t, guide, + "codex_app__set_thread_title", + "PreToolUse", + "PostToolUse", + "hooks.json", + "native title setter", + "ThreadBear footer", + "--control-task-id", + "threadbear inventory", + "threadbear migration", + "threadbear maintenance", + "migration_pending", + "migration_running", + "migration_complete", + "migration_failed", + "background migration-controller", + "Luna helper", + "uninstall --prepare", + "state_N.sqlite", + ) } -func TestHomepageMatchesNativeMaintenanceCapabilities(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "site", "index.html")) - if err != nil { - t.Fatal(err) - } - page := string(data) - for _, requiredClaim := range []string{ - "Create a new session in ChatGPT Desktop using Luna on medium effort, and paste this in:", - "14 quiet days", - "native task control", - "never archives active work", - "installs no LaunchAgent", - "verified official release", - "update check last", - } { - if !strings.Contains(page, requiredClaim) { - t.Errorf("homepage is missing maintenance capability claim %q", requiredClaim) - } - } - for _, removedClaim := range []string{ - "zero-token idle", - "Unchanged heartbeats use zero model tokens", - "produce zero output", - "exits silently", - "control task", - } { - if strings.Contains(page, removedClaim) { - t.Errorf("homepage contains removed capability claim %q", removedClaim) - } - } +func TestInstalledGuidanceDefinesOneTerminalCommand(t *testing.T) { + guidance := readRepoFile(t, "assets", "AGENTS.threadbear.md") + requireText(t, guidance, + "Write the substantive response first.", + `\"$HOME/.local/bin/threadbear\" title --status STATUS --json`, + "yield_time_ms:4000", + "max_output_tokens:1000", + "Make exactly one attempt at that terminal moment.", + "do not poll, retry, reconcile, or delay the response", + "The status controls only the visible icon.", + ) + if count := strings.Count(guidance, "title --status STATUS --json"); count != 1 { + t.Fatalf("managed guidance contains %d terminal title commands; want one", count) + } + rejectText(t, guidance, + "codex_app__set_thread_title", + "PreToolUse", + "PostToolUse", + "ThreadBear footer", + "maintenance --cancel", + "prepared uninstall", + ) } -func TestManagedCleanupContractIsShipped(t *testing.T) { - root := filepath.Join("..", "..", "assets") - for path, required := range map[string][]string{ - filepath.Join(root, "skill", "SKILL.md"): {"## Title cleanup", "same controller ID", "A stopped `migration_failed` installation is uninstallable", "prepared uninstall task", "persistent home remains exactly `ThreadBear`", "For uninstall, target the control task last", "no attempt suffix"}, - filepath.Join(root, "AGENTS.threadbear.md"): {"A prepared uninstall suspends this turn protocol", "respond without another title call or ThreadBear footer", "THREADBEAR_TITLE_ATTEMPT='${attempt}'"}, - } { - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - for _, text := range required { - if !strings.Contains(string(data), text) { - t.Errorf("%s is missing %q", path, text) - } - } - } +func TestInstalledSkillStaysACompactOperatingGuide(t *testing.T) { + protocol := readRepoFile(t, "assets", "skill", "SKILL.md") + if size := len([]byte(protocol)); size > 5*1024 { + t.Fatalf("installed skill is %d bytes; compact-guide ceiling is 5 KiB", size) + } + requireText(t, protocol, + "## Install or reset", + "## Onboard existing tasks", + "onboard --dry-run --json", + "onboard --noninteractive --confirm --json", + "enumerate and deduplicate the full unarchived App Server catalog before any write", + "processes the complete plan serially with no item cap", + "counted only after exact readback", + "Never retry an unconfirmed result.", + "## Update", + "truthful rerunnable partial", + "`restart_required`", + "## Uninstall", + "do not run the title command", + ) + rejectText(t, protocol, + "codex_app__set_thread_title", + "PreToolUse", + "PostToolUse", + "Migration controller", + "migration --phase", + "maintenance --cancel", + "uninstall --prepare", + ) } -func TestShippedLogicStaysBelowAbsoluteLineCeiling(t *testing.T) { - root := filepath.Join("..", "..") - paths, err := filepath.Glob(filepath.Join(root, "cmd", "threadbear", "*.go")) - if err != nil { - t.Fatal(err) - } - paths = append(paths, filepath.Join(root, "assets", "embed.go"), filepath.Join(root, "install.sh")) - count := 0 - for _, path := range paths { - if strings.HasSuffix(path, "_test.go") { - continue - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - count += bytes.Count(data, []byte{'\n'}) - if len(data) > 0 && data[len(data)-1] != '\n' { - count++ - } - } - t.Logf("shipped executable logic: %d lines (target 1500, absolute ceiling 2000)", count) - if count > 2000 { - t.Fatalf("shipped executable logic is %d lines; absolute ceiling is 2000", count) - } +func TestHomepageDescribesOnlyShippedCapabilities(t *testing.T) { + page := readRepoFile(t, "site", "index.html") + requireText(t, page, + "One terminal update", + "One direct writer", + "reads the exact title, writes at most once, and verifies exact readback", + "App Server pagination before serial writes", + "no arbitrary first-50 cap", + "null or blank name", + "preview is never adopted", + "daily update-only LaunchAgent", + "There is no SQLite access, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.", + "rerunnable partial", + "title-core readiness", + ) + rejectText(t, page, + "codex_app__set_thread_title", + "PreToolUse", + "PostToolUse", + "native setter", + "deterministic hook", + "automatic archive", + "read-only SQLite lookup", + ) } diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index b1ff640..3a8e185 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -1,204 +1,314 @@ package main import ( - "cmp" "encoding/json" "errors" + "fmt" "golang.org/x/sys/unix" "io" "os" "path/filepath" "regexp" "strings" + "unicode" "unicode/utf16" ) -const stateFormat, phaseMigrationPending, phaseMigrationRunning, phaseMigrationComplete, phaseMigrationFailed = 4, "migration_pending", "migration_running", "migration_complete", "migration_failed" +const maxTitleUnits = 60 -type pendingProposal struct { - CallerTaskID string `json:"caller_task_id"` - ToolUseID string `json:"tool_use_id"` - BaseSubject string `json:"base_subject"` - Prior string `json:"prior"` - Proposed string `json:"proposed"` - Status string `json:"status"` - Action string `json:"action,omitempty"` - Attempt string `json:"attempt,omitempty"` -} type taskState struct { - Subject string `json:"subject"` - Original string `json:"original,omitempty"` - Last string `json:"last,omitempty"` - Status string `json:"status,omitempty"` - Action string `json:"action,omitempty"` - ArchiveActivity string `json:"archive_activity,omitempty"` - Pending *pendingProposal `json:"pending,omitempty"` -} -type archiveOperation struct { - TaskID string `json:"task_id"` - Action string `json:"action"` - Title string `json:"title"` - Activity string `json:"activity,omitempty"` -} -type uninstallOperation struct { - InitiatorTaskID string `json:"initiator_task_id"` - MainTaskID string `json:"main_task_id"` - MainArchived bool `json:"main_archived"` - ControllerTaskID string `json:"controller_task_id,omitempty"` -} -type state struct { - Format int `json:"format"` - MainTaskID string `json:"main_task_id,omitempty"` - ControllerTaskID string `json:"controller_task_id,omitempty"` - Phase string `json:"phase,omitempty"` - MigrationStarted string `json:"migration_started_at,omitempty"` - MigrationFailure string `json:"migration_failure,omitempty"` - Tasks map[string]taskState `json:"tasks"` - Archives map[string]bool `json:"archives,omitempty"` - ArchivePending *archiveOperation `json:"archive_pending,omitempty"` - UninstallPending *uninstallOperation `json:"uninstall_pending,omitempty"` -} -type footer struct{ Status, Action string } + Subject string `json:"subject"` +} + type store struct{ dir string } -func newStore(dir string) store { return store{dir: dir} } -func (s store) path() string { return filepath.Join(s.dir, "native.json") } -func (s store) openLock(name string, mode int, createDir bool) (*os.File, error) { - if createDir { - if err := os.MkdirAll(s.dir, 0o700); err != nil { +var taskIDPattern = regexp.MustCompile(`^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) + +var statusIcons = map[string]string{ + "complete": "✅", + "next_steps": "➡️", + "needs_input": "🙋", + "blocked": "🚨", + "automation": "🤖", +} + +// Every value here is a title rendering ThreadBear may own. Recognition is +// deliberately finite and byte-exact; ThreadBear never guesses by stripping. +var ownedIcons = []string{"✅", "➡️", "🙋", "🚨", "🤖", "🐻"} + +// legacyPrefixes are ambiguous without a subject record. They include the +// current renderings and old ThreadBear decorations that may remain after the +// 2.2.1 reset. +var legacyPrefixes = []string{ + "✅", "➡️", "➡", "🙋", "🚨", "🤖", "🐻", + "⏳", "❔", "🧵🐻", +} + +var internalEnvelopeMarkers = []string{ + "", "", + " 0 { - return prefix + " → " + truncateUTF16(action, budget) +// A skipped old hook could leave one of these exact operation shapes visible. +// Keep the grammar finite so an ordinary user rename beginning with 🧵🐻 is +// still treated as the user's subject. +func isOperationTitle(title string) bool { + if title == "🧵🐻 complete" || title == "🧵🐻 automation" || title == "⏳ ThreadBear is working" { + return true + } + for _, prefix := range []string{ + "🧵🐻 next steps (", "🧵🐻 needs input (", "🧵🐻 blocked (", + "⏳ ThreadBear is working:", + } { + if strings.HasPrefix(title, prefix) { + return true + } } - return prefix + return false } -func truncateUTF16(value string, limit int) string { - units := utf16.Encode([]rune(value)) - if len(units) <= limit { - return value + +func validateSubject(subject string) error { + if strings.TrimSpace(subject) == "" { + return errors.New("subject is blank") + } + if hasUnsafeText(subject) { + return errors.New("subject contains multiline or control text") } - if limit < 1 { - return "" + lower := strings.ToLower(subject) + for _, marker := range internalEnvelopeMarkers { + if strings.Contains(lower, marker) { + return errors.New("subject is a raw internal envelope") + } } - units = units[:limit-1] - if len(units) > 0 && units[len(units)-1] >= 0xd800 && units[len(units)-1] <= 0xdbff { - units = units[:len(units)-1] + // Stored subjects must fit every production prefix. Otherwise a title can + // get stuck on a stale success icon when a later status uses a wider emoji. + if utf16Units("🐻 "+subject) > maxTitleUnits { + return errors.New("subject does not fit without truncation") } - return strings.TrimSpace(string(utf16.Decode(units))) + "…" + return nil +} + +func hasUnsafeText(value string) bool { + return strings.ContainsFunc(value, func(char rune) bool { + return unicode.IsControl(char) || unicode.Is(unicode.Zl, char) || unicode.Is(unicode.Zp, char) + }) } + +func isOwnedRendering(current, subject string) bool { + for _, icon := range ownedIcons { + if current == icon+" "+subject { + return true + } + } + return false +} + +func hasLegacyPrefix(title string) bool { + return hasPrefix(title, legacyPrefixes) +} + +func hasPrefix(title string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(title, prefix) { + return true + } + } + return false +} + +func utf16Units(value string) int { return len(utf16.Encode([]rune(value))) } diff --git a/cmd/threadbear/state_test.go b/cmd/threadbear/state_test.go index fc0ecbd..5876f63 100644 --- a/cmd/threadbear/state_test.go +++ b/cmd/threadbear/state_test.go @@ -2,36 +2,47 @@ package main import ( "errors" + "golang.org/x/sys/unix" "os" "path/filepath" "strings" "testing" - "unicode/utf16" - "unicode/utf8" + "time" ) -func utf16Len(value string) int { return len(utf16.Encode([]rune(value))) } - -func TestNativeStateStoreInitializesPrivatelyAndRoundTrips(t *testing.T) { - dir := filepath.Join(t.TempDir(), "state") - disk := newStore(dir) - if filepath.Base(disk.path()) != "native.json" { - t.Fatalf("state path = %q", disk.path()) +func testSubjectStore(t testing.TB) store { + t.Helper() + disk := newStore(filepath.Join(t.TempDir(), "state")) + if err := os.MkdirAll(disk.subjectDir(), 0o700); err != nil { + t.Fatal(err) } - if _, err := disk.read(); !errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(filepath.Join(disk.dir, "lifecycle.lock"), nil, 0o600); err != nil { + t.Fatal(err) + } + return disk +} + +func TestSubjectRecordsArePrivateAndPerTask(t *testing.T) { + disk := testSubjectStore(t) + if _, err := disk.readTask(testTaskID); !errors.Is(err, os.ErrNotExist) { t.Fatalf("absent read error = %v", err) } - want := taskState{Subject: "Customer outage", Last: "🚨 Customer outage → restore service"} - if err := disk.update(func(value *state) (bool, error) { - if value.Format != stateFormat || value.Tasks == nil { - t.Fatalf("initial state = %#v", value) - } - value.Tasks["task-1"] = want + if err := disk.updateTask(testTaskID, func(record *taskState) (bool, error) { + record.Subject = "Customer outage " return true, nil }); err != nil { t.Fatal(err) } - for path, mode := range map[string]os.FileMode{dir: 0o700, disk.path(): 0o600, filepath.Join(dir, "native.lock"): 0o600} { + dataPath, lockPath, err := disk.paths(testTaskID) + if err != nil { + t.Fatal(err) + } + for path, mode := range map[string]os.FileMode{ + disk.subjectDir(): 0o700, + filepath.Join(disk.dir, "lifecycle.lock"): 0o600, + dataPath: 0o600, + lockPath: 0o600, + } { info, err := os.Stat(path) if err != nil { t.Fatal(err) @@ -40,211 +51,231 @@ func TestNativeStateStoreInitializesPrivatelyAndRoundTrips(t *testing.T) { t.Fatalf("%s mode = %o, want %o", path, info.Mode().Perm(), mode) } } - got, err := disk.read() - if err != nil { - t.Fatal(err) + got, err := disk.readTask(testTaskID) + if err != nil || got.Subject != "Customer outage " { + t.Fatalf("record = %#v, %v", got, err) } - if got.Format != stateFormat || got.Tasks["task-1"] != want { - t.Fatalf("read = %#v", got) - } - data, err := os.ReadFile(disk.path()) - if err != nil { - t.Fatal(err) + data, err := os.ReadFile(dataPath) + if err != nil || string(data) != "{\"subject\":\"Customer outage \"}\n" { + t.Fatalf("record bytes = %q, %v", data, err) } - for _, key := range []string{`"format"`, `"tasks"`, `"subject"`} { - if !strings.Contains(string(data), key) { - t.Fatalf("state JSON %q lacks %s", data, key) - } + if _, err := os.Stat(filepath.Join(disk.dir, "subjects.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("global subject map exists") } } -func TestNativeStateCorruptionFailsClosed(t *testing.T) { - for name, body := range map[string]string{ - "malformed": `{`, - "wrong format": `{"format":2,"tasks":{}}`, - "missing tasks": `{"format":3}`, - } { - t.Run(name, func(t *testing.T) { - dir := t.TempDir() - disk := newStore(dir) - if err := os.WriteFile(disk.path(), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - called := false - if err := disk.update(func(*state) (bool, error) { called = true; return false, nil }); err == nil { - t.Fatal("corrupt state was accepted") - } - if called { - t.Fatal("mutation ran against corrupt state") - } - got, err := os.ReadFile(disk.path()) - if err != nil || string(got) != body { - t.Fatalf("corrupt state was replaced: %q, %v", got, err) - } - }) +func TestSubjectRecordCorruptionFailsOnlyThatRecord(t *testing.T) { + disk := testSubjectStore(t) + for _, item := range []struct{ id, subject string }{{testBadID, "bad"}, {testGoodID, "good"}} { + if err := disk.updateTask(item.id, func(record *taskState) (bool, error) { + record.Subject = item.subject + return true, nil + }); err != nil { + t.Fatal(err) + } } -} - -func TestNativeStateRejectsUnsafePaths(t *testing.T) { - realDir := t.TempDir() - link := filepath.Join(filepath.Dir(realDir), "state-link") - if err := os.Symlink(realDir, link); err != nil { + badPath, _, _ := disk.paths(testBadID) + if err := os.WriteFile(badPath, []byte(`{"subject":"bad","unexpected":true}`), 0o600); err != nil { t.Fatal(err) } - if err := newStore(link).update(func(*state) (bool, error) { return false, nil }); err == nil { - t.Fatal("symlink state directory was accepted") + if _, err := disk.readTask(testBadID); err == nil { + t.Fatal("corrupt record was accepted") } - dir := filepath.Join(t.TempDir(), "state") - disk := newStore(dir) - if err := os.Mkdir(dir, 0o700); err != nil { + if got, err := disk.readTask(testGoodID); err != nil || got.Subject != "good" { + t.Fatalf("healthy record = %#v, %v", got, err) + } +} + +func TestSubjectRecordRejectsUnsafePaths(t *testing.T) { + disk := testSubjectStore(t) + for _, id := range []string{"", "task", "../escape", "a/b", strings.Repeat("x", 129), strings.ToUpper(testDelegatedID)} { + if err := disk.updateTask(id, func(*taskState) (bool, error) { return false, nil }); err == nil { + t.Fatalf("unsafe task ID %q was accepted", id) + } + } + realDir := t.TempDir() + linkState := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(linkState, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(disk.path(), []byte(`{"format":3,"tasks":{}}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(linkState, "lifecycle.lock"), nil, 0o600); err != nil { t.Fatal(err) } - if err := os.Chmod(disk.path(), 0o644); err != nil { + if err := os.Symlink(realDir, filepath.Join(linkState, "subjects")); err != nil { t.Fatal(err) } - if _, err := disk.read(); err == nil { - t.Fatal("public state file was accepted") + if err := newStore(linkState).updateTask(testTaskID, func(record *taskState) (bool, error) { + record.Subject = "subject" + return true, nil + }); err == nil { + t.Fatal("symlink subject directory was accepted") } } -func TestNativeStateMutationErrorDoesNotSave(t *testing.T) { - disk := newStore(filepath.Join(t.TempDir(), "state")) +func TestSubjectMutationErrorDoesNotSave(t *testing.T) { + disk := testSubjectStore(t) want := errors.New("stop") - if err := disk.update(func(value *state) (bool, error) { - value.Tasks["task-1"] = taskState{Subject: "not saved"} + if err := disk.updateTask(testTaskID, func(record *taskState) (bool, error) { + record.Subject = "not saved" return false, want }); !errors.Is(err, want) { t.Fatalf("update error = %v", err) } - if _, err := disk.read(); !errors.Is(err, os.ErrNotExist) { + if _, err := disk.readTask(testTaskID); !errors.Is(err, os.ErrNotExist) { t.Fatalf("failed mutation wrote state: %v", err) } } -func TestCanonicalSubjectUsesOnlyExactOwnership(t *testing.T) { - previous := taskState{ - Subject: "Customer outage", - Last: "🚨 Customer outage → restore service", - Pending: &pendingProposal{BaseSubject: "Customer outage", Prior: "⏳ Customer outage", Proposed: "✅ Customer outage"}, - } - for name, pair := range map[string][2]string{ - "last committed": {previous.Last, previous.Subject}, - "pending prior": {previous.Pending.Prior, previous.Subject}, - "pending proposed": {previous.Pending.Proposed, previous.Subject}, - "lost post near miss": {"✅ Customer outage!", "✅ Customer outage!"}, - "user rename": {"🚨 Billing → literal user arrow", "🚨 Billing → literal user arrow"}, - "outer whitespace": {" ✅ Customer outage ", "✅ Customer outage"}, - } { - t.Run(name, func(t *testing.T) { - if got := canonicalSubject(pair[0], previous); got != pair[1] { - t.Fatalf("canonicalSubject(%q) = %q, want %q", pair[0], got, pair[1]) - } +func TestSubjectWritesShareLifecycleFence(t *testing.T) { + disk := testSubjectStore(t) + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + firstDone <- disk.updateTask(testFirstID, func(record *taskState) (bool, error) { + close(firstEntered) + <-releaseFirst + record.Subject = "first" + return true, nil }) + }() + <-firstEntered + secondDone := make(chan error, 1) + go func() { + secondDone <- disk.updateTask(testSecondID, func(record *taskState) (bool, error) { + record.Subject = "second" + return true, nil + }) + }() + select { + case err := <-secondDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("independent subject writes serialized on the lifecycle fence") } - if got := canonicalSubject("🚨 User title → do not parse", taskState{}); got != "🚨 User title → do not parse" { - t.Fatalf("fresh title was parsed as owned: %q", got) - } - if got := canonicalSubject("Fresh task\n subject", taskState{}); got != "Fresh task subject" { - t.Fatalf("fresh subject was not normalized: %q", got) + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) } } -func TestParseFooterExactGrammar(t *testing.T) { - valid := map[string]footer{ - "Done.\n\n🧵🐻 complete": {Status: "complete"}, - "🧵🐻 automation\r\n": {Status: "automation"}, - "🧵🐻 next steps (you): approve the release": {Status: "next_steps", Action: "approve the release"}, - "🧵🐻 next steps (agent): retry the title handoff": {Status: "next_steps", Action: "retry the title handoff"}, - "🧵🐻 next steps (external): review the pull request": {Status: "next_steps", Action: "review the pull request"}, - "🧵🐻 needs input (you): choose the release region": {Status: "needs_input", Action: "choose the release region"}, - "🧵🐻 needs input (you): approve": {Status: "needs_input", Action: "approve"}, - "🧵🐻 blocked (external): restore the signing service": {Status: "blocked", Action: "restore the signing service"}, - } - for message, want := range valid { - got, ok := parseFooter(message) - if !ok || got != want { - t.Errorf("parseFooter(%q) = %#v, %v; want %#v", message, got, ok, want) - } +func TestSubjectWriteRefusesBusyTeardownFenceWithoutWaiting(t *testing.T) { + disk := testSubjectStore(t) + path := filepath.Join(disk.dir, "lifecycle.lock") + lifecycle, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) } - invalid := []string{ - "", "🧵🐻 Complete", "> 🧵🐻 complete", "🧵🐻 complete\nextra", - "🧵🐻 complete\n🧵🐻 automation", "🧵🐻 needs input (you): ", "🧵🐻 needs input (you): ", - "🧵🐻 needs input (agent): choose the region", "🧵🐻 blocked (you): restore the service", - "🧵🐻 next steps (bear): approve the release", " 🧵🐻 complete", "🧵🐻 complete ", + if err := unix.Flock(int(lifecycle.Fd()), unix.LOCK_EX); err != nil { + t.Fatal(err) } - for _, message := range invalid { - if got, ok := parseFooter(message); ok { - t.Errorf("parseFooter(%q) accepted %#v", message, got) - } + started := time.Now() + err = disk.updateTask(testLateID, func(record *taskState) (bool, error) { + record.Subject = "late" + return true, nil + }) + if err == nil || !strings.Contains(err.Error(), "lifecycle is busy") { + unlock(lifecycle) + t.Fatalf("subject write with exclusive lifecycle fence = %v", err) + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + unlock(lifecycle) + t.Fatalf("subject write waited behind lifecycle teardown for %s", elapsed) + } + unlock(lifecycle) + if _, err := disk.readTask(testLateID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("refused subject write created state: %v", err) } } -func TestStripStatusIcons(t *testing.T) { - for title, want := range map[string]string{ - "✅ ✅ ❔ hello": "hello", - "✅✅❔hello": "hello", - "➡ task": "task", - "❔ ❔ ❔": "", - "➡️ 🙋 task → action": "task → action", - "🎉 ✅ user title": "🎉 ✅ user title", - "text ✅ suffix": "text ✅ suffix", - } { - if got := stripStatusIcons(title); got != want { - t.Errorf("stripStatusIcons(%q) = %q, want %q", title, got, want) - } +func TestSubjectWriteWithoutLifecycleFenceDoesNotCreateState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "absent") + disk := newStore(dir) + if err := disk.updateTask(testLateID, func(record *taskState) (bool, error) { + record.Subject = "late" + return true, nil + }); err == nil { + t.Fatal("subject write without lifecycle fence succeeded") + } + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("failed subject write created state: %v", err) } } -func TestRenderTitleContractAndSubjectPriority(t *testing.T) { - for name, values := range map[string][4]string{ - "running": {"running", "Ship BEAR-102", "", "⏳ Ship BEAR-102"}, - "next steps": {"next_steps", "Ship BEAR-102", "approve the release", "➡️ Ship BEAR-102 → approve the release"}, - "complete": {"complete", "Ship BEAR-102", "ignored action", "✅ Ship BEAR-102"}, - "automation": {"automation", "Nightly cleanup", "ignored action", "🤖 Nightly cleanup"}, - "unknown": {"not-a-status", "Legacy task", "", "❔ Legacy task"}, +func TestResolveSubjectUsesFiniteOwnershipAndAdoptsRenameVerbatim(t *testing.T) { + record := taskState{Subject: "Stable subject"} + for _, icon := range ownedIcons { + current := icon + " Stable subject" + if got, err := resolveSubject(current, record); err != nil || got != record.Subject { + t.Errorf("owned %q = %q, %v", current, got, err) + } + } + rename := "✅ User rename " + if got, err := resolveSubject(rename, record); err != nil || got != rename { + t.Fatalf("rename = %q, %v", got, err) + } + for _, operation := range []string{ + "🧵🐻 complete", + "🧵🐻 automation", + "🧵🐻 next steps (agent): finish the release", + "🧵🐻 needs input (you): approve onboarding 190 safe tasks", + "🧵🐻 blocked (external): restore the signing service", + "⏳ ThreadBear is working", + "⏳ ThreadBear is working: stale running title", } { - t.Run(name, func(t *testing.T) { - if got := renderTitle(values[0], values[1], values[2]); got != values[3] { - t.Fatalf("renderTitle() = %q, want %q", got, values[3]) - } - }) + if got, err := resolveSubject(operation, record); err != nil || got != record.Subject { + t.Fatalf("operation recovery %q = %q, %v", operation, got, err) + } + if _, err := resolveSubject(operation, taskState{}); err == nil { + t.Fatalf("unowned operation title %q was adopted", operation) + } } - got := renderTitle("next_steps", strings.Repeat("s", 100), "keep this action") - want := renderTitle("next_steps", strings.Repeat("s", 100), "") - if got != want || utf16Len(got) != 60 { - t.Fatalf("action displaced durable subject: %q, want %q", got, want) + for _, rename := range []string{"🧵🐻 Personal project", "🧵🐻 needs attention"} { + if got, err := resolveSubject(rename, record); err != nil || got != rename { + t.Fatalf("bear-prefixed user rename %q = %q, %v", rename, got, err) + } } - got = renderTitle("blocked", "keep subject", strings.Repeat("a", 100)) - if utf16Len(got) > 60 || !strings.HasPrefix(got, "🚨 keep subject → ") || !strings.HasSuffix(got, "…") { - t.Fatalf("long action truncation = %q (%d units)", got, utf16Len(got)) + if _, err := resolveSubject("✅ Unowned", taskState{}); err == nil { + t.Fatal("unowned legacy prefix was adopted") } - subject := strings.Repeat("d", 45) - got = renderTitle("next_steps", subject, "this action must be truncated before the subject") - if !strings.HasPrefix(got, "➡️ "+subject+" → ") || utf16Len(got) != 60 || !strings.HasSuffix(got, "…") { - t.Fatalf("bounded action displaced subject: %q (%d units)", got, utf16Len(got)) + if _, err := resolveSubject("raw", record); err == nil { + t.Fatal("raw envelope was adopted as a rename") } -} - -func TestRenderTitleUTF16Boundaries(t *testing.T) { - for subjectLen, want := range map[int][2]int{ - 57: {59, 0}, - 58: {60, 0}, - 59: {60, 1}, + if _, err := resolveSubject("raw", record); err == nil { + t.Fatal("internal context was adopted as a rename") + } + if _, err := resolveSubject("✅ raw", record); err == nil { + t.Fatal("decorated delegation envelope was adopted as a rename") + } + for _, envelope := range []string{ + "Codex desktop context", + "Default", + "active", + "unrestricted", } { - got := renderTitle("complete", strings.Repeat("x", subjectLen), "") - if utf16Len(got) != want[0] || strings.HasSuffix(got, "…") != (want[1] == 1) { - t.Errorf("subject %d: %q has %d units", subjectLen, got, utf16Len(got)) + if _, err := resolveSubject(envelope, record); err == nil || !strings.Contains(err.Error(), "internal envelope") { + t.Fatalf("internal envelope %q was adopted: %v", envelope, err) } } - got := renderTitle("next_steps", strings.Repeat("🧵", 29), "") - if utf16Len(got) > 60 || !utf8.ValidString(got) || !strings.HasSuffix(got, "…") { - t.Fatalf("emoji title = %q (%d units, valid=%v)", got, utf16Len(got), utf8.ValidString(got)) - } - got = renderTitle("complete", strings.Repeat("🧵", 28), "") - if got != "✅ "+strings.Repeat("🧵", 28) || !utf8.ValidString(got) { - t.Fatalf("fitting emoji pair was split: %q", got) +} + +func TestRenderTitlePreservesSubjectAndNeverTruncates(t *testing.T) { + subject := " 🎉 Exact whitespace " + got, err := renderTitle("complete", subject) + if err != nil || got != "✅ "+subject { + t.Fatalf("render = %q, %v", got, err) + } + fit := strings.Repeat("x", 57) + if got, err := renderTitle("blocked", fit); err != nil || utf16Units(got) != 60 { + t.Fatalf("fitting title = %q (%d), %v", got, utf16Units(got), err) + } + if got, err := renderTitle("complete", strings.Repeat("x", 58)); err == nil || got != "" { + t.Fatalf("too-long title = %q, %v", got, err) + } + emojiFit := strings.Repeat("🧵", 28) + if got, err := renderTitle("complete", emojiFit); err != nil || got != "✅ "+emojiFit { + t.Fatalf("emoji title = %q, %v", got, err) } } diff --git a/cmd/threadbear/test_ids_test.go b/cmd/threadbear/test_ids_test.go new file mode 100644 index 0000000..8c75eb3 --- /dev/null +++ b/cmd/threadbear/test_ids_test.go @@ -0,0 +1,24 @@ +package main + +const ( + testTaskID = "00000000-0000-0000-0000-000000000001" + testOtherID = "00000000-0000-0000-0000-000000000002" + testActiveID = "00000000-0000-0000-0000-000000000003" + testAlphaID = "00000000-0000-0000-0000-000000000004" + testAlreadyID = "00000000-0000-0000-0000-000000000005" + testRawID = "00000000-0000-0000-0000-000000000006" + testBlankAfterID = "00000000-0000-0000-0000-000000000007" + testDriftID = "00000000-0000-0000-0000-000000000008" + testUnconfirmedID = "00000000-0000-0000-0000-000000000009" + testDelegatedID = "00000000-0000-0000-0000-00000000000a" + testDuplicateID = "00000000-0000-0000-0000-00000000000b" + testLegacyID = "00000000-0000-0000-0000-00000000000c" + testSafeID = "00000000-0000-0000-0000-00000000000d" + testBadID = "00000000-0000-0000-0000-00000000000e" + testGoodID = "00000000-0000-0000-0000-00000000000f" + testFirstID = "00000000-0000-0000-0000-000000000010" + testSecondID = "00000000-0000-0000-0000-000000000011" + testLateID = "00000000-0000-0000-0000-000000000012" + testInstallerID = "00000000-0000-0000-0000-000000000013" + testMainID = "00000000-0000-0000-0000-000000000014" +) diff --git a/cmd/threadbear/uninstall_protocol_test.go b/cmd/threadbear/uninstall_protocol_test.go deleted file mode 100644 index 34a0084..0000000 --- a/cmd/threadbear/uninstall_protocol_test.go +++ /dev/null @@ -1,533 +0,0 @@ -package main - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "golang.org/x/sys/unix" -) - -func (s store) waitLock() (*os.File, error) { return s.openLock("operation.lock", unix.LOCK_EX, true) } - -func uninstall(ctx context.Context, confirmed bool) (any, error) { - if !confirmed { - return nil, os.ErrPermission - } - operationLock, err := newStore(stateDir()).waitLock() - if err != nil { - return nil, err - } - defer unlock(operationLock) - value, err := currentStateOrEmpty() - if err != nil { - return nil, err - } - if value.Phase == phaseMigrationRunning { - return nil, errors.New("cannot uninstall while installation migration is running; stop the controller first") - } - if value.ArchivePending != nil { - return nil, errors.New("cannot uninstall while a native archive operation is pending; reconcile it first") - } - if value.MainTaskID != "" { - main, found, err := archiveTaskByID(ctx, value.MainTaskID) - if err != nil { - return nil, err - } - if found && stripStatusIcons(main.Title) != main.Title { - return nil, errors.New("uninstall requires title cleanup from the ThreadBear control task") - } - } - return uninstallLocked(ctx, value) -} - -func addUninstallOwner(t testing.TB, db *sql.DB, root string) { - t.Helper() - addTask(t, db, root, "requester", "Uninstall owner", nil, "vscode", 0) -} - -func TestArchivedControlUninstallPersistsInitiatorAndAuthorizesCleanup(t *testing.T) { - root, db := testIndex(t) - p := installPaths() - addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) - addTask(t, db, root, "controller", "⏳ Completed controller", nil, "vscode", 1) - addUninstallOwner(t, db, root) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.ControllerTaskID, value.Phase = "controller", phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - - var output bytes.Buffer - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { - t.Fatalf("prepare code %d: %s", code, output.String()) - } - for _, want := range []string{`"prepared":true`, `"initiator_task_id":"requester"`, `"main_task_id":"main"`, `"main_archived":true`} { - if !strings.Contains(output.String(), want) { - t.Fatalf("prepare output missing %s: %s", want, output.String()) - } - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"resumed":true`) { - t.Fatalf("same-owner resume code %d: %s", code, output.String()) - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "other", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 { - t.Fatalf("second owner code %d: %s", code, output.String()) - } - ordinary := hookPayload("PreToolUse", "requester", "owner-running", map[string]any{"title": runningMarker + ": Uninstall owner"}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(ordinary), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("ordinary title during uninstall = %q, %v", output.String(), err) - } - for _, plain := range []string{"Renamed", homeTitle} { - output.Reset() - payload := hookPayload("PreToolUse", "other", "plain-during-uninstall", map[string]any{"threadId": "main", "title": plain}, nil) - if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("plain title %q during uninstall = %q, %v", plain, output.String(), err) - } - } - - if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='main'`); err != nil { - t.Fatal(err) - } - denied := hookPayload("PreToolUse", "other", "other-cleanup", map[string]any{"threadId": "main", "title": cleanupMarker}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(denied), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("other cleanup = %q, %v", output.String(), err) - } - pre := hookPayload("PreToolUse", "requester", "owner-cleanup", map[string]any{"threadId": "main", "title": cleanupMarker}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - proposed := rewrittenTitle(t, output.Bytes()) - if proposed != mainTitle { - t.Fatalf("owner cleanup title = %q", proposed) - } - if _, err := db.Exec(`UPDATE threads SET title=?, archived=1 WHERE id='main'`, proposed); err != nil { - t.Fatal(err) - } - response, _ := json.Marshal(map[string]string{"threadId": "main", "title": proposed}) - post := hookPayload("PostToolUse", "requester", "owner-cleanup", map[string]any{"threadId": "main", "title": proposed}, string(response)) - if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { - t.Fatal(err) - } - - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { - t.Fatalf("commit code %d: %s", code, output.String()) - } - for _, path := range []string{p.binary, p.skill, stateDir()} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("uninstall left %s: %v", path, err) - } - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"uninstalled":true`) { - t.Fatalf("retained-candidate no-op code %d: %s", code, output.String()) - } -} - -func TestArchivedControlUninstallPrepareRequiresActiveUserInitiator(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "Control task", nil, "vscode", 1) - addTask(t, db, root, "archived", "Archived", nil, "vscode", 1) - addTask(t, db, root, "invisible", "Invisible", nil, "vscode", 0) - addTask(t, db, root, "nonuser", "Automation", nil, "mcp", 0) - if _, err := db.Exec(`UPDATE threads SET preview='' WHERE id='invisible'`); err != nil { - t.Fatal(err) - } - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - for _, id := range []string{"missing", "archived", "invisible", "nonuser"} { - if _, err := prepareUninstall(context.Background(), id); err == nil { - t.Fatalf("prepare accepted initiator %q", id) - } - value, _ := newStore(stateDir()).read() - if value.UninstallPending != nil { - t.Fatalf("failed prepare persisted owner %q", id) - } - } -} - -func TestFailedMigrationCanPrepareAndCleanForUninstall(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "target", "✅ Target", nil, "vscode", 0) - addUninstallOwner(t, db, root) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.ControllerTaskID, value.Phase, value.MigrationFailure = "controller", phaseMigrationFailed, "controller stopped" - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := prepareUninstall(context.Background(), "requester"); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - pre := hookPayload("PreToolUse", "requester", "cleanup-failed", map[string]any{"threadId": "target", "title": cleanupMarker}, nil) - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - if proposed := rewrittenTitle(t, output.Bytes()); proposed != "Target" { - t.Fatalf("failed migration cleanup title = %q", proposed) - } -} - -func TestArchivedControlUninstallAbortRestoresOrdinaryOperation(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) - addUninstallOwner(t, db, root) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := prepareUninstall(context.Background(), "requester"); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='main'`); err != nil { - t.Fatal(err) - } - if _, err := completeUninstall(context.Background(), "requester", false, true); err == nil { - t.Fatal("abort accepted unrestored archive state") - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='main'`); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Tasks["requester"] = taskState{Pending: &pendingProposal{CallerTaskID: "requester", Prior: "Uninstall owner", Proposed: "Owner"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - result, err := completeUninstall(context.Background(), "requester", false, true) - if err != nil || result.(map[string]any)["aborted"] != true { - t.Fatalf("abort = %#v, %v", result, err) - } - value, _ := newStore(stateDir()).read() - if value.UninstallPending != nil || value.Tasks["requester"].Pending != nil { - t.Fatalf("abort left pending state: %#v", value) - } - if _, err := os.Stat(installPaths().binary); err != nil { - t.Fatalf("abort removed ThreadBear: %v", err) - } -} - -func TestArchivedControlUninstallPrepareRejectsInFlightNativeTitle(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "Control task", nil, "vscode", 1) - addUninstallOwner(t, db, root) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { value.Phase = phaseMigrationComplete; return true, nil }); err != nil { - t.Fatal(err) - } - plain := hookPayload("PreToolUse", "other", "in-flight-plain", map[string]any{"threadId": "main", "title": "Control task"}, nil) - if err := hook(context.Background(), strings.NewReader(plain), &bytes.Buffer{}); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - before, _ := newStore(stateDir()).read() - if pending := before.Tasks["main"].Pending; pending == nil || pending.Prior != pending.Proposed { - t.Fatalf("fixture did not stage a no-op proposal: %#v", pending) - } - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "has not settled") { - t.Fatalf("pending title prepare code %d: %s", code, output.String()) - } - value, _ := newStore(stateDir()).read() - if value.Tasks["main"].Pending == nil || value.UninstallPending != nil { - t.Fatalf("prepare discarded in-flight title or started uninstall: %#v", value) - } -} - -func TestRetainedCandidateFinishesBinaryRemovalAfterStateCommit(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "Control task", nil, "vscode", 1) - p := installPaths() - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - if err := manageBlock(p.agents, ""); err != nil { - t.Fatal(err) - } - hooks, write, err := editHooks(p.hooks, p.binary, false) - if err != nil { - t.Fatal(err) - } - if write { - if len(hooks) == 0 { - if err := removeFiles("", p.hooks); err != nil { - t.Fatal(err) - } - } else if err := writeAtomic(p.hooks, hooks, 0o600); err != nil { - t.Fatal(err) - } - } - if err := removeFiles("", p.skill); err != nil { - t.Fatal(err) - } - if err := removeFiles("", newStore(stateDir()).path()); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("fixture lost installed binary: %v", err) - } - var output bytes.Buffer - if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { - t.Fatalf("retained candidate finish code %d: %s", code, output.String()) - } - for _, path := range []string{p.binary, filepath.Dir(p.skill)} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("retained candidate left %s: %v", path, err) - } - } -} - -func TestArchivedControlUninstallResumeReconcilesUnknownAppliedTitle(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) - addUninstallOwner(t, db, root) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { - t.Fatalf("prepare code %d: %s", code, output.String()) - } - if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='main'`); err != nil { - t.Fatal(err) - } - pre := hookPayload("PreToolUse", "requester", "unknown-cleanup", map[string]any{"threadId": "main", "title": cleanupMarker}, nil) - output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - proposed := rewrittenTitle(t, output.Bytes()) - if _, err := db.Exec(`UPDATE threads SET title=?, archived=1 WHERE id='main'`, proposed); err != nil { - t.Fatal(err) - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"reconciled_titles":1`) { - t.Fatalf("unknown-result resume code %d: %s", code, output.String()) - } - value, err := newStore(stateDir()).read() - if err != nil || value.Tasks["main"].Pending != nil || value.Tasks["main"].Last != proposed { - t.Fatalf("reconciled unknown title = %#v, %v", value.Tasks["main"], err) - } -} - -func TestArchivedControlUninstallCommitRequiresRestoredArchiveAndSettledTitles(t *testing.T) { - root, db := testIndex(t) - p := installPaths() - addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) - addUninstallOwner(t, db, root) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { - t.Fatalf("prepare code %d: %s", code, output.String()) - } - if _, err := db.Exec(`UPDATE threads SET title='Control task', archived=0 WHERE id='main'`); err != nil { - t.Fatal(err) - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "archive state") { - t.Fatalf("unrestored archive commit code %d: %s", code, output.String()) - } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("blocked commit removed retry binary: %v", err) - } - if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='main'`); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Tasks["main"] = taskState{Pending: &pendingProposal{CallerTaskID: "main", Proposed: "Control task"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "native title operation is pending") { - t.Fatalf("unknown title commit code %d: %s", code, output.String()) - } - if _, err := os.Stat(p.binary); err != nil { - t.Fatalf("unknown title commit removed retry binary: %v", err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - record := value.Tasks["main"] - record.Pending = nil - value.Tasks["main"] = record - return true, nil - }); err != nil { - t.Fatal(err) - } - output.Reset() - if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { - t.Fatalf("settled resumed commit code %d: %s", code, output.String()) - } -} - -func TestUninstallHomeCleanupRestoresCanonicalTitle(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "Investigate install failure", nil, "vscode", 0) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - saved, err := newStore(stateDir()).read() - if err != nil || saved.Tasks["main"].Original != "Investigate install failure" { - t.Fatalf("install-time original = %#v, %v", saved.Tasks["main"], err) - } - if _, err := db.Exec(`UPDATE threads SET title=? WHERE id='main'`, "⏳ "+homeTitle); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := prepareUninstall(context.Background(), "main"); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - cleanup := hookPayload("PreToolUse", "main", "cleanup", map[string]any{"title": cleanupMarker}, nil) - if err := hook(context.Background(), strings.NewReader(cleanup), &output); err != nil || rewrittenTitle(t, output.Bytes()) != mainTitle { - t.Fatalf("home cleanup = %q, %v", output.String(), err) - } -} - -func TestUninstallPrepareAcceptsQuiescentPendingInstall(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) - addTask(t, db, root, "unowned", "✅ User-owned title", nil, "vscode", 0) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if result, err := prepareUninstall(context.Background(), "main"); err != nil || result.(map[string]any)["prepared"] != true { - t.Fatalf("pending prepare = %#v, %v", result, err) - } - if _, err := db.Exec(`UPDATE threads SET title=? WHERE id='main'`, homeTitle); err != nil { - t.Fatal(err) - } - if _, err := completeUninstall(context.Background(), "main", true, false); err == nil { - t.Fatal("pending uninstall accepted the install sentinel") - } - if _, err := db.Exec(`UPDATE threads SET title='ThreadBear' WHERE id='main'`); err != nil { - t.Fatal(err) - } - if _, err := completeUninstall(context.Background(), "main", true, false); err != nil { - t.Fatal(err) - } - var title string - if err := db.QueryRow(`SELECT title FROM threads WHERE id='unowned'`).Scan(&title); err != nil || title != "✅ User-owned title" { - t.Fatalf("unowned title = %q, %v", title, err) - } -} - -func TestUninstallPrepareClearsHomeAttestedSettledFailure(t *testing.T) { - root, db := testIndex(t) - for _, id := range []string{"main", "controller", "target"} { - addTask(t, db, root, id, id, nil, "vscode", 0) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase, value.MigrationFailure = "main", "controller", phaseMigrationFailed, "controller reported a settled migration failure" - value.Tasks["target"] = taskState{Pending: &pendingProposal{CallerTaskID: "controller", Prior: "target", Proposed: "✅ target"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - t.Setenv("CODEX_THREAD_ID", "main") - if _, err := prepareUninstall(context.Background(), "main"); err != nil { - t.Fatal(err) - } - value, _ := newStore(stateDir()).read() - if value.Tasks["target"].Pending != nil { - t.Fatal("settled failed proposal remained pending") - } -} - -func TestReconcileTitlesRejectsCanonicalHomeNoopWithoutSettlement(t *testing.T) { - root, db := testIndex(t) - addTask(t, db, root, "main", mainTitle, nil, "vscode", 0) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.Phase = "main", phaseMigrationComplete - value.Tasks["main"] = taskState{Pending: &pendingProposal{Prior: mainTitle, Proposed: mainTitle}} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := reconcileTitles(context.Background(), ""); err == nil { - t.Fatal("canonical home no-op reconciled without settlement") - } - value, _ := newStore(stateDir()).read() - if value.Tasks["main"].Pending == nil { - t.Fatal("canonical home no-op was cleared") - } -} - -func TestSettledMigrationFailureKeepsNonControllerProposal(t *testing.T) { - root, db := testIndex(t) - for _, id := range []string{"main", "controller", "other"} { - addTask(t, db, root, id, id, nil, "vscode", 0) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.MainTaskID, value.ControllerTaskID, value.Phase, value.MigrationFailure = "main", "controller", phaseMigrationFailed, "controller reported a settled migration failure" - value.Tasks["other"] = taskState{Pending: &pendingProposal{CallerTaskID: "main", Prior: "other", Proposed: "✅ other"}} - return true, nil - }); err != nil { - t.Fatal(err) - } - if _, err := reconcileTitles(context.Background(), ""); err == nil { - t.Fatal("settled controller wave cleared a non-controller proposal") - } - value, _ := newStore(stateDir()).read() - if value.Tasks["other"].Pending == nil { - t.Fatal("non-controller proposal was cleared") - } -} diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go index 03eeb44..4b4f093 100644 --- a/cmd/threadbear/update.go +++ b/cmd/threadbear/update.go @@ -44,27 +44,28 @@ type releaseManifest struct { Assets map[string]releaseAsset `json:"assets"` } -func update(ctx context.Context) (any, error) { - operationLock, err := newStore(stateDir()).operationLock() - if err != nil { - return nil, updateFailure("busy", err) +type updateReceipt struct { + CheckedAt string `json:"checked_at"` + From string `json:"from"` + Version string `json:"version"` + Outcome string `json:"outcome"` + Automatic bool `json:"automatic"` + RestartRequired bool `json:"restart_required"` + Error string `json:"error,omitempty"` +} + +func update(ctx context.Context, automatic bool) (result any, returnErr error) { + p := installPaths() + if err := requireCurrentFormatInstall(p); err != nil { + return nil, updateFailure("installation", err) } - defer unlock(operationLock) - value, err := newStore(stateDir()).read() + updateLock, err := updateCheckLock() if err != nil { - return nil, updateFailure("state", err) - } - if value.MainTaskID == "" || value.Phase != phaseMigrationComplete { - return nil, updateFailure("state", errors.New("update requires a completed ThreadBear installation")) - } - if value.ArchivePending != nil { - return nil, updateFailure("archive_pending", errors.New("reconcile the pending native archive operation before updating")) - } - if value.UninstallPending != nil { - return nil, updateFailure("uninstall_pending", errors.New("finish the prepared uninstall before updating")) + return nil, updateFailure("busy", err) } - if hasPendingTitle(value) { - return nil, updateFailure("title_pending", errors.New("settle pending native title operations before updating")) + defer unlock(updateLock) + if err := requireCurrentFormatInstall(p); err != nil { + return nil, updateFailure("installation", err) } assetKey, assetName, err := updatePlatform() if err != nil { @@ -74,7 +75,14 @@ func update(ctx context.Context) (any, error) { if err != nil { return nil, updateFailure("installed_version", err) } - _, healthErr := status(ctx) + receipt := updateReceipt{From: version, Version: version, Automatic: automatic} + defer func() { + if returnErr == nil { + return + } + receipt.Outcome, receipt.Error = "failed", returnErr.Error() + _ = writeUpdateReceiptForCurrentInstall(p, receipt) + }() manifestData, err := fetchUpdate(ctx, updateManifestURL, updateManifestLimit) if err != nil { return nil, updateFailure("manifest_download", err) @@ -87,12 +95,19 @@ func update(ctx context.Context) (any, error) { if err != nil { return nil, updateFailure("manifest_version", err) } + receipt.Version = manifest.Version comparison := slices.Compare(current[:], latest[:]) - if comparison >= 0 && healthErr == nil { - return map[string]any{"ready": true, "current": true, "version": version, "latest": manifest.Version}, nil - } - if comparison > 0 { - return nil, updateFailure("health", errors.New("installed version is newer than the latest release but managed surfaces are unhealthy")) + if comparison >= 0 { + health, healthErr := status(ctx) + if healthErr != nil { + return health, updateFailure("installation", healthErr) + } + result := map[string]any{"ready": true, "current": true, "version": version, "latest": manifest.Version, "automatic": automatic, "restart_required": false} + receipt.Outcome = "current" + if err := writeUpdateReceiptForCurrentInstall(p, receipt); err != nil { + return result, updateFailure("receipt", err) + } + return result, nil } asset, ok := manifest.Assets[assetKey] if !ok { @@ -135,16 +150,49 @@ func update(ctx context.Context) (any, error) { if err := requireCandidate(ctx, candidate, updateCandidateTimeout, "self-test", manifest.Version, "self-test", "--candidate", "--json"); err != nil { return nil, updateFailure("candidate_self_test", err) } - if err := requireCandidate(ctx, candidate, updateInstallTimeout, "install", "", "install", "--noninteractive", "--confirm", "--json"); err != nil { + receipt.RestartRequired = true + if err := requireCandidate(ctx, candidate, updateInstallTimeout, "install", "", "install", "--automatic", "--no-onboard", "--noninteractive", "--confirm", "--json"); err != nil { return nil, updateFailure("candidate_install", err) } if err := requireCandidate(ctx, candidate, updateCandidateTimeout, "status", manifest.Version, "status", "--json"); err != nil { return nil, updateFailure("installed_status", err) } - result := map[string]any{"ready": true, "from": version, "version": manifest.Version} - result[map[bool]string{true: "updated", false: "repaired"}[comparison < 0]] = true + result = map[string]any{"ready": true, "from": version, "version": manifest.Version, "automatic": automatic, "updated": true, "restart_required": true} + receipt.Outcome = "updated" + if err := writeUpdateReceiptForCurrentInstall(p, receipt); err != nil { + return result, updateFailure("receipt", err) + } return result, nil } + +func writeUpdateReceiptForCurrentInstall(p lifecyclePaths, receipt updateReceipt) error { + lock, err := existingLifecycleLock("lifecycle.lock") + if err != nil { + return err + } + defer unlock(lock) + if err := requireCurrentFormatInstall(p); err != nil { + return err + } + receipt.CheckedAt = time.Now().UTC().Format(time.RFC3339) + data, err := json.Marshal(receipt) + if err != nil { + return err + } + return writeAtomic(p.updateReceipt, append(data, '\n'), 0o600) +} + +func readUpdateReceipt(path string) (updateReceipt, error) { + data, err := os.ReadFile(path) + if err != nil { + return updateReceipt{}, err + } + var receipt updateReceipt + if json.Unmarshal(data, &receipt) != nil || receipt.CheckedAt == "" || receipt.From == "" || receipt.Version == "" || receipt.Outcome == "" { + return updateReceipt{}, errors.New("update receipt is invalid") + } + return receipt, nil +} func updatePlatform() (string, string, error) { if updateGOOS != "darwin" { return "", "", errors.New("only Darwin is supported") @@ -224,7 +272,7 @@ func parseChecksum(data []byte) ([]byte, error) { func requireCandidate(parent context.Context, candidate string, timeout time.Duration, operation, expectedVersion string, args ...string) error { ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() - var output cappedOutput + var output bytes.Buffer command := exec.CommandContext(ctx, candidate, args...) command.Stdout, command.Stderr = &output, &output err := command.Run() @@ -234,9 +282,6 @@ func requireCandidate(parent context.Context, candidate string, timeout time.Dur if err != nil { return fmt.Errorf("candidate failed: %w: %s", err, strings.TrimSpace(output.String())) } - if output.Overflow { - return errors.New("candidate output exceeded the size limit") - } var result map[string]any if json.Unmarshal(output.Bytes(), &result) != nil { return errors.New("candidate returned an invalid result") @@ -252,20 +297,3 @@ func requireCandidate(parent context.Context, candidate string, timeout time.Dur } return nil } - -type cappedOutput struct { - bytes.Buffer - Overflow bool -} - -func (output *cappedOutput) Write(data []byte) (int, error) { - const limit = 1 << 20 - written := len(data) - remaining := limit - output.Len() - if remaining < len(data) { - output.Overflow = true - data = data[:max(0, remaining)] - } - _, _ = output.Buffer.Write(data) - return written, nil -} diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go index 7ab9ab7..506be52 100644 --- a/cmd/threadbear/update_test.go +++ b/cmd/threadbear/update_test.go @@ -36,105 +36,125 @@ type updateFixture struct { mu sync.Mutex } -func TestUpdateNewerOlderAndHealthyNoop(t *testing.T) { - prepareUpdate(t, "1.2.3", true) +func TestUpdateNewerCurrentAndAutomaticReceipt(t *testing.T) { + p := prepareUpdate(t, "1.2.3", true) fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "1.2.4"}) - result, err := update(context.Background()) - if err != nil || result.(map[string]any)["updated"] != true || result.(map[string]any)["version"] != "1.2.4" { + result, err := update(context.Background(), true) + if err != nil || result.(map[string]any)["updated"] != true || result.(map[string]any)["automatic"] != true || result.(map[string]any)["version"] != "1.2.4" || result.(map[string]any)["restart_required"] != true { t.Fatalf("newer update = %#v, %v", result, err) } + var receipt updateReceipt + data, readErr := os.ReadFile(p.updateReceipt) + if readErr != nil || json.Unmarshal(data, &receipt) != nil || receipt.Outcome != "updated" || receipt.Version != "1.2.4" || !receipt.Automatic || !receipt.RestartRequired || receipt.CheckedAt == "" { + t.Fatalf("update receipt = %#v, %v", receipt, readErr) + } + entries, err := os.ReadDir(newStore(stateDir()).subjectDir()) + if err != nil || len(entries) != 0 { + t.Fatalf("update touched subject records: %#v, %v", entries, err) + } + version = "1.2.4" before := fixture.count("asset") - result, err = update(context.Background()) - if err != nil || result.(map[string]any)["current"] != true || fixture.count("asset") != before { - t.Fatalf("same-version no-op = %#v, %v, asset requests %d", result, err, fixture.count("asset")) + result, err = update(context.Background(), false) + if err != nil || result.(map[string]any)["current"] != true || result.(map[string]any)["automatic"] != false || result.(map[string]any)["restart_required"] != false || fixture.count("asset") != before { + t.Fatalf("same-version no-op = %#v, %v", result, err) } - version = "1.2.5" - result, err = update(context.Background()) - if err != nil || result.(map[string]any)["current"] != true || fixture.count("asset") != before { - t.Fatalf("older-release no-op = %#v, %v, asset requests %d", result, err, fixture.count("asset")) + data, _ = os.ReadFile(p.updateReceipt) + if json.Unmarshal(data, &receipt) != nil || receipt.Outcome != "current" || receipt.Automatic || receipt.RestartRequired { + t.Fatalf("current receipt = %#v", receipt) } } -func TestUpdateSameVersionRepairsManagedSurfaces(t *testing.T) { +func TestUpdateSameVersionReportsUnhealthyButNewerCandidateRepairs(t *testing.T) { p := prepareUpdate(t, "2.0.0", false) - startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.0"}) - result, err := update(context.Background()) - if err != nil || result.(map[string]any)["repaired"] != true { - t.Fatalf("repair = %#v, %v", result, err) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.0"}) + result, err := update(context.Background(), false) + requireUpdateStage(t, err, "installation") + value := result.(map[string]any) + if value["ready"] != false || value["installed"] != true || value["current"] != nil || fixture.count("asset") != 0 { + t.Fatalf("same-version unhealthy result = %#v, %v", result, err) + } + if data, _ := os.ReadFile(p.skill); string(data) == assets.SkillManagedContent { + t.Fatal("same-version update silently repaired the unhealthy skill") + } + startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1"}) + result, err = update(context.Background(), false) + if err != nil || result.(map[string]any)["updated"] != true || result.(map[string]any)["ready"] != true { + t.Fatalf("newer repair update = %#v, %v", result, err) } - data, _ := os.ReadFile(p.skill) - if string(data) != assets.SkillManagedContent { - t.Fatal("same-version repair did not restore the managed skill") + if data, _ := os.ReadFile(p.skill); string(data) != assets.SkillManagedContent { + t.Fatal("newer candidate did not repair the unhealthy skill") } } -func TestUpdateRefusesPendingArchiveBeforeNetwork(t *testing.T) { - prepareUpdate(t, "2.0.0", true) - fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1"}) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.ArchivePending = &archiveOperation{TaskID: "target", Action: "archive"} - return true, nil - }); err != nil { - t.Fatal(err) - } - _, err := update(context.Background()) - requireUpdateStage(t, err, "archive_pending") - if fixture.count("manifest") != 0 { - t.Fatal("pending archive allowed update network access") +func TestUpdateRecordsFailureWithoutRecreatingUninstalledState(t *testing.T) { + p := prepareUpdate(t, "2.0.0", true) + startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1", ChecksumBody: []byte(strings.Repeat("0", 64) + "\n")}) + _, err := update(context.Background(), true) + requireUpdateStage(t, err, "checksum") + receipt, readErr := readUpdateReceipt(p.updateReceipt) + if readErr != nil || receipt.Outcome != "failed" || receipt.Error == "" || !receipt.Automatic || receipt.Version != "2.0.1" || receipt.RestartRequired { + t.Fatalf("failure receipt = %#v, %v", receipt, readErr) } } -func TestUpdateRefusesPreparedUninstallBeforeNetwork(t *testing.T) { - prepareUpdate(t, "2.0.0", true) +func TestUpdateRefusesLegacyAndMissingInstallBeforeNetwork(t *testing.T) { + p := prepareUpdate(t, "2.0.0", true) fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1"}) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.UninstallPending = &uninstallOperation{InitiatorTaskID: "owner"} - return true, nil - }); err != nil { + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":4}`) + _, err := update(context.Background(), false) + requireUpdateStage(t, err, "installation") + if fixture.count("manifest") != 0 { + t.Fatal("legacy state allowed update network access") + } + if err := os.Remove(filepath.Join(stateDir(), "native.json")); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(newStore(stateDir()).subjectDir()); err != nil { t.Fatal(err) } - _, err := update(context.Background()) - requireUpdateStage(t, err, "uninstall_pending") + _, err = update(context.Background(), false) + requireUpdateStage(t, err, "installation") if fixture.count("manifest") != 0 { - t.Fatal("prepared uninstall allowed update network access") + t.Fatal("missing installation allowed update network access") + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("refused update changed binary: %v", err) } } -func TestUpdateRefusesLegacyPendingTitleBeforeNetwork(t *testing.T) { +func TestUpdateUsesOnlyUpdaterAndLifecycleSurfaces(t *testing.T) { prepareUpdate(t, "2.0.0", true) - fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1"}) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Tasks["legacy"] = taskState{Pending: &pendingProposal{Prior: "Old", Proposed: "New"}} - return true, nil - }); err != nil { - t.Fatal(err) + startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.0"}) + matches, _ := filepath.Glob(filepath.Join(codexHome(), "state_*.sqlite")) + for _, match := range matches { + if err := os.Remove(match); err != nil { + t.Fatal(err) + } } - _, err := update(context.Background()) - requireUpdateStage(t, err, "title_pending") - if fixture.count("manifest") != 0 { - t.Fatal("pending title allowed update network access") + if _, err := update(context.Background(), true); err != nil { + t.Fatalf("update read task catalog: %v", err) } } -func TestUpdateWaitsForConcurrentMaintenanceBeforeNetwork(t *testing.T) { +func TestUpdateSerializesConcurrentChecks(t *testing.T) { prepareUpdate(t, "2.1.2", true) fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.1.3"}) - lock, err := newStore(stateDir()).operationLock() + lock, err := lifecycleLock("update.lock") if err != nil { t.Fatal(err) } done := make(chan error, 1) - go func() { _, updateErr := update(context.Background()); done <- updateErr }() + go func() { _, updateErr := update(context.Background(), false); done <- updateErr }() select { case err := <-done: unlock(lock) - t.Fatalf("update returned while maintenance held the operation lock: %v", err) - case <-time.After(100 * time.Millisecond): + t.Fatalf("update bypassed update lock: %v", err) + case <-time.After(50 * time.Millisecond): } if fixture.count("manifest") != 0 { unlock(lock) - t.Fatal("update fetched the manifest while maintenance held the operation lock") + t.Fatal("update fetched manifest while lock was held") } unlock(lock) if err := <-done; err != nil { @@ -142,7 +162,36 @@ func TestUpdateWaitsForConcurrentMaintenanceBeforeNetwork(t *testing.T) { } } -func TestUpdateRejectsManifestAndCandidateFailures(t *testing.T) { +func TestUpdateCheckWaitsForLifecycleOnlyAtReceipt(t *testing.T) { + prepareUpdate(t, "2.1.2", true) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.1.2"}) + lock, err := lifecycleLock("lifecycle.lock") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, updateErr := update(context.Background(), false); done <- updateErr }() + deadline := time.Now().Add(time.Second) + for fixture.count("manifest") == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if fixture.count("manifest") != 1 { + unlock(lock) + t.Fatal("update check waited for lifecycle.lock before network verification") + } + select { + case err := <-done: + unlock(lock) + t.Fatalf("update receipt bypassed lifecycle.lock: %v", err) + default: + } + unlock(lock) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestUpdateRejectsManifestAndCandidateFailuresWithoutReplacement(t *testing.T) { tests := []struct { name, stage string options updateFixtureOptions @@ -155,7 +204,7 @@ func TestUpdateRejectsManifestAndCandidateFailures(t *testing.T) { {name: "checksum mismatch", stage: "checksum", options: updateFixtureOptions{ReleaseVersion: "2.0.1", ChecksumBody: []byte(strings.Repeat("0", 64) + "\n")}}, {name: "embedded version mismatch", stage: "candidate_version", options: updateFixtureOptions{ReleaseVersion: "2.0.1", CandidateVersion: "2.0.2"}}, {name: "candidate self test failure", stage: "candidate_self_test", options: updateFixtureOptions{ReleaseVersion: "2.0.1", SelfTestMode: "fail"}}, - {name: "candidate timeout", stage: "candidate_self_test", options: updateFixtureOptions{ReleaseVersion: "2.0.1", SelfTestMode: "sleep"}, configure: func() { updateCandidateTimeout = 200 * time.Millisecond }}, + {name: "candidate timeout", stage: "candidate_self_test", options: updateFixtureOptions{ReleaseVersion: "2.0.1", SelfTestMode: "sleep"}, configure: func() { updateCandidateTimeout = 100 * time.Millisecond }}, {name: "candidate install failure", stage: "candidate_install", options: updateFixtureOptions{ReleaseVersion: "2.0.1", InstallFailure: true}}, {name: "oversized download", stage: "candidate_download", options: updateFixtureOptions{ReleaseVersion: "2.0.1", AssetData: bytes.Repeat([]byte("x"), 1024)}, configure: func() { updateBinaryLimit = 32 }}, {name: "interrupted download", stage: "candidate_download", options: updateFixtureOptions{ReleaseVersion: "2.0.1", AssetDelay: 100 * time.Millisecond}, configure: func() { updateClient.Timeout = 20 * time.Millisecond }}, @@ -168,11 +217,16 @@ func TestUpdateRejectsManifestAndCandidateFailures(t *testing.T) { if test.configure != nil { test.configure() } - _, err := update(context.Background()) + _, err := update(context.Background(), false) requireUpdateStage(t, err, test.stage) after, _ := os.ReadFile(p.binary) if !bytes.Equal(before, after) { - t.Fatal("pre-install failure changed the installed binary") + t.Fatal("failed update changed installed binary") + } + receipt, receiptErr := readUpdateReceipt(p.updateReceipt) + wantRestart := test.stage == "candidate_install" + if receiptErr != nil || receipt.Outcome != "failed" || receipt.RestartRequired != wantRestart { + t.Fatalf("failed update receipt = %#v, %v; restart_required want %t", receipt, receiptErr, wantRestart) } }) } @@ -180,18 +234,13 @@ func TestUpdateRejectsManifestAndCandidateFailures(t *testing.T) { func TestUpdateCommandReturnsTypedFailureStage(t *testing.T) { prepareUpdate(t, "2.0.0", true) - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.ArchivePending = &archiveOperation{TaskID: "target", Action: "archive"} - return true, nil - }); err != nil { - t.Fatal(err) - } + mustWrite(t, filepath.Join(stateDir(), "native.json"), `{"format":4}`) var output bytes.Buffer if code := run(context.Background(), []string{"update", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 { t.Fatalf("update exit = %d", code) } var result map[string]any - if json.Unmarshal(output.Bytes(), &result) != nil || result["stage"] != "archive_pending" || result["ready"] != false { + if json.Unmarshal(output.Bytes(), &result) != nil || result["stage"] != "installation" || result["ready"] != false { t.Fatalf("typed failure = %s", output.String()) } } @@ -202,13 +251,7 @@ func prepareUpdate(t *testing.T, current string, healthy bool) lifecyclePaths { oldVersion := version version = current t.Cleanup(func() { version = oldVersion }) - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { - value.Phase = phaseMigrationComplete - return true, nil - }); err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { t.Fatal(err) } source := filepath.Join(t.TempDir(), "managed-skill") @@ -241,14 +284,11 @@ func startUpdateFixture(t *testing.T, options updateFixtureOptions) *updateFixtu } fixture := &updateFixture{requests: map[string]int{}} fixture.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - kind := "other" - switch { - case strings.HasSuffix(request.URL.Path, "latest.json"): + kind := "asset" + if strings.HasSuffix(request.URL.Path, "latest.json") { kind = "manifest" - case strings.HasSuffix(request.URL.Path, ".sha256"): + } else if strings.HasSuffix(request.URL.Path, ".sha256") { kind = "checksum" - default: - kind = "asset" } fixture.mu.Lock() fixture.requests[kind]++ @@ -263,13 +303,12 @@ func startUpdateFixture(t *testing.T, options updateFixtureOptions) *updateFixtu if assetURL == "" { assetURL = fixture.base + "/download/v" + options.ReleaseVersion + "/threadbear_darwin_arm64" } - manifest := releaseManifest{Version: options.ReleaseVersion, Assets: map[string]releaseAsset{ + _ = json.NewEncoder(writer).Encode(releaseManifest{Version: options.ReleaseVersion, Assets: map[string]releaseAsset{ options.AssetKey: {URL: assetURL, SHA256URL: fixture.base + "/download/v" + options.ReleaseVersion + "/threadbear_darwin_arm64.sha256"}, - }} - _ = json.NewEncoder(writer).Encode(manifest) + }}) case "checksum": _, _ = writer.Write(options.ChecksumBody) - case "asset": + default: if options.AssetDelay > 0 { time.Sleep(options.AssetDelay) } @@ -305,24 +344,28 @@ func candidateScript(candidateVersion, selfTestMode string, installFailure bool) } else if selfTestMode == "sleep" { selfTest = "sleep 1" } - install := `cp "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET" + install := `automatic=false +no_onboard=false +for argument in "$@"; do + [ "$argument" = "--automatic" ] && automatic=true + [ "$argument" = "--no-onboard" ] && no_onboard=true +done +[ "$automatic" = true ] && [ "$no_onboard" = true ] || { echo missing-automatic-core-only-flags >&2; exit 10; } +cp "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET" printf '{"ready":true,"installed":true}\n'` if installFailure { install = "echo install-failed >&2; exit 8" } - versionResult := fmt.Sprintf(`printf '{"version":"%s"}\n'`, candidateVersion) - unhealthy := fmt.Sprintf(`printf '{"ready":false,"version":"%s"}\n'`, candidateVersion) - healthy := fmt.Sprintf(`printf '{"ready":true,"version":"%s"}\n'`, candidateVersion) return []byte(`#!/bin/sh case "$1" in -version) ` + versionResult + ` ;; +version) printf '{"version":"` + candidateVersion + `"}\n' ;; self-test) ` + selfTest + ` ;; install) ` + install + ` ;; status) - if ! cmp -s "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET"; then - ` + unhealthy + ` + if cmp -s "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET"; then + printf '{"ready":true,"version":"` + candidateVersion + `"}\n' else - ` + healthy + ` + printf '{"ready":false,"version":"` + candidateVersion + `"}\n' fi ;; *) exit 7 ;; esac diff --git a/docs/README.md b/docs/README.md index 7dd41ca..44faba6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,11 @@ # ThreadBear documentation - `README.md` — product and public CLI -- `docs/architecture.md` — two-call runtime and minimal state -- `docs/status-convention.md` — exact footer forms and title mapping -- `docs/compatibility.md` — supported macOS, Codex hooks, index, and native setter -- `docs/live-eval.md` — fresh-task and rendered Desktop release proof -- `docs/experiments/README.md` — canonical title-mechanism registry and experiment preflight +- `docs/architecture.md` — direct title writer, subject ownership, onboarding, and updates +- `docs/status-convention.md` — status enum and icon mapping +- `docs/compatibility.md` — supported macOS and Codex App Server behavior +- `docs/live-eval.md` — focused exact-candidate release proof +- `docs/experiments/README.md` — title-mechanism registry and experiment preflight - `docs/release-checklist.md` — local, release, and hosted checks Files under `docs/plans/` and `docs/archive/` are point-in-time historical evidence. They do not define the current product. diff --git a/docs/architecture.md b/docs/architecture.md index 576e2ea..e5c9f8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,42 +1,58 @@ # Architecture -ThreadBear is one small Go executable, one private atomic JSON file, one managed instruction block, one installed skill, two Codex hook entries, and one consented hourly Codex heartbeat attached to the persistent Luna-medium task. It has no LaunchAgent or detached writer. +ThreadBear is one Go executable, private per-task subject records, one managed instruction block, one installed skill, and one daily update-only LaunchAgent. It has no persistent management task, controller, classifier, archive system, detached writer, queue, or global migration state. ## Ordinary turn -1. Managed guidance makes one bounded native current-task title attempt the turn's first action with `⏳ ThreadBear is working: ` and no explicit task ID. The same model already answering the user supplies the subject; ThreadBear adds no model call. A four-second outer timer bounds the complete call while each installed hook has a one-second process limit. -2. `PreToolUse` reads the current title, explicit name, and first message from the local Codex index. It preserves an explicit name, a generated short title, exact prior ownership, or a later user rename. Only when an unowned title is still the raw or truncated first message does it adopt the reserved subject handoff. A missing or malformed handoff fails closed. -3. `PostToolUse` runs only after native success, accepts only the exact caller, tool-use ID, returned task ID, and rewritten title, then commits the subject and rendering. Until that exact success, `PreToolUse` refuses to replace the one admitted proposal—even when a readback already resembles its proposed title. A same-controller migration resume may commit only an exact applied proposal under the shared title lock; every other unknown outcome remains pending. -4. Immediately before the final response, the task makes the same bounded native attempt with its exact ThreadBear footer. The hooks expand and commit the matching terminal title; the response ends with that footer. +1. The task completes its work and writes the substantive response. Any owner or next action stays in that prose. +2. Immediately before the final response, managed guidance runs exactly one local command: `threadbear title --status --json`. +3. The binary requires the current task ID from Codex's environment, starts one bounded official App Server process, reads the exact current title, resolves the exact safe subject, and renders one status icon plus that subject. +4. If the title already matches, the command returns unchanged. Otherwise it sends at most one `thread/name/set` request and rereads the title. Only exact readback is confirmed. +5. The App Server process exits. Failure, timeout, or uncertain acknowledgement is reported locally; the task does not poll, retry, reconcile, or delay its response. -Each title moment makes exactly one native attempt with an opaque per-attempt suffix that Pre strips before native execution. A timeout leaves the write result unknown, so the turn does not retry, await, or cancel the abandoned promise. A definite returned failure runs one guarded local cancellation: only the exact current caller may clear a proposal carrying that same attempt ID, and only while authoritative readback remains at the recorded prior title. A rejected call whose Pre hook never staged the new ID therefore cannot clear an older unknown write. There is no Stop hook: an interrupted turn keeps its running title until the next ordinary turn naturally replaces it. +The enum controls only the icon. The command carries no owner, action, or rewritten task description. -## Ownership and state +## Subject ownership -The canonical title is ` [ → ]`. ThreadBear owns only a leading status and action suffix from its last exact committed rendering. Any different current title is a user rename and becomes the complete subject, even if it contains an icon or arrow. A first-call seed is ignored after ownership exists. +State is keyed by task ID and stores only the exact subject needed to recognize ThreadBear's renderings. There is no stored status, action, original title, pending proposal, controller phase, global failure, or repair marker. -State is keyed by task ID and contains the persistent main-task ID, the single migration-controller ID, one migration phase, the canonical subject, the last verified rendering, and at most one pending proposal. A pending proposal lets a later call recognize setter success when Post was lost. A prepared uninstall additionally records the exact active initiator, main/controller identities, and the main task's original archive state. While that operation exists, maintenance, update, and ordinary title calls are denied; only cleanup markers from the persisted initiator may stage. An exact-owner abort clears the operation only after the original main archive state is restored. State is private, locked, and atomically replaced. Confirmed install accepts the preceding format only under the title lock, replaces the binary, atomically upgrades state before releasing that lock, and thereby fences already-queued older hook processes from writing. When preceding state lacks the new original-title field, upgrade preserves its durable subject or uses the clean canonical home name if only the install sentinel remains. Ordinary title proposals are never queued for later repair. +For one title command: -## Maintenance and archive ownership +1. If a subject is stored and the current title byte-matches a valid ThreadBear icon plus that subject, reuse it. +2. Otherwise, treat the exact current title as a user rename when it is safe. +3. Reject blank, multiline, control-bearing, raw internal-envelope, ambiguous unowned legacy-prefixed, or overlong text. Rejection leaves that title unchanged. +4. Persist the chosen exact subject and render one icon plus that subject. -The owned `threadbear-maintenance` heartbeat asks the installed skill to run a deterministic maintenance pass. The CLI reads only user tasks from the local index, excludes the main task, controller, automations, and subagents, and accepts only an exact current ThreadBear-owned `complete` title whose latest terminal footer is still complete. Inactivity starts at the later of the target rollout's last write and its last ThreadBear restore; the default threshold is 14 days. +Subjects are never normalized, stripped, or truncated. User-authored leading emoji and arrows survive as subject bytes. ThreadBear owns only its exact rendering. -Archive and restore use one prepare-native-reconcile transaction. The CLI revalidates identity, kind, title, footer, status, activity, and current archive state, then persists one pending operation. Luna calls Codex's supported native archive control once without opening the target. A second deterministic pass observes native archive state and commits or removes ThreadBear ownership. Interruption leaves the pending operation durable; no new mutation or update may begin until it reconciles. Restore is allowed only for the owned archive ledger, so ThreadBear never adopts a user-created archive. +Codex provides no compare-and-swap title primitive. ThreadBear narrows the race with an immediate read/write/readback sequence and no retries. A later safe user rename is adopted on the next turn. If live canaries show practical corruption or response blocking, rewriting is disabled rather than wrapped in reconciliation machinery. -## Verified updates +## App Server boundary + +The official `codex app-server --stdio` process is the only task read/write authority. ThreadBear initializes one short-lived client, correlates JSON-RPC response IDs while tolerating notifications, and closes it after the bounded operation. + +Current-task writing reads the exact native name, sends at most one `thread/name/set`, and performs exact readback. Onboarding first follows every unarchived `thread/list` page, deduplicates task IDs, and returns no plan unless enumeration completes. Native `name` is the user-facing title. A null or blank name is raw and unowned; `preview` is never adopted, persisted, or rendered. + +ThreadBear does not open Codex SQLite, edit Desktop caches, run an App Server daemon, keep a shared client, use a model, or fall back to another title source. -The same heartbeat runs `threadbear update` only after a closing archive pass proves no native operation is pending. The command refuses before network access unless installation migration is complete and archive state is settled. It fetches one bounded official latest manifest, parses an exact version, selects only the running Darwin architecture, and accepts only exact HTTPS asset and checksum URLs under that version's repository release path. Healthy equal or older releases are quiet no-ops; an unhealthy equal version may use the same verified path to repair managed surfaces, while downgrades are never attempted. +## Onboarding -For a newer or repairing release, the command downloads bounded checksum and binary bodies to a private temporary directory, verifies SHA-256 and embedded version, runs the candidate self-test under a deadline, and invokes that candidate's existing confirmed noninteractive install. The candidate then verifies installed status using its own embedded managed assets. A pre-install failure leaves the current installation untouched; a later failure is returned with a typed stage so the persistent task can report it without guessing or blindly retrying. A successful version change is announced once by the maintenance turn; healthy no-op checks remain silent. +`onboard --dry-run --json` is the only read-only onboarding mode. It enumerates the complete catalog before any mutation and reports `total`, `safe`, `needs_update`, and per-item reasons. Enumeration or protocol failure means zero writes. -The visible title is limited to 60 UTF-16 units. Rendering first computes the bounded standalone status-and-subject display, then truncates or omits only the appended action without changing canonical state. +Mutation requires exact explicit consent through `onboard --noninteractive --confirm --json`. The binary starts from a fresh complete snapshot, skips the active caller and unsafe rows, and handles every safe target serially with no cap or waves. Immediately before a possible write it rereads the target and requires its byte-exact snapshot title. Missing, unreadable, drifted, ambiguous, or overlong targets are skipped. Each target receives at most one neutral `🐻 ` write and is counted as updated only after exact readback. An acknowledgement without exact readback is unconfirmed and is never retried. -The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. The persistent home itself is fixed at the visible title `ThreadBear`: running and terminal status inputs still transact through the hook but never decorate or replace that canonical title. During a prepared uninstall, the persisted active initiator receives cleanup authority and every other title call is denied. The initiator serially cleans active tasks, temporarily unarchives the persisted main only when required, cleans it last, restores its original archive state, and verifies both title and archive settlement before deleting the exact automation. A quiescent pre-controller pending install never owned inventory titles, so that uninstall leaves them untouched and cleans only the persistent home. Local teardown is then a rerunnable commit that validates and removes only the managed skill file, leaves any user-created neighbors intact, removes the skill directory only when empty, removes state before the installed executable, and never performs another native mutation. On-demand cleanup outside uninstall remains restricted to the persisted main task. +An interrupted pass may leave valid partial decoration. A rerun takes a fresh complete snapshot and continues without a controller, worker task, pending queue, or hidden resume state. -## Installation and migration +## Installation, reset, and uninstall -The owned heartbeat remains paused until migration is complete, so it cannot race or noisily fail during a long installation. A reinstall already at `migration_complete` may keep the same exact automation active. +Fresh installation writes only the current core artifacts and updater. Codex must restart before open tasks load the new managed guidance. Installation offers onboarding; it never creates a ThreadBear task. + +Version 2.2.1 is a clean reset, not a state migration. The preview exposes the old main-task ID and complete automation fingerprint. After explicit consent, the guide verifies and deletes only that automation, then unpins and verifies the exact former persistent task without renaming it. Any mismatch aborts before filesystem reset. The reset removes only exact obsolete ThreadBear Pre/Post title-interception entries, preserves foreign entries and order, imports no old state, and performs no heuristic title cleanup. + +Uninstall removes ThreadBear-owned files, managed guidance, subject records, and LaunchAgent without waiting for titles to converge. Historical icons may remain. Once removal commits, the task does not run the title command and asks for a Codex restart. + +## Verified updates -Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order, then creates or updates only the exact owned hourly heartbeat through native automation controls. It records `migration_pending` until the marked controller's first host-side current-title hook registers the actual native runtime ID from a native-indexed subagent task whose delegation names the exact persistent home, so an interrupted pre-controller install is never described as running, an ordinary user task cannot forge the controller marker, and a provisional creation handle is never treated as authority. Ordinary guided installation trusts deterministic self-test and inventory evidence; Desktop visual inspection and computer control are debug/release-canary tools, never an end-user gate. The initiating task is recorded as the persistent ThreadBear home and stays selected while it creates and supervises exactly one projectless Terra-medium migration controller to a terminal phase. The controller inventories native-addressable unarchived local Codex Desktop and CLI tasks, excludes rollout-only internal records plus the main/controller IDs, and immediately starts the first deterministic title wave while launching the fixed-surface classifier wave. Older signed-in ChatGPT chat-history rows can also render in the Desktop sidebar, but they are outside the pageable native inventory and explicit-target title mutation contract and remain unchanged. The guide discloses that boundary and never equates local zero-remaining evidence with every visible sidebar row. The controller classifies exact footers deterministically and uses bounded read-only Luna-medium worker waves only for genuinely ambiguous history, with `❔ ThreadBear could not classify` as the exact hook-accepted unknown proposal. It ignores surplus `action` fields only for `complete`, `automation`, and `unknown`, while exact IDs, valid statuses, required nonempty actions on action-bearing statuses, result grammar, and native settlement remain fail-closed. It retains and awaits every successfully spawned worker even when a later spawn reaches collaboration capacity; worker results may complete out of order, while native title calls run in reconciled waves of at most eight distinct targets and deterministic work never waits on ambiguous classification. Each Pre hook briefly serializes its fresh target read and proposal stage, then Post verifies the exact native result; authoritative inventory settles the whole wave before another begins. When an ownerless migration title begins with prior ThreadBear status marks, the controller boundary removes those marks before rendering so reinstall cannot compound decoration. +The daily LaunchAgent runs only `threadbear update`. The updater selects the Darwin architecture from the official release manifest and validates release origin, checksum, embedded version, and candidate self-test before local installation. Network and verification failures happen before writes and leave the old install untouched. Managed surfaces are written individually, with the binary last; a local failure may truthfully report a rerunnable partial. Successful update JSON includes `restart_required`. It never reads tasks or changes titles. -Migration is rerunnable from the registered native runtime controller ID and skips only inventory rows proven `applied: true` from exact committed ownership state. Native writes run only in reconciled waves of at most eight distinct targets. On resume, the shared title lock permits that controller to commit a lost success hook only when the authoritative live title exactly equals its non-noop proposal; a timeout, Promise rejection, interruption, missing result, legacy proposal, foreign proposal, or drift remains fail-closed. The persistent home may attest that a failed wave settled only when complete controller accounting proves every admitted call returned a terminal result; that durable attestation permits only an exact unchanged or disappeared proposal to clear. Reinstall, update, and `migration_complete` refuse every pending title proposal. Only a final zero-remaining inventory with no pending proposal may record completion. The persistent task supervises progress, verifies final inventory, and records the terminal phase without becoming a second title writer. Status repairs an older running-without-controller state to pending and reconciles a missing controller or a terminal lifecycle event from stale `migration_running` to `migration_failed` without using age as a failure signal. A stopped failed migration with no unsettled native operation, or a quiescent pre-controller pending install, can enter the same exact-owner uninstall protocol as a completed migration. Rendered active-header and sidebar verification belongs in opt-in release QA. +`status` computes title-core `ready` from the binary, subject store, managed guidance, and skill. It reports the LaunchAgent separately. Missing automatic updates do not globally fail ordinary title handling. diff --git a/docs/benchmark.md b/docs/benchmark.md index baeb757..29dfb5a 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,9 +1,11 @@ # Benchmark -Run the read-only local inventory with: +Run the complete read-only local onboarding inventory with: ```sh -threadbear inventory --json +threadbear onboard --dry-run --json ``` -Report task count, deterministic count, ambiguous count, and elapsed time. Inventory reads the Codex index and settled rollout tails; it does not write titles, call a model, or create migration work. +Report App Server page count, elapsed time, total deduplicated unarchived tasks, safe candidates, needed updates, and unchanged tasks by reason. Exercise more than 100 tasks so at least two `thread/list` pages are required. Assert that enumeration applies no arbitrary page or item cap, source-label filter, task mutation, model call, or SQLite access. Include null and blank `name` rows with plausible `preview` text and prove both remain raw and unowned. + +Separately benchmark a confirmed serial pass and report updated, unchanged, skipped, and unconfirmed counts. Performance is informative; correctness and complete accounting are acceptance gates. diff --git a/docs/compatibility.md b/docs/compatibility.md index b2c1bcd..ac2ec9d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,11 +1,15 @@ # Compatibility -ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop tasks indexed in the current local `state_N.sqlite`, Codex `PreToolUse` and `PostToolUse` hooks, and the native current-task and explicit-target title setter. The native task-catalog contract is verified against Codex 0.146.0. +ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop's stdio App Server, and the current task ID supplied to terminal commands. Release canaries record the exact Codex version used for proof. -The hook matcher is the plain literal `codex_appset_thread_title`. The anchored-regex form is not supported because Codex 0.146.0 treated it as match-all. Hook installation preserves unrelated definitions and their array order. Codex 0.146.0 dispatches `PostToolUse` only after success. ThreadBear leaves every unverified result pending; after a controller-authenticated, fully settled failed wave, same-controller resume may commit an exact applied proposal or clear only an exact unchanged/disappeared proposal. Each hook process has a one-second limit, and the managed native-call cell has one attempt with a four-second total wait budget. +The terminal writer starts one bounded `codex app-server --stdio` process. It requires an exact current-task match and nonblank native `name`, makes at most one `thread/name/set` request, and requires exact readback to confirm a change. A protocol, ID, process, timeout, unsafe-title, or readback failure stays local and is never retried. -ThreadBear reads the highest local Codex state database and fails closed when the required thread schema, calling session ID, current title, hook payload, or exact native result is unavailable. Inventory mirrors the verified local native catalog: unarchived records with a nonempty preview and source `vscode` or `cli`. Older signed-in ChatGPT chat-history rows may also appear in the Desktop sidebar, but the current native task APIs do not provide pageable enumeration and explicit-target title mutation for that population; ThreadBear neither inventories nor renames them. A release or migration must stop if a read-only inventory-count canary differs from the live local native task catalog. ThreadBear does not run an app-server subprocess or write the Codex database, Desktop caches, or private UI storage. +`onboard --dry-run --json` follows the complete unarchived `thread/list` catalog, tolerates interleaved notifications, and deduplicates IDs. Rows with null or blank `name` remain raw and unowned regardless of `preview`. Any page failure aborts before mutation. `onboard --noninteractive --confirm --json` rereads each candidate and processes the complete safe set serially with no item cap. -Visible titles are at most 60 UTF-16 units and never split a surrogate pair. Native setter success is the runtime acknowledgement. Each release must separately prove the rendered active header and sidebar in a fresh Codex Desktop task. +ThreadBear never opens Codex SQLite or edits Desktop storage. It runs no App Server daemon or proxy, keeps no App Server cache, uses no model, and has no retry or alternate read/write path. -The supported public commands are `install`, `inventory`, `migration`, `maintenance`, `update`, `status`, `self-test`, `uninstall`, and `version`. Guided uninstall uses a prepared active-task owner and one explicit-target native writer; an archived main task is temporarily unarchived and restored through native archive control without opening or navigating to it. +Visible titles are at most 60 UTF-16 units and never split a surrogate pair. A subject that would not fit intact is left unchanged. ThreadBear does not truncate it. App Server acknowledgement is not rendered-product proof, so every release verifies the active header and sidebar before and after restart. + +The supported public commands are `install`, `title`, `onboard`, `status`, `self-test`, `update`, `uninstall`, and `version`. There is no `inventory`, `migration`, `maintenance`, archive, classifier, controller, or persistent ThreadBear-task API. + +The daily update-only LaunchAgent requires ordinary per-user `launchd` support. Its health is reported separately from title-core `ready`. It does not need `sudo`, Full Disk Access, a model call, or a persistent Codex task. Release binaries are checksum-verified but are not Developer ID signed or notarized. diff --git a/docs/live-eval.md b/docs/live-eval.md index ea641a8..e6fc4b5 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -1,13 +1,41 @@ # Live evaluation -Run release QA in genuinely fresh Codex Desktop tasks so hook and managed-guidance snapshots cannot mask installation defects. +Run release QA with the exact reviewed candidate in fresh Codex Desktop tasks after a clean restart. Unit tests, App Server responses, and local state are supporting evidence; verify the rendered active header and sidebar users actually see. -Before this release matrix, cite a supported capability record for every load-bearing title mechanism. If the candidate depends on a new probe, close its capability preflight and record the bidirectionally linked result experiment in `experiments/registry.json`. Record seam-specific evidence during implementation. The matrix is the third gate and applies only to the installed exact candidate after a clean restart. +Record candidate checksum, Codex version, task IDs, inputs, App Server results, rendered results, restart results, and cleanup. Use recoverable test tasks and privacy-safe screenshots only for release QA, never ordinary installation. -Prove complete, all three next-step owners, needs input, blocked, automation, tool-free, continued, stopped, long-subject, duplicate-title, user-rename, and hook-failure turns. Include fresh tasks where Codex has already generated a short title and where the index still contains the exact or truncated first message, plus a delegated envelope. For each ordinary turn, record that the seeded native running call was the first action, the terminal call immediately preceded the final response, the exact footer was final, and each title moment made exactly one native attempt that returned or timed out within four seconds. +## Terminal title writer -Run this development-only matrix after installing the candidate with `threadbear install --debug-canaries`; ordinary guided installation never runs it. Rendered proof is mandatory. Verify the running and terminal titles in both the active header and sidebar before their corresponding boundaries. Use one explicit-target migration canary and prove that only the intended mounted row repaints. Confirm that Stop removes the official spinner, leaves the running title, and creates no additional ThreadBear turn. Capture privacy-safe screenshots. +Exercise `complete`, `next_steps`, `needs_input`, `blocked`, and `automation`. Include one tool-free turn and one tool-using turn. For each, prove: -Exercise installation against 0-, 1-, and 200-task inventories, including projectless tasks, exact historical footers, genuine ambiguity, v2-owned decoration, user-authored icons and arrows, concurrent rename/archive, interruption, and clean rerun. Read-only Luna-medium classifier workers must be used only for genuine ambiguity, in fixed batches of at most 10, and never write titles. Prove bounded classifier waves under partial capacity, a capacity error after successful spawns, out-of-order completion, one eight-minute worker timeout with its one bounded retry, completed-result preservation, surplus-action tolerance only for `complete`, `automation`, and `unknown`, strict required actions for action-bearing statuses, and a final zero-remaining inventory with no retained worker left active or unaccounted for. In parallel, prove native title calls run in reconciled waves of at most eight distinct targets, deterministic work does not wait for ambiguous classification, and each settled wave is followed by authoritative inventory before the next wave. +- there was no running title update; +- the one local `threadbear title --status ENUM --json` command was the last tool action before the final response; +- the enum changed only the icon while the exact subject survived; +- owners and actions remained in response prose; +- the command exited within its bound and was never polled, retried, or recovered; +- App Server acknowledgement and exact readback agreed; +- the active header and sidebar showed the exact expected title. -Exercise archived-control uninstall without visual or computer control. Use a decorated persisted main that begins archived, a different completed archived controller sentinel, and a separate active initiator. Record candidate SHA and binary hash, native title/archive readbacks, automation identity and deletion, config hashes, and final artifact absence. Interrupt a fresh run after temporary main activation, restore the original archive state, then resume from the same initiator and prove no settled native mutation is duplicated. Separately force local teardown failure before state removal, resume with the still-installed candidate, and require one retained-candidate no-op uninstall after completion. +Exercise a generated short title, continued task, user rename, leading user emoji, user arrow, duplicate subject, maximum fitting subject, overlong subject, multiline or control text, and raw delegated envelope. Safe renames must survive byte-for-byte. Unsafe input must leave only that title unchanged without blocking the response. + +Force App Server start, initialize, current-read, set, readback, and exit failures; missing or malformed current task ID; null and blank `name`; acknowledgement without readback; timeout; and a rename concurrent with a delayed write. Require at most one `thread/name/set` call and no blind retry, alternate source, repair command, pending proposal, or global failure. If this seam causes practical corruption or response blocking, disable rewriting rather than add reconciliation. + +Restart Codex after a successful write. Confirm the exact title remains in the sidebar and the next terminal turn still preserves the subject. + +## Onboarding + +For `onboard --dry-run --json`, prove the exact App Server handshake and cursor protocol. Include more than 100 tasks so the catalog is larger than 50 and necessarily multi-page; inject notifications and a duplicate ID. Prove complete deduplication, no arbitrary cap, no model or SQLite access, and zero mutation. Null and blank names remain raw even when `preview` looks safe. Fail a later page and prove zero writes because no partial plan escaped. + +After explicit consent, run exact `onboard --noninteractive --confirm --json`. Prove every safe target is handled serially and every returned item is accounted for as updated, unchanged, skipped, or unconfirmed. Cover the active caller, null and blank names, unreadable and drifted tasks, ambiguous old status prefixes, overlong text, user emoji, already-onboarded titles, setter failure, and acknowledgement without exact readback. Each safe target receives at most one neutral `🐻 ` write after fresh readback. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. + +Live-test the complete real local catalog with no artificial first-50 subset. Verify the rendered sidebar before and after a clean restart. + +## Lifecycle + +Prove fresh install, reinstall, and a consented exact 2.2.1 reset. The preview exposes the legacy main-task ID and complete automation fingerprint. Verify collision and missing-target dry runs mutate nothing. After consent, delete and verify only the exact automation, unpin and verify only the exact former persistent task, and do not rename it. Either native failure aborts before filesystem reset. The completed reset imports no old state, leaves ambiguous legacy titles untouched, installs one daily updater, and requires restart. + +Exercise dry-run preflight against modified managed guidance, skill, LaunchAgent, and filesystem collisions. Exercise install/update and update/uninstall lock races; each loser reports busy without corrupting either lifecycle. + +Exercise manual and scheduled updates against an isolated official-release service. Origin, platform, checksum, version, and self-test failures must happen before writes and preserve the old install. Inject each local managed-surface failure and require `partial:true`, the failed stage, restart implication, and one safe rerun while the prior binary remains active. Successful update JSON includes `restart_required`; the LaunchAgent invokes only update; missing updater health does not change title-core `ready`. + +Uninstall from an ordinary task. Prove preview and commit JSON are complete and exact. Preserve unrelated AGENTS content, skills, settings, files, and LaunchAgents, and remove the binary last. Historical title cleanup is not a gate and icons may remain. After committed removal, do not run the title command. Restart Codex and prove the managed protocol is gone. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 2eba3e7..d14ffcb 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -2,12 +2,16 @@ Before tagging a stable release: -1. Run `python3 scripts/validate-experiments.py`. For every load-bearing title mechanism claimed by the release, cite its `TB-CAP-*` record and supporting `TB-EXP-*` evidence in the implementing issue and pull request. When the release depends on a new probe, also cite its closed `TB-PRE-*` record and bidirectionally linked result experiment. Do not release an unresolved capability or present contradictory evidence as a global conclusion. Review must judge the declared unknown and changed variable; validator success is not semantic approval. +1. Run `python3 scripts/validate-experiments.py`. Cite current capability and seam records for App Server current-title control, pagination, and restart behavior. Validator success is not semantic approval; resolve contradictory evidence. 2. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. -3. Run `gofmt`, `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity checks. Count tracked non-test Go plus shipped bootstrap shell, report the 1,500-line target comparison, and fail the release above the 2,000-line absolute ceiling. -4. In isolated homes, prove install, reinstall, status, inventory, and both uninstall title choices while preserving unrelated AGENTS content and hook definitions in order. Force local teardown to stop before state removal, prove the installed binary remains runnable, resume, and require a retained-candidate second uninstall to be a clean no-op. -5. Exercise 0-, 1-, and 200-task controller migrations. Prove deterministic exact-footer classification, fixed ambiguity-only Luna-medium batches of at most 10, retention of successful workers after a later capacity error, the eight-minute worker deadline and one bounded retry, out-of-order result accounting, surplus-action tolerance only for non-action-consuming statuses, strict required actions for action-bearing statuses, reconciled concurrent native-title waves of at most eight distinct targets, deterministic work proceeding without ambiguous-classification delay, concurrent rename/archive handling, interruption, same-controller resume, clean rerun, and final zero-remaining convergence. -6. Install with `threadbear install --debug-canaries`, then run the exact-candidate release matrix in `docs/live-eval.md` from fresh Codex Desktop tasks. Verify the rendered active header and sidebar, capture privacy-safe screenshots outside the public repository, and restore controlled canary titles through the supported native path. -7. With a distinct completed archived controller retained as a sentinel, archive a decorated persisted main task through native control. From another active task, prepare uninstall, clean active titles and the temporarily unarchived main through the exact installed candidate, restore the main archive state, delete the exact automation, and commit local teardown. Verify all native and filesystem results by supported readback without visual or computer control. Repeat with one interruption after temporary activation and resume from the same initiator without a duplicate title mutation. +3. Run `gofmt`, `go test ./...`, `go test -race ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity. Review the diff for unnecessary machinery; do not substitute a physical line-count gate for that judgment. +4. In isolated homes, prove fresh install, reinstall, dry-run collisions, status, update, uninstall, and the consented exact 2.2.1 reset while preserving unrelated AGENTS content, skills, settings, files, automations, and LaunchAgents. Confirm reset verifies automation deletion and exact-task unpin before filesystem mutation, imports no old state, and never renames the former persistent task. +5. Prove one bounded `threadbear title --status ENUM --json` call is the last tool action before the final response. Require the exact current task ID and native name, at most one `thread/name/set`, and exact readback. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, timeout, unconfirmed acknowledgement, and the rename race. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. +6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` handles every safe target serially with no cap and accounts honestly for updated, unchanged, skipped, and unconfirmed outcomes. +7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Verify active-header and sidebar rendering before and after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. +8. Prove the daily LaunchAgent invokes only the verified updater. Network and verification failures preserve the old install; local write failures report `partial`, stage, restart implication, and one safe rerun with binary last; success reports `restart_required`. Prove updater health is separate from core `ready` and update/uninstall races are serialized. After uninstall commit, do not run the title command. +9. Confirm `assets/skill/SKILL.md` is below 5 KB, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. -After tagging, confirm the release workflow publishes both Darwin architectures, checksums, and the manifest. Then run the hosted smoke test through `threadbear.sh`, including checksum verification, candidate self-test, install, status, inventory, and uninstall. +Before the local Desktop canary, run the exact binary through the isolated lifecycle smoke with `THREADBEAR_SMOKE_CANDIDATE=/absolute/path/to/threadbear scripts/release-smoke.sh vN.N.N`. This mode skips only the not-yet-published download; the tagged release workflow runs the same smoke through the hosted installer and release assets. + +After tagging, verify the Git tag and GitHub Release, both Darwin binaries, checksums, manifest, and hosted bootstrap. Run the hosted smoke through `threadbear.sh`, including candidate self-test, direct writer acknowledgement/readback, multi-page onboarding and failure-before-writes, exact reset preflight, update isolation, and complete uninstall. Confirm hosted `/install` bytes match the reviewed guide before announcing publication. diff --git a/docs/status-convention.md b/docs/status-convention.md index 67bbafc..33080a8 100644 --- a/docs/status-convention.md +++ b/docs/status-convention.md @@ -1,27 +1,27 @@ -# Status footer convention +# Status convention -Every terminal Codex response under ThreadBear guidance ends with exactly one of these forms: +Immediately before an ordinary final response, ThreadBear's managed guidance runs one of: ```text -🧵🐻 complete -🧵🐻 next steps (you): approve the release plan -🧵🐻 next steps (agent): implement the approved plan -🧵🐻 next steps (external): review the security exception -🧵🐻 needs input (you): choose the release region -🧵🐻 blocked (external): restore the signing service -🧵🐻 automation +threadbear title --status complete --json +threadbear title --status next_steps --json +threadbear title --status needs_input --json +threadbear title --status blocked --json +threadbear title --status automation --json ``` -The footer is the final non-empty line, is not quoted or duplicated, and uses a concrete multiword action when an owner is present. `needs input` belongs to the user, `blocked` belongs to an external condition, and `next steps` may belong to the user, agent, or an external actor. +The status maps to one owned icon: -The same exact line is passed to the native current-task title setter immediately before the final response. ThreadBear maps it deterministically: - -| Footer | Visible title | +| Status | Visible title | | --- | --- | -| `complete` | `✅ ` | -| `next steps (…)` | `➡️ ` | -| `needs input (you)` | `🙋 ` | -| `blocked (external)` | `🚨 ` | -| `automation` | `🤖 ` | +| `complete` | `✅ ` | +| `next_steps` | `➡️ ` | +| `needs_input` | `🙋 ` | +| `blocked` | `🚨 ` | +| `automation` | `🤖 ` | + +The enum controls only the icon. Any owner or next action stays in the substantive response. There is no special ThreadBear line appended to the response and no running icon. Ordinary turns never emit the neutral onboarding mark `🐻`. + +ThreadBear reuses its stored subject when the current title byte-matches a valid owned rendering. Any other safe current title is a user rename and becomes the exact subject, including user-authored emoji and arrows. A null or blank native name is raw and stays unchanged; `preview` is never adopted. Multiline, control-bearing, raw internal, ambiguous unowned legacy-prefixed, or overlong subjects also stay unchanged. ThreadBear never normalizes, strips, or truncates a subject. -At turn start, `⏳ ThreadBear is working: ` maps to `⏳ `. The seed is used only when a fresh task still exposes its raw first message; established subjects and user renames win. `❔` is reserved for legacy items that remain unknown during installation; ordinary turns do not emit it. The migration controller requests that state with the exact native title input `❔ ThreadBear could not classify`; there is no compact unknown footer. +Use `complete` when work is finished with no warranted follow-up; `next_steps` only when the response establishes one concrete next action; `needs_input` for required user input; `blocked` for an external blocker; and `automation` for healthy automated work with nothing pending. Generic offers and speculative possibilities do not qualify as next steps. diff --git a/go.mod b/go.mod index 62e1ead..9ffaac6 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,4 @@ module github.com/ericlitman/threadbear go 1.26 -require ( - github.com/BurntSushi/toml v1.5.0 - golang.org/x/sys v0.46.0 - modernc.org/sqlite v1.54.0 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - modernc.org/libc v1.74.1 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect -) +require golang.org/x/sys v0.46.0 diff --git a/go.sum b/go.sum index 848f760..5975a78 100644 --- a/go.sum +++ b/go.sum @@ -1,53 +1,2 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= -modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/install.sh b/install.sh index a25f37b..1e6197a 100755 --- a/install.sh +++ b/install.sh @@ -124,7 +124,6 @@ chmod 700 "$candidate" if ! selftest_output=$("$candidate" self-test --candidate 2>&1); then printf '%s\n' "$selftest_output" >&2 echo "threadbear: the downloaded candidate failed its self-test; nothing was installed." >&2 - echo "threadbear: the check named above is the reason. If it mentions installed_state, a previous install may have left partial state in ~/.local/share/threadbear." >&2 exit 1 fi embedded=$("$candidate" version --json | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index df2c31b..6e2d471 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -1,150 +1,985 @@ #!/bin/sh set -eu +umask 077 + +fail() { + printf 'release smoke: %s\n' "$1" >&2 + exit 1 +} tag=${1:?usage: release-smoke.sh vN.N.N} +if ! printf '%s\n' "$tag" | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { valid=1 } END { exit(valid ? 0 : 1) }'; then + fail "release tag must be vN.N.N" +fi version=${tag#v} -case "$tag" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) echo "release tag must be vN.N.N" >&2; exit 2 ;; -esac + +test "$(uname -s)" = Darwin || fail "this smoke requires macOS" +test -x /bin/launchctl || fail "/bin/launchctl is unavailable" +command -v python3 >/dev/null 2>&1 || fail "python3 is unavailable" +unset PYTHONOPTIMIZE root=$(mktemp -d "${TMPDIR:-/tmp}/threadbear-smoke.XXXXXX") +root=$(cd "$root" && pwd -P) +home=$root/home +codex_home=$home/.codex +binary=$home/.local/bin/threadbear +state_dir=$home/.local/share/threadbear +agent_label=sh.threadbear.update +agent_target="gui/$(id -u)/$agent_label" +agent_path=$home/Library/LaunchAgents/$agent_label.plist +fake_codex=$home/.local/bin/codex +app_server_log=$root/app-server.jsonl +app_server_state=$root/app-server-state.json +current_id=00000000-0000-4000-8000-000000000001 +raw_id=00000000-0000-4000-8000-000000000002 +delegated_id=00000000-0000-4000-8000-000000000003 +blank_id=00000000-0000-4000-8000-000000000004 +drift_id=10000000-0000-4000-8000-000000000001 +failed_id=10000000-0000-4000-8000-000000000002 +unconfirmed_id=10000000-0000-4000-8000-000000000003 + cleanup() { + set +e + if launch_output=$(/bin/launchctl print "$agent_target" 2>/dev/null) && + { printf '%s\n' "$launch_output" | grep -F "$binary" >/dev/null 2>&1 || + { [ -n "${reset_binary:-}" ] && printf '%s\n' "$launch_output" | grep -F "$reset_binary" >/dev/null 2>&1; }; }; then + if ! /bin/launchctl bootout "$agent_target" >/dev/null 2>&1 && + /bin/launchctl print "$agent_target" >/dev/null 2>&1; then + printf 'release smoke: could not unload owned %s; retained %s\n' "$agent_target" "$root" >&2 + return + fi + fi rm -rf "$root" } trap cleanup EXIT HUP INT TERM -home=$root/home -codex_home=$home/.codex -rollout=$codex_home/release-smoke.jsonl -mkdir -p "$codex_home" "$home/.local/bin" -printf '%s\n' \ - '{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"release smoke"}]}}' \ - '{"type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"Release smoke finished.\n\n🧵🐻 complete"}]}}' >"$rollout" -sqlite3 "$codex_home/state_1.sqlite" </dev/null 2>&1; then + fail "$agent_target is already loaded; refusing to disturb it" +fi +case $(date '+%H:%M') in + 11:58|11:59|12:00|12:01|12:02) + fail "refusing to load the daily updater near its 12:00 calendar firing" + ;; +esac -binary=$home/.local/bin/threadbear -test "$("$binary" version --json | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')" = "$version" -HOME="$home" CODEX_HOME="$codex_home" "$binary" self-test --candidate --json -HOME="$home" CODEX_HOME="$codex_home" "$binary" migration \ - --phase migration_running --controller-task-id release-smoke-controller --json -HOME="$home" CODEX_HOME="$codex_home" "$binary" status --json -inventory=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" inventory --json) -printf '%s\n' "$inventory" | grep -F '"count":1' >/dev/null -printf '%s\n' "$inventory" | grep -F '"deterministic":1' >/dev/null -printf '%s\n' "$inventory" | grep -F '"task_id":"release-smoke"' >/dev/null -printf '%s\n' "$inventory" | grep -F '"status":"complete"' >/dev/null - -pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-running","tool_input":{"title":"⏳ ThreadBear is working: Release smoke seeded subject"}}' -prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) -expected_title='⏳ Release smoke seeded subject' -printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null -printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null -sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke';" -post='{"hook_event_name":"PostToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-running","tool_input":{"title":"⏳ Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"⏳ Release smoke seeded subject\"}"}' -printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook - -pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-final","tool_input":{"title":"🧵🐻 complete"}}' -prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) -expected_title='✅ Release smoke seeded subject' -printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null -printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null - -sqlite3 "$codex_home/state_1.sqlite" \ - "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke';" -post='{"hook_event_name":"PostToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-final","tool_input":{"title":"✅ Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"✅ Release smoke seeded subject\"}"}' -printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook - -state=$home/.local/share/threadbear/native.json -grep -F '"release-smoke"' "$state" >/dev/null -grep -E '"subject"[[:space:]]*:[[:space:]]*"Release smoke seeded subject"' "$state" >/dev/null -grep -E '"last"[[:space:]]*:[[:space:]]*"✅ Release smoke seeded subject"' "$state" >/dev/null -if grep -F '"pending"' "$state" >/dev/null; then - echo "release smoke title remained pending" >&2 - exit 1 +mkdir -p "$codex_home/skills/threadbear" "$home/.local/bin" "$home/Library/LaunchAgents" + +agents_before=$root/AGENTS.before.md +hooks_before=$root/hooks.before.json +skill_neighbor_before=$root/skill-neighbor.before +agent_neighbor_before=$root/agent-neighbor.before.plist + +cat >"$codex_home/AGENTS.md" <<'EOF' +# Unrelated local guidance + +Keep this exact user-owned AGENTS content. +EOF +cp "$codex_home/AGENTS.md" "$agents_before" + +cat >"$codex_home/hooks.json" <<'EOF' +{ + "user_setting": "keep", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + {"type": "command", "command": "foreign-pre", "timeout": 7} + ] + } + ], + "PostToolUse": [ + { + "matcher": "foreign-title-tool", + "hooks": [ + {"type": "command", "command": "foreign-post", "timeout": 9} + ] + } + ] + } +} +EOF +cp "$codex_home/hooks.json" "$hooks_before" + +printf '%s\n' 'user-owned skill neighbor' >"$codex_home/skills/threadbear/NOTES.md" +cp "$codex_home/skills/threadbear/NOTES.md" "$skill_neighbor_before" + +cat >"$home/Library/LaunchAgents/com.example.threadbear-smoke-neighbor.plist" <<'EOF' + + + + + Label + com.example.threadbear-smoke-neighbor + ProgramArguments + /usr/bin/true + + +EOF +cp "$home/Library/LaunchAgents/com.example.threadbear-smoke-neighbor.plist" "$agent_neighbor_before" + +cat >"$fake_codex" <<'PY' +#!/usr/bin/env python3 +import json +import os +import sys + +if sys.argv[1:] != ["app-server", "--stdio"]: + raise SystemExit("fixture accepts only: codex app-server --stdio") + +mode = os.environ.get("THREADBEAR_SMOKE_APP_SERVER_MODE", "normal") +log_path = os.environ["THREADBEAR_SMOKE_APP_SERVER_LOG"] +state_path = os.environ["THREADBEAR_SMOKE_APP_SERVER_STATE"] +current_id = "00000000-0000-4000-8000-000000000001" +raw_id = "00000000-0000-4000-8000-000000000002" +delegated_id = "00000000-0000-4000-8000-000000000003" +blank_id = "00000000-0000-4000-8000-000000000004" +drift_id = "10000000-0000-4000-8000-000000000001" +failed_id = "10000000-0000-4000-8000-000000000002" +unconfirmed_id = "10000000-0000-4000-8000-000000000003" + +def initial_threads(): + value = [ + { + "id": f"10000000-0000-4000-8000-{number:012d}", + "name": f"Existing task {number:03d}", + "preview": f"First message {number:03d}", + "source": "cli", + } + for number in range(1, 106) + ] + value.extend([ + { + "id": current_id, + "name": "Release smoke exact subject", + "preview": "" + ("x" * 752) + "", + "source": "cli", + }, + { + "id": raw_id, + "name": None, + "preview": "private release smoke", + "source": "vscode", + }, + { + "id": delegated_id, + "name": "Visible delegated task", + "preview": "Delegated task with a safe visible name", + "source": "subagent", + }, + { + "id": blank_id, + "name": " ", + "preview": "Plausible safe preview that must never become a title", + "source": "cli", + }, + ]) + assert len(value[-4]["preview"]) == 801 + return value + +if os.path.exists(state_path): + threads = json.load(open(state_path, encoding="utf-8"))["threads"] +else: + threads = initial_threads() + with open(state_path, "w", encoding="utf-8") as target: + json.dump({"threads": threads}, target, separators=(",", ":")) + target.write("\n") + +by_id = {thread["id"]: thread for thread in threads} +current_page_count = 0 + +def save(): + with open(state_path, "w", encoding="utf-8") as target: + json.dump({"threads": threads}, target, separators=(",", ":")) + target.write("\n") + +def send(value): + print(json.dumps(value, separators=(",", ":")), flush=True) + +for encoded in sys.stdin: + message = json.loads(encoded) + with open(log_path, "a", encoding="utf-8") as target: + target.write(json.dumps(message, sort_keys=True) + "\n") + method = message.get("method") + request_id = message.get("id") + params = message.get("params", {}) + + if method == "initialize": + send({"method": "fixture/notification", "params": {"stage": "initialize"}}) + send({"id": request_id, "result": {"serverInfo": {"name": "release-smoke"}}}) + elif method == "initialized": + continue + elif method == "thread/list": + if params == { + "archived": False, + "limit": 25, + "sortKey": "recency_at", + "sortDirection": "desc", + }: + current_page_count += 1 + current_page = [ + dict(by_id[blank_id]), + dict(by_id[delegated_id]), + dict(by_id[current_id]), + dict(by_id[raw_id]), + ] + send({"method": "fixture/notification", "params": {"stage": "current-page"}}) + send({"id": request_id, "result": {"data": current_page, "nextCursor": "must-not-follow"}}) + if mode == "current-rename-race" and current_page_count == 1: + by_id[current_id]["name"] = "User rename during the no-CAS window" + save() + with open(log_path, "a", encoding="utf-8") as target: + target.write(json.dumps({"fixture": "external-rename", "name": by_id[current_id]["name"]}) + "\n") + elif params == {"archived": False, "limit": 100}: + send({"method": "fixture/notification", "params": {"stage": "page-1"}}) + send({"id": request_id, "result": {"data": threads[:100], "nextCursor": "page-2"}}) + elif params == {"archived": False, "limit": 100, "cursor": "page-2"}: + if mode == "fail-page-2": + send({"id": request_id, "error": {"code": -32000, "message": "injected page failure"}}) + else: + send({"id": request_id, "result": {"data": threads[100:] + [threads[0]], "nextCursor": None}}) + else: + send({"id": request_id, "error": {"code": -32602, "message": "unexpected list request"}}) + elif method == "thread/read": + thread_id = params.get("threadId") + if params != {"threadId": thread_id, "includeTurns": False} or thread_id not in by_id: + send({"id": request_id, "error": {"code": -32602, "message": "unexpected read request"}}) + else: + thread = dict(by_id[thread_id]) + if mode == "onboarding-edge" and thread_id == drift_id: + thread["name"] = "Renamed while onboarding" + send({"id": request_id, "result": {"thread": thread}}) + elif method == "thread/name/set": + thread_id = params.get("threadId") + name = params.get("name") + if thread_id not in by_id or not isinstance(name, str): + send({"id": request_id, "error": {"code": -32602, "message": "unexpected set request"}}) + elif mode == "onboarding-edge" and thread_id == failed_id: + send({"id": request_id, "error": {"code": -32001, "message": "injected set failure"}}) + elif ( + mode == "current-unconfirmed" and thread_id == current_id + ) or ( + mode == "onboarding-edge" and thread_id == unconfirmed_id + ): + send({"id": request_id, "result": {}}) + else: + by_id[thread_id]["name"] = name + save() + send({"id": request_id, "result": {}}) + else: + send({"id": request_id, "error": {"code": -32601, "message": "unexpected method"}}) +PY +chmod 700 "$fake_codex" + +run_threadbear_with_caller() { + caller=$1 + shift + HOME="$home" \ + CODEX_HOME="$codex_home" \ + CODEX_THREAD_ID="$caller" \ + PATH="$home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ + THREADBEAR_SMOKE_APP_SERVER_LOG="$app_server_log" \ + THREADBEAR_SMOKE_APP_SERVER_STATE="$app_server_state" \ + THREADBEAR_SMOKE_APP_SERVER_MODE="${THREADBEAR_SMOKE_APP_SERVER_MODE:-normal}" \ + "$binary" "$@" +} + +run_threadbear() { + run_threadbear_with_caller "$current_id" "$@" +} + +run_threadbear_without_caller() { + env -u CODEX_THREAD_ID \ + HOME="$home" \ + CODEX_HOME="$codex_home" \ + PATH="$home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ + THREADBEAR_SMOKE_APP_SERVER_LOG="$app_server_log" \ + THREADBEAR_SMOKE_APP_SERVER_STATE="$app_server_state" \ + "$binary" "$@" +} + +candidate_override=${THREADBEAR_SMOKE_CANDIDATE:-} +published_installer=$root/published-install.sh +if [ -n "$candidate_override" ]; then + test -x "$candidate_override" || fail "THREADBEAR_SMOKE_CANDIDATE is not executable" + test "$("$candidate_override" version --json | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')" = "$version" || + fail "candidate version does not match $version" +else + curl -fsSL https://threadbear.sh/install.sh -o "$published_installer" fi -inventory=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" inventory --json) -printf '%s\n' "$inventory" | grep -F '"applied":true' >/dev/null -printf '%s\n' "$inventory" | grep -F '"remaining":0' >/dev/null -HOME="$home" CODEX_HOME="$codex_home" "$binary" migration \ - --phase migration_complete --controller-task-id release-smoke-controller --json -sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET archived=1 WHERE id='release-smoke-controller';" -status=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" status --json) -printf '%s\n' "$status" | grep -F '"ready":true' >/dev/null -printf '%s\n' "$status" | grep -F '"phase":"migration_complete"' >/dev/null -sqlite3 "$codex_home/state_1.sqlite" "INSERT INTO threads VALUES ('release-smoke-owner',1,'Uninstall owner',NULL,0,'cli','','${rollout}','Uninstall owner','Uninstall owner');" - -prepared=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" uninstall \ - --prepare --initiator-task-id release-smoke-owner --json) -printf '%s\n' "$prepared" | grep -F '"prepared":true' >/dev/null -printf '%s\n' "$prepared" | grep -F '"main_task_id":"release-smoke-home"' >/dev/null -printf '%s\n' "$prepared" | grep -F '"main_archived":true' >/dev/null -sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET archived=0 WHERE id='release-smoke-home';" - -pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-cleanup","tool_input":{"threadId":"release-smoke","title":"🧵🐻 strip title icons"}}' -prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) -expected_title='Release smoke seeded subject' -printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null -printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null -sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke';" -post='{"hook_event_name":"PostToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-cleanup","tool_input":{"threadId":"release-smoke","title":"Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"Release smoke seeded subject\"}"}' -printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook -grep -E '"subject"[[:space:]]*:[[:space:]]*"Release smoke seeded subject"' "$state" >/dev/null -grep -E '"last"[[:space:]]*:[[:space:]]*"Release smoke seeded subject"' "$state" >/dev/null - -pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-home-cleanup","tool_input":{"threadId":"release-smoke-home","title":"🧵🐻 strip title icons"}}' -prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) -expected_title='ThreadBear' -printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null -printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null -sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke-home';" -post='{"hook_event_name":"PostToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-home-cleanup","tool_input":{"threadId":"release-smoke-home","title":"ThreadBear"},"tool_response":"{\"threadId\":\"release-smoke-home\",\"title\":\"ThreadBear\"}"}' -printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook -sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET archived=1 WHERE id='release-smoke-home';" -HOME="$home" CODEX_HOME="$codex_home" "$binary" uninstall --initiator-task-id release-smoke-owner --noninteractive --confirm --json -test ! -e "$binary" -test ! -d "$home/.local/share/threadbear" -test "$(sqlite3 "$codex_home/state_1.sqlite" "SELECT title || ':' || archived FROM threads WHERE id='release-smoke-home';")" = 'ThreadBear:1' -test "$(sqlite3 "$codex_home/state_1.sqlite" "SELECT title || ':' || archived FROM threads WHERE id='release-smoke-controller';")" = '⏳ Completed controller sentinel:1' -if [ -e "$codex_home/AGENTS.md" ]; then - if grep -F 'THREADBEAR MANAGED BLOCK' "$codex_home/AGENTS.md"; then - exit 1 - else - status=$? - test "$status" -eq 1 +run_published_installer() { + if [ -n "$candidate_override" ]; then + env HOME="$home" \ + CODEX_HOME="$codex_home" \ + PATH="$home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ + "$candidate_override" install "$@" + return fi + env HOME="$home" \ + CODEX_HOME="$codex_home" \ + PATH="$home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ + sh "$published_installer" "$@" +} + +# Prove the one supported legacy reset removes only exact obsolete interception +# entries. Current-format paths below must leave hooks.json byte-identical. +reset_home=$root/reset-home +reset_codex_home=$reset_home/.codex +reset_state=$reset_home/.local/share/threadbear +reset_binary=$reset_home/.local/bin/threadbear +reset_main_id=20000000-0000-4000-8000-000000000001 +reset_hooks=$reset_codex_home/hooks.json +mkdir -p "$reset_codex_home" "$reset_state" "$reset_home/.local/bin" "$reset_home/Library/LaunchAgents" +printf '{"format":4,"main_task_id":"%s","phase":"migration_complete","tasks":{}}\n' \ + "$reset_main_id" >"$reset_state/native.json" +chmod 700 "$reset_state" +chmod 600 "$reset_state/native.json" +python3 - "$reset_hooks" "$reset_binary" <<'PY' +import json +import sys + +path, binary = sys.argv[1:] +owned = { + "matcher": "codex_appset_thread_title", + "hooks": [{"type": "command", "command": "'" + binary + "' hook", "timeout": 17}], +} +value = { + "user_setting": "keep", + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "foreign-pre"}]}, + owned, + ], + "PostToolUse": [ + owned, + {"matcher": "foreign-title-tool", "hooks": [{"type": "command", "command": "foreign-post"}]}, + ], + }, +} +with open(path, "w", encoding="utf-8") as target: + json.dump(value, target, indent=2) + target.write("\n") +PY + +run_reset_installer() { + if [ -n "$candidate_override" ]; then + env HOME="$reset_home" \ + CODEX_HOME="$reset_codex_home" \ + PATH="$reset_home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ + "$candidate_override" install "$@" + return + fi + env HOME="$reset_home" \ + CODEX_HOME="$reset_codex_home" \ + PATH="$reset_home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ + sh "$published_installer" "$@" +} +run_reset_threadbear() { + HOME="$reset_home" \ + CODEX_HOME="$reset_codex_home" \ + PATH="$reset_home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$reset_binary" "$@" +} + +run_reset_installer --version "$version" --dry-run --json >"$root/reset-preview.json" +python3 - "$root/reset-preview.json" "$version" "$reset_main_id" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +version, main_id = sys.argv[2:] +assert value["ready"] is True and value["dry_run"] is True, value +assert value["version"] == version and value["legacy_reset_required"] is True, value +assert value["legacy_main_task_id"] == main_id, value +assert value["legacy_automation_id"] == "threadbear-maintenance", value +assert value["legacy_automation_target_thread_id"] == main_id, value +assert any("legacy ThreadBear title hooks" in change for change in value["planned_changes"]), value +PY +if run_reset_installer --version "$version" --noninteractive --confirm --json >"$root/reset-refused.json"; then + fail "legacy install crossed the reset gate without --reset" +fi +test -e "$reset_state/native.json" || fail "refused reset deleted legacy state" +test ! -e "$reset_binary" || fail "refused reset wrote the binary" + +# The guide owns the consented automation deletion and exact-task unpin. This +# isolated CLI begins after those native controls report success. +run_reset_installer --version "$version" --reset --noninteractive --confirm --json >"$root/reset-install.json" +python3 - "$root/reset-install.json" "$version" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["installed"] is True, value +assert value["version"] == sys.argv[2] and value["reset"] is True, value +assert value["legacy_reset_required"] is False and value["restart_required"] is True, value +PY +python3 - "$reset_hooks" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["user_setting"] == "keep", value +assert [group["matcher"] for group in value["hooks"]["PreToolUse"]] == ["Bash"], value +assert [group["matcher"] for group in value["hooks"]["PostToolUse"]] == ["foreign-title-tool"], value +PY +test ! -e "$reset_state/native.json" || fail "completed reset retained legacy state" +run_reset_threadbear uninstall --dry-run --json >"$root/reset-uninstall-preview.json" +run_reset_threadbear uninstall --noninteractive --confirm --json >"$root/reset-uninstall.json" +python3 - "$root/reset-uninstall.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["uninstalled"] is True, value +assert value["restart_required"] is True and value["icons_may_remain"] is True, value +PY + +# A foreign LaunchAgent collision must stop before every current-format surface. +printf '%s\n' 'foreign updater collision' >"$agent_path" +cp "$codex_home/hooks.json" "$root/hooks.before-collision.json" +if run_published_installer --version "$version" --noninteractive --confirm --json >"$root/install-collision.json"; then + fail "install accepted a foreign LaunchAgent collision" fi -if [ -e "$codex_home/hooks.json" ]; then - if grep -F "threadbear' hook" "$codex_home/hooks.json"; then - exit 1 - else - status=$? - test "$status" -eq 1 +python3 - "$root/install-collision.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is False and value["installed"] is False, value +assert "LaunchAgent" in value["error"], value +PY +cmp "$codex_home/hooks.json" "$root/hooks.before-collision.json" >/dev/null || + fail "failed current install changed hooks.json" +test ! -e "$binary" || fail "failed install preflight wrote the binary" +rm "$agent_path" + +run_published_installer --version "$version" --dry-run --json >"$root/install-preview.json" +python3 - "$root/install-preview.json" "$version" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["dry_run"] is True, value +assert value["version"] == sys.argv[2] and value["installed"] is False, value +assert value["legacy_reset_required"] is False and value["partial"] is False, value +assert value["onboarding_requested"] is True, value +assert value["next_request"] == "threadbear onboard --dry-run --json", value +assert not any("hook" in change.lower() for change in value["planned_changes"]), value +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "current install preview changed hooks.json" +test ! -e "$binary" || fail "install preview wrote the binary" + +run_published_installer --version "$version" --noninteractive --confirm --json >"$root/install.json" +python3 - "$root/install.json" "$version" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["installed"] is True, value +assert value["version"] == sys.argv[2] and value["dry_run"] is False, value +assert value["legacy_reset_required"] is False and value["partial"] is False, value +assert value["onboarding_requested"] is True, value +assert value["automatic_updates_enabled"] is True and value["restart_required"] is True, value +assert value["next_request"] == "threadbear onboard --dry-run --json", value +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "current install changed hooks.json" + +test -x "$binary" || fail "published installer did not install an executable" +run_threadbear version --json >"$root/version.json" +run_threadbear self-test --candidate --json >"$root/self-test.json" +run_threadbear status --json >"$root/status.json" +python3 - "$root/version.json" "$root/self-test.json" "$root/status.json" "$version" "$binary" "$agent_path" <<'PY' +import json +import sys + +version_value = json.load(open(sys.argv[1], encoding="utf-8")) +self_test = json.load(open(sys.argv[2], encoding="utf-8")) +status_value = json.load(open(sys.argv[3], encoding="utf-8")) +version, binary, agent_path = sys.argv[4:] +assert version_value == {"version": version}, version_value +assert self_test == {"ready": True, "version": version}, self_test +assert status_value["ready"] is True and status_value["installed"] is True, status_value +assert status_value["version"] == version and status_value["automatic_updates_enabled"] is True, status_value +assert status_value["artifacts"] == { + "agents": True, + "binary": True, + "legacy_state_absent": True, + "skill": True, + "subjects": True, +}, status_value +assert status_value["updater"] == { + "label": "sh.threadbear.update", + "path": agent_path, + "exact": True, + "loaded": True, + "program_arguments": [binary, "update", "--automatic", "--json"], +}, status_value +PY + +python3 - "$codex_home/AGENTS.md" <<'PY' +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +assert text.count("title --status STATUS --json") == 1, text +assert "codex_app__set_thread_title" not in text, text +assert "PreToolUse" not in text and "PostToolUse" not in text, text +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "status or verification changed hooks.json" + +/bin/launchctl print "$agent_target" >"$root/launchctl.txt" +grep -F "$binary" "$root/launchctl.txt" >/dev/null || fail "loaded updater does not name the smoke binary" +python3 - "$agent_path" "$binary" "$home" "$codex_home" <<'PY' +import os +import plistlib +import stat +import sys + +path, binary, home, codex_home = sys.argv[1:] +with open(path, "rb") as source: + value = plistlib.load(source) +assert value == { + "Label": "sh.threadbear.update", + "ProgramArguments": [binary, "update", "--automatic", "--json"], + "StartCalendarInterval": {"Hour": 12, "Minute": 0}, + "EnvironmentVariables": {"HOME": home, "CODEX_HOME": codex_home}, + "StandardOutPath": "/dev/null", + "StandardErrorPath": "/dev/null", +}, value +assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 +PY + +/bin/launchctl bootout "$agent_target" +rm "$agent_path" +run_threadbear status --json >"$root/status-without-updater.json" +python3 - "$root/status-without-updater.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["installed"] is True, value +assert value["automatic_updates_enabled"] is False, value +assert value["updater"]["exact"] is False and value["updater"]["loaded"] is False, value +PY +run_threadbear install --no-onboard --noninteractive --confirm --json >"$root/reinstall-updater.json" +python3 - "$root/reinstall-updater.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["installed"] is True, value +assert value["automatic_updates_enabled"] is True and value["restart_required"] is True, value +assert value["onboarding_requested"] is False and "next_request" not in value, value +PY +/bin/launchctl print "$agent_target" >/dev/null 2>&1 || fail "reinstall did not restore the updater" +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "current reinstall changed hooks.json" + +# The terminal writer performs one exact current read, one name set, and exact +# readback. It persists only the safe subject. +: >"$app_server_log" +run_threadbear title --status complete --json >"$root/title-complete.json" +python3 - "$root/title-complete.json" "$current_id" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +task_id = sys.argv[2] +assert value == { + "ready": True, + "task_id": task_id, + "status": "complete", + "previous_title": "Release smoke exact subject", + "desired_title": "✅ Release smoke exact subject", + "title": "✅ Release smoke exact subject", + "updated": True, + "unchanged": False, + "unconfirmed": False, +}, value +PY +python3 - "$app_server_log" "$current_id" <<'PY' +import json +import sys + +messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +current_id = sys.argv[2] +methods = [message["method"] for message in messages] +assert methods == ["initialize", "initialized", "thread/list", "thread/name/set", "thread/list"], messages +lists = [message for message in messages if message["method"] == "thread/list"] +expected = {"archived": False, "limit": 25, "sortKey": "recency_at", "sortDirection": "desc"} +assert [message["params"] for message in lists] == [expected, expected], lists +assert [message["id"] for message in lists] == [2, 4], lists +setter = next(message for message in messages if message["method"] == "thread/name/set") +assert setter["id"] == 3, setter +assert setter["params"] == {"threadId": current_id, "name": "✅ Release smoke exact subject"}, setter +assert all("cursor" not in message["params"] and "searchTerm" not in message["params"] for message in lists) +PY +python3 - "$state_dir/subjects/$current_id.json" <<'PY' +import json +import os +import stat +import sys + +path = sys.argv[1] +assert json.load(open(path, encoding="utf-8")) == {"subject": "Release smoke exact subject"} +assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 +PY + +: >"$app_server_log" +if run_threadbear_without_caller title --status complete --json >"$root/title-no-caller.json"; then + fail "title command accepted a missing CODEX_THREAD_ID" +fi +python3 - "$root/title-no-caller.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is False and "CODEX_THREAD_ID" in value["error"], value +assert value["updated"] is False and value["unconfirmed"] is False, value +PY +test ! -s "$app_server_log" || fail "missing caller started the App Server" + +: >"$app_server_log" +if THREADBEAR_SMOKE_APP_SERVER_MODE=current-unconfirmed \ + run_threadbear title --status next_steps --json >"$root/title-unconfirmed.json"; then + fail "title command accepted acknowledgement without exact readback" +fi +unset THREADBEAR_SMOKE_APP_SERVER_MODE +python3 - "$root/title-unconfirmed.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is False and value["unconfirmed"] is True, value +assert value["previous_title"] == "✅ Release smoke exact subject", value +assert value["desired_title"] == "➡️ Release smoke exact subject", value +assert "not confirmed by exact readback" in value["reason"], value +assert value["updated"] is False and value["unchanged"] is False, value +PY +python3 - "$app_server_log" <<'PY' +import json +import sys + +messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +assert [message["method"] for message in messages].count("thread/name/set") == 1, messages +PY + +run_threadbear title --status automation --json >"$root/title-automation.json" +python3 - "$root/title-automation.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["updated"] is True, value +assert value["title"] == "🤖 Release smoke exact subject", value +PY + +# Codex has no compare-and-set between the immediate read and the one write. +# Force that accepted ordering and prove it remains one bounded write with no +# retry or reconciliation. The ordinary real-Desktop canary is the practical +# kill-switch gate; this fixture records the unavoidable protocol semantics. +: >"$app_server_log" +THREADBEAR_SMOKE_APP_SERVER_MODE=current-rename-race \ + run_threadbear title --status blocked --json >"$root/title-rename-race.json" +unset THREADBEAR_SMOKE_APP_SERVER_MODE +python3 - "$root/title-rename-race.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["updated"] is True, value +assert value["previous_title"] == "🤖 Release smoke exact subject", value +assert value["desired_title"] == "🚨 Release smoke exact subject", value +assert value["title"] == "🚨 Release smoke exact subject", value +assert value["unconfirmed"] is False, value +PY +python3 - "$app_server_log" <<'PY' +import json +import sys + +messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +assert [message for message in messages if message.get("fixture") == "external-rename"] == [ + {"fixture": "external-rename", "name": "User rename during the no-CAS window"} +], messages +assert [message.get("method") for message in messages].count("thread/name/set") == 1, messages +assert [message.get("method") for message in messages].count("thread/list") == 2, messages +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "direct title writer changed hooks.json" + +# Full enumeration must finish before any historical write. +find "$state_dir/subjects" -type f -exec shasum -a 256 {} \; | LC_ALL=C sort >"$root/subjects.before-failed-page" +app_state_before=$(shasum -a 256 "$app_server_state" | awk '{print $1}') +: >"$app_server_log" +if THREADBEAR_SMOKE_APP_SERVER_MODE=fail-page-2 \ + run_threadbear onboard --dry-run --json >"$root/onboard-failed-page.json"; then + fail "onboard accepted an App Server page failure" +fi +unset THREADBEAR_SMOKE_APP_SERVER_MODE +python3 - "$root/onboard-failed-page.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is False and "thread/list page 2" in value["error"], value +assert value["plan_complete"] is False and value["total"] == 0, value +assert value["items"] is None and value["updated"] == 0 and value["unconfirmed"] == 0, value +PY +find "$state_dir/subjects" -type f -exec shasum -a 256 {} \; | LC_ALL=C sort >"$root/subjects.after-failed-page" +cmp "$root/subjects.before-failed-page" "$root/subjects.after-failed-page" >/dev/null || + fail "failed catalog enumeration changed subject state" +test "$(shasum -a 256 "$app_server_state" | awk '{print $1}')" = "$app_state_before" || + fail "failed catalog enumeration changed task state" +python3 - "$app_server_log" <<'PY' +import json +import sys + +messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +lists = [message for message in messages if message.get("method") == "thread/list"] +assert [message["params"] for message in lists] == [ + {"archived": False, "limit": 100}, + {"archived": False, "limit": 100, "cursor": "page-2"}, +], lists +assert not any(message.get("method") == "thread/name/set" for message in messages), messages +PY + +: >"$app_server_log" +run_threadbear onboard --dry-run --json >"$root/onboard-preview.json" +python3 - "$root/onboard-preview.json" "$current_id" "$raw_id" "$blank_id" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +current_id, raw_id, blank_id = sys.argv[2:] +assert value["ready"] is True and value["plan_complete"] is True and value["read_only"] is True, value +assert value["onboarding_complete"] is False, value +assert value["total"] == len(value["items"]) == 109, value +assert value["safe"] == 107 and value["needs_update"] == 106, value +assert value["updated"] == 0 and value["unchanged"] == 1, value +assert value["skipped"] == 2 and value["unconfirmed"] == 0, value +assert [item["task_id"] for item in value["items"]] == sorted(item["task_id"] for item in value["items"]) +by_id = {item["task_id"]: item for item in value["items"]} +assert by_id[current_id]["outcome"] == "unchanged", by_id[current_id] +for task_id in (raw_id, blank_id): + item = by_id[task_id] + assert item["safe"] is False and item["outcome"] == "skipped", item + assert "title" not in item and "subject" not in item and "desired_title" not in item, item +PY +python3 - "$app_server_log" <<'PY' +import json +import sys + +messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages +PY + +# One confirmed pass handles the entire safe set serially. Synthetic drift, +# setter failure, and acknowledgement-without-readback stay local and appear in +# the aggregate receipt. The active caller is never neutralized. +: >"$app_server_log" +if THREADBEAR_SMOKE_APP_SERVER_MODE=onboarding-edge \ + run_threadbear_with_caller "$delegated_id" onboard --noninteractive --confirm --json >"$root/onboard-edge.json"; then + fail "edge onboarding reported complete despite unconfirmed targets" +fi +unset THREADBEAR_SMOKE_APP_SERVER_MODE +python3 - "$root/onboard-edge.json" "$delegated_id" "$drift_id" "$failed_id" "$unconfirmed_id" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +delegated_id, drift_id, failed_id, unconfirmed_id = sys.argv[2:] +assert value["ready"] is False and value["plan_complete"] is True, value +assert value["read_only"] is False and value["onboarding_complete"] is False, value +assert value["total"] == len(value["items"]) == 109 and value["safe"] == 107, value +assert value["needs_update"] == 0, value +assert value["updated"] == 102 and value["unchanged"] == 2, value +assert value["skipped"] == 3 and value["unconfirmed"] == 2, value +assert value["updated"] + value["unchanged"] + value["skipped"] + value["unconfirmed"] == value["total"], value +by_id = {item["task_id"]: item for item in value["items"]} +assert by_id[delegated_id]["outcome"] == "unchanged", by_id[delegated_id] +assert by_id[delegated_id]["reason"] == "active task is handled by the terminal title writer", by_id[delegated_id] +assert by_id[drift_id]["outcome"] == "skipped", by_id[drift_id] +assert by_id[failed_id]["outcome"] == "unconfirmed", by_id[failed_id] +assert by_id[unconfirmed_id]["outcome"] == "unconfirmed", by_id[unconfirmed_id] +PY +python3 - "$app_server_log" "$delegated_id" "$drift_id" <<'PY' +import json +import sys + +messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +delegated_id, drift_id = sys.argv[2:] +sets = [message for message in messages if message.get("method") == "thread/name/set"] +assert len(sets) == 104, len(sets) +ids = [message["params"]["threadId"] for message in sets] +assert len(ids) == len(set(ids)), ids +assert delegated_id not in ids and drift_id not in ids, ids +reads = [message for message in messages if message.get("method") == "thread/read"] +assert all(message["params"]["includeTurns"] is False for message in reads), reads +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "confirmed onboarding changed hooks.json" + +run_threadbear onboard --dry-run --json >"$root/onboard-after-edge.json" +python3 - "$root/onboard-after-edge.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["plan_complete"] is True, value +assert value["total"] == 109 and value["safe"] == 107, value +assert value["needs_update"] == 4 and value["unchanged"] == 103, value +assert value["skipped"] == 2 and value["updated"] == 0 and value["unconfirmed"] == 0, value +PY + +run_threadbear onboard --noninteractive --confirm --json >"$root/onboard-converged.json" +python3 - "$root/onboard-converged.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["plan_complete"] is True, value +assert value["read_only"] is False and value["onboarding_complete"] is True, value +assert value["total"] == 109 and value["safe"] == 107, value +assert value["needs_update"] == 0 and value["updated"] == 4, value +assert value["unchanged"] == 103 and value["skipped"] == 2 and value["unconfirmed"] == 0, value +assert value["updated"] + value["unchanged"] + value["skipped"] == value["total"], value +PY + +# Exercise the real daily updater once, then the direct current-version command. +binary_before_update=$(shasum -a 256 "$binary" | awk '{print $1}') +agent_before_update=$(shasum -a 256 "$agent_path" | awk '{print $1}') +test ! -e "$state_dir/update.json" || fail "install ran the update-only LaunchAgent unexpectedly" +/bin/launchctl kickstart -k "$agent_target" +update_wait=0 +while [ "$update_wait" -lt 30 ]; do + /bin/launchctl print "$agent_target" >"$root/launchctl-after-kickstart.txt" + if grep -F 'last exit code =' "$root/launchctl-after-kickstart.txt" | + grep -vF '(never exited)' >/dev/null; then + if grep -F 'state = not running' "$root/launchctl-after-kickstart.txt" >/dev/null && + grep -F 'last exit code = 0' "$root/launchctl-after-kickstart.txt" >/dev/null; then + break + fi + fail "the real update-only LaunchAgent exited unsuccessfully" fi + sleep 1 + update_wait=$((update_wait + 1)) +done +test "$update_wait" -lt 30 || fail "the real update-only LaunchAgent did not finish" + +python3 - "$state_dir/update.json" "$version" "${candidate_override:+candidate}" <<'PY' +import datetime +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +current = tuple(map(int, sys.argv[2].split("."))) +latest = tuple(map(int, value["version"].split("."))) +assert value["from"] == sys.argv[2], value +assert (latest <= current if sys.argv[3] == "candidate" else latest == current), value +assert value["outcome"] == "current" and value["automatic"] is True, value +assert value["restart_required"] is False, value +datetime.datetime.fromisoformat(value["checked_at"].replace("Z", "+00:00")) +PY +test "$(shasum -a 256 "$binary" | awk '{print $1}')" = "$binary_before_update" || + fail "automatic current update replaced the binary" +test "$(shasum -a 256 "$agent_path" | awk '{print $1}')" = "$agent_before_update" || + fail "automatic current update changed the LaunchAgent" + +run_threadbear update --json >"$root/update.json" +python3 - "$root/update.json" "$version" "${candidate_override:+candidate}" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["current"] is True, value +assert value["version"] == sys.argv[2] and value["automatic"] is False, value +assert value["restart_required"] is False, value +current = tuple(map(int, sys.argv[2].split("."))) +latest = tuple(map(int, value["latest"].split("."))) +assert (latest <= current if sys.argv[3] == "candidate" else latest == current), value +assert set(value) == {"ready", "current", "version", "latest", "automatic", "restart_required"}, value +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "update changed hooks.json" + +run_threadbear uninstall --dry-run --json >"$root/uninstall-preview.json" +python3 - "$root/uninstall-preview.json" "$binary" "$state_dir" "$codex_home" "$agent_path" <<'PY' +import json +import sys + +path, binary, state_dir, codex_home, agent_path = sys.argv[1:] +changes = [ + f"boot out and remove sh.threadbear.update LaunchAgent {agent_path}", + f"remove managed AGENTS block from {codex_home}/AGENTS.md", + f"remove skill {codex_home}/skills/threadbear/SKILL.md", + f"remove owned subject records under {state_dir}/subjects", + f"remove update receipt {state_dir}/update.json", + f"remove binary last {binary}", +] +value = json.load(open(path, encoding="utf-8")) +assert value == { + "ready": True, + "dry_run": True, + "uninstalled": False, + "icons_may_remain": True, + "restart_required": False, + "partial": False, + "warning": "Existing ThreadBear title icons may remain until renamed.", + "planned_changes": changes, +}, value +assert not any("hook" in change.lower() for change in changes), changes +PY +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "uninstall preview changed hooks.json" +test -x "$binary" || fail "uninstall preview removed the binary" +test -e "$agent_path" || fail "uninstall preview removed the LaunchAgent" + +/bin/launchctl kickstart -k "$agent_target" +run_threadbear uninstall --noninteractive --confirm --json >"$root/uninstall.json" +python3 - "$root/uninstall.json" "$root/uninstall-preview.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +preview = json.load(open(sys.argv[2], encoding="utf-8")) +assert value == { + "ready": True, + "dry_run": False, + "uninstalled": True, + "icons_may_remain": True, + "restart_required": True, + "partial": False, + "warning": preview["warning"], + "planned_changes": preview["planned_changes"], +}, value +PY + +test ! -e "$binary" || fail "uninstall left the binary" +test -x "$fake_codex" || fail "uninstall removed the neighboring Codex fixture" +test ! -e "$state_dir" || fail "uninstall left ThreadBear state" +test ! -e "$agent_path" || fail "uninstall left the LaunchAgent" +if /bin/launchctl print "$agent_target" >/dev/null 2>&1; then + fail "uninstall left the updater loaded" +fi +cmp "$codex_home/AGENTS.md" "$agents_before" >/dev/null || fail "uninstall changed unrelated AGENTS content" +cmp "$codex_home/skills/threadbear/NOTES.md" "$skill_neighbor_before" >/dev/null || + fail "uninstall changed neighboring skill content" +test ! -e "$codex_home/skills/threadbear/SKILL.md" || fail "uninstall left the managed skill" +cmp "$home/Library/LaunchAgents/com.example.threadbear-smoke-neighbor.plist" "$agent_neighbor_before" >/dev/null || + fail "uninstall changed a neighboring LaunchAgent" +cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || + fail "current-format uninstall changed hooks.json" + +if [ -n "$candidate_override" ]; then + printf 'ThreadBear %s exact-candidate release smoke passed.\n' "$version" +else + printf 'ThreadBear %s published release smoke passed.\n' "$version" fi diff --git a/site/index.html b/site/index.html index aeaf6cc..1f5252d 100644 --- a/site/index.html +++ b/site/index.html @@ -30,10 +30,10 @@
-
A local task-state bear for macOS
+
A small local title bear for macOS

ThreadBear

-

Keeps Codex Desktop tasks usefully titled with two native calls per ordinary turn.

-

Create a new session in ChatGPT Desktop using Luna on medium effort, and paste this in:

+

Keeps each Codex Desktop task's exact subject and adds one useful status icon at the end of a turn.

+

Create a new task in Codex Desktop and paste:

Install ThreadBear — follow https://threadbear.sh/install

ThreadBear will guide the preview, approval, install, and verification from there.

macOS 12+ · Apple silicon and Intel · no sudo · standalone pure-Go binary

@@ -46,21 +46,22 @@

ThreadBear

Small by design

-
Running, then resolvedEach turn starts with a native running title and ends with the title that matches its exact status footer.
-
Your subject stays yoursThreadBear preserves user renames and owns only the status and action decoration it previously committed.
-
Two deterministic hooksSmall Pre and Post hooks expand and verify native title calls without adding a model call or narration.
-
Foreground and finishedInstallation migrates existing titles once; ordinary work stays inside the task turn.
-
Quiet housekeepingOne hourly Luna helper stays silent on no-op runs and tucks away eligible completed tasks after 14 quiet days.
-
A verified new coatThe same helper runs the update check last and installs only a checksummed, self-tested official release.
+
One terminal updateEach turn runs one bounded local title command immediately before the final response.
+
Your subject stays yoursThreadBear preserves safe user renames byte-for-byte and changes only its one leading icon.
+
One direct writerA short-lived official App Server client reads the exact title, writes at most once, and verifies exact readback.
+
Every native page accounted forOnboarding finishes App Server pagination before serial writes, with no arbitrary first-50 cap.
+
Uncertainty stays localUnsafe or ambiguous titles are left unchanged. One failure never blocks the response or the rest of ThreadBear.
+
A verified new coatOne daily update-only LaunchAgent installs checksummed, self-tested official releases and never reads tasks.
-

Seven visible states

-

⏳ 🚨 🙋 🤖 ➡️ ✅ ❔

-

Each mark is followed by user-owned subject text, and the complete visible title is bounded to 60 UTF-16 units.

+

Five outcomes, plus a welcome bear

+

🚨 🙋 🤖 ➡️ ✅ 🐻

+

Each mark is followed by the exact user-owned subject. Owners and actions stay in response prose, and overlong subjects are left unchanged instead of truncated.

Small, private footprint

-

ThreadBear installs at ~/.local/bin/threadbear, keeps one small private state file under ~/.local/share/threadbear, and adds one managed instruction block, one skill, and two Codex hook entries.

+

ThreadBear installs at ~/.local/bin/threadbear, keeps tiny private per-task subject records under ~/.local/share/threadbear, and adds one managed instruction block, one skill, and one daily update-only LaunchAgent.

Release binaries are not Developer ID signed or notarized. The installer verifies the published SHA-256 checksum and candidate self-test before installation.

Honest boundaries

-

ThreadBear changes titles only through Codex Desktop's native setter and archives only through Codex's native task control. One background, rerunnable controller handles existing titles while the main ThreadBear task stays selected; adaptive waves of read-only Luna-medium workers may classify genuinely ambiguous legacy history, and workers never write titles. The hourly helper follows a deterministic archive plan, never archives active work, and can restore only tasks ThreadBear archived. After archive work is reconciled, it checks the exact official manifest and installs a verified official release only after URL, architecture, SHA-256, embedded-version, and candidate self-test checks pass. ThreadBear installs no LaunchAgent, adds no token counts, and never edits Desktop caches.

+

One short-lived official App Server is the only task read/write authority. The terminal command reads the exact current title, makes at most one name update, and verifies exact readback. Onboarding fully paginates thread/list before any write and handles every safe target serially with no item cap. A null or blank name is raw and unowned; preview is never adopted. There is no SQLite access, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.

+

The updater never reads tasks. Network and candidate-verification failures leave the old install untouched; a later local surface failure is reported as a rerunnable partial with the binary written last. Updater health is reported separately from title-core readiness.

MIT licensed. Built for tidy threads and small bears. 🧵🐻
diff --git a/site/install b/site/install index eaec693..15dd0a9 100644 --- a/site/install +++ b/site/install @@ -1,6 +1,6 @@ # Install ThreadBear with Codex -Open a new Codex Desktop task with Luna at medium effort and paste: +Open a new Codex Desktop task and paste: ```text Install ThreadBear — follow https://threadbear.sh/install @@ -8,23 +8,21 @@ Install ThreadBear — follow https://threadbear.sh/install ## Agent brief -You are the ThreadBear guide in this task. Make installation feel like a short, thoughtful product experience. Keep commands, paths, task IDs, and raw JSON backstage unless they explain a failure or the user asks for them. +You are the ThreadBear guide in this task. Keep the experience short, warm, and concrete. Explain visible outcomes before commands and keep raw JSON, paths, and task IDs backstage unless they explain a failure. Do not use screenshots, computer control, or ask the user to inspect private files during an ordinary install. -Open with this complete orientation before running a command: +Open with this orientation: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps Codex Desktop task titles useful with a running mark while work is underway and a clear outcome when each response finishes. It uses two native title calls per ordinary turn, two small deterministic hooks, and one quiet Luna helper for housekeeping. +> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command reads and updates the title through Codex's official App Server. > -> I'll check this Mac, show you exactly what will change, and answer questions before installing anything. After you consent, I'll install it and update native-addressable local Codex task titles without taking you away from this ThreadBear task. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. +> I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. -Codex collapses commentary after a turn finishes. The welcome may appear there while checks run, but commentary copies do not satisfy this contract. Every terminal final answer in this first turn must be self-contained. If every check and the dry run succeeds, `phase: final_answer` must include the complete orientation above, the readiness sentence, the full recommendation card, and the consent question. Do not end a successful turn with only the consent question. If any check fails, keep the complete orientation and truthful failure visible in `phase: final_answer`; do not claim readiness, show the recommendation card, or ask for consent. +Codex collapses commentary after a turn finishes, so the final answer that asks for consent must repeat the orientation, readiness result, complete recommendation, and question. If a check fails, report it plainly and do not ask for install consent. -Keep the tone warm, calm, capable, and lightly playful. Explain visible outcomes first. Show the complete recommendation before asking for consent. A clear yes to an unchanged complete recommendation is installation consent; ask again only if the effect changed, the answer was ambiguous, or a reinstall changes the recommendation. +## 1. Check without changing anything -## 1. Check this Mac - -Run compatibility checks without mutation: +Run: ```sh sw_vers -productVersion @@ -39,160 +37,143 @@ if [ -x "$HOME/.local/bin/threadbear" ]; then fi ``` -ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop, and HTTPS access to the official guide and GitHub Releases. Do not use `sudo`, grant Full Disk Access, or edit Codex private UI storage. - -Resolve the canonical ID of this calling task with supported Codex task tooling and keep it as `MAIN_TASK_ID`. This initiating task becomes ThreadBear's persistent home. On a reinstall, use the persisted ThreadBear task ID from `status --json`; never adopt whichever task happened to launch the reinstall. +ThreadBear requires macOS 12 or newer, Apple silicon or Intel, Codex Desktop, and HTTPS access to the official guide and GitHub Releases. It needs no `sudo` or Full Disk Access. It never opens Codex SQLite or edits Desktop storage. For an official release, run the verified bootstrap preview: ```sh -curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --control-task-id "$MAIN_TASK_ID" --dry-run --json +curl -fsSL https://threadbear.sh/install.sh | sh -s -- --dry-run --json ``` For an already-built local candidate, run: ```sh -/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --dry-run --json +/path/to/threadbear install --dry-run --json ``` -Require a successful candidate self-test and a dry-run limited to adopting the initiating task, the binary, one small private state file, one managed AGENTS block, one installed skill, and two hook entries. Preserve unrelated AGENTS content and hook definitions in their existing order. +The preview must pass candidate self-test and be limited to the binary, private subject records, managed AGENTS block, installed skill, and one daily update-only LaunchAgent. It must preserve unrelated AGENTS content, skills, settings, files, and LaunchAgents. + +If the preview returns `legacy_reset_required:true`, require `legacy_main_task_id` plus `legacy_automation_id`, `legacy_automation_name`, `legacy_automation_kind`, and `legacy_automation_target_thread_id`. The target must equal the main-task ID. This is a clean 2.2.1 reset, not an in-place migration. Through supported native controls, verify the exact automation and former persistent task before proposing mutation. A collision, missing target, or uncertain owner stops the reset. The reset also removes only exact obsolete ThreadBear Pre/Post title-interception entries and preserves every foreign entry and its order. Import no old state and reinterpret no legacy title. ## 2. Show the recommendation -Only after every check and the dry run succeeds, compose one terminal final answer with no later tool call or commentary. Repeat the complete orientation, say “This Mac and Codex are ready for ThreadBear,” then continue with the full card: +Only after the checks and dry run succeed, present this complete card in the same final answer as the consent question: > ## Recommended setup > -> - Status icon in each native-addressable local Codex task title. -> - Next action added to the thread title → like this. -> - Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed. -> - Small local footprint: one binary in ~/.local/bin, a skill, and two hooks. -> - One persistent thread, ThreadBear, for changing config and uninstalling; its title never receives a status prefix. -> - Deterministic classification and Luna-medium ambiguity checks run in parallel. -> - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. -> - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. -> - ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat. +> - One status icon in each task title, updated once immediately before the final response. +> - Your exact subject stays intact; owners and actions remain in response prose. +> - Unsafe, ambiguous, active, drifted, or overlong titles are left alone. +> - Existing tasks can be previewed completely and onboarded serially, with no item cap. +> - Small local footprint: one binary, tiny subject records, one skill, and one managed instruction block. +> - One daily LaunchAgent installs only verified official updates and never reads tasks or changes titles. +> - No persistent ThreadBear task, controller, classifier, archive automation, queue, or background repair. +> - Codex restarts once after install so open tasks load the new guidance. > > Install ThreadBear with this recommended setup? -The welcome heading, orientation, readiness sentence, every recommendation bullet, and consent question must all be present in `phase: final_answer` when the completed task is read back. Do not send the card only as commentary and do not follow it with a question-only final answer. +For a 2.2.1 reset, add one sentence: the exact old maintenance automation will be deleted, its exact former persistent task will be unpinned but not renamed, managed artifacts will be replaced, old state will not be imported, and ambiguous historical icons may remain. -Answer questions without inventing options or flags. A clear yes to this unchanged recommendation advances directly to installation. +A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. If the user does not want historical onboarding, accept that preference and add `--no-onboard` to the confirmed install. ## 3. Install after consent -For the verified official release, run: +Before a 2.2.1 reset, delete the exact fingerprinted `threadbear-maintenance` automation through supported native control and verify it is absent. Then unpin the preview's exact legacy main-task ID and verify the returned and reread task ID match with `pinned:false`. Do not rename that task. Any mismatch aborts before filesystem reset. The confirmed candidate command must include `--reset`. + +For the official release, run: ```sh curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --control-task-id "$MAIN_TASK_ID" \ --noninteractive --confirm --json ``` -For the verified local candidate, run: +For a local candidate, run: ```sh -/path/to/threadbear install --control-task-id "$MAIN_TASK_ID" --noninteractive --confirm --json +/path/to/threadbear install --noninteractive --confirm --json ``` -Then verify the installed surfaces: +Add `--no-onboard` only when the user opted out. Add `--reset` only after the exact legacy cleanup is verified. Then run: ```sh ~/.local/bin/threadbear version --json ~/.local/bin/threadbear self-test --json ~/.local/bin/threadbear status --json -~/.local/bin/threadbear inventory --json ``` -The install result must show `installed:true`, the exact `main_task_id`, and `phase:migration_pending` unless a prior migration state is being preserved. Pending means the background controller has not started; it is never described as running. `ready:true` means `migration_complete`, not merely that artifacts were written. Do not claim the hooks work merely because files were written. - -Inventory and migration cover local Codex Desktop and CLI tasks that the native explicit-target title setter can address. They do not enumerate or rename older signed-in ChatGPT chat-history rows that may also appear in the Desktop sidebar. Disclose that boundary before migration and never describe zero local inventory rows as proof that every visible sidebar row changed. - -Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” On creation omit `id`; Codex derives it from the exact name, and the returned `automationId` must equal `threadbear-maintenance`. If it differs, delete only that returned ID, verify it is absent, report the partial install, and stop. Never treat the create request's status as proof: immediately update that exact returned ID with the complete expected fields and `status:"PAUSED"`, then verify the full exact match before creating the controller. Pause and verify an existing exact match before migration too. Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. If creation, pause, or verification fails, delete a just-created automation only after an exact ownership read, verify it is absent, report the partial install, and stop. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. - -If the native automation call fails, say that ThreadBear's local title helper is installed but its housekeeping helper is not, and stop before claiming completion. Do not ask the user to create or repair the automation manually. - -## 4. Migrate without leaving this task - -Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install** and **Migration controller** sections. The installed skill is the canonical operation guide. - -Before migration, tell the user: - -> ThreadBear will stay selected while one background controller updates native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. This usually takes several minutes, and a large or ambiguous local history can take longer. `migration_running` means the controller is actively working; I'll report every 25 applied titles or phase change and won't finish this installation turn until it reaches `migration_complete` or `migration_failed`. - -Before any bulk work, follow **Install** step 5 in the canonical installed skill exactly: make its one exact untagged home-title call, require the returned task ID/title, then pin the initiating task and keep it selected. Do not add a nonce or make a second title call. For an ordinary guided installation, do not use visual inspection, computer control, screenshots, or Codex `/hooks`, and do not ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate; visual hook verification is outside this ordinary installation flow. - -Create exactly one projectless background migration-controller task with `codex_app__create_thread` using `model:"gpt-5.6-terra"`, `thinking:"medium"`, and a prompt containing the controller protocol from the installed ThreadBear skill. Begin its delegated input with the exact sentence `ThreadBear controller registration.` Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. Retain the task ID returned by creation only as a supervision handle; Codex may expose a different native runtime ID. The controller's required first current-task running-title call atomically registers that runtime ID. Before any migration write, require fresh status to show `migration_running` with a nonempty controller ID matching the controller's supported current-task context. If creation or registration fails, leave the truthful `migration_pending` phase, stop the controller before it writes any target title, and report that nothing is running plus the exact retry action. - -The controller is the only migration title writer. It processes explicit targets in bounded concurrent waves of at most eight distinct task IDs and skips only rows already reporting `applied:true`. Immediately before each title wave it concurrently calls `codex_app__read_thread` for every target as a read-only activation gate, requires each exact task ID and inventory-planned title, and starts the setter wave within 15 seconds without navigating or selecting; a failed or mismatched read is reconciled through fresh inventory and never followed by a blind write. From the first inventory snapshot, it starts the first stable deterministic batch of at most 25 and one bounded concurrent spawn wave of fresh read-only Luna-medium workers in stable batches of at most 10 genuinely ambiguous tasks at the same time, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. Worker creation uses the fixed `codex_app__create_thread` surface with `model:"gpt-5.6-luna"` and `thinking:"medium"`, records every settled successful handle, and never lets a failed spawn hide an earlier success. The controller derives every assigned list mechanically from the parsed inventory `task_id` fields, never retypes or synthesizes an ID, and asserts exact membership in the same inventory snapshot before spawning. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; retained classifier IDs are excluded from title waves, and all currently validated workers are archived together in one concurrent native explicit-target call before their batches are applied. A missing, mismatched, or unconfirmed worker archive fails closed. Classifier results may arrive out of order. The classifier's `phase:"final_answer"` item must contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block; separate worker commentary is not part of the result grammar. Because a `wait_threads` snapshot may normalize the one separator newline into a space, the controller locates the array's closing bracket and accepts one or more whitespace characters before the exact footer instead of requiring a physical newline; it still rejects any other prose. The controller ignores the footer only while parsing the array. Any other final-answer prose, malformed content, or mismatched batch is discarded and retried once without writing titles. The controller passes the compact footer grammar—not a subject, visible status icon, or pre-rendered title—so the Pre hook alone expands the authoritative subject. Every footer setter value begins with the full literal `🧵🐻` mark (`title:"🧵🐻 complete"`, for example); bare classification labels such as `complete`, `blocked`, or `next_steps` are never setter values. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Every title wave awaits all native results without a client-created `Promise.race` or other synthetic timeout; the trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so the controller never compares the native return title with the compact input, requires the exact returned task ID, treats an expected hook-expanded title as provisional success, and uses fresh inventory as the authoritative applied result. It reconciles the whole wave through inventory before continuing; every ready stable queue runs in one orchestrated loop with concurrent gates and setters plus fresh inventory after each max-eight wave, without commentary or a model round trip between settled waves. Only an explicit timeout from the native tool is a timeout. A target that explicitly becomes inactive or not found may be dropped only after a fresh authoritative inventory no longer contains its exact ID and every other wave target reconciles as applied; it naturally left the addressable catalog and is not counted as applied. Any other native failure, or an ID that remains present, fails closed. Each worker has an eight-minute deadline, a timed-out or invalid read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also fails, it reports failure and complete accounting to the home. It never runs migration phase commands itself. An explicit timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. - -For classifier results whose status does not consume an action (`complete`, `automation`, and `unknown`), the controller accepts and ignores a surplus `action` field. It still fails closed for wrong, missing, duplicate, or unassigned task IDs; invalid statuses; missing or empty required actions for `blocked`, `needs_input`, or `next_steps`; disallowed prose; and malformed JSON. - -For a failed title wave, the controller leaves pending proposals fail-closed and reports complete accounting to the home, including whether every admitted native call returned a terminal result. It never runs a migration phase command or uses `--settled` itself. +Core `ready` is healthy when the installed binary, private subject state, managed guidance, and skill match the candidate. Report the daily updater separately; missing automatic updates do not make title handling globally unready. Core readiness does not depend on historical title counts. -A bounded `wait_threads` response with `timedOut:true` is normal polling, not a classifier timeout. Continue polling every retained worker until its own eight-minute deadline; only that per-worker deadline can trigger the documented retry or failure path. +No controller, worker, migration phase, persistent task, or hidden onboarding job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. -The controller finishes only when a final inventory reports zero remaining rows and returns that exact result to the home. It remains visible until the home records the terminal phase. +## 4. Restart and onboard -Keep this ThreadBear task selected and supervise the controller with compact task waits. Report only each 25-title milestone or phase change. When the controller returns, first run fresh inventory. Only at zero remaining rows with no unresolved native call does the home record `migration_complete` with the status-reported controller ID; otherwise it records `migration_failed`, adding `--settled` only when complete controller accounting proves every admitted native call returned a terminal result and omitting it for any timeout, unknown result, or interruption. Then run `status --json` and verify the terminal state. Only after the successful transition may the home archive the controller. `migration_pending` always means no controller was recorded; status repairs an older running-without-controller state to pending. `migration_running` always names the active native runtime controller. Status reconciles a missing controller or a terminal lifecycle event from the current attempt from stale `migration_running` to `migration_failed`; it never infers failure from age, slow progress, or a prior attempt's terminal event. If the controller or this turn is interrupted, begin the next turn with status so the durable phase is truthful. +After a successful install say: -Do not send a final installation answer while status still says `migration_pending` or `migration_running`. At `migration_pending`, say that migration has not started and nothing is running, then give the exact start action. At `migration_failed`, say plainly that migration stopped and is not still working, give the applied and remaining counts, name the cause, and give one exact resume action. Keep maintenance paused in every non-complete phase. At `migration_complete`, require zero remaining native-addressable local rows, activate the exact owned heartbeat, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. - -## 5. Close precisely +> ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. +> +> After restart, open any task and say: **ThreadBear onboard** -On complete success, use this shape in natural prose: +When that request arrives, read the installed skill and follow this protocol: -> ## ThreadBear is installed -> -> Everything passed: ThreadBear VERSION is installed, its managed guidance, two hooks, verified updates, and hourly Luna helper are healthy, this task is its persistent home, and the migration controller completed with zero remaining native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows were outside this migration and may remain unchanged in the sidebar. -> -> From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” +1. Run `~/.local/bin/threadbear status --json`, then `~/.local/bin/threadbear onboard --dry-run --json`. +2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any write. If enumeration fails, make zero writes. +3. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. +4. Ask for explicit consent unless unchanged install consent covered this first pass. +5. After consent, run exactly: -Replace `VERSION` with the verified version. Follow the current task's active response guidance; do not append a ThreadBear footer merely because installation wrote future-task guidance. +```sh +~/.local/bin/threadbear onboard --noninteractive --confirm --json +``` -If official-download verification fails before mutation, say that installation stopped, nothing changed, and you are checking the verified download. If a failure occurs after mutation began, name exactly what completed, what stopped, whether anything is still running, and the one safe resume action. +The confirmed command starts from a fresh complete catalog and handles every safe target serially with no item cap, waves, worker tasks, or resume machinery. It rereads each target before a possible write; drift, absence, ambiguity, or unreadability is skipped. It attempts each write once and counts `updated` only after exact readback. An acknowledgement without exact readback is `unconfirmed` and is never retried. -## Help and status +Report `updated`, `unchanged`, `skipped`, and `unconfirmed` honestly and account for the returned target set. Call onboarding complete only when `plan_complete:true` and `onboarding_complete:true`. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan and safely continues. -For later help, lead with a short capability card instead of a command dump. Verify the artifact and migration phase before saying ThreadBear is ready: +## Commands and updater ```sh -~/.local/bin/threadbear status --json ~/.local/bin/threadbear help +~/.local/bin/threadbear status --json +~/.local/bin/threadbear title --status complete --json +~/.local/bin/threadbear onboard --dry-run --json ~/.local/bin/threadbear update --json ``` -The installed binary's help is the authoritative public command list. Run `update --json` only for an explicit check-now request or from the owned maintenance heartbeat after archive work is reconciled. Cross-check the exact `threadbear-maintenance` heartbeat with the native automation control before describing hourly housekeeping as healthy. +The managed guidance runs `title --status --json` exactly once immediately before an ordinary final response. The enum changes only the icon. The binary reads the exact current title, preserves the safe subject, makes at most one App Server name update, and performs exact readback. If the command fails or does not finish in its bounded terminal moment, deliver the response without polling, retrying, or reconciling. -## Uninstall - -You can uninstall from any active native Codex task—even when the ThreadBear home is archived. Do not ask the user to open, select, navigate to, or unarchive the ThreadBear home. +`update` verifies the official manifest, release URLs, architecture, checksum, embedded version, and candidate self-test before replacement. Network or verification failure leaves the old installation untouched. A later managed-surface write can truthfully leave a rerunnable partial; the binary is written last. Every successful update reports `restart_required`. The daily LaunchAgent runs only this command and never reads tasks or changes titles. -Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, resolve this initiating task's exact ID plus the persisted main and distinct controller IDs, inspect the exact owned automation, then ask: - -> Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. If the ThreadBear home is archived, I'll briefly bring it out for cleanup and tuck it back exactly where it was. Your other archived tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. -> -> Should I go ahead? +## Uninstall -After consent, pause the exact owned `threadbear-maintenance` heartbeat; refuse an ID, kind, name, or target mismatch. Prepare the durable operation before changing a title or archive state: +Preview first: ```sh -~/.local/bin/threadbear uninstall --prepare --initiator-task-id INITIATOR_ID --json +~/.local/bin/threadbear uninstall --dry-run --json ``` -If the returned original main state is archived, unarchive that exact task once through native archive control and verify it without opening, selecting, navigating to, or waking the task. The prepared initiating task cleans active titles serially through explicit native target calls, including an active distinct controller, then cleans `main_task_id` last. A quiescent pre-controller `migration_pending` uninstall leaves every inventory task untouched and cleans only the persistent home when it is decorated or its stripped title is the exact install sentinel. Every title mutation gets one four-second attempt, exact authoritative readback, and no blind retry. A clean non-sentinel title on resume is already settled and must not be rewritten. +Explain: + +> Want me to uninstall ThreadBear? I'll remove only its binary, private subject records, managed guidance, installed skill, and daily updater. Existing title icons may remain until those tasks are renamed. Other Codex settings and files stay untouched. When removal finishes, you'll restart Codex. -Restore and verify the main task's exact original archive state before deleting the owned automation. On a recoverable interruption, leave ThreadBear installed; the same initiating task resumes without duplicating settled mutations, and drifted or missing title targets remain user-owned. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, and require `aborted:true`. Once the exact automation is deleted and verified absent, cross the local commit boundary and run: +After consent: ```sh -~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json +~/.local/bin/threadbear uninstall --noninteractive --confirm --json ``` -Commit refuses unless the prepared owner, clean main title, restored archive state, eligible stopped phase, and settled native operations all match. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill file, private state, and binary; partial local teardown is rerunnable and the installed binary is removed last. It preserves unrelated content—including user-created files beside the managed skill—hook order, unrelated automations, and archive states, and removes the skill directory only when empty. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. +Require committed removal and verify unrelated AGENTS content, skills, settings, files, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. + +## Release proof + +Before release, run unit and integration tests, race tests, both Darwin builds, shell checks, experiment validation, installer/guide parity, and the focused fixture smoke. -## Maintainer verification +Release acceptance additionally requires one reviewed candidate live-tested end to end in Codex Desktop: -A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, bounded write-wave overlap, and controller resume, interruption, and failure cases. +- the terminal `title` command changes only the status icon and preserves the exact subject; +- App Server acknowledgement and exact readback agree; +- the rendered sidebar shows the expected title before and after a clean restart; +- a full onboarding preview enumerates every local task, and a consented serial pass updates every safe target with honest counts; +- failure and unconfirmed cases never block the substantive response or trigger retries; +- automatic update and uninstall preserve neighboring user content. -Tests and fixtures alone are not shipping proof. Also execute every lifecycle command printed here against the reviewed release candidate and exercise each changed native lifecycle against a real, recoverable Codex test task through the supported native control. Exercise changed download/update behavior against the official release service in an isolated installation. Record exact candidate SHA, task/release IDs, before/after state, results, and cleanup without visual inspection. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. +If the direct writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. diff --git a/site/install.sh b/site/install.sh index a25f37b..1e6197a 100755 --- a/site/install.sh +++ b/site/install.sh @@ -124,7 +124,6 @@ chmod 700 "$candidate" if ! selftest_output=$("$candidate" self-test --candidate 2>&1); then printf '%s\n' "$selftest_output" >&2 echo "threadbear: the downloaded candidate failed its self-test; nothing was installed." >&2 - echo "threadbear: the check named above is the reason. If it mentions installed_state, a previous install may have left partial state in ~/.local/share/threadbear." >&2 exit 1 fi embedded=$("$candidate" version --json | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') From 132f1f614c901667c71918aeb1319710bb74fc15 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 20:36:59 -0400 Subject: [PATCH 02/14] fix: route ThreadBear writes through mounted Codex [BEAR-116] --- .github/workflows/release-smoke.yml | 2 +- CHANGELOG.md | 4 +- INSTALL.md | 24 +- README.md | 8 +- assets/AGENTS.threadbear.md | 36 +- assets/help.txt | 10 +- assets/skill/SKILL.md | 105 +++-- cmd/threadbear/appserver_list.go | 75 ++-- cmd/threadbear/appserver_list_test.go | 166 ++++---- cmd/threadbear/core_test.go | 257 +++++++----- cmd/threadbear/install_test.go | 34 +- cmd/threadbear/main.go | 7 +- cmd/threadbear/managed_javascript_test.go | 155 ++++++++ cmd/threadbear/scan.go | 149 ++----- cmd/threadbear/site_contract_test.go | 156 ++++++-- cmd/threadbear/state.go | 4 +- docs/README.md | 4 +- docs/architecture.md | 36 +- docs/benchmark.md | 2 +- docs/compatibility.md | 10 +- docs/experiments/registry.json | 72 +++- docs/live-eval.md | 20 +- docs/release-checklist.md | 8 +- docs/status-convention.md | 4 +- scripts/release-smoke.sh | 457 +++++++++++++++------- scripts/validate-experiments.py | 5 +- site/index.html | 10 +- site/install | 24 +- 28 files changed, 1225 insertions(+), 619 deletions(-) create mode 100644 cmd/threadbear/managed_javascript_test.go diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index b8334d5..ff71f29 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -51,7 +51,7 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; direct terminal title read/set/readback with exact subject preservation, local failure, and unconfirmed acknowledgement; a complete multi-page onboarding preview with deduplication and failure-before-writes; an uncapped serial confirmed pass with drift, skipped, updated, unchanged, and unconfirmed accounting; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." + echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; a one-read current-title plan with zero production title writes plus an exact simulated mounted native setter success and one-attempt failure; complete multi-page onboarding preview with deduplication and failure-before-writes; uncapped complete-snapshot subject preparation with zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation, drift skipping, one-attempt writes, exact accounting, and no retry; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." echo "- Not proved: real Codex auth, native automation deletion or exact-task unpin, rendered Desktop repaint, clean-restart persistence, native timeout behavior, a newer-version partial update, or architectures other than this runner. Those seams require the recorded local Desktop and fault-injection canaries." echo "- Deployment timing: a Pages/CDN lag can make the live bootstrap older than the release commit; that red result still requires operator investigation." echo "- A red result marks the published release for operator action; this workflow does not delete, demote, or retry a release." diff --git a/CHANGELOG.md b/CHANGELOG.md index 5489aa0..78e8cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ ### Changed - Reset ThreadBear to one terminal local title command that preserves exact subjects, changes only the status icon, keeps actions in response prose, and leaves unsafe titles unchanged. -- Made one short-lived official Codex App Server the sole task read/write authority: exact current-title read, at most one `thread/name/set`, and exact readback, with no SQLite access, daemon, proxy, cache, fallback, or retry. -- Replaced controller migration with explicit, uncapped, serial onboarding that enumerates the complete catalog before writes, skips null, blank, drifted, or unsafe names, never adopts preview text, and reports honest updated, unchanged, skipped, and unconfirmed counts. +- Made one short-lived official Codex App Server the read/planning authority and the mounted app-native setter the sole title writer: one prepared current-task title, at most one native call, and exact returned ID/title validation, with no SQLite access, detached write, fallback, or retry. +- Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each current app-native title before its one possible write, and reports honest updated, skipped, unchanged, and unconfirmed counts. - Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. - Separated title-core readiness from updater health and made successful updates report restart requirements; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. - Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. diff --git a/INSTALL.md b/INSTALL.md index 15dd0a9..71145f4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -14,7 +14,7 @@ Open with this orientation: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command reads and updates the title through Codex's official App Server. +> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command safely prepares the title, then the mounted Codex app applies it once through the native title setter. > > I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. @@ -61,7 +61,7 @@ Only after the checks and dry run succeed, present this complete card in the sam > ## Recommended setup > -> - One status icon in each task title, updated once immediately before the final response. +> - One status icon in each task title, prepared locally and updated once by the mounted Codex app immediately before the final response. > - Your exact subject stays intact; owners and actions remain in response prose. > - Unsafe, ambiguous, active, drifted, or overlong titles are left alone. > - Existing tasks can be previewed completely and onboarded serially, with no item cap. @@ -116,18 +116,18 @@ After a successful install say: When that request arrives, read the installed skill and follow this protocol: 1. Run `~/.local/bin/threadbear status --json`, then `~/.local/bin/threadbear onboard --dry-run --json`. -2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any write. If enumeration fails, make zero writes. +2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any preparation or title write. If enumeration fails, make zero changes. 3. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. 4. Ask for explicit consent unless unchanged install consent covered this first pass. -5. After consent, run exactly: +5. After consent, follow the installed skill's single onboarding JavaScript cell. Its first action runs exactly: ```sh ~/.local/bin/threadbear onboard --noninteractive --confirm --json ``` -The confirmed command starts from a fresh complete catalog and handles every safe target serially with no item cap, waves, worker tasks, or resume machinery. It rereads each target before a possible write; drift, absence, ambiguity, or unreadability is skipped. It attempts each write once and counts `updated` only after exact readback. An acknowledgement without exact readback is `unconfirmed` and is never retried. +The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, or changed title is skipped. Only an exact snapshot match may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. -Report `updated`, `unchanged`, `skipped`, and `unconfirmed` honestly and account for the returned target set. Call onboarding complete only when `plan_complete:true` and `onboarding_complete:true`. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan and safely continues. +Report `updated`, `skipped`, `unchanged`, and `unconfirmed`: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. ## Commands and updater @@ -139,7 +139,7 @@ Report `updated`, `unchanged`, `skipped`, and `unconfirmed` honestly and account ~/.local/bin/threadbear update --json ``` -The managed guidance runs `title --status --json` exactly once immediately before an ordinary final response. The enum changes only the icon. The binary reads the exact current title, preserves the safe subject, makes at most one App Server name update, and performs exact readback. If the command fails or does not finish in its bounded terminal moment, deliver the response without polling, retrying, or reconciling. +The managed guidance runs one injection-safe terminal JavaScript cell immediately before an ordinary final response. Replace only the status enum; the parsed `plan.desired_title` variable passes directly to the native tool and is never re-embedded by the model. The cell runs `title --status --json` exactly once. The binary reads the exact current title through the App Server, preserves the safe subject, and returns a plan without writing a title. When `write_required` is true, the cell calls `tools.codex_app__set_thread_title({title:plan.desired_title})` exactly once with `threadId` omitted, and accepts only the exact returned planned task ID and title. If the outer cell yields after 30 seconds, wait only for that same cell; the yield does not cancel a slow native call, which may delay the final response. Never retry, start another cell, poll the title, or reconcile. `update` verifies the official manifest, release URLs, architecture, checksum, embedded version, and candidate self-test before replacement. Network or verification failure leaves the old installation untouched. A later managed-surface write can truthfully leave a rerunnable partial; the binary is written last. Every successful update reports `restart_required`. The daily LaunchAgent runs only this command and never reads tasks or changes titles. @@ -169,11 +169,11 @@ Before release, run unit and integration tests, race tests, both Darwin builds, Release acceptance additionally requires one reviewed candidate live-tested end to end in Codex Desktop: -- the terminal `title` command changes only the status icon and preserves the exact subject; -- App Server acknowledgement and exact readback agree; +- the terminal planner changes no Codex title, preserves the exact subject, and prepares only the status icon change; +- the mounted app-native setter receives no explicit current-task ID and returns the exact planned task ID and title; - the rendered sidebar shows the expected title before and after a clean restart; -- a full onboarding preview enumerates every local task, and a consented serial pass updates every safe target with honest counts; -- failure and unconfirmed cases never block the substantive response or trigger retries; +- a full onboarding preview enumerates every local task, confirmed preparation writes no title, and the consented serial app-native pass accounts for every prepared target while skipping title drift before any write; +- failures and unconfirmed results are reported locally without retries or global failure state; - automatic update and uninstall preserve neighboring user content. -If the direct writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. +If the mounted app-native writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. diff --git a/README.md b/README.md index dca792a..59ad841 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ThreadBear -ThreadBear is a small local title decorator for Codex Desktop. Immediately before each ordinary final response, managed guidance runs one local command. ThreadBear keeps the exact user-owned subject and changes only the leading status icon. +ThreadBear is a small local title decorator for Codex Desktop. Immediately before each ordinary final response, managed guidance runs one terminal cell: a local planner prepares the safe title and the mounted Codex app applies it once. ThreadBear keeps the exact user-owned subject and changes only the leading status icon. | Mark | Meaning | | --- | --- | @@ -35,13 +35,13 @@ threadbear version Every command accepts `--json`; the installed binary's `help` output is authoritative. -The terminal `title` command accepts exactly `complete`, `next_steps`, `needs_input`, `blocked`, or `automation`. The enum controls only the icon. The binary opens one short-lived official Codex App Server, reads the exact current title, resolves the safe subject, makes at most one `thread/name/set` request, and rereads the title. A failure or unconfirmed result stays local to that turn and never blocks the response or triggers a retry. +The terminal `title` command accepts exactly `complete`, `next_steps`, `needs_input`, `blocked`, or `automation`. The enum controls only the icon. The binary opens one short-lived official Codex App Server, reads the exact current title, resolves and records the safe subject, and returns one prepared title. When a change is needed, the mounted app's native title setter applies it once to the calling task and must return the exact task ID and title. A returned failure stays local and is never retried. If the terminal cell yields, the agent waits for that same cell; a slow native call may delay the final response. -`onboard --dry-run --json` enumerates the complete unarchived App Server catalog before any write and reports a full plan. Explicit consent runs `onboard --noninteractive --confirm --json`, which processes every safe target serially with no arbitrary item cap. It rereads each target, skips drift or uncertainty, attempts one write, and counts it only after exact readback. The receipt accounts honestly for updated, unchanged, skipped, and unconfirmed tasks. A fresh rerun safely continues after an interruption. +`onboard --dry-run --json` enumerates the complete unarchived App Server catalog and reports a read-only plan. After explicit consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, records safe subjects, and returns prepared actions with no arbitrary item cap. Managed guidance resumes that same preparation process if it yields. For each prepared action it serially asks the mounted app for the current title, skips drift, and makes at most one native write. The receipt accounts honestly for updated, skipped, unchanged, and unconfirmed tasks. A fresh rerun safely continues after an interruption. ## Boundaries -The short-lived official App Server is ThreadBear's only task read/write authority. ThreadBear does not open or edit Codex SQLite, Desktop caches, or task prose. It does not archive tasks, classify in the background, retry title writes, or maintain a queue, controller, repair pass, or persistent management task. +The short-lived official App Server is ThreadBear's read and planning authority only. The mounted Codex app's native title setter is the sole title writer. ThreadBear does not open or edit Codex SQLite, Desktop caches, or task prose. It does not archive tasks, classify in the background, retry title writes, or maintain a queue, controller, repair pass, or persistent management task. The daily LaunchAgent does one job: check for a verified official update. Network and candidate-verification failures leave the old install untouched. A later managed-surface write can produce a truthful rerunnable partial, with the binary written last. Successful updates report whether Codex must restart. Updater health is separate from title-core `ready`; it never reads tasks or changes titles. diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index c45eb00..05e493f 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -4,18 +4,44 @@ For every ordinary interactive turn in a main Codex Desktop task: 1. Write the substantive response first. Keep any owner or next action in that prose. 2. Choose exactly one status: `complete`, `next_steps`, `needs_input`, `blocked`, or `automation`. -3. Immediately before the final response, run one local title command. Replace only `STATUS` in this exact cell with the exact enum: +3. Immediately before the final response, run this one terminal cell. Replace only `STATUS` with the exact enum: ```js -const result = await tools.exec_command({ +// @exec: {"yield_time_ms": 30000, "max_output_tokens": 1000} +const local = await tools.exec_command({ cmd:"\"$HOME/.local/bin/threadbear\" title --status STATUS --json", - yield_time_ms:4000, + yield_time_ms:30000, max_output_tokens:1000 }); -text(result); +if (local.exit_code !== 0) { text(local); exit(); } +let plan; +try { plan = JSON.parse(local.output); } catch { + text(JSON.stringify({ready:false, reason:"ThreadBear title planner returned malformed JSON"})); + exit(); +} +if (!plan || plan.ready !== true || typeof plan.write_required !== "boolean" || + typeof plan.task_id !== "string" || + (plan.write_required && typeof plan.desired_title !== "string")) { + text(JSON.stringify({ready:false, reason:"ThreadBear title planner returned an invalid plan"})); + exit(); +} +if (!plan.write_required) { text(local); exit(); } +let renamed; +try { + renamed = await tools.codex_app__set_thread_title({title:plan.desired_title}); +} catch (error) { + text(JSON.stringify({ready:false, reason:"Codex title write failed", error:String(error)})); + exit(); +} +if (!renamed || typeof renamed !== "object" || renamed.threadId !== plan.task_id || + renamed.title !== plan.desired_title) { + text(JSON.stringify({ready:false, reason:"Codex title write was not confirmed exactly"})); + exit(); +} +text(JSON.stringify({ready:true, task_id:plan.task_id, title:renamed.title, updated:true})); ``` -Make exactly one attempt at that terminal moment. Never run it as a progress update. If it fails or has not returned after the bounded yield, do not poll, retry, reconcile, or delay the response. The failure is local to this turn. +The local command only prepares a safe title. When a write is needed, the mounted Codex app is the sole writer and receives no explicit task ID, so it can target only the calling task. Make at most one native write attempt. Never run the cell as a progress update. If the outer cell yields, wait only for that same cell; the yield does not cancel a slow native call. Never start another cell, poll the title, retry, or reconcile. A returned failure is local to this turn. The status controls only the visible icon. ThreadBear preserves the task's exact safe subject and user-authored emoji. It never puts an owner or action in the title. Use: diff --git a/assets/help.txt b/assets/help.txt index 50e8295..c9475cf 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -5,8 +5,8 @@ Usage: Commands: install Preview or install ThreadBear - title Set this task's status icon while preserving its exact subject - onboard Preview or apply existing-task onboarding + title Prepare this task's safe status title for Codex to apply + onboard Preview or prepare existing-task onboarding status Check title-core readiness and updater health separately self-test Validate a release candidate update Install the latest verified release and report restart need @@ -19,14 +19,14 @@ Title: threadbear title --status --json The managed guidance runs title exactly once immediately before a final response. -ThreadBear reads the exact current title, writes one icon plus the safe subject, and verifies the result through Codex App Server. -A local failure never blocks the response and is never retried. +ThreadBear reads the exact current title and prepares one icon plus the safe subject. +The mounted Codex app applies and confirms the title once. A returned failure stays local and is never retried; a yielded terminal cell is awaited as the same call. Onboard: threadbear onboard --dry-run --json threadbear onboard --noninteractive --confirm --json -The preview enumerates the complete local catalog before any write. The confirmed pass handles every safe target serially with no item cap, skips drift or uncertainty, and reports honest updated, unchanged, skipped, and unconfirmed counts. +The preview enumerates the complete local catalog before any write. The confirmed command takes a fresh complete snapshot and prepares every safe target with no item cap. Managed guidance immediately rereads each prepared task, skips drift, applies at most once, and reports updated, skipped, unchanged, and unconfirmed counts. Install flags: --dry-run Preview without changing the machine diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 5f8295b..c1369df 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,52 +1,105 @@ --- name: threadbear -description: Install, inspect, onboard, update, or uninstall the local ThreadBear title decorator for Codex Desktop on macOS. +description: Operate the local ThreadBear title decorator for Codex Desktop on macOS. --- # ThreadBear -Be brief, warm, and lightly bear-themed. Explain effects before commands and get explicit consent before install, reset, historical title mutation, manual update, or uninstall. +Be brief, warm, and lightly bear-themed. Explain effects first. Get explicit consent before install/reset, historical onboarding, manual update, or uninstall. -ThreadBear's managed guidance runs one local `title` command immediately before an ordinary final response. The status enum changes only the icon; the exact safe subject remains intact and any owner or action stays in response prose. There is no persistent task, running call, controller, classifier, archive system, or repair job. +One terminal cell runs the local planner, then at most one mounted Codex title write. Status changes only the icon; owners/actions stay in prose. There is no persistent task, controller, classifier, queue, or repair job. ## Help and status -Run `~/.local/bin/threadbear status --json` before calling ThreadBear installed or healthy. `threadbear help` is authoritative. - -- “How is ThreadBear?” — run `status --json`. -- “ThreadBear onboard” — follow **Onboard existing tasks**. -- “Check for updates” — run `update --json` after consent. -- “Uninstall ThreadBear” — follow **Uninstall**. - -`ready` covers the title core. Report updater health separately; a missing updater does not make title handling globally unready. Onboarding has its own receipt. +Run `status --json` before calling it healthy. `threadbear help` is authoritative; core and updater health are separate. ## Install or reset -Follow `https://threadbear.sh/install` and the candidate help. Run checks, self-test, and dry run first. Explain the binary, private subject state, managed block, skill, and daily updater. +Follow `https://threadbear.sh/install` and candidate help. Preview first. Explain the binary, subject records, guidance, skill, and updater. -For a 2.2.1 reset, require the preview's legacy main-task ID and complete automation fingerprint. Consent covers deleting only that exact automation, unpinning that exact former ThreadBear task without renaming it, and removing only exact obsolete ThreadBear Pre/Post title-interception entries. Preserve foreign entries and order. Verify both native results before `install --reset`. Any mismatch stops. Import no old state and guess at no legacy title. +For a 2.2.1 reset, require the exact former task and automation. Delete only it, unpin only that task without renaming it, and remove exact old title hooks. Verify both results before `install --reset`; mismatch stops. Import no old state or title guess. -After consent, install and verify `version --json`, `self-test --json`, and `status --json`. Ask for a Codex restart. Unless the user opted out, give this exact request: +After consent, install; verify `version`, `self-test`, and `status` JSON. Ask for one restart, then say: > Open any task after restart and say: **ThreadBear onboard** ## Onboard existing tasks -1. Run `status --json`, then `onboard --dry-run --json`. The preview must enumerate and deduplicate the full unarchived App Server catalog before any write. Enumeration failure means zero writes. -2. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Never adopt preview text. Ask for explicit consent unless unchanged install consent already covered this first pass. -3. After consent, run exactly `~/.local/bin/threadbear onboard --noninteractive --confirm --json`. It processes the complete plan serially with no item cap. Each candidate is reread; missing, unreadable, drifted, or uncertain tasks are skipped. Each write is attempted once and counted only after exact readback. Never retry an unconfirmed result. -4. Report the returned totals: `Updated X of N existing tasks; Y were unchanged or skipped; Z could not be confirmed.` Do not claim completion unless `plan_complete` and `onboarding_complete` are true and every target is accounted for. - -An interruption may leave valid partial decoration. A later **ThreadBear onboard** starts a fresh complete plan and safely continues. Never create a controller, queue, hidden resume state, product cap, or persistent ThreadBear task. +1. Run `status --json`, then `onboard --dry-run --json`. Require `ready:true`, `plan_complete:true`, and `read_only:true`. It must enumerate and deduplicate the complete unarchived App Server catalog; failure means zero writes. +2. Explain `total`, `safe`, and `needs_update`. Active, blank, unsafe, ambiguous, or overlong titles stay unchanged; never adopt `preview`. Ask for explicit consent. +3. After consent, run this exact cell once: + +```js +// @exec: {"yield_time_ms": 30000, "max_output_tokens": 4000} +notify("ThreadBear onboarding: preparing complete catalog"); +let local = await tools.exec_command({ + cmd:"\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json", + yield_time_ms:30000, + max_output_tokens:200000 +}); +let output = local.output || ""; +while (local.session_id !== undefined) { + notify("ThreadBear onboarding: preparing"); + local = await tools.write_stdin({ + session_id:local.session_id, + yield_time_ms:30000, + max_output_tokens:200000 + }); + output += local.output || ""; +} +if (local.exit_code !== 0) { text(local); exit(); } +let plan; +try { plan = JSON.parse(output); } +catch { text(JSON.stringify({ready:false,reason:"Malformed preparation"})); exit(); } +if (!plan || plan.ready !== true || plan.plan_complete !== true || + plan.read_only !== false || !Number.isInteger(plan.total) || !Array.isArray(plan.items)) { + text(JSON.stringify({ready:false,reason:"Incomplete preparation"})); exit(); +} +const prepared = plan.items.filter(item => item.outcome === "prepared"); +if (prepared.some(item => !item || typeof item.task_id !== "string" || + typeof item.title !== "string" || typeof item.desired_title !== "string")) { + text(JSON.stringify({ready:false,reason:"Invalid prepared item"})); exit(); +} +let updated = 0, skipped = 0, unconfirmed = 0; +for (const item of prepared) { + let current; + try { + current = await tools.codex_app__read_thread({threadId:item.task_id, + includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1}); + } catch { current = null; } + if (current?.thread?.title !== item.title) skipped++; + else { + let renamed; + try { + renamed = await tools.codex_app__set_thread_title({threadId:item.task_id, + title:item.desired_title}); + } catch { renamed = null; } + if (renamed && typeof renamed === "object" && renamed.threadId === item.task_id && + renamed.title === item.desired_title) updated++; + else unconfirmed++; + } + const done = updated + skipped + unconfirmed; + if (done % 25 === 0 || done === prepared.length) notify(`ThreadBear onboarding: ${done}/${prepared.length}`); +} +const accounted = updated + skipped + unconfirmed === prepared.length; +text(JSON.stringify({ + ready:accounted && unconfirmed === 0, + plan_complete:true, + onboarding_complete:accounted && unconfirmed === 0, + total:plan.total, + updated, + skipped, + unchanged:plan.total - updated - unconfirmed, + unconfirmed +})); +``` + +If the cell yields, wait only for it; never start another process or title call. Progress appears during preparation and every 25 outcomes. Report: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Ready requires zero `unconfirmed`. Drift is skipped. A later **ThreadBear onboard** makes a fresh plan. Never create a cap, wave, controller, worker task, queue, or persistent ThreadBear task. ## Update -The daily LaunchAgent runs only `threadbear update`. Network and candidate-verification failures leave the old installation untouched. A later local managed-surface write can leave a truthful rerunnable partial; the binary is written last. The updater never reads tasks or changes titles. - -For a requested check, show `~/.local/bin/threadbear update --json`, get consent, run it, and report `restart_required`. If true, say open tasks keep their snapshot until Codex restarts. +The daily LaunchAgent runs only `threadbear update` and never reads tasks. For manual update, get consent, run `update --json`, and report `restart_required`. Failure may return a rerunnable partial; the binary is last. ## Uninstall -Run `status --json` and show the uninstall dry run. Explain the owned artifacts removed and that historical icons may remain. Ask for consent. - -After consent, run the confirmed uninstall. Preserve unrelated AGENTS content, skills, settings, files, and LaunchAgents. Once removal commits, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. +Run status and uninstall dry run. Explain removal and remaining icons; ask consent. Preserve unrelated content. Do not run the title cell again. Ask for restart. diff --git a/cmd/threadbear/appserver_list.go b/cmd/threadbear/appserver_list.go index 51ca12f..ad8eef1 100644 --- a/cmd/threadbear/appserver_list.go +++ b/cmd/threadbear/appserver_list.go @@ -13,7 +13,6 @@ import ( ) const ( - appServerCurrentLimit = 25 appServerCurrentTimeout = 3 * time.Second appServerListLimit = 100 appServerListTimeout = 30 * time.Second @@ -140,23 +139,44 @@ func (client *appServerClient) ioError(operation string, err error) error { } func (client *appServerClient) currentTask(requestID int, id string) (indexedTask, error) { - result, err := client.request(requestID, "thread/list", map[string]any{ - "archived": false, "limit": appServerCurrentLimit, - "sortKey": "recency_at", "sortDirection": "desc", - }, "read Codex App Server current thread/list page") - if err != nil { - return indexedTask{}, err - } - page, _, err := decodeAppServerThreadPage(result) - if err != nil { - return indexedTask{}, fmt.Errorf("read Codex App Server current thread/list page: %w", err) - } - for index := range page { - if page[index].ID != nil && *page[index].ID == id { - return indexedTaskFromAppServer(page[index]) + seenCursors := make(map[string]struct{}) + var cursor *string + for pageNumber := 1; ; pageNumber++ { + params := map[string]any{ + "archived": false, "limit": appServerListLimit, + "sortKey": "recency_at", "sortDirection": "desc", + } + if cursor != nil { + params["cursor"] = *cursor + } + operation := fmt.Sprintf("read Codex App Server current thread/list page %d", pageNumber) + result, err := client.request(requestID, "thread/list", params, operation) + if err != nil { + return indexedTask{}, err + } + requestID++ + page, next, err := decodeAppServerThreadPage(result) + if err != nil { + return indexedTask{}, fmt.Errorf("%s: %w", operation, err) + } + for index := range page { + if page[index].ID != nil && *page[index].ID == id { + return indexedTaskFromAppServer(page[index]) + } } + if next == nil { + break + } + if *next == "" { + return indexedTask{}, fmt.Errorf("%s: empty next cursor", operation) + } + if _, repeated := seenCursors[*next]; repeated { + return indexedTask{}, fmt.Errorf("%s: repeated next cursor", operation) + } + seenCursors[*next] = struct{}{} + cursor = next } - return indexedTask{}, errors.New("read Codex App Server current thread/list page: current task is absent") + return indexedTask{}, errors.New("read Codex App Server current thread/list: current task is absent") } func (client *appServerClient) inventory(nextRequestID *int) ([]indexedTask, error) { @@ -195,29 +215,6 @@ func (client *appServerClient) inventory(nextRequestID *int) ([]indexedTask, err return finishAppServerInventory(all) } -func (client *appServerClient) readTask(requestID int, id string) (indexedTask, error) { - result, err := client.request(requestID, "thread/read", map[string]any{ - "threadId": id, "includeTurns": false, - }, "read Codex App Server task") - if err != nil { - return indexedTask{}, err - } - var response struct { - Thread appServerThread `json:"thread"` - } - if json.Unmarshal(result, &response) != nil || response.Thread.ID == nil || *response.Thread.ID != id { - return indexedTask{}, errors.New("read Codex App Server task: invalid thread/read result") - } - return indexedTaskFromAppServer(response.Thread) -} - -func (client *appServerClient) setName(requestID int, id, name string) error { - _, err := client.request(requestID, "thread/name/set", map[string]any{ - "threadId": id, "name": name, - }, "set Codex App Server task name") - return err -} - func indexedTaskFromAppServer(thread appServerThread) (indexedTask, error) { if thread.ID == nil || *thread.ID == "" { return indexedTask{}, errors.New("Codex App Server returned an invalid task") diff --git a/cmd/threadbear/appserver_list_test.go b/cmd/threadbear/appserver_list_test.go index 4c79fea..9292c71 100644 --- a/cmd/threadbear/appserver_list_test.go +++ b/cmd/threadbear/appserver_list_test.go @@ -39,15 +39,54 @@ func TestAppServerInventoryExhaustsPagesBeforeDedupe(t *testing.T) { } } -func TestAppServerInventoryFailsBeforeStateWrites(t *testing.T) { - _, _ = testIndex(t) - installAppServerFixture(t, "page-failure") - if result, err := runOnboarding(t.Context(), false, ""); err == nil || result.PlanComplete || !strings.Contains(err.Error(), "page 2") { - t.Fatalf("failed inventory = %#v, %v", result, err) +func TestAppServerCurrentLookupPaginatesPastFirstPage(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Stable subject") + installAppServerFixture(t, "current-multipage") + + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err != nil || !result.Ready || result.PreviousTitle != "Stable subject" || + result.DesiredTitle != "✅ Stable subject" || !result.WriteRequired { + t.Fatalf("multipage current plan = %#v, %v", result, err) + } + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/list") != 2 || countFixtureMethod(requests, "thread/read") != 0 || + countFixtureMethod(requests, "thread/name/set") != 0 { + t.Fatalf("multipage current RPCs = %#v", requests) + } + second := fixtureMethod(requests, "thread/list", 1) + if fixtureStringParam(t, second, "cursor") != "current-page-2" || + fixtureStringParam(t, second, "sortKey") != "recency_at" || + fixtureStringParam(t, second, "sortDirection") != "desc" { + t.Fatalf("second current page = %#v", second) } - entries, err := os.ReadDir(newStore(stateDir()).subjectDir()) - if err != nil || len(entries) != 0 { - t.Fatalf("failed inventory wrote state: %#v, %v", entries, err) + if got := index.title(t, testTaskID); got != "Stable subject" { + t.Fatalf("multipage planner mutated title = %q", got) + } +} + +func TestAppServerInventoryFailsBeforeStateWrites(t *testing.T) { + for _, apply := range []bool{false, true} { + t.Run(fmt.Sprintf("confirmed=%t", apply), func(t *testing.T) { + _, _ = testIndex(t) + installAppServerFixture(t, "page-failure") + activeTaskID := "" + if apply { + activeTaskID = testActiveID + } + if result, err := runOnboarding(t.Context(), apply, activeTaskID); err == nil || result.PlanComplete || + !strings.Contains(err.Error(), "page 2") { + t.Fatalf("failed inventory = %#v, %v", result, err) + } + entries, err := os.ReadDir(newStore(stateDir()).subjectDir()) + if err != nil || len(entries) != 0 { + t.Fatalf("failed inventory wrote state: %#v, %v", entries, err) + } + if requests := fixtureRequests(t); countFixtureMethod(requests, "thread/name/set") != 0 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("failed inventory performed target RPCs: %#v", requests) + } + }) } } @@ -55,12 +94,14 @@ func TestAppServerNonzeroExitCannotOverturnCompleteProof(t *testing.T) { _, index := testIndex(t) index.setTitle(t, testTaskID, "Stable subject") installAppServerFixture(t, "close-nonzero") - if result, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err != nil || !result.Ready || result.Title != "✅ Stable subject" { + if result, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err != nil || !result.Ready || + !result.WriteRequired || result.DesiredTitle != "✅ Stable subject" { t.Fatalf("current proof = %#v, %v", result, err) } clearFixtureRequests(t) index.setTitle(t, testAlphaID, "Alpha") - if result, err := runOnboarding(t.Context(), true, testActiveID); err != nil || !result.Ready || !result.OnboardingComplete || result.Updated != 2 { + if result, err := runOnboarding(t.Context(), true, testActiveID); err != nil || !result.Ready || + result.OnboardingComplete || result.Prepared != 2 || result.NeedsUpdate != 2 { t.Fatalf("onboarding proof = %#v, %v", result, err) } } @@ -72,14 +113,11 @@ func TestAppServerCurrentFailuresStartOnlyOnce(t *testing.T) { {"missing", "current-missing", "current task is absent"}, {"protocol", "current-protocol", "current thread/list page"}, {"response ID", "current-response-id", "unexpected Codex App Server response ID"}, + {"repeated cursor", "current-repeated-cursor", "repeated next cursor"}, {"timeout", "current-timeout", "context deadline exceeded"}, - {"unclean", "current-unclean", "set Codex App Server task name"}, } { t.Run(test.name, func(t *testing.T) { - _, index := testIndex(t) - if test.scenario == "current-unclean" { - index.setTitle(t, testTaskID, "Stable subject") - } + _, _ = testIndex(t) if test.scenario == "current-timeout" { setAppServerCurrentBudget(t, 150*time.Millisecond) } @@ -98,6 +136,27 @@ func TestAppServerCurrentFailuresStartOnlyOnce(t *testing.T) { } } +func TestProductionHasNoDetachedTitleSetterOrSyntheticThreadRead(t *testing.T) { + paths, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, name := range paths { + if strings.HasSuffix(name, "_test.go") { + continue + } + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + for _, method := range []string{"thread/name/set", "thread/read"} { + if strings.Contains(string(data), method) { + t.Fatalf("%s contains forbidden App Server method %q", name, method) + } + } + } +} + func installAppServerFixture(t testing.TB, scenario string) string { t.Helper() dir := t.TempDir() @@ -142,7 +201,6 @@ func TestAppServerFixtureProcess(t *testing.T) { if initialized.ID != 0 || initialized.Method != "initialized" { t.Fatalf("initialized = %#v", initialized) } - setCalls := make(map[string]int) listCalls := 0 for { request, err := decodeFixtureMessage(decoder) @@ -161,9 +219,9 @@ func TestAppServerFixtureProcess(t *testing.T) { listCalls++ serveFixtureList(t, scenario, listCalls, request, encoder) case "thread/read": - serveFixtureRead(t, scenario, setCalls, request, encoder) + t.Fatal("production attempted an unsafe thread/read") case "thread/name/set": - serveFixtureSet(t, scenario, setCalls, request, encoder) + t.Fatal("production attempted a detached title write") default: t.Fatalf("unexpected fixture method %q", request.Method) } @@ -187,6 +245,25 @@ func serveFixtureList(t testing.TB, scenario string, call int, request fixtureMe "data": []map[string]any{{"id": testOtherID, "name": "Other", "preview": "private"}}, "nextCursor": nil, }}) return + case "current-repeated-cursor": + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "data": []map[string]any{{"id": testOtherID, "name": "Other", "preview": "private"}}, + "nextCursor": "same-cursor", + }}) + return + case "current-multipage": + if call == 1 { + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "data": []map[string]any{{"id": testOtherID, "name": "Other", "preview": "private"}}, + "nextCursor": "current-page-2", + }}) + return + } + _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ + "data": []map[string]any{{"id": testTaskID, "name": "Stable subject", "preview": "private"}}, + "nextCursor": nil, + }}) + return case "multipage", "page-failure": serveFixturePages(t, scenario, request, encoder) return @@ -205,8 +282,8 @@ func serveFixtureList(t testing.TB, scenario string, call int, request fixtureMe t.Fatal(err) } if scenario == "current-concurrent-rename" && call == 1 { - // The initial title is already on the wire. Apply the user's rename before - // reading ThreadBear's next (and only) set request. + // The initial title is already on the wire. Apply the user's rename after + // ThreadBear's one observation; the planner must not overwrite it. rename := "User rename during delayed write" task := tasks[testTaskID] task.Name = &rename @@ -245,55 +322,6 @@ func serveFixturePages(t testing.TB, scenario string, request fixtureMessage, en }}) } -func serveFixtureRead(t testing.TB, scenario string, setCalls map[string]int, request fixtureMessage, encoder *json.Encoder) { - t.Helper() - id := fixtureStringParam(t, request, "threadId") - tasks := fixtureReadTasks(t) - task, ok := tasks[id] - if !ok { - _ = encoder.Encode(map[string]any{"id": request.ID, "error": map[string]any{"code": -32004}}) - return - } - name := task.Name - if scenario == "onboarding-slow-readback" && setCalls[id] > 0 { - time.Sleep(300 * time.Millisecond) - } - if scenario == "onboarding-races" { - switch id { - case testDriftID: - value := "Renamed after snapshot" - name = &value - case testBlankAfterID: - name = nil - case testUnconfirmedID: - // The setter acknowledges this target but its exact readback never changes. - _ = setCalls[id] - } - } - _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{ - "thread": map[string]any{"id": id, "name": name, "preview": task.Preview}, - }}) -} - -func serveFixtureSet(t testing.TB, scenario string, setCalls map[string]int, request fixtureMessage, encoder *json.Encoder) { - t.Helper() - id, name := fixtureStringParam(t, request, "threadId"), fixtureStringParam(t, request, "name") - setCalls[id]++ - tasks := fixtureReadTasks(t) - if scenario != "current-readback-mismatch" && !(scenario == "onboarding-races" && id == testUnconfirmedID) { - task := tasks[id] - task.Name = &name - tasks[id] = task - fixtureWriteTasks(t, tasks) - } - if scenario == "current-set-error" { - _ = encoder.Encode(map[string]any{"id": request.ID, "error": map[string]any{"code": -32001}}) - return - } - // The real response is an empty object. Its contents are never title proof. - _ = encoder.Encode(map[string]any{"id": request.ID, "result": map[string]any{}}) -} - type fixtureMessage struct { ID int `json:"id"` Method string `json:"method"` diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index 10b530a..fb336bb 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -109,51 +109,79 @@ func addTask(t testing.TB, index *testTaskIndex, root, id, title string, name an return rollout } -func TestCurrentTitleUsesOneWriterAndExactReadback(t *testing.T) { +func TestCurrentTitlePlansOneAppNativeWriteFromExactListName(t *testing.T) { root, index := testIndex(t) addTask(t, index, root, testTaskID, "stale SQLite title", "Stable subject", "vscode", 0) result, err := runCurrentTitle(t.Context(), testTaskID, "complete") if err != nil { t.Fatal(err) } - if !result.Ready || !result.Updated || result.Unchanged || result.Unconfirmed || - result.PreviousTitle != "Stable subject" || result.DesiredTitle != "✅ Stable subject" || result.Title != result.DesiredTitle { + if !result.Ready || !result.WriteRequired || result.Unchanged || + result.PreviousTitle != "Stable subject" || result.DesiredTitle != "✅ Stable subject" || + result.Reason != "app-native title write required" { t.Fatalf("title result = %#v", result) } - if got := index.title(t, testTaskID); got != "✅ Stable subject" { - t.Fatalf("native name = %q", got) + if got := index.title(t, testTaskID); got != "Stable subject" { + t.Fatalf("planner mutated native name = %q", got) } if record, err := newStore(stateDir()).readTask(testTaskID); err != nil || record.Subject != "Stable subject" { t.Fatalf("subject record = %#v, %v", record, err) } requests := fixtureRequests(t) - if countFixtureMethod(requests, "thread/name/set") != 1 || countFixtureMethod(requests, "thread/list") != 2 || + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 1 || countFixtureMethod(requests, "thread/read") != 0 { t.Fatalf("RPC sequence = %#v", requests) } - set := fixtureMethod(requests, "thread/name/set", 0) - if fixtureStringParam(t, set, "threadId") != testTaskID || fixtureStringParam(t, set, "name") != result.DesiredTitle { - t.Fatalf("setter = %#v", set) + list := fixtureMethod(requests, "thread/list", 0) + var limit int + var archived bool + if json.Unmarshal(list.Params["limit"], &limit) != nil || limit != appServerListLimit || + json.Unmarshal(list.Params["archived"], &archived) != nil || archived || + fixtureStringParam(t, list, "sortKey") != "recency_at" || + fixtureStringParam(t, list, "sortDirection") != "desc" { + t.Fatalf("current lookup params = %#v", list.Params) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) + } + for _, expected := range []string{"ready", "task_id", "status", "previous_title", "desired_title", "write_required", "unchanged", "reason"} { + if _, exists := fields[expected]; !exists { + t.Fatalf("planner omitted field %q: %s", expected, encoded) + } + } + for _, obsolete := range []string{"title", "updated", "unconfirmed"} { + if _, exists := fields[obsolete]; exists { + t.Fatalf("planner emitted obsolete field %q: %s", obsolete, encoded) + } } } func TestCurrentTitlePreservesFiniteOwnershipAndUserRename(t *testing.T) { _, index := testIndex(t) index.setTitle(t, testTaskID, "Initial subject") - if _, err := runCurrentTitle(t.Context(), testTaskID, "complete"); err != nil { - t.Fatal(err) - } - if _, err := runCurrentTitle(t.Context(), testTaskID, "automation"); err != nil { + first, err := runCurrentTitle(t.Context(), testTaskID, "complete") + if err != nil { t.Fatal(err) } - if got := index.title(t, testTaskID); got != "🤖 Initial subject" { - t.Fatalf("owned rendering stacked: %q", got) + index.setTitle(t, testTaskID, first.DesiredTitle) // Simulate the separate app-native setter. + second, err := runCurrentTitle(t.Context(), testTaskID, "automation") + if err != nil || second.DesiredTitle != "🤖 Initial subject" { + t.Fatalf("second plan = %#v, %v", second, err) } + index.setTitle(t, testTaskID, second.DesiredTitle) // Simulate the separate app-native setter. index.setTitle(t, testTaskID, "✅ Quarterly close ") result, err := runCurrentTitle(t.Context(), testTaskID, "complete") - if err != nil || result.Title != "✅ ✅ Quarterly close " { + if err != nil || result.DesiredTitle != "✅ ✅ Quarterly close " || !result.WriteRequired { t.Fatalf("verbatim rename = %#v, %v", result, err) } + if got := index.title(t, testTaskID); got != "✅ Quarterly close " { + t.Fatalf("planner mutated user rename = %q", got) + } if record, err := newStore(stateDir()).readTask(testTaskID); err != nil || record.Subject != "✅ Quarterly close " { t.Fatalf("renamed record = %#v, %v", record, err) } @@ -169,11 +197,14 @@ func TestCurrentTitleAlreadyExactDoesNotCallSetter(t *testing.T) { t.Fatal(err) } result, err := runCurrentTitle(t.Context(), testTaskID, "complete") - if err != nil || !result.Ready || result.Updated || !result.Unchanged || result.Title != "✅ Stable subject" { + if err != nil || !result.Ready || result.WriteRequired || !result.Unchanged || + result.PreviousTitle != "✅ Stable subject" || result.DesiredTitle != "✅ Stable subject" || + result.Reason != "native title already matches desired title" { t.Fatalf("unchanged result = %#v, %v", result, err) } requests := fixtureRequests(t) - if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 2 { + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 1 || + countFixtureMethod(requests, "thread/read") != 0 { t.Fatalf("unchanged RPCs = %#v", requests) } } @@ -192,8 +223,10 @@ func TestCurrentTitleDoesNotWriteUnsafeOrBlankNativeNames(t *testing.T) { if err == nil || result.PreviousTitle != "" || result.DesiredTitle != "" { t.Fatalf("unsafe result = %#v, %v", result, err) } - if countFixtureMethod(fixtureRequests(t), "thread/name/set") != 0 { - t.Fatal("unsafe task reached native setter") + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 1 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("unsafe planner RPCs = %#v", requests) } if _, err := newStore(stateDir()).readTask(testTaskID); !errors.Is(err, os.ErrNotExist) { t.Fatalf("unsafe task wrote subject state: %v", err) @@ -202,47 +235,26 @@ func TestCurrentTitleDoesNotWriteUnsafeOrBlankNativeNames(t *testing.T) { } } -func TestCurrentTitleNeverRetriesUnknownNativeResult(t *testing.T) { - for _, scenario := range []string{"current-readback-mismatch", "current-set-error"} { - t.Run(scenario, func(t *testing.T) { - _, index := testIndex(t) - index.setTitle(t, testTaskID, "Stable subject") - starts := installAppServerFixture(t, scenario) - result, err := runCurrentTitle(t.Context(), testTaskID, "complete") - if err == nil || !result.Unconfirmed || result.Ready || result.Title != "" { - t.Fatalf("unknown result = %#v, %v", result, err) - } - requests := fixtureRequests(t) - if countFixtureMethod(requests, "thread/name/set") != 1 { - t.Fatalf("setter attempts = %#v", requests) - } - if data, err := os.ReadFile(starts); err != nil || string(data) != "x" { - t.Fatalf("App Server starts = %q, %v", data, err) - } - }) - } -} - -func TestCurrentTitleAcceptsBoundedNoCASConcurrentRename(t *testing.T) { +func TestCurrentTitleAcceptsBoundedNoCASConcurrentRenameWithoutWriting(t *testing.T) { _, index := testIndex(t) index.setTitle(t, testTaskID, "Stable subject") starts := installAppServerFixture(t, "current-concurrent-rename") result, err := runCurrentTitle(t.Context(), testTaskID, "complete") - if err != nil || !result.Ready || !result.Updated || result.Unconfirmed || - result.PreviousTitle != "Stable subject" || result.Title != "✅ Stable subject" { + if err != nil || !result.Ready || !result.WriteRequired || result.Unchanged || + result.PreviousTitle != "Stable subject" || result.DesiredTitle != "✅ Stable subject" { t.Fatalf("concurrent rename result = %#v, %v", result, err) } marker, err := os.ReadFile(os.Getenv("THREADBEAR_APP_SERVER_RACE_MARKER")) if err != nil || string(marker) != "User rename during delayed write\n" { t.Fatalf("concurrent rename marker = %q, %v", marker, err) } - if got := index.title(t, testTaskID); got != result.DesiredTitle { - t.Fatalf("exact native readback = %q, want %q", got, result.DesiredTitle) + if got := index.title(t, testTaskID); got != "User rename during delayed write" { + t.Fatalf("planner overwrote concurrent rename = %q", got) } requests := fixtureRequests(t) - if countFixtureMethod(requests, "thread/name/set") != 1 || - countFixtureMethod(requests, "thread/list") != 2 || + if countFixtureMethod(requests, "thread/name/set") != 0 || + countFixtureMethod(requests, "thread/list") != 1 || countFixtureMethod(requests, "thread/read") != 0 { t.Fatalf("no-CAS RPC sequence = %#v", requests) } @@ -270,33 +282,58 @@ func TestOnboardingPlanMatchesConfirmedCorpusAndSkipsActiveTask(t *testing.T) { t.Fatalf("onboarding plan = %#v, %v", plan, err) } active := onboardingItemByID(t, plan.Items, testActiveID) - if active.Outcome != onboardingUnchanged || active.Applied || active.Reason != "active task is handled by the terminal title writer" { + if active.Outcome != onboardingUnchanged || active.Reason != "active task is handled by the terminal title writer" { t.Fatalf("active plan item = %#v", active) } if countFixtureMethod(fixtureRequests(t), "thread/read") != 0 || countFixtureMethod(fixtureRequests(t), "thread/name/set") != 0 { t.Fatal("read-only plan performed target RPCs") } + if _, err := newStore(stateDir()).readTask(testAlphaID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read-only plan wrote subject state: %v", err) + } clearFixtureRequests(t) - applied, err := runOnboarding(t.Context(), true, testActiveID) - if err != nil || !applied.Ready || applied.ReadOnly || !applied.OnboardingComplete || - applied.Updated != 1 || applied.Unchanged != 2 || applied.Skipped != 1 || applied.Unconfirmed != 0 { - t.Fatalf("onboarding apply = %#v, %v", applied, err) + prepared, err := runOnboarding(t.Context(), true, testActiveID) + if err != nil || !prepared.Ready || !prepared.PlanComplete || prepared.ReadOnly || prepared.OnboardingComplete || + prepared.Prepared != 1 || prepared.NeedsUpdate != 1 || prepared.Unchanged != 2 || prepared.Skipped != 1 { + t.Fatalf("onboarding preparation = %#v, %v", prepared, err) } - if index.title(t, testActiveID) != "Active task" || index.title(t, testAlphaID) != "🐻 Alpha" { - t.Fatalf("native titles: active=%q alpha=%q", index.title(t, testActiveID), index.title(t, testAlphaID)) + if index.title(t, testActiveID) != "Active task" || index.title(t, testAlphaID) != "Alpha" { + t.Fatalf("planner mutated native titles: active=%q alpha=%q", index.title(t, testActiveID), index.title(t, testAlphaID)) } requests := fixtureRequests(t) - if countFixtureTarget(requests, "thread/name/set", testActiveID) != 0 || countFixtureTarget(requests, "thread/read", testActiveID) != 0 || - countFixtureTarget(requests, "thread/name/set", testAlphaID) != 1 { + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/read") != 0 { t.Fatalf("onboarding RPCs = %#v", requests) } + alpha := onboardingItemByID(t, prepared.Items, testAlphaID) + if alpha.Outcome != onboardingPrepared || alpha.Reason != "app-native title write required" { + t.Fatalf("prepared item = %#v", alpha) + } + encoded, err := json.Marshal(prepared) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) + } + for _, obsolete := range []string{"updated", "unconfirmed"} { + if _, exists := fields[obsolete]; exists { + t.Fatalf("preparation emitted obsolete field %q: %s", obsolete, encoded) + } + } + if strings.Contains(string(encoded), `"applied"`) { + t.Fatalf("preparation emitted obsolete item field: %s", encoded) + } + if record, err := newStore(stateDir()).readTask(testAlphaID); err != nil || record.Subject != "Alpha" { + t.Fatalf("prepared subject = %#v, %v", record, err) + } if _, err := newStore(stateDir()).readTask(testActiveID); !errors.Is(err, os.ErrNotExist) { t.Fatalf("active task wrote state: %v", err) } } -func TestOnboardingSeriallySkipsDriftAndReportsUnconfirmed(t *testing.T) { +func TestOnboardingPreparesEverySafeSnapshotCandidateWithoutTargetRPCs(t *testing.T) { _, index := testIndex(t) for id, title := range map[string]string{ testActiveID: "Active", testAlphaID: "Alpha", testAlreadyID: "🐻 Beta", testBlankAfterID: "Blank later", @@ -311,27 +348,57 @@ func TestOnboardingSeriallySkipsDriftAndReportsUnconfirmed(t *testing.T) { }); err != nil { t.Fatal(err) } - installAppServerFixture(t, "onboarding-races") result, err := runOnboarding(t.Context(), true, testActiveID) - if err == nil || result.Ready || result.OnboardingComplete || result.Total != 7 || result.Safe != 6 || - result.Updated != 1 || result.Unchanged != 2 || result.Skipped != 3 || result.Unconfirmed != 1 { - t.Fatalf("raced onboarding = %#v, %v", result, err) + if err != nil || !result.Ready || !result.PlanComplete || result.ReadOnly || result.OnboardingComplete || + result.Total != 7 || result.Safe != 6 || result.NeedsUpdate != 4 || result.Prepared != 4 || + result.Unchanged != 2 || result.Skipped != 1 { + t.Fatalf("snapshot onboarding = %#v, %v", result, err) } requests := fixtureRequests(t) - if countFixtureTarget(requests, "thread/name/set", testAlphaID) != 1 || - countFixtureTarget(requests, "thread/name/set", testUnconfirmedID) != 1 || - countFixtureTarget(requests, "thread/name/set", testDriftID) != 0 || - countFixtureTarget(requests, "thread/name/set", testBlankAfterID) != 0 || - countFixtureTarget(requests, "thread/read", testRawID) != 0 { - t.Fatalf("raced setter calls = %#v", requests) - } - for _, id := range []string{testActiveID, testDriftID, testBlankAfterID} { + if countFixtureMethod(requests, "thread/name/set") != 0 || + countFixtureMethod(requests, "thread/read") != 0 || countFixtureMethod(requests, "thread/list") != 1 { + t.Fatalf("snapshot preparation calls = %#v", requests) + } + for _, id := range []string{testActiveID, testRawID} { if _, err := newStore(stateDir()).readTask(id); !errors.Is(err, os.ErrNotExist) { t.Fatalf("%s wrote subject state: %v", id, err) } } - if item := onboardingItemByID(t, result.Items, testUnconfirmedID); item.Outcome != onboardingUnconfirmed { - t.Fatalf("unconfirmed item = %#v", item) + for _, id := range []string{testAlphaID, testBlankAfterID, testDriftID, testUnconfirmedID} { + item := onboardingItemByID(t, result.Items, id) + if item.Outcome != onboardingPrepared { + t.Fatalf("prepared item = %#v", item) + } + if record, err := newStore(stateDir()).readTask(id); err != nil || record.Subject != item.Subject { + t.Fatalf("prepared state for %s = %#v, %v", id, record, err) + } + } +} + +func TestPreparedSubjectYieldsToLaterSafeUserRename(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testAlphaID, "Alpha") + prepared, err := runOnboarding(t.Context(), true, testActiveID) + if err != nil || prepared.Prepared != 1 { + t.Fatalf("initial preparation = %#v, %v", prepared, err) + } + index.setTitle(t, testAlphaID, "Renamed after snapshot") + clearFixtureRequests(t) + + plan, err := runCurrentTitle(t.Context(), testAlphaID, "complete") + if err != nil || !plan.Ready || plan.PreviousTitle != "Renamed after snapshot" || + plan.DesiredTitle != "✅ Renamed after snapshot" || !plan.WriteRequired { + t.Fatalf("later rename plan = %#v, %v", plan, err) + } + if record, err := newStore(stateDir()).readTask(testAlphaID); err != nil || record.Subject != "Renamed after snapshot" { + t.Fatalf("later rename subject = %#v, %v", record, err) + } + if got := index.title(t, testAlphaID); got != "Renamed after snapshot" { + t.Fatalf("planner mutated later rename = %q", got) + } + if requests := fixtureRequests(t); countFixtureMethod(requests, "thread/read") != 0 || + countFixtureMethod(requests, "thread/name/set") != 0 { + t.Fatalf("later rename RPCs = %#v", requests) } } @@ -348,7 +415,7 @@ func TestUnsafeActiveOnboardingTaskDoesNotInflateSafeCount(t *testing.T) { } } -func TestCurrentWriterCannotOutliveLifecycleFence(t *testing.T) { +func TestCurrentPlannerCannotOutliveLifecycleFence(t *testing.T) { _, index := testIndex(t) index.setTitle(t, testTaskID, "Stable subject") path := filepath.Join(stateDir(), "lifecycle.lock") @@ -368,28 +435,31 @@ func TestCurrentWriterCannotOutliveLifecycleFence(t *testing.T) { case err := <-done: if err == nil || !strings.Contains(err.Error(), "lifecycle is busy") { unlock(lifecycle) - t.Fatalf("writer with exclusive lifecycle fence = %v", err) + t.Fatalf("planner with exclusive lifecycle fence = %v", err) } case <-time.After(250 * time.Millisecond): unlock(lifecycle) - t.Fatal("writer waited behind lifecycle teardown") + t.Fatal("planner waited behind lifecycle teardown") } unlock(lifecycle) if _, err := os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); err == nil { requests := fixtureRequests(t) if len(requests) != 0 { - t.Fatalf("busy writer started App Server: %#v", requests) + t.Fatalf("busy planner started App Server: %#v", requests) } } else if !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } } -func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPass(t *testing.T) { +func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPreparation(t *testing.T) { _, index := testIndex(t) index.setTitle(t, testAlphaID, "Alpha") index.setTitle(t, testOtherID, "Other") - installAppServerFixture(t, "onboarding-slow-readback") + stateLock, err := newStore(stateDir()).lock(testAlphaID) + if err != nil { + t.Fatal(err) + } done := make(chan error, 1) go func() { @@ -400,11 +470,12 @@ func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPass(t *testing.T) { for _, err := os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); errors.Is(err, os.ErrNotExist) && time.Now().Before(deadline); _, err = os.Stat(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")) { time.Sleep(10 * time.Millisecond) } - for countFixtureMethod(fixtureRequests(t), "thread/name/set") == 0 && time.Now().Before(deadline) { + for countFixtureMethod(fixtureRequests(t), "thread/list") == 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } - if countFixtureMethod(fixtureRequests(t), "thread/name/set") == 0 { - t.Fatal("onboarding did not reach its first write") + if countFixtureMethod(fixtureRequests(t), "thread/list") == 0 { + unlock(stateLock) + t.Fatal("onboarding did not complete its snapshot") } locked := make(chan *os.File, 1) @@ -420,11 +491,14 @@ func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPass(t *testing.T) { select { case lock := <-locked: unlock(lock) + unlock(stateLock) t.Fatal("replacement lifecycle entered during onboarding") case err := <-lockErr: + unlock(stateLock) t.Fatalf("replacement lifecycle failed while waiting: %v", err) case <-time.After(50 * time.Millisecond): } + unlock(stateLock) if err := <-done; err != nil { t.Fatal(err) } @@ -436,6 +510,10 @@ func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPass(t *testing.T) { case <-time.After(time.Second): t.Fatal("replacement lifecycle did not resume after onboarding") } + if requests := fixtureRequests(t); countFixtureMethod(requests, "thread/name/set") != 0 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("preparation attempted target RPC: %#v", requests) + } } func TestOnboardingDryRunDoesNotTakeLifecycleFence(t *testing.T) { @@ -456,7 +534,7 @@ func TestOnboardingDryRunDoesNotTakeLifecycleFence(t *testing.T) { } } -func TestConfirmedOnboardingRefusesBusyLifecycleBeforeWrites(t *testing.T) { +func TestConfirmedOnboardingRefusesBusyLifecycleBeforePreparation(t *testing.T) { _, index := testIndex(t) index.setTitle(t, testAlphaID, "Alpha") path := filepath.Join(stateDir(), "lifecycle.lock") @@ -545,21 +623,6 @@ func fixtureMethod(requests []fixtureMessage, method string, at int) fixtureMess return fixtureMessage{} } -func countFixtureTarget(requests []fixtureMessage, method, taskID string) int { - count := 0 - for _, request := range requests { - if request.Method != method { - continue - } - var id string - _ = json.Unmarshal(request.Params["threadId"], &id) - if id == taskID { - count++ - } - } - return count -} - func TestNoSQLiteDependency(t *testing.T) { root, index := testIndex(t) index.setTitle(t, testTaskID, "Visible") diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 27475a2..3f5755c 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -155,6 +155,34 @@ func TestLifecycleNeverTouchesCodexHooks(t *testing.T) { if !strings.HasPrefix(string(agents), foreignAgents) || !managedBlockExact(p.agents) { t.Fatalf("managed AGENTS content = %q", agents) } + skill, _ := os.ReadFile(p.skill) + for label, text := range map[string]string{"AGENTS": string(agents), "skill": string(skill)} { + if strings.Count(text, "tools.codex_app__set_thread_title") != 1 { + t.Fatalf("%s must contain exactly one mounted app-native setter: %q", label, text) + } + for _, obsolete := range []string{"plan.updated", "plan.unconfirmed", "thread/name/set"} { + if strings.Contains(text, obsolete) { + t.Fatalf("%s contains obsolete detached-writer contract %q", label, obsolete) + } + } + } + if !strings.Contains(string(agents), "plan.write_required") || !strings.Contains(string(skill), `item.outcome === "prepared"`) { + t.Fatalf("installed guidance lacks planner/prepared contract: AGENTS=%q skill=%q", agents, skill) + } + for _, required := range []string{ + "tools.write_stdin({", + "tools.codex_app__read_thread({", + "current?.thread?.title !== item.title", + "let updated = 0, skipped = 0, unconfirmed = 0", + "updated + skipped + unconfirmed === prepared.length", + } { + if !strings.Contains(string(skill), required) { + t.Fatalf("installed skill lacks mounted revalidation contract %q: %q", required, skill) + } + } + if strings.Count(string(skill), "tools.write_stdin({") != 1 || strings.Count(string(skill), "tools.codex_app__read_thread({") != 1 { + t.Fatalf("installed skill must contain one preparation resume and one mounted reread: %q", skill) + } if !exactFile(p.skill, []byte(assets.SkillManagedContent)) { t.Fatal("managed skill is not exact") } @@ -571,11 +599,11 @@ func TestOnboardReturnsCompleteReadOnlyPlan(t *testing.T) { t.Fatal(err) } value := result.(onboardingResult) - if !value.Ready || !value.ReadOnly || !value.PlanComplete || value.OnboardingComplete || value.Total != 3 || value.Safe != 1 || value.NeedsUpdate != 1 || value.Updated != 0 || value.Unchanged != 0 || value.Skipped != 2 || value.Unconfirmed != 0 { + if !value.Ready || !value.ReadOnly || !value.PlanComplete || value.OnboardingComplete || value.Total != 3 || value.Safe != 1 || value.NeedsUpdate != 1 || value.Prepared != 0 || value.Unchanged != 0 || value.Skipped != 2 { t.Fatalf("onboard plan = %#v", value) } items := value.Items - if items[2].TaskID != testSafeID || items[2].DesiredTitle != "🐻 Exact subject" { + if items[2].TaskID != testSafeID || items[2].Title != "Exact subject" || items[2].DesiredTitle != "🐻 Exact subject" { t.Fatalf("onboard items = %#v", items) } t.Setenv("CODEX_THREAD_ID", testSafeID) @@ -584,7 +612,7 @@ func TestOnboardReturnsCompleteReadOnlyPlan(t *testing.T) { t.Fatal(err) } active := activeResult.(onboardingResult) - if !active.OnboardingComplete || active.NeedsUpdate != 0 || active.Unchanged != 1 || active.Items[2].Outcome != onboardingUnchanged || active.Items[2].Applied || active.Items[2].Reason != "active task is handled by the terminal title writer" { + if !active.OnboardingComplete || active.NeedsUpdate != 0 || active.Prepared != 0 || active.Unchanged != 1 || active.Items[2].Outcome != onboardingUnchanged || active.Items[2].Reason != "active task is handled by the terminal title writer" { t.Fatalf("active-task onboarding plan = %#v", active) } data, err := os.ReadFile(requests) diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index 121050b..29bf0ad 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -49,16 +49,13 @@ func run(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writ case "onboard": dry := flags.Bool("dry-run", false, "return the complete read-only onboarding plan") noninteractive := flags.Bool("noninteractive", false, "run without prompts") - confirm := flags.Bool("confirm", false, "confirm all safe onboarding title changes") + confirm := flags.Bool("confirm", false, "prepare all safe onboarding title changes") action = func() (any, error) { return onboard(ctx, *dry, *noninteractive && *confirm) } case "title": - selectedStatus := flags.String("status", "", "set complete, next_steps, needs_input, blocked, or automation") + selectedStatus := flags.String("status", "", "plan a title for complete, next_steps, needs_input, blocked, or automation") action = func() (any, error) { - if _, ok := statusIcons[*selectedStatus]; !ok { - return nil, fmt.Errorf("unsupported ThreadBear status %q", *selectedStatus) - } return runCurrentTitle(ctx, os.Getenv("CODEX_THREAD_ID"), *selectedStatus) } case "status": diff --git a/cmd/threadbear/managed_javascript_test.go b/cmd/threadbear/managed_javascript_test.go new file mode 100644 index 0000000..e3c32ce --- /dev/null +++ b/cmd/threadbear/managed_javascript_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "encoding/json" + "fmt" + "os/exec" + "reflect" + "strings" + "testing" +) + +func TestEmbeddedOnboardingJavaScriptResumesAndSerializesNativeWrites(t *testing.T) { + protocol := readRepoFile(t, "assets", "skill", "SKILL.md") + source := extractJavaScriptCell(t, protocol) + sourceJSON, err := json.Marshal(source) + if err != nil { + t.Fatal(err) + } + + harness := fmt.Sprintf(` +const source = %s; +const plan = { + ready:true, plan_complete:true, read_only:false, total:4, + items:[ + {outcome:"prepared",task_id:"drift",title:"old drift",desired_title:"🐻 old drift"}, + {outcome:"prepared",task_id:"exact",title:"old exact",desired_title:"🐻 old exact"}, + {outcome:"prepared",task_id:"bad",title:"old bad",desired_title:"🐻 old bad"}, + {outcome:"unchanged",task_id:"same",title:"same",desired_title:"same"} + ] +}; +const encoded = JSON.stringify(plan); +const cut1 = Math.floor(encoded.length / 3); +const cut2 = Math.floor(encoded.length * 2 / 3); +const trace = [], outputs = [], notices = []; +let writeCalls = 0; +const tools = { + exec_command: async args => { + trace.push("exec"); + if (args.cmd !== "\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json" || + args.yield_time_ms !== 30000 || args.max_output_tokens !== 200000) throw new Error("bad exec args"); + return {session_id:77,output:encoded.slice(0,cut1)}; + }, + write_stdin: async args => { + trace.push("write:" + args.session_id); + if (args.session_id !== 77 || args.yield_time_ms !== 30000 || + args.max_output_tokens !== 200000) throw new Error("bad resume args"); + writeCalls++; + if (writeCalls === 1) return {session_id:77,output:encoded.slice(cut1,cut2)}; + if (writeCalls === 2) return {exit_code:0,output:encoded.slice(cut2)}; + throw new Error("preparation process resumed more than needed"); + }, + codex_app__read_thread: async args => { + trace.push("read:" + args.threadId); + if (Object.keys(args).sort().join(",") !== + "includeOutputs,maxOutputCharsPerItem,threadId,turnLimit" || + args.includeOutputs !== false || args.turnLimit !== 1 || + args.maxOutputCharsPerItem !== 1) throw new Error("bad read args"); + if (args.threadId === "drift") return {thread:{title:"changed"}}; + if (args.threadId === "exact") return {thread:{title:"old exact"}}; + if (args.threadId === "bad") return {thread:{title:"old bad"}}; + throw new Error("unexpected read target"); + }, + codex_app__set_thread_title: async args => { + trace.push("set:" + args.threadId); + if (Object.keys(args).sort().join(",") !== "threadId,title") throw new Error("bad setter args"); + if (args.threadId === "exact" && args.title === "🐻 old exact") + return {threadId:"exact",title:"🐻 old exact"}; + if (args.threadId === "bad" && args.title === "🐻 old bad") return "malformed"; + throw new Error("unexpected setter target"); + } +}; +const text = value => outputs.push(value); +const notify = value => notices.push(value); +class Exit extends Error {} +const exit = () => { throw new Exit(); }; +const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; +try { + await new AsyncFunction("tools","text","exit","notify",source)(tools,text,exit,notify); +} catch (error) { + if (!(error instanceof Exit)) throw error; +} +process.stdout.write(JSON.stringify({trace,outputs,notices,writeCalls})); +`, sourceJSON) + + output, err := exec.Command("node", "--input-type=module", "--eval", harness).CombinedOutput() + if err != nil { + t.Fatalf("execute embedded onboarding JavaScript: %v\n%s", err, output) + } + + var run struct { + Trace []string `json:"trace"` + Outputs []string `json:"outputs"` + Notices []string `json:"notices"` + WriteCalls int `json:"writeCalls"` + } + if err := json.Unmarshal(output, &run); err != nil { + t.Fatalf("decode JavaScript harness output: %v\n%s", err, output) + } + wantTrace := []string{ + "exec", "write:77", "write:77", + "read:drift", + "read:exact", "set:exact", + "read:bad", "set:bad", + } + if !reflect.DeepEqual(run.Trace, wantTrace) { + t.Fatalf("unexpected managed-loop order\n got: %v\nwant: %v", run.Trace, wantTrace) + } + if run.WriteCalls != 2 { + t.Fatalf("write_stdin calls = %d; want two resumptions of the same process", run.WriteCalls) + } + if len(run.Outputs) != 1 { + t.Fatalf("terminal outputs = %d; want one receipt", len(run.Outputs)) + } + + var receipt struct { + Ready bool `json:"ready"` + PlanComplete bool `json:"plan_complete"` + OnboardingComplete bool `json:"onboarding_complete"` + Total int `json:"total"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Unchanged int `json:"unchanged"` + Unconfirmed int `json:"unconfirmed"` + } + if err := json.Unmarshal([]byte(run.Outputs[0]), &receipt); err != nil { + t.Fatalf("decode managed-loop receipt: %v\n%s", err, run.Outputs[0]) + } + if receipt.Ready || !receipt.PlanComplete || receipt.OnboardingComplete || + receipt.Total != 4 || receipt.Updated != 1 || receipt.Skipped != 1 || + receipt.Unchanged != 2 || receipt.Unconfirmed != 1 { + t.Fatalf("unexpected managed-loop receipt: %+v", receipt) + } + if len(run.Notices) < 3 || run.Notices[0] != "ThreadBear onboarding: preparing complete catalog" || + run.Notices[len(run.Notices)-1] != "ThreadBear onboarding: 3/3" { + t.Fatalf("unexpected progress notifications: %v", run.Notices) + } +} + +func extractJavaScriptCell(t *testing.T, markdown string) string { + t.Helper() + const opener = "```js\n" + start := strings.Index(markdown, opener) + if start < 0 { + t.Fatal("installed skill has no JavaScript cell") + } + rest := markdown[start+len(opener):] + end := strings.Index(rest, "\n```") + if end < 0 { + t.Fatal("installed skill JavaScript cell is not closed") + } + if strings.Contains(rest[end+len("\n```"):], opener) { + t.Fatal("installed skill contains more than one JavaScript cell") + } + return rest[:end] +} diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index fc072a5..94e7f10 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -9,10 +9,9 @@ import ( const ( onboardingNeedsUpdate = "needs_update" - onboardingUpdated = "updated" + onboardingPrepared = "prepared" onboardingUnchanged = "unchanged" onboardingSkipped = "skipped" - onboardingUnconfirmed = "unconfirmed" ) type indexedTask struct { @@ -23,15 +22,13 @@ type indexedTask struct { type currentTitleResult struct { Ready bool `json:"ready"` - TaskID string `json:"task_id,omitempty"` - Status string `json:"status,omitempty"` - PreviousTitle string `json:"previous_title,omitempty"` - DesiredTitle string `json:"desired_title,omitempty"` - Title string `json:"title,omitempty"` - Updated bool `json:"updated"` + TaskID string `json:"task_id"` + Status string `json:"status"` + PreviousTitle string `json:"previous_title"` + DesiredTitle string `json:"desired_title"` + WriteRequired bool `json:"write_required"` Unchanged bool `json:"unchanged"` - Unconfirmed bool `json:"unconfirmed"` - Reason string `json:"reason,omitempty"` + Reason string `json:"reason"` } type onboardingItem struct { @@ -40,7 +37,6 @@ type onboardingItem struct { Subject string `json:"subject,omitempty"` DesiredTitle string `json:"desired_title,omitempty"` Safe bool `json:"safe"` - Applied bool `json:"applied"` Outcome string `json:"outcome"` Reason string `json:"reason,omitempty"` } @@ -53,10 +49,9 @@ type onboardingResult struct { Total int `json:"total"` Safe int `json:"safe"` NeedsUpdate int `json:"needs_update"` - Updated int `json:"updated"` + Prepared int `json:"prepared"` Unchanged int `json:"unchanged"` Skipped int `json:"skipped"` - Unconfirmed int `json:"unconfirmed"` Items []onboardingItem `json:"items"` } @@ -86,54 +81,38 @@ func runCurrentTitle(ctx context.Context, taskID, status string) (currentTitleRe return err } if task.RawFallback { - return errors.New("native task name is blank; task is raw or unowned") + result.Reason = "native task name is blank; task is raw or unowned" + return errors.New(result.Reason) } subject, err := persistSubjectUnderFence(disk, task.ID, task.Title) if err != nil { + result.Reason = "subject could not be resolved or saved" return err } desired, err := renderTitle(status, subject) if err != nil { + result.Reason = "desired title could not be rendered" return err } result.PreviousTitle, result.DesiredTitle = task.Title, desired - - attempted := desired != task.Title - nextRequestID := 3 - if attempted { - if err := client.setName(nextRequestID, taskID, desired); err != nil { - result.Unconfirmed = true - result.Reason = "the single native title write did not return a usable result" - return err - } - nextRequestID++ - } - readback, err := client.currentTask(nextRequestID, taskID) - if err != nil { - result.Unconfirmed = attempted - result.Reason = "the native title could not be read back" - return err - } - if readback.RawFallback || readback.Title != desired { - result.Unconfirmed = attempted - if attempted { - result.Reason = "the single native title write was not confirmed by exact readback" - } else { - result.Reason = "the native title changed during verification" - } - return errors.New(result.Reason) + result.WriteRequired = desired != task.Title + result.Unchanged = !result.WriteRequired + if result.WriteRequired { + result.Reason = "app-native title write required" + } else { + result.Reason = "native title already matches desired title" } - result.Ready, result.Title = true, readback.Title - result.Updated, result.Unchanged = attempted, !attempted + result.Ready = true return nil }() if err != nil { return result, err } - // Exact native readback is the success gate. A process exit error after that - // point cannot make an already-observed title uncertain. + // The exact native name was already read and the plan is complete. Process + // exit cannot change that proof, so close and reap without promoting it into + // another title observation. client.close() return result, nil } @@ -191,77 +170,38 @@ func runOnboarding(ctx context.Context, apply bool, activeTaskID string) (onboar } var operationErr error - // thread/read is only a drift check and exact readback for rows admitted by - // the complete thread/list snapshot above. It never supplies a subject. + // The completed thread/list snapshot is the preparation authority. Subject + // state is safe to pre-persist: the later app-native writer owns the immediate + // title observation and a future planner adopts any safe intervening rename. for index := range items { item := &items[index] - if !item.Safe { - continue - } - if item.TaskID == activeTaskID { + if !item.Safe || item.TaskID == activeTaskID || item.Outcome != onboardingNeedsUpdate { continue } targetErr := func() error { - current, readErr := client.readTask(nextRequestID, item.TaskID) - nextRequestID++ - if readErr != nil { - item.Applied, item.Outcome = false, onboardingSkipped - item.Reason = "native task name could not be revalidated" - return nil - } - if current.RawFallback { - item.Applied, item.Outcome = false, onboardingSkipped - item.Reason = "native task name became blank during revalidation" - return nil - } - if current.Title != item.Title { - item.Applied, item.Outcome = false, onboardingSkipped - item.Reason = "native task name changed after the onboarding snapshot" - return nil - } - if item.DesiredTitle == current.Title { - item.Applied, item.Outcome = true, onboardingUnchanged - item.Reason = "already decorated" - return nil - } - if _, err := persistSubjectUnderFence(disk, item.TaskID, current.Title); err != nil { - item.Applied, item.Outcome = false, onboardingSkipped + if _, err := persistSubjectUnderFence(disk, item.TaskID, item.Title); err != nil { + item.Outcome = onboardingSkipped item.Reason = "subject state could not be saved" return fmt.Errorf("save subject state for %s: %w", item.TaskID, err) } - if err := client.setName(nextRequestID, item.TaskID, item.DesiredTitle); err != nil { - nextRequestID++ - item.Applied, item.Outcome = false, onboardingUnconfirmed - item.Reason = "the single native title write did not return a usable result" - return fmt.Errorf("set title for %s: %w", item.TaskID, err) - } - nextRequestID++ - readback, err := client.readTask(nextRequestID, item.TaskID) - nextRequestID++ - if err != nil || readback.RawFallback || readback.Title != item.DesiredTitle { - item.Applied, item.Outcome = false, onboardingUnconfirmed - item.Reason = "the single native title write was not confirmed by exact readback" - if err == nil { - err = errors.New(item.Reason) - } - return fmt.Errorf("confirm title for %s: %w", item.TaskID, err) - } - item.Applied, item.Outcome, item.Reason = true, onboardingUpdated, "" + item.Outcome = onboardingPrepared + item.Reason = "app-native title write required" return nil }() if targetErr != nil { - if item.Outcome != onboardingUnconfirmed && item.Outcome != onboardingSkipped { - item.Applied, item.Outcome = false, onboardingSkipped - item.Reason = "the ThreadBear lifecycle changed before this task could be written" + if item.Outcome != onboardingSkipped { + item.Outcome = onboardingSkipped + item.Reason = "the ThreadBear lifecycle changed before this task could be prepared" } operationErr = errors.Join(operationErr, targetErr) } } - // As with the current-task path, per-task exact readback is authoritative. - // Still reap the one long-lived process before returning the aggregate. + // Reap the one long-lived process after every admitted task has been + // prepared. Exit is proof-neutral once the plan is complete. client.close() result = summarizeOnboarding(items, false) result.Ready, result.PlanComplete = operationErr == nil, true + result.OnboardingComplete = result.OnboardingComplete && operationErr == nil return result, operationErr } @@ -271,7 +211,6 @@ func excludeActiveOnboardingTask(items []onboardingItem, activeTaskID string) { } for index := range items { if items[index].TaskID == activeTaskID { - items[index].Applied = false items[index].Outcome = onboardingUnchanged items[index].Reason = "active task is handled by the terminal title writer" return @@ -299,7 +238,7 @@ func prepareOnboardingItems(tasks []indexedTask) []onboardingItem { } if record.Subject != "" && isOwnedRendering(task.Title, record.Subject) { item.Title, item.Subject, item.DesiredTitle = task.Title, record.Subject, task.Title - item.Safe, item.Applied, item.Outcome, item.Reason = true, true, onboardingUnchanged, "already decorated" + item.Safe, item.Outcome, item.Reason = true, onboardingUnchanged, "already decorated" items = append(items, item) continue } @@ -318,32 +257,26 @@ func prepareOnboardingItems(tasks []indexedTask) []onboardingItem { func summarizeOnboarding(items []onboardingItem, readOnly bool) onboardingResult { result := onboardingResult{ReadOnly: readOnly, Total: len(items), Items: items} - allSafeConfirmed := true for _, item := range items { if item.Safe { result.Safe++ - if item.Outcome == onboardingNeedsUpdate { + if item.Outcome == onboardingNeedsUpdate || item.Outcome == onboardingPrepared { result.NeedsUpdate++ } - if item.Outcome != onboardingUpdated && item.Outcome != onboardingUnchanged { - allSafeConfirmed = false - } } switch item.Outcome { - case onboardingUpdated: - result.Updated++ + case onboardingPrepared: + result.Prepared++ case onboardingUnchanged: result.Unchanged++ case onboardingSkipped: result.Skipped++ - case onboardingUnconfirmed: - result.Unconfirmed++ } } if readOnly { result.OnboardingComplete = result.NeedsUpdate == 0 } else { - result.OnboardingComplete = allSafeConfirmed && result.Unconfirmed == 0 + result.OnboardingComplete = result.Prepared == 0 } return result } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 51ea6ef..3f9317b 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -41,20 +41,26 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { t.Fatal("INSTALL.md and site/install must be byte-identical") } requireText(t, guide, - "small local command reads and updates the title through Codex's official App Server", + "mounted Codex app applies it once through the native title setter", "--dry-run --json", "--noninteractive --confirm --json", "--no-onboard", "ThreadBear onboard", - "onboard --dry-run --json", - "onboard --noninteractive --confirm --json", - "entire unarchived App Server catalog before any write", - "handles every safe target serially with no item cap", - "attempts each write once", - "acknowledgement without exact readback is `unconfirmed`", - "`updated`, `unchanged`, `skipped`, and `unconfirmed`", - "`legacy_main_task_id` plus `legacy_automation_id`, `legacy_automation_name`, `legacy_automation_kind`, and `legacy_automation_target_thread_id`", - "unpin the preview's exact legacy main-task ID", + "entire unarchived App Server catalog before any preparation or title write", + "fresh complete catalog snapshot", + "returns one `prepared` action containing the snapshot title and desired title", + "tools.write_stdin", + "tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})", + "A missing, unreadable, or changed title is skipped.", + "tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})", + "Every prepared item must reach exactly one outcome.", + "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", + "tools.codex_app__set_thread_title({title:plan.desired_title})", + "one injection-safe terminal JavaScript cell", + "never re-embedded by the model", + "wait only for that same cell", + "yield does not cancel a slow native call", + "exact returned planned task ID and title", "never opens Codex SQLite", "binary is written last", "Every successful update reports `restart_required`", @@ -62,11 +68,10 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "do not run the title command", ) rejectText(t, guide, - "codex_app__set_thread_title", - "PreToolUse", - "PostToolUse", - "hooks.json", - "native title setter", + "makes at most one App Server name update", + "acknowledgement without exact readback", + "only task read/write authority", + "thread/name/set", "ThreadBear footer", "--control-task-id", "threadbear inventory", @@ -80,25 +85,48 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "Luna helper", "uninstall --prepare", "state_N.sqlite", + "rereads every candidate", + "serially exactly once for every prepared item", + "do not poll, retry, reconcile, or delay the response", ) } -func TestInstalledGuidanceDefinesOneTerminalCommand(t *testing.T) { +func TestInstalledGuidanceDefinesOneTerminalPlannerAndNativeWrite(t *testing.T) { guidance := readRepoFile(t, "assets", "AGENTS.threadbear.md") requireText(t, guidance, "Write the substantive response first.", `\"$HOME/.local/bin/threadbear\" title --status STATUS --json`, - "yield_time_ms:4000", - "max_output_tokens:1000", - "Make exactly one attempt at that terminal moment.", - "do not poll, retry, reconcile, or delay the response", + `// @exec: {"yield_time_ms": 30000, "max_output_tokens": 1000}`, + "Replace only `STATUS` with the exact enum", + "if (local.exit_code !== 0) { text(local); exit(); }", + "plan = JSON.parse(local.output)", + `typeof plan.write_required !== "boolean"`, + "if (!plan.write_required) { text(local); exit(); }", + "tools.codex_app__set_thread_title({title:plan.desired_title})", + "renamed.threadId !== plan.task_id", + "renamed.title !== plan.desired_title", + "mounted Codex app is the sole writer", + "If the outer cell yields, wait only for that same cell", + "yield does not cancel a slow native call", + "Never start another cell, poll the title, retry, or reconcile.", + "A returned failure is local to this turn.", "The status controls only the visible icon.", ) + if count := strings.Count(guidance, "```js"); count != 1 { + t.Fatalf("managed guidance contains %d JavaScript cells; want one", count) + } if count := strings.Count(guidance, "title --status STATUS --json"); count != 1 { - t.Fatalf("managed guidance contains %d terminal title commands; want one", count) + t.Fatalf("managed guidance contains %d terminal planners; want one", count) + } + if count := strings.Count(guidance, "tools.codex_app__set_thread_title("); count != 1 { + t.Fatalf("managed guidance contains %d native title calls; want one", count) } rejectText(t, guidance, - "codex_app__set_thread_title", + "threadId:plan.task_id", + "thread/name/set", + "Promise.race", + "setTimeout", + "delay the response", "PreToolUse", "PostToolUse", "ThreadBear footer", @@ -107,28 +135,60 @@ func TestInstalledGuidanceDefinesOneTerminalCommand(t *testing.T) { ) } -func TestInstalledSkillStaysACompactOperatingGuide(t *testing.T) { +func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { protocol := readRepoFile(t, "assets", "skill", "SKILL.md") if size := len([]byte(protocol)); size > 5*1024 { t.Fatalf("installed skill is %d bytes; compact-guide ceiling is 5 KiB", size) } requireText(t, protocol, + "Get explicit consent before install/reset, historical onboarding, manual update, or uninstall.", "## Install or reset", "## Onboard existing tasks", "onboard --dry-run --json", - "onboard --noninteractive --confirm --json", - "enumerate and deduplicate the full unarchived App Server catalog before any write", - "processes the complete plan serially with no item cap", - "counted only after exact readback", - "Never retry an unconfirmed result.", + `\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json`, + "enumerate and deduplicate the complete unarchived App Server catalog", + `item.outcome === "prepared"`, + `typeof item.title !== "string"`, + "for (const item of prepared)", + "tools.write_stdin({", + "session_id:local.session_id", + "tools.codex_app__read_thread({threadId:item.task_id", + "includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1", + "current?.thread?.title !== item.title", + "tools.codex_app__set_thread_title({", + "threadId:item.task_id", + "title:item.desired_title", + "renamed.threadId === item.task_id", + "renamed.title === item.desired_title", + "notify(`ThreadBear onboarding: ${done}/${prepared.length}`)", + "const accounted = updated + skipped + unconfirmed === prepared.length", + "ready:accounted && unconfirmed === 0", + "onboarding_complete:accounted && unconfirmed === 0", + "unchanged:plan.total - updated - unconfirmed", + "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", + "Never create a cap, wave, controller, worker task, queue, or persistent ThreadBear task.", "## Update", - "truthful rerunnable partial", "`restart_required`", "## Uninstall", - "do not run the title command", + "Do not run the title cell again.", ) + if count := strings.Count(protocol, "tools.codex_app__set_thread_title("); count != 1 { + t.Fatalf("installed skill contains %d native title call sites; want one", count) + } + if count := strings.Count(protocol, "tools.codex_app__read_thread("); count != 1 { + t.Fatalf("installed skill contains %d mounted title read call sites; want one", count) + } + if count := strings.Count(protocol, "tools.exec_command("); count != 1 { + t.Fatalf("installed skill contains %d preparation process call sites; want one", count) + } rejectText(t, protocol, - "codex_app__set_thread_title", + "thread/name/set", + "exact readback", + "Promise.all", + "Promise.race", + "setTimeout", + "ready:unconfirmed === 0", + "rereads every candidate", "PreToolUse", "PostToolUse", "Migration controller", @@ -138,14 +198,36 @@ func TestInstalledSkillStaysACompactOperatingGuide(t *testing.T) { ) } +func TestCurrentDocsNameThePlannerAndSoleMountedWriter(t *testing.T) { + for _, path := range [][]string{ + {"README.md"}, + {"docs", "architecture.md"}, + {"docs", "compatibility.md"}, + {"docs", "status-convention.md"}, + } { + text := readRepoFile(t, path...) + requireText(t, text, "mounted Codex app") + rejectText(t, text, + "only task read/write authority", + "makes at most one `thread/name/set`", + "immediate read/write/readback", + "exact readback", + ) + } +} + func TestHomepageDescribesOnlyShippedCapabilities(t *testing.T) { page := readRepoFile(t, "site", "index.html") requireText(t, page, "One terminal update", - "One direct writer", - "reads the exact title, writes at most once, and verifies exact readback", - "App Server pagination before serial writes", + "The mounted app writes", + "App Server client prepares the safe title; Codex's native setter applies it", + "App Server pagination before serial app-native writes", "no arbitrary first-50 cap", + "read/planning authority only", + "native setter is the sole title writer", + "immediately rereads each prepared task through the mounted app", + "skips drift", "null or blank name", "preview is never adopted", "daily update-only LaunchAgent", @@ -154,10 +236,12 @@ func TestHomepageDescribesOnlyShippedCapabilities(t *testing.T) { "title-core readiness", ) rejectText(t, page, - "codex_app__set_thread_title", + "One direct writer", + "writes at most once, and verifies exact readback", + "only task read/write authority", + "thread/name/set", "PreToolUse", "PostToolUse", - "native setter", "deterministic hook", "automatic archive", "read-only SQLite lookup", diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index 3a8e185..fac4343 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -179,8 +179,8 @@ func (s store) updateTask(id string, change func(*taskState) (bool, error)) erro } // updateTaskUnderFence is only for callers already holding lifecycleFence. -// Keeping the native read, state write, title write, and readback under that -// one shared fence prevents uninstall from completing between those steps. +// Keeping native reads and subject preparation under that shared fence +// prevents uninstall from completing between those steps. func (s store) updateTaskUnderFence(id string, change func(*taskState) (bool, error)) (err error) { lock, err := s.lock(id) if err != nil { diff --git a/docs/README.md b/docs/README.md index 44faba6..54dad84 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,9 @@ # ThreadBear documentation - `README.md` — product and public CLI -- `docs/architecture.md` — direct title writer, subject ownership, onboarding, and updates +- `docs/architecture.md` — title planning, app-native writing, subject ownership, onboarding, and updates - `docs/status-convention.md` — status enum and icon mapping -- `docs/compatibility.md` — supported macOS and Codex App Server behavior +- `docs/compatibility.md` — supported macOS, Codex App Server reads, and app-native title behavior - `docs/live-eval.md` — focused exact-candidate release proof - `docs/experiments/README.md` — title-mechanism registry and experiment preflight - `docs/release-checklist.md` — local, release, and hosted checks diff --git a/docs/architecture.md b/docs/architecture.md index e5c9f8c..4701dba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,18 +5,18 @@ ThreadBear is one Go executable, private per-task subject records, one managed i ## Ordinary turn 1. The task completes its work and writes the substantive response. Any owner or next action stays in that prose. -2. Immediately before the final response, managed guidance runs exactly one local command: `threadbear title --status --json`. -3. The binary requires the current task ID from Codex's environment, starts one bounded official App Server process, reads the exact current title, resolves the exact safe subject, and renders one status icon plus that subject. -4. If the title already matches, the command returns unchanged. Otherwise it sends at most one `thread/name/set` request and rereads the title. Only exact readback is confirmed. -5. The App Server process exits. Failure, timeout, or uncertain acknowledgement is reported locally; the task does not poll, retry, reconcile, or delay its response. +2. Immediately before the final response, managed guidance runs one terminal JavaScript cell containing `threadbear title --status --json`. +3. The binary requires the current task ID, starts one bounded official App Server process, reads the exact native title, resolves and records the safe subject, and returns `desired_title` plus `write_required`. It never writes a task title. +4. If no write is required, the cell exits. Otherwise it calls the mounted Codex app's native `set_thread_title` once with the prepared title and no explicit task ID, so only the calling task can be targeted. +5. The cell accepts success only when the native result returns the planner's exact task ID and desired title. A returned failure, malformed output, or mismatch stays local. If the outer cell yields after 30 seconds, the task waits only for that same running cell; the yield is not cancellation, so a slow native call can delay the response. The task never starts another cell, polls the title, retries, or reconciles. -The enum controls only the icon. The command carries no owner, action, or rewritten task description. +The enum controls only the icon. Neither the planner nor the native call carries an owner, action, or rewritten task description. ## Subject ownership State is keyed by task ID and stores only the exact subject needed to recognize ThreadBear's renderings. There is no stored status, action, original title, pending proposal, controller phase, global failure, or repair marker. -For one title command: +For one title plan: 1. If a subject is stored and the current title byte-matches a valid ThreadBear icon plus that subject, reuse it. 2. Otherwise, treat the exact current title as a user rename when it is safe. @@ -25,21 +25,27 @@ For one title command: Subjects are never normalized, stripped, or truncated. User-authored leading emoji and arrows survive as subject bytes. ThreadBear owns only its exact rendering. -Codex provides no compare-and-swap title primitive. ThreadBear narrows the race with an immediate read/write/readback sequence and no retries. A later safe user rename is adopted on the next turn. If live canaries show practical corruption or response blocking, rewriting is disabled rather than wrapped in reconciliation machinery. +Codex provides no compare-and-swap title primitive. ThreadBear keeps the planner-to-native-call interval to one terminal cell and never retries. A later safe user rename is adopted on the next turn. If live canaries show practical corruption or response blocking, rewriting is disabled instead of wrapped in reconciliation machinery. -## App Server boundary +## Native boundaries -The official `codex app-server --stdio` process is the only task read/write authority. ThreadBear initializes one short-lived client, correlates JSON-RPC response IDs while tolerating notifications, and closes it after the bounded operation. +The official `codex app-server --stdio` process is ThreadBear's read and planning authority only. The binary initializes one short-lived client, correlates JSON-RPC response IDs while tolerating notifications, and closes it before returning a plan. It contains no `thread/name/set` path. -Current-task writing reads the exact native name, sends at most one `thread/name/set`, and performs exact readback. Onboarding first follows every unarchived `thread/list` page, deduplicates task IDs, and returns no plan unless enumeration completes. Native `name` is the user-facing title. A null or blank name is raw and unowned; `preview` is never adopted, persisted, or rendered. +The mounted Codex app's native `set_thread_title` tool is the sole title writer. Current-task calls omit `threadId`; onboarding calls carry one explicit prepared target. Exact returned task ID and title are the acknowledgement. Release acceptance still requires the mounted header and sidebar to render that title. + +Onboarding follows every unarchived `thread/list` page and deduplicates task IDs before any preparation. Native `name` is the user-facing title. A null or blank name is raw and unowned; `preview` is never adopted, persisted, or rendered. ThreadBear does not open Codex SQLite, edit Desktop caches, run an App Server daemon, keep a shared client, use a model, or fall back to another title source. ## Onboarding -`onboard --dry-run --json` is the only read-only onboarding mode. It enumerates the complete catalog before any mutation and reports `total`, `safe`, `needs_update`, and per-item reasons. Enumeration or protocol failure means zero writes. +`onboard --dry-run --json` enumerates the complete catalog without mutation and reports `total`, `safe`, `needs_update`, and per-item reasons. Enumeration or protocol failure means zero writes. + +Mutation requires explicit consent. `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, skips the active caller and unsafe rows, stores the safe subject, and returns one `prepared` action containing the snapshot `title` and `desired_title` for each eligible target. The binary performs no per-target app read and writes no Codex title. + +The installed skill runs preparation and the native pass in one managed JavaScript cell. If the preparation process yields, the cell resumes that same process with `write_stdin`; it never starts another command. Immediately before each possible write, the cell serially reads the prepared target through the mounted app and requires its current title to equal the snapshot `title`. A read failure or drift is `skipped` and receives no write. An exact match receives at most one native title call. Only an exact returned target ID and desired title counts as `updated`; a throw, string error, malformed response, or mismatch is `unconfirmed` and is never retried. -Mutation requires exact explicit consent through `onboard --noninteractive --confirm --json`. The binary starts from a fresh complete snapshot, skips the active caller and unsafe rows, and handles every safe target serially with no cap or waves. Immediately before a possible write it rereads the target and requires its byte-exact snapshot title. Missing, unreadable, drifted, ambiguous, or overlong targets are skipped. Each target receives at most one neutral `🐻 ` write and is counted as updated only after exact readback. An acknowledgement without exact readback is unconfirmed and is never retried. +The final receipt reports the complete catalog and `updated`, `skipped`, `unchanged`, and `unconfirmed` counts. Every prepared item must reach exactly one deliberate outcome. ThreadBear is ready only when all prepared items are accounted for and no call is unconfirmed; skipped tasks are honestly left unchanged. An interrupted pass may leave valid partial decoration. A rerun takes a fresh complete snapshot and continues without a controller, worker task, pending queue, or hidden resume state. @@ -47,12 +53,12 @@ An interrupted pass may leave valid partial decoration. A rerun takes a fresh co Fresh installation writes only the current core artifacts and updater. Codex must restart before open tasks load the new managed guidance. Installation offers onboarding; it never creates a ThreadBear task. -Version 2.2.1 is a clean reset, not a state migration. The preview exposes the old main-task ID and complete automation fingerprint. After explicit consent, the guide verifies and deletes only that automation, then unpins and verifies the exact former persistent task without renaming it. Any mismatch aborts before filesystem reset. The reset removes only exact obsolete ThreadBear Pre/Post title-interception entries, preserves foreign entries and order, imports no old state, and performs no heuristic title cleanup. +Version 2.2.1 is a clean reset, not a state migration. The preview exposes the old main-task ID and complete automation fingerprint. After explicit consent, the guide verifies and deletes only that automation, then unpins and verifies the exact former persistent task without renaming it. Any mismatch aborts before filesystem reset. The reset removes only exact obsolete ThreadBear title-hook entries, preserves foreign entries and order, imports no old state, and performs no heuristic title cleanup. -Uninstall removes ThreadBear-owned files, managed guidance, subject records, and LaunchAgent without waiting for titles to converge. Historical icons may remain. Once removal commits, the task does not run the title command and asks for a Codex restart. +Uninstall removes ThreadBear-owned files, managed guidance, subject records, and LaunchAgent without waiting for titles to converge. Historical icons may remain. Once removal commits, the task does not run the title cell and asks for a Codex restart. ## Verified updates -The daily LaunchAgent runs only `threadbear update`. The updater selects the Darwin architecture from the official release manifest and validates release origin, checksum, embedded version, and candidate self-test before local installation. Network and verification failures happen before writes and leave the old install untouched. Managed surfaces are written individually, with the binary last; a local failure may truthfully report a rerunnable partial. Successful update JSON includes `restart_required`. It never reads tasks or changes titles. +The daily LaunchAgent runs only `threadbear update`. It validates release origin, architecture, checksum, version, and candidate self-test before local installation. Network and verification failures happen before writes and leave the old install untouched. Managed surfaces are written individually, with the binary last; a local failure may truthfully report a rerunnable partial. Successful update JSON includes `restart_required`. The updater never reads tasks or changes titles. `status` computes title-core `ready` from the binary, subject store, managed guidance, and skill. It reports the LaunchAgent separately. Missing automatic updates do not globally fail ordinary title handling. diff --git a/docs/benchmark.md b/docs/benchmark.md index 29dfb5a..c2645c3 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -8,4 +8,4 @@ threadbear onboard --dry-run --json Report App Server page count, elapsed time, total deduplicated unarchived tasks, safe candidates, needed updates, and unchanged tasks by reason. Exercise more than 100 tasks so at least two `thread/list` pages are required. Assert that enumeration applies no arbitrary page or item cap, source-label filter, task mutation, model call, or SQLite access. Include null and blank `name` rows with plausible `preview` text and prove both remain raw and unowned. -Separately benchmark a confirmed serial pass and report updated, unchanged, skipped, and unconfirmed counts. Performance is informative; correctness and complete accounting are acceptance gates. +Separately benchmark confirmed preparation and the serial mounted app-native pass. Report prepared, updated, unchanged, skipped, and unconfirmed counts. Performance is informative; correctness, exact native responses, and complete accounting are acceptance gates. diff --git a/docs/compatibility.md b/docs/compatibility.md index ac2ec9d..dd0a0b5 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,14 +1,16 @@ # Compatibility -ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop's stdio App Server, and the current task ID supplied to terminal commands. Release canaries record the exact Codex version used for proof. +ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop's stdio App Server, the mounted app-native `set_thread_title` tool, and the current task ID supplied to terminal commands. Release canaries record the exact Codex version used for proof. -The terminal writer starts one bounded `codex app-server --stdio` process. It requires an exact current-task match and nonblank native `name`, makes at most one `thread/name/set` request, and requires exact readback to confirm a change. A protocol, ID, process, timeout, unsafe-title, or readback failure stays local and is never retried. +The terminal planner starts one bounded `codex app-server --stdio` process. It requires an exact current-task match and nonblank native `name`, resolves the safe subject, and returns a prepared title without writing it. A protocol, ID, process, timeout, or unsafe-title failure stays local and is never retried. -`onboard --dry-run --json` follows the complete unarchived `thread/list` catalog, tolerates interleaved notifications, and deduplicates IDs. Rows with null or blank `name` remain raw and unowned regardless of `preview`. Any page failure aborts before mutation. `onboard --noninteractive --confirm --json` rereads each candidate and processes the complete safe set serially with no item cap. +When `write_required` is true, the same terminal cell calls the mounted Codex app's native setter once with no explicit task ID. Success requires the exact planned task ID and title in the returned object. A returned throw, string error, malformed response, or mismatch stays local; there is no alternate writer or reconciliation path. If the outer cell yields after 30 seconds, the task waits only for that same running cell. The yield does not cancel a slow native call, which can delay the response; it never starts another cell or retries. + +`onboard --dry-run --json` follows the complete unarchived `thread/list` catalog, tolerates interleaved notifications, and deduplicates IDs. Rows with null or blank `name` remain raw and unowned regardless of `preview`. Any page failure aborts before mutation. After consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, and returns every prepared action with its snapshot title and desired title, no item cap, no per-target app read, and zero title writes. The installed skill resumes only that same preparation process if it yields, then serially reads each prepared target through the mounted app immediately before any explicit-target write. A read failure or title drift is skipped without a write; an exact match receives at most one setter call. ThreadBear never opens Codex SQLite or edits Desktop storage. It runs no App Server daemon or proxy, keeps no App Server cache, uses no model, and has no retry or alternate read/write path. -Visible titles are at most 60 UTF-16 units and never split a surrogate pair. A subject that would not fit intact is left unchanged. ThreadBear does not truncate it. App Server acknowledgement is not rendered-product proof, so every release verifies the active header and sidebar before and after restart. +Visible titles are at most 60 UTF-16 units and never split a surrogate pair. A subject that would not fit intact is left unchanged. ThreadBear does not truncate it. Native acknowledgement is not rendered-product proof, so every release verifies the active header and sidebar before and after restart. The supported public commands are `install`, `title`, `onboard`, `status`, `self-test`, `update`, `uninstall`, and `version`. There is no `inventory`, `migration`, `maintenance`, archive, classifier, controller, or persistent ThreadBear-task API. diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 44d06d9..455109f 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -6,14 +6,13 @@ { "id": "TB-CAP-DIRECT-REPAINT", "premise": "A standalone App Server title write repaints a mounted Codex Desktop task when the result is representable and materially different within the persisted boundary.", - "status": "conditional", - "supported_by": [ - "TB-EXP-0059-003" - ], + "status": "rejected", + "supported_by": [], "contradicted_by": [ - "TB-EXP-0059-001" + "TB-EXP-0059-001", + "TB-EXP-0116-001" ], - "decision": "The 60 UTF-16-unit representation change reconciles the earlier stale-row observation. Reuse this result only for the standalone thread/name/set path and still require rendered proof.", + "decision": "Detached thread/name/set can persist and read back an exact representable title without repainting the mounted Desktop catalog. TB-EXP-0059-003 did not isolate this boundary: its only accessibility proof followed Codex-hosted native setters, while its later standalone probes used list_threads rather than rendered inspection. ThreadBear must use the mounted Codex native setter for visible title mutation.", "next_preflight": null }, { @@ -183,17 +182,64 @@ "git:a3dcdc0798737d4cbd1cde249a4922751649cabd" ], "result": { - "status": "observed", - "summary": "Titles that differed inside the 60 UTF-16-unit limit repainted active and unloaded tasks immediately. Earlier overlong proposals had differed only beyond the persisted prefix, creating a false cache diagnosis.", - "timing_ms": "unknown: live proof recorded immediate repaint, not a numeric latency", + "status": "invalidated", + "summary": "The rollout does not establish standalone repaint. Its only captured accessibility proof followed three Codex-hosted set_thread_title calls. Later standalone canaries were followed by app-native list_threads observations with no post-standalone accessibility capture, and the final scheduled-heartbeat claim also lacked rendered evidence.", + "timing_ms": "unknown: no isolated standalone-to-rendered interval was captured", "hook_participation": "none", - "rendered_proof": "mounted Desktop task repainted; unnecessary shared-daemon and model-actuator machinery was removed" + "rendered_proof": "insufficient: native-setter contamination and no accessibility-tree or screenshot proof after the standalone writes" }, - "confidence": "high", - "applicability": "Standalone bounded thread/name/set only. This reconciles TB-EXP-0059-001 by isolating representability; it does not establish current-task native hook participation.", + "confidence": "low", + "applicability": "Historical implementation and persistence evidence only. Do not use this record as support for mounted standalone repaint or to override TB-EXP-0059-001 and TB-EXP-0116-001.", "supersedes": [], "conflicts": [ - "TB-EXP-0059-001" + "TB-EXP-0059-001", + "TB-EXP-0116-001" + ] + }, + { + "id": "TB-EXP-0116-001", + "date": "2026-08-07", + "issue": "BEAR-116", + "preflight_id": null, + "question": "Do exact detached title writes repaint the mounted Codex Desktop task catalog, and does the mounted Codex native setter repaint the same catalog under a reversible control?", + "invariant": "Exact persistence and readback are provisional; user-visible success requires separate mounted header and sidebar evidence.", + "environment": { + "threadbear_version": "3.0.0 exact local candidate; binary SHA-256 c34ecfbee913fe62780c68f9ade85d69c4fceed332047af54e7f423a7c8ea968", + "git_sha": "a7765477f2b80db5efe347a0e8577570d0cb212d", + "codex_version": "Desktop 26.803.41515 build 6321; embedded CLI 0.147.0-alpha.6.5; detached CLI 0.146.0", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "one active mounted task plus visible mounted historical project rows; confirmed onboarding prepared and detached-wrote 190 safe historical titles across a complete 360-task catalog", + "restart_state": "detached current-task write was inspected before restart and after a clean Codex restart; historical rows were inspected before restart and after project collapse and expansion", + "hook_fingerprint": "no candidate title hook; installed hooks inventory SHA-256 43d2619e3510054cc165f9b1f7915e16c20a162fa4427d2ed0b20294141ddf04 remained unchanged", + "guidance_fingerprint": "unknown: installed managed guidance hash was not separately recorded for this seam observation" + }, + "invocation": { + "outer_tool": "exact ThreadBear binary through functions.exec, read-only Computer Use inspection, and one reversible Codex-hosted native setter control", + "code": "one detached current-task thread/name/set with exact readback; one confirmed full-catalog serial detached onboarding pass; mounted inspection before and after collapse and expansion; clean restart; one distinct app-native explicit-target title followed by exact restoration", + "native_tool_identity": "thread/name/set for detached writes; codex_app__set_thread_title for the reversible mounted control", + "target_identity_mode": "exact active caller ID for the terminal write and authoritative explicit task IDs for onboarding and the reversible control" + }, + "evidence": [ + "linear:BEAR-116", + "github-pr:124", + "git:a7765477f2b80db5efe347a0e8577570d0cb212d", + "codex-rollout:019fdcce-d0dc-7522-ad8e-a0c3c1c02110" + ], + "result": { + "status": "observed", + "summary": "Detached current-task and onboarding writes persisted and read back exactly, but the mounted header and neighboring historical sidebar rows stayed stale; collapsing and expanding the project did not refresh them. A clean restart rendered the persisted current title. On one historical row, a distinct Codex-hosted native setter repainted immediately and exact restoration repainted immediately, while untouched neighboring detached-written rows remained stale.", + "timing_ms": "unknown: the seam was observed immediately and across a clean restart, but numeric repaint latency was not the measured variable", + "hook_participation": "none for detached writes; the reversible control used the mounted Codex native setter directly", + "rendered_proof": "Privacy-safe evidence retained outside the repository: pre-restart current-task SHA-256 d009a3c0a4c7746a0b21e85b3fed3ddc7b49688f3b04e6000de8dbee0da493bf; post-restart current-task SHA-256 2163b0d896552050ff72f7e733fd183cd31dc4073be92d9a51917b9597de3575; initial stale onboarding SHA-256 ebd55474edb4f4c9d2ba22e01a89ffeb14a5bfdcd8d56bea805b9bd3f3537bf9; collapse-expand stale SHA-256 e4eb9b7de5eb63991a0def1f5923f6facbd7656735c11c575da0cd6f04a33322; app-native repaint and restoration SHA-256 56956b07eb12f49e0f6ab76065588bc0fc3b1465881390234130ed5f14aa7983" + }, + "confidence": "high", + "applicability": "Establishes the detached persistence-versus-mounted-render boundary for exact candidate a7765477 on the recorded Desktop build and catalog, and independently confirms the mounted native setter control. It does not establish the corrected product path or release readiness.", + "supersedes": [ + "TB-EXP-0059-003" + ], + "conflicts": [ + "TB-EXP-0059-003" ] }, { diff --git a/docs/live-eval.md b/docs/live-eval.md index e6fc4b5..7c9823f 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -2,31 +2,35 @@ Run release QA with the exact reviewed candidate in fresh Codex Desktop tasks after a clean restart. Unit tests, App Server responses, and local state are supporting evidence; verify the rendered active header and sidebar users actually see. -Record candidate checksum, Codex version, task IDs, inputs, App Server results, rendered results, restart results, and cleanup. Use recoverable test tasks and privacy-safe screenshots only for release QA, never ordinary installation. +Record candidate checksum, Codex version, task IDs, planner results, app-native results, rendered results, restart results, and cleanup. Use recoverable test tasks and privacy-safe screenshots only for release QA, never ordinary installation. -## Terminal title writer +## Terminal title cell Exercise `complete`, `next_steps`, `needs_input`, `blocked`, and `automation`. Include one tool-free turn and one tool-using turn. For each, prove: - there was no running title update; -- the one local `threadbear title --status ENUM --json` command was the last tool action before the final response; +- one terminal JavaScript cell was the last tool action before the final response; +- that cell ran exactly one local `threadbear title --status ENUM --json` planner and parsed its complete JSON only after exit zero; - the enum changed only the icon while the exact subject survived; - owners and actions remained in response prose; -- the command exited within its bound and was never polled, retried, or recovered; -- App Server acknowledgement and exact readback agreed; +- the planner wrote no Codex title; +- when `write_required` was true, the cell made exactly one mounted app-native call with `threadId` omitted and accepted only the exact planned task ID and title; +- if the outer cell yielded after 30 seconds, the agent waited only for that same running cell; it never started another cell, polled the title, retried, or reconciled; - the active header and sidebar showed the exact expected title. Exercise a generated short title, continued task, user rename, leading user emoji, user arrow, duplicate subject, maximum fitting subject, overlong subject, multiline or control text, and raw delegated envelope. Safe renames must survive byte-for-byte. Unsafe input must leave only that title unchanged without blocking the response. -Force App Server start, initialize, current-read, set, readback, and exit failures; missing or malformed current task ID; null and blank `name`; acknowledgement without readback; timeout; and a rename concurrent with a delayed write. Require at most one `thread/name/set` call and no blind retry, alternate source, repair command, pending proposal, or global failure. If this seam causes practical corruption or response blocking, disable rewriting rather than add reconciliation. +Force planner App Server start, initialize, current-read, and exit failures; missing or malformed current task ID; null and blank `name`; malformed planner JSON; and a planner-to-native-call rename race. For the mounted writer, cover a throw, returned error string, malformed object, wrong task ID, wrong title, and a slow call that outlasts the initial 30-second outer yield before returning. Require the yielded case to resume only the same running cell. Require zero binary `thread/name/set` calls, at most one app-native call, and no blind retry, alternate source, repair command, pending proposal, or global failure. If this seam causes practical corruption or response blocking, disable rewriting rather than add reconciliation. Restart Codex after a successful write. Confirm the exact title remains in the sidebar and the next terminal turn still preserves the subject. ## Onboarding -For `onboard --dry-run --json`, prove the exact App Server handshake and cursor protocol. Include more than 100 tasks so the catalog is larger than 50 and necessarily multi-page; inject notifications and a duplicate ID. Prove complete deduplication, no arbitrary cap, no model or SQLite access, and zero mutation. Null and blank names remain raw even when `preview` looks safe. Fail a later page and prove zero writes because no partial plan escaped. +For `onboard --dry-run --json`, prove the exact App Server handshake and cursor protocol. Include more than 100 tasks so the catalog is larger than 50 and necessarily multi-page; inject notifications and a duplicate ID. Prove complete deduplication, no arbitrary cap, no model or SQLite access, and zero mutation. Null and blank names remain raw even when `preview` looks safe. Fail a later page and prove zero preparation or native calls because no partial plan escaped. -After explicit consent, run exact `onboard --noninteractive --confirm --json`. Prove every safe target is handled serially and every returned item is accounted for as updated, unchanged, skipped, or unconfirmed. Cover the active caller, null and blank names, unreadable and drifted tasks, ambiguous old status prefixes, overlong text, user emoji, already-onboarded titles, setter failure, and acknowledgement without exact readback. Each safe target receives at most one neutral `🐻 ` write after fresh readback. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. +After explicit consent, run exact `onboard --noninteractive --confirm --json`. Prove it starts from a fresh complete snapshot, stores subjects only for safe snapshot titles, emits `prepared` actions containing snapshot `title` and `desired_title`, performs no per-target app read, and makes zero Codex title writes. Cover the active caller, null and blank names, ambiguous old status prefixes, overlong text, user emoji, and already-onboarded titles. Force the preparation command to yield and prove the exact embedded JavaScript resumes that same process through `write_stdin` without starting a second command. + +Run the installed skill's one serial native loop. Immediately before each possible write, require one mounted-app `read_thread` call with `includeOutputs:false`, `turnLimit:1`, and `maxOutputCharsPerItem:1`. A missing or unreadable task or a title that differs from the prepared snapshot is `skipped` and receives no setter call. Every exact match receives at most one explicit-target setter call for `🐻 `. Validate the exact returned ID/title and cover a throw, error string, malformed response, wrong target, and wrong title. Count every non-exact setter result as `unconfirmed` without retry. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. Require serial read-before-write ordering, progress during preparation and every 25 outcomes, and a final receipt where every prepared row is exactly one of `updated`, `skipped`, or `unconfirmed`. Report `unchanged` honestly and report ready only when all prepared rows are accounted for and `unconfirmed` is zero. Live-test the complete real local catalog with no artificial first-50 subset. Verify the rendered sidebar before and after a clean restart. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d14ffcb..bcddd53 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -2,16 +2,16 @@ Before tagging a stable release: -1. Run `python3 scripts/validate-experiments.py`. Cite current capability and seam records for App Server current-title control, pagination, and restart behavior. Validator success is not semantic approval; resolve contradictory evidence. +1. Run `python3 scripts/validate-experiments.py`. Cite current capability and seam records for App Server planning, mounted app-native title control, pagination, and restart behavior. Validator success is not semantic approval; resolve contradictory evidence. 2. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. 3. Run `gofmt`, `go test ./...`, `go test -race ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity. Review the diff for unnecessary machinery; do not substitute a physical line-count gate for that judgment. 4. In isolated homes, prove fresh install, reinstall, dry-run collisions, status, update, uninstall, and the consented exact 2.2.1 reset while preserving unrelated AGENTS content, skills, settings, files, automations, and LaunchAgents. Confirm reset verifies automation deletion and exact-task unpin before filesystem mutation, imports no old state, and never renames the former persistent task. -5. Prove one bounded `threadbear title --status ENUM --json` call is the last tool action before the final response. Require the exact current task ID and native name, at most one `thread/name/set`, and exact readback. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, timeout, unconfirmed acknowledgement, and the rename race. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. -6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` handles every safe target serially with no cap and accounts honestly for updated, unchanged, skipped, and unconfirmed outcomes. +5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one local `threadbear title --status ENUM --json` planner, parse only complete exit-zero JSON, make zero binary title writes, and make at most one mounted app-native call with `threadId` omitted. Require exact returned planned task ID/title. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, malformed output, native throws or non-exact returns, the rename race, and a native call that outlasts the initial 30-second outer yield. In the yielded case, prove the agent waits only for that same cell; the yield is not cancellation, the slow call may delay the response, and no second cell starts. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. +6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, prepares every eligible target with snapshot `title` and `desired_title`, has no cap, performs no per-target app read, and writes zero titles. Prove the exact embedded JavaScript resumes a yielded preparation process through `write_stdin` on the same session and starts no second process. Then prove it serially reads each prepared target through the mounted app immediately before any write, skips missing, unreadable, or drifted targets without a setter call, makes at most one explicit-target setter call for an exact match, validates exact returned ID/title, never retries, reports progress, and accounts every prepared item as updated, skipped, or unconfirmed. `ready` and `onboarding_complete` require complete accounting and zero unconfirmed; skipped rows remain honestly unchanged. 7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Verify active-header and sidebar rendering before and after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. 8. Prove the daily LaunchAgent invokes only the verified updater. Network and verification failures preserve the old install; local write failures report `partial`, stage, restart implication, and one safe rerun with binary last; success reports `restart_required`. Prove updater health is separate from core `ready` and update/uninstall races are serialized. After uninstall commit, do not run the title command. 9. Confirm `assets/skill/SKILL.md` is below 5 KB, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. Before the local Desktop canary, run the exact binary through the isolated lifecycle smoke with `THREADBEAR_SMOKE_CANDIDATE=/absolute/path/to/threadbear scripts/release-smoke.sh vN.N.N`. This mode skips only the not-yet-published download; the tagged release workflow runs the same smoke through the hosted installer and release assets. -After tagging, verify the Git tag and GitHub Release, both Darwin binaries, checksums, manifest, and hosted bootstrap. Run the hosted smoke through `threadbear.sh`, including candidate self-test, direct writer acknowledgement/readback, multi-page onboarding and failure-before-writes, exact reset preflight, update isolation, and complete uninstall. Confirm hosted `/install` bytes match the reviewed guide before announcing publication. +After tagging, verify the Git tag and GitHub Release, both Darwin binaries, checksums, manifest, and hosted bootstrap. Run the hosted smoke through `threadbear.sh`, including candidate self-test, planner-only proof, multi-page onboarding preparation and failure-before-writes, exact reset preflight, update isolation, and complete uninstall. In Codex Desktop, separately prove exact app-native acknowledgement and mounted rendering. Confirm hosted `/install` bytes match the reviewed guide before announcing publication. diff --git a/docs/status-convention.md b/docs/status-convention.md index 33080a8..1e74f2d 100644 --- a/docs/status-convention.md +++ b/docs/status-convention.md @@ -1,6 +1,6 @@ # Status convention -Immediately before an ordinary final response, ThreadBear's managed guidance runs one of: +Immediately before an ordinary final response, ThreadBear's managed guidance runs one terminal cell whose local planner receives one of: ```text threadbear title --status complete --json @@ -20,7 +20,7 @@ The status maps to one owned icon: | `blocked` | `🚨 ` | | `automation` | `🤖 ` | -The enum controls only the icon. Any owner or next action stays in the substantive response. There is no special ThreadBear line appended to the response and no running icon. Ordinary turns never emit the neutral onboarding mark `🐻`. +The enum controls only the icon. The planner writes no Codex title; when a change is needed, the same cell makes one native title call through the mounted Codex app and accepts only the exact returned task ID and title. Any owner or next action stays in the substantive response. There is no special ThreadBear line appended to the response and no running icon. Ordinary turns never emit the neutral onboarding mark `🐻`. ThreadBear reuses its stored subject when the current title byte-matches a valid owned rendering. Any other safe current title is a user rename and becomes the exact subject, including user-authored emoji and arrows. A null or blank native name is raw and stays unchanged; `preview` is never adopted. Multiline, control-bearing, raw internal, ambiguous unowned legacy-prefixed, or overlong subjects also stay unchanged. ThreadBear never normalizes, strips, or truncates a subject. diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index 6e2d471..9bc7324 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -30,12 +30,13 @@ agent_path=$home/Library/LaunchAgents/$agent_label.plist fake_codex=$home/.local/bin/codex app_server_log=$root/app-server.jsonl app_server_state=$root/app-server-state.json +native_tool_log=$root/native-tool.jsonl +simulate_mounted=$root/simulate-mounted.py current_id=00000000-0000-4000-8000-000000000001 raw_id=00000000-0000-4000-8000-000000000002 delegated_id=00000000-0000-4000-8000-000000000003 blank_id=00000000-0000-4000-8000-000000000004 -drift_id=10000000-0000-4000-8000-000000000001 -failed_id=10000000-0000-4000-8000-000000000002 +mounted_drift_id=10000000-0000-4000-8000-000000000002 unconfirmed_id=10000000-0000-4000-8000-000000000003 cleanup() { @@ -134,9 +135,6 @@ current_id = "00000000-0000-4000-8000-000000000001" raw_id = "00000000-0000-4000-8000-000000000002" delegated_id = "00000000-0000-4000-8000-000000000003" blank_id = "00000000-0000-4000-8000-000000000004" -drift_id = "10000000-0000-4000-8000-000000000001" -failed_id = "10000000-0000-4000-8000-000000000002" -unconfirmed_id = "10000000-0000-4000-8000-000000000003" def initial_threads(): value = [ @@ -186,13 +184,6 @@ else: target.write("\n") by_id = {thread["id"]: thread for thread in threads} -current_page_count = 0 - -def save(): - with open(state_path, "w", encoding="utf-8") as target: - json.dump({"threads": threads}, target, separators=(",", ":")) - target.write("\n") - def send(value): print(json.dumps(value, separators=(",", ":")), flush=True) @@ -216,7 +207,6 @@ for encoded in sys.stdin: "sortKey": "recency_at", "sortDirection": "desc", }: - current_page_count += 1 current_page = [ dict(by_id[blank_id]), dict(by_id[delegated_id]), @@ -225,11 +215,6 @@ for encoded in sys.stdin: ] send({"method": "fixture/notification", "params": {"stage": "current-page"}}) send({"id": request_id, "result": {"data": current_page, "nextCursor": "must-not-follow"}}) - if mode == "current-rename-race" and current_page_count == 1: - by_id[current_id]["name"] = "User rename during the no-CAS window" - save() - with open(log_path, "a", encoding="utf-8") as target: - target.write(json.dumps({"fixture": "external-rename", "name": by_id[current_id]["name"]}) + "\n") elif params == {"archived": False, "limit": 100}: send({"method": "fixture/notification", "params": {"stage": "page-1"}}) send({"id": request_id, "result": {"data": threads[:100], "nextCursor": "page-2"}}) @@ -246,31 +231,127 @@ for encoded in sys.stdin: send({"id": request_id, "error": {"code": -32602, "message": "unexpected read request"}}) else: thread = dict(by_id[thread_id]) - if mode == "onboarding-edge" and thread_id == drift_id: - thread["name"] = "Renamed while onboarding" send({"id": request_id, "result": {"thread": thread}}) elif method == "thread/name/set": - thread_id = params.get("threadId") - name = params.get("name") - if thread_id not in by_id or not isinstance(name, str): - send({"id": request_id, "error": {"code": -32602, "message": "unexpected set request"}}) - elif mode == "onboarding-edge" and thread_id == failed_id: - send({"id": request_id, "error": {"code": -32001, "message": "injected set failure"}}) - elif ( - mode == "current-unconfirmed" and thread_id == current_id - ) or ( - mode == "onboarding-edge" and thread_id == unconfirmed_id - ): - send({"id": request_id, "result": {}}) - else: - by_id[thread_id]["name"] = name - save() - send({"id": request_id, "result": {}}) + send({"id": request_id, "error": {"code": -32099, "message": "production binary must not call thread/name/set"}}) else: send({"id": request_id, "error": {"code": -32601, "message": "unexpected method"}}) PY chmod 700 "$fake_codex" +cat >"$simulate_mounted" <<'PY' +#!/usr/bin/env python3 +import json +import sys + +mode, plan_path, state_path, log_path, output_path, fail_id, drift_id = sys.argv[1:] +plan = json.load(open(plan_path, encoding="utf-8")) +state = json.load(open(state_path, encoding="utf-8")) +by_id = {thread["id"]: thread for thread in state["threads"]} + +def save(): + with open(state_path, "w", encoding="utf-8") as target: + json.dump(state, target, separators=(",", ":")) + target.write("\n") + +def set_title(task_id, title, explicit): + params = {"title": title} + if explicit: + params["threadId"] = task_id + record = {"method": "codex_app__set_thread_title", "params": params} + if task_id == fail_id: + record["error"] = "injected mounted setter failure" + with open(log_path, "a", encoding="utf-8") as target: + target.write(json.dumps(record, sort_keys=True) + "\n") + return None + if task_id not in by_id or not isinstance(title, str): + raise SystemExit("invalid simulated native setter input") + by_id[task_id]["name"] = title + save() + response = {"threadId": task_id, "title": title} + record["response"] = response + with open(log_path, "a", encoding="utf-8") as target: + target.write(json.dumps(record, sort_keys=True) + "\n") + return response + +def read_title(task_id): + if task_id not in by_id: + return None + if task_id == drift_id: + by_id[task_id]["name"] = "User rename at mounted revalidation" + save() + response = {"thread": {"id": task_id, "title": by_id[task_id].get("name")}} + record = { + "method": "codex_app__read_thread", + "params": { + "threadId": task_id, + "includeOutputs": False, + "turnLimit": 1, + "maxOutputCharsPerItem": 1, + }, + "response": response, + } + with open(log_path, "a", encoding="utf-8") as target: + target.write(json.dumps(record, sort_keys=True) + "\n") + return response + +if mode == "current": + if plan.get("ready") is not True or not isinstance(plan.get("write_required"), bool): + raise SystemExit("invalid current title plan") + if not plan["write_required"]: + result = plan + else: + task_id = plan.get("task_id") + desired = plan.get("desired_title") + if not isinstance(task_id, str) or not isinstance(desired, str): + raise SystemExit("invalid current write plan") + response = set_title(task_id, desired, False) + if response is None: + result = {"ready": False, "reason": "Codex title write failed"} + elif response.get("threadId") != task_id or response.get("title") != desired: + result = {"ready": False, "reason": "Codex title write was not confirmed exactly"} + else: + result = {"ready": True, "task_id": task_id, "title": response["title"], "updated": True} +elif mode == "onboard": + if plan.get("ready") is not True or plan.get("plan_complete") is not True or plan.get("read_only") is not False or not isinstance(plan.get("items"), list): + raise SystemExit("invalid onboarding plan") + prepared = [item for item in plan["items"] if item.get("outcome") == "prepared"] + if any(not isinstance(item.get("task_id"), str) or not isinstance(item.get("title"), str) or not isinstance(item.get("desired_title"), str) for item in prepared): + raise SystemExit("invalid prepared onboarding item") + updated = 0 + skipped = 0 + unconfirmed = 0 + for item in prepared: + current = read_title(item["task_id"]) + if current is None or current.get("thread", {}).get("title") != item["title"]: + skipped += 1 + continue + response = set_title(item["task_id"], item["desired_title"], True) + if response is not None and response.get("threadId") == item["task_id"] and response.get("title") == item["desired_title"]: + updated += 1 + else: + unconfirmed += 1 + total = plan["total"] if isinstance(plan.get("total"), int) else len(plan["items"]) + accounted = updated + skipped + unconfirmed == len(prepared) + result = { + "ready": accounted and unconfirmed == 0, + "plan_complete": True, + "onboarding_complete": accounted and unconfirmed == 0, + "total": total, + "updated": updated, + "skipped": skipped, + "unchanged": total - updated - unconfirmed, + "unconfirmed": unconfirmed, + } +else: + raise SystemExit("unknown mounted simulation mode") + +with open(output_path, "w", encoding="utf-8") as target: + json.dump(result, target, separators=(",", ":")) + target.write("\n") +PY +chmod 700 "$simulate_mounted" + run_threadbear_with_caller() { caller=$1 shift @@ -529,9 +610,23 @@ import sys text = open(sys.argv[1], encoding="utf-8").read() assert text.count("title --status STATUS --json") == 1, text -assert "codex_app__set_thread_title" not in text, text +assert text.count("tools.codex_app__set_thread_title") == 1, text +assert "plan.write_required" in text, text +assert "thread/name/set" not in text, text assert "PreToolUse" not in text and "PostToolUse" not in text, text PY +python3 - "$codex_home/skills/threadbear/SKILL.md" <<'PY' +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +assert text.count("tools.codex_app__set_thread_title") == 1, text +assert text.count("tools.codex_app__read_thread") == 1, text +assert text.count("tools.write_stdin") == 1, text +assert 'item.outcome === "prepared"' in text, text +assert "current?.thread?.title !== item.title" in text, text +assert "updated + skipped + unconfirmed === prepared.length" in text, text +assert "thread/name/set" not in text, text +PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || fail "status or verification changed hooks.json" @@ -583,11 +678,13 @@ PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || fail "current reinstall changed hooks.json" -# The terminal writer performs one exact current read, one name set, and exact -# readback. It persists only the safe subject. +# The production binary plans from one current-list read and performs no title +# write. The second fixture boundary simulates the exact mounted native setter +# cell installed into Codex guidance. : >"$app_server_log" -run_threadbear title --status complete --json >"$root/title-complete.json" -python3 - "$root/title-complete.json" "$current_id" <<'PY' +: >"$native_tool_log" +run_threadbear title --status complete --json >"$root/title-complete-plan.json" +python3 - "$root/title-complete-plan.json" "$current_id" <<'PY' import json import sys @@ -599,28 +696,36 @@ assert value == { "status": "complete", "previous_title": "Release smoke exact subject", "desired_title": "✅ Release smoke exact subject", - "title": "✅ Release smoke exact subject", - "updated": True, + "write_required": True, "unchanged": False, - "unconfirmed": False, + "reason": "app-native title write required", }, value PY -python3 - "$app_server_log" "$current_id" <<'PY' +python3 - "$app_server_log" <<'PY' import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -current_id = sys.argv[2] -methods = [message["method"] for message in messages] -assert methods == ["initialize", "initialized", "thread/list", "thread/name/set", "thread/list"], messages -lists = [message for message in messages if message["method"] == "thread/list"] -expected = {"archived": False, "limit": 25, "sortKey": "recency_at", "sortDirection": "desc"} -assert [message["params"] for message in lists] == [expected, expected], lists -assert [message["id"] for message in lists] == [2, 4], lists -setter = next(message for message in messages if message["method"] == "thread/name/set") -assert setter["id"] == 3, setter -assert setter["params"] == {"threadId": current_id, "name": "✅ Release smoke exact subject"}, setter -assert all("cursor" not in message["params"] and "searchTerm" not in message["params"] for message in lists) +assert [message["method"] for message in messages] == ["initialize", "initialized", "thread/list"], messages +request = messages[2] +assert request["id"] == 2, request +assert request["params"] == {"archived": False, "limit": 25, "sortKey": "recency_at", "sortDirection": "desc"}, request +assert not any(message.get("method") == "thread/name/set" for message in messages), messages +PY +"$simulate_mounted" current "$root/title-complete-plan.json" "$app_server_state" "$native_tool_log" "$root/title-complete.json" "" "" +python3 - "$root/title-complete.json" "$native_tool_log" "$current_id" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +calls = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +task_id = sys.argv[3] +assert value == {"ready": True, "task_id": task_id, "title": "✅ Release smoke exact subject", "updated": True}, value +assert calls == [{ + "method": "codex_app__set_thread_title", + "params": {"title": "✅ Release smoke exact subject"}, + "response": {"threadId": task_id, "title": "✅ Release smoke exact subject"}, +}], calls PY python3 - "$state_dir/subjects/$current_id.json" <<'PY' import json @@ -643,77 +748,66 @@ import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is False and "CODEX_THREAD_ID" in value["error"], value -assert value["updated"] is False and value["unconfirmed"] is False, value +assert value["write_required"] is False and value["desired_title"] == "", value PY test ! -s "$app_server_log" || fail "missing caller started the App Server" +# A mounted setter failure is local to the managed cell. It gets one native +# attempt, no detached fallback, and no reconciliation or retry. : >"$app_server_log" -if THREADBEAR_SMOKE_APP_SERVER_MODE=current-unconfirmed \ - run_threadbear title --status next_steps --json >"$root/title-unconfirmed.json"; then - fail "title command accepted acknowledgement without exact readback" -fi -unset THREADBEAR_SMOKE_APP_SERVER_MODE -python3 - "$root/title-unconfirmed.json" <<'PY' +: >"$native_tool_log" +run_threadbear title --status next_steps --json >"$root/title-next-plan.json" +"$simulate_mounted" current "$root/title-next-plan.json" "$app_server_state" "$native_tool_log" "$root/title-next-failed.json" "$current_id" "" +python3 - "$root/title-next-plan.json" "$root/title-next-failed.json" "$native_tool_log" <<'PY' import json import sys -value = json.load(open(sys.argv[1], encoding="utf-8")) -assert value["ready"] is False and value["unconfirmed"] is True, value -assert value["previous_title"] == "✅ Release smoke exact subject", value -assert value["desired_title"] == "➡️ Release smoke exact subject", value -assert "not confirmed by exact readback" in value["reason"], value -assert value["updated"] is False and value["unchanged"] is False, value +plan = json.load(open(sys.argv[1], encoding="utf-8")) +result = json.load(open(sys.argv[2], encoding="utf-8")) +calls = [json.loads(line) for line in open(sys.argv[3], encoding="utf-8")] +assert plan["ready"] is True and plan["write_required"] is True, plan +assert plan["previous_title"] == "✅ Release smoke exact subject", plan +assert plan["desired_title"] == "➡️ Release smoke exact subject", plan +assert result == {"ready": False, "reason": "Codex title write failed"}, result +assert len(calls) == 1 and calls[0]["params"] == {"title": "➡️ Release smoke exact subject"}, calls +assert calls[0]["error"] == "injected mounted setter failure", calls PY python3 - "$app_server_log" <<'PY' import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -assert [message["method"] for message in messages].count("thread/name/set") == 1, messages -PY - -run_threadbear title --status automation --json >"$root/title-automation.json" -python3 - "$root/title-automation.json" <<'PY' -import json -import sys - -value = json.load(open(sys.argv[1], encoding="utf-8")) -assert value["ready"] is True and value["updated"] is True, value -assert value["title"] == "🤖 Release smoke exact subject", value +assert [message.get("method") for message in messages].count("thread/list") == 1, messages +assert not any(message.get("method") == "thread/name/set" for message in messages), messages PY -# Codex has no compare-and-set between the immediate read and the one write. -# Force that accepted ordering and prove it remains one bounded write with no -# retry or reconciliation. The ordinary real-Desktop canary is the practical -# kill-switch gate; this fixture records the unavoidable protocol semantics. : >"$app_server_log" -THREADBEAR_SMOKE_APP_SERVER_MODE=current-rename-race \ - run_threadbear title --status blocked --json >"$root/title-rename-race.json" -unset THREADBEAR_SMOKE_APP_SERVER_MODE -python3 - "$root/title-rename-race.json" <<'PY' +: >"$native_tool_log" +run_threadbear title --status automation --json >"$root/title-automation-plan.json" +"$simulate_mounted" current "$root/title-automation-plan.json" "$app_server_state" "$native_tool_log" "$root/title-automation.json" "" "" +python3 - "$root/title-automation-plan.json" "$root/title-automation.json" "$native_tool_log" <<'PY' import json import sys -value = json.load(open(sys.argv[1], encoding="utf-8")) +plan = json.load(open(sys.argv[1], encoding="utf-8")) +value = json.load(open(sys.argv[2], encoding="utf-8")) +calls = [json.loads(line) for line in open(sys.argv[3], encoding="utf-8")] +assert plan["previous_title"] == "✅ Release smoke exact subject", plan +assert plan["desired_title"] == "🤖 Release smoke exact subject", plan assert value["ready"] is True and value["updated"] is True, value -assert value["previous_title"] == "🤖 Release smoke exact subject", value -assert value["desired_title"] == "🚨 Release smoke exact subject", value -assert value["title"] == "🚨 Release smoke exact subject", value -assert value["unconfirmed"] is False, value +assert value["title"] == "🤖 Release smoke exact subject", value +assert len(calls) == 1, calls PY python3 - "$app_server_log" <<'PY' import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -assert [message for message in messages if message.get("fixture") == "external-rename"] == [ - {"fixture": "external-rename", "name": "User rename during the no-CAS window"} -], messages -assert [message.get("method") for message in messages].count("thread/name/set") == 1, messages -assert [message.get("method") for message in messages].count("thread/list") == 2, messages +assert [message.get("method") for message in messages].count("thread/list") == 1, messages +assert not any(message.get("method") == "thread/name/set" for message in messages), messages PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || - fail "direct title writer changed hooks.json" + fail "title planning or mounted setter simulation changed hooks.json" # Full enumeration must finish before any historical write. find "$state_dir/subjects" -type f -exec shasum -a 256 {} \; | LC_ALL=C sort >"$root/subjects.before-failed-page" @@ -731,7 +825,7 @@ import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is False and "thread/list page 2" in value["error"], value assert value["plan_complete"] is False and value["total"] == 0, value -assert value["items"] is None and value["updated"] == 0 and value["unconfirmed"] == 0, value +assert value["items"] is None and value["prepared"] == 0 and value["needs_update"] == 0, value PY find "$state_dir/subjects" -type f -exec shasum -a 256 {} \; | LC_ALL=C sort >"$root/subjects.after-failed-page" cmp "$root/subjects.before-failed-page" "$root/subjects.after-failed-page" >/dev/null || @@ -763,8 +857,7 @@ assert value["ready"] is True and value["plan_complete"] is True and value["read assert value["onboarding_complete"] is False, value assert value["total"] == len(value["items"]) == 109, value assert value["safe"] == 107 and value["needs_update"] == 106, value -assert value["updated"] == 0 and value["unchanged"] == 1, value -assert value["skipped"] == 2 and value["unconfirmed"] == 0, value +assert value["prepared"] == 0 and value["unchanged"] == 1 and value["skipped"] == 2, value assert [item["task_id"] for item in value["items"]] == sorted(item["task_id"] for item in value["items"]) by_id = {item["task_id"]: item for item in value["items"]} assert by_id[current_id]["outcome"] == "unchanged", by_id[current_id] @@ -781,48 +874,93 @@ messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages PY -# One confirmed pass handles the entire safe set serially. Synthetic drift, -# setter failure, and acknowledgement-without-readback stay local and appear in -# the aggregate receipt. The active caller is never neutralized. +# One confirmed production pass prepares subjects from the complete snapshot +# without per-target RPCs. The mounted-native simulation then rereads every +# prepared title immediately before one possible setter; drift skips the write, +# and one injected setter failure proves exact accounting and no retry. : >"$app_server_log" -if THREADBEAR_SMOKE_APP_SERVER_MODE=onboarding-edge \ - run_threadbear_with_caller "$delegated_id" onboard --noninteractive --confirm --json >"$root/onboard-edge.json"; then - fail "edge onboarding reported complete despite unconfirmed targets" -fi -unset THREADBEAR_SMOKE_APP_SERVER_MODE -python3 - "$root/onboard-edge.json" "$delegated_id" "$drift_id" "$failed_id" "$unconfirmed_id" <<'PY' +: >"$native_tool_log" +app_state_before_preparation=$(shasum -a 256 "$app_server_state" | awk '{print $1}') +run_threadbear_with_caller "$delegated_id" onboard --noninteractive --confirm --json >"$root/onboard-prepared-edge.json" +python3 - "$root/onboard-prepared-edge.json" "$delegated_id" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) -delegated_id, drift_id, failed_id, unconfirmed_id = sys.argv[2:] -assert value["ready"] is False and value["plan_complete"] is True, value +delegated_id = sys.argv[2] +assert value["ready"] is True and value["plan_complete"] is True, value assert value["read_only"] is False and value["onboarding_complete"] is False, value assert value["total"] == len(value["items"]) == 109 and value["safe"] == 107, value -assert value["needs_update"] == 0, value -assert value["updated"] == 102 and value["unchanged"] == 2, value -assert value["skipped"] == 3 and value["unconfirmed"] == 2, value -assert value["updated"] + value["unchanged"] + value["skipped"] + value["unconfirmed"] == value["total"], value +assert value["needs_update"] == 105 and value["prepared"] == 105, value +assert value["unchanged"] == 2 and value["skipped"] == 2, value +assert value["prepared"] + value["unchanged"] + value["skipped"] == value["total"], value by_id = {item["task_id"]: item for item in value["items"]} assert by_id[delegated_id]["outcome"] == "unchanged", by_id[delegated_id] assert by_id[delegated_id]["reason"] == "active task is handled by the terminal title writer", by_id[delegated_id] -assert by_id[drift_id]["outcome"] == "skipped", by_id[drift_id] -assert by_id[failed_id]["outcome"] == "unconfirmed", by_id[failed_id] -assert by_id[unconfirmed_id]["outcome"] == "unconfirmed", by_id[unconfirmed_id] +assert all(item["outcome"] != "updated" and item["outcome"] != "unconfirmed" for item in value["items"]), value PY -python3 - "$app_server_log" "$delegated_id" "$drift_id" <<'PY' +python3 - "$app_server_log" <<'PY' import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -delegated_id, drift_id = sys.argv[2:] -sets = [message for message in messages if message.get("method") == "thread/name/set"] -assert len(sets) == 104, len(sets) -ids = [message["params"]["threadId"] for message in sets] -assert len(ids) == len(set(ids)), ids -assert delegated_id not in ids and drift_id not in ids, ids -reads = [message for message in messages if message.get("method") == "thread/read"] -assert all(message["params"]["includeTurns"] is False for message in reads), reads +lists = [message for message in messages if message.get("method") == "thread/list"] +assert [message["params"] for message in lists] == [ + {"archived": False, "limit": 100}, + {"archived": False, "limit": 100, "cursor": "page-2"}, +], lists +assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages +PY +test "$(shasum -a 256 "$app_server_state" | awk '{print $1}')" = "$app_state_before_preparation" || + fail "onboarding preparation mutated native task state" +python3 - "$state_dir/subjects/$unconfirmed_id.json" <<'PY' +import json +import sys + +assert json.load(open(sys.argv[1], encoding="utf-8")) == {"subject": "Existing task 003"} +PY +"$simulate_mounted" onboard "$root/onboard-prepared-edge.json" "$app_server_state" "$native_tool_log" "$root/onboard-edge.json" "$unconfirmed_id" "$mounted_drift_id" +python3 - "$root/onboard-edge.json" "$native_tool_log" "$root/onboard-prepared-edge.json" "$unconfirmed_id" "$mounted_drift_id" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +calls = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +plan = json.load(open(sys.argv[3], encoding="utf-8")) +failed_id, drift_id = sys.argv[4:] +assert value == { + "ready": False, + "plan_complete": True, + "onboarding_complete": False, + "total": 109, + "updated": 103, + "skipped": 1, + "unchanged": 5, + "unconfirmed": 1, +}, value +reads = [call for call in calls if call["method"] == "codex_app__read_thread"] +sets = [call for call in calls if call["method"] == "codex_app__set_thread_title"] +assert len(reads) == 105 and len(sets) == 104 and len(calls) == 209, len(calls) +read_ids = [call["params"]["threadId"] for call in reads] +set_ids = [call["params"]["threadId"] for call in sets] +prepared = {item["task_id"]: item for item in plan["items"] if item["outcome"] == "prepared"} +assert len(read_ids) == len(set(read_ids)), read_ids +assert len(set_ids) == len(set(set_ids)), set_ids +assert set(read_ids) == set(prepared), (read_ids, prepared) +assert all(call["params"]["title"] == prepared[call["params"]["threadId"]]["desired_title"] for call in sets), sets +assert read_ids.count(drift_id) == 1 and drift_id not in set_ids, (read_ids, set_ids) +assert next(call for call in reads if call["params"]["threadId"] == drift_id)["response"]["thread"]["title"] == "User rename at mounted revalidation", reads +assert set_ids.count(failed_id) == 1, set_ids +assert all(call["params"] == { + "threadId": call["params"]["threadId"], + "includeOutputs": False, + "turnLimit": 1, + "maxOutputCharsPerItem": 1, +} for call in reads), reads +assert all(calls[index - 1]["method"] == "codex_app__read_thread" and + calls[index - 1]["params"]["threadId"] == call["params"]["threadId"] + for index, call in enumerate(calls) if call["method"] == "codex_app__set_thread_title"), calls +assert sum("error" in call for call in sets) == 1, sets PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || fail "confirmed onboarding changed hooks.json" @@ -835,22 +973,67 @@ import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["plan_complete"] is True, value assert value["total"] == 109 and value["safe"] == 107, value -assert value["needs_update"] == 4 and value["unchanged"] == 103, value -assert value["skipped"] == 2 and value["updated"] == 0 and value["unconfirmed"] == 0, value +assert value["needs_update"] == 3 and value["prepared"] == 0, value +assert value["unchanged"] == 104 and value["skipped"] == 2, value PY -run_threadbear onboard --noninteractive --confirm --json >"$root/onboard-converged.json" -python3 - "$root/onboard-converged.json" <<'PY' +: >"$app_server_log" +: >"$native_tool_log" +run_threadbear onboard --noninteractive --confirm --json >"$root/onboard-final-plan.json" +python3 - "$root/onboard-final-plan.json" "$app_server_log" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["plan_complete"] is True, value -assert value["read_only"] is False and value["onboarding_complete"] is True, value +assert value["read_only"] is False and value["onboarding_complete"] is False, value assert value["total"] == 109 and value["safe"] == 107, value -assert value["needs_update"] == 0 and value["updated"] == 4, value -assert value["unchanged"] == 103 and value["skipped"] == 2 and value["unconfirmed"] == 0, value -assert value["updated"] + value["unchanged"] + value["skipped"] == value["total"], value +assert value["needs_update"] == 3 and value["prepared"] == 3, value +assert value["unchanged"] == 104 and value["skipped"] == 2, value +messages = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages +PY +"$simulate_mounted" onboard "$root/onboard-final-plan.json" "$app_server_state" "$native_tool_log" "$root/onboard-converged.json" "" "" +python3 - "$root/onboard-converged.json" "$native_tool_log" "$root/onboard-final-plan.json" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +calls = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +plan = json.load(open(sys.argv[3], encoding="utf-8")) +assert value == { + "ready": True, + "plan_complete": True, + "onboarding_complete": True, + "total": 109, + "updated": 3, + "skipped": 0, + "unchanged": 106, + "unconfirmed": 0, +}, value +reads = [call for call in calls if call["method"] == "codex_app__read_thread"] +sets = [call for call in calls if call["method"] == "codex_app__set_thread_title"] +prepared = {item["task_id"]: item for item in plan["items"] if item["outcome"] == "prepared"} +assert len(reads) == 3 and len(sets) == 3 and len(calls) == 6, calls +assert len({call["params"]["threadId"] for call in reads}) == 3, reads +assert len({call["params"]["threadId"] for call in sets}) == 3, sets +assert {call["params"]["threadId"] for call in reads} == set(prepared), (reads, prepared) +assert all(call["params"]["title"] == prepared[call["params"]["threadId"]]["desired_title"] for call in sets), sets +assert all("response" in call and "error" not in call for call in calls), calls +PY + +: >"$app_server_log" +run_threadbear onboard --dry-run --json >"$root/onboard-final-preview.json" +python3 - "$root/onboard-final-preview.json" "$app_server_log" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +assert value["ready"] is True and value["onboarding_complete"] is True, value +assert value["needs_update"] == 0 and value["prepared"] == 0, value +assert value["unchanged"] == 107 and value["skipped"] == 2, value +messages = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages PY # Exercise the real daily updater once, then the direct current-version command. diff --git a/scripts/validate-experiments.py b/scripts/validate-experiments.py index 44192f4..0c95e20 100644 --- a/scripts/validate-experiments.py +++ b/scripts/validate-experiments.py @@ -413,9 +413,10 @@ def mutated_fixture(registry: dict[str, Any], mutation: str) -> Any: elif mutation == "bare-unknown": candidate["experiments"][0]["result"]["timing_ms"] = "unknown" elif mutation == "capability-evidence-overlap": - candidate["capabilities"][0]["contradicted_by"].append( - candidate["capabilities"][0]["supported_by"][0] + capability = next( + item for item in candidate["capabilities"] if item["supported_by"] ) + capability["contradicted_by"].append(capability["supported_by"][0]) return candidate diff --git a/site/index.html b/site/index.html index 1f5252d..cfd8a1d 100644 --- a/site/index.html +++ b/site/index.html @@ -46,11 +46,11 @@

ThreadBear

Small by design

-
One terminal updateEach turn runs one bounded local title command immediately before the final response.
+
One terminal updateEach turn runs one bounded cell: local planning, then at most one mounted Codex title write.
Your subject stays yoursThreadBear preserves safe user renames byte-for-byte and changes only its one leading icon.
-
One direct writerA short-lived official App Server client reads the exact title, writes at most once, and verifies exact readback.
-
Every native page accounted forOnboarding finishes App Server pagination before serial writes, with no arbitrary first-50 cap.
-
Uncertainty stays localUnsafe or ambiguous titles are left unchanged. One failure never blocks the response or the rest of ThreadBear.
+
The mounted app writesA short-lived App Server client prepares the safe title; Codex's native setter applies it and returns the exact result.
+
Every native page accounted forOnboarding finishes App Server pagination before serial app-native writes, with no arbitrary first-50 cap.
+
Uncertainty stays localUnsafe or ambiguous titles are left unchanged. A returned failure is not retried or promoted into global state.
A verified new coatOne daily update-only LaunchAgent installs checksummed, self-tested official releases and never reads tasks.

Five outcomes, plus a welcome bear

@@ -60,7 +60,7 @@

Small, private footprint

ThreadBear installs at ~/.local/bin/threadbear, keeps tiny private per-task subject records under ~/.local/share/threadbear, and adds one managed instruction block, one skill, and one daily update-only LaunchAgent.

Release binaries are not Developer ID signed or notarized. The installer verifies the published SHA-256 checksum and candidate self-test before installation.

Honest boundaries

-

One short-lived official App Server is the only task read/write authority. The terminal command reads the exact current title, makes at most one name update, and verifies exact readback. Onboarding fully paginates thread/list before any write and handles every safe target serially with no item cap. A null or blank name is raw and unowned; preview is never adopted. There is no SQLite access, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.

+

One short-lived official App Server is the read/planning authority only; the mounted Codex app's native setter is the sole title writer. The terminal cell prepares the exact current task title, makes at most one native call, and accepts only the exact returned task ID and title. Onboarding fully paginates thread/list, immediately rereads each prepared task through the mounted app, skips drift, and applies at most once with no item cap. A null or blank name is raw and unowned; preview is never adopted. There is no SQLite access, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.

The updater never reads tasks. Network and candidate-verification failures leave the old install untouched; a later local surface failure is reported as a rerunnable partial with the binary written last. Updater health is reported separately from title-core readiness.

MIT licensed. Built for tidy threads and small bears. 🧵🐻
diff --git a/site/install b/site/install index 15dd0a9..71145f4 100644 --- a/site/install +++ b/site/install @@ -14,7 +14,7 @@ Open with this orientation: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command reads and updates the title through Codex's official App Server. +> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command safely prepares the title, then the mounted Codex app applies it once through the native title setter. > > I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. @@ -61,7 +61,7 @@ Only after the checks and dry run succeed, present this complete card in the sam > ## Recommended setup > -> - One status icon in each task title, updated once immediately before the final response. +> - One status icon in each task title, prepared locally and updated once by the mounted Codex app immediately before the final response. > - Your exact subject stays intact; owners and actions remain in response prose. > - Unsafe, ambiguous, active, drifted, or overlong titles are left alone. > - Existing tasks can be previewed completely and onboarded serially, with no item cap. @@ -116,18 +116,18 @@ After a successful install say: When that request arrives, read the installed skill and follow this protocol: 1. Run `~/.local/bin/threadbear status --json`, then `~/.local/bin/threadbear onboard --dry-run --json`. -2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any write. If enumeration fails, make zero writes. +2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any preparation or title write. If enumeration fails, make zero changes. 3. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. 4. Ask for explicit consent unless unchanged install consent covered this first pass. -5. After consent, run exactly: +5. After consent, follow the installed skill's single onboarding JavaScript cell. Its first action runs exactly: ```sh ~/.local/bin/threadbear onboard --noninteractive --confirm --json ``` -The confirmed command starts from a fresh complete catalog and handles every safe target serially with no item cap, waves, worker tasks, or resume machinery. It rereads each target before a possible write; drift, absence, ambiguity, or unreadability is skipped. It attempts each write once and counts `updated` only after exact readback. An acknowledgement without exact readback is `unconfirmed` and is never retried. +The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, or changed title is skipped. Only an exact snapshot match may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. -Report `updated`, `unchanged`, `skipped`, and `unconfirmed` honestly and account for the returned target set. Call onboarding complete only when `plan_complete:true` and `onboarding_complete:true`. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan and safely continues. +Report `updated`, `skipped`, `unchanged`, and `unconfirmed`: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. ## Commands and updater @@ -139,7 +139,7 @@ Report `updated`, `unchanged`, `skipped`, and `unconfirmed` honestly and account ~/.local/bin/threadbear update --json ``` -The managed guidance runs `title --status --json` exactly once immediately before an ordinary final response. The enum changes only the icon. The binary reads the exact current title, preserves the safe subject, makes at most one App Server name update, and performs exact readback. If the command fails or does not finish in its bounded terminal moment, deliver the response without polling, retrying, or reconciling. +The managed guidance runs one injection-safe terminal JavaScript cell immediately before an ordinary final response. Replace only the status enum; the parsed `plan.desired_title` variable passes directly to the native tool and is never re-embedded by the model. The cell runs `title --status --json` exactly once. The binary reads the exact current title through the App Server, preserves the safe subject, and returns a plan without writing a title. When `write_required` is true, the cell calls `tools.codex_app__set_thread_title({title:plan.desired_title})` exactly once with `threadId` omitted, and accepts only the exact returned planned task ID and title. If the outer cell yields after 30 seconds, wait only for that same cell; the yield does not cancel a slow native call, which may delay the final response. Never retry, start another cell, poll the title, or reconcile. `update` verifies the official manifest, release URLs, architecture, checksum, embedded version, and candidate self-test before replacement. Network or verification failure leaves the old installation untouched. A later managed-surface write can truthfully leave a rerunnable partial; the binary is written last. Every successful update reports `restart_required`. The daily LaunchAgent runs only this command and never reads tasks or changes titles. @@ -169,11 +169,11 @@ Before release, run unit and integration tests, race tests, both Darwin builds, Release acceptance additionally requires one reviewed candidate live-tested end to end in Codex Desktop: -- the terminal `title` command changes only the status icon and preserves the exact subject; -- App Server acknowledgement and exact readback agree; +- the terminal planner changes no Codex title, preserves the exact subject, and prepares only the status icon change; +- the mounted app-native setter receives no explicit current-task ID and returns the exact planned task ID and title; - the rendered sidebar shows the expected title before and after a clean restart; -- a full onboarding preview enumerates every local task, and a consented serial pass updates every safe target with honest counts; -- failure and unconfirmed cases never block the substantive response or trigger retries; +- a full onboarding preview enumerates every local task, confirmed preparation writes no title, and the consented serial app-native pass accounts for every prepared target while skipping title drift before any write; +- failures and unconfirmed results are reported locally without retries or global failure state; - automatic update and uninstall preserve neighboring user content. -If the direct writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. +If the mounted app-native writer causes practical title corruption or response blocking, disable rewriting instead of adding reconciliation machinery. From d49c2a5cb4c17336d4b527e354620fc7aa3b3a90 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 20:41:31 -0400 Subject: [PATCH 03/14] test: exercise paginated current title planning [BEAR-116] --- .github/workflows/release-smoke.yml | 2 +- scripts/release-smoke.sh | 42 ++++++++++++++++------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index ff71f29..521ad1a 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -51,7 +51,7 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; a one-read current-title plan with zero production title writes plus an exact simulated mounted native setter success and one-attempt failure; complete multi-page onboarding preview with deduplication and failure-before-writes; uncapped complete-snapshot subject preparation with zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation, drift skipping, one-attempt writes, exact accounting, and no retry; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." + echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; a two-page sorted current-title plan with zero production title writes plus an exact simulated mounted native setter success and one-attempt failure; complete multi-page onboarding preview with deduplication and failure-before-writes; uncapped complete-snapshot subject preparation with zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation, drift skipping, one-attempt writes, exact accounting, and no retry; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." echo "- Not proved: real Codex auth, native automation deletion or exact-task unpin, rendered Desktop repaint, clean-restart persistence, native timeout behavior, a newer-version partial update, or architectures other than this runner. Those seams require the recorded local Desktop and fault-injection canaries." echo "- Deployment timing: a Pages/CDN lag can make the live bootstrap older than the release commit; that red result still requires operator investigation." echo "- A red result marks the published release for operator action; this workflow does not delete, demote, or retry a release." diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index 9bc7324..a7d7ba9 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -203,18 +203,21 @@ for encoded in sys.stdin: elif method == "thread/list": if params == { "archived": False, - "limit": 25, + "limit": 100, "sortKey": "recency_at", "sortDirection": "desc", }: - current_page = [ - dict(by_id[blank_id]), - dict(by_id[delegated_id]), - dict(by_id[current_id]), - dict(by_id[raw_id]), - ] - send({"method": "fixture/notification", "params": {"stage": "current-page"}}) - send({"id": request_id, "result": {"data": current_page, "nextCursor": "must-not-follow"}}) + send({"method": "fixture/notification", "params": {"stage": "current-page-1"}}) + send({"id": request_id, "result": {"data": threads[:100], "nextCursor": "current-page-2"}}) + elif params == { + "archived": False, + "limit": 100, + "sortKey": "recency_at", + "sortDirection": "desc", + "cursor": "current-page-2", + }: + send({"method": "fixture/notification", "params": {"stage": "current-page-2"}}) + send({"id": request_id, "result": {"data": threads[100:], "nextCursor": None}}) elif params == {"archived": False, "limit": 100}: send({"method": "fixture/notification", "params": {"stage": "page-1"}}) send({"id": request_id, "result": {"data": threads[:100], "nextCursor": "page-2"}}) @@ -678,9 +681,9 @@ PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || fail "current reinstall changed hooks.json" -# The production binary plans from one current-list read and performs no title -# write. The second fixture boundary simulates the exact mounted native setter -# cell installed into Codex guidance. +# The production binary finds the current task on the second sorted 100-item +# page and performs no title write. The second fixture boundary simulates the +# exact mounted native setter cell installed into Codex guidance. : >"$app_server_log" : >"$native_tool_log" run_threadbear title --status complete --json >"$root/title-complete-plan.json" @@ -706,10 +709,13 @@ import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -assert [message["method"] for message in messages] == ["initialize", "initialized", "thread/list"], messages -request = messages[2] -assert request["id"] == 2, request -assert request["params"] == {"archived": False, "limit": 25, "sortKey": "recency_at", "sortDirection": "desc"}, request +assert [message["method"] for message in messages] == ["initialize", "initialized", "thread/list", "thread/list"], messages +pages = messages[2:] +assert [message["id"] for message in pages] == [2, 3], pages +assert [message["params"] for message in pages] == [ + {"archived": False, "limit": 100, "sortKey": "recency_at", "sortDirection": "desc"}, + {"archived": False, "limit": 100, "sortKey": "recency_at", "sortDirection": "desc", "cursor": "current-page-2"}, +], pages assert not any(message.get("method") == "thread/name/set" for message in messages), messages PY "$simulate_mounted" current "$root/title-complete-plan.json" "$app_server_state" "$native_tool_log" "$root/title-complete.json" "" "" @@ -777,7 +783,7 @@ import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -assert [message.get("method") for message in messages].count("thread/list") == 1, messages +assert [message.get("method") for message in messages].count("thread/list") == 2, messages assert not any(message.get("method") == "thread/name/set" for message in messages), messages PY @@ -803,7 +809,7 @@ import json import sys messages = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] -assert [message.get("method") for message in messages].count("thread/list") == 1, messages +assert [message.get("method") for message in messages].count("thread/list") == 2, messages assert not any(message.get("method") == "thread/name/set" for message in messages), messages PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || From d9accd55418b37520e9a61a76f9bd0cdf73a19b8 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 21:02:27 -0400 Subject: [PATCH 04/14] fix: decode mounted Codex title results [BEAR-116] --- .github/workflows/release-smoke.yml | 2 +- CHANGELOG.md | 4 +- INSTALL.md | 2 +- assets/AGENTS.threadbear.md | 6 +- assets/skill/SKILL.md | 32 +++-- cmd/threadbear/install_test.go | 18 ++- cmd/threadbear/managed_javascript_test.go | 149 ++++++++++++++++++++-- cmd/threadbear/scan.go | 6 +- cmd/threadbear/site_contract_test.go | 11 +- docs/architecture.md | 4 +- docs/compatibility.md | 4 +- docs/live-eval.md | 4 +- docs/release-checklist.md | 4 +- scripts/release-smoke.sh | 100 +++++++++------ site/install | 2 +- 15 files changed, 269 insertions(+), 79 deletions(-) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 521ad1a..72b976f 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -51,7 +51,7 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; a two-page sorted current-title plan with zero production title writes plus an exact simulated mounted native setter success and one-attempt failure; complete multi-page onboarding preview with deduplication and failure-before-writes; uncapped complete-snapshot subject preparation with zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation, drift skipping, one-attempt writes, exact accounting, and no retry; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." + echo "- Proved: published bootstrap, manifest, checksum, candidate version and self-test; dry-run collision refusal; exact 2.2.1 reset fingerprint, --reset gate, filesystem reset, and teardown; title-core readiness without the updater; a two-page sorted current-title plan with zero production title writes, installed decoder presence, and a simulated raw JSON-string mounted setter result plus one-attempt failure; complete multi-page onboarding preview with deduplication and failure-before-writes; uncapped complete-snapshot subject preparation with zero per-target production RPCs, followed by simulated serial mounted read-before-set revalidation using raw JSON-string tool results, drift and wrong-task-ID skipping, one-attempt writes, exact accounting, and no retry; no hooks.json mutation or SQLite dependency; real-launchd automatic and direct current updates with restart reporting; an update/uninstall overlap; and complete uninstall JSON with binary-last teardown and unrelated-content preservation." echo "- Not proved: real Codex auth, native automation deletion or exact-task unpin, rendered Desktop repaint, clean-restart persistence, native timeout behavior, a newer-version partial update, or architectures other than this runner. Those seams require the recorded local Desktop and fault-injection canaries." echo "- Deployment timing: a Pages/CDN lag can make the live bootstrap older than the release commit; that red result still requires operator investigation." echo "- A red result marks the published release for operator action; this workflow does not delete, demote, or retry a release." diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e8cb5..86f46fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ ### Changed - Reset ThreadBear to one terminal local title command that preserves exact subjects, changes only the status icon, keeps actions in response prose, and leaves unsafe titles unchanged. -- Made one short-lived official Codex App Server the read/planning authority and the mounted app-native setter the sole title writer: one prepared current-task title, at most one native call, and exact returned ID/title validation, with no SQLite access, detached write, fallback, or retry. -- Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each current app-native title before its one possible write, and reports honest updated, skipped, unchanged, and unconfirmed counts. +- Made one short-lived official Codex App Server the read/planning authority and the mounted app-native setter the sole title writer: one prepared current-task title, one-time decoding of native JSON-text results, at most one native call, and exact returned ID/title validation, with no SQLite access, detached write, fallback, or retry. +- Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each app-native task ID and title before its one possible write, and reports honest updated, skipped, unchanged, and unconfirmed counts. - Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. - Separated title-core readiness from updater health and made successful updates report restart requirements; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. - Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. diff --git a/INSTALL.md b/INSTALL.md index 71145f4..b75bb15 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -125,7 +125,7 @@ When that request arrives, read the installed skill and follow this protocol: ~/.local/bin/threadbear onboard --noninteractive --confirm --json ``` -The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, or changed title is skipped. Only an exact snapshot match may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. +The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, wrong-ID, or changed-title response is skipped. Only an exact task ID and snapshot title may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. Report `updated`, `skipped`, `unchanged`, and `unconfirmed`: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index 05e493f..7454743 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -25,10 +25,14 @@ if (!plan || plan.ready !== true || typeof plan.write_required !== "boolean" || text(JSON.stringify({ready:false, reason:"ThreadBear title planner returned an invalid plan"})); exit(); } +const decodeNative = value => { + if (typeof value !== "string") return value; + try { return JSON.parse(value); } catch { return null; } +}; if (!plan.write_required) { text(local); exit(); } let renamed; try { - renamed = await tools.codex_app__set_thread_title({title:plan.desired_title}); + renamed = decodeNative(await tools.codex_app__set_thread_title({title:plan.desired_title})); } catch (error) { text(JSON.stringify({ready:false, reason:"Codex title write failed", error:String(error)})); exit(); diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index c1369df..52772a6 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,13 +1,13 @@ --- name: threadbear -description: Operate the local ThreadBear title decorator for Codex Desktop on macOS. +description: Operate ThreadBear for Codex Desktop on macOS. --- # ThreadBear -Be brief, warm, and lightly bear-themed. Explain effects first. Get explicit consent before install/reset, historical onboarding, manual update, or uninstall. +Be brief and warm. Explain effects first. Get explicit consent before install/reset, historical onboarding, manual update, or uninstall. -One terminal cell runs the local planner, then at most one mounted Codex title write. Status changes only the icon; owners/actions stay in prose. There is no persistent task, controller, classifier, queue, or repair job. +One terminal cell plans, then makes at most one mounted title write. Status changes only the icon; actions stay in prose. No persistent task or background title machinery. ## Help and status @@ -15,9 +15,9 @@ Run `status --json` before calling it healthy. `threadbear help` is authoritativ ## Install or reset -Follow `https://threadbear.sh/install` and candidate help. Preview first. Explain the binary, subject records, guidance, skill, and updater. +Follow `https://threadbear.sh/install` and candidate help. Preview the binary, subject records, guidance, skill, and updater. -For a 2.2.1 reset, require the exact former task and automation. Delete only it, unpin only that task without renaming it, and remove exact old title hooks. Verify both results before `install --reset`; mismatch stops. Import no old state or title guess. +For a 2.2.1 reset, require the exact former task and automation. Delete only that automation, unpin only that task without renaming, and remove exact old title hooks. Verify deletion and unpin before `install --reset`; stop on mismatch. Import no old state or title guess. After consent, install; verify `version`, `self-test`, and `status` JSON. Ask for one restart, then say: @@ -25,8 +25,8 @@ After consent, install; verify `version`, `self-test`, and `status` JSON. Ask fo ## Onboard existing tasks -1. Run `status --json`, then `onboard --dry-run --json`. Require `ready:true`, `plan_complete:true`, and `read_only:true`. It must enumerate and deduplicate the complete unarchived App Server catalog; failure means zero writes. -2. Explain `total`, `safe`, and `needs_update`. Active, blank, unsafe, ambiguous, or overlong titles stay unchanged; never adopt `preview`. Ask for explicit consent. +1. Run `status --json`, then `onboard --dry-run --json`. Require `ready:true`, `plan_complete:true`, and `read_only:true`; it must enumerate and deduplicate the complete unarchived App Server catalog. +2. Report `total`, `safe`, and `needs_update`. Leave active, blank, unsafe, ambiguous, or overlong titles unchanged; never adopt `preview`. Ask for explicit consent. 3. After consent, run this exact cell once: ```js @@ -61,18 +61,22 @@ if (prepared.some(item => !item || typeof item.task_id !== "string" || text(JSON.stringify({ready:false,reason:"Invalid prepared item"})); exit(); } let updated = 0, skipped = 0, unconfirmed = 0; +const parseNative = value => { + if (typeof value !== "string") return value; + try { return JSON.parse(value); } catch { return null; } +}; for (const item of prepared) { let current; try { - current = await tools.codex_app__read_thread({threadId:item.task_id, - includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1}); + current = parseNative(await tools.codex_app__read_thread({threadId:item.task_id, + includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})); } catch { current = null; } - if (current?.thread?.title !== item.title) skipped++; + if (current?.thread?.id !== item.task_id || current.thread.title !== item.title) skipped++; else { let renamed; try { - renamed = await tools.codex_app__set_thread_title({threadId:item.task_id, - title:item.desired_title}); + renamed = parseNative(await tools.codex_app__set_thread_title({threadId:item.task_id, + title:item.desired_title})); } catch { renamed = null; } if (renamed && typeof renamed === "object" && renamed.threadId === item.task_id && renamed.title === item.desired_title) updated++; @@ -94,11 +98,11 @@ text(JSON.stringify({ })); ``` -If the cell yields, wait only for it; never start another process or title call. Progress appears during preparation and every 25 outcomes. Report: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Ready requires zero `unconfirmed`. Drift is skipped. A later **ThreadBear onboard** makes a fresh plan. Never create a cap, wave, controller, worker task, queue, or persistent ThreadBear task. +If it yields, wait for that cell; start no second process or title call. Show progress during preparation and every 25 items. Report: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Ready requires zero `unconfirmed`; drift is skipped. A later **ThreadBear onboard** replans. Never create a cap, wave, controller, worker task, queue, or persistent ThreadBear task. ## Update -The daily LaunchAgent runs only `threadbear update` and never reads tasks. For manual update, get consent, run `update --json`, and report `restart_required`. Failure may return a rerunnable partial; the binary is last. +The daily LaunchAgent runs only `threadbear update` and never reads tasks. For manual update, get consent, run `update --json`, and report `restart_required`. A partial is rerunnable; the binary is last. ## Uninstall diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 3f5755c..655482d 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -169,10 +169,26 @@ func TestLifecycleNeverTouchesCodexHooks(t *testing.T) { if !strings.Contains(string(agents), "plan.write_required") || !strings.Contains(string(skill), `item.outcome === "prepared"`) { t.Fatalf("installed guidance lacks planner/prepared contract: AGENTS=%q skill=%q", agents, skill) } + for _, required := range []string{ + "const decodeNative = value => {", + `if (typeof value !== "string") return value;`, + "return JSON.parse(value)", + "decodeNative(await tools.codex_app__set_thread_title", + } { + if !strings.Contains(string(agents), required) { + t.Fatalf("installed AGENTS lacks JSON-string native result decoding %q: %q", required, agents) + } + } for _, required := range []string{ "tools.write_stdin({", "tools.codex_app__read_thread({", - "current?.thread?.title !== item.title", + "const parseNative = value => {", + `if (typeof value !== "string") return value;`, + "try { return JSON.parse(value); } catch { return null; }", + "current = parseNative(await tools.codex_app__read_thread", + "renamed = parseNative(await tools.codex_app__set_thread_title", + "current?.thread?.id !== item.task_id", + "current.thread.title !== item.title", "let updated = 0, skipped = 0, unconfirmed = 0", "updated + skipped + unconfirmed === prepared.length", } { diff --git a/cmd/threadbear/managed_javascript_test.go b/cmd/threadbear/managed_javascript_test.go index e3c32ce..dd513e3 100644 --- a/cmd/threadbear/managed_javascript_test.go +++ b/cmd/threadbear/managed_javascript_test.go @@ -20,10 +20,13 @@ func TestEmbeddedOnboardingJavaScriptResumesAndSerializesNativeWrites(t *testing harness := fmt.Sprintf(` const source = %s; const plan = { - ready:true, plan_complete:true, read_only:false, total:4, + ready:true, plan_complete:true, read_only:false, total:7, items:[ {outcome:"prepared",task_id:"drift",title:"old drift",desired_title:"🐻 old drift"}, + {outcome:"prepared",task_id:"unreadable",title:"old unreadable",desired_title:"🐻 old unreadable"}, + {outcome:"prepared",task_id:"wrongid",title:"old wrongid",desired_title:"🐻 old wrongid"}, {outcome:"prepared",task_id:"exact",title:"old exact",desired_title:"🐻 old exact"}, + {outcome:"prepared",task_id:"object",title:"old object",desired_title:"🐻 old object"}, {outcome:"prepared",task_id:"bad",title:"old bad",desired_title:"🐻 old bad"}, {outcome:"unchanged",task_id:"same",title:"same",desired_title:"same"} ] @@ -55,17 +58,22 @@ const tools = { "includeOutputs,maxOutputCharsPerItem,threadId,turnLimit" || args.includeOutputs !== false || args.turnLimit !== 1 || args.maxOutputCharsPerItem !== 1) throw new Error("bad read args"); - if (args.threadId === "drift") return {thread:{title:"changed"}}; - if (args.threadId === "exact") return {thread:{title:"old exact"}}; - if (args.threadId === "bad") return {thread:{title:"old bad"}}; + if (args.threadId === "drift") return JSON.stringify({thread:{id:"drift",title:"changed"}}); + if (args.threadId === "unreadable") return "{malformed"; + if (args.threadId === "wrongid") return JSON.stringify({thread:{id:"other",title:"old wrongid"}}); + if (args.threadId === "exact") return JSON.stringify({thread:{id:"exact",title:"old exact"}}); + if (args.threadId === "object") return {thread:{id:"object",title:"old object"}}; + if (args.threadId === "bad") return JSON.stringify({thread:{id:"bad",title:"old bad"}}); throw new Error("unexpected read target"); }, codex_app__set_thread_title: async args => { trace.push("set:" + args.threadId); if (Object.keys(args).sort().join(",") !== "threadId,title") throw new Error("bad setter args"); if (args.threadId === "exact" && args.title === "🐻 old exact") - return {threadId:"exact",title:"🐻 old exact"}; - if (args.threadId === "bad" && args.title === "🐻 old bad") return "malformed"; + return JSON.stringify({threadId:"exact",title:"🐻 old exact"}); + if (args.threadId === "object" && args.title === "🐻 old object") + return {threadId:"object",title:"🐻 old object"}; + if (args.threadId === "bad" && args.title === "🐻 old bad") return "{malformed"; throw new Error("unexpected setter target"); } }; @@ -99,7 +107,10 @@ process.stdout.write(JSON.stringify({trace,outputs,notices,writeCalls})); wantTrace := []string{ "exec", "write:77", "write:77", "read:drift", + "read:unreadable", + "read:wrongid", "read:exact", "set:exact", + "read:object", "set:object", "read:bad", "set:bad", } if !reflect.DeepEqual(run.Trace, wantTrace) { @@ -126,16 +137,136 @@ process.stdout.write(JSON.stringify({trace,outputs,notices,writeCalls})); t.Fatalf("decode managed-loop receipt: %v\n%s", err, run.Outputs[0]) } if receipt.Ready || !receipt.PlanComplete || receipt.OnboardingComplete || - receipt.Total != 4 || receipt.Updated != 1 || receipt.Skipped != 1 || - receipt.Unchanged != 2 || receipt.Unconfirmed != 1 { + receipt.Total != 7 || receipt.Updated != 2 || receipt.Skipped != 3 || + receipt.Unchanged != 4 || receipt.Unconfirmed != 1 { t.Fatalf("unexpected managed-loop receipt: %+v", receipt) } if len(run.Notices) < 3 || run.Notices[0] != "ThreadBear onboarding: preparing complete catalog" || - run.Notices[len(run.Notices)-1] != "ThreadBear onboarding: 3/3" { + run.Notices[len(run.Notices)-1] != "ThreadBear onboarding: 6/6" { t.Fatalf("unexpected progress notifications: %v", run.Notices) } } +func TestEmbeddedOrdinaryJavaScriptAcceptsStringAndObjectNativeResults(t *testing.T) { + guidance := readRepoFile(t, "assets", "AGENTS.threadbear.md") + source := extractJavaScriptCell(t, guidance) + sourceJSON, err := json.Marshal(source) + if err != nil { + t.Fatal(err) + } + + harness := fmt.Sprintf(` +const source = %s; +const writePlan = {ready:true,write_required:true,task_id:"current",desired_title:"✅ exact subject"}; +const noWritePlan = {ready:true,write_required:false,task_id:"current"}; +const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; +async function run(plan,nativeResult) { + const trace = [], outputs = []; + const tools = { + exec_command: async args => { + trace.push("exec"); + if (args.cmd !== "\"$HOME/.local/bin/threadbear\" title --status STATUS --json" || + args.yield_time_ms !== 30000 || args.max_output_tokens !== 1000) throw new Error("bad planner args"); + return {exit_code:0,output:JSON.stringify(plan)}; + }, + codex_app__set_thread_title: async args => { + trace.push("set"); + if (Object.keys(args).join(",") !== "title" || args.title !== plan.desired_title) + throw new Error("bad current-task setter args"); + return nativeResult; + } + }; + const text = value => outputs.push(typeof value === "string" ? value : JSON.stringify(value)); + class Exit extends Error {} + const exit = () => { throw new Exit(); }; + try { + await new AsyncFunction("tools","text","exit",source)(tools,text,exit); + } catch (error) { + if (!(error instanceof Exit)) throw error; + } + return {trace,outputs}; +} +const expected = {threadId:writePlan.task_id,title:writePlan.desired_title}; +const stringRun = await run(writePlan,JSON.stringify(expected)); +const objectRun = await run(writePlan,expected); +const malformedRun = await run(writePlan,"{malformed"); +const wrongIDRun = await run(writePlan,JSON.stringify({...expected,threadId:"wrong"})); +const wrongTitleRun = await run(writePlan,JSON.stringify({...expected,title:"wrong"})); +const noWriteRun = await run(noWritePlan,null); +process.stdout.write(JSON.stringify({ + stringRun,objectRun,malformedRun,wrongIDRun,wrongTitleRun,noWriteRun +})); +`, sourceJSON) + + output, err := exec.Command("node", "--input-type=module", "--eval", harness).CombinedOutput() + if err != nil { + t.Fatalf("execute embedded ordinary JavaScript: %v\n%s", err, output) + } + + var got struct { + StringRun javascriptRun `json:"stringRun"` + ObjectRun javascriptRun `json:"objectRun"` + MalformedRun javascriptRun `json:"malformedRun"` + WrongIDRun javascriptRun `json:"wrongIDRun"` + WrongTitleRun javascriptRun `json:"wrongTitleRun"` + NoWriteRun javascriptRun `json:"noWriteRun"` + } + if err := json.Unmarshal(output, &got); err != nil { + t.Fatalf("decode ordinary JavaScript harness output: %v\n%s", err, output) + } + for name, run := range map[string]javascriptRun{"string": got.StringRun, "object": got.ObjectRun} { + if !reflect.DeepEqual(run.Trace, []string{"exec", "set"}) { + t.Fatalf("%s native result trace = %v; want one planner then one setter", name, run.Trace) + } + if len(run.Outputs) != 1 { + t.Fatalf("%s native result outputs = %d; want one receipt", name, len(run.Outputs)) + } + var receipt struct { + Ready bool `json:"ready"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Updated bool `json:"updated"` + } + if err := json.Unmarshal([]byte(run.Outputs[0]), &receipt); err != nil { + t.Fatalf("decode %s native result receipt: %v", name, err) + } + if !receipt.Ready || receipt.TaskID != "current" || + receipt.Title != "✅ exact subject" || !receipt.Updated { + t.Fatalf("unexpected %s native result receipt: %+v", name, receipt) + } + } + for name, run := range map[string]javascriptRun{ + "malformed": got.MalformedRun, + "wrong ID": got.WrongIDRun, + "wrong title": got.WrongTitleRun, + } { + if !reflect.DeepEqual(run.Trace, []string{"exec", "set"}) { + t.Fatalf("%s native result trace = %v; want one planner then one setter", name, run.Trace) + } + if len(run.Outputs) != 1 { + t.Fatalf("%s native result outputs = %d; want one failure", name, len(run.Outputs)) + } + var receipt struct { + Ready bool `json:"ready"` + Reason string `json:"reason"` + } + if err := json.Unmarshal([]byte(run.Outputs[0]), &receipt); err != nil { + t.Fatalf("decode %s native result failure: %v", name, err) + } + if receipt.Ready || receipt.Reason != "Codex title write was not confirmed exactly" { + t.Fatalf("unexpected %s native result failure: %+v", name, receipt) + } + } + if !reflect.DeepEqual(got.NoWriteRun.Trace, []string{"exec"}) || len(got.NoWriteRun.Outputs) != 1 { + t.Fatalf("no-write plan must run one planner and zero setters: %+v", got.NoWriteRun) + } +} + +type javascriptRun struct { + Trace []string `json:"trace"` + Outputs []string `json:"outputs"` +} + func extractJavaScriptCell(t *testing.T, markdown string) string { t.Helper() const opener = "```js\n" diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index 94e7f10..28a875c 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -57,12 +57,12 @@ type onboardingResult struct { func runCurrentTitle(ctx context.Context, taskID, status string) (currentTitleResult, error) { result := currentTitleResult{TaskID: taskID, Status: status} - if !taskIDPattern.MatchString(taskID) { - return result, errors.New("CODEX_THREAD_ID is unavailable or invalid") - } if _, ok := statusIcons[status]; !ok { return result, fmt.Errorf("unsupported ThreadBear status %q", status) } + if !taskIDPattern.MatchString(taskID) { + return result, errors.New("CODEX_THREAD_ID is unavailable or invalid") + } disk := newStore(stateDir()) fence, err := disk.lifecycleFence() diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 3f9317b..90059fa 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -51,7 +51,7 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "returns one `prepared` action containing the snapshot title and desired title", "tools.write_stdin", "tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})", - "A missing, unreadable, or changed title is skipped.", + "A missing, unreadable, wrong-ID, or changed-title response is skipped.", "tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})", "Every prepared item must reach exactly one outcome.", "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", @@ -103,6 +103,9 @@ func TestInstalledGuidanceDefinesOneTerminalPlannerAndNativeWrite(t *testing.T) `typeof plan.write_required !== "boolean"`, "if (!plan.write_required) { text(local); exit(); }", "tools.codex_app__set_thread_title({title:plan.desired_title})", + "const decodeNative = value =>", + `typeof value !== "string"`, + "renamed = decodeNative(await tools.codex_app__set_thread_title", "renamed.threadId !== plan.task_id", "renamed.title !== plan.desired_title", "mounted Codex app is the sole writer", @@ -154,8 +157,12 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "session_id:local.session_id", "tools.codex_app__read_thread({threadId:item.task_id", "includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1", - "current?.thread?.title !== item.title", + "const parseNative = value =>", + `typeof value !== "string"`, + "current = parseNative(await tools.codex_app__read_thread", + "current?.thread?.id !== item.task_id || current.thread.title !== item.title", "tools.codex_app__set_thread_title({", + "renamed = parseNative(await tools.codex_app__set_thread_title", "threadId:item.task_id", "title:item.desired_title", "renamed.threadId === item.task_id", diff --git a/docs/architecture.md b/docs/architecture.md index 4701dba..090dae6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ Codex provides no compare-and-swap title primitive. ThreadBear keeps the planner The official `codex app-server --stdio` process is ThreadBear's read and planning authority only. The binary initializes one short-lived client, correlates JSON-RPC response IDs while tolerating notifications, and closes it before returning a plan. It contains no `thread/name/set` path. -The mounted Codex app's native `set_thread_title` tool is the sole title writer. Current-task calls omit `threadId`; onboarding calls carry one explicit prepared target. Exact returned task ID and title are the acknowledgement. Release acceptance still requires the mounted header and sidebar to render that title. +The mounted Codex app's native `set_thread_title` tool is the sole title writer. Current-task calls omit `threadId`; onboarding calls carry one explicit prepared target. Mounted tool results normally arrive as raw JSON text; managed cells decode that text once, also accept already-decoded objects, and reject malformed or non-object results. Exact returned task ID and title are the acknowledgement. Release acceptance still requires the mounted header and sidebar to render that title. Onboarding follows every unarchived `thread/list` page and deduplicates task IDs before any preparation. Native `name` is the user-facing title. A null or blank name is raw and unowned; `preview` is never adopted, persisted, or rendered. @@ -43,7 +43,7 @@ ThreadBear does not open Codex SQLite, edit Desktop caches, run an App Server da Mutation requires explicit consent. `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, skips the active caller and unsafe rows, stores the safe subject, and returns one `prepared` action containing the snapshot `title` and `desired_title` for each eligible target. The binary performs no per-target app read and writes no Codex title. -The installed skill runs preparation and the native pass in one managed JavaScript cell. If the preparation process yields, the cell resumes that same process with `write_stdin`; it never starts another command. Immediately before each possible write, the cell serially reads the prepared target through the mounted app and requires its current title to equal the snapshot `title`. A read failure or drift is `skipped` and receives no write. An exact match receives at most one native title call. Only an exact returned target ID and desired title counts as `updated`; a throw, string error, malformed response, or mismatch is `unconfirmed` and is never retried. +The installed skill runs preparation and the native pass in one managed JavaScript cell. If the preparation process yields, the cell resumes that same process with `write_stdin`; it never starts another command. Immediately before each possible write, the cell serially reads the prepared target through the mounted app and requires the returned task ID and current title to equal the prepared ID and snapshot `title`. A read failure, wrong ID, or drift is `skipped` and receives no write. An exact match receives at most one native title call. Only an exact returned target ID and desired title counts as `updated`; a throw, undecodable or non-object response, or mismatch is `unconfirmed` and is never retried. The final receipt reports the complete catalog and `updated`, `skipped`, `unchanged`, and `unconfirmed` counts. Every prepared item must reach exactly one deliberate outcome. ThreadBear is ready only when all prepared items are accounted for and no call is unconfirmed; skipped tasks are honestly left unchanged. diff --git a/docs/compatibility.md b/docs/compatibility.md index dd0a0b5..2906594 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -4,9 +4,9 @@ ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop' The terminal planner starts one bounded `codex app-server --stdio` process. It requires an exact current-task match and nonblank native `name`, resolves the safe subject, and returns a prepared title without writing it. A protocol, ID, process, timeout, or unsafe-title failure stays local and is never retried. -When `write_required` is true, the same terminal cell calls the mounted Codex app's native setter once with no explicit task ID. Success requires the exact planned task ID and title in the returned object. A returned throw, string error, malformed response, or mismatch stays local; there is no alternate writer or reconciliation path. If the outer cell yields after 30 seconds, the task waits only for that same running cell. The yield does not cancel a slow native call, which can delay the response; it never starts another cell or retries. +When `write_required` is true, the same terminal cell calls the mounted Codex app's native setter once with no explicit task ID. The mounted boundary normally returns raw JSON text, which the cell decodes once; it also accepts an already-decoded object. Success still requires the exact planned task ID and title. A throw, undecodable or non-object response, or mismatch stays local; there is no alternate writer or reconciliation path. If the outer cell yields after 30 seconds, the task waits only for that same running cell. The yield does not cancel a slow native call, which can delay the response; it never starts another cell or retries. -`onboard --dry-run --json` follows the complete unarchived `thread/list` catalog, tolerates interleaved notifications, and deduplicates IDs. Rows with null or blank `name` remain raw and unowned regardless of `preview`. Any page failure aborts before mutation. After consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, and returns every prepared action with its snapshot title and desired title, no item cap, no per-target app read, and zero title writes. The installed skill resumes only that same preparation process if it yields, then serially reads each prepared target through the mounted app immediately before any explicit-target write. A read failure or title drift is skipped without a write; an exact match receives at most one setter call. +`onboard --dry-run --json` follows the complete unarchived `thread/list` catalog, tolerates interleaved notifications, and deduplicates IDs. Rows with null or blank `name` remain raw and unowned regardless of `preview`. Any page failure aborts before mutation. After consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, and returns every prepared action with its snapshot title and desired title, no item cap, no per-target app read, and zero title writes. The installed skill resumes only that same preparation process if it yields, then serially reads each prepared target through the mounted app immediately before any explicit-target write. It decodes raw JSON-text reads and setter results once while retaining object compatibility. A read failure, wrong returned ID, or title drift is skipped without a write; an exact ID/title match receives at most one setter call. ThreadBear never opens Codex SQLite or edits Desktop storage. It runs no App Server daemon or proxy, keeps no App Server cache, uses no model, and has no retry or alternate read/write path. diff --git a/docs/live-eval.md b/docs/live-eval.md index 7c9823f..3433fac 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -20,7 +20,7 @@ Exercise `complete`, `next_steps`, `needs_input`, `blocked`, and `automation`. I Exercise a generated short title, continued task, user rename, leading user emoji, user arrow, duplicate subject, maximum fitting subject, overlong subject, multiline or control text, and raw delegated envelope. Safe renames must survive byte-for-byte. Unsafe input must leave only that title unchanged without blocking the response. -Force planner App Server start, initialize, current-read, and exit failures; missing or malformed current task ID; null and blank `name`; malformed planner JSON; and a planner-to-native-call rename race. For the mounted writer, cover a throw, returned error string, malformed object, wrong task ID, wrong title, and a slow call that outlasts the initial 30-second outer yield before returning. Require the yielded case to resume only the same running cell. Require zero binary `thread/name/set` calls, at most one app-native call, and no blind retry, alternate source, repair command, pending proposal, or global failure. If this seam causes practical corruption or response blocking, disable rewriting rather than add reconciliation. +Force planner App Server start, initialize, current-read, and exit failures; missing or malformed current task ID; null and blank `name`; malformed planner JSON; and a planner-to-native-call rename race. For the mounted writer, cover normal raw JSON-text success, already-decoded object compatibility, a throw, undecodable text, decoded non-object, wrong task ID, wrong title, and a slow call that outlasts the initial 30-second outer yield before returning. Require the yielded case to resume only the same running cell. Require zero binary `thread/name/set` calls, at most one app-native call, and no blind retry, alternate source, repair command, pending proposal, or global failure. If this seam causes practical corruption or response blocking, disable rewriting rather than add reconciliation. Restart Codex after a successful write. Confirm the exact title remains in the sidebar and the next terminal turn still preserves the subject. @@ -30,7 +30,7 @@ For `onboard --dry-run --json`, prove the exact App Server handshake and cursor After explicit consent, run exact `onboard --noninteractive --confirm --json`. Prove it starts from a fresh complete snapshot, stores subjects only for safe snapshot titles, emits `prepared` actions containing snapshot `title` and `desired_title`, performs no per-target app read, and makes zero Codex title writes. Cover the active caller, null and blank names, ambiguous old status prefixes, overlong text, user emoji, and already-onboarded titles. Force the preparation command to yield and prove the exact embedded JavaScript resumes that same process through `write_stdin` without starting a second command. -Run the installed skill's one serial native loop. Immediately before each possible write, require one mounted-app `read_thread` call with `includeOutputs:false`, `turnLimit:1`, and `maxOutputCharsPerItem:1`. A missing or unreadable task or a title that differs from the prepared snapshot is `skipped` and receives no setter call. Every exact match receives at most one explicit-target setter call for `🐻 `. Validate the exact returned ID/title and cover a throw, error string, malformed response, wrong target, and wrong title. Count every non-exact setter result as `unconfirmed` without retry. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. Require serial read-before-write ordering, progress during preparation and every 25 outcomes, and a final receipt where every prepared row is exactly one of `updated`, `skipped`, or `unconfirmed`. Report `unchanged` honestly and report ready only when all prepared rows are accounted for and `unconfirmed` is zero. +Run the installed skill's one serial native loop. Immediately before each possible write, require one mounted-app `read_thread` call with `includeOutputs:false`, `turnLimit:1`, and `maxOutputCharsPerItem:1`. Exercise raw JSON-text and already-decoded object results for both mounted tools. A missing or unreadable task, wrong returned task ID, or title that differs from the prepared snapshot is `skipped` and receives no setter call. Every exact ID/title match receives at most one explicit-target setter call for `🐻 `. Validate the exact returned ID/title and cover a throw, undecodable or non-object response, wrong target, and wrong title. Count every non-exact setter result as `unconfirmed` without retry. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. Require serial read-before-write ordering, progress during preparation and every 25 outcomes, and a final receipt where every prepared row is exactly one of `updated`, `skipped`, or `unconfirmed`. Report `unchanged` honestly and report ready only when all prepared rows are accounted for and `unconfirmed` is zero. Live-test the complete real local catalog with no artificial first-50 subset. Verify the rendered sidebar before and after a clean restart. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index bcddd53..3b74ff8 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -6,8 +6,8 @@ Before tagging a stable release: 2. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. 3. Run `gofmt`, `go test ./...`, `go test -race ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity. Review the diff for unnecessary machinery; do not substitute a physical line-count gate for that judgment. 4. In isolated homes, prove fresh install, reinstall, dry-run collisions, status, update, uninstall, and the consented exact 2.2.1 reset while preserving unrelated AGENTS content, skills, settings, files, automations, and LaunchAgents. Confirm reset verifies automation deletion and exact-task unpin before filesystem mutation, imports no old state, and never renames the former persistent task. -5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one local `threadbear title --status ENUM --json` planner, parse only complete exit-zero JSON, make zero binary title writes, and make at most one mounted app-native call with `threadId` omitted. Require exact returned planned task ID/title. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, malformed output, native throws or non-exact returns, the rename race, and a native call that outlasts the initial 30-second outer yield. In the yielded case, prove the agent waits only for that same cell; the yield is not cancellation, the slow call may delay the response, and no second cell starts. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. -6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, prepares every eligible target with snapshot `title` and `desired_title`, has no cap, performs no per-target app read, and writes zero titles. Prove the exact embedded JavaScript resumes a yielded preparation process through `write_stdin` on the same session and starts no second process. Then prove it serially reads each prepared target through the mounted app immediately before any write, skips missing, unreadable, or drifted targets without a setter call, makes at most one explicit-target setter call for an exact match, validates exact returned ID/title, never retries, reports progress, and accounts every prepared item as updated, skipped, or unconfirmed. `ready` and `onboarding_complete` require complete accounting and zero unconfirmed; skipped rows remain honestly unchanged. +5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one local `threadbear title --status ENUM --json` planner, parse only complete exit-zero JSON, make zero binary title writes, and make at most one mounted app-native call with `threadId` omitted. Exercise normal raw JSON-text and already-decoded object returns; reject malformed, non-object, or non-exact results. Require exact returned planned task ID/title. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, malformed output, native throws or non-exact returns, the rename race, and a native call that outlasts the initial 30-second outer yield. In the yielded case, prove the agent waits only for that same cell; the yield is not cancellation, the slow call may delay the response, and no second cell starts. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. +6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, prepares every eligible target with snapshot `title` and `desired_title`, has no cap, performs no per-target app read, and writes zero titles. Prove the exact embedded JavaScript resumes a yielded preparation process through `write_stdin` on the same session and starts no second process. Then prove it decodes raw JSON-text reads and setter results once, retains object compatibility, serially reads each prepared target immediately before any write, skips missing, unreadable, wrong-ID, or drifted responses without a setter call, makes at most one explicit-target setter call for an exact ID/title match, validates exact returned ID/title, never retries, reports progress, and accounts every prepared item as updated, skipped, or unconfirmed. `ready` and `onboarding_complete` require complete accounting and zero unconfirmed; skipped rows remain honestly unchanged. 7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Verify active-header and sidebar rendering before and after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. 8. Prove the daily LaunchAgent invokes only the verified updater. Network and verification failures preserve the old install; local write failures report `partial`, stage, restart implication, and one safe rerun with binary last; success reports `restart_required`. Prove updater health is separate from core `ready` and update/uninstall races are serialized. After uninstall commit, do not run the title command. 9. Confirm `assets/skill/SKILL.md` is below 5 KB, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index a7d7ba9..b458433 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -38,6 +38,7 @@ delegated_id=00000000-0000-4000-8000-000000000003 blank_id=00000000-0000-4000-8000-000000000004 mounted_drift_id=10000000-0000-4000-8000-000000000002 unconfirmed_id=10000000-0000-4000-8000-000000000003 +mounted_wrong_id=10000000-0000-4000-8000-000000000004 cleanup() { set +e @@ -247,7 +248,7 @@ cat >"$simulate_mounted" <<'PY' import json import sys -mode, plan_path, state_path, log_path, output_path, fail_id, drift_id = sys.argv[1:] +mode, plan_path, state_path, log_path, output_path, fail_id, drift_id, wrong_id = sys.argv[1:] plan = json.load(open(plan_path, encoding="utf-8")) state = json.load(open(state_path, encoding="utf-8")) by_id = {thread["id"]: thread for thread in state["threads"]} @@ -257,6 +258,14 @@ def save(): json.dump(state, target, separators=(",", ":")) target.write("\n") +def decode_tool_result(value): + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None + def set_title(task_id, title, explicit): params = {"title": title} if explicit: @@ -271,7 +280,7 @@ def set_title(task_id, title, explicit): raise SystemExit("invalid simulated native setter input") by_id[task_id]["name"] = title save() - response = {"threadId": task_id, "title": title} + response = json.dumps({"threadId": task_id, "title": title}, separators=(",", ":")) record["response"] = response with open(log_path, "a", encoding="utf-8") as target: target.write(json.dumps(record, sort_keys=True) + "\n") @@ -283,7 +292,8 @@ def read_title(task_id): if task_id == drift_id: by_id[task_id]["name"] = "User rename at mounted revalidation" save() - response = {"thread": {"id": task_id, "title": by_id[task_id].get("name")}} + returned_id = "90000000-0000-4000-8000-000000000999" if task_id == wrong_id else task_id + response = json.dumps({"thread": {"id": returned_id, "title": by_id[task_id].get("name")}}, separators=(",", ":")) record = { "method": "codex_app__read_thread", "params": { @@ -308,10 +318,11 @@ if mode == "current": desired = plan.get("desired_title") if not isinstance(task_id, str) or not isinstance(desired, str): raise SystemExit("invalid current write plan") - response = set_title(task_id, desired, False) - if response is None: + raw_response = set_title(task_id, desired, False) + response = decode_tool_result(raw_response) + if raw_response is None: result = {"ready": False, "reason": "Codex title write failed"} - elif response.get("threadId") != task_id or response.get("title") != desired: + elif response is None or response.get("threadId") != task_id or response.get("title") != desired: result = {"ready": False, "reason": "Codex title write was not confirmed exactly"} else: result = {"ready": True, "task_id": task_id, "title": response["title"], "updated": True} @@ -325,11 +336,11 @@ elif mode == "onboard": skipped = 0 unconfirmed = 0 for item in prepared: - current = read_title(item["task_id"]) - if current is None or current.get("thread", {}).get("title") != item["title"]: + current = decode_tool_result(read_title(item["task_id"])) + if current is None or current.get("thread", {}).get("id") != item["task_id"] or current.get("thread", {}).get("title") != item["title"]: skipped += 1 continue - response = set_title(item["task_id"], item["desired_title"], True) + response = decode_tool_result(set_title(item["task_id"], item["desired_title"], True)) if response is not None and response.get("threadId") == item["task_id"] and response.get("title") == item["desired_title"]: updated += 1 else: @@ -614,6 +625,8 @@ import sys text = open(sys.argv[1], encoding="utf-8").read() assert text.count("title --status STATUS --json") == 1, text assert text.count("tools.codex_app__set_thread_title") == 1, text +assert "const decodeNative = value =>" in text, text +assert "decodeNative(await tools.codex_app__set_thread_title" in text, text assert "plan.write_required" in text, text assert "thread/name/set" not in text, text assert "PreToolUse" not in text and "PostToolUse" not in text, text @@ -626,7 +639,13 @@ assert text.count("tools.codex_app__set_thread_title") == 1, text assert text.count("tools.codex_app__read_thread") == 1, text assert text.count("tools.write_stdin") == 1, text assert 'item.outcome === "prepared"' in text, text -assert "current?.thread?.title !== item.title" in text, text +assert "const parseNative = value =>" in text, text +assert 'if (typeof value !== "string") return value;' in text, text +assert "try { return JSON.parse(value); } catch { return null; }" in text, text +assert "current = parseNative(await tools.codex_app__read_thread" in text, text +assert "renamed = parseNative(await tools.codex_app__set_thread_title" in text, text +assert "current?.thread?.id !== item.task_id" in text, text +assert "current.thread.title !== item.title" in text, text assert "updated + skipped + unconfirmed === prepared.length" in text, text assert "thread/name/set" not in text, text PY @@ -718,7 +737,7 @@ assert [message["params"] for message in pages] == [ ], pages assert not any(message.get("method") == "thread/name/set" for message in messages), messages PY -"$simulate_mounted" current "$root/title-complete-plan.json" "$app_server_state" "$native_tool_log" "$root/title-complete.json" "" "" +"$simulate_mounted" current "$root/title-complete-plan.json" "$app_server_state" "$native_tool_log" "$root/title-complete.json" "" "" "" python3 - "$root/title-complete.json" "$native_tool_log" "$current_id" <<'PY' import json import sys @@ -730,7 +749,7 @@ assert value == {"ready": True, "task_id": task_id, "title": "✅ Release smoke assert calls == [{ "method": "codex_app__set_thread_title", "params": {"title": "✅ Release smoke exact subject"}, - "response": {"threadId": task_id, "title": "✅ Release smoke exact subject"}, + "response": json.dumps({"threadId": task_id, "title": "✅ Release smoke exact subject"}, separators=(",", ":")), }], calls PY python3 - "$state_dir/subjects/$current_id.json" <<'PY' @@ -763,7 +782,7 @@ test ! -s "$app_server_log" || fail "missing caller started the App Server" : >"$app_server_log" : >"$native_tool_log" run_threadbear title --status next_steps --json >"$root/title-next-plan.json" -"$simulate_mounted" current "$root/title-next-plan.json" "$app_server_state" "$native_tool_log" "$root/title-next-failed.json" "$current_id" "" +"$simulate_mounted" current "$root/title-next-plan.json" "$app_server_state" "$native_tool_log" "$root/title-next-failed.json" "$current_id" "" "" python3 - "$root/title-next-plan.json" "$root/title-next-failed.json" "$native_tool_log" <<'PY' import json import sys @@ -790,7 +809,7 @@ PY : >"$app_server_log" : >"$native_tool_log" run_threadbear title --status automation --json >"$root/title-automation-plan.json" -"$simulate_mounted" current "$root/title-automation-plan.json" "$app_server_state" "$native_tool_log" "$root/title-automation.json" "" "" +"$simulate_mounted" current "$root/title-automation-plan.json" "$app_server_state" "$native_tool_log" "$root/title-automation.json" "" "" "" python3 - "$root/title-automation-plan.json" "$root/title-automation.json" "$native_tool_log" <<'PY' import json import sys @@ -802,7 +821,7 @@ assert plan["previous_title"] == "✅ Release smoke exact subject", plan assert plan["desired_title"] == "🤖 Release smoke exact subject", plan assert value["ready"] is True and value["updated"] is True, value assert value["title"] == "🤖 Release smoke exact subject", value -assert len(calls) == 1, calls +assert len(calls) == 1 and isinstance(calls[0].get("response"), str), calls PY python3 - "$app_server_log" <<'PY' import json @@ -882,8 +901,9 @@ PY # One confirmed production pass prepares subjects from the complete snapshot # without per-target RPCs. The mounted-native simulation then rereads every -# prepared title immediately before one possible setter; drift skips the write, -# and one injected setter failure proves exact accounting and no retry. +# prepared title immediately before one possible setter; drift and a same-title +# response for the wrong task both skip the write, while one injected setter +# failure proves exact accounting and no retry. : >"$app_server_log" : >"$native_tool_log" app_state_before_preparation=$(shasum -a 256 "$app_server_state" | awk '{print $1}') @@ -925,28 +945,28 @@ import sys assert json.load(open(sys.argv[1], encoding="utf-8")) == {"subject": "Existing task 003"} PY -"$simulate_mounted" onboard "$root/onboard-prepared-edge.json" "$app_server_state" "$native_tool_log" "$root/onboard-edge.json" "$unconfirmed_id" "$mounted_drift_id" -python3 - "$root/onboard-edge.json" "$native_tool_log" "$root/onboard-prepared-edge.json" "$unconfirmed_id" "$mounted_drift_id" <<'PY' +"$simulate_mounted" onboard "$root/onboard-prepared-edge.json" "$app_server_state" "$native_tool_log" "$root/onboard-edge.json" "$unconfirmed_id" "$mounted_drift_id" "$mounted_wrong_id" +python3 - "$root/onboard-edge.json" "$native_tool_log" "$root/onboard-prepared-edge.json" "$unconfirmed_id" "$mounted_drift_id" "$mounted_wrong_id" <<'PY' import json import sys value = json.load(open(sys.argv[1], encoding="utf-8")) calls = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] plan = json.load(open(sys.argv[3], encoding="utf-8")) -failed_id, drift_id = sys.argv[4:] +failed_id, drift_id, wrong_id = sys.argv[4:] assert value == { "ready": False, "plan_complete": True, "onboarding_complete": False, "total": 109, - "updated": 103, - "skipped": 1, - "unchanged": 5, + "updated": 102, + "skipped": 2, + "unchanged": 6, "unconfirmed": 1, }, value reads = [call for call in calls if call["method"] == "codex_app__read_thread"] sets = [call for call in calls if call["method"] == "codex_app__set_thread_title"] -assert len(reads) == 105 and len(sets) == 104 and len(calls) == 209, len(calls) +assert len(reads) == 105 and len(sets) == 103 and len(calls) == 208, len(calls) read_ids = [call["params"]["threadId"] for call in reads] set_ids = [call["params"]["threadId"] for call in sets] prepared = {item["task_id"]: item for item in plan["items"] if item["outcome"] == "prepared"} @@ -955,7 +975,13 @@ assert len(set_ids) == len(set(set_ids)), set_ids assert set(read_ids) == set(prepared), (read_ids, prepared) assert all(call["params"]["title"] == prepared[call["params"]["threadId"]]["desired_title"] for call in sets), sets assert read_ids.count(drift_id) == 1 and drift_id not in set_ids, (read_ids, set_ids) -assert next(call for call in reads if call["params"]["threadId"] == drift_id)["response"]["thread"]["title"] == "User rename at mounted revalidation", reads +drift_response = next(call for call in reads if call["params"]["threadId"] == drift_id)["response"] +assert isinstance(drift_response, str), drift_response +assert json.loads(drift_response)["thread"]["title"] == "User rename at mounted revalidation", reads +assert read_ids.count(wrong_id) == 1 and wrong_id not in set_ids, (read_ids, set_ids) +wrong_response = json.loads(next(call for call in reads if call["params"]["threadId"] == wrong_id)["response"]) +assert wrong_response["thread"]["id"] != wrong_id, wrong_response +assert wrong_response["thread"]["title"] == prepared[wrong_id]["title"], wrong_response assert set_ids.count(failed_id) == 1, set_ids assert all(call["params"] == { "threadId": call["params"]["threadId"], @@ -966,6 +992,8 @@ assert all(call["params"] == { assert all(calls[index - 1]["method"] == "codex_app__read_thread" and calls[index - 1]["params"]["threadId"] == call["params"]["threadId"] for index, call in enumerate(calls) if call["method"] == "codex_app__set_thread_title"), calls +assert all(isinstance(call.get("response"), str) for call in reads), reads +assert all(isinstance(call.get("response"), str) for call in sets if "error" not in call), sets assert sum("error" in call for call in sets) == 1, sets PY cmp "$codex_home/hooks.json" "$hooks_before" >/dev/null || @@ -979,8 +1007,8 @@ import sys value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["plan_complete"] is True, value assert value["total"] == 109 and value["safe"] == 107, value -assert value["needs_update"] == 3 and value["prepared"] == 0, value -assert value["unchanged"] == 104 and value["skipped"] == 2, value +assert value["needs_update"] == 4 and value["prepared"] == 0, value +assert value["unchanged"] == 103 and value["skipped"] == 2, value PY : >"$app_server_log" @@ -994,12 +1022,12 @@ value = json.load(open(sys.argv[1], encoding="utf-8")) assert value["ready"] is True and value["plan_complete"] is True, value assert value["read_only"] is False and value["onboarding_complete"] is False, value assert value["total"] == 109 and value["safe"] == 107, value -assert value["needs_update"] == 3 and value["prepared"] == 3, value -assert value["unchanged"] == 104 and value["skipped"] == 2, value +assert value["needs_update"] == 4 and value["prepared"] == 4, value +assert value["unchanged"] == 103 and value["skipped"] == 2, value messages = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] assert not any(message.get("method") in {"thread/read", "thread/name/set"} for message in messages), messages PY -"$simulate_mounted" onboard "$root/onboard-final-plan.json" "$app_server_state" "$native_tool_log" "$root/onboard-converged.json" "" "" +"$simulate_mounted" onboard "$root/onboard-final-plan.json" "$app_server_state" "$native_tool_log" "$root/onboard-converged.json" "" "" "" python3 - "$root/onboard-converged.json" "$native_tool_log" "$root/onboard-final-plan.json" <<'PY' import json import sys @@ -1012,20 +1040,20 @@ assert value == { "plan_complete": True, "onboarding_complete": True, "total": 109, - "updated": 3, + "updated": 4, "skipped": 0, - "unchanged": 106, + "unchanged": 105, "unconfirmed": 0, }, value reads = [call for call in calls if call["method"] == "codex_app__read_thread"] sets = [call for call in calls if call["method"] == "codex_app__set_thread_title"] prepared = {item["task_id"]: item for item in plan["items"] if item["outcome"] == "prepared"} -assert len(reads) == 3 and len(sets) == 3 and len(calls) == 6, calls -assert len({call["params"]["threadId"] for call in reads}) == 3, reads -assert len({call["params"]["threadId"] for call in sets}) == 3, sets +assert len(reads) == 4 and len(sets) == 4 and len(calls) == 8, calls +assert len({call["params"]["threadId"] for call in reads}) == 4, reads +assert len({call["params"]["threadId"] for call in sets}) == 4, sets assert {call["params"]["threadId"] for call in reads} == set(prepared), (reads, prepared) assert all(call["params"]["title"] == prepared[call["params"]["threadId"]]["desired_title"] for call in sets), sets -assert all("response" in call and "error" not in call for call in calls), calls +assert all(isinstance(call.get("response"), str) and "error" not in call for call in calls), calls PY : >"$app_server_log" diff --git a/site/install b/site/install index 71145f4..b75bb15 100644 --- a/site/install +++ b/site/install @@ -125,7 +125,7 @@ When that request arrives, read the installed skill and follow this protocol: ~/.local/bin/threadbear onboard --noninteractive --confirm --json ``` -The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, or changed title is skipped. Only an exact snapshot match may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. +The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, wrong-ID, or changed-title response is skipped. Only an exact task ID and snapshot title may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. Report `updated`, `skipped`, `unchanged`, and `unconfirmed`: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. From f8a3e166a550ad23ee51088028856310f68d58cf Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 21:16:42 -0400 Subject: [PATCH 05/14] test: record mounted restart proof [BEAR-116] --- docs/experiments/registry.json | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 455109f..d9ee5cc 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -242,6 +242,48 @@ "TB-EXP-0059-003" ] }, + { + "id": "TB-EXP-0116-002", + "date": "2026-08-07", + "issue": "BEAR-116", + "preflight_id": null, + "question": "Does the exact corrected ThreadBear planner plus mounted Codex native setter repaint current and historical task titles immediately and preserve both rendered titles across a clean Codex restart?", + "invariant": "The binary may plan and persist only a safe subject; the mounted Codex app is the sole title writer, and release success requires exact native response validation plus rendered sidebar and header proof before and after restart.", + "environment": { + "threadbear_version": "3.0.0 exact local candidate; arm64 CGO=0 binary SHA-256 f20bf5c778ce37ac2910680fea432179ffe7125f75b5a71ec149aee5ae6a2836", + "git_sha": "d9accd55418b37520e9a61a76f9bd0cdf73a19b8", + "codex_version": "Desktop 26.803.41515 build 6321; detached CLI 0.146.0", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "one active mounted task plus one visible historical target selected from a complete 360-task native catalog; controlled canaries began from exact undecorated subjects", + "restart_state": "both mounted titles were captured before restart; the replacement ChatGPT process started at 2026-08-07 21:07:48 EDT and both were recaptured in the mounted UI at 21:14:31 EDT", + "hook_fingerprint": "no ThreadBear title hook; hooks.json SHA-256 43d2619e3510054cc165f9b1f7915e16c20a162fa4427d2ed0b20294141ddf04 remained byte-identical", + "guidance_fingerprint": "installed skill SHA-256 d6936e3b44dce04507b4fc3919e927c96bc3c4f065ccabd0d58c545100454763; complete AGENTS.md SHA-256 b6828aa9f9e13e4305dcc2b268fcc3e5c7a2a4123325c2f0062d554079bf8dd7" + }, + "invocation": { + "outer_tool": "the exact installed ordinary-turn and onboarding JavaScript cells through functions.exec, followed by read-only Computer Use mounted inspection", + "code": "ordinary path: one local title plan, one current-task mounted setter with threadId omitted, exact raw-JSON response decoding and ID/title validation; onboarding path: complete preview, one safe prepared target, immediate mounted read requiring exact task ID and snapshot title, one explicit-target mounted setter, and exact result validation; then one clean Codex restart", + "native_tool_identity": "codex_app__set_thread_title as sole writer; codex_app__read_thread only for immediate onboarding drift validation; thread/list App Server reads only inside the local planner", + "target_identity_mode": "current task by omitted threadId; historical target by authoritative explicit ID after exact mounted ID/title reread" + }, + "evidence": [ + "linear:BEAR-116", + "github-pr:124", + "git:d9accd55418b37520e9a61a76f9bd0cdf73a19b8", + "codex-rollout:019fdcce-d0dc-7522-ad8e-a0c3c1c02110" + ], + "result": { + "status": "observed", + "summary": "The exact ordinary cell decoded the mounted raw JSON response, confirmed one write, and immediately repainted the active header and sidebar to `➡️ Diagnose ThreadBear failures`. The exact onboarding cell enumerated 360 tasks, prepared one controlled target, reread its exact ID/title, confirmed one write with zero unconfirmed outcomes, and immediately repainted its sidebar row to `🐻 Update website copy boxes`. After a clean Codex restart, both exact titles remained rendered and the installed binary, updater, guidance, and state remained healthy.", + "timing_ms": "unknown: both repaints were present in the next mounted accessibility capture; persistence was recaptured 6 minutes 43 seconds after the replacement process started", + "hook_participation": "none; current-format install left hooks.json byte-identical and all title mutation used the mounted native setter directly", + "rendered_proof": "Privacy-safe evidence retained outside the repository: corrected onboarding immediate repaint SHA-256 459700e6bfbb96a0d3a693024213ae6b2c496454fc490572487d939a92d6aada; corrected current-task immediate repaint SHA-256 f8f8596e8a348e168476ee01d4a886bf3ddbb5e517e6b3e4686114419f5d08c5; clean-restart persistence SHA-256 23887c1a498f1064db14f13431dc169395a448ba9e02cf43472e3d582c28e600" + }, + "confidence": "high", + "applicability": "Establishes the corrected planner-plus-mounted-writer product path, raw JSON-text decoding boundary, immediate current and historical repaint, and clean-restart persistence for exact artifact f20bf5c7 on Desktop 26.803.41515. It does not override TB-EXP-0116-001: detached App Server title mutation remains rejected, and future Desktop builds still require the recorded release canary.", + "supersedes": [], + "conflicts": [] + }, { "id": "TB-EXP-0102-001", "date": "2026-07-31", From 4cc3670911de9ac8f9c82a2b36a91b472ed84293 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 21:48:57 -0400 Subject: [PATCH 06/14] fix: close reviewed lifecycle gaps [BEAR-116] --- cmd/threadbear/install.go | 62 ++++++++++++++++++++++++++++++---- cmd/threadbear/install_test.go | 31 ++++++++++++++++- cmd/threadbear/update.go | 39 ++++++++++++++------- cmd/threadbear/update_test.go | 20 +++++++++-- docs/experiments/registry.json | 35 +++++++++++++++++-- 5 files changed, 162 insertions(+), 25 deletions(-) diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index cc2e880..b8bedea 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -106,6 +106,8 @@ func install(ctx context.Context, options installOptions) (any, error) { } var updateLock *os.File + // Manual install orders update -> stable boundary -> lifecycle. The updater + // already owns update.lock before its automatic child reaches this path. if !options.Automatic { updateLock, err = lifecycleLock("update.lock") if err != nil { @@ -113,6 +115,16 @@ func install(ctx context.Context, options installOptions) (any, error) { } defer unlock(updateLock) } + boundaryLock, err := lifecycleBoundaryLock() + if err != nil { + return preview, err + } + defer unlock(boundaryLock) + if updateLock != nil { + if err := currentLockPath(updateLock); err != nil { + return preview, err + } + } var lock *os.File if options.Automatic { lock, err = existingLifecycleLock("lifecycle.lock") @@ -314,12 +326,17 @@ func uninstall(ctx context.Context, options uninstallOptions) (any, error) { if !options.Confirmed { return preview, errors.New("uninstall requires --noninteractive --confirm after its preview") } - var lock *os.File - if !partialAdmission { - lock, err = existingLifecycleLock("lifecycle.lock") - if err != nil { + boundaryLock, err := lifecycleBoundaryLock() + if err != nil { + return preview, err + } + defer unlock(boundaryLock) + lock, err := existingLifecycleLock("lifecycle.lock") + if err != nil { + if !partialAdmission || !errors.Is(err, os.ErrNotExist) { return preview, err } + lock = nil } defer func() { if lock != nil { @@ -467,6 +484,38 @@ func existingLifecycleLock(name string) (*os.File, error) { return openLifecycleLock(name, false, false) } func updateCheckLock() (*os.File, error) { return openLifecycleLock("update.lock", false, true) } + +func lifecycleBoundaryLock() (*os.File, error) { + path := filepath.Dir(stateDir()) + if err := os.MkdirAll(path, 0o700); err != nil { + return nil, err + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if err := unix.Flock(fd, unix.LOCK_EX); err != nil { + return nil, errors.Join(err, file.Close()) + } + info, statErr := file.Stat() + current, pathErr := os.Lstat(path) + if statErr != nil || pathErr != nil || !current.IsDir() || !os.SameFile(info, current) { + unlock(file) + return nil, errors.Join(errors.New("ThreadBear lifecycle boundary changed while the operation was waiting"), statErr, pathErr) + } + return file, nil +} + +func currentLockPath(file *os.File) error { + info, statErr := file.Stat() + current, pathErr := os.Lstat(file.Name()) + if statErr != nil || pathErr != nil || !current.Mode().IsRegular() || current.Mode().Perm() != 0o600 || !os.SameFile(info, current) { + return errors.Join(errors.New("ThreadBear lifecycle changed while the operation was waiting"), statErr, pathErr) + } + return nil +} + func openLifecycleLock(name string, createDir, createFile bool) (*os.File, error) { if createDir { if err := os.MkdirAll(stateDir(), 0o700); err != nil { @@ -497,10 +546,9 @@ func openLifecycleLock(name string, createDir, createFile bool) (*os.File, error if err := unix.Flock(fd, unix.LOCK_EX); err != nil { return nil, errors.Join(err, file.Close()) } - current, pathErr := os.Lstat(file.Name()) - if pathErr != nil || !current.Mode().IsRegular() || current.Mode().Perm() != 0o600 || !os.SameFile(info, current) { + if err := currentLockPath(file); err != nil { unlock(file) - return nil, errors.Join(errors.New("ThreadBear lifecycle changed while the operation was waiting"), pathErr) + return nil, err } return file, nil } diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 655482d..404e17a 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -817,10 +817,23 @@ func TestUninstallInvalidatesPreopenedLifecycleWaiter(t *testing.T) { t.Fatalf("lifecycle waiter bypassed uninstall: %v", err) case <-time.After(50 * time.Millisecond): } + installer := make(chan error, 1) + go func() { + _, err := install(context.Background(), installOptions{Confirmed: true}) + installer <- err + }() + select { + case err := <-installer: + t.Fatalf("installer bypassed uninstall boundary: %v", err) + case <-time.After(50 * time.Millisecond): + } close(proceed) if err := <-uninstalled; err != nil { t.Fatal(err) } + if err := <-installer; err == nil || !strings.Contains(err.Error(), "changed while the operation was waiting") { + t.Fatalf("installer with stale update lock = %v", err) + } if err := <-waiter; err == nil || !strings.Contains(err.Error(), "changed while the operation was waiting") { t.Fatalf("preopened waiter = %v", err) } @@ -1020,7 +1033,23 @@ func TestUninstallPartialNamesStageAndSafeRerun(t *testing.T) { if err := os.Rename(backup, codexHome()); err != nil { t.Fatal(err) } - if _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + fence, err := newStore(stateDir()).lifecycleFence() + if err != nil { + t.Fatal(err) + } + rerun := make(chan error, 1) + go func() { + _, err := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + rerun <- err + }() + select { + case err := <-rerun: + unlock(fence) + t.Fatalf("partial rerun bypassed active title fence: %v", err) + case <-time.After(50 * time.Millisecond): + } + unlock(fence) + if err := <-rerun; err != nil { t.Fatalf("safe rerun failed: %v", err) } } diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go index 4b4f093..6281ad7 100644 --- a/cmd/threadbear/update.go +++ b/cmd/threadbear/update.go @@ -151,7 +151,12 @@ func update(ctx context.Context, automatic bool) (result any, returnErr error) { return nil, updateFailure("candidate_self_test", err) } receipt.RestartRequired = true - if err := requireCandidate(ctx, candidate, updateInstallTimeout, "install", "", "install", "--automatic", "--no-onboard", "--noninteractive", "--confirm", "--json"); err != nil { + candidateInstall, err := candidateResult(ctx, candidate, updateInstallTimeout, "install", "", "install", "--automatic", "--no-onboard", "--noninteractive", "--confirm", "--json") + if err != nil { + if candidateInstall != nil { + candidateInstall["install_stage"] = candidateInstall["stage"] + return candidateInstall, updateFailure("candidate_install", err) + } return nil, updateFailure("candidate_install", err) } if err := requireCandidate(ctx, candidate, updateCandidateTimeout, "status", manifest.Version, "status", "--json"); err != nil { @@ -270,30 +275,40 @@ func parseChecksum(data []byte) ([]byte, error) { return value, nil } func requireCandidate(parent context.Context, candidate string, timeout time.Duration, operation, expectedVersion string, args ...string) error { + _, err := candidateResult(parent, candidate, timeout, operation, expectedVersion, args...) + return err +} + +func candidateResult(parent context.Context, candidate string, timeout time.Duration, operation, expectedVersion string, args ...string) (map[string]any, error) { ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() - var output bytes.Buffer + var stdout, stderr bytes.Buffer command := exec.CommandContext(ctx, candidate, args...) - command.Stdout, command.Stderr = &output, &output + command.Stdout, command.Stderr = &stdout, &stderr err := command.Run() if ctx.Err() != nil { - return errors.New("candidate timed out") + return nil, errors.New("candidate timed out") } + var result map[string]any + decodeErr := json.Unmarshal(stdout.Bytes(), &result) if err != nil { - return fmt.Errorf("candidate failed: %w: %s", err, strings.TrimSpace(output.String())) + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + return result, fmt.Errorf("candidate failed: %w: %s", err, detail) } - var result map[string]any - if json.Unmarshal(output.Bytes(), &result) != nil { - return errors.New("candidate returned an invalid result") + if decodeErr != nil { + return nil, errors.New("candidate returned an invalid result") } if expectedVersion != "" && result["version"] != expectedVersion { - return errors.New("candidate version mismatch") + return result, errors.New("candidate version mismatch") } if operation != "version" && result["ready"] != true { - return errors.New("candidate returned an unhealthy result") + return result, errors.New("candidate returned an unhealthy result") } if operation == "install" && result["installed"] != true { - return errors.New("candidate did not confirm installation") + return result, errors.New("candidate did not confirm installation") } - return nil + return result, nil } diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go index 506be52..2f976ad 100644 --- a/cmd/threadbear/update_test.go +++ b/cmd/threadbear/update_test.go @@ -26,6 +26,7 @@ type updateFixtureOptions struct { AssetData, ChecksumBody []byte SelfTestMode string InstallFailure bool + StructuredInstallFailure bool AssetDelay time.Duration } @@ -245,6 +246,19 @@ func TestUpdateCommandReturnsTypedFailureStage(t *testing.T) { } } +func TestUpdateCommandPreservesStructuredCandidateInstallFailure(t *testing.T) { + prepareUpdate(t, "2.0.0", true) + startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1", StructuredInstallFailure: true}) + var output bytes.Buffer + if code := run(context.Background(), []string{"update", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 { + t.Fatalf("update exit = %d", code) + } + var result map[string]any + if json.Unmarshal(output.Bytes(), &result) != nil || result["stage"] != "candidate_install" || result["install_stage"] != "managed_guidance" || result["partial"] != true || result["restart_required"] != true || result["safe_rerun"] != "threadbear update --json" { + t.Fatalf("structured candidate failure = %s", output.String()) + } +} + func prepareUpdate(t *testing.T, current string, healthy bool) lifecyclePaths { t.Helper() p := isolatedLifecycle(t) @@ -276,7 +290,7 @@ func startUpdateFixture(t *testing.T, options updateFixtureOptions) *updateFixtu options.AssetKey = "darwin_arm64" } if options.AssetData == nil { - options.AssetData = candidateScript(options.CandidateVersion, options.SelfTestMode, options.InstallFailure) + options.AssetData = candidateScript(options.CandidateVersion, options.SelfTestMode, options.InstallFailure, options.StructuredInstallFailure) } if options.ChecksumBody == nil { digest := sha256.Sum256(options.AssetData) @@ -337,7 +351,7 @@ func (fixture *updateFixture) count(kind string) int { return fixture.requests[kind] } -func candidateScript(candidateVersion, selfTestMode string, installFailure bool) []byte { +func candidateScript(candidateVersion, selfTestMode string, installFailure, structuredInstallFailure bool) []byte { selfTest := fmt.Sprintf(`printf '{"ready":true,"version":"%s"}\n'`, candidateVersion) if selfTestMode == "fail" { selfTest = "echo self-test-failed >&2; exit 9" @@ -355,6 +369,8 @@ cp "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET" printf '{"ready":true,"installed":true}\n'` if installFailure { install = "echo install-failed >&2; exit 8" + } else if structuredInstallFailure { + install = `printf '{"ready":false,"partial":true,"stage":"managed_guidance","restart_required":true,"safe_rerun":"threadbear update --json"}\n'; exit 8` } return []byte(`#!/bin/sh case "$1" in diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index d9ee5cc..246ca4c 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -9,10 +9,9 @@ "status": "rejected", "supported_by": [], "contradicted_by": [ - "TB-EXP-0059-001", - "TB-EXP-0116-001" + "TB-EXP-0059-001" ], - "decision": "Detached thread/name/set can persist and read back an exact representable title without repainting the mounted Desktop catalog. TB-EXP-0059-003 did not isolate this boundary: its only accessibility proof followed Codex-hosted native setters, while its later standalone probes used list_threads rather than rendered inspection. ThreadBear must use the mounted Codex native setter for visible title mutation.", + "decision": "TB-EXP-0059-001 shows that detached thread/name/set can persist and read back an exact representable title without repainting the mounted Desktop catalog. TB-EXP-0116-001 reproduced that boundary without a declared preflight and remains exploratory corroboration rather than capability evidence. TB-EXP-0059-003 did not isolate this boundary: its only accessibility proof followed Codex-hosted native setters, while its later standalone probes used list_threads rather than rendered inspection. ThreadBear must use the mounted Codex native setter for visible title mutation.", "next_preflight": null }, { @@ -66,6 +65,15 @@ "contradicted_by": [], "decision": "Exact corrected candidate 40285dd completed the two declared clean-restart 98-task migrations in 5:26.311 and 4:15.414, followed by serial clean uninstalls in 1:35.540 and 1:32.440. Both cycles captured authoritative running and terminal current-home results plus immediate readback exactly equal to ThreadBear, reached migration_complete with 98 applied and zero remaining, archived every classifier and controller, preserved the neighboring user file byte-exactly, and left every ThreadBear-owned artifact absent. TB-EXP-0120-006 closes TB-PRE-0120-003 and establishes the bounded migration premise for this exact candidate, Desktop build, inventory, and protocol.", "next_preflight": null + }, + { + "id": "TB-CAP-MOUNTED-NATIVE-REPAINT", + "premise": "The final ThreadBear planner plus mounted Codex native setter repaints current and historical task titles immediately and preserves those rendered titles across a clean Codex restart.", + "status": "unresolved", + "supported_by": [], + "contradicted_by": [], + "decision": "TB-EXP-0116-002 is strong exploratory evidence, but it was collected without the registry's required preflight. Run the declared exact-final-candidate reproduction before using this premise for release.", + "next_preflight": "TB-PRE-0116-001" } ], "experiments": [ @@ -1142,6 +1150,27 @@ ], "stop_condition": "Stop after exactly two complete install/migration/uninstall cycles against the same corrected built candidate and binary hash. In each cycle capture authoritative running and terminal current-home results plus immediate readback exactly equal to ThreadBear, 98-applied/zero-remaining status and inventory, migration and uninstall timers, terminal privacy-safe screenshots, neighboring-user-content preservation, and final owned-artifact absence. Do not run a supplemental probe or claim capability until one linked result is recorded and this preflight is closed.", "result_experiment_id": "TB-EXP-0120-006" + }, + { + "id": "TB-PRE-0116-001", + "issue": "BEAR-116", + "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", + "status": "pending", + "consulted": [ + "TB-EXP-0059-001", + "TB-EXP-0059-002", + "TB-EXP-0116-001", + "TB-EXP-0116-002" + ], + "remaining_unknown": "Whether the exact post-review v3.0.0 candidate still produces immediate mounted current-task and historical-task repaint through the sole app-native writer and preserves both rendered titles after a clean Codex restart.", + "single_changed_variable": "Executable candidate and binary fingerprint: replace the earlier f20bf5c7 exploratory candidate with the final reviewed candidate containing only the lifecycle-lock and structured-update-receipt corrections required by PR #124 review.", + "held_constant": "Desktop 26.803.41515 build 6321; local macOS arm64 host; no ThreadBear title hooks or SQLite access; one exact current-task planner followed by one mounted setter with omitted task ID; one complete onboarding snapshot followed by one immediate exact-ID/title historical reread and one explicit-target mounted setter; raw JSON-text decoding; no retry or reconciliation; the same controlled current and historical subjects; one clean Codex restart; mounted header/sidebar capture before and after restart.", + "predicted_outcomes": [ + "If the final candidate preserves the corrected product path, both one-attempt mounted writes will return exact IDs and titles, repaint the mounted header/sidebar immediately, and remain rendered after the clean restart while status and updater health remain ready.", + "If either mounted response is non-exact, either row fails to repaint immediately, either title changes or disappears after restart, or the candidate reports unhealthy state, the release capability remains unproved and v3.0.0 must not ship." + ], + "stop_condition": "Stop after one exact current-task mounted write, one exact controlled historical onboarding write, immediate mounted inspection, one clean Codex restart, post-restart mounted inspection, and read-only status/artifact checks. Do not retry a failed title write, add another target, change architecture, or claim release readiness until the result is recorded and this preflight is closed.", + "result_experiment_id": "" } ] } From 3ec1d4c08be162ae8f5a8cb66cbe7916d2722a38 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 22:21:37 -0400 Subject: [PATCH 07/14] test: record final mounted restart proof [BEAR-116] --- docs/experiments/registry.json | 58 ++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 246ca4c..a87f3f4 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -69,11 +69,13 @@ { "id": "TB-CAP-MOUNTED-NATIVE-REPAINT", "premise": "The final ThreadBear planner plus mounted Codex native setter repaints current and historical task titles immediately and preserves those rendered titles across a clean Codex restart.", - "status": "unresolved", - "supported_by": [], + "status": "established", + "supported_by": [ + "TB-EXP-0116-003" + ], "contradicted_by": [], - "decision": "TB-EXP-0116-002 is strong exploratory evidence, but it was collected without the registry's required preflight. Run the declared exact-final-candidate reproduction before using this premise for release.", - "next_preflight": "TB-PRE-0116-001" + "decision": "TB-EXP-0116-003 executed the declared preflight against exact reviewed candidate f1ba8307. One current-task mounted write and one controlled historical mounted write returned exact IDs and titles, repainted the mounted UI, survived a full Codex process restart, and left the installed core and update-only LaunchAgent healthy. This establishes the release premise for that exact candidate and Desktop build; future Desktop builds still require the release canary.", + "next_preflight": null } ], "experiments": [ @@ -292,6 +294,50 @@ "supersedes": [], "conflicts": [] }, + { + "id": "TB-EXP-0116-003", + "date": "2026-08-07", + "issue": "BEAR-116", + "preflight_id": "TB-PRE-0116-001", + "question": "Does the exact post-review v3.0.0 candidate repaint one current task and one controlled historical task through the mounted native writer, then preserve both exact rendered titles across a full Codex process restart?", + "invariant": "The local binary prepares safe subjects and desired titles, the mounted Codex app performs the sole title mutation, and release success requires exact native responses plus rendered current and historical title persistence after a genuinely new Desktop process starts.", + "environment": { + "threadbear_version": "3.0.0 exact local candidate; arm64 CGO=0 binary SHA-256 f1ba83079d8bfbd0d2013e89aa76cf03fd320857e40bbb8c551792950d213e7a", + "git_sha": "4cc3670911de9ac8f9c82a2b36a91b472ed84293", + "codex_version": "Desktop 26.803.41515 build 6321; detached CLI 0.146.0", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "one active mounted task plus one visible historical target selected from a complete 360-task native catalog; both controlled canaries began from exact undecorated subjects", + "restart_state": "both mounted titles were captured before restart; the replacement ChatGPT process PID 16852 started at 2026-08-07 22:17:01 EDT and both exact titles were recaptured in the mounted UI at 22:18:31 EDT", + "hook_fingerprint": "no ThreadBear title hook; current-format title mutation used only the mounted native setter", + "guidance_fingerprint": "installed skill SHA-256 d6936e3b44dce04507b4fc3919e927c96bc3c4f065ccabd0d58c545100454763; complete AGENTS.md SHA-256 b6828aa9f9e13e4305dcc2b268fcc3e5c7a2a4123325c2f0062d554079bf8dd7" + }, + "invocation": { + "outer_tool": "the exact installed ordinary-turn and onboarding JavaScript cells through functions.exec, followed by read-only Computer Use mounted inspection before and after a full app quit and relaunch", + "code": "ordinary path: one local title plan and one current-task mounted setter with task ID omitted; onboarding path: complete 360-task preparation, exact mounted target ID/title reread, and one explicit-target mounted setter; both decoded raw JSON text and required exact returned ID/title", + "native_tool_identity": "codex_app__set_thread_title as sole writer; codex_app__read_thread only for immediate onboarding drift validation; paginated thread/list reads only inside the local planner", + "target_identity_mode": "current task by omitted task ID; historical target by authoritative explicit ID after exact mounted ID/title reread" + }, + "evidence": [ + "linear:BEAR-116", + "github-pr:124", + "git:4cc3670911de9ac8f9c82a2b36a91b472ed84293", + "codex-rollout:019fdcce-d0dc-7522-ad8e-a0c3c1c02110" + ], + "result": { + "status": "observed", + "summary": "The exact current-task cell confirmed one mounted write and repainted the active task to `➡️ Diagnose ThreadBear failures`. The exact onboarding cell enumerated 360 tasks, prepared one controlled target, confirmed one mounted write with zero skipped or unconfirmed outcomes, and repainted it to `🐻 Update website copy boxes`. After a full Codex quit and relaunch created a new process, both exact titles remained rendered. The exact candidate binary, guidance, subject store, and update-only LaunchAgent then remained ready and byte-identical.", + "timing_ms": "unknown: each immediate repaint was present in the next mounted capture; restart persistence was captured 90 seconds after the replacement app process started", + "hook_participation": "none; no current-format ThreadBear title hook participated and every title mutation used the mounted native setter directly", + "rendered_proof": "Privacy-safe evidence retained outside the repository: historical immediate repaint SHA-256 bf09d1a9590360345e694bd2c83a5dc7c41f6dd50b6c4c88163f886ffd865051; current immediate repaint SHA-256 6258278fcd0d5a944b7c426a43254d6c7148551f09f428a0dc84d36c3c5b05d8; full-process-restart persistence SHA-256 7e333370cad7054e69bd620ff03fc7d3b173d1c67c74872eb6ea25b1b7715621" + }, + "confidence": "high", + "applicability": "Closes TB-PRE-0116-001 and establishes the mounted-writer repaint and restart-persistence premise for exact candidate f1ba8307 on Desktop 26.803.41515. It supersedes the unpreflighted exploratory reproduction but does not override detached-writer rejection or remove the need for future Desktop-build release canaries.", + "supersedes": [ + "TB-EXP-0116-002" + ], + "conflicts": [] + }, { "id": "TB-EXP-0102-001", "date": "2026-07-31", @@ -1155,7 +1201,7 @@ "id": "TB-PRE-0116-001", "issue": "BEAR-116", "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", - "status": "pending", + "status": "closed", "consulted": [ "TB-EXP-0059-001", "TB-EXP-0059-002", @@ -1170,7 +1216,7 @@ "If either mounted response is non-exact, either row fails to repaint immediately, either title changes or disappears after restart, or the candidate reports unhealthy state, the release capability remains unproved and v3.0.0 must not ship." ], "stop_condition": "Stop after one exact current-task mounted write, one exact controlled historical onboarding write, immediate mounted inspection, one clean Codex restart, post-restart mounted inspection, and read-only status/artifact checks. Do not retry a failed title write, add another target, change architecture, or claim release readiness until the result is recorded and this preflight is closed.", - "result_experiment_id": "" + "result_experiment_id": "TB-EXP-0116-003" } ] } From 6e3da67c5f1943c84d3ba5a58bb5ba8cd16d9e49 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 22:28:27 -0400 Subject: [PATCH 08/14] fix: serialize uninstall with updater [BEAR-116] --- CHANGELOG.md | 2 +- cmd/threadbear/install.go | 20 ++++++++++++++++ cmd/threadbear/install_test.go | 42 ++++++++++++++++++++++++++++++++++ docs/experiments/registry.json | 28 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f46fe..adb588e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Made one short-lived official Codex App Server the read/planning authority and the mounted app-native setter the sole title writer: one prepared current-task title, one-time decoding of native JSON-text results, at most one native call, and exact returned ID/title validation, with no SQLite access, detached write, fallback, or retry. - Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each app-native task ID and title before its one possible write, and reports honest updated, skipped, unchanged, and unconfirmed counts. - Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. -- Separated title-core readiness from updater health and made successful updates report restart requirements; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. +- Separated title-core readiness from updater health, made successful updates report restart requirements, and made uninstall wait for any in-flight updater before teardown; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. - Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. ### Removed diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index b8bedea..0ad3330 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -326,11 +326,31 @@ func uninstall(ctx context.Context, options uninstallOptions) (any, error) { if !options.Confirmed { return preview, errors.New("uninstall requires --noninteractive --confirm after its preview") } + // Use the same update -> stable boundary -> lifecycle order as install. + // Waiting for an in-flight updater before teardown keeps its locked inode + // current, so it cannot resume later over a fresh installation. + updateLock, err := existingLifecycleLock("update.lock") + if err != nil { + if !partialAdmission || !errors.Is(err, os.ErrNotExist) { + return preview, err + } + updateLock = nil + } + defer func() { + if updateLock != nil { + unlock(updateLock) + } + }() boundaryLock, err := lifecycleBoundaryLock() if err != nil { return preview, err } defer unlock(boundaryLock) + if updateLock != nil { + if err := currentLockPath(updateLock); err != nil { + return preview, err + } + } lock, err := existingLifecycleLock("lifecycle.lock") if err != nil { if !partialAdmission || !errors.Is(err, os.ErrNotExist) { diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 404e17a..a67d8f9 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -850,6 +850,48 @@ func TestUninstallInvalidatesPreopenedLifecycleWaiter(t *testing.T) { unlock(lock) } +func TestUninstallWaitsForInFlightUpdaterBeforeTeardown(t *testing.T) { + p := isolatedLifecycle(t) + fake := currentFakeLaunchctl(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + updateLock, err := updateCheckLock() + if err != nil { + t.Fatal(err) + } + locked := true + t.Cleanup(func() { + if locked { + unlock(updateLock) + } + }) + done := make(chan error, 1) + go func() { + _, uninstallErr := uninstall(context.Background(), uninstallOptions{Confirmed: true}) + done <- uninstallErr + }() + select { + case err := <-done: + t.Fatalf("uninstall bypassed in-flight updater: %v", err) + case <-time.After(50 * time.Millisecond): + } + fake.mu.Lock() + bootouts := fake.bootouts + fake.mu.Unlock() + if bootouts != 0 { + t.Fatal("uninstall began teardown while an updater was active") + } + unlock(updateLock) + locked = false + if err := <-done; err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall retained binary: %v", err) + } +} + func TestUninstallRemovesOwnedArtifactsAndPreservesNeighbors(t *testing.T) { p := isolatedLifecycle(t) fake := currentFakeLaunchctl(t) diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index a87f3f4..0758fa7 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -76,6 +76,15 @@ "contradicted_by": [], "decision": "TB-EXP-0116-003 executed the declared preflight against exact reviewed candidate f1ba8307. One current-task mounted write and one controlled historical mounted write returned exact IDs and titles, repainted the mounted UI, survived a full Codex process restart, and left the installed core and update-only LaunchAgent healthy. This establishes the release premise for that exact candidate and Desktop build; future Desktop builds still require the release canary.", "next_preflight": null + }, + { + "id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", + "premise": "Confirmed uninstall waits for an in-flight verified updater before removing its lock or any managed surface, so that updater cannot later replace a fresh installation with its stale candidate.", + "status": "unresolved", + "supported_by": [], + "contradicted_by": [], + "decision": "The first stable-boundary fix did not cover an updater already holding the old update.lock inode. The corrected candidate now takes update.lock before the stable boundary and lifecycle fence, but the exact lock-hold teardown and final candidate still require the declared live proof before release.", + "next_preflight": "TB-PRE-0116-002" } ], "experiments": [ @@ -1217,6 +1226,25 @@ ], "stop_condition": "Stop after one exact current-task mounted write, one exact controlled historical onboarding write, immediate mounted inspection, one clean Codex restart, post-restart mounted inspection, and read-only status/artifact checks. Do not retry a failed title write, add another target, change architecture, or claim release readiness until the result is recorded and this preflight is closed.", "result_experiment_id": "TB-EXP-0116-003" + }, + { + "id": "TB-PRE-0116-002", + "issue": "BEAR-116", + "capability_id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", + "status": "pending", + "consulted": [ + "TB-EXP-0116-002", + "TB-EXP-0116-003" + ], + "remaining_unknown": "Whether the exact candidate with the corrected lock order truly waits for an already-running updater before teardown, completes cleanly after that updater releases its lock, and retains the already-proved mounted title behavior and restart persistence as the final release artifact.", + "single_changed_variable": "Lifecycle serialization and executable fingerprint: confirmed uninstall now acquires the existing update.lock before the stable boundary and lifecycle fence, replacing candidate f1ba8307 with the exact corrected build while leaving title planner, mounted writer, onboarding, guidance, and updater command unchanged.", + "held_constant": "Desktop 26.803.41515 build 6321; local macOS arm64 host; same current and historical controlled subjects; no title hooks, SQLite, detached writer, retry, or reconciliation; update-only LaunchAgent; exact release build flags; one serial current-task mounted write and one controlled historical mounted write; one full Codex process restart; read-only post-restart status and artifact checks.", + "predicted_outcomes": [ + "If the corrected lock order closes the race, confirmed uninstall will remain pending without booting out the updater while the current update.lock is held, complete only after that lock is released, leave no owned artifact, and the same exact candidate will pass the isolated release smoke, reinstall, mounted title checks, and full-restart persistence check.", + "If teardown can still bypass or deadlock with the updater, it will remove a managed surface before lock release, fail to finish after release, permit stale replacement, leave an owned artifact, fail the release smoke, or disturb the mounted title/restart behavior; the candidate must not merge or release." + ], + "stop_condition": "Stop after one timed hold of the live current update.lock around confirmed uninstall, one exact-candidate isolated release smoke, one live reinstall, one controlled current and historical mounted-write check, one full Codex process restart, and read-only runtime/artifact verification. Do not add another concurrency protocol, retry a title write, or claim release readiness until one linked result closes this preflight.", + "result_experiment_id": "" } ] } From 6511ab888c0cae8f05c58f193799c0cef498011d Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 23:04:52 -0400 Subject: [PATCH 09/14] test: record final updater teardown proof [BEAR-116] --- docs/experiments/registry.json | 61 +++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 0758fa7..8f3ed57 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -71,20 +71,23 @@ "premise": "The final ThreadBear planner plus mounted Codex native setter repaints current and historical task titles immediately and preserves those rendered titles across a clean Codex restart.", "status": "established", "supported_by": [ - "TB-EXP-0116-003" + "TB-EXP-0116-003", + "TB-EXP-0116-004" ], "contradicted_by": [], - "decision": "TB-EXP-0116-003 executed the declared preflight against exact reviewed candidate f1ba8307. One current-task mounted write and one controlled historical mounted write returned exact IDs and titles, repainted the mounted UI, survived a full Codex process restart, and left the installed core and update-only LaunchAgent healthy. This establishes the release premise for that exact candidate and Desktop build; future Desktop builds still require the release canary.", + "decision": "TB-EXP-0116-003 established the mounted-writer path, and TB-EXP-0116-004 repeated it against final corrected candidate b5cc0cbe. One current-task mounted write and one controlled historical mounted write returned exact IDs and titles, repainted the mounted UI, survived a full Codex process restart, and left the installed core and update-only LaunchAgent healthy. This establishes the release premise for the final candidate and Desktop build; future Desktop builds still require the release canary.", "next_preflight": null }, { "id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", "premise": "Confirmed uninstall waits for an in-flight verified updater before removing its lock or any managed surface, so that updater cannot later replace a fresh installation with its stale candidate.", - "status": "unresolved", - "supported_by": [], + "status": "established", + "supported_by": [ + "TB-EXP-0116-004" + ], "contradicted_by": [], - "decision": "The first stable-boundary fix did not cover an updater already holding the old update.lock inode. The corrected candidate now takes update.lock before the stable boundary and lifecycle fence, but the exact lock-hold teardown and final candidate still require the declared live proof before release.", - "next_preflight": "TB-PRE-0116-002" + "decision": "TB-EXP-0116-004 held the live current update.lock while confirmed uninstall ran. Teardown remained pending with every managed surface and the updater job intact, then completed immediately after the lock was released and left every owned artifact absent. The same exact candidate passed the isolated release smoke, reinstalled cleanly, preserved both mounted titles across a new Desktop process, and remained healthy after restart.", + "next_preflight": null } ], "experiments": [ @@ -347,6 +350,48 @@ ], "conflicts": [] }, + { + "id": "TB-EXP-0116-004", + "date": "2026-08-07", + "issue": "BEAR-116", + "preflight_id": "TB-PRE-0116-002", + "question": "Does the exact final v3.0.0 candidate serialize confirmed uninstall behind an already-running updater, then retain the mounted native repaint and clean-restart behavior already proved for the prior candidate?", + "invariant": "Confirmed uninstall must not remove the updater lock or any managed surface while an updater holds that lock; after release it must complete cleanly, and the exact same artifact must still pass the full release, mounted-title, restart-persistence, and runtime-health gates.", + "environment": { + "threadbear_version": "3.0.0 exact final local candidate; arm64 CGO=0 binary SHA-256 b5cc0cbe89cd7849e40c9cf49af95da183a093d528656a3869e21204a7f33cca", + "git_sha": "6e3da67c5f1943c84d3ba5a58bb5ba8cd16d9e49", + "codex_version": "Desktop 26.803.41515 build 6321; detached CLI 0.146.0", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "one active mounted task plus one controlled visible historical task selected from a complete 360-task native catalog; both canaries began from exact undecorated subjects", + "restart_state": "both mounted titles were captured before restart; the replacement ChatGPT process PID 31578 started at 2026-08-07 22:59:25 EDT and both exact titles were recaptured in the mounted UI at 23:01:31 EDT", + "hook_fingerprint": "no ThreadBear title hook; current-format title mutation used only the mounted native setter", + "guidance_fingerprint": "installed skill SHA-256 d6936e3b44dce04507b4fc3919e927c96bc3c4f065ccabd0d58c545100454763; complete AGENTS.md SHA-256 b6828aa9f9e13e4305dcc2b268fcc3e5c7a2a4123325c2f0062d554079bf8dd7" + }, + "invocation": { + "outer_tool": "one coordinated live update.lock hold around confirmed uninstall; the exact-candidate release-smoke script; the exact installed ordinary-turn and onboarding JavaScript cells through functions.exec; and read-only Computer Use inspection before and after a full app quit and relaunch", + "code": "hold the installed update.lock until explicit release; start confirmed uninstall and verify no teardown; release the lock and verify complete teardown; run exact-candidate smoke; reinstall the same artifact; perform one current-task mounted write and one historical mounted write with exact response checks; restart Codex; verify titles, status, hashes, and update-only LaunchAgent", + "native_tool_identity": "codex_app__set_thread_title as sole title writer; codex_app__read_thread only for immediate onboarding drift validation; paginated thread/list reads only inside the local planner", + "target_identity_mode": "current task by omitted task ID; historical target by authoritative explicit ID after exact mounted ID/title reread" + }, + "evidence": [ + "linear:BEAR-116", + "github-pr:124", + "git:6e3da67c5f1943c84d3ba5a58bb5ba8cd16d9e49", + "codex-rollout:019fdcce-d0dc-7522-ad8e-a0c3c1c02110" + ], + "result": { + "status": "observed", + "summary": "While the live updater lock was held, confirmed uninstall remained pending and the binary, skill, LaunchAgent plist, loaded updater job, and exact candidate SHA all remained intact. Releasing the lock let uninstall complete and left the binary, skill, plist, state directory, and loaded job absent. Candidate b5cc0cbe then passed the isolated exact-candidate release smoke, reinstalled healthy, repainted the active task to `➡️ Diagnose ThreadBear failures`, repainted the controlled historical task to `🐻 Update website copy boxes`, and preserved both titles across a genuinely new Desktop process. Post-restart status was ready with the exact update-only LaunchAgent loaded, and the binary plus managed guidance remained byte-identical.", + "timing_ms": "unknown: teardown remained blocked for the full explicit hold and completed immediately after release; each repaint was present in the next mounted capture; restart persistence was captured 126 seconds after the replacement app process started", + "hook_participation": "none; no current-format ThreadBear title hook participated and every title mutation used the mounted native setter directly", + "rendered_proof": "Privacy-safe evidence retained outside the repository: final-candidate immediate mounted repaint SHA-256 076c34e84e2833c65f7787185dac19375d3ca5d7a1a2f9be20f9516f1ef40aaf; full-process-restart persistence SHA-256 551a63af5d21c48762f18a29aa9e2ff2b141ceaf76dc0b6c267ba0a48c0f250a" + }, + "confidence": "high", + "applicability": "Closes TB-PRE-0116-002 and establishes both updater-teardown serialization and the mounted-writer restart-persistence premise for exact final candidate b5cc0cbe on Desktop 26.803.41515. It does not override detached-writer rejection or remove the need for future Desktop-build release canaries.", + "supersedes": [], + "conflicts": [] + }, { "id": "TB-EXP-0102-001", "date": "2026-07-31", @@ -1231,7 +1276,7 @@ "id": "TB-PRE-0116-002", "issue": "BEAR-116", "capability_id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", - "status": "pending", + "status": "closed", "consulted": [ "TB-EXP-0116-002", "TB-EXP-0116-003" @@ -1244,7 +1289,7 @@ "If teardown can still bypass or deadlock with the updater, it will remove a managed surface before lock release, fail to finish after release, permit stale replacement, leave an owned artifact, fail the release smoke, or disturb the mounted title/restart behavior; the candidate must not merge or release." ], "stop_condition": "Stop after one timed hold of the live current update.lock around confirmed uninstall, one exact-candidate isolated release smoke, one live reinstall, one controlled current and historical mounted-write check, one full Codex process restart, and read-only runtime/artifact verification. Do not add another concurrency protocol, retry a title write, or claim release readiness until one linked result closes this preflight.", - "result_experiment_id": "" + "result_experiment_id": "TB-EXP-0116-004" } ] } From 11d76a12addac86c79611801d387d0290a606b9c Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 23:18:56 -0400 Subject: [PATCH 10/14] fix: reject stale updater processes [BEAR-116] --- CHANGELOG.md | 2 +- cmd/threadbear/update.go | 33 +++++++++++++++++++++++++++++++++ cmd/threadbear/update_test.go | 27 +++++++++++++++++++++++++++ docs/experiments/registry.json | 25 ++++++++++++++++++++++--- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb588e..1f73136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Made one short-lived official Codex App Server the read/planning authority and the mounted app-native setter the sole title writer: one prepared current-task title, one-time decoding of native JSON-text results, at most one native call, and exact returned ID/title validation, with no SQLite access, detached write, fallback, or retry. - Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each app-native task ID and title before its one possible write, and reports honest updated, skipped, unchanged, and unconfirmed counts. - Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. -- Separated title-core readiness from updater health, made successful updates report restart requirements, and made uninstall wait for any in-flight updater before teardown; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. +- Separated title-core readiness from updater health, made successful updates report restart requirements, made uninstall wait for any in-flight updater before teardown, and made queued updater processes reject a replaced installed binary; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. - Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. ### Removed diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go index 6281ad7..e6963aa 100644 --- a/cmd/threadbear/update.go +++ b/cmd/threadbear/update.go @@ -25,6 +25,7 @@ const updateManifestLimit = int64(1 << 20) var updateReleaseBase, updateManifestURL = "https://github.com/ericlitman/threadbear/releases", "https://github.com/ericlitman/threadbear/releases/latest/download/latest.json" var updateClient, updateBinaryLimit = &http.Client{Timeout: 30 * time.Second}, int64(64 << 20) var updateGOOS, updateGOARCH, updateVersionTimeout, updateCandidateTimeout, updateInstallTimeout = runtime.GOOS, runtime.GOARCH, 30 * time.Second, 30 * time.Second, 2 * time.Minute +var updateExecutableDigest, updateExecutableDigestErr = runningExecutableDigest() type updateError struct { Stage string @@ -56,6 +57,9 @@ type updateReceipt struct { func update(ctx context.Context, automatic bool) (result any, returnErr error) { p := installPaths() + if err := requireRunningUpdateBinary(p.binary); err != nil { + return nil, updateFailure("installation", err) + } if err := requireCurrentFormatInstall(p); err != nil { return nil, updateFailure("installation", err) } @@ -64,6 +68,9 @@ func update(ctx context.Context, automatic bool) (result any, returnErr error) { return nil, updateFailure("busy", err) } defer unlock(updateLock) + if err := requireRunningUpdateBinary(p.binary); err != nil { + return nil, updateFailure("installation", err) + } if err := requireCurrentFormatInstall(p); err != nil { return nil, updateFailure("installation", err) } @@ -170,6 +177,32 @@ func update(ctx context.Context, automatic bool) (result any, returnErr error) { return result, nil } +func runningExecutableDigest() ([sha256.Size]byte, error) { + path, err := os.Executable() + if err != nil { + return [sha256.Size]byte{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return [sha256.Size]byte{}, err + } + return sha256.Sum256(data), nil +} + +func requireRunningUpdateBinary(path string) error { + if updateExecutableDigestErr != nil { + return updateExecutableDigestErr + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if sha256.Sum256(data) != updateExecutableDigest { + return errors.New("installed binary changed after this updater process started") + } + return nil +} + func writeUpdateReceiptForCurrentInstall(p lifecyclePaths, receipt updateReceipt) error { lock, err := existingLifecycleLock("lifecycle.lock") if err != nil { diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go index 2f976ad..3a8efb2 100644 --- a/cmd/threadbear/update_test.go +++ b/cmd/threadbear/update_test.go @@ -163,6 +163,33 @@ func TestUpdateSerializesConcurrentChecks(t *testing.T) { } } +func TestUpdateRefusesBinaryReplacementWhileWaitingForLock(t *testing.T) { + p := prepareUpdate(t, "2.1.2", true) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.1.3"}) + lock, err := lifecycleLock("update.lock") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, updateErr := update(context.Background(), false); done <- updateErr }() + select { + case err := <-done: + unlock(lock) + t.Fatalf("update bypassed update lock: %v", err) + case <-time.After(50 * time.Millisecond): + } + if err := writeAtomic(p.binary, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + unlock(lock) + t.Fatal(err) + } + unlock(lock) + err = <-done + requireUpdateStage(t, err, "installation") + if !strings.Contains(err.Error(), "installed binary changed") || fixture.count("manifest") != 0 { + t.Fatalf("stale updater admission = %v, manifest requests = %d", err, fixture.count("manifest")) + } +} + func TestUpdateCheckWaitsForLifecycleOnlyAtReceipt(t *testing.T) { prepareUpdate(t, "2.1.2", true) fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.1.2"}) diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 8f3ed57..281ad90 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -81,13 +81,13 @@ { "id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", "premise": "Confirmed uninstall waits for an in-flight verified updater before removing its lock or any managed surface, so that updater cannot later replace a fresh installation with its stale candidate.", - "status": "established", + "status": "unresolved", "supported_by": [ "TB-EXP-0116-004" ], "contradicted_by": [], - "decision": "TB-EXP-0116-004 held the live current update.lock while confirmed uninstall ran. Teardown remained pending with every managed surface and the updater job intact, then completed immediately after the lock was released and left every owned artifact absent. The same exact candidate passed the isolated release smoke, reinstalled cleanly, preserved both mounted titles across a new Desktop process, and remained healthy after restart.", - "next_preflight": null + "decision": "TB-EXP-0116-004 proves that uninstall drains a process already holding the original update.lock inode. The final review identified a narrower admission gap: an old updater can start before teardown, open the replacement lock after its pathname is unlinked, wait behind a fresh installer, and then act on that replacement installation. The corrected updater now fingerprints its running executable and rechecks the installed binary after locking; TB-PRE-0116-003 must prove that exact guard before release.", + "next_preflight": "TB-PRE-0116-003" } ], "experiments": [ @@ -1290,6 +1290,25 @@ ], "stop_condition": "Stop after one timed hold of the live current update.lock around confirmed uninstall, one exact-candidate isolated release smoke, one live reinstall, one controlled current and historical mounted-write check, one full Codex process restart, and read-only runtime/artifact verification. Do not add another concurrency protocol, retry a title write, or claim release readiness until one linked result closes this preflight.", "result_experiment_id": "TB-EXP-0116-004" + }, + { + "id": "TB-PRE-0116-003", + "issue": "BEAR-116", + "capability_id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", + "status": "pending", + "consulted": [ + "TB-EXP-0116-003", + "TB-EXP-0116-004" + ], + "remaining_unknown": "Whether an updater process from the pre-teardown executable rejects a different binary installed while it waits on a replacement update.lock, before it reads the release manifest or launches an automatic candidate install, and whether the exact corrected candidate retains the already-proved release, mounted-title, and restart behavior.", + "single_changed_variable": "Updater admission and executable fingerprint: the process records the SHA-256 of its own executable during package initialization and requires the installed binary to remain byte-identical both before and after acquiring update.lock. No lock order, state format, title path, onboarding path, LaunchAgent command, retry, or reconciliation changes.", + "held_constant": "Desktop 26.803.41515 build 6321; local macOS arm64 host; update -> stable boundary -> lifecycle lock order; exact uninstall sequence; task-blind update-only LaunchAgent; mounted native title writer; complete native onboarding catalog; same controlled current and historical subjects; exact release build flags; no hooks, SQLite, detached title writer, controller task, retry, or reconciliation.", + "predicted_outcomes": [ + "If the executable guard closes the admission gap, replacing the installed binary while an updater waits on update.lock will make that updater fail locally at installation admission with zero manifest requests, while the exact corrected candidate still passes the release smoke, live updater teardown, mounted title checks, clean restart, and post-restart health checks.", + "If the queued updater reaches the manifest, launches candidate installation, accepts the replacement binary, leaves a receipt in the replacement installation, or the correction disturbs any previously proved lifecycle or title behavior, the capability remains unresolved and v3.0.0 must not ship." + ], + "stop_condition": "Stop after one deterministic real-binary replacement-while-waiting probe with zero network admission, one exact-candidate release smoke, one live updater-lock uninstall check, one live reinstall, one controlled current and historical mounted-write check, one full Codex process restart, and read-only post-restart runtime/artifact verification. Do not add a generation file, another lock, retry a title write, or claim release readiness until one linked result closes this preflight.", + "result_experiment_id": "" } ] } From 2cd2d5377b19edf9b72b80e2297357f83459d7dd Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 7 Aug 2026 23:50:47 -0400 Subject: [PATCH 11/14] docs: make lifecycle guidance friendly and durable [BEAR-116] [BEAR-117] --- CHANGELOG.md | 3 +- INSTALL.md | 103 ++++++++++++++++++++++----- assets/help.txt | 12 ++-- assets/skill/SKILL.md | 27 ++++--- cmd/threadbear/site_contract_test.go | 83 +++++++++++++++++++-- docs/architecture.md | 2 + docs/compatibility.md | 2 + docs/live-eval.md | 4 +- docs/release-checklist.md | 4 +- site/install | 103 ++++++++++++++++++++++----- 10 files changed, 281 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f73136..f577d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ - Reset ThreadBear to one terminal local title command that preserves exact subjects, changes only the status icon, keeps actions in response prose, and leaves unsafe titles unchanged. - Made one short-lived official Codex App Server the read/planning authority and the mounted app-native setter the sole title writer: one prepared current-task title, one-time decoding of native JSON-text results, at most one native call, and exact returned ID/title validation, with no SQLite access, detached write, fallback, or retry. -- Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each app-native task ID and title before its one possible write, and reports honest updated, skipped, unchanged, and unconfirmed counts. +- Replaced controller migration with explicit, uncapped onboarding that prepares a fresh complete snapshot, immediately checks each app-native task ID and title before its one possible write, reports honest updated, skipped, unchanged, and unconfirmed counts, and discloses that cached historical rows may redraw on project reopen or restart. - Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. - Separated title-core readiness from updater health, made successful updates report restart requirements, made uninstall wait for any in-flight updater before teardown, and made queued updater processes reject a replaced installed binary; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. - Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. +- Made install, onboarding, manual update, and uninstall guidance friendly and plain: each action now previews what changes and what stays untouched, then leaves a durable final-response recap with results, uncertainty, and the next step. ### Removed diff --git a/INSTALL.md b/INSTALL.md index b75bb15..40fe03c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -14,14 +14,20 @@ Open with this orientation: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command safely prepares the title, then the mounted Codex app applies it once through the native title setter. +> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. It prepares one safe title, then Codex applies it once. > > I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. Codex collapses commentary after a turn finishes, so the final answer that asks for consent must repeat the orientation, readiness result, complete recommendation, and question. If a check fails, report it plainly and do not ask for install consent. +For every lifecycle action, write the lasting summary after all tool calls. End the final response with **ThreadBear recap 🐻** and include the result, counts or uncertainty, what stayed untouched, and the next action. Never leave that recap only in commentary, progress notices, notifications, or raw tool output; those can disappear when Codex summarizes the turn. + +Keep that recap user-facing: do not copy raw fields or list internal files and components. Translate them into helper, title memory, instructions, and daily update check. Group safe skips as “left unchanged” unless the user needs to act. + ## 1. Check without changing anything +Say: “First I'll check that this Mac is ready and preview the exact ThreadBear setup. Nothing changes in this step.” + Run: ```sh @@ -59,25 +65,26 @@ If the preview returns `legacy_reset_required:true`, require `legacy_main_task_i Only after the checks and dry run succeed, present this complete card in the same final answer as the consent question: -> ## Recommended setup +> ## Here's what will happen > -> - One status icon in each task title, prepared locally and updated once by the mounted Codex app immediately before the final response. -> - Your exact subject stays intact; owners and actions remain in response prose. -> - Unsafe, ambiguous, active, drifted, or overlong titles are left alone. -> - Existing tasks can be previewed completely and onboarded serially, with no item cap. -> - Small local footprint: one binary, tiny subject records, one skill, and one managed instruction block. -> - One daily LaunchAgent installs only verified official updates and never reads tasks or changes titles. -> - No persistent ThreadBear task, controller, classifier, archive automation, queue, or background repair. -> - Codex restarts once after install so open tasks load the new guidance. +> - ThreadBear adds one helpful status icon without rewriting your task's subject or emoji. +> - Existing tasks stay unchanged until you preview onboarding and approve it separately. +> - A small local helper, Codex instructions, private title memory, and a quiet daily update check are added. +> - The daily check never reads tasks or changes titles. +> - Unclear or unsafe titles are left alone, and there is no persistent ThreadBear task. +> - Other Codex settings and files stay untouched. +> - Codex restarts once so open tasks load the new instructions. > -> Install ThreadBear with this recommended setup? +> Install ThreadBear? -For a 2.2.1 reset, add one sentence: the exact old maintenance automation will be deleted, its exact former persistent task will be unpinned but not renamed, managed artifacts will be replaced, old state will not be imported, and ambiguous historical icons may remain. +For a 2.2.1 reset, add: “I'll remove only the verified old ThreadBear automation, unpin its former task without renaming it, and install the simpler version fresh. Old title history will not be guessed or imported, so some existing icons may remain.” A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. If the user does not want historical onboarding, accept that preference and add `--no-onboard` to the confirmed install. ## 3. Install after consent +Say: “Thanks—I'll install ThreadBear now, then check that it is healthy. Existing task titles will not change in this step.” + Before a 2.2.1 reset, delete the exact fingerprinted `threadbear-maintenance` automation through supported native control and verify it is absent. Then unpin the preview's exact legacy main-task ID and verify the returned and reread task ID match with `pinned:false`. Do not rename that task. Any mismatch aborts before filesystem reset. The confirmed candidate command must include `--reset`. For the official release, run: @@ -105,8 +112,18 @@ Core `ready` is healthy when the installed binary, private subject state, manage No controller, worker, migration phase, persistent task, or hidden onboarding job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. +After the checks finish, end the final response with this plain-language receipt, filled with the real result: + +> ## ThreadBear recap 🐻 +> +> - ThreadBear is installed and its daily update check is [ready / needs attention]. +> - Existing tasks have not been changed yet, and unrelated Codex settings stayed untouched. +> - Next: restart Codex, then open any task and say **ThreadBear onboard**. + ## 4. Restart and onboard +Say: “Installation is finished. One restart loads the new instructions; onboarding stays a separate previewed choice.” + After a successful install say: > ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. @@ -117,7 +134,18 @@ When that request arrives, read the installed skill and follow this protocol: 1. Run `~/.local/bin/threadbear status --json`, then `~/.local/bin/threadbear onboard --dry-run --json`. 2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any preparation or title write. If enumeration fails, make zero changes. -3. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. +3. Explain `total`, `safe`, and `needs_update` with this card: + +> ## Here's what will happen +> +> - I found N existing tasks. X have safe titles, and Y need a ThreadBear icon. +> - The rest stay untouched. +> - I'll check each task again immediately before its one possible title change. +> - If a title changed or the result is uncertain, I'll leave it alone and tell you. +> +> Update these existing tasks now? + +The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. 4. Ask for explicit consent unless unchanged install consent covered this first pass. 5. After consent, follow the installed skill's single onboarding JavaScript cell. Its first action runs exactly: @@ -127,7 +155,17 @@ When that request arrives, read the installed skill and follow this protocol: The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, wrong-ID, or changed-title response is skipped. Only an exact task ID and snapshot title may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. -Report `updated`, `skipped`, `unchanged`, and `unconfirmed`: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. +Codex can keep an already-mounted historical row cached after an exact native write. Do not retry or add refresh machinery. The persisted title appears when its project is reopened or Codex restarts; say this plainly in the onboarding summary. + +Report `updated`, `skipped`, `unchanged`, and `unconfirmed`. Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. + +End with: + +> ## ThreadBear recap 🐻 +> +> - Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z. +> - No uncertain task was retried. Older sidebar rows may refresh when their project reopens or Codex restarts. +> - Next: [ThreadBear is ready / rerun **ThreadBear onboard** after resolving the named problem]. ## Commands and updater @@ -143,6 +181,24 @@ The managed guidance runs one injection-safe terminal JavaScript cell immediatel `update` verifies the official manifest, release URLs, architecture, checksum, embedded version, and candidate self-test before replacement. Network or verification failure leaves the old installation untouched. A later managed-surface write can truthfully leave a rerunnable partial; the binary is written last. Every successful update reports `restart_required`. The daily LaunchAgent runs only this command and never reads tasks or changes titles. +For a manual update, preview first and end the consent turn with: + +> ## Here's what will happen +> +> - ThreadBear will download the official update, verify it, and replace its local helper only after the checks pass. +> - The update check does not read tasks or change titles. +> - I'll tell you whether Codex needs a restart. +> +> Update ThreadBear now? + +Afterward, end with: + +> ## ThreadBear recap 🐻 +> +> - ThreadBear is now version [version], and the daily update check is [ready / needs attention]. +> - Codex [does / does not] need a restart. +> - Next: [nothing—you're up to date / the one safe rerun for a partial update]. + ## Uninstall Preview first: @@ -151,9 +207,16 @@ Preview first: ~/.local/bin/threadbear uninstall --dry-run --json ``` -Explain: +End the consent turn with: -> Want me to uninstall ThreadBear? I'll remove only its binary, private subject records, managed guidance, installed skill, and daily updater. Existing title icons may remain until those tasks are renamed. Other Codex settings and files stay untouched. When removal finishes, you'll restart Codex. +> ## Here's what will happen +> +> - I'll remove ThreadBear's local helper, private title memory, Codex instructions, skill, and daily update check. +> - Your tasks, other Codex settings, and unrelated files stay untouched. +> - Existing title icons may remain until those tasks are renamed. +> - After removal, you'll restart Codex once. +> +> Uninstall ThreadBear now? After consent: @@ -163,6 +226,14 @@ After consent: Require committed removal and verify unrelated AGENTS content, skills, settings, files, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. +The final response after committed removal is: + +> ## ThreadBear recap 🐻 +> +> - ThreadBear and its daily update check were removed. +> - Your tasks and unrelated Codex content stayed untouched; old title icons may remain. +> - Next: restart Codex so open tasks drop the old instructions. + ## Release proof Before release, run unit and integration tests, race tests, both Darwin builds, shell checks, experiment validation, installer/guide parity, and the focused fixture smoke. diff --git a/assets/help.txt b/assets/help.txt index c9475cf..dab008c 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -1,16 +1,16 @@ -ThreadBear keeps Codex task titles useful with one local update at the end of each turn. +ThreadBear keeps Codex task titles useful with one status icon at the end of each turn. Usage: threadbear [flags] Commands: - install Preview or install ThreadBear + install Show what will change, then install ThreadBear title Prepare this task's safe status title for Codex to apply onboard Preview or prepare existing-task onboarding - status Check title-core readiness and updater health separately + status Check whether ThreadBear and daily updates are ready self-test Validate a release candidate - update Install the latest verified release and report restart need - uninstall Remove ThreadBear-owned local artifacts + update Verify and install the latest release + uninstall Show what will be removed, then remove ThreadBear version Show the installed version Every command accepts --json. @@ -39,3 +39,5 @@ Uninstall flags: --dry-run Preview without changing the machine --noninteractive Do not prompt --confirm Confirm removal + +Guided actions preview what will happen and what stays untouched before consent. After tools finish, the final response must recap the result, uncertainty, and next action so the summary stays visible. diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 52772a6..156e76b 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,32 +1,31 @@ --- name: threadbear -description: Operate ThreadBear for Codex Desktop on macOS. +description: Install, onboard, update, check, or uninstall ThreadBear for Codex Desktop on macOS. --- # ThreadBear -Be brief and warm. Explain effects first. Get explicit consent before install/reset, historical onboarding, manual update, or uninstall. +Be upbeat/plain. For each lifecycle change: -One terminal cell plans, then makes at most one mounted title write. Status changes only the icon; actions stay in prose. No persistent task or background title machinery. +- Before consent, end with **Here's what will happen**: changes, untouched items, restart, one question. +- After tools, end with **ThreadBear recap 🐻**: result/counts, uncertainty, next action. -## Help and status - -Run `status --json` before calling it healthy. `threadbear help` is authoritative; core and updater health are separate. +Never leave it in commentary/tool output. Recap visible facts—no JSON/self-test/state/files/planners/records/booleans. Safe skips are “left unchanged”; title failure is “this title stayed as-is.” Give partial work one safe next action. ## Install or reset -Follow `https://threadbear.sh/install` and candidate help. Preview the binary, subject records, guidance, skill, and updater. +Follow `https://threadbear.sh/install`. Preview helper, title memory, instructions, daily check; leave tasks/settings/titles. Restart, then onboard. -For a 2.2.1 reset, require the exact former task and automation. Delete only that automation, unpin only that task without renaming, and remove exact old title hooks. Verify deletion and unpin before `install --reset`; stop on mismatch. Import no old state or title guess. +For 2.2.1, verify old task/automation; delete/unpin only those, without renaming. Stop on mismatch; import nothing. -After consent, install; verify `version`, `self-test`, and `status` JSON. Ask for one restart, then say: +Install with consent; verify `version`, `self-test`, `status`. Recap: > Open any task after restart and say: **ThreadBear onboard** ## Onboard existing tasks -1. Run `status --json`, then `onboard --dry-run --json`. Require `ready:true`, `plan_complete:true`, and `read_only:true`; it must enumerate and deduplicate the complete unarchived App Server catalog. -2. Report `total`, `safe`, and `needs_update`. Leave active, blank, unsafe, ambiguous, or overlong titles unchanged; never adopt `preview`. Ask for explicit consent. +1. Run `status --json`, then `onboard --dry-run --json`. Require `ready:true`, `plan_complete:true`, `read_only:true`, full catalog. +2. Say: “I found N tasks. X are safe; Y need an icon. The rest stay untouched. I'll recheck each before one change.” Ask consent; ignore `preview`. 3. After consent, run this exact cell once: ```js @@ -98,12 +97,12 @@ text(JSON.stringify({ })); ``` -If it yields, wait for that cell; start no second process or title call. Show progress during preparation and every 25 items. Report: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Ready requires zero `unconfirmed`; drift is skipped. A later **ThreadBear onboard** replans. Never create a cap, wave, controller, worker task, queue, or persistent ThreadBear task. +Wait if yielded; progress every 25. Recap: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Ready needs no `unconfirmed`. Skip drift; no retry. Rows may refresh after reopen/restart. Reruns replan. No cap, controller, worker, queue, or persistent task. ## Update -The daily LaunchAgent runs only `threadbear update` and never reads tasks. For manual update, get consent, run `update --json`, and report `restart_required`. A partial is rerunnable; the binary is last. +Daily checks never read tasks. Preview download, verification, replacement, restart. After consent run `update --json`; recap version, restart, safe rerun. ## Uninstall -Run status and uninstall dry run. Explain removal and remaining icons; ask consent. Preserve unrelated content. Do not run the title cell again. Ask for restart. +Preview removing helper, title memory, instructions, skill, daily check; keep tasks/settings/files; icons may remain. Then no title cell. Recap exactly: “ThreadBear was removed. Its helper, title memory, instructions, skill, and daily check are gone. Tasks/settings/files stayed; icons may remain. Restart Codex.” diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 90059fa..9f61cb0 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -41,7 +41,21 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { t.Fatal("INSTALL.md and site/install must be byte-identical") } requireText(t, guide, - "mounted Codex app applies it once through the native title setter", + "It prepares one safe title, then Codex applies it once.", + "For every lifecycle action, write the lasting summary after all tool calls.", + "Nothing changes in this step.", + "Existing task titles will not change in this step.", + "onboarding stays a separate previewed choice.", + "Never leave that recap only in commentary, progress notices, notifications, or raw tool output", + "do not copy raw fields or list internal files and components", + "Group safe skips as “left unchanged” unless the user needs to act.", + "## Here's what will happen", + "## ThreadBear recap 🐻", + "Other Codex settings and files stay untouched.", + "Existing tasks have not been changed yet", + "Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z.", + "The update check does not read tasks or change titles.", + "ThreadBear and its daily update check were removed.", "--dry-run --json", "--noninteractive --confirm --json", "--no-onboard", @@ -54,7 +68,7 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "A missing, unreadable, wrong-ID, or changed-title response is skipped.", "tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})", "Every prepared item must reach exactly one outcome.", - "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", + "Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z.", "tools.codex_app__set_thread_title({title:plan.desired_title})", "one injection-safe terminal JavaScript cell", "never re-embedded by the model", @@ -144,12 +158,20 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { t.Fatalf("installed skill is %d bytes; compact-guide ceiling is 5 KiB", size) } requireText(t, protocol, - "Get explicit consent before install/reset, historical onboarding, manual update, or uninstall.", + "Be upbeat/plain.", + "Before consent, end with **Here's what will happen**", + "After tools, end with **ThreadBear recap 🐻**", + "Never leave it in commentary/tool output.", + "Recap visible facts", + "no JSON/self-test/state/files/planners/records/booleans", + "Safe skips are “left unchanged”", + "title failure is “this title stayed as-is.”", "## Install or reset", + "leave tasks/settings/titles", "## Onboard existing tasks", "onboard --dry-run --json", `\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json`, - "enumerate and deduplicate the complete unarchived App Server catalog", + "full catalog", `item.outcome === "prepared"`, `typeof item.title !== "string"`, "for (const item of prepared)", @@ -173,11 +195,13 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "onboarding_complete:accounted && unconfirmed === 0", "unchanged:plan.total - updated - unconfirmed", "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", - "Never create a cap, wave, controller, worker task, queue, or persistent ThreadBear task.", + "No cap, controller, worker, queue, or persistent task.", "## Update", - "`restart_required`", + "Preview download, verification, replacement, restart.", "## Uninstall", - "Do not run the title cell again.", + "keep tasks/settings/files; icons may remain.", + "no title cell.", + "Recap exactly: “ThreadBear was removed.", ) if count := strings.Count(protocol, "tools.codex_app__set_thread_title("); count != 1 { t.Fatalf("installed skill contains %d native title call sites; want one", count) @@ -205,6 +229,51 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { ) } +func TestLifecycleCopyLeavesADurableFriendlyRecap(t *testing.T) { + guide := readRepoFile(t, "INSTALL.md") + protocol := readRepoFile(t, "assets", "skill", "SKILL.md") + help := readRepoFile(t, "assets", "help.txt") + var userFacing strings.Builder + for _, line := range strings.Split(guide, "\n") { + if strings.HasPrefix(line, "> ") { + userFacing.WriteString(strings.TrimPrefix(line, "> ")) + userFacing.WriteByte('\n') + } + } + + if count := strings.Count(guide, "## Here's what will happen"); count < 4 { + t.Fatalf("install guide has %d lifecycle previews; want install, onboarding, update, and uninstall guidance", count) + } + if count := strings.Count(guide, "## ThreadBear recap 🐻"); count < 4 { + t.Fatalf("install guide has %d durable recaps; want universal plus lifecycle results", count) + } + requireText(t, guide, + "end the final response", + "after all tool calls", + "what stayed untouched", + "the next action", + "those can disappear when Codex summarizes the turn", + ) + requireText(t, protocol, + "Before consent, end with", + "After tools, end with", + "result/counts, uncertainty, next action", + ) + requireText(t, help, + "Show what will change, then install ThreadBear", + "Show what will be removed, then remove ThreadBear", + "the final response must recap the result, uncertainty, and next action", + ) + rejectText(t, userFacing.String(), + "binary", + "LaunchAgent", + "App Server", + "JSON", + "subject record", + "native setter", + ) +} + func TestCurrentDocsNameThePlannerAndSoleMountedWriter(t *testing.T) { for _, path := range [][]string{ {"README.md"}, diff --git a/docs/architecture.md b/docs/architecture.md index 090dae6..3cbb7ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,6 +45,8 @@ Mutation requires explicit consent. `onboard --noninteractive --confirm --json` The installed skill runs preparation and the native pass in one managed JavaScript cell. If the preparation process yields, the cell resumes that same process with `write_stdin`; it never starts another command. Immediately before each possible write, the cell serially reads the prepared target through the mounted app and requires the returned task ID and current title to equal the prepared ID and snapshot `title`. A read failure, wrong ID, or drift is `skipped` and receives no write. An exact match receives at most one native title call. Only an exact returned target ID and desired title counts as `updated`; a throw, undecodable or non-object response, or mismatch is `unconfirmed` and is never retried. +The current task repaints with its mounted write. An already-mounted historical row can retain a cached label after its exact write; the persisted native title appears on a supported project redraw or clean Codex restart. ThreadBear reports that boundary and never retries, reconciles, edits a private cache, or drives the sidebar UI. + The final receipt reports the complete catalog and `updated`, `skipped`, `unchanged`, and `unconfirmed` counts. Every prepared item must reach exactly one deliberate outcome. ThreadBear is ready only when all prepared items are accounted for and no call is unconfirmed; skipped tasks are honestly left unchanged. An interrupted pass may leave valid partial decoration. A rerun takes a fresh complete snapshot and continues without a controller, worker task, pending queue, or hidden resume state. diff --git a/docs/compatibility.md b/docs/compatibility.md index 2906594..4e017f8 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -8,6 +8,8 @@ When `write_required` is true, the same terminal cell calls the mounted Codex ap `onboard --dry-run --json` follows the complete unarchived `thread/list` catalog, tolerates interleaved notifications, and deduplicates IDs. Rows with null or blank `name` remain raw and unowned regardless of `preview`. Any page failure aborts before mutation. After consent, `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, and returns every prepared action with its snapshot title and desired title, no item cap, no per-target app read, and zero title writes. The installed skill resumes only that same preparation process if it yields, then serially reads each prepared target through the mounted app immediately before any explicit-target write. It decodes raw JSON-text reads and setter results once while retaining object compatibility. A read failure, wrong returned ID, or title drift is skipped without a write; an exact ID/title match receives at most one setter call. +An exact historical setter response proves persistence, not an immediate repaint of an already-mounted row. Codex may show that title only after the project is reopened or the app restarts. ThreadBear does not retry or manipulate the sidebar cache. + ThreadBear never opens Codex SQLite or edits Desktop storage. It runs no App Server daemon or proxy, keeps no App Server cache, uses no model, and has no retry or alternate read/write path. Visible titles are at most 60 UTF-16 units and never split a surrogate pair. A subject that would not fit intact is left unchanged. ThreadBear does not truncate it. Native acknowledgement is not rendered-product proof, so every release verifies the active header and sidebar before and after restart. diff --git a/docs/live-eval.md b/docs/live-eval.md index 3433fac..d0374da 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -32,10 +32,12 @@ After explicit consent, run exact `onboard --noninteractive --confirm --json`. P Run the installed skill's one serial native loop. Immediately before each possible write, require one mounted-app `read_thread` call with `includeOutputs:false`, `turnLimit:1`, and `maxOutputCharsPerItem:1`. Exercise raw JSON-text and already-decoded object results for both mounted tools. A missing or unreadable task, wrong returned task ID, or title that differs from the prepared snapshot is `skipped` and receives no setter call. Every exact ID/title match receives at most one explicit-target setter call for `🐻 `. Validate the exact returned ID/title and cover a throw, undecodable or non-object response, wrong target, and wrong title. Count every non-exact setter result as `unconfirmed` without retry. Interrupt a pass, rerun from another task, and prove completed titles are not doubled. Require serial read-before-write ordering, progress during preparation and every 25 outcomes, and a final receipt where every prepared row is exactly one of `updated`, `skipped`, or `unconfirmed`. Report `unchanged` honestly and report ready only when all prepared rows are accounted for and `unconfirmed` is zero. -Live-test the complete real local catalog with no artificial first-50 subset. Verify the rendered sidebar before and after a clean restart. +Live-test the complete real local catalog with no artificial first-50 subset. Capture the untouched sidebar after the historical write. If that mounted row is cached, reopen its project once and verify the persisted title appears; do not issue another title write. Verify both controlled titles again after a clean restart. ## Lifecycle +For install/reset, onboarding, manual update, and uninstall, verify the conversational layer as well as the command result. Before consent, the final response must use plain language to say what changes, what stays untouched, whether a restart follows, and ask one clear question. After all tools finish, the final response must end with a friendly recap containing the real result/counts, uncertainty, and next action. Summarize or reopen the task and confirm that recap remains visible and understandable; commentary, notifications, and raw tool output do not count. + Prove fresh install, reinstall, and a consented exact 2.2.1 reset. The preview exposes the legacy main-task ID and complete automation fingerprint. Verify collision and missing-target dry runs mutate nothing. After consent, delete and verify only the exact automation, unpin and verify only the exact former persistent task, and do not rename it. Either native failure aborts before filesystem reset. The completed reset imports no old state, leaves ambiguous legacy titles untouched, installs one daily updater, and requires restart. Exercise dry-run preflight against modified managed guidance, skill, LaunchAgent, and filesystem collisions. Exercise install/update and update/uninstall lock races; each loser reports busy without corrupting either lifecycle. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3b74ff8..e4b7825 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -8,9 +8,9 @@ Before tagging a stable release: 4. In isolated homes, prove fresh install, reinstall, dry-run collisions, status, update, uninstall, and the consented exact 2.2.1 reset while preserving unrelated AGENTS content, skills, settings, files, automations, and LaunchAgents. Confirm reset verifies automation deletion and exact-task unpin before filesystem mutation, imports no old state, and never renames the former persistent task. 5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one local `threadbear title --status ENUM --json` planner, parse only complete exit-zero JSON, make zero binary title writes, and make at most one mounted app-native call with `threadId` omitted. Exercise normal raw JSON-text and already-decoded object returns; reject malformed, non-object, or non-exact results. Require exact returned planned task ID/title. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, malformed output, native throws or non-exact returns, the rename race, and a native call that outlasts the initial 30-second outer yield. In the yielded case, prove the agent waits only for that same cell; the yield is not cancellation, the slow call may delay the response, and no second cell starts. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. 6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, prepares every eligible target with snapshot `title` and `desired_title`, has no cap, performs no per-target app read, and writes zero titles. Prove the exact embedded JavaScript resumes a yielded preparation process through `write_stdin` on the same session and starts no second process. Then prove it decodes raw JSON-text reads and setter results once, retains object compatibility, serially reads each prepared target immediately before any write, skips missing, unreadable, wrong-ID, or drifted responses without a setter call, makes at most one explicit-target setter call for an exact ID/title match, validates exact returned ID/title, never retries, reports progress, and accounts every prepared item as updated, skipped, or unconfirmed. `ready` and `onboarding_complete` require complete accounting and zero unconfirmed; skipped rows remain honestly unchanged. -7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Verify active-header and sidebar rendering before and after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. +7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Verify the active header immediately. Capture an untouched historical row after its write; if Codex keeps it cached, reopen the project once without another title call and verify the persisted title, then verify both titles after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. 8. Prove the daily LaunchAgent invokes only the verified updater. Network and verification failures preserve the old install; local write failures report `partial`, stage, restart implication, and one safe rerun with binary last; success reports `restart_required`. Prove updater health is separate from core `ready` and update/uninstall races are serialized. After uninstall commit, do not run the title command. -9. Confirm `assets/skill/SKILL.md` is below 5 KB, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. +9. Confirm `assets/skill/SKILL.md` is below 5 KB without minified safety code, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. Live-test the friendly install/onboarding/update/uninstall previews and final-response recaps. Summarize or reopen the task and prove the final recap remains visible after commentary and tool output collapse. Before the local Desktop canary, run the exact binary through the isolated lifecycle smoke with `THREADBEAR_SMOKE_CANDIDATE=/absolute/path/to/threadbear scripts/release-smoke.sh vN.N.N`. This mode skips only the not-yet-published download; the tagged release workflow runs the same smoke through the hosted installer and release assets. diff --git a/site/install b/site/install index b75bb15..40fe03c 100644 --- a/site/install +++ b/site/install @@ -14,14 +14,20 @@ Open with this orientation: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. Its small local command safely prepares the title, then the mounted Codex app applies it once through the native title setter. +> ThreadBear keeps each Codex task's exact subject and adds one useful status icon at the end of a turn. It prepares one safe title, then Codex applies it once. > > I'll check this Mac, show you exactly what will change, and ask before installing anything. Afterward, Codex needs one restart. Then you can say **ThreadBear onboard** in any task to update every safe existing local task—there is no 50-task cap or persistent ThreadBear task. Codex collapses commentary after a turn finishes, so the final answer that asks for consent must repeat the orientation, readiness result, complete recommendation, and question. If a check fails, report it plainly and do not ask for install consent. +For every lifecycle action, write the lasting summary after all tool calls. End the final response with **ThreadBear recap 🐻** and include the result, counts or uncertainty, what stayed untouched, and the next action. Never leave that recap only in commentary, progress notices, notifications, or raw tool output; those can disappear when Codex summarizes the turn. + +Keep that recap user-facing: do not copy raw fields or list internal files and components. Translate them into helper, title memory, instructions, and daily update check. Group safe skips as “left unchanged” unless the user needs to act. + ## 1. Check without changing anything +Say: “First I'll check that this Mac is ready and preview the exact ThreadBear setup. Nothing changes in this step.” + Run: ```sh @@ -59,25 +65,26 @@ If the preview returns `legacy_reset_required:true`, require `legacy_main_task_i Only after the checks and dry run succeed, present this complete card in the same final answer as the consent question: -> ## Recommended setup +> ## Here's what will happen > -> - One status icon in each task title, prepared locally and updated once by the mounted Codex app immediately before the final response. -> - Your exact subject stays intact; owners and actions remain in response prose. -> - Unsafe, ambiguous, active, drifted, or overlong titles are left alone. -> - Existing tasks can be previewed completely and onboarded serially, with no item cap. -> - Small local footprint: one binary, tiny subject records, one skill, and one managed instruction block. -> - One daily LaunchAgent installs only verified official updates and never reads tasks or changes titles. -> - No persistent ThreadBear task, controller, classifier, archive automation, queue, or background repair. -> - Codex restarts once after install so open tasks load the new guidance. +> - ThreadBear adds one helpful status icon without rewriting your task's subject or emoji. +> - Existing tasks stay unchanged until you preview onboarding and approve it separately. +> - A small local helper, Codex instructions, private title memory, and a quiet daily update check are added. +> - The daily check never reads tasks or changes titles. +> - Unclear or unsafe titles are left alone, and there is no persistent ThreadBear task. +> - Other Codex settings and files stay untouched. +> - Codex restarts once so open tasks load the new instructions. > -> Install ThreadBear with this recommended setup? +> Install ThreadBear? -For a 2.2.1 reset, add one sentence: the exact old maintenance automation will be deleted, its exact former persistent task will be unpinned but not renamed, managed artifacts will be replaced, old state will not be imported, and ambiguous historical icons may remain. +For a 2.2.1 reset, add: “I'll remove only the verified old ThreadBear automation, unpin its former task without renaming it, and install the simpler version fresh. Old title history will not be guessed or imported, so some existing icons may remain.” A clear yes to the unchanged recommendation is consent. Ask again only if the effect changes or the answer is ambiguous. If the user does not want historical onboarding, accept that preference and add `--no-onboard` to the confirmed install. ## 3. Install after consent +Say: “Thanks—I'll install ThreadBear now, then check that it is healthy. Existing task titles will not change in this step.” + Before a 2.2.1 reset, delete the exact fingerprinted `threadbear-maintenance` automation through supported native control and verify it is absent. Then unpin the preview's exact legacy main-task ID and verify the returned and reread task ID match with `pinned:false`. Do not rename that task. Any mismatch aborts before filesystem reset. The confirmed candidate command must include `--reset`. For the official release, run: @@ -105,8 +112,18 @@ Core `ready` is healthy when the installed binary, private subject state, manage No controller, worker, migration phase, persistent task, or hidden onboarding job should exist after installation. If installation fails after mutation starts, report `partial:true`, the failed stage, whether restart is required, and the one safe rerun action. `planned_changes` is a plan, not a claim that every item ran. +After the checks finish, end the final response with this plain-language receipt, filled with the real result: + +> ## ThreadBear recap 🐻 +> +> - ThreadBear is installed and its daily update check is [ready / needs attention]. +> - Existing tasks have not been changed yet, and unrelated Codex settings stayed untouched. +> - Next: restart Codex, then open any task and say **ThreadBear onboard**. + ## 4. Restart and onboard +Say: “Installation is finished. One restart loads the new instructions; onboarding stays a separate previewed choice.” + After a successful install say: > ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. @@ -117,7 +134,18 @@ When that request arrives, read the installed skill and follow this protocol: 1. Run `~/.local/bin/threadbear status --json`, then `~/.local/bin/threadbear onboard --dry-run --json`. 2. Require `ready:true`, `plan_complete:true`, and `read_only:true`. The preview enumerates and deduplicates the entire unarchived App Server catalog before any preparation or title write. If enumeration fails, make zero changes. -3. Explain `total`, `safe`, and `needs_update`. The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. +3. Explain `total`, `safe`, and `needs_update` with this card: + +> ## Here's what will happen +> +> - I found N existing tasks. X have safe titles, and Y need a ThreadBear icon. +> - The rest stay untouched. +> - I'll check each task again immediately before its one possible title change. +> - If a title changed or the result is uncertain, I'll leave it alone and tell you. +> +> Update these existing tasks now? + +The active caller, null or blank names, unsafe or overlong subjects, and ambiguous legacy titles stay unchanged. Preview text is never a title source. 4. Ask for explicit consent unless unchanged install consent covered this first pass. 5. After consent, follow the installed skill's single onboarding JavaScript cell. Its first action runs exactly: @@ -127,7 +155,17 @@ When that request arrives, read the installed skill and follow this protocol: The confirmed command takes a fresh complete catalog snapshot, stores each safe subject, and returns one `prepared` action containing the snapshot title and desired title. It makes no Codex title writes. If preparation yields, the same JavaScript cell resumes that exact process through `tools.write_stdin`; it never starts another command. For every prepared item, call `tools.codex_app__read_thread({threadId:item.task_id,includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})` immediately before a possible write. A missing, unreadable, wrong-ID, or changed-title response is skipped. Only an exact task ID and snapshot title may receive one serial `tools.codex_app__set_thread_title({threadId:item.task_id,title:item.desired_title})` call. Lightweight progress appears during preparation and every 25 outcomes. There is no item cap, wave, worker task, or resume state. Count only an exact returned task ID/title as `updated`; a throw, malformed response, or mismatch is `unconfirmed` and is never retried. -Report `updated`, `skipped`, `unchanged`, and `unconfirmed`: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. +Codex can keep an already-mounted historical row cached after an exact native write. Do not retry or add refresh machinery. The persisted title appears when its project is reopened or Codex restarts; say this plainly in the onboarding summary. + +Report `updated`, `skipped`, `unchanged`, and `unconfirmed`. Every prepared item must reach exactly one outcome. ThreadBear is ready only when all are accounted for and `unconfirmed` is zero. An interruption may leave valid partial decoration; a later **ThreadBear onboard** starts a fresh plan. + +End with: + +> ## ThreadBear recap 🐻 +> +> - Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z. +> - No uncertain task was retried. Older sidebar rows may refresh when their project reopens or Codex restarts. +> - Next: [ThreadBear is ready / rerun **ThreadBear onboard** after resolving the named problem]. ## Commands and updater @@ -143,6 +181,24 @@ The managed guidance runs one injection-safe terminal JavaScript cell immediatel `update` verifies the official manifest, release URLs, architecture, checksum, embedded version, and candidate self-test before replacement. Network or verification failure leaves the old installation untouched. A later managed-surface write can truthfully leave a rerunnable partial; the binary is written last. Every successful update reports `restart_required`. The daily LaunchAgent runs only this command and never reads tasks or changes titles. +For a manual update, preview first and end the consent turn with: + +> ## Here's what will happen +> +> - ThreadBear will download the official update, verify it, and replace its local helper only after the checks pass. +> - The update check does not read tasks or change titles. +> - I'll tell you whether Codex needs a restart. +> +> Update ThreadBear now? + +Afterward, end with: + +> ## ThreadBear recap 🐻 +> +> - ThreadBear is now version [version], and the daily update check is [ready / needs attention]. +> - Codex [does / does not] need a restart. +> - Next: [nothing—you're up to date / the one safe rerun for a partial update]. + ## Uninstall Preview first: @@ -151,9 +207,16 @@ Preview first: ~/.local/bin/threadbear uninstall --dry-run --json ``` -Explain: +End the consent turn with: -> Want me to uninstall ThreadBear? I'll remove only its binary, private subject records, managed guidance, installed skill, and daily updater. Existing title icons may remain until those tasks are renamed. Other Codex settings and files stay untouched. When removal finishes, you'll restart Codex. +> ## Here's what will happen +> +> - I'll remove ThreadBear's local helper, private title memory, Codex instructions, skill, and daily update check. +> - Your tasks, other Codex settings, and unrelated files stay untouched. +> - Existing title icons may remain until those tasks are renamed. +> - After removal, you'll restart Codex once. +> +> Uninstall ThreadBear now? After consent: @@ -163,6 +226,14 @@ After consent: Require committed removal and verify unrelated AGENTS content, skills, settings, files, and LaunchAgents remain byte-for-byte intact. After commit, do not run the title command. Ask the user to restart Codex so open tasks stop using snapshotted guidance. +The final response after committed removal is: + +> ## ThreadBear recap 🐻 +> +> - ThreadBear and its daily update check were removed. +> - Your tasks and unrelated Codex content stayed untouched; old title icons may remain. +> - Next: restart Codex so open tasks drop the old instructions. + ## Release proof Before release, run unit and integration tests, race tests, both Darwin builds, shell checks, experiment validation, installer/guide parity, and the focused fixture smoke. From b1e71f3b614a970230ab9d51314c1416b50c634d Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 8 Aug 2026 09:51:50 -0400 Subject: [PATCH 12/14] test: close final v3 release preflight [BEAR-116] [BEAR-117] --- CHANGELOG.md | 2 +- docs/experiments/registry.json | 58 ++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f577d71..a2606f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -## v3.0.0 - 2026-08-07 +## v3.0.0 - 2026-08-08 ### Changed diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 281ad90..39128f4 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-08-07", + "updated_at": "2026-08-08", "canonical_for": "ThreadBear title-path architecture and live experiments", "capabilities": [ { @@ -81,13 +81,14 @@ { "id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", "premise": "Confirmed uninstall waits for an in-flight verified updater before removing its lock or any managed surface, so that updater cannot later replace a fresh installation with its stale candidate.", - "status": "unresolved", + "status": "established", "supported_by": [ - "TB-EXP-0116-004" + "TB-EXP-0116-004", + "TB-EXP-0116-005" ], "contradicted_by": [], - "decision": "TB-EXP-0116-004 proves that uninstall drains a process already holding the original update.lock inode. The final review identified a narrower admission gap: an old updater can start before teardown, open the replacement lock after its pathname is unlinked, wait behind a fresh installer, and then act on that replacement installation. The corrected updater now fingerprints its running executable and rechecks the installed binary after locking; TB-PRE-0116-003 must prove that exact guard before release.", - "next_preflight": "TB-PRE-0116-003" + "decision": "TB-EXP-0116-004 proves that uninstall drains a process already holding the original update.lock inode. TB-EXP-0116-005 closes the narrower replacement-lock admission gap: an exact final updater waiting on update.lock rejected a different installed binary at local installation admission, before update work or a receipt, and the restored final candidate then passed release smoke and clean-restart health. Together they establish bounded updater teardown and stale-process rejection for the exact v3.0.0 candidate.", + "next_preflight": null } ], "experiments": [ @@ -392,6 +393,49 @@ "supersedes": [], "conflicts": [] }, + { + "id": "TB-EXP-0116-005", + "date": "2026-08-08", + "issue": "BEAR-116", + "preflight_id": "TB-PRE-0116-003", + "question": "Does an updater from the exact final v3.0.0 candidate reject a different binary installed while it waits on update.lock, and does the restored artifact retain the release, restart, title, and BEAR-117 conversational UX gates?", + "invariant": "A queued updater may act only while the installed binary remains byte-identical to the executable that started it; failure must be local before update work, and the exact restored candidate must remain healthy and user-legible after a clean restart.", + "environment": { + "threadbear_version": "3.0.0 exact final local candidate; arm64 CGO=0 binary SHA-256 489ca988d6a3c7487500c64366bd07e9a7cd35187dc46d38f19eb34274c84424; amd64 SHA-256 fd3d0684ee2ceb286adb3ff9a3c9fffc5b1e49cfd1b4f42b62ed4a1a32e16dd2", + "git_sha": "2cd2d5377b19edf9b72b80e2297357f83459d7dd", + "codex_version": "Desktop 26.803.41515 build 6321; detached CLI 0.146.0", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "active task rendered as `➡️ Diagnose ThreadBear failures`; dedicated local lifecycle task retained the final friendly uninstall recap; native catalog contained 360 tasks in the prior full-catalog product canary", + "restart_state": "the restored exact candidate launched replacement ChatGPT PID 83381 at 2026-08-08 09:46:08 EDT; the exact active title and lifecycle recap were present after restart, with the recap reread at 09:47:30 EDT", + "hook_fingerprint": "no current-format ThreadBear title hook; hooks.json SHA-256 43d2619e3510054cc165f9b1f7915e16c20a162fa4427d2ed0b20294141ddf04 remained outside the product path", + "guidance_fingerprint": "installed skill SHA-256 f78f5c58ded4d361be8feaca79be1f43d06fdf8eeb2fc52a7a1f21a4ad15191b at 4,884 bytes; complete AGENTS.md SHA-256 b6828aa9f9e13e4305dcc2b268fcc3e5c7a2a4123325c2f0062d554079bf8dd7" + }, + "invocation": { + "outer_tool": "one coordinated real update.lock hold and binary replacement; exact-candidate release smoke; exact install/status/self-test; Codex list_threads; and read-only Computer Use inspection across a full app quit and relaunch", + "code": "start the exact installed updater while a real process holds update.lock; verify both processes hold the same inode; replace the installed binary through the automatic-install path; release the lock; require local stage=installation rejection and no update receipt; restore the exact final candidate; run exact-candidate smoke; verify byte hashes, runtime health, current title, and the durable BEAR-117 recap after clean restart", + "native_tool_identity": "codex_app__set_thread_title remains the sole title writer; this experiment made no title mutation", + "target_identity_mode": "no title target; read-only mounted checks used the exact active and lifecycle task IDs" + }, + "evidence": [ + "linear:BEAR-116", + "linear:BEAR-117", + "github-pr:124", + "git:2cd2d5377b19edf9b72b80e2297357f83459d7dd", + "codex-rollout:019fdcce-d0dc-7522-ad8e-a0c3c1c02110" + ], + "result": { + "status": "observed", + "summary": "The exact 489ca988 updater blocked on the held update.lock. After the installed binary was replaced with prior candidate b5cc0cbe and the lock was released, the queued process exited `stage:installation` with `installed binary changed after this updater process started`; it created no update receipt and did not continue as the replacement install. The exact 489ca988 candidate was restored, reproduced from source, passed the isolated exact-candidate release smoke, reported version/self-test/status ready with the exact update-only LaunchAgent, and retained `➡️ Diagnose ThreadBear failures` after a full app restart. The lifecycle canary's final response remained visible after reopen and restart as `ThreadBear recap 🐻` with plain removal, preservation, icon, and restart language.", + "timing_ms": "the queued updater remained blocked for the explicit hold and rejected immediately after release; the restored recap was reread 82 seconds after the replacement ChatGPT process started", + "hook_participation": "none; the updater probe and recap inspection do not use title hooks", + "rendered_proof": "Privacy-safe final-response recap screenshot SHA-256 dbbb35cf4f5e8083abab5da57fcf7ffc0369691372d790ae3ebd2a5f46a6035a; final restored restart also has exact accessibility-text proof in the linked rollout" + }, + "confidence": "high", + "applicability": "Closes TB-PRE-0116-003 and establishes stale-updater rejection plus restored exact-candidate release health for v3.0.0 on Desktop 26.803.41515. The BEAR-117 evidence establishes final-response durability and tone for the recorded lifecycle canary; future Desktop builds and future user-facing guidance changes still require their release canaries.", + "supersedes": [], + "conflicts": [] + }, { "id": "TB-EXP-0102-001", "date": "2026-07-31", @@ -1295,7 +1339,7 @@ "id": "TB-PRE-0116-003", "issue": "BEAR-116", "capability_id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", - "status": "pending", + "status": "closed", "consulted": [ "TB-EXP-0116-003", "TB-EXP-0116-004" @@ -1308,7 +1352,7 @@ "If the queued updater reaches the manifest, launches candidate installation, accepts the replacement binary, leaves a receipt in the replacement installation, or the correction disturbs any previously proved lifecycle or title behavior, the capability remains unresolved and v3.0.0 must not ship." ], "stop_condition": "Stop after one deterministic real-binary replacement-while-waiting probe with zero network admission, one exact-candidate release smoke, one live updater-lock uninstall check, one live reinstall, one controlled current and historical mounted-write check, one full Codex process restart, and read-only post-restart runtime/artifact verification. Do not add a generation file, another lock, retry a title write, or claim release readiness until one linked result closes this preflight.", - "result_experiment_id": "" + "result_experiment_id": "TB-EXP-0116-005" } ] } From f745f50ae2e5c71805a282b03841c9b7ca1bdc67 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 8 Aug 2026 10:20:28 -0400 Subject: [PATCH 13/14] docs: make final lifecycle promises honest [BEAR-116] [BEAR-117] --- CHANGELOG.md | 2 +- INSTALL.md | 17 +++++----- assets/skill/SKILL.md | 16 ++++----- cmd/threadbear/managed_javascript_test.go | 2 +- cmd/threadbear/site_contract_test.go | 40 ++++++++++++++++++----- docs/architecture.md | 2 +- docs/experiments/registry.json | 26 +++++++++++++-- docs/release-checklist.md | 2 +- site/install | 17 +++++----- 9 files changed, 85 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2606f3..5c8741b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Removed title interception and configuration dependencies; installation now manages only the binary, subject records, guidance, skill, and daily update-only LaunchAgent. - Separated title-core readiness from updater health, made successful updates report restart requirements, made uninstall wait for any in-flight updater before teardown, and made queued updater processes reject a replaced installed binary; pre-install failures preserve the old install while later local write failures report a rerunnable partial with the binary last. - Made 2.2.1 upgrades an explicit reset that previews the exact automation and former persistent task, verifies deletion and unpinning before filesystem mutation, imports no state, and performs no heuristic title cleanup. -- Made install, onboarding, manual update, and uninstall guidance friendly and plain: each action now previews what changes and what stays untouched, then leaves a durable final-response recap with results, uncertainty, and the next step. +- Made install, onboarding, manual update, and uninstall guidance friendly and plain: each action now previews what changes and what stays untouched, clearly discloses verified automatic updates, distinguishes safe skips from uncertain writes, and leaves a durable final-response recap with the result and next step. ### Removed diff --git a/INSTALL.md b/INSTALL.md index 40fe03c..6bf38c3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -22,7 +22,7 @@ Codex collapses commentary after a turn finishes, so the final answer that asks For every lifecycle action, write the lasting summary after all tool calls. End the final response with **ThreadBear recap 🐻** and include the result, counts or uncertainty, what stayed untouched, and the next action. Never leave that recap only in commentary, progress notices, notifications, or raw tool output; those can disappear when Codex summarizes the turn. -Keep that recap user-facing: do not copy raw fields or list internal files and components. Translate them into helper, title memory, instructions, and daily update check. Group safe skips as “left unchanged” unless the user needs to act. +Keep that recap user-facing: do not copy raw fields or list internal files and components. Translate them into helper, title memory, instructions, and automatic updates. Group safe skips as “left unchanged” unless the user needs to act. An unconfirmed title write means “I couldn't confirm whether this title changed,” never “it stayed unchanged.” ## 1. Check without changing anything @@ -69,8 +69,8 @@ Only after the checks and dry run succeed, present this complete card in the sam > > - ThreadBear adds one helpful status icon without rewriting your task's subject or emoji. > - Existing tasks stay unchanged until you preview onboarding and approve it separately. -> - A small local helper, Codex instructions, private title memory, and a quiet daily update check are added. -> - The daily check never reads tasks or changes titles. +> - A small local helper, Codex instructions, and private title memory are added. +> - Once a day, ThreadBear checks for and installs only verified official releases. Updates never read tasks or change titles. > - Unclear or unsafe titles are left alone, and there is no persistent ThreadBear task. > - Other Codex settings and files stay untouched. > - Codex restarts once so open tasks load the new instructions. @@ -116,7 +116,7 @@ After the checks finish, end the final response with this plain-language receipt > ## ThreadBear recap 🐻 > -> - ThreadBear is installed and its daily update check is [ready / needs attention]. +> - ThreadBear is installed and automatic updates are [ready / need attention]. > - Existing tasks have not been changed yet, and unrelated Codex settings stayed untouched. > - Next: restart Codex, then open any task and say **ThreadBear onboard**. @@ -141,7 +141,8 @@ When that request arrives, read the installed skill and follow this protocol: > - I found N existing tasks. X have safe titles, and Y need a ThreadBear icon. > - The rest stay untouched. > - I'll check each task again immediately before its one possible title change. -> - If a title changed or the result is uncertain, I'll leave it alone and tell you. +> - If a title changed before its turn, I'll leave it alone. +> - If a change cannot be confirmed, I won't retry it and I'll tell you. > > Update these existing tasks now? @@ -195,7 +196,7 @@ Afterward, end with: > ## ThreadBear recap 🐻 > -> - ThreadBear is now version [version], and the daily update check is [ready / needs attention]. +> - ThreadBear is now version [version], and automatic updates are [ready / need attention]. > - Codex [does / does not] need a restart. > - Next: [nothing—you're up to date / the one safe rerun for a partial update]. @@ -211,7 +212,7 @@ End the consent turn with: > ## Here's what will happen > -> - I'll remove ThreadBear's local helper, private title memory, Codex instructions, skill, and daily update check. +> - I'll remove ThreadBear's local helper, private title memory, Codex instructions, skill, and automatic updates. > - Your tasks, other Codex settings, and unrelated files stay untouched. > - Existing title icons may remain until those tasks are renamed. > - After removal, you'll restart Codex once. @@ -230,7 +231,7 @@ The final response after committed removal is: > ## ThreadBear recap 🐻 > -> - ThreadBear and its daily update check were removed. +> - ThreadBear and its automatic updates were removed. > - Your tasks and unrelated Codex content stayed untouched; old title icons may remain. > - Next: restart Codex so open tasks drop the old instructions. diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 156e76b..8d6c2f6 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -8,13 +8,13 @@ description: Install, onboard, update, check, or uninstall ThreadBear for Codex Be upbeat/plain. For each lifecycle change: - Before consent, end with **Here's what will happen**: changes, untouched items, restart, one question. -- After tools, end with **ThreadBear recap 🐻**: result/counts, uncertainty, next action. +- After tools, end with **ThreadBear recap 🐻**: result, uncertainty, next action. -Never leave it in commentary/tool output. Recap visible facts—no JSON/self-test/state/files/planners/records/booleans. Safe skips are “left unchanged”; title failure is “this title stayed as-is.” Give partial work one safe next action. +Never leave it in commentary/tool output. Recap visible facts, not raw results or internal names. Safe skips are “left unchanged.” For an unconfirmed title say “I couldn't confirm whether this title changed.” Give partials one safe next action. ## Install or reset -Follow `https://threadbear.sh/install`. Preview helper, title memory, instructions, daily check; leave tasks/settings/titles. Restart, then onboard. +Follow `https://threadbear.sh/install`. Preview helper, title memory, instructions, automatic installation of verified official updates; leave tasks/settings/titles. Restart; then onboard. For 2.2.1, verify old task/automation; delete/unpin only those, without renaming. Stop on mismatch; import nothing. @@ -30,7 +30,7 @@ Install with consent; verify `version`, `self-test`, `status`. Recap: ```js // @exec: {"yield_time_ms": 30000, "max_output_tokens": 4000} -notify("ThreadBear onboarding: preparing complete catalog"); +notify("ThreadBear onboarding: preparing"); let local = await tools.exec_command({ cmd:"\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json", yield_time_ms:30000, @@ -57,7 +57,7 @@ if (!plan || plan.ready !== true || plan.plan_complete !== true || const prepared = plan.items.filter(item => item.outcome === "prepared"); if (prepared.some(item => !item || typeof item.task_id !== "string" || typeof item.title !== "string" || typeof item.desired_title !== "string")) { - text(JSON.stringify({ready:false,reason:"Invalid prepared item"})); exit(); + text(JSON.stringify({ready:false,reason:"Invalid item"})); exit(); } let updated = 0, skipped = 0, unconfirmed = 0; const parseNative = value => { @@ -97,12 +97,12 @@ text(JSON.stringify({ })); ``` -Wait if yielded; progress every 25. Recap: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Ready needs no `unconfirmed`. Skip drift; no retry. Rows may refresh after reopen/restart. Reruns replan. No cap, controller, worker, queue, or persistent task. +If yielded, wait; progress every 25. Recap: `Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.` Skip drift; no retry. Rows may refresh after reopen/restart. No cap or persistent task. ## Update -Daily checks never read tasks. Preview download, verification, replacement, restart. After consent run `update --json`; recap version, restart, safe rerun. +Automatic updates never read tasks. Preview official download, verification, replacement, restart. With consent run `update --json`; recap version, restart, safe rerun. ## Uninstall -Preview removing helper, title memory, instructions, skill, daily check; keep tasks/settings/files; icons may remain. Then no title cell. Recap exactly: “ThreadBear was removed. Its helper, title memory, instructions, skill, and daily check are gone. Tasks/settings/files stayed; icons may remain. Restart Codex.” +Run `uninstall --dry-run --json`. Preview removing helper, title memory, instructions, skill, automatic updates; keep tasks/settings/files; icons may remain. Ask consent. Run `uninstall --noninteractive --confirm --json`. Only `uninstalled:true` means removed; otherwise recap the partial and its one safe next action. After commit, no title cell. Recap exactly: “ThreadBear was removed. Its helper, title memory, instructions, skill, and automatic updates are gone. Tasks, settings, and files stayed; icons may remain. Restart Codex.” diff --git a/cmd/threadbear/managed_javascript_test.go b/cmd/threadbear/managed_javascript_test.go index dd513e3..f1140d0 100644 --- a/cmd/threadbear/managed_javascript_test.go +++ b/cmd/threadbear/managed_javascript_test.go @@ -141,7 +141,7 @@ process.stdout.write(JSON.stringify({trace,outputs,notices,writeCalls})); receipt.Unchanged != 4 || receipt.Unconfirmed != 1 { t.Fatalf("unexpected managed-loop receipt: %+v", receipt) } - if len(run.Notices) < 3 || run.Notices[0] != "ThreadBear onboarding: preparing complete catalog" || + if len(run.Notices) < 3 || run.Notices[0] != "ThreadBear onboarding: preparing" || run.Notices[len(run.Notices)-1] != "ThreadBear onboarding: 6/6" { t.Fatalf("unexpected progress notifications: %v", run.Notices) } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 9f61cb0..9881df8 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -49,13 +49,15 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "Never leave that recap only in commentary, progress notices, notifications, or raw tool output", "do not copy raw fields or list internal files and components", "Group safe skips as “left unchanged” unless the user needs to act.", + "I couldn't confirm whether this title changed", "## Here's what will happen", "## ThreadBear recap 🐻", "Other Codex settings and files stay untouched.", "Existing tasks have not been changed yet", "Checked N existing tasks: updated X, left Y unchanged, and could not confirm Z.", - "The update check does not read tasks or change titles.", - "ThreadBear and its daily update check were removed.", + "installs only verified official releases", + "Updates never read tasks or change titles.", + "ThreadBear and its automatic updates were removed.", "--dry-run --json", "--noninteractive --confirm --json", "--no-onboard", @@ -82,6 +84,9 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { "do not run the title command", ) rejectText(t, guide, + "quiet daily update check", + "this title stayed as-is", + "the result is uncertain, I'll leave it alone", "makes at most one App Server name update", "acknowledgement without exact readback", "only task read/write authority", @@ -105,6 +110,20 @@ func TestPublishedInstallGuideMatchesCurrentProduct(t *testing.T) { ) } +func TestReleaseDocsKeepTheEstablishedImmediateRepaintGate(t *testing.T) { + architecture := readRepoFile(t, "docs", "architecture.md") + checklist := readRepoFile(t, "docs", "release-checklist.md") + requireText(t, architecture, + "repaint one current task and one controlled historical task immediately", + "preserve both titles across a clean Codex restart", + ) + requireText(t, checklist, + "Require immediate mounted repaint for the active header and one controlled historical row", + "A stale controlled row fails this canary", + ) + rejectText(t, checklist, "if Codex keeps it cached, reopen the project once") +} + func TestInstalledGuidanceDefinesOneTerminalPlannerAndNativeWrite(t *testing.T) { guidance := readRepoFile(t, "assets", "AGENTS.threadbear.md") requireText(t, guidance, @@ -163,10 +182,11 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "After tools, end with **ThreadBear recap 🐻**", "Never leave it in commentary/tool output.", "Recap visible facts", - "no JSON/self-test/state/files/planners/records/booleans", - "Safe skips are “left unchanged”", - "title failure is “this title stayed as-is.”", + "not raw results or internal names", + "Safe skips are “left unchanged.”", + "I couldn't confirm whether this title changed", "## Install or reset", + "automatic installation of verified official updates", "leave tasks/settings/titles", "## Onboard existing tasks", "onboard --dry-run --json", @@ -195,14 +215,18 @@ func TestInstalledSkillStaysCompactAndRunsOneSerialNativePass(t *testing.T) { "onboarding_complete:accounted && unconfirmed === 0", "unchanged:plan.total - updated - unconfirmed", "Updated X of N existing tasks; Y were left unchanged; Z could not be confirmed.", - "No cap, controller, worker, queue, or persistent task.", + "No cap or persistent task.", "## Update", - "Preview download, verification, replacement, restart.", + "Preview official download, verification, replacement, restart.", "## Uninstall", + "uninstall --dry-run --json", + "uninstall --noninteractive --confirm --json", + "Only `uninstalled:true` means removed", "keep tasks/settings/files; icons may remain.", "no title cell.", "Recap exactly: “ThreadBear was removed.", ) + rejectText(t, protocol, "this title stayed as-is") if count := strings.Count(protocol, "tools.codex_app__set_thread_title("); count != 1 { t.Fatalf("installed skill contains %d native title call sites; want one", count) } @@ -257,7 +281,7 @@ func TestLifecycleCopyLeavesADurableFriendlyRecap(t *testing.T) { requireText(t, protocol, "Before consent, end with", "After tools, end with", - "result/counts, uncertainty, next action", + "result, uncertainty, next action", ) requireText(t, help, "Show what will change, then install ThreadBear", diff --git a/docs/architecture.md b/docs/architecture.md index 3cbb7ae..4d0babf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ Mutation requires explicit consent. `onboard --noninteractive --confirm --json` The installed skill runs preparation and the native pass in one managed JavaScript cell. If the preparation process yields, the cell resumes that same process with `write_stdin`; it never starts another command. Immediately before each possible write, the cell serially reads the prepared target through the mounted app and requires the returned task ID and current title to equal the prepared ID and snapshot `title`. A read failure, wrong ID, or drift is `skipped` and receives no write. An exact match receives at most one native title call. Only an exact returned target ID and desired title counts as `updated`; a throw, undecodable or non-object response, or mismatch is `unconfirmed` and is never retried. -The current task repaints with its mounted write. An already-mounted historical row can retain a cached label after its exact write; the persisted native title appears on a supported project redraw or clean Codex restart. ThreadBear reports that boundary and never retries, reconciles, edits a private cache, or drives the sidebar UI. +The release canary requires the mounted writer to repaint one current task and one controlled historical task immediately, then preserve both titles across a clean Codex restart. At runtime, exact native acknowledgement remains the only programmatic result. If another historical row stays cached, ThreadBear reports that it may refresh when its project reopens or Codex restarts; it never retries, reconciles, edits a private cache, or drives the sidebar UI. The final receipt reports the complete catalog and `updated`, `skipped`, `unchanged`, and `unconfirmed` counts. Every prepared item must reach exactly one deliberate outcome. ThreadBear is ready only when all prepared items are accounted for and no call is unconfirmed; skipped tasks are honestly left unchanged. diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 39128f4..fb8c359 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -69,14 +69,14 @@ { "id": "TB-CAP-MOUNTED-NATIVE-REPAINT", "premise": "The final ThreadBear planner plus mounted Codex native setter repaints current and historical task titles immediately and preserves those rendered titles across a clean Codex restart.", - "status": "established", + "status": "unresolved", "supported_by": [ "TB-EXP-0116-003", "TB-EXP-0116-004" ], "contradicted_by": [], - "decision": "TB-EXP-0116-003 established the mounted-writer path, and TB-EXP-0116-004 repeated it against final corrected candidate b5cc0cbe. One current-task mounted write and one controlled historical mounted write returned exact IDs and titles, repainted the mounted UI, survived a full Codex process restart, and left the installed core and update-only LaunchAgent healthy. This establishes the release premise for the final candidate and Desktop build; future Desktop builds still require the release canary.", - "next_preflight": null + "decision": "TB-EXP-0116-003 established the mounted-writer path, and TB-EXP-0116-004 repeated it against corrected candidate b5cc0cbe. The title implementation is unchanged, but the final BEAR-117 skill changes the embedded executable fingerprint, so TB-PRE-0116-004 must repeat the exact release canary before v3.0.0 ships.", + "next_preflight": "TB-PRE-0116-004" }, { "id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", @@ -1353,6 +1353,26 @@ ], "stop_condition": "Stop after one deterministic real-binary replacement-while-waiting probe with zero network admission, one exact-candidate release smoke, one live updater-lock uninstall check, one live reinstall, one controlled current and historical mounted-write check, one full Codex process restart, and read-only post-restart runtime/artifact verification. Do not add a generation file, another lock, retry a title write, or claim release readiness until one linked result closes this preflight.", "result_experiment_id": "TB-EXP-0116-005" + }, + { + "id": "TB-PRE-0116-004", + "issue": "BEAR-116", + "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", + "status": "pending", + "consulted": [ + "TB-EXP-0116-003", + "TB-EXP-0116-004", + "TB-EXP-0116-005" + ], + "remaining_unknown": "Whether the exact v3.0.0 artifact containing the final BEAR-117 consent, uncertainty, and uninstall protocol still repaints one current and one controlled historical task immediately, preserves both titles across a clean Codex restart, and leaves the lifecycle recap visible after reopen and restart.", + "single_changed_variable": "Embedded skill and executable fingerprint: replace candidate 489ca988 with the final reviewed skill that discloses automatic verified installs, runs previewed confirmed uninstall before claiming removal, and reports unconfirmed title writes as unknown. The planner, mounted writer, onboarding read-before-write guard, one-attempt rule, and lifecycle implementation are unchanged.", + "held_constant": "Desktop 26.803.41515 build 6321; local macOS arm64 host; same controlled current and historical subjects; one exact current-task mounted write with task ID omitted; one complete onboarding plan, exact immediate historical ID/title reread, and one explicit-target mounted write; raw JSON-text decoding; no hooks, SQLite, detached writer, retry, reconciliation, or cache edit; one clean Codex restart; exact install/status/self-test and updater checks.", + "predicted_outcomes": [ + "If the final embedded skill preserves the proved release path, both one-attempt writes return exact IDs and titles, repaint the mounted current and controlled historical rows immediately, remain rendered after restart, and the installed artifact stays healthy while the final lifecycle recap remains visible and accurate.", + "If either native result is non-exact, either controlled row stays stale, either title changes or disappears after restart, the artifact is unhealthy, or the lifecycle task claims removal before committed uninstall or loses its recap after reopen/restart, v3.0.0 must not ship." + ], + "stop_condition": "Stop after one exact current-task mounted write, one exact controlled historical onboarding write, immediate mounted inspection, one previewed and confirmed lifecycle canary with its final recap, one clean Codex restart, post-restart mounted inspection, and read-only runtime/artifact checks. Do not retry a title write, add another target, add refresh machinery, or claim release readiness until one linked result closes this preflight.", + "result_experiment_id": "" } ] } diff --git a/docs/release-checklist.md b/docs/release-checklist.md index e4b7825..59c434b 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -8,7 +8,7 @@ Before tagging a stable release: 4. In isolated homes, prove fresh install, reinstall, dry-run collisions, status, update, uninstall, and the consented exact 2.2.1 reset while preserving unrelated AGENTS content, skills, settings, files, automations, and LaunchAgents. Confirm reset verifies automation deletion and exact-task unpin before filesystem mutation, imports no old state, and never renames the former persistent task. 5. Prove one bounded terminal JavaScript cell is the last tool action before the final response. It must run exactly one local `threadbear title --status ENUM --json` planner, parse only complete exit-zero JSON, make zero binary title writes, and make at most one mounted app-native call with `threadId` omitted. Exercise normal raw JSON-text and already-decoded object returns; reject malformed, non-object, or non-exact results. Require exact returned planned task ID/title. Cover every status, user renames, unsafe input, missing ID, null name, App Server failures, malformed output, native throws or non-exact returns, the rename race, and a native call that outlasts the initial 30-second outer yield. In the yielded case, prove the agent waits only for that same cell; the yield is not cancellation, the slow call may delay the response, and no second cell starts. Prove no SQLite access, model, daemon, proxy, cache, retry, fallback, queue, controller, or repair state. 6. Prove `onboard --dry-run --json` follows App Server pagination past 100 tasks, tolerates notifications, deduplicates IDs, applies no source-label exclusion, treats null and blank names as raw, never adopts `preview`, and returns no partial plan after a later-page failure. After consent, prove exact `onboard --noninteractive --confirm --json` takes a fresh complete snapshot, stores safe subjects, prepares every eligible target with snapshot `title` and `desired_title`, has no cap, performs no per-target app read, and writes zero titles. Prove the exact embedded JavaScript resumes a yielded preparation process through `write_stdin` on the same session and starts no second process. Then prove it decodes raw JSON-text reads and setter results once, retains object compatibility, serially reads each prepared target immediately before any write, skips missing, unreadable, wrong-ID, or drifted responses without a setter call, makes at most one explicit-target setter call for an exact ID/title match, validates exact returned ID/title, never retries, reports progress, and accounts every prepared item as updated, skipped, or unconfirmed. `ready` and `onboarding_complete` require complete accounting and zero unconfirmed; skipped rows remain honestly unchanged. -7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Verify the active header immediately. Capture an untouched historical row after its write; if Codex keeps it cached, reopen the project once without another title call and verify the persisted title, then verify both titles after restart. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. +7. Install the exact candidate locally, restart Codex, and run `docs/live-eval.md`. Require immediate mounted repaint for the active header and one controlled historical row after their sole writes, then verify both titles after restart. A stale controlled row fails this canary; do not treat project reopen as immediate-repaint proof. Live-test the full local onboarding corpus, not an arbitrary subset. Disable rewriting if it practically corrupts titles or blocks responses. Open SWE is review/merge only for Desktop behavior, not the implementation or live-proof environment. 8. Prove the daily LaunchAgent invokes only the verified updater. Network and verification failures preserve the old install; local write failures report `partial`, stage, restart implication, and one safe rerun with binary last; success reports `restart_required`. Prove updater health is separate from core `ready` and update/uninstall races are serialized. After uninstall commit, do not run the title command. 9. Confirm `assets/skill/SKILL.md` is below 5 KB without minified safety code, `INSTALL.md` and `site/install` are byte-identical, current docs describe no historical controller protocol as product behavior, and the homepage matches the release. Live-test the friendly install/onboarding/update/uninstall previews and final-response recaps. Summarize or reopen the task and prove the final recap remains visible after commentary and tool output collapse. diff --git a/site/install b/site/install index 40fe03c..6bf38c3 100644 --- a/site/install +++ b/site/install @@ -22,7 +22,7 @@ Codex collapses commentary after a turn finishes, so the final answer that asks For every lifecycle action, write the lasting summary after all tool calls. End the final response with **ThreadBear recap 🐻** and include the result, counts or uncertainty, what stayed untouched, and the next action. Never leave that recap only in commentary, progress notices, notifications, or raw tool output; those can disappear when Codex summarizes the turn. -Keep that recap user-facing: do not copy raw fields or list internal files and components. Translate them into helper, title memory, instructions, and daily update check. Group safe skips as “left unchanged” unless the user needs to act. +Keep that recap user-facing: do not copy raw fields or list internal files and components. Translate them into helper, title memory, instructions, and automatic updates. Group safe skips as “left unchanged” unless the user needs to act. An unconfirmed title write means “I couldn't confirm whether this title changed,” never “it stayed unchanged.” ## 1. Check without changing anything @@ -69,8 +69,8 @@ Only after the checks and dry run succeed, present this complete card in the sam > > - ThreadBear adds one helpful status icon without rewriting your task's subject or emoji. > - Existing tasks stay unchanged until you preview onboarding and approve it separately. -> - A small local helper, Codex instructions, private title memory, and a quiet daily update check are added. -> - The daily check never reads tasks or changes titles. +> - A small local helper, Codex instructions, and private title memory are added. +> - Once a day, ThreadBear checks for and installs only verified official releases. Updates never read tasks or change titles. > - Unclear or unsafe titles are left alone, and there is no persistent ThreadBear task. > - Other Codex settings and files stay untouched. > - Codex restarts once so open tasks load the new instructions. @@ -116,7 +116,7 @@ After the checks finish, end the final response with this plain-language receipt > ## ThreadBear recap 🐻 > -> - ThreadBear is installed and its daily update check is [ready / needs attention]. +> - ThreadBear is installed and automatic updates are [ready / need attention]. > - Existing tasks have not been changed yet, and unrelated Codex settings stayed untouched. > - Next: restart Codex, then open any task and say **ThreadBear onboard**. @@ -141,7 +141,8 @@ When that request arrives, read the installed skill and follow this protocol: > - I found N existing tasks. X have safe titles, and Y need a ThreadBear icon. > - The rest stay untouched. > - I'll check each task again immediately before its one possible title change. -> - If a title changed or the result is uncertain, I'll leave it alone and tell you. +> - If a title changed before its turn, I'll leave it alone. +> - If a change cannot be confirmed, I won't retry it and I'll tell you. > > Update these existing tasks now? @@ -195,7 +196,7 @@ Afterward, end with: > ## ThreadBear recap 🐻 > -> - ThreadBear is now version [version], and the daily update check is [ready / needs attention]. +> - ThreadBear is now version [version], and automatic updates are [ready / need attention]. > - Codex [does / does not] need a restart. > - Next: [nothing—you're up to date / the one safe rerun for a partial update]. @@ -211,7 +212,7 @@ End the consent turn with: > ## Here's what will happen > -> - I'll remove ThreadBear's local helper, private title memory, Codex instructions, skill, and daily update check. +> - I'll remove ThreadBear's local helper, private title memory, Codex instructions, skill, and automatic updates. > - Your tasks, other Codex settings, and unrelated files stay untouched. > - Existing title icons may remain until those tasks are renamed. > - After removal, you'll restart Codex once. @@ -230,7 +231,7 @@ The final response after committed removal is: > ## ThreadBear recap 🐻 > -> - ThreadBear and its daily update check were removed. +> - ThreadBear and its automatic updates were removed. > - Your tasks and unrelated Codex content stayed untouched; old title icons may remain. > - Next: restart Codex so open tasks drop the old instructions. From ba3f2f38513af98ab8fda856197912ebc84f4670 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 8 Aug 2026 12:52:19 -0400 Subject: [PATCH 14/14] test: record final BEAR-117 release proof [BEAR-116] [BEAR-117] --- docs/experiments/registry.json | 56 ++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index fb8c359..fe1a2e2 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -69,14 +69,15 @@ { "id": "TB-CAP-MOUNTED-NATIVE-REPAINT", "premise": "The final ThreadBear planner plus mounted Codex native setter repaints current and historical task titles immediately and preserves those rendered titles across a clean Codex restart.", - "status": "unresolved", + "status": "established", "supported_by": [ "TB-EXP-0116-003", - "TB-EXP-0116-004" + "TB-EXP-0116-004", + "TB-EXP-0116-006" ], "contradicted_by": [], - "decision": "TB-EXP-0116-003 established the mounted-writer path, and TB-EXP-0116-004 repeated it against corrected candidate b5cc0cbe. The title implementation is unchanged, but the final BEAR-117 skill changes the embedded executable fingerprint, so TB-PRE-0116-004 must repeat the exact release canary before v3.0.0 ships.", - "next_preflight": "TB-PRE-0116-004" + "decision": "TB-EXP-0116-003 established the mounted-writer path, TB-EXP-0116-004 repeated it against corrected candidate b5cc0cbe, and TB-EXP-0116-006 closes the final BEAR-117 artifact gate. Exact candidate a1ecec67 repainted the current and controlled historical rows immediately through the mounted writer, preserved both exact titles across a clean Desktop restart, and kept the friendly preview and committed-removal recap visible after reopen and restart.", + "next_preflight": null }, { "id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", @@ -436,6 +437,49 @@ "supersedes": [], "conflicts": [] }, + { + "id": "TB-EXP-0116-006", + "date": "2026-08-08", + "issue": "BEAR-116", + "preflight_id": "TB-PRE-0116-004", + "question": "Does the exact v3.0.0 artifact containing the final BEAR-117 consent, uncertainty, and uninstall protocol retain immediate mounted current and historical repaint, clean-restart persistence, and a durable end-user lifecycle recap?", + "invariant": "The local planner may prepare titles but the mounted Codex app remains the sole writer; the exact release artifact must repaint both controlled rows immediately, preserve them after restart, remove only previewed owned lifecycle surfaces after consent, and leave its plain-language recap as the final visible content after reopen and restart.", + "environment": { + "threadbear_version": "3.0.0 exact final local candidate; arm64 CGO=0 binary SHA-256 a1ecec67bc7c2fb5c2a166d454c113c307bcd9a8d220fabd9c5f3825d8210a31; amd64 SHA-256 99a20910d5ff8f6c3bc04920853509dd52c28a343964ba0b4fb6ad82ea35658d", + "git_sha": "f745f50ae2e5c71805a282b03841c9b7ca1bdc67", + "codex_version": "Desktop 26.803.41515 build 6321; detached CLI 0.146.0", + "codex_source": "vscode", + "host": "local macOS arm64", + "task_state": "one active mounted task plus the visible controlled historical task `Update website copy boxes`; the complete onboarding catalog contained 360 tasks, with two safe rows and exactly one historical row needing a write after controlled setup", + "restart_state": "the lifecycle recap remained visible after replacement ChatGPT process PID 15260 started at 2026-08-08 11:08:52 EDT; after exact-candidate restoration, replacement PID 37695 started at 12:47:10 EDT, native list_threads still returned `➡️ Diagnose ThreadBear failures` and `🐻 Update website copy boxes`, and status remained ready", + "hook_fingerprint": "no ThreadBear title hook; hooks.json SHA-256 43d2619e3510054cc165f9b1f7915e16c20a162fa4427d2ed0b20294141ddf04 remained byte-identical across install, onboarding, uninstall, and reinstall", + "guidance_fingerprint": "installed skill SHA-256 4fa64eb271df4f639d4f5aaf5b6a2abd8f04aa153a9ec7edab5c26da20ef0d44 at 5,061 bytes; complete installed AGENTS.md SHA-256 b6828aa9f9e13e4305dcc2b268fcc3e5c7a2a4123325c2f0062d554079bf8dd7" + }, + "invocation": { + "outer_tool": "the exact installed ordinary-turn and onboarding JavaScript cells through functions.exec; one dedicated lifecycle task using the installed skill for dry-run, consent, confirmed uninstall, and final recap; read-only Computer Use inspection before and after restart; exact-candidate release smoke, reinstall, status, hashes, and LaunchAgent inspection", + "code": "run one current-task plan followed by one mounted setter with task ID omitted; enumerate all 360 tasks, prepare one controlled historical target, reread its exact mounted ID/title, and make one explicit-target mounted write; in a separate task run uninstall dry-run, present the final consent card, accept explicit yes, require uninstalled:true, reopen and restart; reinstall the same exact artifact and verify bytes and health after another clean process start", + "native_tool_identity": "codex_app__set_thread_title as sole title writer; codex_app__read_thread only for the immediate historical ID/title drift guard; paginated native thread/list reads only inside the local planner", + "target_identity_mode": "current task by omitted task ID; historical target by authoritative explicit ID after exact mounted ID/title reread; lifecycle task by exact task ID for read-only reopen and evidence" + }, + "evidence": [ + "linear:BEAR-116", + "linear:BEAR-117", + "github-pr:124", + "git:f745f50ae2e5c71805a282b03841c9b7ca1bdc67", + "codex-rollout:019fdcce-d0dc-7522-ad8e-a0c3c1c02110" + ], + "result": { + "status": "observed", + "summary": "The exact ordinary cell confirmed one mounted write and immediately rendered `➡️ Diagnose ThreadBear failures`. The exact onboarding cell enumerated 360 tasks, prepared the one controlled historical target, confirmed one write with zero skipped or unconfirmed outcomes, and immediately rendered `🐻 Update website copy boxes`. The lifecycle task first showed a jargon-light removal preview and one yes/no question, then after explicit consent required committed removal before ending with the exact friendly recap. Computer Use showed that recap as the final visible content after reopen and after a genuinely new Desktop process. Confirmed uninstall removed the binary, skill, managed guidance, subject state, plist, and loaded updater while preserving hooks; the same a1ecec67 artifact then reinstalled byte-exactly, survived another clean process start, and reported ready with the exact update-only LaunchAgent.", + "timing_ms": "both title repaints were present in the next mounted accessibility capture; the lifecycle recap was recaptured after the replacement Desktop process started, and restored title persistence was confirmed by native list_threads after the final replacement process started", + "hook_participation": "none; current-format title mutation used only the mounted native setter and hooks.json remained byte-identical", + "rendered_proof": "Privacy-safe evidence retained outside the repository: current immediate repaint SHA-256 6aaee653769df1ba3f6841906cf1b1d4303267adf4f7046cb9098f8d99dbfce9; historical controlled before SHA-256 e7af320d57dc0b51d9496d4aa01fae5162a5af581a7b400598da350a69c1c675 and immediate after SHA-256 91245ab1345d7cbbc1de5b7852a01f2e316b3bd4b3871305f6b7e225044ab589; lifecycle recap before restart SHA-256 e952fb2fb4b88fe0ab6f944a249c6bcf4fbcb43bac1029ea8686bc6864828931 and after restart SHA-256 be2b771b4a5d5269c86a8788c3953e60e26211eb14085a836d0178b24da5b8e3" + }, + "confidence": "high", + "applicability": "Closes TB-PRE-0116-004 and establishes the final BEAR-117 artifact's mounted current and historical repaint, restart persistence, exact consent boundary, committed uninstall, and durable final recap on Desktop 26.803.41515. Detached App Server title mutation remains rejected, and future Desktop builds or changes to the mounted cells still require their recorded release canary.", + "supersedes": [], + "conflicts": [] + }, { "id": "TB-EXP-0102-001", "date": "2026-07-31", @@ -1358,7 +1402,7 @@ "id": "TB-PRE-0116-004", "issue": "BEAR-116", "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", - "status": "pending", + "status": "closed", "consulted": [ "TB-EXP-0116-003", "TB-EXP-0116-004", @@ -1372,7 +1416,7 @@ "If either native result is non-exact, either controlled row stays stale, either title changes or disappears after restart, the artifact is unhealthy, or the lifecycle task claims removal before committed uninstall or loses its recap after reopen/restart, v3.0.0 must not ship." ], "stop_condition": "Stop after one exact current-task mounted write, one exact controlled historical onboarding write, immediate mounted inspection, one previewed and confirmed lifecycle canary with its final recap, one clean Codex restart, post-restart mounted inspection, and read-only runtime/artifact checks. Do not retry a title write, add another target, add refresh machinery, or claim release readiness until one linked result closes this preflight.", - "result_experiment_id": "" + "result_experiment_id": "TB-EXP-0116-006" } ] }