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..72b976f 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; 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." } >> "$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..5c8741b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +## v3.0.0 - 2026-08-08 + +### 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, 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, 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, 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 + +- 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..6bf38c3 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,27 @@ 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. It prepares one safe title, then Codex applies it once. > -> 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. +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. -## 1. Check this Mac +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.” -Run compatibility checks without mutation: +## 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 sw_vers -productVersion @@ -39,160 +43,209 @@ 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 +> ## Here's what will happen > -> - 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. +> - 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, 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. > -> Install ThreadBear with this recommended setup? +> Install ThreadBear? -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: “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.” -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: +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: ```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. +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. -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. +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. -## 4. Migrate without leaving this task +After the checks finish, end the final response with this plain-language receipt, filled with the real result: -Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install** and **Migration controller** sections. The installed skill is the canonical operation guide. +> ## ThreadBear recap 🐻 +> +> - 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**. -Before migration, tell the user: +## 4. Restart and onboard -> 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`. +Say: “Installation is finished. One restart loads the new instructions; onboarding stays a separate previewed choice.” -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. +After a successful install say: -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. +> ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. +> +> After restart, open any task and say: **ThreadBear onboard** -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. +When that request arrives, read the installed skill and follow this protocol: -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. +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` with this card: -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. +> ## 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 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? -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. +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: -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. +```sh +~/.local/bin/threadbear onboard --noninteractive --confirm --json +``` -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. +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. -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. +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. -## 5. Close precisely +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. -On complete success, use this shape in natural prose: +End with: -> ## 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. +> ## ThreadBear recap 🐻 > -> From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” - -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. - -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. +> - 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]. -## Help and status - -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 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. -## Uninstall +`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. -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. +For a manual update, preview first and end the consent turn with: -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: +> ## 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: -> 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. +> ## ThreadBear recap 🐻 > -> Should I go ahead? +> - 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]. + +## 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. +End the consent turn with: -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: +> ## Here's what will happen +> +> - 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. +> +> Uninstall ThreadBear now? + +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. + +The final response after committed removal is: + +> ## ThreadBear recap 🐻 +> +> - 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. + +## 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 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, 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. -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 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 0ba2b84..59ad841 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 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 | | --- | --- | -| ⏳ | 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 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 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 -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 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. -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..7454743 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -2,48 +2,59 @@ 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 this one terminal cell. Replace only `STATUS` 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"; +// @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:30000, + max_output_tokens:1000 +}); +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(); +} +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 = 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(); +} +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(result); +text(JSON.stringify({ready:true, task_id:plan.task_id, title:renamed.title, updated:true})); ``` -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 +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. -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..dab008c 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -1,35 +1,43 @@ -ThreadBear keeps Codex task titles useful with native per-turn updates. +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 - 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 + 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 whether ThreadBear and daily updates are ready self-test Validate a release candidate - update Install the latest verified release - uninstall Remove ThreadBear after control-task title cleanup + update Verify and install the latest release + uninstall Show what will be removed, then remove ThreadBear 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 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. -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 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 + --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 + +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 22ac2e7..8d6c2f6 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,124 +1,108 @@ --- name: threadbear -description: Install, inspect, migrate, maintain, verify, or uninstall the local ThreadBear title manager for Codex Desktop on macOS. +description: Install, onboard, update, check, or uninstall ThreadBear 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. - -## Help - -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. - -Run `~/.local/bin/threadbear status --json` before saying ThreadBear is installed or healthy. Use `~/.local/bin/threadbear help` as the authoritative public command reference. - -Show a command before running it. Ask for explicit consent before any lifecycle mutation. - -| 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. | - -## Install - -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. - -For a large existing workspace, say this before migration: - -> 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`. - -Do not claim success from installed files, a native setter return, or partial counts. - -## Maintenance - -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 `~/.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. - -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. - -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. - -## 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. +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, uncertainty, 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, 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. + +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`, `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 +// @exec: {"yield_time_ms": 30000, "max_output_tokens": 4000} +notify("ThreadBear onboarding: preparing"); +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 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 = parseNative(await tools.codex_app__read_thread({threadId:item.task_id, + includeOutputs:false,turnLimit:1,maxOutputCharsPerItem:1})); + } catch { current = null; } + if (current?.thread?.id !== item.task_id || current.thread.title !== item.title) skipped++; + else { + let renamed; + try { + 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++; + 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 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 + +Automatic updates never read tasks. Preview official download, verification, replacement, restart. With consent run `update --json`; recap version, restart, safe rerun. ## 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. - -Thank the user and invite optional feedback at `eric@litman.org`. Never remove artifacts before title cleanup has completed. +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/appserver_list.go b/cmd/threadbear/appserver_list.go new file mode 100644 index 0000000..ad8eef1 --- /dev/null +++ b/cmd/threadbear/appserver_list.go @@ -0,0 +1,296 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "sort" + "strings" + "time" +) + +const ( + 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) { + 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: 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 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..9292c71 --- /dev/null +++ b/cmd/threadbear/appserver_list_test.go @@ -0,0 +1,392 @@ +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 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) + } + 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) + } + }) + } +} + +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.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.Prepared != 2 || result.NeedsUpdate != 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"}, + {"repeated cursor", "current-repeated-cursor", "repeated next cursor"}, + {"timeout", "current-timeout", "context deadline exceeded"}, + } { + t.Run(test.name, func(t *testing.T) { + _, _ = testIndex(t) + 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 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() + 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) + } + 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": + t.Fatal("production attempted an unsafe thread/read") + case "thread/name/set": + t.Fatal("production attempted a detached title write") + 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 "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 + } + 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 after + // ThreadBear's one observation; the planner must not overwrite it. + 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, + }}) +} + +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..fb336bb 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -1,949 +1,638 @@ 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 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) } - payload := hookPayload("PreToolUse", "task", "locked", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - type result struct { - output string - err error + 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) } - 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("planner mutated 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 got.err != nil || !strings.Contains(got.output, `"permissionDecision":"allow"`) { - t.Fatalf("released PreToolUse = %q, %v", got.output, got.err) + if record, err := newStore(stateDir()).readTask(testTaskID); err != nil || record.Subject != "Stable subject" { + t.Fatalf("subject record = %#v, %v", record, err) } - 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) + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 1 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("RPC sequence = %#v", requests) } -} - -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 { - t.Fatal(err) + 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) } - lock, err := newStore(stateDir()).titleLock() + encoded, err := json.Marshal(result) if 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 err := os.RemoveAll(stateDir()); err != nil { - unlock(lock) + var fields map[string]any + if err := json.Unmarshal(encoded, &fields); err != nil { t.Fatal(err) } - unlock(lock) - select { - case output := <-done: - if !strings.Contains(output, `"permissionDecision":"deny"`) { - t.Fatalf("post-teardown PreToolUse = %q", output) + 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) } - 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) + for _, obsolete := range []string{"title", "updated", "unconfirmed"} { + if _, exists := fields[obsolete]; exists { + t.Fatalf("planner emitted obsolete field %q: %s", obsolete, encoded) + } } } -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() +func TestCurrentTitlePreservesFiniteOwnershipAndUserRename(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testTaskID, "Initial subject") + first, err := runCurrentTitle(t.Context(), testTaskID, "complete") 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): + 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) } - if err := os.RemoveAll(stateDir()); err != nil { - unlock(lock) - t.Fatal(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.DesiredTitle != "✅ ✅ Quarterly close " || !result.WriteRequired { + t.Fatalf("verbatim rename = %#v, %v", result, 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") + if got := index.title(t, testTaskID); got != "✅ Quarterly close " { + t.Fatalf("planner mutated user rename = %q", got) } - if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("queued PostToolUse 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 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 { +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) } - 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) + result, err := runCurrentTitle(t.Context(), testTaskID, "complete") + 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) } - 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) + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/list") != 1 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("unchanged RPCs = %#v", requests) } } -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 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) } - var value struct { - Hook struct { - Updated map[string]json.RawMessage `json:"updatedInput"` - } `json:"hookSpecificOutput"` + 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) } - err = json.Unmarshal(output.Bytes(), &value) - var title string - if err == nil { - err = json.Unmarshal(value.Hook.Updated["title"], &title) + if _, err := newStore(stateDir()).readTask(testTaskID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unsafe task wrote subject state: %v", 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) - } +} + +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.WriteRequired || result.Unchanged || + result.PreviousTitle != "Stable subject" || result.DesiredTitle != "✅ Stable subject" { + t.Fatalf("concurrent rename result = %#v, %v", result, 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)) - } + 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 len(postFailures) > 0 { - t.Fatalf("commit failures: %s", strings.Join(postFailures, "; ")) + if got := index.title(t, testTaskID); got != "User rename during delayed write" { + t.Fatalf("planner overwrote concurrent rename = %q", got) } - saved, err := newStore(stateDir()).read() - if err != nil { - t.Fatal(err) + requests := fixtureRequests(t) + 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) } - 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) - } + if data, err := os.ReadFile(starts); err != nil || string(data) != "x" { + t.Fatalf("App Server starts = %q, %v", data, err) } } -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 { +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) } - 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 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) - } - } -} - -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) + 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) } - 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) + active := onboardingItemByID(t, plan.Items, testActiveID) + if active.Outcome != onboardingUnchanged || active.Reason != "active task is handled by the terminal title writer" { + t.Fatalf("active plan item = %#v", active) } - if _, err := db.Exec(`UPDATE threads SET first_user_message=title WHERE id='delegated'`); err != nil { - t.Fatal(err) + if countFixtureMethod(fixtureRequests(t), "thread/read") != 0 || countFixtureMethod(fixtureRequests(t), "thread/name/set") != 0 { + t.Fatal("read-only plan performed target RPCs") } - 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 _, err := newStore(stateDir()).readTask(testAlphaID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read-only plan wrote subject state: %v", err) } -} + clearFixtureRequests(t) -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) + 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) } - 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) + 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)) } - stateBefore, err := currentStateOrEmpty() - if err != nil || len(stateBefore.Tasks) != 0 { - t.Fatalf("fresh state = %#v, %v", stateBefore, err) + requests := fixtureRequests(t) + if countFixtureMethod(requests, "thread/name/set") != 0 || countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("onboarding RPCs = %#v", requests) } - 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) + alpha := onboardingItemByID(t, prepared.Items, testAlphaID) + if alpha.Outcome != onboardingPrepared || alpha.Reason != "app-native title write required" { + t.Fatalf("prepared item = %#v", alpha) } - 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 { + encoded, err := json.Marshal(prepared) + if 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) + var fields map[string]any + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) } - 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) + for _, obsolete := range []string{"updated", "unconfirmed"} { + if _, exists := fields[obsolete]; exists { + t.Fatalf("preparation emitted obsolete field %q: %s", obsolete, encoded) } } - stateAfter, err := currentStateOrEmpty() - if err != nil || len(stateAfter.Tasks) != 1 { - t.Fatalf("denied markers changed state: %#v, %v", stateAfter, err) + if strings.Contains(string(encoded), `"applied"`) { + t.Fatalf("preparation emitted obsolete item field: %s", encoded) } - 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 { - t.Fatal(err) + if record, err := newStore(stateDir()).readTask(testAlphaID); err != nil || record.Subject != "Alpha" { + t.Fatalf("prepared subject = %#v, %v", record, 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) - } - if _, err := db.Exec(`UPDATE threads SET title=? WHERE id='task'`, proposed); err != nil { - t.Fatal(err) - } - 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 _, err := newStore(stateDir()).readTask(testActiveID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("active task wrote state: %v", err) } } -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 +func TestOnboardingPreparesEverySafeSnapshotCandidateWithoutTargetRPCs(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) + } + 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) } - 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) + result, err := runOnboarding(t.Context(), true, testActiveID) + 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) } - 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 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) } } - 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) + 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 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) +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) } - if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='delegated'`, delegation); err != nil { - t.Fatal(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 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}} - return true, nil - }); err != nil { - t.Fatal(err) + if record, err := newStore(stateDir()).readTask(testAlphaID); err != nil || record.Subject != "Renamed after snapshot" { + t.Fatalf("later rename subject = %#v, %v", record, 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) - } + if got := index.title(t, testAlphaID); got != "Renamed after snapshot" { + t.Fatalf("planner mutated later rename = %q", got) } - 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) + if requests := fixtureRequests(t); countFixtureMethod(requests, "thread/read") != 0 || + countFixtureMethod(requests, "thread/name/set") != 0 { + t.Fatalf("later rename RPCs = %#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) - } +} + +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) } - 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) + 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) } } -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 { +func TestCurrentPlannerCannotOutliveLifecycleFence(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) } - 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) - } - 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 { + if err := unix.Flock(int(lifecycle.Fd()), unix.LOCK_EX); 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 { + 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("planner with exclusive lifecycle fence = %v", err) + } + case <-time.After(250 * time.Millisecond): + unlock(lifecycle) + 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 planner started App Server: %#v", requests) + } + } else if !errors.Is(err, os.ErrNotExist) { 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) - } } -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 { +func TestConfirmedOnboardingHoldsOneFenceAcrossSerialPreparation(t *testing.T) { + _, index := testIndex(t) + index.setTitle(t, testAlphaID, "Alpha") + index.setTitle(t, testOtherID, "Other") + stateLock, err := newStore(stateDir()).lock(testAlphaID) + if err != nil { t.Fatal(err) } - if _, err := install("main", false, true, false); err != nil { - t.Fatal(err) + + 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/list") == 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/list") == 0 { + unlock(stateLock) + t.Fatal("onboarding did not complete its snapshot") } -} -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) + 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): } - if _, err := install("main", false, true, false); err != nil { + unlock(stateLock) + 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) + case <-time.After(time.Second): + t.Fatal("replacement lifecycle did not resume after onboarding") } - saved, _ := newStore(stateDir()).read() - if saved.Phase != phaseMigrationPending || saved.ControllerTaskID != "" { - t.Fatalf("unmarked task claimed controller: %#v", saved) + if requests := fixtureRequests(t); countFixtureMethod(requests, "thread/name/set") != 0 || + countFixtureMethod(requests, "thread/read") != 0 { + t.Fatalf("preparation attempted target RPC: %#v", requests) } } -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 TestConfirmedOnboardingRefusesBusyLifecycleBeforePreparation(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) - } - 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) + 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) } - 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 elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("confirmed onboarding waited behind lifecycle operation for %s", elapsed) } -} - -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) + 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 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 { - 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) +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 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 fixtureRequests(t testing.TB) []fixtureMessage { + t.Helper() + file, err := os.Open(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")) + if 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") + 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 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 { +func clearFixtureRequests(t testing.TB) { + t.Helper() + if err := os.Remove(os.Getenv("THREADBEAR_APP_SERVER_REQUESTS")); 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 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 countFixtureMethod(requests []fixtureMessage, method string) int { + count := 0 + for _, request := range requests { + if request.Method == method { + count++ + } } + return count } -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 fixtureMethod(requests []fixtureMessage, method string, at int) fixtureMessage { + for _, request := range requests { + if request.Method == method { + if at == 0 { + return request + } + at-- + } } - data, _ := json.Marshal(value) - return string(data) + return fixtureMessage{} } -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..0ad3330 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -1,489 +1,1061 @@ 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, hooks string } -type rawObject map[string]json.RawMessage +type lifecyclePaths struct { + binary, agents, skill, launchAgent, updateReceipt string +} + +type installOptions struct { + DryRun, Confirmed, Reset, NoOnboard, Automatic bool + SelectedVersion string +} -func hasPendingTitle(value state) bool { - return slices.ContainsFunc(slices.Collect(maps.Values(value.Tasks)), func(task taskState) bool { return task.Pending != nil }) +type uninstallOptions struct { + DryRun, Confirmed bool } -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") } + +type legacyInstall struct{ MainTaskID string } + +var postResetStatus = status + +func codexHome() string { + if value := strings.TrimSpace(os.Getenv("CODEX_HOME")); value != "" { + return value + } + return filepath.Join(homeDir(), ".codex") +} + +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") + } + 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 + } + binary, err := os.ReadFile(source) + if err != nil { + return nil, err } p := installPaths() - hooks, write, err := editHooks(p.hooks, p.binary, true) + legacy, legacyFound, err := readLegacyInstall() 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 + preview := installResult(options, legacy, legacyFound, options.DryRun) + if err := preflightInstall(ctx, p, binary, legacyFound); err != nil { + return preview, err } - if !confirmed { - return nil, errors.New("install requires --noninteractive --confirm after its preview") + if options.DryRun { + return preview, nil } - titleLock, lockErr := newStore(stateDir()).installLock() - if lockErr != nil { - return nil, lockErr + if !options.Confirmed { + return preview, errors.New("install requires --noninteractive --confirm after its preview") } - defer unlock(titleLock) - if value, err = currentStateOrEmpty(); err != nil { - return nil, err + + 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 { + return preview, err + } + defer unlock(updateLock) } - if value.MainTaskID != "" && value.MainTaskID != mainTaskID { - return nil, errors.New("persisted ThreadBear task changed during install") + boundaryLock, err := lifecycleBoundaryLock() + if err != nil { + return preview, err } - if hasPendingTitle(value) { - return nil, errors.New("install requires all native title operations to settle") + defer unlock(boundaryLock) + if updateLock != nil { + if err := currentLockPath(updateLock); err != nil { + return preview, err + } } - 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 completeUninstall(ctx context.Context, initiatorTaskID string, confirmed, abort bool) (any, error) { - if !confirmed && !abort { - return nil, errors.New("uninstall requires --noninteractive --confirm") + +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 initiatorTaskID = strings.TrimSpace(initiatorTaskID); initiatorTaskID == "" { - return nil, errors.New("uninstall requires the initiating task ID") + 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 !abort { - if committed, err := finishCommittedUninstall(); err != nil || committed { - return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err - } + if onboarding { + result["next_request"] = "threadbear onboard --dry-run --json" } - return withUninstallLocks(func() (any, error) { - value, err := newStore(stateDir()).read() - if err != nil { - return nil, 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") - } - 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")) - } - if main.Archived != pending.MainArchived { - return nil, errors.New("control task archive state must be restored before uninstall completion") - } - 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 stripStatusIcons(main.Title) != main.Title || stripStatusIcons(main.Title) == homeTitle { - return nil, errors.New("uninstall requires title cleanup from the ThreadBear control task") - } - return uninstallLocked(ctx, value) - }) + return result } -func withUninstallLocks(action func() (any, error)) (any, error) { - store := newStore(stateDir()) - operationLock, err := store.operationLock() - if err != nil { - return nil, err + +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" } - defer unlock(operationLock) - titleLock, err := store.titleLock() - if err != nil { - return nil, err + return partialResult(result, stage, restart, rerun) +} + +func confirmedInstallRerun(p lifecyclePaths, noOnboard bool) string { + command := quoteArgument(p.binary) + " install" + if noOnboard { + command += " --no-onboard" } - defer unlock(titleLock) - return action() + return command + " --noninteractive --confirm --json" } -func finishCommittedUninstall() (bool, error) { - p := installPaths() - if _, err := os.Stat(newStore(stateDir()).path()); !errors.Is(err, os.ErrNotExist) { - return false, err + +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") } - _, 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 !dryRun && !confirmed { + return nil, errors.New("onboarding requires --dry-run or --noninteractive --confirm") } - if err := errors.Join(removeFiles(filepath.Dir(p.skill), p.skill, filepath.Dir(p.skill)), os.RemoveAll(stateDir())); err != nil { - return false, err + p := installPaths() + if err := requireCurrentFormatInstall(p); err != nil { + return nil, fmt.Errorf("onboarding requires the current ThreadBear installation: %w", err) } - return true, removeFiles("", p.binary) + return runOnboarding(ctx, confirmed, os.Getenv("CODEX_THREAD_ID")) } -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 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 + } + 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 } - if value.MainTaskID != "" && value.Phase != phaseMigrationPending { - tasks, scanErr := inventory(ctx) - if scanErr != nil { - return nil, scanErr + defer func() { + if updateLock != nil { + unlock(updateLock) } - 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") + }() + boundaryLock, err := lifecycleBoundaryLock() + if err != nil { + return preview, err + } + defer unlock(boundaryLock) + if updateLock != nil { + if err := currentLockPath(updateLock); err != nil { + return preview, err } } - p := installPaths() - hooks, write, err := editHooks(p.hooks, p.binary, false) + lock, err := existingLifecycleLock("lifecycle.lock") if err != nil { - return nil, err + if !partialAdmission || !errors.Is(err, os.ErrNotExist) { + return preview, err + } + lock = nil } - err = validateFile(p.skill, assets.SkillManagedContent) - if err == nil { - err = manageBlock(p.agents, "") + defer func() { + if lock != nil { + unlock(lock) + } + }() + 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 } - if err == nil && write { - if len(hooks) == 0 { - err = os.Remove(p.hooks) + + // 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.hooks, hooks, 0o600) + err = writeAtomic(p.agents, agents, 0o600) + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return partialResult(preview, "managed_guidance", true, uninstallRerun(p)), err } } - if err == nil { - err = removeFiles(filepath.Dir(p.skill), p.skill, filepath.Dir(p.skill)) + if err := os.Remove(p.skill); err != nil && !errors.Is(err, os.ErrNotExist) { + return partialResult(preview, "skill", true, uninstallRerun(p)), err } - if err == nil { - err = os.RemoveAll(stateDir()) + 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 err == nil { - err = removeFiles("", p.binary) + 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 } - return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err + 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 status(ctx context.Context) (any, error) { + +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() - _, changed, err := editHooks(p.hooks, p.binary, true) - if err == nil && changed { - err = errors.New("native title hooks are incomplete") + 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, } - for _, path := range []string{p.binary, p.agents, p.skill} { - if err == nil { - _, err = os.Stat(path) + var problems []error + for name, healthy := range artifacts { + if !healthy { + problems = append(problems, fmt.Errorf("managed %s surface is missing or changed", name)) } } - 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" + 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 } - return result, err + 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 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 editHooks(path, binary string, add bool) ([]byte, bool, error) { + +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 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 { + 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 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 + } + 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()) + } + if err := currentLockPath(file); err != nil { + unlock(file) + return nil, err + } + return file, nil +} + +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 + } + if err != nil { + return legacyInstall{}, false, err + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return legacyInstall{}, false, errors.New("legacy native.json is not a private regular file") + } data, err := os.ReadFile(path) - missing := errors.Is(err, os.ErrNotExist) - if missing && !add { - return nil, false, nil + if err != nil { + return legacyInstall{}, false, err } - if err != nil && !missing { - return nil, false, err + var value struct { + Format int `json:"format"` + MainTaskID string `json:"main_task_id"` + Tasks map[string]json.RawMessage `json:"tasks"` } - 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 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") } - 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") + return legacyInstall{MainTaskID: value.MainTaskID}, true, nil +} + +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 + } } - 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) + 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 + } + } } - kept := slices.DeleteFunc(groups, func(group json.RawMessage) bool { - owned := ownedHookGroup(group, binary) - removed = removed || owned - return owned - }) - if add { - kept = append(kept, owner) + } 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 } - if raw, _ := json.Marshal(kept); len(kept) == 0 { - delete(events, event) - } else { - events[event] = raw + } + 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")) + } + found, err = privateDirectory(newStore(stateDir()).subjectDir()) + if err != nil || !found { + return errors.Join(err, errors.New("subject store is missing or not private")) + } + 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 !add && !removed { - return data, false, nil + if legacy { + if _, err := regularLeaf(legacyHooksPath(), false); err != nil { + return err + } + if _, _, _, err := removeLegacyHooks(legacyHooksPath(), p.binary); err != nil { + return err + } } - if !add && len(events) == 0 { - delete(root, "hooks") - } else { - root["hooks"], _ = json.Marshal(events) + 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 !add && len(root) == 0 { - return nil, true, nil + if _, err := regularLeaf(p.agents, false); err != nil { + return err } - updated, err := json.MarshalIndent(root, "", " ") - return append(updated, '\n'), string(before) != string(encodedJSON(decodedJSON(updated))), err + 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") + } + 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 + } + } + return nil } -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))) + +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 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 preflightCurrentUninstall(ctx context.Context, p lifecyclePaths) error { + if err := validateManagedParents(p); err != nil { + return err + } + 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 preflightPartialUninstall(ctx context.Context, p lifecyclePaths) error { + if err := validateManagedParents(p); err != nil { + return err + } + if err := runningInstalledBinary(p.binary); err != nil { + return err + } + if legacy, err := legacyStatePresent(); err != nil || legacy { + return errors.Join(err, map[bool]error{true: errors.New("legacy native.json is present")}[legacy]) + } + if err := validateRemovableState(); err != nil { + return err + } + 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()) + } + 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") + } + 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)) + } + } + } + return nil +} + +func regularLeaf(path string, private bool) (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.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)) + } + return true, nil +} + +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 false, nil } - if err == nil && !valid { - return errors.New("managed file was modified: " + path) + 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)) + } + 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 + } } - 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) + return nil +} + +func validateOwnedSubjectLeaves() error { + dir := newStore(stateDir()).subjectDir() + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + 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())) + } + } + 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 } - if content == "" && start < 0 { - return nil + 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 } - return writeAtomic(path, []byte(text), 0o600) + 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 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..a67d8f9 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -1,678 +1,1298 @@ 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) + 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) + } + } } - 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 !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({", + "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", + } { + 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") + } + 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 TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { +func TestInstallRejectsLegacyStateWithoutMainTaskIdentity(t *testing.T) { p := isolatedLifecycle(t) - if _, err := install("installer", true, false, false); err != nil { + 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 TestResetRerunsAfterPartialInstallWithoutDeletingNewSubjects(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":{}}`) + 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 err := os.Chmod(stateDir(), 0o700); err != nil { + t.Fatal(err) } - if got, err := os.ReadFile(sibling); err != nil || string(got) != "keep" { - t.Fatalf("uninstall changed sibling skill: %q, %v", got, 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.Prepared != 0 || value.Unchanged != 0 || value.Skipped != 2 { + t.Fatalf("onboard plan = %#v", value) + } + items := value.Items + if items[2].TaskID != testSafeID || items[2].Title != "Exact subject" || 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.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) + 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 TestStatusRejectsModifiedManagedGuidance(t *testing.T) { +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 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) + fake := currentFakeLaunchctl(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + 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) - 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 { + 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): + } + 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) } - 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) + 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 got, _ := os.ReadFile(p.hooks); !reflect.DeepEqual(got, hooks) { - t.Fatal("blocked uninstall changed hooks") + 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); err != nil { - t.Fatalf("blocked uninstall removed binary: %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 TestUninstallWaitsForInFlightUpdaterBeforeTeardown(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 { + updateLock, err := updateCheckLock() + if 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) + 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.Stat(p.binary); err != nil { - t.Fatalf("blocked uninstall removed binary: %v", err) + if _, err := os.Lstat(p.binary); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("uninstall retained binary: %v", err) } } -func TestUninstallKeepsBinaryUntilStateRemovalCommits(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) } - stateParent := filepath.Dir(stateDir()) - if err := os.Chmod(stateParent, 0o500); err != nil { - t.Fatal(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) + } } - 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") + 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 _, err := os.Stat(p.binary); err != nil { - t.Fatalf("failed state removal deleted retry binary: %v", err) + if got, err := os.ReadFile(neighbor); err != nil || string(got) != "preserve" { + t.Fatalf("skill neighbor = %q, %v", got, err) } - if err := os.Chmod(stateParent, 0o700); err != nil { - t.Fatal(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 _, err := uninstall(context.Background(), true); err != nil { - t.Fatalf("resumed teardown: %v", 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 TestUninstallDryRunRequiresCurrentInstallWithoutMutation(t *testing.T) { + p := isolatedLifecycle(t) + 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, filepath.Dir(p.skill)} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("resumed teardown left %s: %v", path, 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) } } } -func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { +func TestLifecycleIsolatesCorruptOwnedStateAndPreservesNeighbors(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) + } + 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) } - got, _ := os.ReadFile(p.hooks) - if !reflect.DeepEqual(got, malformed) { - t.Fatal("failed install changed hooks.json") + + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatalf("isolated corruption blocked reinstall: %v", 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 { + t.Fatalf("isolated corruption blocked uninstall 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 := uninstall(context.Background(), uninstallOptions{Confirmed: true}); err != nil { + t.Fatalf("isolated corruption blocked uninstall: %v", err) } - if got, _ := os.ReadFile(p.binary); string(got) != "sentinel" { - t.Fatal("failed uninstall mutated installation") + 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, 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 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 TestUninstallRefusesUnsafeOwnedSubjectLeafBeforeMutation(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + 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) } - 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) + 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, 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) + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("failed preflight removed binary: %v", err) + } + fake := currentFakeLaunchctl(t) + fake.mu.Lock() + loaded := fake.loaded + fake.mu.Unlock() + if !loaded { + t.Fatal("failed preflight booted out updater") } } -func TestUninstallRemovesOwnedOnlyHooksFile(t *testing.T) { +func TestUninstallPartialNamesStageAndSafeRerun(t *testing.T) { p := isolatedLifecycle(t) - data, _, err := editHooks(p.hooks, p.binary, true) - if err != nil { + if _, err := install(context.Background(), installOptions{Confirmed: true}); err != nil { + t.Fatal(err) + } + fake := currentFakeLaunchctl(t) + fake.bootoutStarted = make(chan struct{}) + continueBootout := make(chan struct{}) + fake.continueBootout = continueBootout + type outcome struct { + result any + err error + } + 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) + } + 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) + } + fence, err := newStore(stateDir()).lifecycleFence() + if err != nil { t.Fatal(err) } - if _, err := os.Stat(p.hooks); !os.IsNotExist(err) { - t.Fatalf("owned-only hooks file survived: %v", 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) } } -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..29bf0ad 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,52 @@ 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) { - items, remaining, value, err := migrationInventory(ctx) - deterministic := 0 - for _, item := range items { - if item.Deterministic { - deterministic++ - } - } - 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 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, "prepare all safe onboarding title changes") + action = func() (any, error) { + return onboard(ctx, *dry, *noninteractive && *confirm) + } + case "title": + selectedStatus := flags.String("status", "", "plan a title for complete, next_steps, needs_input, blocked, or automation") + action = func() (any, error) { + 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 +79,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/managed_javascript_test.go b/cmd/threadbear/managed_javascript_test.go new file mode 100644 index 0000000..f1140d0 --- /dev/null +++ b/cmd/threadbear/managed_javascript_test.go @@ -0,0 +1,286 @@ +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: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"} + ] +}; +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 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 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"); + } +}; +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:unreadable", + "read:wrongid", + "read:exact", "set:exact", + "read:object", "set:object", + "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 != 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" || + 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" + 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/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..28a875c 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -1,202 +1,282 @@ 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" + onboardingPrepared = "prepared" + onboardingUnchanged = "unchanged" + onboardingSkipped = "skipped" +) + +type indexedTask struct { + ID string `json:"task_id"` + Title string `json:"title"` + RawFallback bool `json:"-"` +} + +type currentTitleResult struct { + Ready bool `json:"ready"` + 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"` + Reason string `json:"reason"` +} -func inventory(ctx context.Context) ([]indexedTask, error) { - db, err := openIndex() +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"` + 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"` + Prepared int `json:"prepared"` + Unchanged int `json:"unchanged"` + Skipped int `json:"skipped"` + Items []onboardingItem `json:"items"` +} + +func runCurrentTitle(ctx context.Context, taskID, status string) (currentTitleResult, error) { + result := currentTitleResult{TaskID: taskID, Status: status} + 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() if err != nil { - return nil, err + return result, 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`) + defer unlock(fence) + client, err := startAppServer(ctx, appServerCurrentBudget) if err != nil { - return nil, fmt.Errorf("read Codex task index: %w", err) + return result, 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 + defer client.abort() + err = func() error { + task, err := client.currentTask(2, taskID) + if err != nil { + return err + } + if task.RawFallback { + result.Reason = "native task name is blank; task is raw or unowned" + return errors.New(result.Reason) } - tasks = append(tasks, task) + + 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 + 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 = true + return nil + }() + if err != nil { + return result, err } - return tasks, rows.Err() + + // 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 +} + +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 + } + subject = resolved + changed := record.Subject != resolved + *record = taskState{Subject: resolved} + return changed, nil + }) + return subject, err } -func oneTask(ctx context.Context, id string) (indexedTask, bool, error) { - if strings.TrimSpace(id) == "" { - return indexedTask{}, false, errors.New("task ID is empty") + +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") } - db, err := openIndex() - if err != nil { - return indexedTask{}, false, err + 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) } - 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 + budget := appServerListBudget + if apply { + budget = appServerOnboardingBudget } + client, err := startAppServer(ctx, budget) if err != nil { - return indexedTask{}, false, fmt.Errorf("read Codex task index: %w", err) + return onboardingResult{}, err } - return task, true, nil -} -func openIndex() (*sql.DB, error) { - home, err := sqliteHome() + defer client.abort() + nextRequestID := 2 + tasks, err := client.inventory(&nextRequestID) if err != nil { - return nil, err + return onboardingResult{}, 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 - } + items := prepareOnboardingItems(tasks) + excludeActiveOnboardingTask(items, activeTaskID) + result := summarizeOnboarding(items, !apply) + result.Ready, result.PlanComplete = true, true + if !apply { + client.close() + return result, nil } - if latest == "" { - return nil, errors.New("Codex state index not found") + + var operationErr error + // 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 || item.TaskID == activeTaskID || item.Outcome != onboardingNeedsUpdate { + continue + } + targetErr := func() error { + 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) + } + item.Outcome = onboardingPrepared + item.Reason = "app-native title write required" + return nil + }() + if targetErr != nil { + if item.Outcome != onboardingSkipped { + item.Outcome = onboardingSkipped + item.Reason = "the ThreadBear lifecycle changed before this task could be prepared" + } + operationErr = errors.Join(operationErr, targetErr) + } } - dsn := (&url.URL{Scheme: "file", Path: latest, RawQuery: "mode=ro"}).String() - return sql.Open("sqlite", dsn) + // 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 } -func sqliteHome() (string, error) { - base := codexHome() - var config struct { - SQLiteHome string `toml:"sqlite_home"` + +func excludeActiveOnboardingTask(items []onboardingItem, activeTaskID string) { + if activeTaskID == "" { + return } - if _, err := toml.DecodeFile(filepath.Join(base, "config.toml"), &config); err != nil { - if errors.Is(err, os.ErrNotExist) { - return base, nil + for index := range items { + if items[index].TaskID == activeTaskID { + items[index].Outcome = onboardingUnchanged + items[index].Reason = "active task is handled by the terminal title writer" + return } - return "", fmt.Errorf("parse Codex config: %w", err) - } - value := strings.TrimSpace(config.SQLiteHome) - switch { - case value == "": - value = base - case !filepath.IsAbs(value): - value = filepath.Join(base, value) } - return value, nil } -func rolloutFooter(path string) (footer, bool) { - if path == "" { - return footer{}, false - } - f, err := os.Open(path) - if err != nil { - return footer{}, false - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return footer{}, false - } - 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 { + +func prepareOnboardingItems(tasks []indexedTask) []onboardingItem { + items := make([]onboardingItem, 0, len(tasks)) + disk := newStore(stateDir()) + for _, task := range tasks { + 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 } - 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 + 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 item.Type != "response_item" || item.Payload.Type != "message" || item.Payload.Role != "assistant" || item.Payload.Phase != "final_answer" { + if record.Subject != "" && isOwnedRendering(task.Title, record.Subject) { + item.Title, item.Subject, item.DesiredTitle = task.Title, record.Subject, task.Title + item.Safe, item.Outcome, item.Reason = true, onboardingUnchanged, "already decorated" + items = append(items, item) continue } - message := item.Payload.Message - for _, part := range item.Payload.Content { - message += part.Text + subject, subjectErr := resolveSubject(task.Title, record) + if subjectErr != nil { + item.Reason = subjectErr.Error() + items = append(items, item) + continue } - return parseFooter(message) + item.Title, item.Subject, item.DesiredTitle = task.Title, subject, "🐻 "+subject + item.Safe, item.Outcome = true, onboardingNeedsUpdate + items = append(items, item) } - 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"` + return items } -func migrationInventory(ctx context.Context) ([]inventoryItem, int, state, error) { - tasks, err := inventory(ctx) - if err != nil { - return nil, 0, state{}, err - } - known, readErr := currentStateOrEmpty() - if readErr != nil { - return nil, 0, state{}, readErr - } - items := make([]inventoryItem, 0, len(tasks)) - remaining := 0 - for _, task := range tasks { - if task.ID == known.MainTaskID || task.ID == known.ControllerTaskID { - 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" +func summarizeOnboarding(items []onboardingItem, readOnly bool) onboardingResult { + result := onboardingResult{ReadOnly: readOnly, Total: len(items), Items: items} + for _, item := range items { + if item.Safe { + result.Safe++ + if item.Outcome == onboardingNeedsUpdate || item.Outcome == onboardingPrepared { + result.NeedsUpdate++ } } - 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 onboardingPrepared: + result.Prepared++ + case onboardingUnchanged: + result.Unchanged++ + case onboardingSkipped: + result.Skipped++ } } - return items, remaining, known, nil + if readOnly { + result.OnboardingComplete = result.NeedsUpdate == 0 + } else { + result.OnboardingComplete = result.Prepared == 0 + } + return result } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index e1ee23a..9881df8 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -1,356 +1,349 @@ 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") + return string(data) +} + +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) + } } +} - 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) +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) } } - 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", +} + +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, + "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.", + "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.", + "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", - "~/.local/bin/threadbear inventory --json", - "~/.local/bin/threadbear update --json", + "--no-onboard", + "ThreadBear onboard", + "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, 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.", + "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", + "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`", + "daily update-only LaunchAgent", + "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", + "thread/name/set", + "ThreadBear footer", + "--control-task-id", + "threadbear inventory", + "threadbear migration", + "threadbear maintenance", "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) - } - } + "background migration-controller", + "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 TestInstalledSkillDefinesAdaptiveMigrationWaves(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "assets", "skill", "SKILL.md")) - if err != nil { - t.Fatal(err) - } - protocol := string(data) +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") +} - 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 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`, + `// @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})", + "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", + "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 !strings.Contains(protocol, "`"+unknownMarker+"`") { - t.Errorf("installed skill does not name the hook-accepted unknown marker %q", unknownMarker) + if count := strings.Count(guidance, "title --status STATUS --json"); count != 1 { + t.Fatalf("managed guidance contains %d terminal planners; want one", count) } - 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") + 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, + "threadId:plan.task_id", + "thread/name/set", + "Promise.race", + "setTimeout", + "delay the response", + "PreToolUse", + "PostToolUse", + "ThreadBear footer", + "maintenance --cancel", + "prepared uninstall", + ) } -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"), +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) } - 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) - } - } + requireText(t, protocol, + "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", + "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", + `\"$HOME/.local/bin/threadbear\" onboard --noninteractive --confirm --json`, + "full 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", + "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", + "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.", + "No cap or persistent task.", + "## Update", + "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) } -} - -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 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 strings.Contains(guide, "Continue in the same response with the full card") { - t.Error("published install guide retains the ambiguous response-boundary rule") + 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, + "thread/name/set", + "exact readback", + "Promise.all", + "Promise.race", + "setTimeout", + "ready:unconfirmed === 0", + "rereads every candidate", + "PreToolUse", + "PostToolUse", + "Migration controller", + "migration --phase", + "maintenance --cancel", + "uninstall --prepare", + ) } -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 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') } } -} -func TestHomepageMatchesNativeMaintenanceCapabilities(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "site", "index.html")) - if err != nil { - t.Fatal(err) + 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) } - 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) - } + 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, 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 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}'"}, +func TestCurrentDocsNameThePlannerAndSoleMountedWriter(t *testing.T) { + for _, path := range [][]string{ + {"README.md"}, + {"docs", "architecture.md"}, + {"docs", "compatibility.md"}, + {"docs", "status-convention.md"}, } { - 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) - } - } + 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 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", + "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", + "There is no SQLite access, daemon, proxy, cache, model, retry, fallback, queue, or repair pass.", + "rerunnable partial", + "title-core readiness", + ) + rejectText(t, page, + "One direct writer", + "writes at most once, and verifies exact readback", + "only task read/write authority", + "thread/name/set", + "PreToolUse", + "PostToolUse", + "deterministic hook", + "automatic archive", + "read-only SQLite lookup", + ) } diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index b1ff640..fac4343 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..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 @@ -44,27 +45,34 @@ 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) - } - defer unlock(operationLock) - value, err := newStore(stateDir()).read() - if err != nil { - return nil, updateFailure("state", 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 := requireRunningUpdateBinary(p.binary); err != nil { + return nil, updateFailure("installation", err) } - if value.MainTaskID == "" || value.Phase != phaseMigrationComplete { - return nil, updateFailure("state", errors.New("update requires a completed ThreadBear installation")) + if err := requireCurrentFormatInstall(p); err != nil { + return nil, updateFailure("installation", err) } - if value.ArchivePending != nil { - return nil, updateFailure("archive_pending", errors.New("reconcile the pending native archive operation before updating")) + updateLock, err := updateCheckLock() + if err != nil { + return nil, updateFailure("busy", err) } - if value.UninstallPending != nil { - return nil, updateFailure("uninstall_pending", errors.New("finish the prepared uninstall before updating")) + defer unlock(updateLock) + if err := requireRunningUpdateBinary(p.binary); err != nil { + return nil, updateFailure("installation", err) } - if hasPendingTitle(value) { - return nil, updateFailure("title_pending", errors.New("settle pending native title operations before updating")) + if err := requireCurrentFormatInstall(p); err != nil { + return nil, updateFailure("installation", err) } assetKey, assetName, err := updatePlatform() if err != nil { @@ -74,7 +82,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 +102,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 +157,80 @@ 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 + 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 { 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 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 { + 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") @@ -222,50 +308,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 cappedOutput + 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())) - } - if output.Overflow { - return errors.New("candidate output exceeded the size limit") + 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 -} - -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 + return result, nil } diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go index 7ab9ab7..3a8efb2 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 } @@ -36,105 +37,181 @@ 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") } - data, _ := os.ReadFile(p.skill) - if string(data) != assets.SkillManagedContent { - t.Fatal("same-version repair did not restore the managed 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) + } + 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) +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 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.ArchivePending = &archiveOperation{TaskID: "target", Action: "archive"} - 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, "archive_pending") + _, err = update(context.Background(), false) + requireUpdateStage(t, err, "installation") if fixture.count("manifest") != 0 { - t.Fatal("pending archive 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 TestUpdateRefusesPreparedUninstallBeforeNetwork(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.UninstallPending = &uninstallOperation{InitiatorTaskID: "owner"} - 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, "uninstall_pending") - if fixture.count("manifest") != 0 { - t.Fatal("prepared uninstall allowed update network access") + if _, err := update(context.Background(), true); err != nil { + t.Fatalf("update read task catalog: %v", err) } } -func TestUpdateRefusesLegacyPendingTitleBeforeNetwork(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 { +func TestUpdateSerializesConcurrentChecks(t *testing.T) { + 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) } - _, err := update(context.Background()) - requireUpdateStage(t, err, "title_pending") + 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 fixture.count("manifest") != 0 { - t.Fatal("pending title allowed update network access") + unlock(lock) + t.Fatal("update fetched manifest while lock was held") + } + unlock(lock) + if err := <-done; err != nil { + t.Fatal(err) } } -func TestUpdateWaitsForConcurrentMaintenanceBeforeNetwork(t *testing.T) { - prepareUpdate(t, "2.1.2", true) +func TestUpdateRefusesBinaryReplacementWhileWaitingForLock(t *testing.T) { + p := 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 { + if err := writeAtomic(p.binary, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { unlock(lock) - t.Fatal("update fetched the manifest while maintenance held the operation 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"}) + 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 { @@ -142,7 +219,7 @@ func TestUpdateWaitsForConcurrentMaintenanceBeforeNetwork(t *testing.T) { } } -func TestUpdateRejectsManifestAndCandidateFailures(t *testing.T) { +func TestUpdateRejectsManifestAndCandidateFailuresWithoutReplacement(t *testing.T) { tests := []struct { name, stage string options updateFixtureOptions @@ -155,7 +232,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 +245,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,35 +262,37 @@ 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()) } } +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) 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") @@ -233,7 +317,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) @@ -241,14 +325,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 +344,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) } @@ -298,31 +378,37 @@ 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" } 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" + } else if structuredInstallFailure { + install = `printf '{"ready":false,"partial":true,"stage":"managed_guidance","restart_required":true,"safe_rerun":"threadbear update --json"}\n'; 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..54dad84 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` — title planning, app-native writing, subject ownership, onboarding, and updates +- `docs/status-convention.md` — status enum and icon mapping +- `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 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..4d0babf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,42 +1,66 @@ # 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 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. -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. Neither the planner nor the native call carries an 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 plan: -## 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 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. -## Verified updates +## Native boundaries + +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. 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. + +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 -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. +`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. -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. +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 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. +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 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. +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. -## Installation and migration +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. -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. +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, reset, and uninstall + +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 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 cell 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`. 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. -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..c2645c3 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 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 b2c1bcd..4e017f8 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,11 +1,19 @@ # 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, 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 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 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. -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. +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. -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. +`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. -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. +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. + +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/experiments/registry.json b/docs/experiments/registry.json index 44d06d9..fe1a2e2 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -1,19 +1,17 @@ { "schema_version": 1, - "updated_at": "2026-08-07", + "updated_at": "2026-08-08", "canonical_for": "ThreadBear title-path architecture and live experiments", "capabilities": [ { "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" ], - "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": "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 }, { @@ -67,6 +65,31 @@ "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": "established", + "supported_by": [ + "TB-EXP-0116-003", + "TB-EXP-0116-004", + "TB-EXP-0116-006" + ], + "contradicted_by": [], + "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", + "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", + "supported_by": [ + "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. 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": [ @@ -183,19 +206,280 @@ "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" ] }, + { + "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-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-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-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-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", @@ -1054,6 +1338,85 @@ ], "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": "closed", + "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": "TB-EXP-0116-003" + }, + { + "id": "TB-PRE-0116-002", + "issue": "BEAR-116", + "capability_id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", + "status": "closed", + "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": "TB-EXP-0116-004" + }, + { + "id": "TB-PRE-0116-003", + "issue": "BEAR-116", + "capability_id": "TB-CAP-UPDATER-TEARDOWN-SERIALIZATION", + "status": "closed", + "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": "TB-EXP-0116-005" + }, + { + "id": "TB-PRE-0116-004", + "issue": "BEAR-116", + "capability_id": "TB-CAP-MOUNTED-NATIVE-REPAINT", + "status": "closed", + "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": "TB-EXP-0116-006" } ] } diff --git a/docs/live-eval.md b/docs/live-eval.md index ea641a8..d0374da 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -1,13 +1,47 @@ # 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, 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. -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 cell -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; +- 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 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 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 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. + +## 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 preparation or native calls because no partial plan escaped. + +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`. 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. 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. + +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..59c434b 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 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 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 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`. 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. -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, 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 67bbafc..1e74f2d 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 terminal cell whose local planner receives 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. 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. -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..b458433 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -1,150 +1,1202 @@ #!/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 +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 +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 + 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" + +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} +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": 100, + "sortKey": "recency_at", + "sortDirection": "desc", + }: + 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"}}) + 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]) + send({"id": request_id, "result": {"thread": thread}}) + elif method == "thread/name/set": + 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, 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"]} + +def save(): + with open(state_path, "w", encoding="utf-8") as target: + 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: + 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 = 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") + 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() + 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": { + "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") + 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 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} +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 = 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 = 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: + 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 + 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 +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 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 +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 "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 +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 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" +python3 - "$root/title-complete-plan.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", + "write_required": True, + "unchanged": False, + "reason": "app-native title write required", +}, 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] == ["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" "" "" "" +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": json.dumps({"threadId": task_id, "title": "✅ Release smoke exact subject"}, separators=(",", ":")), +}], calls +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["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" +: >"$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 + +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.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 + +: >"$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" "" "" "" +python3 - "$root/title-automation-plan.json" "$root/title-automation.json" "$native_tool_log" <<'PY' +import json +import sys + +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["title"] == "🤖 Release smoke exact subject", value +assert len(calls) == 1 and isinstance(calls[0].get("response"), str), 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.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 || + 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" +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 -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 +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["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 || + 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["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] +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 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 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}') +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 = 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"] == 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 all(item["outcome"] != "updated" and item["outcome"] != "unconfirmed" for item in value["items"]), 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")] +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" "$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, wrong_id = sys.argv[4:] +assert value == { + "ready": False, + "plan_complete": True, + "onboarding_complete": False, + "total": 109, + "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) == 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"} +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) +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"], + "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 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 || + 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["prepared"] == 0, value +assert value["unchanged"] == 103 and value["skipped"] == 2, value +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 False, value +assert value["total"] == 109 and value["safe"] == 107, 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" "" "" "" +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": 4, + "skipped": 0, + "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) == 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(isinstance(call.get("response"), str) 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. +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/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 aeaf6cc..cfd8a1d 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 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.
+
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.
-

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 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 eaec693..6bf38c3 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,27 @@ 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. It prepares one safe title, then Codex applies it once. > -> 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. +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. -## 1. Check this Mac +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.” -Run compatibility checks without mutation: +## 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 sw_vers -productVersion @@ -39,160 +43,209 @@ 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 +> ## Here's what will happen > -> - 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. +> - 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, 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. > -> Install ThreadBear with this recommended setup? +> Install ThreadBear? -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: “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.” -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: +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: ```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. +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. -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. +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. -## 4. Migrate without leaving this task +After the checks finish, end the final response with this plain-language receipt, filled with the real result: -Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install** and **Migration controller** sections. The installed skill is the canonical operation guide. +> ## ThreadBear recap 🐻 +> +> - 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**. -Before migration, tell the user: +## 4. Restart and onboard -> 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`. +Say: “Installation is finished. One restart loads the new instructions; onboarding stays a separate previewed choice.” -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. +After a successful install say: -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. +> ThreadBear is installed. Restart Codex so open tasks load the new managed guidance. +> +> After restart, open any task and say: **ThreadBear onboard** -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. +When that request arrives, read the installed skill and follow this protocol: -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. +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` with this card: -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. +> ## 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 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? -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. +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: -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. +```sh +~/.local/bin/threadbear onboard --noninteractive --confirm --json +``` -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. +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. -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. +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. -## 5. Close precisely +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. -On complete success, use this shape in natural prose: +End with: -> ## 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. +> ## ThreadBear recap 🐻 > -> From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” - -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. - -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. +> - 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]. -## Help and status - -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 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. -## Uninstall +`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. -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. +For a manual update, preview first and end the consent turn with: -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: +> ## 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: -> 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. +> ## ThreadBear recap 🐻 > -> Should I go ahead? +> - 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]. + +## 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. +End the consent turn with: -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: +> ## Here's what will happen +> +> - 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. +> +> Uninstall ThreadBear now? + +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. + +The final response after committed removal is: + +> ## ThreadBear recap 🐻 +> +> - 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. + +## 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 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, 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. -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 mounted app-native 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')