From a7608b2c5a704a6f86fac8e104c5f55afac2c812 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Fri, 31 Jul 2026 16:54:33 -0400 Subject: [PATCH] feat: replace runtime with native turn finalization --- .github/workflows/ci.yml | 22 +- .github/workflows/release-smoke.yml | 4 +- .github/workflows/release.yml | 23 +- CHANGELOG.md | 9 + CLAUDE.md | 8 +- INSTALL.md | 291 ++------ README.md | 40 +- assets/AGENTS.threadbear.md | 22 +- assets/both5_safe.ans | 6 - assets/embed.go | 7 +- assets/help.txt | 12 + assets/org.litman.threadbear.plist.tmpl | 34 - assets/skill/SKILL.md | 169 ++--- cmd/threadbear/appserver.go | 124 ---- cmd/threadbear/core_test.go | 664 ++++-------------- cmd/threadbear/engine.go | 313 --------- cmd/threadbear/hook.go | 155 ++++ cmd/threadbear/install.go | 411 ++++++----- cmd/threadbear/install_test.go | 304 ++++++++ cmd/threadbear/main.go | 163 ++--- cmd/threadbear/migration_test.go | 166 +++++ cmd/threadbear/scan.go | 391 ++++------- cmd/threadbear/site_contract_test.go | 8 +- cmd/threadbear/state.go | 310 ++++---- cmd/threadbear/state_test.go | 224 ++++++ cmd/threadbear/testdata/help.golden | 173 ----- cmd/threadbear/titleplan.go | 149 ---- docs/README.md | 20 +- docs/architecture.md | 55 +- docs/benchmark.md | 10 +- docs/compatibility.md | 20 +- docs/live-eval.md | 16 +- docs/release-checklist.md | 24 +- docs/status-convention.md | 22 +- internal/status/schema.json | 25 - internal/tokens/testdata/rollout-tail.jsonl | 4 - internal/update/notes.txt | 0 scripts/release-smoke.sh | 45 +- scripts/replay-title-batch.mjs | 96 --- site/index.html | 22 +- site/install | 291 ++------ testdata/appserver/fake_codex.py | 77 -- testdata/appserver/rollout.jsonl | 9 - testdata/appserver/schema/ClientRequest.json | 1 - .../schema/v2/ThreadStartParams.json | 1 - .../appserver/schema/v2/TurnStartParams.json | 1 - testdata/status/cases.json | 56 -- testdata/status/expected.json | 56 -- 48 files changed, 1895 insertions(+), 3158 deletions(-) delete mode 100644 assets/both5_safe.ans create mode 100644 assets/help.txt delete mode 100644 assets/org.litman.threadbear.plist.tmpl delete mode 100644 cmd/threadbear/appserver.go delete mode 100644 cmd/threadbear/engine.go create mode 100644 cmd/threadbear/hook.go create mode 100644 cmd/threadbear/install_test.go create mode 100644 cmd/threadbear/migration_test.go create mode 100644 cmd/threadbear/state_test.go delete mode 100644 cmd/threadbear/testdata/help.golden delete mode 100644 cmd/threadbear/titleplan.go delete mode 100644 internal/status/schema.json delete mode 100644 internal/tokens/testdata/rollout-tail.jsonl delete mode 100644 internal/update/notes.txt delete mode 100755 scripts/replay-title-batch.mjs delete mode 100755 testdata/appserver/fake_codex.py delete mode 100644 testdata/appserver/rollout.jsonl delete mode 100644 testdata/appserver/schema/ClientRequest.json delete mode 100644 testdata/appserver/schema/v2/ThreadStartParams.json delete mode 100644 testdata/appserver/schema/v2/TurnStartParams.json delete mode 100644 testdata/status/cases.json delete mode 100644 testdata/status/expected.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc35505..1fe89c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,14 @@ jobs: with: go-version-file: go.mod cache: true + - name: Check Go formatting and production size + 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$/') + lines=$(printf '%s\n' "$production" | xargs wc -l | awk 'END { print $1 }') + echo "production Go lines: $lines" + test "$lines" -le 1000 - run: go test ./... - run: go vet ./... - run: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o /tmp/threadbear_darwin_arm64 ./cmd/threadbear @@ -34,17 +42,3 @@ jobs: for path in / /install /install.sh; do curl --fail --silent --show-error "http://127.0.0.1:8000$path" >/dev/null done - - name: Lint rendered plist fixture - run: | - sed \ - -e 's#{{xml .Label}}#org.litman.threadbear#g' \ - -e 's#{{xml .BinaryPath}}#/tmp/threadbear#g' \ - -e 's#{{.StartInterval}}#300#g' \ - -e 's#{{xml .Home}}#/tmp/threadbear-home#g' \ - -e 's#{{xml .CodexHome}}#/tmp/threadbear-home/.codex#g' \ - -e 's#{{xml .Path}}#/usr/bin:/bin#g' \ - -e 's#{{xml .LCAll}}#C#g' \ - -e 's#{{xml .StdoutPath}}#/tmp/threadbear.stdout.log#g' \ - -e 's#{{xml .StderrPath}}#/tmp/threadbear.stderr.log#g' \ - assets/org.litman.threadbear.plist.tmpl > /tmp/org.litman.threadbear.plist - plutil -lint /tmp/org.litman.threadbear.plist diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 5077a28..e900bf5 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -53,8 +53,8 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: live release manifest, checksum, candidate self-test, fixture control-task validation, binary install, LaunchAgent load, status, and uninstall cleanup." - echo "- Not proved: real Codex auth, real App Server title effects, Luna behavior, rendered Desktop titles, or architectures other than this runner." + echo "- Proved: live release manifest, checksum, candidate self-test, binary install, native PreToolUse/PostToolUse title finalization against an unarchived task and settled rollout, committed state, status, inventory convergence, and uninstall cleanup." + echo "- Not proved: real Codex auth, rendered Desktop titles, Luna behavior, or architectures other than this runner." 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 283591c..a5edd23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,14 @@ jobs: with: go-version-file: go.mod cache: true + - name: Check Go formatting and production size + 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$/') + lines=$(printf '%s\n' "$production" | xargs wc -l | awk 'END { print $1 }') + echo "production Go lines: $lines" + test "$lines" -le 1000 - name: Validate release tag id: release_tag run: | @@ -61,20 +69,7 @@ jobs: - run: go vet ./... - run: sh -n install.sh - run: if [ -f site/install.sh ]; then cmp install.sh site/install.sh; fi - - name: Lint rendered plist fixture - run: | - sed \ - -e 's#{{xml .Label}}#org.litman.threadbear#g' \ - -e 's#{{xml .BinaryPath}}#/tmp/threadbear#g' \ - -e 's#{{.StartInterval}}#300#g' \ - -e 's#{{xml .Home}}#/tmp/threadbear-home#g' \ - -e 's#{{xml .CodexHome}}#/tmp/threadbear-home/.codex#g' \ - -e 's#{{xml .Path}}#/usr/bin:/bin#g' \ - -e 's#{{xml .LCAll}}#C#g' \ - -e 's#{{xml .StdoutPath}}#/tmp/threadbear.stdout.log#g' \ - -e 's#{{xml .StderrPath}}#/tmp/threadbear.stderr.log#g' \ - assets/org.litman.threadbear.plist.tmpl > /tmp/org.litman.threadbear.plist - plutil -lint /tmp/org.litman.threadbear.plist + - run: cmp INSTALL.md site/install - name: Build Darwin release binaries run: | mkdir -p dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d42c58..5594a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Changed + +- Replaced the v2 heartbeat, retained control task, title queue, and LaunchAgent with two native current-task title calls per ordinary turn, expanded and verified by two deterministic hooks. +- Made installation and v2 title migration foreground, consented, rerunnable flows with fresh-task and rendered Desktop verification. + +### Removed + +- Removed background title processing, runtime Luna classification, Stop repair, durable title plans, and App Server title writes. + ## v2.0.0 - 2026-07-31 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 3d9a2cb..ea9a058 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,11 +2,11 @@ @docs/README.md -ThreadBear is a playful, token-conscious Codex title manager for macOS: one Go binary and one LaunchAgent observe Codex tasks, decide seven statuses, and safely update visible Desktop titles. +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. -- Current work item: BEAR-100. The evergreen contract is `README.md` plus `docs/architecture.md`; dated files in `docs/plans/` are historical evidence, not current architecture. +- Current work item: BEAR-102. The evergreen contract is `README.md` plus `docs/architecture.md`; dated files in `docs/plans/` are historical evidence, not current architecture. - 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. -- Keep the complete production executable surface below 2,000 source lines and prefer below 1,000. Do not compress code to game the count. -- Control-task adoption is explicit through `threadbear install --control-task-id`; installation never creates a persistent conversation. +- CI rejects more than 1,000 physical lines of production Go; stay materially below that without compressing code to game the count. +- Do not add a scheduler, retained control 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. diff --git a/INSTALL.md b/INSTALL.md index 072bf08..64d7116 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,87 +1,28 @@ # Install ThreadBear with Codex -Open Codex, paste `Install ThreadBear — follow https://threadbear.sh/install`, and -ThreadBear will meet you there. +Open a new Codex task and paste: + +```text +Install ThreadBear — follow https://threadbear.sh/install +``` ## Agent brief -You are the ThreadBear guide for the person in this Codex task. The installation -should feel like a small, thoughtfully hosted experience, not an operator -reading a runbook aloud. Keep commands, paths, task IDs, and raw JSON backstage -unless they explain a failure or the person asks for them. - -This task becomes ThreadBear's retained control task. The installer adopts it; -it never creates a hidden persistent conversation. - -### Conversation contract - -- Open with the complete welcome below before running a command or summarizing - the setup. Brand, orientation, and reassurance come before machinery. -- Sound warm, calm, capable, and lightly playful. Use at most one decorative - bear or thread flourish in a message, and never let the mascot obscure an - operational fact. -- Explain visible outcomes first. Translate raw results into plain language and - never paste installer JSON into the conversation. -- Treat the dry run as a friendly review. Show the complete recommended setup - before asking for consent. -- A clear yes to the unchanged recommendation is installation consent. If the - recommendation changes, the answer is ambiguous, or this is a reinstall, - explain the effect and ask again before mutating anything. -- Report only real progress. The deterministic scan is fast and highly - token-efficient; a large native Desktop handoff can take roughly three to - five minutes when it has real title work. -- Luna medium is reserved for genuinely ambiguous legacy history. Never imply - that routine scanning opens Luna. -- Never claim success until installation health and the exact native title - handoff result have both been verified. - -### Required visible flow - -1. Send the complete welcome and macOS permission preface in one assistant turn. -2. Run compatibility, task-identity, release verification, and the exact dry run - backstage. If a check fails, stop before showing readiness or asking for - consent. -3. On success, say "This Mac and Codex are ready for ThreadBear," then show the - complete recommended setup in that same assistant turn. -4. Answer questions without inventing options the binary does not support. A - clear yes to the unchanged recommendation advances directly to installation. -5. Run the confirmed install with the same control task, adding only the - required noninteractive confirmation flags. -6. Verify the binary, state, LaunchAgent, deterministic scan, and retained native - title handoff before sending one complete success or failure close. - -## 1. Welcome the person - -Open with this information and spirit; natural wording is fine, but preserve -every promise: +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. + +Open with this complete orientation before running a command: > ## Hi. Let's install ThreadBear. > -> ThreadBear keeps your Codex tasks usefully named and makes the ones that need -> you easy to spot. Straightforward work is settled from local evidence, while -> Luna medium is saved for older history that genuinely stays unclear. +> 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 and two small deterministic hooks. > -> I'll take care of the setup right here. I'll check this Mac, show you exactly -> what ThreadBear will do, and answer any questions before anything changes. -> Then I'll install it and verify the result in Codex Desktop. - -Follow with one calm macOS heads-up: - -> You may see Documents or Automation permission prompts with ThreadBear's name -> while I check this Mac. ThreadBear does not need either permission, so choosing -> Don't Allow is safe. It never needs Full Disk Access. If a prompt appears, -> I'll pause so you can decide. - -Finish that opening turn with this promise, then continue without waiting unless -the person interrupts or a privacy panel appears: +> I'll check this Mac, show you exactly what will change, and answer questions before installing anything. Then I'll prove the result in a genuinely fresh Codex task and update existing titles only after you consent. -> I'm checking whether this Mac is ready for ThreadBear. I won't install -> anything or change your settings. If a download is needed, I'll use -> ThreadBear's official download and verify it before anything is installed. +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. -## 2. Check this Mac quietly +## 1. Check this Mac -Run the compatibility checks backstage: +Run compatibility checks without mutation: ```sh sw_vers -productVersion @@ -96,232 +37,118 @@ if [ -x "$HOME/.local/bin/threadbear" ]; then fi ``` -ThreadBear requires macOS 12 or newer, Apple silicon or Intel, a working Codex -executable and App Server, and HTTPS access to the official guide and GitHub -Releases. Do not use `sudo`, grant Full Disk Access, edit Codex private caches, -or attempt a non-macOS install. +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 Codex task with supported task tooling -and record it as `CONTROL_TASK_ID`. Do not ask the person to copy an ID when the -tooling can resolve it. Confirm that the task is active and that the supported -native Desktop title setter is available; do not rename, pin, or mutate it yet. - -Build or download the verified candidate and run its exact preview. For a -published release, the bootstrap verifies the release manifest, SHA-256, and -candidate self-test before delegating to the candidate: +For an official release, run the verified bootstrap preview: ```sh -curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --control-task-id "$CONTROL_TASK_ID" \ - --dry-run --json +curl -fsSL https://threadbear.sh/install.sh | sh -s -- --dry-run --json ``` -For a local candidate, run: +For an already-built local candidate, run: ```sh -/path/to/threadbear install \ - --control-task-id "$CONTROL_TASK_ID" \ - --dry-run --json +/path/to/threadbear install --dry-run --json ``` -Require `ready=true`, `dry_run=true`, and effects limited to adopting the -control task, writing the local binary and managed guidance, and scheduling the -five-minute heartbeat. Keep raw IDs, paths, and JSON backstage. - -## 3. Show the setup - -ThreadBear scans local Codex task metadata and rollout tails every five minutes, -decides exact statuses, and keeps Desktop titles current. The scan is highly -token-efficient and mostly deterministic: exact footers and live runtime state -do not open a model. Luna medium is reserved for legacy history that remains -ambiguous across two unchanged passes. - -The deterministic scan of a large local inventory should finish in seconds. -The separate native Desktop handoff can take roughly three to five minutes when -a large existing workspace actually has many titles to repaint. Progress should -describe completed work, never unexplained waiting. +Require a successful candidate self-test and a dry-run limited to 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. -It writes: +## 2. Show the recommendation -- `~/.local/bin/threadbear` -- `~/.local/share/threadbear/core.json` and private logs -- `~/Library/LaunchAgents/org.litman.threadbear.plist` -- one managed block in `~/.codex/AGENTS.md` -- one managed skill at `~/.codex/skills/threadbear/SKILL.md` - -It reads the local Codex SQLite index and rollout files, and uses App Server -only to read current runtime state when a rollout is ambiguous. Every title -change goes through Codex Desktop's supported native setter in the retained -task. No `sudo` is used. - -Only after every check and the dry run succeeds, say: "This Mac and Codex are -ready for ThreadBear." Continue in that same assistant turn with this complete -card: +Only after every check and the dry run succeeds, say: “This Mac and Codex are ready for ThreadBear.” Continue in the same response with the full card: > ## Recommended setup > -> - **A quiet five-minute check.** ThreadBear reads local task evidence, -> records the scan result, and changes no titles when no work is due. -> - **Useful titles.** Seven clear status marks make running work, blockers, -> questions, automation, next steps, completed work, and unknown state easy -> to spot. Every visible title is bounded to 60 UTF-16 units. -> - **Deterministic first.** Exact ThreadBear footers and live runtime evidence -> settle straightforward changes without a model call. -> - **Luna only for genuine ambiguity.** `gpt-5.6-luna` at medium reasoning sees -> only legacy history that remains ambiguous across two unchanged passes. -> - **Native Desktop title changes.** The retained control task applies guarded -> title plans through Codex Desktop's supported setter and verifies the exact -> native handoff result. -> - **Small boundaries.** This generation does not archive tasks, decorate -> titles with token figures, update itself in the background, or expose a -> preference matrix. +> - **Useful per-turn titles.** Each ordinary turn starts with a native running title and ends with a native title that matches its exact ThreadBear footer. +> - **Stable subjects.** ThreadBear preserves the user-owned task subject, adopts later user renames, and bounds visible titles to 60 UTF-16 units. +> - **Small local footprint.** One standalone Go binary, one private state file, managed guidance, and two deterministic Codex hooks. +> - **One foreground migration.** After a fresh-task canary, existing local unarchived tasks—including projectless tasks—can be updated through explicit native Desktop calls. +> - **Deterministic first.** Exact historical footers are classified locally. Luna medium is reserved for genuinely ambiguous legacy history, with at most eight read-only workers; workers never write titles. +> - **Honest cleanup.** Uninstall can remove ThreadBear-owned decoration or intentionally keep current titles before removing its local artifacts. > > Install ThreadBear with this recommended setup? -The dry-run effects and this card are the complete review. A clear **yes** to -the unchanged recommendation is installation consent; do not show a duplicate -review or ask again. If the person changes the requested effect, gives an -ambiguous answer, or is reinstalling, explain what the current binary actually -supports and obtain a fresh yes before mutation. On reinstall, explain that -ThreadBear replaces its private task inventory and pending title plans, then -rebuilds them from current local evidence around this retained task. Never -invent missing flags or offer a removed feature. +Answer questions without inventing options or flags. A clear yes to this unchanged recommendation advances directly to installation. -## 4. Install after consent +## 3. Install after consent -For the verified published candidate, run exactly: +For the verified official release, run: ```sh curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --control-task-id "$CONTROL_TASK_ID" \ --noninteractive --confirm --json ``` -For the already verified local candidate, run exactly: +For the verified local candidate, run: ```sh -/path/to/threadbear install \ - --control-task-id "$CONTROL_TASK_ID" \ - --noninteractive --confirm --json +/path/to/threadbear install --noninteractive --confirm --json ``` -Do not add heartbeat, archive, title, token, update, rename, guidance, model, or -reasoning flags. They are not part of the current installer contract. - -Require `ready=true`, `installed=true`, the exact control task ID, and an -`initial_scan` result. Then verify the installed surfaces and read-only scan: +Then verify the installed surfaces: ```sh ~/.local/bin/threadbear version --json ~/.local/bin/threadbear self-test --json ~/.local/bin/threadbear status --json -~/.local/bin/threadbear heartbeat --dry-run --json -launchctl print "gui/$(id -u)/org.litman.threadbear" +~/.local/bin/threadbear inventory --json ``` -`status --json` must report `ready=true`, the installed version, the exact -control task, `pending_titles`, and `last_scan`. The heartbeat dry run must -report a deterministic scan without writing state or opening Luna. - -## 5. Complete the native Desktop handoff +Do not claim the hooks work merely because files were written. Codex snapshots hooks and managed guidance when a task starts, so verification must continue in a new task. -Load the installed skill at `~/.codex/skills/threadbear/SKILL.md` and follow its -**Native title handoff** section verbatim. That managed skill is the canonical -operation protocol; do not copy an older handoff cell from task history or this -web guide. +## 4. Prove a fresh task and migrate -Before loading the native title tool, tell the person that the deterministic -scan is already done and highly token-efficient, that a large existing -workspace can spend roughly three to five minutes in the native Desktop handoff -when it has real title work, that only real progress will be reported, and that -Luna medium runs only for genuinely ambiguous legacy history. +Read `~/.codex/skills/threadbear/SKILL.md` and follow its **Install**, **Bulk migration**, and **Rendered Desktop verification** sections. The installed skill is the canonical operation guide. -The installed skill revalidates every operation immediately before the native -setter, requires the exact task ID and title from the native result, reports the -complete payload back to ThreadBear, and fails closed unless the report is -accepted. If its raw cell yields, wait only on that cell until terminal output. -After terminal output, make no more tool calls or commentary. +Before migration, tell the user: -Use a successful close only when the raw handoff reports `complete=true`. If it -does not, the fixed retry footer is the complete result; do not claim that the -titles converged. +> The deterministic scan is already done and highly token-efficient. A large workspace can spend about three to five minutes in the native Desktop handoff. I'll report only real progress. Luna medium runs only for genuinely ambiguous legacy history. -## 6. Close warmly and precisely +Use Codex `/hooks` to inspect and trust the two installed definitions, then create a genuinely fresh Codex Desktop task. Prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. -On complete success, preserve this content in natural prose: +After that canary passes, inventory every local unarchived task. Classify exact historical footers deterministically. Use at most eight fresh Luna-medium workers only for items that remain genuinely ambiguous; workers classify only. Call the native setter with the explicit task ID and the classification's compact footer marker (or the documented unknown marker). The Pre hook immediately re-reads the target and preserves any newer rename before rewriting the title; Post verifies the exact returned pair. -> ## ThreadBear is installed -> -> Everything passed: ThreadBear VERSION is installed, its five-minute check is -> healthy, and this task is now its retained home. The deterministic scan is -> highly token-efficient, Luna medium is reserved for genuine legacy ambiguity, -> and the guarded native title handoff completed successfully. -> -> From here, you can ask "how are you?", "what would you do right now?", or -> "uninstall ThreadBear." ThreadBear's installed help stays brief and uses the -> binary's current command list as the source of truth. +Migration is rerunnable from scratch. Skip only inventory rows proven `applied: true`; a similar-looking but unowned title still goes through the native Pre/Post boundary. After the batch, rerun inventory and require every row to be applied. If interrupted, rerun inventory. -Replace `VERSION` with the verified installed version. Follow the reply guidance -already loaded in the current task. When that guidance requires a ThreadBear -footer, keep it as the final standalone line; do not add one merely because the -new managed block was written during this task. +## 5. Close precisely -For an official-download verification failure before mutation, use this shape -and do not append a technical inventory: +On complete success, use this shape in natural prose: -> ThreadBear paused before installing because it couldn't verify the official -> download. +> ## ThreadBear is installed > -> Nothing was installed and your settings did not change. +> Everything passed: ThreadBear VERSION is installed, its managed guidance and two hooks are healthy, a fresh task completed both native title moments, and Codex Desktop rendered the result in its header and sidebar. Existing tasks were handled through the foreground migration you approved. > -> I'm checking the connection to the verified download now. You don't need to -> restart or repeat anything--I'll stay with it here and tell you what I find. +> 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. -For a failure after mutation began, state exactly which installed surfaces and -title operations completed, which verification failed, and whether the retry is -safe. Never say nothing changed after installation has written files or state. +If official-download verification fails before mutation, say that installation paused, nothing changed, and you are checking the verified download. If a failure occurs after mutation began, name exactly which surfaces and native title operations completed, which check failed, and whether rerunning is safe. Never claim visual success from state, `read_thread`, or setter output alone. -## Living with ThreadBear +## Help and status -For help-shaped asks, lead with a friendly capability card instead of a command -dump. Confirm health before claiming ThreadBear is watching Codex: +For later help, lead with a short capability card instead of a command dump. Verify health before saying ThreadBear is active: ```sh ~/.local/bin/threadbear status --json ~/.local/bin/threadbear help -~/.local/bin/threadbear help heartbeat ``` -The installed binary help is the authoritative command list. Plain-language -requests map to the small current surface: "how are you?" reads status, "what -would you do right now?" runs `heartbeat --dry-run`, and "uninstall" follows the -playbook below. +The installed binary's help is the authoritative public command list. ## Uninstall -Confirm that the person intends to remove ThreadBear and consult -`~/.local/bin/threadbear help uninstall`. Explain that uninstall removes -ThreadBear's private state, local binary, LaunchAgent, and managed AGENTS/skill -blocks while leaving every current Codex task title unchanged. +Read the installed skill's **Uninstall** section. Run status and inventory, then ask whether to clean ThreadBear-owned decoration to subject-only titles or keep current titles. Preview the complete chosen effect and obtain explicit consent. -Thank them, invite optional feedback at `eric@litman.org`, show the exact command, -and obtain a final explicit yes before running: +Requested title cleanup must finish first through explicit native target calls with exact returned IDs and titles. Then show and run: ```sh ~/.local/bin/threadbear uninstall --noninteractive --confirm --json ``` -Uninstall removes ThreadBear's state, binary, LaunchAgent, and managed guidance -while leaving every current Codex task title unchanged. +Uninstall removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content and hook order. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance. -## Maintainer verification expectations +## Maintainer verification -A release is ready only after unit and integration tests pass, a real inventory -scan is timed separately from title application, Luna calls are counted, and a -controlled Codex Desktop canary proves the rendered title with screenshot -evidence. State writes and command exit codes alone are not visual proof. +A release is ready only after unit and integration tests, the 1,000-line production-Go gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task migration fixtures, and the fresh Desktop matrix pass. Rendered header and sidebar screenshots are required; command exit codes and stored titles are not visual proof. -Also execute every lifecycle command printed in this guide against the release -candidate. Confirm that `INSTALL.md` and `site/install` are byte-identical, and -that the public `threadbear.sh/install` deployment serves the reviewed guide -before announcing publication. +Also execute every lifecycle command printed here against the release candidate. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. diff --git a/README.md b/README.md index ca54c5c..6172e11 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,6 @@ # ThreadBear -ThreadBear watches local Codex tasks and keeps their Desktop titles useful. Its -heartbeat is deterministic first: it reads the Codex task index and fixed -rollout tails, accepts exact status footers, and uses runtime state for active -tasks. Luna medium sees only legacy evidence that remains genuinely ambiguous -across two passes. - -The title marks are deliberately small: +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. | Mark | Meaning | | --- | --- | @@ -16,43 +10,31 @@ The title marks are deliberately small: | 🤖 | healthy automation | | ➡️ | next steps | | ✅ | complete | -| ❔ | unknown | +| ❔ | unknown legacy state | -ThreadBear preserves the user-owned subject after the mark and bounds every -visible title to 60 UTF-16 units, matching Codex Desktop. Heartbeats stage -guarded decisions without writing titles. The retained control task drains -those plans through Codex Desktop's supported native setter, the only path -proved to repaint the rendered UI. +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. ## Install -Open [INSTALL.md](INSTALL.md) in a Codex task and follow the guided flow. The -installer verifies the release checksum and candidate self-test before it -changes anything. +Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, install, migration, and rendered Desktop verification. + +ThreadBear installs a standalone Go binary, one small private state file, managed guidance, and two Codex hooks. ## Commands ```text threadbear install -threadbear heartbeat +threadbear inventory threadbear status threadbear self-test threadbear uninstall threadbear version ``` -Every command accepts `--json`; `heartbeat --dry-run` reads the complete current -inventory without writing state, opening Luna, or changing titles. `title-plan` -is a hidden compatibility surface used only by the installed native handoff. +Every command accepts `--json`. `inventory` is read-only and includes every local unarchived task across source shapes, including projectless tasks. The installed binary's `help` output is authoritative. -## Deliberate scope +## Boundaries -ThreadBear owns observation, status resolution, guarded title staging, native -handoff, and enough private state to make those operations repeatable. It does not -archive tasks, decorate titles with token counts, update itself in the -background, expose a configuration framework, or migrate the pre-reset state -schema. Installing this generation starts from `core.json` and conservatively -adopts each existing non-status title remainder as user-owned text. +ThreadBear does not archive tasks, add token counts, update itself in the background, or edit Codex private UI storage. It adds no model call or narration to ordinary turns. Installation may use at most eight ephemeral Luna-medium workers only when genuinely ambiguous v2 history cannot be classified deterministically; those workers classify and never write 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 footer convention](docs/status-convention.md). diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index 186ad0a..4853e49 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -1,8 +1,16 @@ # ThreadBear +For every ordinary interactive turn in a main Codex Desktop task: + +1. Your first action must be the native current-task title call with `title` exactly `⏳ ThreadBear is working`. Omit `threadId`. Do not send commentary or call another tool first. If the call fails, retry it once, then continue the turn. +2. End the response with exactly one compact status footer chosen from the forms below. +3. Immediately before the final response, call the same native title tool with `title` exactly equal to that footer line and no `threadId`. If the call fails, retry it once, then deliver the response. + +ThreadBear's hooks expand those compact inputs into the task's 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 -End each terminal response with exactly one compact status line. Use the matching literal example as its shape: +Use the matching literal example as the footer's shape: - Finished with no warranted follow-up: `🧵🐻 complete` - Finished with one concrete action for the user: `🧵🐻 next steps (you): approve the release plan` @@ -12,14 +20,4 @@ End each terminal response with exactly one compact status line. Use the matchin - Unable to continue because of an external condition: `🧵🐻 blocked (external): restore the signing service` - Healthy scheduled or automated work with nothing pending: `🧵🐻 automation` -Never write the literal placeholder words `STATUS`, `OWNER`, or `ACTION` in the line. Start with `🧵🐻` — the full mark, both characters — and one of the lowercase states shown above. - -Report the turn's actual disposition; do not invent or recommend work to populate this line. Use `complete` unless the substantive response already ends with one clear, concrete, warranted next step. Generic offers, speculative possibilities, and mentions of recorded work do not qualify. - -## Retained native title drain - -Before loading the native title tool, explain that the deterministic scan is already done and highly token-efficient, that a large existing workspace can spend about three to five minutes in the native Desktop handoff, that only real progress will be reported, and that Luna medium runs only for genuinely ambiguous legacy history. - -Before a retained native batch, stage the fixed safety footer `🧵🐻 next steps (agent): retry the first title handoff` with the separate dynamic command `~/.local/bin/threadbear title-plan --json --stage`. Exit zero alone is insufficient: inspect `ready` and `retryable`, and retry typed `heartbeat_active` or `heartbeat_cycle_active` stage results once per second for at most 120 attempts until `ready=true`. Only then run the exact raw-V8 cell embedded in the installed ThreadBear skill as one top-level `functions.exec`. That cell drains the first guarded batch, kickstarts exactly one immediate LaunchAgent continuation only when `continuation_due=true`, gives every command and native setter one shared five-minute elapsed-time and 300-attempt budget, drains all remaining guarded plans, and stages plus drains the final footer that matches its `complete` result. Normal handoff work stops at the five-minute deadline. If the result becomes incomplete before that deadline, it restores the retry footer. If the deadline expires before success finalization begins, it makes no more tool calls and reports `complete=false`, leaving the pre-staged retry footer as the conservative fallback. If and only if the deadline expires after success finalization has begun, it performs one bounded compensating retry-footer restoration through the existing guarded plan bridge, allows at most ten one-second typed-busy waits, and then reports `complete=false`. If it yields, use only `functions.wait` on that same cell until terminal output. After terminal output, use no more tools or commentary. - -End with `🧵🐻 complete` only when the cell reports `complete=true`; otherwise end with `🧵🐻 next steps (agent): retry the first title handoff`. The cell already completed all permitted footer work, including the bounded compensation when applicable, so write nothing after that footer. +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. diff --git a/assets/both5_safe.ans b/assets/both5_safe.ans deleted file mode 100644 index 59f11ef..0000000 --- a/assets/both5_safe.ans +++ /dev/null @@ -1,6 +0,0 @@ -[?25l  ▅▆▆▆▆▅  [?25l ▅▅▀▅▅▀▅▅  -   ▆▅▅▆   ▊▝▔ ▔▘▎ -   ▏▎▕▕  ▍ ▕▃▖▗▃▎ ▋ -  ▗▂▂▂▂   ▝▁ ▎▆▆▊ ▁▘ -  ▀▃▃▃▃▀  ▆▅▃▃▃▃▅▆  -[?25h [?25h diff --git a/assets/embed.go b/assets/embed.go index 34e473b..8ab960d 100644 --- a/assets/embed.go +++ b/assets/embed.go @@ -2,14 +2,11 @@ package assets import _ "embed" -//go:embed org.litman.threadbear.plist.tmpl -var LaunchAgentPlistTemplate string - //go:embed AGENTS.threadbear.md var AgentsManagedContent string //go:embed skill/SKILL.md var SkillManagedContent string -//go:embed both5_safe.ans -var WelcomeArt string +//go:embed help.txt +var HelpText string diff --git a/assets/help.txt b/assets/help.txt new file mode 100644 index 0000000..bbdfbb2 --- /dev/null +++ b/assets/help.txt @@ -0,0 +1,12 @@ +ThreadBear keeps Codex task titles useful with native per-turn updates. + +Usage: + threadbear [flags] + +Commands: + install Preview or install ThreadBear + inventory Classify local unarchived tasks for guided setup + status Check the installed helper and hooks + self-test Validate a release candidate + uninstall Remove ThreadBear while keeping current titles + version Show the installed version diff --git a/assets/org.litman.threadbear.plist.tmpl b/assets/org.litman.threadbear.plist.tmpl deleted file mode 100644 index 578e14b..0000000 --- a/assets/org.litman.threadbear.plist.tmpl +++ /dev/null @@ -1,34 +0,0 @@ - - - - - Label - {{xml .Label}} - ProgramArguments - - {{xml .BinaryPath}} - heartbeat - - StartInterval - {{.StartInterval}} - ProcessType - Background - KeepAlive - - EnvironmentVariables - - HOME - {{xml .Home}} - CODEX_HOME - {{xml .CodexHome}} - PATH - {{xml .Path}} - LC_ALL - {{xml .LCAll}} - - StandardOutPath - {{xml .StdoutPath}} - StandardErrorPath - {{xml .StderrPath}} - - diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 6e631f3..c8360b4 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -1,117 +1,78 @@ +--- +name: threadbear +description: Install, inspect, migrate, verify, or uninstall the local ThreadBear title manager for Codex Desktop on macOS. +--- + # ThreadBear -Use a playful bear voice and stay brief, never at the expense of operational clarity. +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 help-shaped asks, lead with a friendly capability card rather than a command dump. Say that ThreadBear keeps Codex task titles useful without wasting model tokens and accepts plain-language requests such as "how are you?", "what would you do?", or "uninstall". Run `~/.local/bin/threadbear status` before claiming it is installed, healthy, or currently watching this Codex. - -After the card, use `~/.local/bin/threadbear help` for the authoritative command list and `~/.local/bin/threadbear help ` for command flags, then summarize only what answers the ask. The installed binary help is canonical; do not maintain or invent an exhaustive command reference here. +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. ThreadBear adds no model call or narration to ordinary turns. -## Plain-language intents +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 the matching command before running it. +Show a command before running it. Ask for explicit consent before any lifecycle mutation. -| The user says | Command | +| Plain-language request | Command | | --- | --- | -| "how are you?" / "is everything ok?" | `~/.local/bin/threadbear status` | -| "what would you do right now?" | `~/.local/bin/threadbear heartbeat --dry-run` | -| "uninstall" / "leave my machine" | Follow the uninstall playbook below. | +| "How are you?" | `~/.local/bin/threadbear status --json` | +| "What tasks do you see?" | `~/.local/bin/threadbear inventory --json` | +| "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. +2. Run the exact dry run. Explain the complete effect: the local binary, one small private state file, one managed AGENTS block, this skill, and two hook entries. +3. Show the recommended setup and 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 and verify `version`, `self-test`, and `status`. +5. Use Codex `/hooks` to inspect and trust the two installed definitions. Then open a genuinely fresh Codex Desktop task and prove the first native call, the terminal native call, and the exact hook results before changing existing titles. Existing sessions may retain the hook snapshot they started with. +6. Follow **Bulk migration** for the current inventory, then prove a rendered active header and sidebar row in Codex Desktop. + +For a large existing workspace, say this before migration: + +> The deterministic scan is already done and highly token-efficient. A large workspace can spend about three to five minutes in the native Desktop handoff. I'll report only real progress. Luna medium runs only for genuinely ambiguous legacy history. + +Do not claim success until the installed checks, fresh-task canary, exact native results, and rendered Desktop proof all pass. + +## Status and inventory + +`status --json` checks the installed binary, managed files, hooks, and state readability. It does not mutate titles. -Before a mutating lifecycle command, consult its help, preserve normal Codex command approval, and show the effect in chat. Obtain an explicit yes before using `--noninteractive --confirm`. Installation follows the supported guided preview and consent contract. +`inventory --json` reads every local, unarchived Codex task across source shapes, including projectless tasks. Treat its deterministic classifications and ownership evidence as authoritative. Do not infer ThreadBear ownership from an icon or arrow alone. + +## Bulk migration + +Bulk migration is foreground and rerunnable: + +1. Run `inventory --json` and report only counts and changed phase progress. +2. Accept exact historical ThreadBear footers deterministically. Send only genuinely ambiguous legacy items to fresh, read-only Luna-medium workers, with at most eight workers active. Each worker must inspect its assigned task through Codex's read-only task reader; workers classify and never write titles. Use `❔` when ambiguity remains. +3. For each unapplied item, call the native title setter with its explicit task ID and a compact marker: the exact valid footer for the classified status/action, or `❔ ThreadBear could not classify` for unknown. The Pre hook immediately re-reads that target, preserves any newer user rename, records the proposal, and rewrites the marker to the full desired title. +4. Require the native result to contain the exact target ID and returned title. The Post hook accepts only that exact pair and commits it. After the batch, rerun inventory and require every row to report `applied: true` before claiming success. +5. If interrupted or if any item fails, rerun inventory from scratch. Skip only rows already proven `applied: true`; a syntactically similar but unowned title must still pass through the native Pre/Post boundary. + +The native setter has no compare-and-set argument. Do not claim it can prevent a rename that races the setter itself. + +## Rendered Desktop verification + +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. + +Capture privacy-safe evidence when preparing a release. ## Uninstall -After the help capability card: - -1. Confirm that the user intends to remove ThreadBear and consult `~/.local/bin/threadbear help uninstall`. -2. Explain that uninstall removes ThreadBear's state, binary, LaunchAgent, and managed AGENTS/skill blocks while leaving every current task title unchanged. -3. Say: "Thanks for using ThreadBear. I'd love any feedback on why this wasn't for you. Drop me an email at eric@litman.org if you're open to sharing. Now, on to the uninstall!" Show the exact command and obtain final approval. -4. Run `~/.local/bin/threadbear uninstall --noninteractive --confirm` and report the result. - -## Native title handoff - -Before loading the native title tool, explain that the deterministic scan is already done and highly token-efficient, that a large existing workspace can spend about three to five minutes in the native Desktop handoff, that only real progress will be reported, and that Luna medium runs only for genuinely ambiguous legacy history. - -Stage the fixed retry footer `🧵🐻 next steps (agent): retry the first title handoff` with `~/.local/bin/threadbear title-plan --json --stage`. Require `ready=true`; exit zero alone is insufficient. Only then invoke the fixed raw-V8 cell below once as a top-level `functions.exec`. It drains guarded batches, gives the whole handoff one five-minute/300-attempt budget, and stages the final footer. Every operation is revalidated immediately before the native setter and every nested command must exit zero before JSON parsing. - -```js -// @exec: {"yield_time_ms": 120000, "max_output_tokens": 1000} -const counts = {accepted: 0, canonically_verified: 0, failed: 0, timed_out: 0, drifted: 0, rejected: 0}; let waitsRemaining = 300, cleanupWaitsRemaining = 10; const deadlineAt = Date.now() + 300000; const timedOut = () => { if (!counts.timed_out) counts.timed_out++; throw {kind: "timed_out"}; }; const requireTime = () => { if (Date.now() >= deadlineAt) timedOut(); }; const command = async (cmd, enforceDeadline = true) => { if (enforceDeadline) requireTime(); const result = await tools.exec_command({cmd}); if (enforceDeadline) requireTime(); if (!result || result.exit_code !== 0) throw {kind: "command_failed"}; return result; }; const commandJSON = async (cmd, enforceDeadline = true) => { const result = await command(cmd, enforceDeadline); if (typeof result.output !== "string") throw {kind: "command_failed"}; return JSON.parse(result.output); }; -const readyJSON = async (cmd, waitForContinuation = false, enforceDeadline = true) => { for (;;) { - const value = await commandJSON(cmd, enforceDeadline); - if (value.ready && (!value.continuation_due || !waitForContinuation)) return value; - if (!value.ready && (!value.retryable || !["heartbeat_active", "heartbeat_cycle_active"].includes(value.error_code))) throw {kind: "not_ready"}; - if (enforceDeadline && waitsRemaining-- <= 0) timedOut(); - if (!enforceDeadline && cleanupWaitsRemaining-- <= 0) throw {kind: "not_ready"}; - await command("sleep 1", enforceDeadline); -} }; -const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; -const batchCommand = "~/.local/bin/threadbear title-plan --json --batch", stageFooter = (footer, enforceDeadline = true) => readyJSON("printf %s " + quote(footer) + " | ~/.local/bin/threadbear title-plan --json --stage", false, enforceDeadline); -const drain = async (batch, enforceDeadline = true) => { let complete = true; - for (const operationID of batch.operation_ids || []) try { - const operation = await readyJSON("~/.local/bin/threadbear title-plan --json --operation " + quote(operationID), false, enforceDeadline); - if (operation.disposition === "drifted") { counts.drifted++; complete = false; continue; } - if (operation.disposition !== "ready" || operation.action !== "set") { counts.rejected++; complete = false; continue; } - let outcome = "succeeded", errorCode = ""; - let nativeResult; - try { if (enforceDeadline) requireTime(); nativeResult = await tools.codex_app__set_thread_title({threadId: operation.task_id, title: operation.desired_title}); if (enforceDeadline) requireTime(); if (typeof nativeResult === "string") nativeResult = JSON.parse(nativeResult); if (!nativeResult || nativeResult.threadId !== operation.task_id || nativeResult.title !== operation.desired_title) throw {kind: "native_result_mismatch"}; } - catch (error) { if (error?.kind === "timed_out") throw error; if (enforceDeadline) requireTime(); outcome = "failed"; errorCode = "native_setter_failed"; counts.failed++; complete = false; } - const payload = {reports: [{operation_id: operationID, outcome, task_id: operation.task_id, title: operation.desired_title, ...(errorCode && {error_code: errorCode})}]}; - const report = await readyJSON("printf %s " + quote(JSON.stringify(payload)) + " | ~/.local/bin/threadbear title-plan --json --report", false, enforceDeadline); - counts.accepted += report.accepted || 0; - if (outcome === "succeeded" && report.accepted === 1) counts.canonically_verified++; - else if (outcome === "succeeded") { counts.rejected++; complete = false; } - } catch (error) { if (error?.kind !== "timed_out") counts.failed++; complete = false; } - return complete; -}; -let coreComplete = false, complete = false, successFinalizationStarted = false; -try { - let batch = await readyJSON(batchCommand), kicked = false; - for (;;) { - if (!await drain(batch)) break; - if (!batch.continuation_due) { coreComplete = true; break; } - if (!kicked) { await command('launchctl kickstart "gui/$(id -u)/org.litman.threadbear"'); kicked = true; } - batch = await readyJSON(batchCommand); - } -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -if (coreComplete) try { - requireTime(); - successFinalizationStarted = true; - await stageFooter("🧵🐻 complete"); - complete = await drain(await readyJSON(batchCommand)); -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -if (!complete && !counts.timed_out) try { - await stageFooter("🧵🐻 next steps (agent): retry the first title handoff"); - await drain(await readyJSON(batchCommand)); -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -if (!complete && counts.timed_out && successFinalizationStarted) try { - await stageFooter("🧵🐻 next steps (agent): retry the first title handoff", false); - await drain(await readyJSON(batchCommand, false, false), false); -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -text(JSON.stringify({...counts, complete})); -``` - -If the top-level raw cell yields, use only `functions.wait` on that same cell until terminal output. After terminal output, use no more tools or commentary. Show only aggregate accepted, canonically verified, failed, drifted, and rejected counts, plus the timed-out count and the `complete` boolean; never expose task IDs, titles, revisions, operation IDs, manifests, or report payloads. - -When the raw cell reports `complete=true`, end the retained response with `🧵🐻 complete`. Otherwise end it with `🧵🐻 next steps (agent): retry the first title handoff`. The cell has already completed all permitted footer work, including the bounded compensation when applicable; write no later text. - -Never edit ThreadBear state files, `.codex-global-state.json`, Codex Desktop private caches, task databases, AGENTS.md managed markers, skill managed markers, or LaunchAgent files directly. Do not click through Desktop, force sidebar refreshes, invent rollback state, or wake a model merely to inspect ThreadBear status. - -Describe the control task title by its current status, never as a permanently fixed bootstrap name. The canonical meanings are `⏳` running, `🚨` blocked, `🙋` needs input, `🤖` automation, `➡️` next steps, `✅` complete, and `❔` unknown. - -## Runtime roles - -The retained control task handles help, install, uninstall, user decisions, and native title handoff. Every heartbeat observes and resolves changed tasks, then stages guarded plans without writing titles. The supported native Desktop setter is the only title writer, so pending plans wait for the next retained-task handoff. Luna medium runs in fresh ephemeral read-only calls only after deterministic and runtime evidence remain ambiguous across passes. The native setter has no compare-and-set operation, so an external rename between revalidation and the setter remains a narrow non-atomic interval; the report fails closed unless the native result and current title both match exactly. - -When managed global guidance is enabled, terminal responses use one concrete footer line: - -- `🧵🐻 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` - -Never emit the literal placeholder words `STATUS`, `OWNER`, or `ACTION`. Report the actual disposition, and do not invent work merely to populate a next action; generic offers, speculative possibilities, and recorded-work mentions do not qualify. +1. Run `help`, `status --json`, and `inventory --json`. +2. Ask whether to clean ThreadBear-owned decoration to subject-only titles or keep current titles. Preview the chosen effect and obtain a clear yes. + Keeping titles makes those decorated strings user-owned; a later reinstall preserves them literally. +3. For cleanup, re-read and update each explicit task through the native setter before removing any artifacts. Require exact returned IDs and titles. A changed title that is not the last committed ThreadBear rendering is user-owned and must not be cleaned. +4. Run the confirmed uninstall. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary while preserving unrelated content and hook order. +5. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance. + +Thank the user and invite optional feedback at `eric@litman.org`. Never remove artifacts before requested title cleanup has completed. diff --git a/cmd/threadbear/appserver.go b/cmd/threadbear/appserver.go deleted file mode 100644 index d8dd100..0000000 --- a/cmd/threadbear/appserver.go +++ /dev/null @@ -1,124 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os/exec" - "sync/atomic" - "syscall" -) - -type appClient struct { - cmd *exec.Cmd - in io.WriteCloser - scanner *bufio.Scanner - next atomic.Int64 -} - -type appThread struct { - ID string - Status struct { - Type string `json:"type"` - ActiveFlags []string `json:"activeFlags"` - } `json:"status"` -} - -func openApp(ctx context.Context, codexPath string) (*appClient, error) { - if codexPath == "" { - codexPath = "codex" - } - cmd := exec.CommandContext(ctx, codexPath, "app-server", "--listen", "stdio://") - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { - if cmd.Process == nil { - return nil - } - return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - } - in, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - out, err := cmd.StdoutPipe() - if err != nil { - _ = in.Close() - return nil, err - } - if err := cmd.Start(); err != nil { - _ = in.Close() - _ = out.Close() - return nil, err - } - client := &appClient{cmd: cmd, in: in, scanner: bufio.NewScanner(out)} - client.scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) - var initialized any - if err := client.call("initialize", map[string]any{ - "clientInfo": map[string]string{"name": "threadbear", "title": "ThreadBear", "version": version}, - "capabilities": map[string]bool{"experimentalApi": true}, - }, &initialized); err != nil { - client.close() - return nil, err - } - if err := client.write(map[string]any{"method": "initialized"}); err != nil { - client.close() - return nil, err - } - return client, nil -} - -func (c *appClient) write(value any) error { - return json.NewEncoder(c.in).Encode(value) -} - -func (c *appClient) call(method string, params any, result any) error { - id := c.next.Add(1) - if err := c.write(map[string]any{"id": id, "method": method, "params": params}); err != nil { - return err - } - for c.scanner.Scan() { - var message struct { - ID int64 `json:"id"` - Method string `json:"method"` - Result json.RawMessage `json:"result"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if json.Unmarshal(c.scanner.Bytes(), &message) != nil || message.ID != id { - continue - } - if message.Error != nil { - return fmt.Errorf("App Server %s failed (%d): %s", method, message.Error.Code, message.Error.Message) - } - if result != nil && len(message.Result) != 0 { - return json.Unmarshal(message.Result, result) - } - return nil - } - return errors.Join(errors.New("App Server closed"), c.scanner.Err()) -} - -func (c *appClient) readThread(id string) (appThread, error) { - var response struct { - Thread appThread `json:"thread"` - } - err := c.call("thread/read", map[string]any{"threadId": id, "includeTurns": false}, &response) - if err == nil && response.Thread.ID != id { - err = fmt.Errorf("App Server returned task %q for %q", response.Thread.ID, id) - } - return response.Thread, err -} - -func (c *appClient) close() error { - _ = c.in.Close() - if c.cmd.Process != nil { - _ = syscall.Kill(-c.cmd.Process.Pid, syscall.SIGKILL) - _ = c.cmd.Process.Kill() - } - return c.cmd.Wait() -} diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index e156f4f..b7b366d 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "database/sql" "encoding/json" @@ -8,578 +9,248 @@ import ( "path/filepath" "strings" "testing" - "time" - "github.com/ericlitman/threadbear/assets" _ "modernc.org/sqlite" ) -func TestDeterministicFooterAndAmbiguity(t *testing.T) { - complete := rolloutLine("response_item", map[string]any{ - "type": "message", "role": "assistant", "phase": "final_answer", - "content": []map[string]string{{"type": "output_text", "text": "Done.\n\n🧵🐻 complete"}}, - }) - need := rolloutLine("response_item", map[string]any{ - "type": "message", "role": "assistant", "phase": "final_answer", - "content": []map[string]string{{"type": "output_text", "text": "Pick one.\n\n🧵🐻 needs input (you): choose the release region"}}, - }) - for _, tc := range []struct { - name, data, status, action string - resolved bool - }{ - {"complete", complete, "complete", "", true}, - {"needs input", need, "needs_input", "choose the release region", true}, - {"legacy prose", rolloutLine("response_item", map[string]any{"type": "message", "role": "assistant", "phase": "final_answer", "content": []map[string]string{{"type": "output_text", "text": "I think this is done."}}}), "", "", false}, - } { - t.Run(tc.name, func(t *testing.T) { - got := analyze([]byte(tc.data)) - if got.Resolved != tc.resolved || got.Status != tc.status || got.Action != tc.action { - t.Fatalf("analyze() = %#v", got) - } - }) - } -} - -func TestFooterMustBeFinalAndConcrete(t *testing.T) { - for _, message := range []string{ - "🧵🐻 complete\nextra", - "> 🧵🐻 complete", - "🧵🐻 needs input (you): decide", - "🧵🐻 blocked (agent): restore the service", - } { - if result := parseFooter(message); result.Resolved { - t.Fatalf("accepted %q", message) - } - } -} - -func TestVisibleTitleIsAtMostSixtyUTF16Units(t *testing.T) { - got := title("complete", strings.Repeat("🧵", 80), "") - if utf16Len(got) > 60 || !strings.HasSuffix(got, "…") || !strings.HasPrefix(got, "✅ ") { - t.Fatalf("title = %q (%d units)", got, utf16Len(got)) - } -} - -func TestFreshStateAdoptsExistingTitleAsUserSubject(t *testing.T) { - task := indexedTask{ID: "task-1", Revision: 3, Title: "🚨 Customer's literal title"} - record, plan := decide(task, analysis{Resolved: true, Status: "complete"}) - if record.Subject != "Customer's literal title" { - t.Fatalf("subject = %q", record.Subject) - } - if plan.ExpectedTitle != task.Title || plan.DesiredTitle != "✅ Customer's literal title" { - t.Fatalf("plan = %#v", plan) - } -} - -func TestPlanIdentityBindsEvidenceAndOwnership(t *testing.T) { - a := planID(plan{TaskID: "t", Revision: 1, ExpectedTitle: "A", DesiredTitle: "✅ A", Evidence: "one", Subject: "A"}) - b := planID(plan{TaskID: "t", Revision: 1, ExpectedTitle: "A", DesiredTitle: "✅ A", Evidence: "two", Subject: "A"}) - c := planID(plan{TaskID: "t", Revision: 1, ExpectedTitle: "A", DesiredTitle: "✅ A", Evidence: "one", Subject: "A hidden"}) - if a == b || a == c { - t.Fatal("plan identity ignored evidence or ownership") - } -} - -func TestStateWriteIsPrivateAndAtomic(t *testing.T) { - dir := t.TempDir() - store := newStore(dir) - value := freshState("control") - if err := store.save(value); err != nil { +func testIndex(t *testing.T) (string, *sql.DB) { + t.Helper() + root := t.TempDir() + codex := filepath.Join(root, "codex") + if err := os.Mkdir(codex, 0o700); err != nil { t.Fatal(err) } - info, err := os.Stat(filepath.Join(dir, "core.json")) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0o600 { - t.Fatalf("mode = %o", info.Mode().Perm()) - } - loaded, err := store.load() - if err != nil || loaded.Format != stateFormat || loaded.ControlTaskID != "control" { - t.Fatalf("load = %#v, %v", loaded, err) - } -} - -func TestAmbiguityRequiresStableEvidenceBeforeLuna(t *testing.T) { - record := taskRecord{Evidence: "same", AmbiguousPasses: 2} - if !lunaEligible(record, "same") || lunaEligible(record, "changed") || lunaEligible(taskRecord{}, "same") { - t.Fatal("ambiguity stability rule failed") - } -} - -func TestEvidenceIdentityBindsPathAndBoundary(t *testing.T) { - data := []byte("same") - if evidenceID("/a", 4, data) == evidenceID("/b", 4, data) || - evidenceID("/a", 4, data) == evidenceID("/a", 5, data) { - t.Fatal("evidence identity omitted its source or fixed boundary") - } -} - -func TestReadEvidenceDropsPartialBoundaryLines(t *testing.T) { - path := filepath.Join(t.TempDir(), "rollout.jsonl") - first := rolloutLine("response_item", map[string]any{"type": "message", "role": "assistant", "phase": "final_answer", "content": []map[string]string{{"text": "🧵🐻 complete"}}}) - if err := os.WriteFile(path, []byte(first+`{"type":"response_item"`), 0o600); err != nil { - t.Fatal(err) - } - data, boundary, err := readEvidence(path) - if err != nil || string(data) != first || boundary <= int64(len(first)) { - t.Fatalf("readEvidence = %q, %d, %v", data, boundary, err) - } -} - -func TestRuntimePrecedence(t *testing.T) { - var thread appThread - thread.Status.Type = "active" - thread.Status.ActiveFlags = []string{"waitingForApproval"} - if got := runtimeResult(thread, ""); got.Status != "needs_input" { - t.Fatalf("runtime = %#v", got) - } - thread.Status.ActiveFlags = nil - if got := runtimeResult(thread, ""); got.Status != "running" { - t.Fatalf("runtime = %#v", got) - } - thread.Status.Type = "idle" - if got := runtimeResult(thread, "automation"); got.Status != "automation" { - t.Fatalf("runtime = %#v", got) - } -} - -func TestSQLiteHomeFollowsCodexTOML(t *testing.T) { - for _, config := range []string{ - `sqlite_home = "state" # local database`, - `"sqlite_home" = 'state'`, - `sqlite_home = """state"""`, - } { - t.Run(config, func(t *testing.T) { - base := t.TempDir() - t.Setenv("CODEX_HOME", base) - t.Setenv("CODEX_SQLITE_HOME", filepath.Join(base, "wrong")) - if err := os.WriteFile(filepath.Join(base, "config.toml"), []byte(config), 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 TestDryRunScansWithoutCreatingState(t *testing.T) { - root := t.TempDir() - db, err := sql.Open("sqlite", filepath.Join(root, "state_1.sqlite")) + db, err := sql.Open("sqlite", filepath.Join(codex, "state_1.sqlite")) if 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)`) if err != nil { t.Fatal(err) } - rollout := filepath.Join(root, "task.jsonl") - if err := os.WriteFile(rollout, []byte(rolloutLine("response_item", map[string]any{"type": "message", "role": "assistant", "phase": "final_answer", "content": []map[string]string{{"text": "🧵🐻 complete"}}})), 0o600); err != nil { - t.Fatal(err) - } - _, err = db.Exec(`INSERT INTO threads VALUES ('task',1,'subject',NULL,0,'vscode','',?)`, rollout) - if err != nil { - t.Fatal(err) - } - db.Close() - stateRoot := filepath.Join(root, "absent-state") - t.Setenv("CODEX_SQLITE_HOME", root) - t.Setenv("THREADBEAR_STATE_DIR", stateRoot) - t.Setenv("THREADBEAR_CONTROL_TASK_ID", "control") - result, err := heartbeat(context.Background(), true) - if err != nil || result.Stats.Deterministic != 1 { - t.Fatalf("heartbeat = %#v, %v", result, err) - } - if _, err := os.Stat(stateRoot); !os.IsNotExist(err) { - t.Fatalf("dry run created %s", stateRoot) - } + t.Setenv("HOME", root) + t.Setenv("CODEX_HOME", codex) + return root, db } -func TestHeartbeatStagesWithoutWritingTitle(t *testing.T) { - root, db, rollout := testIndex(t, "task", "subject") - writeRollout(t, rollout, rolloutLine("response_item", map[string]any{ - "type": "message", "role": "assistant", "phase": "final_answer", - "content": []map[string]string{{"text": "🧵🐻 complete"}}, - })) - stateRoot := filepath.Join(root, "state") - t.Setenv("CODEX_SQLITE_HOME", root) - t.Setenv("THREADBEAR_STATE_DIR", stateRoot) - store := newStore(stateRoot) - value := freshState("control") - value.NativeBootstrap = false - if err := store.save(value); err != nil { +func addTask(t *testing.T, db *sql.DB, 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) } - result, err := heartbeat(context.Background(), false) - if err != nil || result.Stats.Staged != 1 { - t.Fatalf("heartbeat = %#v, %v", result, err) - } - value, err = store.load() - if err != nil || len(value.Plans) != 1 || value.Tasks["task"].LastApplied != "" { - t.Fatalf("state = %#v, %v", value, err) - } - if value.LastScan.Staged != 1 { - t.Fatalf("persisted staged count = %d", value.LastScan.Staged) - } - var title string - if err := db.QueryRow(`SELECT COALESCE(name,title) FROM threads WHERE id='task'`).Scan(&title); err != nil || title != "subject" { - t.Fatalf("title = %q, %v", title, err) + if _, err := db.Exec(`INSERT INTO threads VALUES (?,1,?,?,?,?,'',?)`, id, title, name, archived, source, rollout); err != nil { + t.Fatal(err) } + return rollout } -func TestHeartbeatPrunesPlansForMissingTasks(t *testing.T) { - root, db, rollout := testIndex(t, "task", "subject") - writeRollout(t, rollout, rolloutLine("response_item", map[string]any{ - "type": "message", "role": "assistant", "phase": "final_answer", - "content": []map[string]string{{"text": "🧵🐻 complete"}}, - })) - t.Setenv("CODEX_SQLITE_HOME", root) - t.Setenv("THREADBEAR_STATE_DIR", filepath.Join(root, "threadbear")) - store := newStore(stateDir()) - value := freshState("control") - value.NativeBootstrap = false - if err := store.save(value); err != nil { - t.Fatal(err) +func TestInventoryIncludesEveryUnarchivedSourceAndExactTask(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "desktop", "generated", "renamed", "vscode", 0) + addTask(t, db, root, "exec", "exec title", nil, "exec", 0) + addTask(t, db, root, "cli", "", nil, "cli", 0) + addTask(t, db, root, "archived", "old", nil, "vscode", 1) + tasks, err := inventory(context.Background()) + if err != nil || len(tasks) != 3 || tasks[0].ID != "cli" || tasks[1].ID != "desktop" || tasks[1].Title != "renamed" { + t.Fatalf("inventory = %#v, %v", tasks, err) } - if _, err := heartbeat(context.Background(), false); err != nil { - t.Fatal(err) + got, found, err := oneTask(context.Background(), "desktop") + if err != nil || !found || got.Title != "renamed" { + t.Fatalf("oneTask = %#v, %v, %v", got, found, err) } - if _, err := db.Exec(`DELETE FROM threads WHERE id='task'`); err != nil { - t.Fatal(err) + if _, found, _ := oneTask(context.Background(), "archived"); found { + t.Fatal("archived task was addressable") } - if _, err := heartbeat(context.Background(), false); err != nil { + readOnly, err := openIndex() + if err != nil { t.Fatal(err) } - value, _ = store.load() - if len(value.Plans) != 0 || len(value.Tasks) != 0 { - t.Fatalf("missing task retained: %#v", value) + 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 TestControlOperationAlwaysUsesNativeSetterAndGuardsTurn(t *testing.T) { - root, db, rollout := testIndex(t, "control", "subject") - writeRollout(t, rollout, rolloutLine("turn_context", map[string]any{"turn_id": "turn-1"})+ - rolloutLine("response_item", map[string]any{"type": "function_call_output", "output": strings.Repeat("x", 600*1024)})) - t.Setenv("CODEX_SQLITE_HOME", root) - t.Setenv("THREADBEAR_STATE_DIR", filepath.Join(root, "threadbear")) - t.Setenv("CODEX_THREAD_ID", "control") - store := newStore(stateDir()) - if err := store.save(freshState("control")); err != nil { - t.Fatal(err) - } - if _, err := titlePlan(context.Background(), "stage", "", strings.NewReader("🧵🐻 complete")); err != nil { +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 { t.Fatal(err) } - value, _ := store.load() - var pending plan - for _, pending = range value.Plans { + got, err := sqliteHome() + if err != nil || got != filepath.Join(base, "state") { + t.Fatalf("sqliteHome = %q, %v", got, err) } - if _, err := db.Exec(`UPDATE threads SET name=? WHERE id='control'`, pending.DesiredTitle); err != nil { +} + +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, err := titlePlan(context.Background(), "operation", pending.ID, strings.NewReader("")) - operation := got.(map[string]any) - if err != nil || operation["action"] != "set" { - t.Fatalf("same-title operation = %#v, %v", got, err) + got, ok := rolloutFooter(path) + if !ok || got.Status != "needs_input" || got.Action != "choose the release region" { + t.Fatalf("footer = %#v, %v", got, ok) } - f, err := os.OpenFile(rollout, os.O_APPEND|os.O_WRONLY, 0) - if err != nil { + 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) } - _, _ = f.WriteString(rolloutLine("turn_context", map[string]any{"turn_id": "turn-2"})) - _ = f.Close() - got, err = titlePlan(context.Background(), "operation", pending.ID, strings.NewReader("")) - operation = got.(map[string]any) - if err != nil || operation["disposition"] != "drifted" { - t.Fatalf("new-turn operation = %#v, %v", got, err) - } -} - -func TestInstallRequiresExplicitControlTask(t *testing.T) { - t.Setenv("CODEX_THREAD_ID", "ambient-task") - if _, err := install(context.Background(), "", true, false); err == nil || !strings.Contains(err.Error(), "--control-task-id") { - t.Fatalf("install error = %v", err) - } -} - -func TestInstallDryRunHasNoEffects(t *testing.T) { - root, _, _ := testIndex(t, "control", "control task") - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) - t.Setenv("CODEX_SQLITE_HOME", root) - t.Setenv("THREADBEAR_STATE_DIR", filepath.Join(home, "state")) - result, err := install(context.Background(), "control", true, false) - if err != nil || result.(map[string]any)["dry_run"] != true { - t.Fatalf("dry install = %#v, %v", result, err) - } - binary, _, _, _ := installPaths() - if _, err := os.Stat(binary); !os.IsNotExist(err) { - t.Fatalf("dry install created %s", binary) - } - if _, err := os.Stat(stateDir()); !os.IsNotExist(err) { - t.Fatalf("dry install created %s", stateDir()) + if _, ok := rolloutFooter(path); ok { + t.Fatal("legacy prose classified deterministically") } } -func TestStatusChecksInstalledRuntime(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) - t.Setenv("THREADBEAR_STATE_DIR", filepath.Join(home, "state")) - binary, _, _, plist := installPaths() - if err := os.MkdirAll(filepath.Dir(binary), 0o700); err != nil { +func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(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) } - if err := os.MkdirAll(filepath.Dir(plist), 0o700); err != nil { + pre := hookPayload("PreToolUse", "task", "call-1", map[string]any{"title": runningMarker}, nil) + var output bytes.Buffer + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { t.Fatal(err) } - writeRollout(t, binary, "binary") - writeRollout(t, plist, "plist") - if err := newStore(stateDir()).save(freshState("control")); err != nil { - t.Fatal(err) - } - binDir := filepath.Join(home, "bin") - if err := os.MkdirAll(binDir, 0o700); err != nil { - t.Fatal(err) - } - launchctl := filepath.Join(binDir, "launchctl") - if err := os.WriteFile(launchctl, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { - t.Fatal(err) + proposed := rewrittenTitle(t, output.Bytes()) + if proposed != "⏳ Stable subject" { + t.Fatalf("rewritten title = %q", proposed) } - t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) - if _, err := status(); err != nil { - t.Fatal(err) + saved, _ := newStore(stateDir()).read() + if saved.Tasks["task"].Pending == nil { + t.Fatal("Pre did not record a proposal") } - if err := os.Remove(plist); err != nil { + 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) } - if _, err := status(); err == nil { - t.Fatal("status accepted missing plist") + 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 TestBootoutIgnoresOnlyMissingService(t *testing.T) { - binDir := t.TempDir() - launchctl := filepath.Join(binDir, "launchctl") - t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) - if err := os.WriteFile(launchctl, []byte("#!/bin/sh\necho 'Boot-out failed: 3: No such process' >&2\nexit 3\n"), 0o700); err != nil { + // A setter success with a lost Post remains provisional ownership on the next turn. + 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 { t.Fatal(err) } - if err := bootout(context.Background(), "gui/1"); err != nil { + proposed = rewrittenTitle(t, output.Bytes()) + if _, err := db.Exec(`UPDATE threads SET name=? WHERE id='task'`, proposed); err != nil { t.Fatal(err) } - if err := os.WriteFile(launchctl, []byte("#!/bin/sh\necho denied >&2\nexit 1\n"), 0o700); err != nil { + pre = hookPayload("PreToolUse", "task", "call-3", map[string]any{"title": runningMarker}, nil) + output.Reset() + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { t.Fatal(err) } - if err := bootout(context.Background(), "gui/1"); err == nil { - t.Fatal("bootout ignored hard failure") + if got := rewrittenTitle(t, output.Bytes()); got != "⏳ Stable subject" { + t.Fatalf("lost-Post recovery duplicated ownership: %q", got) } } -func TestUninstallRemovesOnlyManagedRuntime(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) - t.Setenv("THREADBEAR_STATE_DIR", filepath.Join(home, "state")) - binary, agents, skill, plist := installPaths() - for _, path := range []string{binary, plist} { - if err := writeAtomic(path, []byte("managed"), 0o600); err != nil { - t.Fatal(err) - } - } - if err := writeAtomic(agents, []byte("keep this\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := writeManaged(agents, "managed agents"); err != nil { +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}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { t.Fatal(err) } - if err := writeManaged(skill, "managed skill"); 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") } - if err := newStore(stateDir()).save(freshState("control")); err != nil { - t.Fatal(err) - } - binDir := filepath.Join(home, "bin") - if err := os.MkdirAll(binDir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(binDir, "launchctl"), []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) - if _, err := uninstall(context.Background(), true); err != nil { - t.Fatal(err) + saved, _ := newStore(stateDir()).read() + if saved.Tasks["task"].Pending == nil || saved.Tasks["task"].Last != "" { + t.Fatalf("mismatch committed state: %#v", saved.Tasks["task"]) } - data, err := os.ReadFile(agents) - if err != nil || string(data) != "keep this\n" { - t.Fatalf("unrelated guidance = %q, %v", data, err) - } - for _, path := range []string{binary, skill, plist, stateDir()} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("uninstall retained %s", path) - } + extra := `{"threadId":"task","title":` + string(mustJSON(title)) + `,"extra":true}` + post = hookPayload("PostToolUse", "task", "call", map[string]any{"title": title}, extra) + if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err == nil { + t.Fatal("native result with extra fields was accepted") } } -func TestHeartbeatAndAppServerAreDeadlineBound(t *testing.T) { - if heartbeatLimit >= 5*time.Minute { - t.Fatalf("heartbeat limit = %s", heartbeatLimit) - } - command := filepath.Join(t.TempDir(), "codex") - if err := os.WriteFile(command, []byte("#!/bin/sh\nsleep 5 &\nwait\n"), 0o700); err != nil { +func TestBulkMarkerRereadsExplicitTargetAndAdoptsRename(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "target", "Bulk subject", nil, "exec", 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) } - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - started := time.Now() - if _, err := openApp(ctx, command); err == nil || time.Since(started) > time.Second { - t.Fatalf("deadline result after %s: %v", time.Since(started), err) + desired := rewrittenTitle(t, output.Bytes()) + if desired != "✅ Bulk subject" { + t.Fatalf("bulk title = %q", desired) } -} - -func TestNativeCellRejectsUnacceptedReport(t *testing.T) { - if !strings.Contains(assets.SkillManagedContent, `outcome === "succeeded" && report.accepted === 1`) || - !strings.Contains(assets.SkillManagedContent, `counts.rejected++; complete = false`) { - t.Fatal("native cell can complete after an unaccepted report") + saved, _ := newStore(stateDir()).read() + if saved.Tasks["target"].Pending.ToolUseID != "bulk-1" { + t.Fatal("bulk proposal was not bound to native call") } -} - -func TestTitleReportRequiresExactNativeResult(t *testing.T) { - root, _, _ := testIndex(t, "task", "✅ subject") - t.Setenv("CODEX_SQLITE_HOME", root) - t.Setenv("THREADBEAR_STATE_DIR", filepath.Join(root, "threadbear")) - store := newStore(stateDir()) - value := freshState("control") - pending := plan{ID: "operation", TaskID: "task", DesiredTitle: "✅ subject", Status: "complete", Subject: "subject"} - value.Plans[pending.ID] = pending - value.Tasks[pending.TaskID] = taskRecord{Subject: "subject"} - if err := store.save(value); err != nil { + 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) } - wrong := `{"reports":[{"operation_id":"operation","outcome":"succeeded","task_id":"other","title":"✅ subject"}]}` - got, err := titlePlan(context.Background(), "report", "", strings.NewReader(wrong)) - if err != nil || got.(map[string]any)["accepted"] != 0 { - t.Fatalf("mismatched report = %#v, %v", got, err) - } - exact := `{"reports":[{"operation_id":"operation","outcome":"succeeded","task_id":"task","title":"✅ subject"}]}` - got, err = titlePlan(context.Background(), "report", "", strings.NewReader(exact)) - if err != nil || got.(map[string]any)["accepted"] != 1 { - t.Fatalf("exact report = %#v, %v", got, err) + if _, err := db.Exec(`UPDATE threads SET name='User renamed' WHERE id='target'`); err != nil { + t.Fatal(err) } -} - -func TestTitleBatchIsSortedAndBounded(t *testing.T) { - root := t.TempDir() - t.Setenv("THREADBEAR_STATE_DIR", root) - store := newStore(root) - for _, count := range []int{8, 9} { - value := freshState("control") - for i := count - 1; i >= 0; i-- { - id := string(rune('a' + i)) - value.Plans[id] = plan{ID: id, TaskID: id} - } - if err := store.save(value); err != nil { - t.Fatal(err) - } - got, err := titlePlan(context.Background(), "batch", "", strings.NewReader("")) - if err != nil { - t.Fatal(err) - } - batch := got.(map[string]any) - ids := batch["operation_ids"].([]string) - if len(ids) != 8 || strings.Join(ids, "") != "abcdefgh" || batch["continuation_due"] != (count == 9) { - t.Fatalf("%d plans -> %#v", count, batch) - } + pre = hookPayload("PreToolUse", "installer", "bulk-2", map[string]any{"threadId": "target", "title": "🧵🐻 blocked (external): restore the signing service"}, nil) + output.Reset() + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) } -} - -func TestLunaResultValidation(t *testing.T) { - expected := map[string]bool{"a": true, "b": true} - valid := `{"results":[{"task_id":"a","status":"complete","action":""},{"task_id":"b","status":"needs_input","action":" choose region "}]}` - results, err := decodeLuna([]byte(valid), expected, 2) - if err != nil || results["b"].Action != "choose region" { - t.Fatalf("valid result = %#v, %v", results, err) - } - for name, data := range map[string]string{ - "malformed": `{`, - "missing": `{"results":[{"task_id":"a","status":"complete","action":""}]}`, - "duplicate": `{"results":[{"task_id":"a","status":"complete","action":""},{"task_id":"a","status":"complete","action":""}]}`, - "unknown": `{"results":[{"task_id":"a","status":"complete","action":""},{"task_id":"x","status":"complete","action":""}]}`, - "status": `{"results":[{"task_id":"a","status":"invented","action":""},{"task_id":"b","status":"complete","action":""}]}`, - } { - t.Run(name, func(t *testing.T) { - if _, err := decodeLuna([]byte(data), expected, 2); err == nil { - t.Fatal("invalid Luna result accepted") - } - }) + if got := rewrittenTitle(t, output.Bytes()); got != "🚨 User renamed → restore the signing service" { + t.Fatalf("bulk rename was not adopted: %q", got) } } -func TestLiveLunaBatch(t *testing.T) { - if os.Getenv("THREADBEAR_LIVE_LUNA") != "1" { - t.Skip("set THREADBEAR_LIVE_LUNA=1 for the opt-in model canary") - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - tasks := []semanticCandidate{ - {Guard: guardedResult{Task: indexedTask{ID: "legacy-complete"}}, Text: "USER:\nFix the typo\nASSISTANT:\nFixed and tests pass."}, - {Guard: guardedResult{Task: indexedTask{ID: "legacy-question"}}, Text: "USER:\nDeploy it\nASSISTANT:\nWhich region should I use?"}, - } - results, err := classifyLuna(ctx, tasks) - if err != nil || len(results) != 2 || !results["legacy-complete"].Resolved || !results["legacy-question"].Resolved { - t.Fatalf("classifyLuna = %#v, %v", results, err) +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 TestLiveAppServerRead(t *testing.T) { - if os.Getenv("THREADBEAR_LIVE_APP") != "1" { - t.Skip("set THREADBEAR_LIVE_APP=1 for the opt-in App Server canary") - } - id := os.Getenv("THREADBEAR_CANARY_TASK_ID") - if id == "" { - id = os.Getenv("CODEX_THREAD_ID") +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 id == "" { - t.Fatal("CODEX_THREAD_ID is required") - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - client, err := openApp(ctx, "codex") - if err != nil { - t.Fatal(err) - } - defer client.close() - thread, err := client.readThread(id) - if err != nil || thread.ID != id { - t.Fatalf("readThread = %#v, %v", thread, err) + if response != nil { + value["tool_response"] = response } + data, _ := json.Marshal(value) + return string(data) } -func TestLiveNativePlanPrepare(t *testing.T) { - if os.Getenv("THREADBEAR_LIVE_NATIVE") != "1" { - t.Skip("set THREADBEAR_LIVE_NATIVE=1 for the retained-task canary") - } - id := os.Getenv("CODEX_THREAD_ID") - if id == "" { - t.Fatal("CODEX_THREAD_ID is required") +func rewrittenTitle(t *testing.T, data []byte) string { + t.Helper() + var value struct { + Hook struct { + Updated map[string]json.RawMessage `json:"updatedInput"` + } `json:"hookSpecificOutput"` } - store := newStore(stateDir()) - if err := store.save(freshState(id)); err != nil { + if err := json.Unmarshal(data, &value); err != nil { t.Fatal(err) } - footer := "🧵🐻 next steps (agent): verify the native Desktop canary" - if _, err := titlePlan(context.Background(), "stage", "", strings.NewReader(footer)); err != nil { + title, err := stringField(value.Hook.Updated, "title", true) + if err != nil { t.Fatal(err) } - value, err := store.load() - if err != nil || len(value.Plans) != 1 { - t.Fatalf("prepared state = %#v, %v", value, err) - } + return title } func rolloutLine(kind string, payload any) string { @@ -587,30 +258,7 @@ func rolloutLine(kind string, payload any) string { return string(data) + "\n" } -func testIndex(t *testing.T, id, title string) (string, *sql.DB, string) { - t.Helper() - root := t.TempDir() - db, err := sql.Open("sqlite", filepath.Join(root, "state_1.sqlite")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - if _, 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)`); err != nil { - t.Fatal(err) - } - rollout := filepath.Join(root, id+".jsonl") - writeRollout(t, rollout, "") - if _, err := db.Exec(`INSERT INTO threads VALUES (?,1,?,NULL,0,'vscode','',?)`, id, title, rollout); err != nil { - t.Fatal(err) - } - return root, db, rollout -} - -func writeRollout(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } +func mustJSON(value any) []byte { + data, _ := json.Marshal(value) + return data } diff --git a/cmd/threadbear/engine.go b/cmd/threadbear/engine.go deleted file mode 100644 index a567deb..0000000 --- a/cmd/threadbear/engine.go +++ /dev/null @@ -1,313 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -type heartbeatResult struct { - Ready bool `json:"ready"` - Stats scanStats `json:"stats"` -} - -type guardedResult struct { - Task indexedTask - Info analysis -} - -type semanticCandidate struct { - Guard guardedResult - Text string -} - -func heartbeat(ctx context.Context, dry bool) (heartbeatResult, error) { - started := time.Now() - disk := newStore(stateDir()) - var lock *os.File - var err error - if !dry { - lock, err = disk.lock() - if err != nil { - return heartbeatResult{}, err - } - } - current, err := disk.load() - if dry && errors.Is(err, os.ErrNotExist) && os.Getenv("THREADBEAR_CONTROL_TASK_ID") != "" { - current = freshState(os.Getenv("THREADBEAR_CONTROL_TASK_ID")) - err = nil - } - if err != nil { - if lock != nil { - unlock(lock) - } - return heartbeatResult{}, err - } - tasks, err := inventory(ctx, current.ControlTaskID) - if err != nil { - if lock != nil { - unlock(lock) - } - return heartbeatResult{}, err - } - stats := scanStats{Tasks: len(tasks)} - present := make(map[string]bool, len(tasks)) - for _, task := range tasks { - present[task.ID] = true - } - for id := range current.Tasks { - if id != current.ControlTaskID && !present[id] { - delete(current.Tasks, id) - } - } - for id, pending := range current.Plans { - if pending.TaskID != current.ControlTaskID && !present[pending.TaskID] { - delete(current.Plans, id) - } - } - var unresolved []guardedResult - for _, task := range tasks { - data, boundary, readErr := readEvidence(task.RolloutPath) - if readErr != nil { - continue - } - result := analyze(data) - result.Evidence = evidenceID(task.RolloutPath, boundary, data) - result.Size = boundary - old, exists := current.Tasks[task.ID] - if exists && old.Revision == task.Revision && old.Title == task.Title && old.Evidence == result.Evidence && old.RolloutSize == boundary { - if old.Status == "unknown" && old.LastApplied == "" { - old.AmbiguousPasses++ - current.Tasks[task.ID] = old - unresolved = append(unresolved, guardedResult{task, result}) - stats.Ambiguous++ - } - continue - } - if exists && old.Evidence == result.Evidence && old.LastApplied != "" && task.Title == old.LastApplied { - old.Revision, old.Title, old.RolloutSize = task.Revision, task.Title, boundary - current.Tasks[task.ID] = old - continue - } - stats.Changed++ - if result.Resolved { - record, pending := decision(task, old, result) - current.Tasks[task.ID] = record - putPlan(¤t, pending) - stats.Deterministic++ - } else { - passes := 1 - if exists && old.Evidence == result.Evidence { - passes = old.AmbiguousPasses + 1 - } - record := taskRecord{Revision: task.Revision, Title: task.Title, RolloutPath: task.RolloutPath, RolloutSize: boundary, Evidence: result.Evidence, Status: "unknown", Subject: chooseSubject(task.Title, old), AmbiguousPasses: passes, LastApplied: old.LastApplied} - current.Tasks[task.ID] = record - unresolved = append(unresolved, guardedResult{task, result}) - stats.Ambiguous++ - } - } - stats.ScanMillis = time.Since(started).Milliseconds() - stats.Staged = len(current.Plans) - current.LastScan = stats - if dry { - return heartbeatResult{Ready: true, Stats: stats}, nil - } - if err := disk.save(current); err != nil { - unlock(lock) - return heartbeatResult{}, err - } - if err := unlock(lock); err != nil { - return heartbeatResult{}, err - } - if current.NativeBootstrap { - return heartbeatResult{Ready: true, Stats: stats}, nil - } - if len(unresolved) == 0 { - return heartbeatResult{Ready: true, Stats: stats}, nil - } - - client, err := openApp(ctx, current.CodexPath) - if err != nil { - return heartbeatResult{Ready: false, Stats: stats}, err - } - defer client.close() - var outcomes []guardedResult - var semantic []semanticCandidate - for _, item := range unresolved { - thread, readErr := client.readThread(item.Task.ID) - if readErr != nil { - continue - } - if runtime := runtimeResult(thread, item.Task.ThreadSource); runtime.Resolved { - runtime.Evidence, runtime.Size = item.Info.Evidence, item.Info.Size - outcomes = append(outcomes, guardedResult{item.Task, runtime}) - continue - } - record := current.Tasks[item.Task.ID] - if lunaEligible(record, item.Info.Evidence) { - data, _, _ := readEvidence(item.Task.RolloutPath) - semantic = append(semantic, semanticCandidate{item, semanticText(data)}) - } - } - for len(semantic) > 0 { - end := min(16, len(semantic)) - batch := semantic[:end] - classified, semanticErr := classifyLuna(ctx, batch) - stats.Luna++ - if semanticErr == nil { - for _, item := range batch { - result := classified[item.Guard.Task.ID] - result.Evidence, result.Size = item.Guard.Info.Evidence, item.Guard.Info.Size - outcomes = append(outcomes, guardedResult{item.Guard.Task, result}) - } - } - semantic = semantic[end:] - } - lock, err = disk.lock() - if err != nil { - return heartbeatResult{}, err - } - latest, err := disk.load() - if err == nil && len(outcomes) > 0 { - tasks, indexErr := inventory(ctx, latest.ControlTaskID) - byID := make(map[string]indexedTask, len(tasks)) - for _, task := range tasks { - byID[task.ID] = task - } - if indexErr != nil { - err = indexErr - } else { - for _, outcome := range outcomes { - record, ok := latest.Tasks[outcome.Task.ID] - task, present := byID[outcome.Task.ID] - if !ok || !present || record.Evidence != outcome.Info.Evidence || !sameEvidence(task, record) { - continue - } - updated, pending := decision(task, record, outcome.Info) - latest.Tasks[task.ID] = updated - putPlan(&latest, pending) - } - } - } - if err == nil { - stats.Staged = len(latest.Plans) - latest.LastScan = stats - err = disk.save(latest) - } - err = errors.Join(err, unlock(lock)) - if err != nil { - return heartbeatResult{}, err - } - return heartbeatResult{Ready: true, Stats: stats}, nil -} - -func runtimeResult(thread appThread, source string) analysis { - if thread.Status.Type == "active" { - for _, flag := range thread.Status.ActiveFlags { - value := strings.ToLower(flag) - if strings.Contains(value, "wait") || strings.Contains(value, "approval") || strings.Contains(value, "input") { - return analysis{Resolved: true, Status: "needs_input", Action: "respond to the pending prompt"} - } - } - return analysis{Resolved: true, Status: "running"} - } - if thread.Status.Type == "idle" && strings.Contains(strings.ToLower(source), "automation") { - return analysis{Resolved: true, Status: "automation"} - } - return analysis{} -} - -func decision(task indexedTask, old taskRecord, result analysis) (taskRecord, plan) { - record, pending := decide(task, result) - record.Subject = chooseSubject(task.Title, old) - record.LastApplied = old.LastApplied - pending.Subject = record.Subject - pending.DesiredTitle = title(result.Status, record.Subject, result.Action) - pending.ID = planID(pending) - return record, pending -} - -func chooseSubject(current string, old taskRecord) string { - if old.LastApplied != "" && current == old.LastApplied && old.Subject != "" { - return old.Subject - } - return subject(current) -} - -func putPlan(value *state, pending plan) { - for id, current := range value.Plans { - if current.TaskID == pending.TaskID { - delete(value.Plans, id) - } - } - if pending.DesiredTitle != pending.ExpectedTitle { - value.Plans[pending.ID] = pending - } -} - -func classifyLuna(ctx context.Context, tasks []semanticCandidate) (map[string]analysis, error) { - dir, err := os.MkdirTemp("", "threadbear-luna-") - if err != nil { - return nil, err - } - defer os.RemoveAll(dir) - schema := `{"type":"object","additionalProperties":false,"required":["results"],"properties":{"results":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["task_id","status","action"],"properties":{"task_id":{"type":"string"},"status":{"type":"string","enum":["running","blocked","needs_input","automation","next_steps","complete","unknown"]},"action":{"type":"string"}}}}}}` - schemaPath, outputPath := filepath.Join(dir, "schema.json"), filepath.Join(dir, "result.json") - if err := os.WriteFile(schemaPath, []byte(schema), 0o600); err != nil { - return nil, err - } - input := make([]map[string]string, 0, len(tasks)) - expected := map[string]bool{} - for _, task := range tasks { - input = append(input, map[string]string{"task_id": task.Guard.Task.ID, "evidence": task.Text}) - expected[task.Guard.Task.ID] = true - } - payload, _ := json.Marshal(input) - prompt := "Classify each legacy Codex task using only supplied evidence. Return unknown when uncertain. Action is empty unless a concrete owner action is explicit. Return every task exactly once.\n" + string(payload) - isolatedHome := filepath.Join(dir, "codex") - if err := os.Mkdir(isolatedHome, 0o700); err != nil { - return nil, err - } - auth, err := os.ReadFile(filepath.Join(codexHome(), "auth.json")) - if err != nil || os.WriteFile(filepath.Join(isolatedHome, "auth.json"), auth, 0o600) != nil { - return nil, errors.New("copy minimal Codex authentication") - } - args := []string{"exec", "--ephemeral", "--ignore-user-config", "-m", "gpt-5.6-luna", "-c", `model_reasoning_effort="medium"`, "-c", "features.shell_tool=false", "-c", "features.unified_exec=false", "-c", "features.apps=false", "-c", "features.plugins=false", "-c", "features.memories=false", "-c", "features.multi_agent=false", "-c", "features.computer_use=false", "-c", "features.image_generation=false", "-c", `web_search="disabled"`, "-c", "mcp_servers={}", "-s", "read-only", "--skip-git-repo-check", "-C", dir, "--output-schema", schemaPath, "-o", outputPath, prompt} - command := exec.CommandContext(ctx, "codex", args...) - command.Env = append(os.Environ(), "CODEX_HOME="+isolatedHome) - if output, err := command.CombinedOutput(); err != nil { - return nil, fmt.Errorf("Luna classifier failed: %w: %s", err, strings.TrimSpace(string(output))) - } - data, err := os.ReadFile(outputPath) - if err != nil { - return nil, err - } - return decodeLuna(data, expected, len(tasks)) -} - -func decodeLuna(data []byte, expected map[string]bool, count int) (map[string]analysis, error) { - var decoded struct { - Results []struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - Action string `json:"action"` - } `json:"results"` - } - if json.Unmarshal(data, &decoded) != nil || len(decoded.Results) != count { - return nil, fmt.Errorf("invalid Luna result: %s", strings.TrimSpace(string(data))) - } - results := map[string]analysis{} - for _, row := range decoded.Results { - if !expected[row.TaskID] || statusEmoji[row.Status] == "" || results[row.TaskID].Resolved { - return nil, fmt.Errorf("invalid Luna row: task=%q status=%q", row.TaskID, row.Status) - } - results[row.TaskID] = analysis{Resolved: true, Status: row.Status, Action: strings.TrimSpace(row.Action)} - } - return results, nil -} diff --git a/cmd/threadbear/hook.go b/cmd/threadbear/hook.go new file mode 100644 index 0000000..20aae29 --- /dev/null +++ b/cmd/threadbear/hook.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +const ( + titleTool = "codex_appset_thread_title" + runningMarker = "⏳ ThreadBear is working" + unknownMarker = "❔ ThreadBear could not classify" + maxHookBytes = 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 err != nil { + return err + } + if len(data) > maxHookBytes { + return errors.New("input exceeds 1 MiB") + } + return 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, err := stringField(event.ToolInput, "title", true) + if err != nil { + return "", "", err + } + target, err := stringField(event.ToolInput, "threadId", false) + if target == "" { + target = event.SessionID + } + return title, target, err +} +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 + } + switch event.Event { + case "PreToolUse": + if err := preTitle(ctx, event, out); 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 { + title, target, err := titleTarget(event) + if err != nil { + return err + } + result, terminal := parseFooter(title) + if title == runningMarker { + result, terminal = footer{Status: "running"}, true + } else if title == unknownMarker { + result, terminal = footer{Status: "unknown"}, true + } + if !terminal { + if strings.HasPrefix(title, "🧵🐻 ") { + return errors.New("invalid ThreadBear footer marker") + } + return nil + } + proposed, err := stageTitle(ctx, target, result.Status, result.Action, event.ToolUseID) + 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, toolUseID string) (string, error) { + task, found, err := oneTask(ctx, id) + if err != nil { + return "", err + } + if !found { + return "", errors.New("task is not active in Codex") + } + var proposed string + err = newStore(stateDir()).update(func(saved *state) (bool, error) { + record := saved.Tasks[id] + subject := canonicalSubject(task.Title, record) + proposed = renderTitle(status, subject, action) + record.Subject = subject + record.Pending = &pendingProposal{ToolUseID: toolUseID, BaseSubject: subject, Prior: task.Title, Proposed: proposed, Status: status, Action: action} + 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.Proposed != title { + return false, errors.New("native title call does not match its proposal") + } + var encoded string + if json.Unmarshal(event.ToolResponse, &encoded) != nil { + return false, errors.New("native title result is not JSON text") + } + var result map[string]string + if 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 c8b0193..3e65d56 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -1,20 +1,18 @@ package main import ( - "bytes" "context" + "encoding/json" "errors" "fmt" - "html" + "github.com/ericlitman/threadbear/assets" "os" "os/exec" "path/filepath" + "reflect" "runtime" + "slices" "strings" - "text/template" - "time" - - "github.com/ericlitman/threadbear/assets" ) const ( @@ -23,6 +21,9 @@ const ( blockEnd = "" ) +type lifecyclePaths struct{ binary, agents, skill, hooks, plist string } +type rawObject map[string]json.RawMessage + func codexHome() string { if value := os.Getenv("CODEX_HOME"); value != "" { return value @@ -30,210 +31,280 @@ func codexHome() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".codex") } - func stateDir() string { - if value := os.Getenv("THREADBEAR_STATE_DIR"); value != "" { - return value - } home, _ := os.UserHomeDir() return filepath.Join(home, ".local", "share", "threadbear") } - -func installPaths() (binary, agents, skill, plist string) { +func installPaths() lifecyclePaths { home, _ := os.UserHomeDir() - return filepath.Join(home, ".local", "bin", "threadbear"), - filepath.Join(codexHome(), "AGENTS.md"), - filepath.Join(codexHome(), "skills", "threadbear", "SKILL.md"), - filepath.Join(home, "Library", "LaunchAgents", launchLabel+".plist") + return lifecyclePaths{filepath.Join(home, ".local/bin/threadbear"), filepath.Join(codexHome(), "AGENTS.md"), filepath.Join(codexHome(), "skills/threadbear/SKILL.md"), filepath.Join(codexHome(), "hooks.json"), filepath.Join(home, "Library/LaunchAgents", launchLabel+".plist")} } - -func install(ctx context.Context, control string, dry, confirmed bool) (any, error) { - if control == "" { - return nil, errors.New("install requires --control-task-id") - } - task, found, err := oneTask(ctx, control) - if err != nil || !found { - return nil, errors.New("control task must be an existing active Codex task") +func install(ctx context.Context, dry, confirmed bool) (any, error) { + p := installPaths() + hooks, write, err := editHooks(p.hooks, p.binary, true) + if err != nil { + return nil, err } - binary, agents, skill, plist := installPaths() - preview := []string{"adopt control task " + task.ID, "write " + binary, "manage " + agents, "manage " + skill, "schedule five-minute heartbeat"} if dry { - return map[string]any{"ready": true, "dry_run": true, "effects": preview}, nil + return map[string]any{"ready": true, "dry_run": true}, nil } if !confirmed { return nil, errors.New("install requires --noninteractive --confirm after its preview") } - domain := fmt.Sprintf("gui/%d", os.Getuid()) - if err := bootout(ctx, domain); err != nil { - return nil, err - } - codexPath, err := exec.LookPath("codex") - if err != nil { - return nil, errors.New("Codex executable is unavailable") - } - codexPath, _ = filepath.Abs(codexPath) - source, err := os.Executable() + legacy, err := readLegacyState(filepath.Join(stateDir(), "core.json")) if err != nil { return nil, err } - if err := copyAtomic(source, binary, 0o755); err != nil { + if err = stopLegacyService(ctx); err != nil { return nil, err } - if err := writeManaged(agents, assets.AgentsManagedContent); err != nil { - return nil, err - } - if err := writeManaged(skill, assets.SkillManagedContent); err != nil { + err = newStore(stateDir()).update(func(value *state) (bool, error) { + changed := false + for id, task := range legacy { + if old, ok := value.Tasks[id]; !ok || old.Subject == "" { + value.Tasks[id] = task + changed = true + } + } + return changed, nil + }) + if err != nil { return nil, err } - disk := newStore(stateDir()) - lock, err := disk.lock() + source, err := os.Executable() if err != nil { return nil, err } - value := freshState(control) - value.CodexPath = codexPath - err = disk.save(value) - unlock(lock) + binary, err := os.ReadFile(source) if err != nil { return nil, err } - initial, err := heartbeat(ctx, false) - if err != nil { - return nil, fmt.Errorf("initial deterministic scan: %w", err) + if err = writeAtomic(p.binary, binary, 0o755); err == nil { + err = manageBlock(p.agents, assets.AgentsManagedContent) } - home, _ := os.UserHomeDir() - logDir := filepath.Join(stateDir(), "logs") - _ = os.MkdirAll(logDir, 0o700) - spec := map[string]any{ - "Label": launchLabel, "BinaryPath": binary, "StartInterval": 300, "Home": home, "CodexHome": codexHome(), - "Path": filepath.Dir(codexPath) + ":/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", "LCAll": "C", - "StdoutPath": filepath.Join(logDir, "heartbeat.stdout.log"), "StderrPath": filepath.Join(logDir, "heartbeat.stderr.log"), - } - tmpl, err := template.New("plist").Funcs(template.FuncMap{"xml": html.EscapeString}).Parse(assets.LaunchAgentPlistTemplate) - var rendered bytes.Buffer if err == nil { - err = tmpl.Execute(&rendered, spec) + err = writeAtomic(p.skill, []byte(assets.SkillManagedContent), 0o600) } - if err != nil { - return nil, err + if err == nil && write { + err = writeAtomic(p.hooks, hooks, 0o600) } - if err := writeAtomic(plist, rendered.Bytes(), 0o600); err != nil { - return nil, err - } - if output, err := exec.CommandContext(ctx, "launchctl", "bootstrap", domain, plist).CombinedOutput(); err != nil { - return nil, fmt.Errorf("load LaunchAgent: %w: %s", err, strings.TrimSpace(string(output))) + if err == nil { + err = removeFiles(p.plist, filepath.Join(stateDir(), "core.json"), filepath.Join(stateDir(), "core.lock")) } - return map[string]any{"ready": true, "installed": true, "control_task_id": control, "initial_scan": initial.Stats, "effects": preview}, nil + return map[string]any{"ready": err == nil, "installed": err == nil}, err } - func uninstall(ctx context.Context, confirmed bool) (any, error) { if !confirmed { return nil, errors.New("uninstall requires --noninteractive --confirm") } - if !safeRemovalRoot(stateDir()) { - return nil, errors.New("refusing unsafe ThreadBear state removal path") - } - binary, agents, skill, plist := installPaths() - domain := fmt.Sprintf("gui/%d", os.Getuid()) - if err := bootout(ctx, domain); err != nil { - return nil, err - } - disk := newStore(stateDir()) - lock, err := disk.lock() + p := installPaths() + hooks, write, err := editHooks(p.hooks, p.binary, false) if err != nil { return nil, err } - lockHeld := true - defer func() { - if lockHeld { - _ = unlock(lock) - } - }() - _, err = disk.load() - if err != nil { - return nil, errors.New("ThreadBear is not installed") + if err = validateFile(p.skill, assets.SkillManagedContent); err == nil { + err = stopLegacyService(ctx) } - if err := removeManaged(agents); err != nil { - return nil, err + if err == nil { + err = manageBlock(p.agents, "") } - if err := removeManaged(skill); err != nil { - return nil, err + if err == nil && write && len(hooks) == 0 { + err = os.Remove(p.hooks) + } else if err == nil && write { + err = writeAtomic(p.hooks, hooks, 0o600) } - for _, path := range []string{plist, binary} { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err + if err == nil { + err = removeFiles(p.plist, p.binary, p.skill) + } + if err == nil { + err = os.RemoveAll(stateDir()) + } + return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err +} +func status() (any, error) { + p := installPaths() + _, changed, err := editHooks(p.hooks, p.binary, true) + if err == nil && changed { + err = errors.New("native title hooks are incomplete") + } + for _, path := range []string{p.binary, p.agents, p.skill} { + if err == nil { + _, err = os.Stat(path) } } - if err := unlock(lock); err != nil { - return nil, err + if err == nil { + err = validateFile(p.skill, assets.SkillManagedContent) } - lockHeld = false - if err := os.RemoveAll(stateDir()); err != nil { - return nil, err + if err == nil { + err = validateManagedBlock(p.agents) } - return map[string]any{"ready": true, "uninstalled": true}, nil -} - -func safeRemovalRoot(path string) bool { - clean := filepath.Clean(path) - home, _ := os.UserHomeDir() - return filepath.IsAbs(clean) && clean != "/" && clean != filepath.Clean(home) && - clean != filepath.Clean(codexHome()) && len(strings.Split(strings.Trim(clean, string(filepath.Separator)), string(filepath.Separator))) >= 3 + if err == nil { + _, err = newStore(stateDir()).read() + } + return map[string]any{"ready": err == nil, "version": version}, err } - -func writeManaged(path, content string) error { - old, err := os.ReadFile(path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err +func selfTest() (any, error) { + if runtime.GOOS != "darwin" || assets.AgentsManagedContent == "" || assets.SkillManagedContent == "" || version == "" { + return nil, errors.New("candidate is incomplete or unsupported") } - text := string(old) - block := blockStart + "\n" + strings.TrimSpace(content) + "\n" + blockEnd - if start := strings.Index(text, blockStart); start >= 0 { - end := strings.Index(text[start:], blockEnd) - if end < 0 { - return errors.New("unterminated ThreadBear managed block") + return map[string]any{"ready": true, "version": version}, nil +} +func editHooks(path, binary string, add bool) ([]byte, bool, error) { + data, err := os.ReadFile(path) + missing := errors.Is(err, os.ErrNotExist) + if missing && !add { + return nil, false, nil + } + if err != nil && !missing { + return nil, false, err + } + root, events := rawObject{}, rawObject{} + if !missing && (json.Unmarshal(data, &root) != nil || root == nil) { + return nil, false, errors.New("hooks.json must contain an object") + } + if raw, ok := root["hooks"]; ok && (json.Unmarshal(raw, &events) != nil || events == nil) { + return nil, false, errors.New("hooks.json hooks must be an object") + } + owner := ownedHookJSON(binary) + changed := missing + 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) + } + kept := slices.DeleteFunc(groups, func(group json.RawMessage) bool { return sameJSON(group, owner) }) + owners := len(groups) - len(kept) + if add { + kept = append(kept, owner) + } + changed = changed || owners != 1 && add || owners != 0 && !add + if raw, _ := json.Marshal(kept); len(kept) == 0 { + delete(events, event) + } else { + events[event] = raw } - text = text[:start] + block + text[start+end+len(blockEnd):] + } + if !changed { + return data, false, nil + } + if !add && len(events) == 0 { + delete(root, "hooks") } else { - if text != "" && !strings.HasSuffix(text, "\n") { - text += "\n" + root["hooks"], _ = json.Marshal(events) + } + if !add && len(root) == 0 { + return nil, true, nil + } + data, err = json.MarshalIndent(root, "", " ") + return append(data, '\n'), true, err +} +func ownedHookJSON(binary string) json.RawMessage { + data, _ := json.Marshal(map[string]any{"matcher": "codex_appset_thread_title", "hooks": []any{map[string]any{"type": "command", "command": quoteCommand(binary), "timeout": 5}}}) + return data +} +func sameJSON(a, b []byte) bool { + var left, right any + if json.Unmarshal(a, &left) != nil || json.Unmarshal(b, &right) != nil { + return false + } + return reflect.DeepEqual(left, right) +} +func quoteCommand(binary string) string { + return "'" + strings.ReplaceAll(binary, "'", "'\"'\"'") + "' hook" +} +func readLegacyState(path string) (map[string]taskState, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var legacy struct { + Tasks map[string]struct { + Subject string `json:"subject"` + Last string `json:"last_applied"` + } `json:"tasks"` + } + if err = json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("read legacy state: %w", err) + } + result := map[string]taskState{} + for id, task := range legacy.Tasks { + if strings.TrimSpace(task.Subject) != "" { + result[id] = taskState{Subject: task.Subject, Last: task.Last} } - text += block + "\n" } - return writeAtomic(path, []byte(text), 0o600) + return result, nil +} + +var stopLegacyService = func(ctx context.Context) error { + output, err := exec.CommandContext(ctx, "launchctl", "bootout", fmt.Sprintf("gui/%d/%s", os.Getuid(), launchLabel)).CombinedOutput() + message := strings.ToLower(string(output)) + if err != nil && !strings.Contains(message, "no such process") && !strings.Contains(message, "could not find service") && !strings.Contains(message, "service not found") { + return fmt.Errorf("stop legacy LaunchAgent: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil } -func removeManaged(path string) error { +func validateFile(path, content string) error { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil } - if err != nil { - return err + valid := string(data) == content + if err == nil && !valid { + return errors.New("managed file was modified: " + path) } + return err +} +func validateManagedBlock(path string) error { + data, err := os.ReadFile(path) text := string(data) - start, end := strings.Index(text, blockStart), strings.Index(text, blockEnd) - if start < 0 && end < 0 { + valid := strings.Count(text, blockStart) == 1 && strings.Count(text, blockEnd) == 1 && strings.Contains(text, managedBlock()) + if err == nil && !valid { + return errors.New("managed file was modified: " + path) + } + return err +} +func managedBlock() string { + return blockStart + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockEnd +} +func manageBlock(path, content string) error { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) && content == "" { return nil } - if start < 0 || end < start { + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + text := string(data) + start, end := strings.Index(text, blockStart), strings.Index(text, blockEnd) + if strings.Count(text, blockStart) > 1 || strings.Count(text, blockEnd) > 1 || start < 0 != (end < 0) || end >= 0 && end < start { return errors.New("invalid ThreadBear managed block") } - text = strings.TrimSpace(text[:start] + text[end+len(blockEnd):]) - if text == "" { - return os.Remove(path) + if content != "" { + block := managedBlock() + if start >= 0 { + text = text[:start] + block + text[end+len(blockEnd):] + } else { + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += block + "\n" + } + } else if start >= 0 { + after := text[end+len(blockEnd):] + if strings.HasSuffix(text[:start], "\n") && strings.HasPrefix(after, "\n") { + after = after[1:] + } + text = text[:start] + after } - return writeAtomic(path, []byte(text+"\n"), 0o600) -} - -func copyAtomic(source, destination string, mode os.FileMode) error { - data, err := os.ReadFile(source) - if err != nil { - return err + if strings.TrimSpace(text) == "" { + return os.Remove(path) } - return writeAtomic(destination, data, mode) + return writeAtomic(path, []byte(text), 0o600) } - func writeAtomic(path string, data []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err @@ -244,56 +315,24 @@ func writeAtomic(path string, data []byte, mode os.FileMode) error { } name := f.Name() defer os.Remove(name) - if err = f.Chmod(mode); err == nil { + err = f.Chmod(mode) + if err == nil { _, err = f.Write(data) } if err == nil { err = f.Sync() } err = errors.Join(err, f.Close()) - if err != nil { - return err - } - return os.Rename(name, path) -} - -func selfTest() (any, error) { - if runtime.GOOS != "darwin" || assets.AgentsManagedContent == "" || assets.SkillManagedContent == "" || version == "" { - return nil, errors.New("candidate is incomplete or unsupported") + if err == nil { + err = os.Rename(name, path) } - return map[string]any{"ready": true, "version": version}, nil + return err } - -func status() (any, error) { - value, err := newStore(stateDir()).load() - if err != nil { - return nil, err - } - binary, _, _, plist := installPaths() - for _, path := range []string{binary, plist} { - info, statErr := os.Lstat(path) - if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, fmt.Errorf("installed runtime is incomplete: %s", path) +func removeFiles(paths ...string) error { + for _, path := range paths { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err } } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - domain := fmt.Sprintf("gui/%d", os.Getuid()) - if output, printErr := exec.CommandContext(ctx, "launchctl", "print", domain+"/"+launchLabel).CombinedOutput(); printErr != nil { - return nil, fmt.Errorf("LaunchAgent is not healthy: %s", strings.TrimSpace(string(output))) - } - return map[string]any{"ready": true, "version": version, "control_task_id": value.ControlTaskID, "pending_titles": len(value.Plans), "last_scan": value.LastScan}, nil -} - -func bootout(ctx context.Context, domain string) error { - output, err := exec.CommandContext(ctx, "launchctl", "bootout", domain+"/"+launchLabel).CombinedOutput() - if err == nil { - return nil - } - message := strings.ToLower(string(output)) - if strings.Contains(message, "no such process") || strings.Contains(message, "could not find service") || - strings.Contains(message, "service not found") { - return nil - } - return fmt.Errorf("stop LaunchAgent: %w: %s", err, strings.TrimSpace(string(output))) + return nil } diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go new file mode 100644 index 0000000..cc514d7 --- /dev/null +++ b/cmd/threadbear/install_test.go @@ -0,0 +1,304 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/ericlitman/threadbear/assets" +) + +func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { + p, stopped := 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) + mustWrite(t, p.plist, "old heartbeat") + mustWrite(t, filepath.Join(stateDir(), "core.json"), `{"tasks":{"kept":{"subject":"Stable subject","last_applied":"✅ Stable subject"},"ignored":{"subject":""}}}`) + + if _, err := install(context.Background(), false, true); err != nil { + t.Fatal(err) + } + if *stopped != 1 { + t.Fatalf("legacy service stops = %d, want 1", *stopped) + } + if _, err := os.Stat(p.plist); !os.IsNotExist(err) { + t.Fatalf("legacy plist survived: %v", err) + } + if _, err := os.Stat(filepath.Join(stateDir(), "core.json")); !os.IsNotExist(err) { + t.Fatalf("legacy state survived: %v", err) + } + stateData, err := os.ReadFile(filepath.Join(stateDir(), "native.json")) + if err != nil || !strings.Contains(string(stateData), `"Stable subject"`) || strings.Contains(string(stateData), `"ignored"`) { + t.Fatalf("legacy ownership migration = %q, err %v", stateData, 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) + } + firstHooks, _ := os.ReadFile(p.hooks) + if _, err := status(); err != nil { + t.Fatalf("installed status: %v", err) + } + if err := manageBlock(p.agents, ""); err != nil { + t.Fatal(err) + } + if _, err := status(); err == nil { + t.Fatal("status accepted a missing AGENTS managed block") + } + if err := manageBlock(p.agents, assets.AgentsManagedContent); err != nil { + t.Fatal(err) + } + + if _, err := install(context.Background(), false, true); 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 _, err := uninstall(context.Background(), true); err != nil { + t.Fatal(err) + } + if _, err := uninstall(context.Background(), true); err != nil { + t.Fatalf("repeated uninstall: %v", err) + } + 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) + } + 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) + } + } +} + +func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { + p, stopped := isolatedLifecycle(t) + if _, err := install(context.Background(), true, false); err != nil { + t.Fatal(err) + } + if _, err := install(context.Background(), false, false); err == nil { + t.Fatal("unconfirmed install succeeded") + } + if *stopped != 0 { + t.Fatal("preview or rejected install stopped the legacy service") + } + 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) + } + } +} + +func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { + p, stopped := isolatedLifecycle(t) + malformed := []byte(`{"hooks":{"PreToolUse":{"not":"an array"}}}`) + mustWrite(t, p.hooks, string(malformed)) + if _, err := install(context.Background(), false, true); err == nil { + t.Fatal("install accepted wrong-shaped hooks") + } + if *stopped != 0 { + t.Fatal("failed install stopped the legacy service") + } + got, _ := os.ReadFile(p.hooks) + if !reflect.DeepEqual(got, malformed) { + t.Fatal("failed install changed hooks.json") + } + if _, err := os.Stat(p.binary); !os.IsNotExist(err) { + t.Fatal("failed install copied the binary") + } + mustWrite(t, p.binary, "sentinel") + if _, err := uninstall(context.Background(), true); err == nil { + t.Fatal("uninstall accepted wrong-shaped hooks") + } + if got, _ := os.ReadFile(p.binary); string(got) != "sentinel" { + t.Fatal("failed uninstall mutated installation") + } +} + +func TestStatusRequiresExactManagedAgentsBlock(t *testing.T) { + p, _ := isolatedLifecycle(t) + if _, err := install(context.Background(), false, true); err != nil { + t.Fatal(err) + } + canonical, err := os.ReadFile(p.agents) + if err != nil { + t.Fatal(err) + } + if _, err := status(); err != nil { + t.Fatalf("canonical status: %v", err) + } + + variants := map[string]string{ + "edited": strings.Replace(string(canonical), "# ThreadBear", "# ThreadBear edited", 1), + "empty": blockStart + "\n" + blockEnd + "\n", + "duplicate": string(canonical) + string(canonical), + "reversed": blockEnd + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockStart + "\n", + } + for name, value := range variants { + t.Run(name, func(t *testing.T) { + mustWrite(t, p.agents, value) + if _, err := status(); err == nil { + t.Fatalf("status accepted %s managed AGENTS block", name) + } + mustWrite(t, p.agents, string(canonical)) + }) + } +} + +func TestMalformedLegacyStateFailsBeforeLifecycleMutation(t *testing.T) { + p, stopped := isolatedLifecycle(t) + legacyPath := filepath.Join(stateDir(), "core.json") + malformed := []byte(`{"tasks":`) + mustWrite(t, legacyPath, string(malformed)) + mustWrite(t, p.plist, "legacy service sentinel") + mustWrite(t, p.hooks, `{"owner":"unchanged"}`) + + if _, err := install(context.Background(), false, true); err == nil { + t.Fatal("install accepted malformed legacy state") + } + if *stopped != 0 { + t.Fatalf("failed install stopped the legacy service %d times", *stopped) + } + for path, want := range map[string][]byte{ + legacyPath: malformed, + p.plist: []byte("legacy service sentinel"), + p.hooks: []byte(`{"owner":"unchanged"}`), + } { + got, err := os.ReadFile(path) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("failed install changed %s: got %q, err %v", path, got, err) + } + } + for _, path := range []string{p.binary, p.agents, p.skill, filepath.Join(stateDir(), "native.json")} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("failed install created %s: %v", path, 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 { + 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) + } + 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) + } +} + +func TestUninstallRemovesOwnedOnlyHooksFile(t *testing.T) { + p, _ := isolatedLifecycle(t) + data, _, err := editHooks(p.hooks, p.binary, true) + if err != nil { + t.Fatal(err) + } + mustWrite(t, p.hooks, string(data)) + if _, err := uninstall(context.Background(), true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p.hooks); !os.IsNotExist(err) { + t.Fatalf("owned-only hooks file survived: %v", err) + } +} + +func isolatedLifecycle(t *testing.T) (lifecyclePaths, *int) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) + oldStop := stopLegacyService + stopped := 0 + stopLegacyService = func(context.Context) error { stopped++; return nil } + t.Cleanup(func() { stopLegacyService = oldStop }) + return installPaths(), &stopped +} + +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"}}}}, + }, + } + 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 { + t.Fatal(err) + } + var root struct { + Hooks map[string][]json.RawMessage `json:"hooks"` + } + if json.Unmarshal(data, &root) != nil { + t.Fatal("invalid hooks output") + } + wantLen := len(foreign) + if binary != "" { + wantLen++ + } + if len(root.Hooks[event]) != wantLen { + t.Fatalf("%s groups = %d, want %d", event, len(root.Hooks[event]), wantLen) + } + 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 binary != "" && !sameJSON(root.Hooks[event][len(foreign)], ownedHookJSON(binary)) { + t.Fatalf("%s missing exact owned hook: %s", event, root.Hooks[event][len(foreign)]) + } + 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 != 5 { + t.Fatalf("%s owned hook contract = %+v", event, owner) + } + } +} + +func mustWrite(t *testing.T, path, value string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index 3e94c12..938042a 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -5,160 +5,81 @@ import ( "encoding/json" "flag" "fmt" + "github.com/ericlitman/threadbear/assets" "io" "os" - "time" ) var version = "dev" -const heartbeatLimit = 4*time.Minute + 30*time.Second - -const helpText = `ThreadBear keeps Codex task titles useful with a deterministic, low-token heartbeat. - -Usage: - threadbear [flags] - -Commands: - install Preview or install the managed runtime - heartbeat Scan changed tasks and stage guarded title decisions - status Show runtime health and the latest scan - self-test Validate a release candidate - uninstall Remove ThreadBear while preserving current titles - version Show the installed version - -Run threadbear --help for command flags. -` - -var commandHelp = map[string]string{ - "install": "Usage: threadbear install [--control-task-id ID] [--dry-run] [--noninteractive --confirm] [--json]\n", - "heartbeat": "Usage: threadbear heartbeat [--dry-run] [--json]\n", - "status": "Usage: threadbear status [--json]\n", - "self-test": "Usage: threadbear self-test [--candidate] [--json]\n", - "uninstall": "Usage: threadbear uninstall --noninteractive --confirm [--json]\n", - "version": "Usage: threadbear version [--json]\n", -} - -func main() { os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) } - -func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { - io.WriteString(stdout, helpText) - if len(args) == 0 { - return 2 - } - return 0 +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 { + if len(args) == 0 { + fmt.Fprint(stdout, assets.HelpText) + return 2 } - if args[0] == "help" { - if len(args) == 2 { - io.WriteString(stdout, commandHelp[args[1]]) - } else { - io.WriteString(stdout, helpText) - } + if args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + fmt.Fprint(stdout, assets.HelpText) return 0 } command := args[0] - if len(args) == 2 && (args[1] == "--help" || args[1] == "-h") { - io.WriteString(stdout, commandHelp[command]) + 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) - asJSON := flags.Bool("json", false, "write JSON output") - var result any - var err error + flags.Bool("json", false, "write JSON output") + var action func() (any, error) switch command { case "install": - control := flags.String("control-task-id", "", "adopt active Codex task ID") - dry := flags.Bool("dry-run", false, "validate and print effects without mutation") + 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") flags.String("version", "", "installer-selected release version") - if flags.Parse(args[1:]) == nil { - result, err = install(ctx, *control, *dry, *noninteractive && *confirm) - } else { - return 2 - } - case "heartbeat": - dry := flags.Bool("dry-run", false, "scan without models or state writes") - if flags.Parse(args[1:]) == nil { - heartbeatCtx, cancel := context.WithTimeout(ctx, heartbeatLimit) - defer cancel() - result, err = heartbeat(heartbeatCtx, *dry) - } else { - return 2 + action = func() (any, error) { return install(ctx, *dry, *noninteractive && *confirm) } + case "inventory": + action = func() (any, error) { + items, err := migrationInventory(ctx) + deterministic := 0 + for _, item := range items { + if item.Deterministic { + deterministic++ + } + } + return map[string]any{"ready": err == nil, "count": len(items), "deterministic": deterministic, "ambiguous": len(items) - deterministic, "tasks": items}, err } case "status": - if flags.Parse(args[1:]) == nil { - result, err = status() - } else { - return 2 - } + action = status case "self-test": flags.Bool("candidate", false, "validate this binary before installation") - if flags.Parse(args[1:]) == nil { - result, err = selfTest() - } else { - return 2 - } + action = selfTest case "uninstall": noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm the previewed uninstall") - if flags.Parse(args[1:]) == nil { - result, err = uninstall(ctx, *noninteractive && *confirm) - } else { - return 2 - } + action = func() (any, error) { return uninstall(ctx, *noninteractive && *confirm) } case "version": - if flags.Parse(args[1:]) == nil { - if *asJSON { - result = map[string]any{"version": version} - } else { - fmt.Fprintf(stdout, "ThreadBear %s\n", version) - return 0 - } - } else { - return 2 - } - case "title-plan": - stage := flags.Bool("stage", false, "stage a guarded retained-task footer") - batch := flags.Bool("batch", false, "list the next guarded operations") - operation := flags.String("operation", "", "revalidate an operation") - report := flags.Bool("report", false, "commit verified native outcomes") - if flags.Parse(args[1:]) != nil { - return 2 - } - mode := "" - switch { - case *stage: - mode = "stage" - case *batch: - mode = "batch" - case *operation != "": - mode = "operation" - case *report: - mode = "report" - } - result, err = titlePlan(ctx, mode, *operation, os.Stdin) + action = func() (any, error) { return map[string]any{"version": version}, nil } default: - fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, helpText) + 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 { - if *asJSON { - json.NewEncoder(stdout).Encode(map[string]any{"ready": false, "error": err.Error()}) - } else { - fmt.Fprintln(stderr, "ThreadBear:", err) - } + _ = json.NewEncoder(stdout).Encode(map[string]any{"ready": false, "error": err.Error()}) return 1 } - if *asJSON { - if json.NewEncoder(stdout).Encode(result) != nil { - return 1 - } - } else { - data, _ := json.MarshalIndent(result, "", " ") - fmt.Fprintln(stdout, string(data)) + if json.NewEncoder(stdout).Encode(result) != nil { + return 1 } return 0 } diff --git a/cmd/threadbear/migration_test.go b/cmd/threadbear/migration_test.go new file mode 100644 index 0000000..30ffb70 --- /dev/null +++ b/cmd/threadbear/migration_test.go @@ -0,0 +1,166 @@ +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 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) + } +} diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index 938deb9..71e0b9d 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -1,41 +1,32 @@ package main import ( - "bufio" "bytes" "context" - "crypto/sha256" "database/sql" - "encoding/hex" "encoding/json" "errors" "fmt" + "github.com/BurntSushi/toml" "io" + _ "modernc.org/sqlite" "net/url" "os" "path/filepath" - "sort" "strconv" "strings" - - "github.com/BurntSushi/toml" - _ "modernc.org/sqlite" ) -type indexedTask struct { - ID, Title, RolloutPath, ThreadSource string - Revision int64 -} +type indexedTask struct{ ID, Title, RolloutPath string } -func inventory(ctx context.Context, control string) ([]indexedTask, error) { +func inventory(ctx context.Context) ([]indexedTask, error) { db, err := openIndex() if err != nil { return nil, err } defer db.Close() - rows, err := db.QueryContext(ctx, `SELECT id, updated_at_ms, COALESCE(name,title), rollout_path, - COALESCE(thread_source,'') FROM threads - WHERE archived=0 AND source='vscode' AND id<>? ORDER BY id`, control) + rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,'') + FROM threads WHERE archived=0 ORDER BY id`) if err != nil { return nil, fmt.Errorf("read Codex task index: %w", err) } @@ -43,72 +34,25 @@ func inventory(ctx context.Context, control string) ([]indexedTask, error) { var tasks []indexedTask for rows.Next() { var task indexedTask - if err := rows.Scan(&task.ID, &task.Revision, &task.Title, &task.RolloutPath, &task.ThreadSource); err != nil { + if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath); err != nil { return nil, err } tasks = append(tasks, task) } return tasks, rows.Err() } - -func openIndex() (*sql.DB, error) { - home, err := sqliteHome() - if err != nil { - return nil, err - } - matches, _ := filepath.Glob(filepath.Join(home, "state_*.sqlite")) - sort.Slice(matches, func(i, j int) bool { return stateNumber(matches[i]) < stateNumber(matches[j]) }) - if len(matches) == 0 { - return nil, errors.New("Codex state index not found") - } - dsn := (&url.URL{Scheme: "file", Path: matches[len(matches)-1], RawQuery: "mode=ro"}).String() - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, err - } - return db, nil -} - -func sqliteHome() (string, error) { - base := codexHome() - data, err := os.ReadFile(filepath.Join(base, "config.toml")) - if err == nil { - var config struct { - SQLiteHome *string `toml:"sqlite_home"` - } - if err := toml.Unmarshal(data, &config); err != nil { - return "", fmt.Errorf("parse Codex config: %w", err) - } - if config.SQLiteHome != nil { - value := strings.TrimSpace(*config.SQLiteHome) - if value == "" { - return "", errors.New("Codex sqlite_home is empty") - } - if !filepath.IsAbs(value) { - value = filepath.Join(base, value) - } - return value, nil - } - } else if !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("read Codex config: %w", err) - } - if value := os.Getenv("CODEX_SQLITE_HOME"); value != "" { - return value, nil - } - return base, nil -} - func oneTask(ctx context.Context, id string) (indexedTask, bool, error) { + if strings.TrimSpace(id) == "" { + return indexedTask{}, false, errors.New("task ID is empty") + } db, err := openIndex() if err != nil { return indexedTask{}, false, err } defer db.Close() var task indexedTask - err = db.QueryRowContext(ctx, `SELECT id, updated_at_ms, COALESCE(name,title), rollout_path, - COALESCE(thread_source,'') FROM threads - WHERE archived=0 AND source='vscode' AND id=?`, id). - Scan(&task.ID, &task.Revision, &task.Title, &task.RolloutPath, &task.ThreadSource) + err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,'') + FROM threads WHERE id=? AND archived=0`, id).Scan(&task.ID, &task.Title, &task.RolloutPath) if errors.Is(err, sql.ErrNoRows) { return indexedTask{}, false, nil } @@ -117,242 +61,147 @@ func oneTask(ctx context.Context, id string) (indexedTask, bool, error) { } return task, true, nil } - -func stateNumber(path string) int { - base := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "state_"), ".sqlite") - n, _ := strconv.Atoi(base) - return n -} - -type analysis struct { - Resolved bool - Status, Action string - Evidence string - Size int64 -} - -func readEvidence(path string) ([]byte, int64, error) { - f, err := os.Open(path) - if err != nil { - return nil, 0, err - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, 0, err - } - end := info.Size() - start := max(int64(0), end-512*1024) - if _, err := f.Seek(start, io.SeekStart); err != nil { - return nil, 0, err - } - data, err := io.ReadAll(io.LimitReader(f, end-start)) +func openIndex() (*sql.DB, error) { + home, err := sqliteHome() if err != nil { - return nil, 0, err - } - if start > 0 { - if at := bytes.IndexByte(data, '\n'); at >= 0 { - data = data[at+1:] - } else { - data = nil - } + return nil, err } - if len(data) > 0 && data[len(data)-1] != '\n' { - if at := bytes.LastIndexByte(data, '\n'); at >= 0 { - data = data[:at+1] - } else { - data = nil + 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 } } - return data, end, nil -} - -func latestTurnID(data []byte) string { - var latest string - for _, line := range bytes.Split(data, []byte{'\n'}) { - var item struct { - Type string `json:"type"` - Payload struct { - TurnID string `json:"turn_id"` - } `json:"payload"` - } - if json.Unmarshal(line, &item) == nil && item.Type == "turn_context" && item.Payload.TurnID != "" { - latest = item.Payload.TurnID - } + if latest == "" { + return nil, errors.New("Codex state index not found") } - return latest + dsn := (&url.URL{Scheme: "file", Path: latest, RawQuery: "mode=ro"}).String() + return sql.Open("sqlite", dsn) } - -func latestTurnIDFile(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err +func sqliteHome() (string, error) { + base := codexHome() + var config struct { + SQLiteHome *string `toml:"sqlite_home"` } - defer file.Close() - reader := bufio.NewReaderSize(file, 64*1024) - var latest string - var line []byte - skip := false - for { - fragment, readErr := reader.ReadSlice('\n') - if !skip && len(line)+len(fragment) <= 64*1024 { - line = append(line, fragment...) - } else { - line = line[:0] - skip = true - } - if errors.Is(readErr, bufio.ErrBufferFull) { - continue - } - if !skip { - if id := latestTurnID(line); id != "" { - latest = id - } - } - line = line[:0] - skip = false - if errors.Is(readErr, io.EOF) { - return latest, nil - } - if readErr != nil { - return "", readErr + if _, err := toml.DecodeFile(filepath.Join(base, "config.toml"), &config); err != nil { + if errors.Is(err, os.ErrNotExist) { + return base, nil } + return "", fmt.Errorf("parse Codex config: %w", err) } -} - -func analyze(data []byte) analysis { - sum := sha256.Sum256(data) - result := analysis{Evidence: hex.EncodeToString(sum[:]), Size: int64(len(data))} - var latest string - var latestRole string - hadError := false - for _, line := range bytes.Split(data, []byte{'\n'}) { - if len(line) == 0 { - continue - } - var item struct { - Type string `json:"type"` - Payload struct { - Type, Role, Phase, Message string - Content []struct{ Text string } - } `json:"payload"` - } - if json.Unmarshal(line, &item) != nil { - continue - } - if item.Type == "event_msg" && (item.Payload.Type == "error" || item.Payload.Type == "stream_error") { - hadError = true - } - if item.Type != "response_item" || item.Payload.Type != "message" { - continue - } - text := item.Payload.Message - for _, part := range item.Payload.Content { - text += part.Text - } - if item.Payload.Role == "user" { - latest, latestRole = text, item.Payload.Role - hadError = false - } else if item.Payload.Role == "assistant" && item.Payload.Phase == "final_answer" { - latest, latestRole = text, item.Payload.Role - } + if config.SQLiteHome == nil { + return base, nil } - if latestRole == "assistant" { - footer := parseFooter(latest) - footer.Evidence, footer.Size = result.Evidence, result.Size - if footer.Resolved { - return footer - } + value := strings.TrimSpace(*config.SQLiteHome) + if value == "" { + return "", errors.New("Codex sqlite_home is empty") } - if hadError { - result.Resolved, result.Status = true, "blocked" + if !filepath.IsAbs(value) { + value = filepath.Join(base, value) } - return result + return value, nil } -func evidenceID(path string, boundary int64, data []byte) string { - sum := sha256.New() - fmt.Fprintf(sum, "%s\x00%d\x00", path, boundary) - sum.Write(data) - return hex.EncodeToString(sum.Sum(nil)) -} - -func parseFooter(message string) analysis { - lines := strings.Split(strings.TrimRight(message, "\r\n"), "\n") - if len(lines) == 0 { - return analysis{} - } - line := strings.TrimSuffix(lines[len(lines)-1], "\r") - if strings.HasPrefix(strings.TrimSpace(line), ">") { - return analysis{} - } - for _, prior := range lines[:len(lines)-1] { - if strings.Contains(prior, "🧵🐻 ") { - return analysis{} - } +// rolloutFooter reads only the settled tail; classification never sends history to a model. +func rolloutFooter(path string) (footer, bool) { + if path == "" { + return footer{}, false } - if line == "🧵🐻 complete" { - return analysis{Resolved: true, Status: "complete"} + f, err := os.Open(path) + if err != nil { + return footer{}, false } - if line == "🧵🐻 automation" { - return analysis{Resolved: true, Status: "automation"} + defer f.Close() + info, err := f.Stat() + if err != nil { + return footer{}, false } - remainder := strings.TrimPrefix(line, "🧵🐻 ") - if remainder == line { - return analysis{} + 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 } - statusText, ownerAction, ok := strings.Cut(remainder, " (") - owner, action, ok2 := strings.Cut(ownerAction, "): ") - statuses := map[string]string{"next steps": "next_steps", "needs input": "needs_input", "blocked": "blocked"} - status, valid := statuses[statusText] - if !ok || !ok2 || !valid || strings.TrimSpace(action) != action || len(strings.Fields(action)) < 2 { - return analysis{} + if len(data) == 0 || bytes.IndexByte(data, '\n') < 0 { + return footer{}, false } - if status == "needs_input" && owner != "you" || status == "blocked" && owner != "external" || - status == "next_steps" && owner != "you" && owner != "agent" && owner != "external" { - return analysis{} + if start > 0 { + data = data[bytes.IndexByte(data, '\n')+1:] } - return analysis{Resolved: true, Status: status, Action: action} -} - -func semanticText(data []byte) string { - var user, agent string - for _, line := range bytes.Split(data, []byte{'\n'}) { + lines := bytes.Split(data, []byte{'\n'}) + // The final fragment is either empty or an in-flight JSONL write. + 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 } + Content []struct { + Text string `json:"text"` + } `json:"content"` } `json:"payload"` } - if json.Unmarshal(line, &item) != nil || item.Type != "response_item" || item.Payload.Type != "message" { + if json.Unmarshal(lines[i], &item) != nil { continue } - text := item.Payload.Message - for _, part := range item.Payload.Content { - text += part.Text + if item.Type == "turn_context" || item.Type == "event_msg" && (item.Payload.Type == "turn_aborted" || item.Payload.Type == "task_started") || + item.Type == "response_item" && item.Payload.Type == "message" && item.Payload.Role == "user" { + return footer{}, false } - if item.Payload.Role == "user" { - user = text - } else if item.Payload.Role == "assistant" && item.Payload.Phase == "final_answer" { - agent = text + if item.Type != "response_item" || item.Payload.Type != "message" || item.Payload.Role != "assistant" || item.Payload.Phase != "final_answer" { + continue } - } - trim := func(value string) string { - if len(value) > 4*1024 { - return value[len(value)-4*1024:] + message := item.Payload.Message + for _, part := range item.Payload.Content { + message += part.Text } - return value + return parseFooter(message) } - return "USER:\n" + trim(user) + "\nASSISTANT:\n" + trim(agent) + return footer{}, false } -func sameEvidence(task indexedTask, record taskRecord) bool { - data, size, err := readEvidence(task.RolloutPath) - if err != nil || size != record.RolloutSize { - return 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"` +} + +func migrationInventory(ctx context.Context) ([]inventoryItem, error) { + tasks, err := inventory(ctx) + if err != nil { + return nil, err + } + known := state{Tasks: map[string]taskState{}} + if saved, readErr := newStore(stateDir()).read(); readErr == nil { + known = saved + } else if !errors.Is(readErr, os.ErrNotExist) { + return nil, readErr + } + items := make([]inventoryItem, 0, len(tasks)) + for _, task := range tasks { + 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" + } + } + desired := renderTitle(result.Status, subject, result.Action) + items = append(items, inventoryItem{ + TaskID: task.ID, Title: task.Title, Subject: subject, Status: result.Status, + Action: result.Action, Deterministic: ok, + }) + last := &items[len(items)-1] + last.Applied = record.Pending == nil && record.Last == task.Title && record.Last == desired } - got := analyze(data) - got.Evidence = evidenceID(task.RolloutPath, size, data) - return got.Evidence == record.Evidence && task.Revision == record.Revision && task.Title == record.Title + return items, nil } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index a48b755..09fc17d 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -44,9 +44,10 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { for _, required := range []string{ "## Hi. Let's install ThreadBear.", "## Recommended setup", - "--control-task-id \"$CONTROL_TASK_ID\"", "--noninteractive --confirm --json", - "~/.local/bin/threadbear heartbeat --dry-run --json", + "~/.local/bin/threadbear inventory --json", + "genuinely fresh Codex Desktop task", + "two native title calls per ordinary turn", "~/.local/bin/threadbear uninstall --noninteractive --confirm --json", } { if !strings.Contains(text, required) { @@ -71,6 +72,9 @@ func TestHomepageDoesNotPromiseRemovedCapabilities(t *testing.T) { "exits silently", "update-check", "version-change", + "LaunchAgent", + "heartbeat", + "control task", } { if strings.Contains(page, removedClaim) { t.Errorf("homepage contains removed capability claim %q", removedClaim) diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index fb1a51f..25ae7b0 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -1,242 +1,196 @@ package main import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" - "fmt" + "golang.org/x/sys/unix" + "io" "os" "path/filepath" "strings" "unicode/utf16" - "unicode/utf8" - - "golang.org/x/sys/unix" ) -const stateFormat = 1 - -type state struct { - Format int `json:"format"` - ControlTaskID string `json:"control_task_id"` - CodexPath string `json:"codex_path"` - NativeBootstrap bool `json:"native_bootstrap"` - Tasks map[string]taskRecord `json:"tasks"` - Plans map[string]plan `json:"plans"` - LastScan scanStats `json:"last_scan"` -} - -type taskRecord struct { - Revision int64 `json:"revision"` - Title string `json:"title"` - RolloutPath string `json:"rollout_path"` - RolloutSize int64 `json:"rollout_size"` - Evidence string `json:"evidence"` - Status string `json:"status"` - Subject string `json:"subject"` - Action string `json:"action,omitempty"` - LastApplied string `json:"last_applied,omitempty"` - AmbiguousPasses int `json:"ambiguous_passes,omitempty"` -} +const stateFormat = 3 -type plan struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Revision int64 `json:"revision"` - TurnID string `json:"turn_id,omitempty"` - ExpectedTitle string `json:"expected_title"` - DesiredTitle string `json:"desired_title"` - Evidence string `json:"evidence"` - Status string `json:"status"` - Subject string `json:"subject"` - Action string `json:"action,omitempty"` - Epoch int64 `json:"epoch"` +type pendingProposal struct { + 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"` } - -type scanStats struct { - Tasks int `json:"tasks"` - Changed int `json:"changed"` - Deterministic int `json:"deterministic"` - Ambiguous int `json:"ambiguous"` - Luna int `json:"luna"` - Staged int `json:"staged"` - ScanMillis int64 `json:"scan_millis"` +type taskState struct { + Subject string `json:"subject"` + Last string `json:"last,omitempty"` + Status string `json:"status,omitempty"` + Action string `json:"action,omitempty"` + Pending *pendingProposal `json:"pending,omitempty"` } - -func freshState(control string) state { - return state{Format: stateFormat, ControlTaskID: control, NativeBootstrap: true, Tasks: map[string]taskRecord{}, Plans: map[string]plan{}} +type state struct { + Format int `json:"format"` + Tasks map[string]taskState `json:"tasks"` } - +type footer struct{ Status, Action string } type store struct{ dir string } func newStore(dir string) store { return store{dir: dir} } -func (s store) path() string { return filepath.Join(s.dir, "core.json") } - +func (s store) path() string { return filepath.Join(s.dir, "native.json") } func (s store) lock() (*os.File, error) { - if info, err := os.Lstat(s.dir); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) { - return nil, errors.New("ThreadBear state path is not a real directory") - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err - } if err := os.MkdirAll(s.dir, 0o700); err != nil { return nil, err } - if err := os.Chmod(s.dir, 0o700); err != nil { + dir, err := unix.Open(s.dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if err != nil { return nil, err } - f, err := os.OpenFile(filepath.Join(s.dir, "core.lock"), os.O_CREATE|os.O_RDWR, 0o600) - if err == nil { - err = unix.Flock(int(f.Fd()), unix.LOCK_EX) + defer unix.Close(dir) + if err = unix.Fchmod(dir, 0o700); err != nil { + return nil, err } + fd, err := unix.Openat(dir, "native.lock", unix.O_CREAT|unix.O_RDWR|unix.O_NOFOLLOW, 0o600) if err != nil { - if f != nil { - f.Close() - } return nil, err } + f := os.NewFile(uintptr(fd), "native.lock") + info, statErr := f.Stat() + if statErr != nil || !info.Mode().IsRegular() { + return nil, errors.Join(errors.New("ThreadBear lock is not a regular file"), statErr, f.Close()) + } + if err = errors.Join(unix.Fchmod(fd, 0o600), unix.Flock(fd, unix.LOCK_EX)); err != nil { + return nil, errors.Join(err, f.Close()) + } return f, nil } - -func unlock(f *os.File) error { - return errors.Join(unix.Flock(int(f.Fd()), unix.LOCK_UN), f.Close()) -} - -func (s store) load() (state, error) { - if info, err := os.Lstat(s.path()); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600) { - return state{}, errors.New("ThreadBear state is not a regular file") - } else if err != nil { - return state{}, err - } - data, err := os.ReadFile(s.path()) +func (s store) read() (state, error) { + fd, err := unix.Open(s.path(), unix.O_RDONLY|unix.O_NOFOLLOW, 0) if err != nil { return state{}, err } - var value state - if err := json.Unmarshal(data, &value); err != nil { + f := os.NewFile(uintptr(fd), s.path()) + defer f.Close() + info, err := f.Stat() + if err != nil { return state{}, err } - if value.Format != stateFormat || strings.TrimSpace(value.ControlTaskID) == "" { - return state{}, fmt.Errorf("unsupported ThreadBear state format %d", value.Format) + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return state{}, errors.New("ThreadBear state is not a private regular file") } - if value.Tasks == nil { - value.Tasks = map[string]taskRecord{} + data, err := io.ReadAll(f) + if err != nil { + return state{}, err } - if value.Plans == nil { - value.Plans = map[string]plan{} + var value state + if err := json.Unmarshal(data, &value); err != nil || value.Format != stateFormat || value.Tasks == nil { + return state{}, errors.New("unsupported or corrupt ThreadBear state format") } return value, nil } - -func (s store) save(value state) error { - data, err := json.MarshalIndent(value, "", " ") +func (s store) update(change func(*state) (bool, error)) (err error) { + lock, err := s.lock() if err != nil { return err } - data = append(data, '\n') - if err := os.MkdirAll(s.dir, 0o700); err != nil { + defer func() { err = errors.Join(err, unix.Flock(int(lock.Fd()), unix.LOCK_UN), lock.Close()) }() + value, err := s.read() + created := errors.Is(err, os.ErrNotExist) + if created { + value = state{Format: stateFormat, Tasks: map[string]taskState{}} + } else if err != nil { return err } - f, err := os.CreateTemp(s.dir, ".core-*") + changed, err := change(&value) if err != nil { return err } - name := f.Name() - ok := false - defer func() { - f.Close() - if !ok { - os.Remove(name) - } - }() - if err = f.Chmod(0o600); err == nil { - _, err = f.Write(data) - } - if err == nil { - err = f.Sync() - } - if closeErr := f.Close(); err == nil { - err = closeErr - } - if err == nil { - err = os.Rename(name, s.path()) + if !created && !changed { + return nil } + return s.save(value) +} +func (s store) save(value state) error { + data, err := json.Marshal(value) if err != nil { return err } - ok = true - d, err := os.Open(s.dir) - if err != nil { - return err + return writeAtomic(s.path(), append(data, '\n'), 0o600) +} +func canonicalSubject(current string, previous taskState) string { + if current == previous.Last && previous.Subject != "" { + return previous.Subject } - return errors.Join(d.Sync(), d.Close()) + if pending := previous.Pending; pending != nil && pending.BaseSubject != "" && (current == pending.Prior || current == pending.Proposed) { + return pending.BaseSubject + } + return strings.Join(strings.Fields(current), " ") } - -var statusEmoji = map[string]string{ - "running": "⏳", "blocked": "🚨", "needs_input": "🙋", "automation": "🤖", - "next_steps": "➡️", "complete": "✅", "unknown": "❔", +func parseFooter(message string) (footer, bool) { + lines := strings.Split(strings.TrimRight(message, "\r\n"), "\n") + line := lines[len(lines)-1] + if strings.Contains(strings.Join(lines[:len(lines)-1], "\n"), "🧵🐻 ") { + return footer{}, false + } + if line == "🧵🐻 complete" || line == "🧵🐻 automation" { + return footer{Status: strings.TrimPrefix(line, "🧵🐻 ")}, true + } + remainder := strings.TrimPrefix(line, "🧵🐻 ") + statusText, ownerAction, ok := strings.Cut(remainder, " (") + owner, action, ownerOK := strings.Cut(ownerAction, "): ") + statuses := map[string]string{"next steps": "next_steps", "needs input": "needs_input", "blocked": "blocked"} + status, statusOK := statuses[statusText] + if remainder == line || !ok || !ownerOK || !statusOK || strings.TrimSpace(action) != action || len(strings.Fields(action)) < 2 { + return footer{}, false + } + if status == "needs_input" && owner != "you" || status == "blocked" && owner != "external" || + status == "next_steps" && owner != "you" && owner != "agent" && owner != "external" { + return footer{}, false + } + return footer{Status: status, Action: action}, true } -func title(status, subject, action string) string { - value := statusEmoji[status] - if subject = strings.TrimSpace(subject); subject != "" { - value += " " + subject +var statusIcons = map[string]string{"running": "⏳", "blocked": "🚨", "needs_input": "🙋", "automation": "🤖", "next_steps": "➡️", "complete": "✅", "unknown": "❔"} + +func renderTitle(status, subject, action string) string { + icon := statusIcons[status] + if icon == "" { + icon = statusIcons["unknown"] } - if action != "" { - value += " — " + strings.TrimSpace(action) + subject, action = strings.TrimSpace(subject), strings.TrimSpace(action) + if status == "complete" || status == "automation" { + action = "" } - if utf16Len(value) <= 60 { - return value + prefix := strings.TrimSpace(icon + " " + subject) + if action == "" { + return truncateUTF16(prefix, 60) } - units := 0 - end := 0 - for i, r := range value { - width := utf16.RuneLen(r) - if units+width > 59 { - break - } - units += width - end = i + utf8.RuneLen(r) + suffix := " → " + action + if utf16Len(prefix+suffix) <= 60 { + return prefix + suffix } - return strings.TrimSpace(value[:end]) + "…" -} - -func utf16Len(value string) int { return len(utf16.Encode([]rune(value))) } - -func subject(value string) string { - value = strings.TrimSpace(value) - for { - removed := false - for _, mark := range statusEmoji { - if strings.HasPrefix(value, mark+" ") { - value = strings.TrimSpace(strings.TrimPrefix(value, mark)) - removed = true - break - } - } - if !removed { - return value + if subject != "" { + subjectBudget := 60 - utf16Len(icon+" ") - utf16Len(suffix) + if subjectBudget > 0 { + return icon + " " + truncateUTF16(subject, subjectBudget) + suffix } + subject, action = "…", truncateUTF16(action, 60-utf16Len(icon+" … → ")) + return icon + " " + subject + " → " + action } + action = truncateUTF16(action, 60-utf16Len(icon+" → ")) + return icon + " → " + action } - -func planID(value plan) string { - copy := value - copy.ID = "" - data, _ := json.Marshal(copy) - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:16]) -} - -func decide(task indexedTask, result analysis) (taskRecord, plan) { - owned := subject(task.Title) - record := taskRecord{Revision: task.Revision, Title: task.Title, RolloutPath: task.RolloutPath, RolloutSize: result.Size, Evidence: result.Evidence, Status: result.Status, Subject: owned, Action: result.Action} - desired := title(result.Status, owned, result.Action) - value := plan{TaskID: task.ID, Revision: task.Revision, ExpectedTitle: task.Title, DesiredTitle: desired, Evidence: result.Evidence, Status: result.Status, Subject: owned, Action: result.Action, Epoch: result.Size} - value.ID = planID(value) - return record, value -} - -func lunaEligible(record taskRecord, evidence string) bool { - return record.Evidence != "" && record.Evidence == evidence && record.AmbiguousPasses >= 2 +func truncateUTF16(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + if limit < 1 { + return "" + } + units = units[:limit-1] + if len(units) > 0 && units[len(units)-1] >= 0xd800 && units[len(units)-1] <= 0xdbff { + units = units[:len(units)-1] + } + return strings.TrimSpace(string(utf16.Decode(units))) + "…" } +func utf16Len(value string) int { return len(utf16.Encode([]rune(value))) } diff --git a/cmd/threadbear/state_test.go b/cmd/threadbear/state_test.go new file mode 100644 index 0000000..f655454 --- /dev/null +++ b/cmd/threadbear/state_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" +) + +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()) + } + if _, err := disk.read(); !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 + 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} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != mode { + t.Fatalf("%s mode = %o, want %o", path, info.Mode().Perm(), mode) + } + } + got, err := disk.read() + if err != nil { + t.Fatal(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) + } + for _, key := range []string{`"format"`, `"tasks"`, `"subject"`} { + if !strings.Contains(string(data), key) { + t.Fatalf("state JSON %q lacks %s", data, key) + } + } +} + +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 TestNativeStateRejectsUnsafePaths(t *testing.T) { + realDir := t.TempDir() + link := filepath.Join(filepath.Dir(realDir), "state-link") + if err := os.Symlink(realDir, link); 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") + } + dir := filepath.Join(t.TempDir(), "state") + disk := newStore(dir) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(disk.path(), []byte(`{"format":3,"tasks":{}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(disk.path(), 0o644); err != nil { + t.Fatal(err) + } + if _, err := disk.read(); err == nil { + t.Fatal("public state file was accepted") + } +} + +func TestNativeStateMutationErrorDoesNotSave(t *testing.T) { + disk := newStore(filepath.Join(t.TempDir(), "state")) + want := errors.New("stop") + if err := disk.update(func(value *state) (bool, error) { + value.Tasks["task-1"] = taskState{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) { + 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]) + } + }) + } + 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) + } +} + +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"}, + "🧵🐻 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) + } + } + invalid := []string{ + "", "🧵🐻 Complete", "> 🧵🐻 complete", "🧵🐻 complete\nextra", + "🧵🐻 complete\n🧵🐻 automation", "🧵🐻 needs input (you): decide", + "🧵🐻 needs input (agent): choose the region", "🧵🐻 blocked (you): restore the service", + "🧵🐻 next steps (bear): approve the release", " 🧵🐻 complete", "🧵🐻 complete ", + } + for _, message := range invalid { + if got, ok := parseFooter(message); ok { + t.Errorf("parseFooter(%q) accepted %#v", message, got) + } + } +} + +func TestRenderTitleContractAndActionPriority(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"}, + } { + 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]) + } + }) + } + got := renderTitle("next_steps", strings.Repeat("s", 100), "keep this action") + if utf16Len(got) > 60 || !strings.HasSuffix(got, " → keep this action") || !strings.Contains(got, "…") { + t.Fatalf("subject-first truncation = %q (%d units)", got, utf16Len(got)) + } + got = renderTitle("blocked", "keep subject", strings.Repeat("a", 100)) + if utf16Len(got) > 60 || !strings.HasPrefix(got, "🚨 … → ") || !strings.HasSuffix(got, "…") { + t.Fatalf("long action truncation = %q (%d units)", got, utf16Len(got)) + } +} + +func TestRenderTitleUTF16Boundaries(t *testing.T) { + for subjectLen, want := range map[int][2]int{ + 57: {59, 0}, + 58: {60, 0}, + 59: {60, 1}, + } { + 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)) + } + } + 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) + } +} diff --git a/cmd/threadbear/testdata/help.golden b/cmd/threadbear/testdata/help.golden deleted file mode 100644 index 5f74b07..0000000 --- a/cmd/threadbear/testdata/help.golden +++ /dev/null @@ -1,173 +0,0 @@ -ThreadBear keeps Codex tasks tidy without wasting model tokens. - -Usage: - threadbear [flags] - threadbear help [command] - -Commands: - install Install ThreadBear and set up its managed runtime. - heartbeat Classify changed tasks and apply lifecycle work. - status Show ThreadBear health, preferences, and recent activity. - inspect Explain ThreadBear's saved state for one task. - configure Preview or change ThreadBear preferences. - enable Enable the ThreadBear LaunchAgent. - disable Disable the ThreadBear LaunchAgent. - restore Restore one task archived by ThreadBear. - self-test Check that ThreadBear's runtime surfaces are ready. - update Install the latest or an exact verified release. - uninstall Clean managed task titles, then remove ThreadBear. - version Show the installed ThreadBear version and website. - -Run `threadbear help ` for command details. - -ThreadBear install — Install ThreadBear and set up its managed runtime. - -Usage: - threadbear install [flags] - -Flags: - -h, --help show this help and let the bear rest - --agents[=true|false] enable or disable managed AGENTS content (default: unchanged) - --archive[=true|false] enable or disable automatic archiving (default: unchanged) - --archive-after-days DAYS set complete-task archive age in positive DAYS (default: "") - --auto-update[=true|false] enable or disable automatic ThreadBear updates (default: unchanged) - --classifier-context-budget-bytes BYTES set classifier context budget in positive BYTES (default: "") - --classifier-effort low|medium|high|xhigh set classifier reasoning effort: low|medium|high|xhigh (default: "") - --classifier-model MODEL set non-empty classifier MODEL without surrounding whitespace (default: "") - --confirm assert confirmation for noninteractive use (default: false) - --control-task-id TASK_ID adopt existing readable unarchived Codex task TASK_ID (default: "") - --dry-run validate and print the complete install preview without mutation (default: false) - --heartbeat-seconds SECONDS set the heartbeat interval in positive SECONDS (default: "") - --json write stable JSON command results; help stays human-readable (default: false) - --non-interactive do not prompt (default: false) - --noninteractive do not prompt (default: false) - --rename[=true|false] enable or disable managed titles (default: unchanged) - --token-display off|start|end show output tokens in managed titles: off|start|end (default: "") - --version N.N.N install exact release N.N.N without a leading v instead of the latest (default: "") - -ThreadBear heartbeat — Classify changed tasks and apply lifecycle work. - -Usage: - threadbear heartbeat [flags] - -Flags: - -h, --help show this help and let the bear rest - --dry-run inventory and analyze without models or mutations (default: false) - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear status — Show ThreadBear health, preferences, and recent activity. - -Usage: - threadbear status [flags] - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear inspect — Explain ThreadBear's saved state for one task. - -Usage: - threadbear inspect [flags] TASK_ID - -Arguments: - TASK_ID Codex task ID to inspect - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear configure — Preview or change ThreadBear preferences. - -Usage: - threadbear configure [flags] - -Flags: - -h, --help show this help and let the bear rest - --agents[=true|false] enable or disable managed AGENTS content (default: unchanged) - --archive[=true|false] enable or disable automatic archiving (default: unchanged) - --archive-after-days DAYS set complete-task archive age in positive DAYS (default: "") - --auto-update[=true|false] enable or disable automatic ThreadBear updates (default: unchanged) - --classifier-context-budget-bytes BYTES set classifier context budget in positive BYTES (default: "") - --classifier-effort low|medium|high|xhigh set classifier reasoning effort: low|medium|high|xhigh (default: "") - --classifier-model MODEL set non-empty classifier MODEL without surrounding whitespace (default: "") - --confirm confirm the previewed changes (default: false) - --dry-run preview configuration effects (default: false) - --heartbeat-seconds SECONDS set the heartbeat interval in positive SECONDS (default: "") - --json write stable JSON command results; help stays human-readable (default: false) - --non-interactive do not prompt (default: false) - --noninteractive do not prompt (default: false) - --rename[=true|false] enable or disable managed titles (default: unchanged) - --token-display off|start|end show output tokens in managed titles: off|start|end (default: "") - -ThreadBear enable — Enable the ThreadBear LaunchAgent. - -Usage: - threadbear enable [flags] - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear disable — Disable the ThreadBear LaunchAgent. - -Usage: - threadbear disable [flags] - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear restore — Restore one task archived by ThreadBear. - -Usage: - threadbear restore [flags] TASK_ID - -Arguments: - TASK_ID Codex task ID to restore - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear self-test — Check that ThreadBear's runtime surfaces are ready. - -Usage: - threadbear self-test [flags] - -Flags: - -h, --help show this help and let the bear rest - --candidate validate this candidate before installation (default: false) - --json write stable JSON command results; help stays human-readable (default: false) - -ThreadBear update — Install the latest or an exact verified release. - -Usage: - threadbear update [flags] - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) - --version N.N.N install exact release N.N.N without a leading v instead of the latest (default: "") - -ThreadBear uninstall — Clean managed task titles, then remove ThreadBear. - -Usage: - threadbear uninstall [flags] - -Flags: - -h, --help show this help and let the bear rest - --archive-control-task archive the control task (default: false) - --confirm assert confirmation for noninteractive use (default: false) - --delete-state deprecated no-op; persistent state is always deleted (default: false) - --json write stable JSON command results; help stays human-readable (default: false) - --non-interactive do not prompt (default: false) - --noninteractive do not prompt (default: false) - -ThreadBear version — Show the installed ThreadBear version and website. - -Usage: - threadbear version [flags] - -Flags: - -h, --help show this help and let the bear rest - --json write stable JSON command results; help stays human-readable (default: false) diff --git a/cmd/threadbear/titleplan.go b/cmd/threadbear/titleplan.go deleted file mode 100644 index 442fb36..0000000 --- a/cmd/threadbear/titleplan.go +++ /dev/null @@ -1,149 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "io" - "os" - "sort" -) - -type titleReport struct { - Reports []struct { - OperationID string `json:"operation_id"` - Outcome string `json:"outcome"` - ErrorCode string `json:"error_code,omitempty"` - TaskID string `json:"task_id"` - Title string `json:"title"` - } `json:"reports"` -} - -func titlePlan(ctx context.Context, mode, operation string, input io.Reader) (any, error) { - disk := newStore(stateDir()) - switch mode { - case "stage": - data, _ := io.ReadAll(io.LimitReader(input, 16*1024)) - result := parseFooter(string(data)) - if !result.Resolved { - return nil, errors.New("stage input has no exact ThreadBear footer") - } - lock, err := disk.lock() - if err != nil { - return nil, err - } - defer unlock(lock) - value, err := disk.load() - if err != nil { - return nil, err - } - if os.Getenv("CODEX_THREAD_ID") != value.ControlTaskID { - return nil, errors.New("title plan must run in the retained control task") - } - task, found, err := oneTask(ctx, value.ControlTaskID) - if err != nil || !found { - return nil, errors.New("retained control task is not readable and active") - } - _, boundary, err := readEvidence(task.RolloutPath) - if err != nil { - return nil, err - } - turnID, err := latestTurnIDFile(task.RolloutPath) - if err != nil { - return nil, err - } - result.Evidence, result.Size = evidenceID("retained-footer", int64(len(data)), data), boundary - old := value.Tasks[task.ID] - record, pending := decision(task, old, result) - pending.TurnID = turnID - pending.ID = planID(pending) - value.Tasks[task.ID] = record - for id, existing := range value.Plans { - if existing.TaskID == task.ID { - delete(value.Plans, id) - } - } - value.Plans[pending.ID] = pending - if err := disk.save(value); err != nil { - return nil, err - } - return map[string]any{"ready": true}, nil - case "batch": - value, err := disk.load() - if err != nil { - return nil, err - } - ids := make([]string, 0, len(value.Plans)) - for id := range value.Plans { - ids = append(ids, id) - } - sort.Strings(ids) - if len(ids) > 8 { - ids = ids[:8] - } - return map[string]any{"ready": true, "operation_ids": ids, "continuation_due": len(value.Plans) > len(ids)}, nil - case "operation": - value, err := disk.load() - if err != nil { - return nil, err - } - pending, ok := value.Plans[operation] - if !ok { - return nil, errors.New("unknown title operation") - } - task, found, err := oneTask(ctx, pending.TaskID) - if err != nil || !found { - return map[string]any{"ready": true, "disposition": "drifted"}, nil - } - record := value.Tasks[pending.TaskID] - guarded := task.Revision == pending.Revision && sameEvidence(task, record) - if pending.TaskID == value.ControlTaskID { - turnID, readErr := latestTurnIDFile(task.RolloutPath) - guarded = readErr == nil && pending.TurnID != "" && turnID == pending.TurnID - } - if task.Title != pending.ExpectedTitle && task.Title != pending.DesiredTitle || !guarded { - return map[string]any{"ready": true, "disposition": "drifted"}, nil - } - return map[string]any{"ready": true, "disposition": "ready", "action": "set", "task_id": pending.TaskID, "desired_title": pending.DesiredTitle}, nil - case "report": - var report titleReport - if err := json.NewDecoder(io.LimitReader(input, 64*1024)).Decode(&report); err != nil { - return nil, err - } - accepted := 0 - lock, err := disk.lock() - if err != nil { - return nil, err - } - defer unlock(lock) - value, err := disk.load() - if err != nil { - return nil, err - } - for _, item := range report.Reports { - pending, ok := value.Plans[item.OperationID] - if !ok || item.Outcome != "succeeded" || item.TaskID != pending.TaskID || item.Title != pending.DesiredTitle { - continue - } - task, found, readErr := oneTask(ctx, pending.TaskID) - if readErr != nil || !found || task.Title != pending.DesiredTitle { - continue - } - record := value.Tasks[pending.TaskID] - record.Title, record.LastApplied = pending.DesiredTitle, pending.DesiredTitle - record.Status, record.Subject, record.Action = pending.Status, pending.Subject, pending.Action - value.Tasks[pending.TaskID] = record - delete(value.Plans, item.OperationID) - if pending.TaskID == value.ControlTaskID && pending.Status == "complete" && len(value.Plans) == 0 { - value.NativeBootstrap = false - } - accepted++ - } - if err := disk.save(value); err != nil { - return nil, err - } - return map[string]any{"ready": true, "accepted": accepted}, nil - default: - return nil, errors.New("title-plan mode is required") - } -} diff --git a/docs/README.md b/docs/README.md index 467f183..334e6e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,14 +1,10 @@ -# ThreadBear Documentation Index +# ThreadBear documentation -## Evergreen specs -- `README.md` — Current product and CLI surface. -- `docs/architecture.md` — The complete four-stage runtime state machine. -- `docs/status-convention.md` — Exact managed footer and seven title states. -- `docs/compatibility.md` — Supported macOS, Codex, and title surfaces. -- `docs/benchmark.md` — Mutation-free scan measurement. -- `docs/live-eval.md` — Mixed-corpus and rendered Desktop proof. +- `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/release-checklist.md` — local, release, and hosted checks -## Historical artifacts - -`docs/plans/` and `docs/archive/` are point-in-time evidence. They are not -maintained after execution and do not define the current product. +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 d3d2466..60dc9e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,33 +1,26 @@ # Architecture -ThreadBear is one executable and one versioned JSON state file. The production -path is a four-stage state machine: - -1. **Scan.** Under one private flock, read the current Codex index and each - changed rollout to a fixed complete-line boundary. Exact footers and - structured errors become decisions; unresolved evidence records an exact - path, boundary, digest, revision, and title. -2. **Resolve.** Release the lock. Read live App Server state for unresolved - tasks. Active and waiting tasks resolve deterministically. Only evidence - that remains byte-identical across a later pass may enter an ephemeral, - read-only Luna medium call. -3. **Commit.** Reacquire the lock, reread the index and evidence, and discard - any stale runtime or semantic result. A durable plan binds the evidence, - full user-owned subject, status, action, expected title, desired title, and - issuance epoch. -4. **Apply.** The retained task drains plans in fixed batches. Each operation - revalidates current evidence and title, calls the supported native Desktop - setter, checks its exact task-and-title result, and commits only after the - Codex index exposes the desired title. Drift is preserved rather than - overwritten. - -Background heartbeats stop after the commit stage; they never write a title. -The native retained-task endpoint exposes durable plans in batches of eight. -Codex Desktop performs the supported setter; ThreadBear accepts a report only -after both the native result and current Codex task index match exactly. -The setter has no compare-and-set primitive, so a user rename in the narrow -interval after revalidation and before mutation cannot be made atomic. - -`core.json` has an explicit format number and is written through a mode-0600 -temporary file, `fsync`, rename, and directory `fsync`. The reset does not -reinterpret the former `config.json` or `state.json` schemas. +ThreadBear is one small Go executable, one private atomic JSON file, one managed instruction block, one installed skill, and two Codex hook entries. + +## Ordinary turn + +1. Managed guidance makes the native current-task title setter the turn's first action with the compact input `⏳ ThreadBear is working` and no explicit task ID. +2. `PreToolUse` reads the calling task's current title from the local Codex index, preserves the stable user-owned subject, records one pending proposal, and rewrites the title input to `⏳ `. +3. `PostToolUse` accepts only the exact returned task ID and rewritten title, then commits the subject and rendering. +4. Immediately before the final response, the task calls the same native setter with its exact ThreadBear footer. The hooks expand and commit the matching terminal title; the response ends with that footer. + +Each title moment may be retried once. There is no Stop hook: an interrupted turn keeps its running title until the next ordinary turn naturally replaces it. + +## Ownership and state + +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. + +State is keyed by task ID and contains the canonical subject, last verified rendering, and at most one pending proposal. A pending proposal lets a later call recognize setter success when Post was lost. State is private, locked, and atomically replaced. + +The visible title is limited to 60 UTF-16 units. Rendering truncates displayed subject before displayed action without changing canonical state. + +## Installation and migration + +Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order. A foreground Codex task inventories all local unarchived tasks, classifies exact footers deterministically, and uses Luna medium only for genuinely ambiguous v2 history. Immediately before each explicit-target native write, the helper re-reads the target and adopts any newer rename. + +Migration is rerunnable from scratch and skips only inventory rows proven `applied: true` from exact committed ownership state. It persists no migration workflow. Rendered active-header and sidebar verification belongs in release QA. diff --git a/docs/benchmark.md b/docs/benchmark.md index 82e2541..baeb757 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,13 +1,9 @@ # Benchmark -Run a mutation-free local scan with: +Run the read-only local inventory with: ```sh -THREADBEAR_CONTROL_TASK_ID="$CODEX_THREAD_ID" \ -THREADBEAR_STATE_DIR="$(mktemp -d)" \ -threadbear heartbeat --dry-run --json +threadbear inventory --json ``` -Report inventory size, deterministic and ambiguous counts, scan milliseconds, -model calls, and title-application milliseconds separately. A dry run never -creates state, opens App Server, calls Luna, or changes a title. +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. diff --git a/docs/compatibility.md b/docs/compatibility.md index 407a908..0b7b4ea 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,19 +1,11 @@ # Compatibility -ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop -tasks indexed with `source='vscode'`, Codex App Server `thread/read`, and the -Codex Desktop native title setter exposed to the retained task. +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 SQLite dependency is read-only. ThreadBear selects the highest local -`state_N.sqlite` database and requires the current `threads` columns used by the -task index. An unsupported schema fails closed without title mutation. +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. -Visible titles are at most 60 UTF-16 units. The native handoff must return the -exact requested task ID and title, and the task index must then expose that -title. ThreadBear does not write Desktop caches, private UI databases, or -invented compatibility state. +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. It does not write the Codex database, Desktop caches, or private UI storage. -The supported public commands are `install`, `heartbeat`, `status`, -`self-test`, `uninstall`, and `version`. The former configuration, lifecycle, -archive, inspect, update, and restore commands were removed with their product -scope rather than retained as adapters. +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. + +The supported public commands are `install`, `inventory`, `status`, `self-test`, `uninstall`, and `version`. diff --git a/docs/live-eval.md b/docs/live-eval.md index e0de64f..4ccb55b 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -1,11 +1,9 @@ # Live evaluation -Use a mixed corpus with exact complete, needs-input, blocked, automation, -active, malformed-footer, interrupted, and legacy-prose cases. Prove that the -first pass calls no model and that only byte-identical ambiguity from a later -pass can call `gpt-5.6-luna` at medium effort. - -For the Desktop canary, choose one retained task, record its exact current -title, stage one guarded change, apply it through the supported native setter, -and verify the rendered sidebar title in the accessibility tree. Capture a -screenshot, then restore the original title through the same guarded path. +Run release QA in genuinely fresh Codex Desktop tasks so hook and managed-guidance snapshots cannot mask installation defects. + +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. For each ordinary turn, record that the native running call was the first action, the terminal call immediately preceded the final response, the exact footer was final, and each failed call was attempted at most twice. + +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 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. Luna-medium workers must be used only for genuine ambiguity, never write titles, and never exceed eight active workers. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 68b1312..1d8cc87 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -2,22 +2,10 @@ Before tagging a stable release: -1. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh - `Unreleased` section. -2. Run `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax, - plist lint, and the public-site parity checks. -3. On a disposable private Codex home with at least 50 real-shape tasks, verify - a mutation-free first scan, a later ambiguity-only Luna pass, guarded direct - title staging, uninstall that preserves current titles, and no retained task - data in the report. -4. On the real Codex Desktop, stage one controlled retained-task plan, apply it - through the supported native setter, verify the rendered title in the - accessibility tree, capture a privacy-safe screenshot, and restore the - original title through the same guarded path. -5. Record aggregate task, deterministic, ambiguous, Luna, staged, scan-time, - native accepted, and native failed values. Never retain task IDs, titles, - messages, rollout paths, model payloads, or copied state. +1. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. +2. Run `gofmt`, `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity checks. Count every tracked non-test `.go` file and require at most 1,000 physical production lines. +3. In isolated homes, prove install, reinstall, status, inventory, and both uninstall title choices while preserving unrelated AGENTS content and hook definitions in order. +4. Exercise 0-, 1-, and 200-task foreground migrations. Prove deterministic exact-footer classification, ambiguity-only Luna medium with at most eight workers, explicit native results, concurrent rename/archive handling, interruption, and clean rerun. +5. In fresh Codex Desktop tasks, run the matrix in `docs/live-eval.md`. Verify the rendered active header and sidebar, capture privacy-safe screenshots, and restore controlled canary titles through the supported native path. -After tagging, confirm the release workflow publishes both architectures, -checksums, and the manifest, then prove the live bootstrap verifies the -checksum, candidate self-test, install, LaunchAgent, and uninstall chain. +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. diff --git a/docs/status-convention.md b/docs/status-convention.md index cb11b14..441c80a 100644 --- a/docs/status-convention.md +++ b/docs/status-convention.md @@ -1,6 +1,6 @@ # Status footer convention -A terminal Codex response may end with exactly one of these lines: +Every terminal Codex response under ThreadBear guidance ends with exactly one of these forms: ```text 🧵🐻 complete @@ -12,12 +12,16 @@ A terminal Codex response may end with exactly one of these lines: 🧵🐻 automation ``` -The footer must be the final non-empty line, cannot be quoted or duplicated, -and an owner-bearing action must contain a concrete multiword instruction. -ThreadBear accepts only the bindings shown above: `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 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. -Exact accepted footers are deterministic. Legacy prose without an exact footer -remains unknown until live runtime state resolves it or unchanged ambiguity is -sent to Luna medium. +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 | +| --- | --- | +| `complete` | `✅ ` | +| `next steps (…)` | `➡️ ` | +| `needs input (you)` | `🙋 ` | +| `blocked (external)` | `🚨 ` | +| `automation` | `🤖 ` | + +At turn start, `⏳ ThreadBear is working` maps to `⏳ `. `❔` is reserved for legacy items that remain unknown during installation; ordinary turns do not emit it. diff --git a/internal/status/schema.json b/internal/status/schema.json deleted file mode 100644 index 6b85b2c..0000000 --- a/internal/status/schema.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "required": ["schema_revision", "results"], - "properties": { - "schema_revision": {"type": "string", "const": "threadbear.status.v1"}, - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["task_id", "task_revision", "state", "durable_subject", "managed_action", "request_previous"], - "properties": { - "task_id": {"type": "string", "minLength": 1}, - "task_revision": {"type": "string", "minLength": 1}, - "state": {"type": "string", "enum": ["blocked", "needs_input", "running", "automation", "next_steps", "complete", "unknown"]}, - "durable_subject": {"type": "string"}, - "managed_action": {"type": "string"}, - "request_previous": {"type": "boolean"} - } - } - } - } -} diff --git a/internal/tokens/testdata/rollout-tail.jsonl b/internal/tokens/testdata/rollout-tail.jsonl deleted file mode 100644 index 2f6cb02..0000000 --- a/internal/tokens/testdata/rollout-tail.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"timestamp":"2026-07-26T12:00:00Z","type":"event_msg","payload":{"type":"agent_message","message":"Synthetic content only."}} -{"timestamp":"2026-07-26T12:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":88100000,"cached_input_tokens":82000000,"cache_write_input_tokens":0,"output_tokens":1200000,"reasoning_output_tokens":200000,"total_tokens":89300000}}}} -{"timestamp":"2026-07-26T12:00:02Z","type":"event_msg","payload":{"type":"agent_message","message":"More synthetic content."}} -{"timestamp":"2026-07-26T12:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":431399877,"cached_input_tokens":424100000,"cache_write_input_tokens":0,"output_tokens":1600123,"reasoning_output_tokens":300123,"total_tokens":433000000}}}} diff --git a/internal/update/notes.txt b/internal/update/notes.txt deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index 64f0aaf..49fc1cd 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -10,36 +10,65 @@ esac root=$(mktemp -d "${TMPDIR:-/tmp}/threadbear-smoke.XXXXXX") cleanup() { - HOME="$root/home" launchctl bootout "gui/$(id -u)/org.litman.threadbear" >/dev/null 2>&1 || true 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" -rollout=$root/control.jsonl -printf '%s\n' '{"type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"Ready.\\n\\n🧵🐻 complete"}]}}' > "$rollout" +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" < "$home/.local/bin/codex" -chmod 700 "$home/.local/bin/codex" -env HOME="$home" CODEX_HOME="$codex_home" CODEX_THREAD_ID=control \ +env HOME="$home" CODEX_HOME="$codex_home" \ PATH="$home/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ THREADBEAR_RELEASE_BASE_URL="https://github.com/ericlitman/threadbear/releases" \ sh -c 'curl -fsSL https://threadbear.sh/install.sh | sh -s -- \ - --version "$1" --control-task-id control --noninteractive --confirm --json' \ + --version "$1" --noninteractive --confirm --json' \ sh "$version" 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" 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-final","tool_input":{"title":"🧵🐻 complete"}}' +prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) +expected_title='✅ Release smoke 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 subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"✅ Release smoke 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 subject"' "$state" >/dev/null +grep -E '"last"[[:space:]]*:[[:space:]]*"✅ Release smoke subject"' "$state" >/dev/null +if grep -F '"pending"' "$state" >/dev/null; then + echo "release smoke title remained pending" >&2 + exit 1 +fi +inventory=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" inventory --json) +printf '%s\n' "$inventory" | grep -F '"applied":true' >/dev/null HOME="$home" CODEX_HOME="$codex_home" "$binary" uninstall --noninteractive --confirm --json test ! -e "$binary" diff --git a/scripts/replay-title-batch.mjs b/scripts/replay-title-batch.mjs deleted file mode 100755 index 3e53c02..0000000 --- a/scripts/replay-title-batch.mjs +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -import assert from "node:assert/strict"; import vm from "node:vm"; -const RAW_CELL = String.raw`// @exec: {"yield_time_ms": 120000, "max_output_tokens": 1000} -const counts = {accepted: 0, canonically_verified: 0, failed: 0, timed_out: 0, drifted: 0, rejected: 0}; let waitsRemaining = 300, cleanupWaitsRemaining = 10; const deadlineAt = Date.now() + 300000; const timedOut = () => { if (!counts.timed_out) counts.timed_out++; throw {kind: "timed_out"}; }; const requireTime = () => { if (Date.now() >= deadlineAt) timedOut(); }; const command = async (cmd, enforceDeadline = true) => { if (enforceDeadline) requireTime(); const result = await tools.exec_command({cmd}); if (enforceDeadline) requireTime(); if (!result || result.exit_code !== 0) throw {kind: "command_failed"}; return result; }; const commandJSON = async (cmd, enforceDeadline = true) => { const result = await command(cmd, enforceDeadline); if (typeof result.output !== "string") throw {kind: "command_failed"}; return JSON.parse(result.output); }; -const readyJSON = async (cmd, waitForContinuation = false, enforceDeadline = true) => { for (;;) { - const value = await commandJSON(cmd, enforceDeadline); - if (value.ready && (!value.continuation_due || !waitForContinuation)) return value; - if (!value.ready && (!value.retryable || !["heartbeat_active", "heartbeat_cycle_active"].includes(value.error_code))) throw {kind: "not_ready"}; - if (enforceDeadline && waitsRemaining-- <= 0) timedOut(); - if (!enforceDeadline && cleanupWaitsRemaining-- <= 0) throw {kind: "not_ready"}; - await command("sleep 1", enforceDeadline); -} }; -const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; -const batchCommand = "~/.local/bin/threadbear title-plan --json --batch", stageFooter = (footer, enforceDeadline = true) => readyJSON("printf %s " + quote(footer) + " | ~/.local/bin/threadbear title-plan --json --stage", false, enforceDeadline); -const drain = async (batch, enforceDeadline = true) => { let complete = true; - for (const operationID of batch.operation_ids || []) try { - const operation = await readyJSON("~/.local/bin/threadbear title-plan --json --operation " + quote(operationID), false, enforceDeadline); - if (operation.disposition === "drifted") { counts.drifted++; complete = false; continue; } - if (operation.disposition !== "ready" || !["set", "report_success"].includes(operation.action)) { counts.rejected++; complete = false; continue; } - let outcome = "succeeded", errorCode = ""; - if (operation.action === "set") try { if (enforceDeadline) requireTime(); await tools.codex_app__set_thread_title({threadId: operation.task_id, title: operation.desired_title}); if (enforceDeadline) requireTime(); } - catch (error) { if (error?.kind === "timed_out") throw error; if (enforceDeadline) requireTime(); outcome = "failed"; errorCode = "native_setter_failed"; counts.failed++; complete = false; } - const payload = {reports: [{operation_id: operationID, outcome, ...(errorCode && {error_code: errorCode})}]}; - const report = await readyJSON("printf %s " + quote(JSON.stringify(payload)) + " | ~/.local/bin/threadbear title-plan --json --report", false, enforceDeadline); - counts.accepted += report.accepted || 0; - if (outcome === "succeeded") counts.canonically_verified += report.accepted || 0; - } catch (error) { if (error?.kind !== "timed_out") counts.failed++; complete = false; } - return complete; -}; -let coreComplete = false, complete = false, successFinalizationStarted = false; -try { - const first = await readyJSON(batchCommand); - if (await drain(first)) { - let remaining = first.continuation_due ? first : await readyJSON(batchCommand); - if (remaining.continuation_due) { - await command('launchctl kickstart "gui/$(id -u)/org.litman.threadbear"'); - remaining = await readyJSON(batchCommand, true); - } - coreComplete = await drain(remaining); - } -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -if (coreComplete) try { - requireTime(); - successFinalizationStarted = true; - await stageFooter("🧵🐻 complete"); - complete = await drain(await readyJSON(batchCommand)); -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -if (!complete && !counts.timed_out) try { - await stageFooter("🧵🐻 next steps (agent): retry the first title handoff"); - await drain(await readyJSON(batchCommand)); -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -if (!complete && counts.timed_out && successFinalizationStarted) try { - await stageFooter("🧵🐻 next steps (agent): retry the first title handoff", false); - await drain(await readyJSON(batchCommand, false, false), false); -} catch (error) { if (error?.kind !== "timed_out") counts.failed++; } -text(JSON.stringify({...counts, complete}));`; -const runCell = async (scenario) => { - const output = [], tools = {exec_command: ({cmd}) => scenario.exec(cmd), codex_app__set_thread_title: (payload) => scenario.set(payload)}; - await new vm.Script(`(async()=>{${RAW_CELL}\n})()`).runInContext(vm.createContext({tools, text: (value) => output.push(value), Date: {now: () => scenario.now()}})); assert.equal(output.length, 1); return output[0]; -}; -const fixture = (options = {}) => { - const secrets = ["0123456789abcdef0123456789abcdef", "sensitive-task", "✅ Sensitive title"]; let batches = 0, logicalBatches = 0, kickstarts = 0, stages = 0, stagedBatches = 0, now = 0, lateStarts = 0, cleanupSleeps = 0, retryStageBusy = options.cleanupBusy || 0; const calls = [], lateCalls = [], stagedFooters = [], appliedFooters = []; - return {secrets, async exec(cmd) { - calls.push(cmd); if (now >= 300000) { lateStarts++; lateCalls.push(cmd); if (cmd === "sleep 1") cleanupSleeps++; } - now += options.commandMillis || 0; - if (cmd === "sleep 1") return {exit_code: 0, output: ""}; - if (cmd.includes("launchctl kickstart")) { kickstarts++; return options.kickstartFailure ? {exit_code: 1, output: ""} : {exit_code: 0, output: ""}; } - if (cmd.includes("--stage")) { const footer = cmd.includes("🧵🐻 complete") ? "complete" : "retry"; if (footer === "retry" && retryStageBusy-- > 0) return {exit_code: 0, output: JSON.stringify({ready: false, retryable: true, error_code: "heartbeat_active"})}; stages++; stagedFooters.push(footer); if (footer === "complete") now += options.completeStageMillis || 0; return {exit_code: 0, output: JSON.stringify({ready: true})}; } - if (cmd.includes("--batch")) { - batches++; - if (stages > stagedBatches) { stagedBatches++; return {exit_code: 0, output: JSON.stringify({ready: true, operation_ids: [secrets[0]]})}; } - if (options.retry && batches === 1) return {exit_code: 0, output: JSON.stringify({ready: false, retryable: true, error_code: "heartbeat_active"})}; - logicalBatches++; - if (options.initialContinuationDue && logicalBatches === 1) return {exit_code: 0, output: JSON.stringify({ready: true, continuation_due: true})}; - if (options.initialContinuationDue && logicalBatches === 2) return {exit_code: 0, output: JSON.stringify({ready: true, operation_ids: [secrets[0]]})}; - if (logicalBatches === 1) return {exit_code: 0, output: JSON.stringify({ready: true, operation_ids: [secrets[0]]})}; - if (options.continuationAlreadyComplete && logicalBatches === 2) return {exit_code: 0, output: JSON.stringify({ready: true})}; - if (logicalBatches === 2 || options.timeout && logicalBatches > 2) return {exit_code: 0, output: JSON.stringify({ready: true, continuation_due: true})}; - return {exit_code: 0, output: JSON.stringify({ready: true})}; - } - if (cmd.includes("--operation")) return {exit_code: 0, output: JSON.stringify(stages === 0 && options.operation || {ready: true, disposition: "ready", action: "set", task_id: secrets[1], desired_title: secrets[2]})}; - now += stages > 0 ? options.completeReportMillis || 0 : options.reportMillis || 0; - return options.reportFailure ? {exit_code: 1, output: ""} : {exit_code: 0, output: JSON.stringify({ready: true, accepted: 1})}; - }, now: () => now, calls: () => calls, lateCalls: () => lateCalls, lateStarts: () => lateStarts, cleanupSleeps: () => cleanupSleeps, stagedFooters: () => stagedFooters, appliedFooters: () => appliedFooters, batches: () => batches, kickstarts: () => kickstarts, stages: () => stages, async set({threadId, title}) { assert.deepEqual([threadId, title], secrets.slice(1)); calls.push("native-setter"); if (now >= 300000) { lateStarts++; lateCalls.push("native-setter"); } appliedFooters.push(stagedFooters.at(-1) || "core"); now += stages > 0 ? options.completeSetterMillis || 0 : options.setterMillis || 0; if (options.setterFailure || options.footerSetterFailure && stages === 1) throw new Error(); }}; -}; -const cases = [[{}, [2,2,0,0,0,0,true]], [{retry:true}, [2,2,0,0,0,0,true]], [{initialContinuationDue:true}, [2,2,0,0,0,0,true]], [{continuationAlreadyComplete:true}, [2,2,0,0,0,0,true]], [{kickstartFailure:true}, [2,2,1,0,0,0,false]], [{operation:{ready:true,disposition:"drifted"}}, [1,1,0,0,1,0,false]], [{operation:{ready:true,disposition:"missing"}}, [1,1,0,0,0,1,false]], [{reportFailure:true}, [0,0,2,0,0,0,false]], [{setterFailure:true}, [2,0,2,0,0,0,false]], [{footerSetterFailure:true}, [3,2,1,0,0,0,false]]]; for (const [options, values] of cases) { - const scenario = fixture(options), output = await runCell(scenario); assert.deepEqual(Object.values(JSON.parse(output)), values); - for (const secret of scenario.secrets) assert.equal(output.includes(secret), false); - assert.equal(scenario.kickstarts(), !options.continuationAlreadyComplete && !options.reportFailure && !options.setterFailure && !options.operation ? 1 : 0); - assert.equal(scenario.stages(), values.at(-1) ? 1 : options.footerSetterFailure ? 2 : 1); -} -const timed = fixture({timeout: true}), timedOutput = JSON.parse(await runCell(timed)); assert.equal(timedOutput.timed_out, 1); assert.equal(timedOutput.complete, false); assert.equal(timed.kickstarts(), 1); assert.equal(timed.stages(), 0); -for (const options of [{commandMillis: 300000}, {setterMillis: 300000}, {reportMillis: 300000}]) { const slow = fixture(options), output = JSON.parse(await runCell(slow)); assert.equal(output.timed_out, 1); assert.equal(output.complete, false); assert.equal(slow.stages(), 0); assert.equal(slow.lateStarts(), 0); } -for (const options of [{completeStageMillis: 300000}, {completeSetterMillis: 300000}, {completeReportMillis: 300000}]) { const slow = fixture(options), output = JSON.parse(await runCell(slow)); assert.equal(output.timed_out, 1); assert.equal(output.complete, false); assert.deepEqual(slow.stagedFooters(), ["complete", "retry"]); assert.equal(slow.appliedFooters().at(-1), "retry"); assert.match(slow.lateCalls()[0], /retry the first title handoff/); } -for (const cleanupBusy of [10, 11]) { const slow = fixture({completeStageMillis: 300000, cleanupBusy}), output = JSON.parse(await runCell(slow)); assert.equal(output.timed_out, 1); assert.equal(output.complete, false); assert.equal(slow.cleanupSleeps(), 10); assert.deepEqual(slow.stagedFooters(), cleanupBusy === 10 ? ["complete", "retry"] : ["complete"]); } -assert.equal(/\b(process|setTimeout|functions\.|call\.wait|require\(|console\.)/.test(RAW_CELL), false); console.log("title batch replay harness passed"); export {RAW_CELL}; diff --git a/site/index.html b/site/index.html index 4cefe60..29615d7 100644 --- a/site/index.html +++ b/site/index.html @@ -4,7 +4,7 @@ - ThreadBear — tidy Codex tasks, deterministic first + ThreadBear — tidy Codex tasks, turn by turn