diff --git a/CHANGELOG.md b/CHANGELOG.md index b78a205..6e03343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ## Unreleased +## v2.2.1 - 2026-08-05 + ### Fixed -- Made uninstall resumable from any active native task when the persisted ThreadBear home is archived, with durable initiator ownership, exact archive restoration, archived-main title validation, and binary-last local teardown. +- Completed guided title migration in bounded concurrent waves without client-created native-call timeouts, while keeping exact inventory reconciliation and read-only Luna classifiers isolated to temporary installation tasks. +- Prevented delayed/older title callbacks and no-op proposals from racing replacement or uninstall teardown with a locked state-format fence, and registered the controller's actual native runtime ID from its marked home delegation instead of trusting Codex's provisional creation handle. +- Required that marked controller registration also come from Codex's native `subagent` source, preventing an ordinary user task from forging the delegation text and claiming migration authority. +- Made uninstall resumable from any active native task after a completed migration, a stopped failed migration, or a quiescent pre-controller install, with installer-time home-title capture, upgrade-safe legacy subject recovery, exact decorated-home restoration, durable initiator ownership, exact archive restoration, preservation of user-created files beside the managed skill, and binary-last local teardown. ## v2.2.0 - 2026-08-03 diff --git a/INSTALL.md b/INSTALL.md index 60915ab..be4e43d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -69,7 +69,7 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed. > - Small local footprint: one binary in ~/.local/bin, a skill, and two hooks. > - One persistent thread, 🧵🐻 ThreadBear 🐻🧵, for changing config and uninstalling. -> - Deterministic classification first; Luna medium only for ambiguity. +> - Deterministic classification and Luna-low ambiguity checks run in parallel. > - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. > - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. > - ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat. @@ -109,7 +109,7 @@ The install result must show `installed:true`, the exact `main_task_id`, and `ph Inventory and migration cover local Codex Desktop and CLI tasks that the native explicit-target title setter can address. They do not enumerate or rename older signed-in ChatGPT chat-history rows that may also appear in the Desktop sidebar. Disclose that boundary before migration and never describe zero local inventory rows as proof that every visible sidebar row changed. -Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. +Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” On creation omit `id`; Codex derives it from the exact name, and the returned `automationId` must equal `threadbear-maintenance`. If it differs, delete only that returned ID, verify it is absent, report the partial install, and stop. Never treat the create request's status as proof: immediately update that exact returned ID with the complete expected fields and `status:"PAUSED"`, then verify the full exact match before creating the controller. Pause and verify an existing exact match before migration too. Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. If creation, pause, or verification fails, delete a just-created automation only after an exact ownership read, verify it is absent, report the partial install, and stop. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. If the native automation call fails, say that ThreadBear's local title helper is installed but its housekeeping helper is not, and stop before claiming completion. Do not ask the user to create or repair the automation manually. @@ -121,27 +121,19 @@ Before migration, tell the user: > ThreadBear will stay selected while one background controller updates native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged. This usually takes several minutes, and a large or ambiguous local history can take longer. `migration_running` means the controller is actively working; I'll report every 25 applied titles or phase change and won't finish this installation turn until it reaches `migration_complete` or `migration_failed`. -Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep this task selected. For an ordinary guided installation, do not use visual inspection, computer control, screenshots, or Codex `/hooks`, and do not ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate; visual hook verification is outside this ordinary installation flow. +Before any bulk work, follow **Install** step 5 in the canonical installed skill exactly: make its one exact untagged home-title call, require the returned task ID/title, then pin the initiating task and keep it selected. Do not add a nonce or make a second title call. For an ordinary guided installation, do not use visual inspection, computer control, screenshots, or Codex `/hooks`, and do not ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate; visual hook verification is outside this ordinary installation flow. -Create exactly one background migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave the truthful `migration_pending` phase and report that nothing is running plus the exact retry action. After successful creation, immediately persist its exact ID: +Create exactly one projectless background migration-controller task with `codex_app__create_thread` using `model:"gpt-5.6-terra"`, `thinking:"medium"`, and a prompt containing the controller protocol from the installed ThreadBear skill. Begin its delegated input with the exact sentence `ThreadBear controller registration.` Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. Retain the task ID returned by creation only as a supervision handle; Codex may expose a different native runtime ID. The controller's required first current-task running-title call atomically registers that runtime ID. Before any migration write, require fresh status to show `migration_running` with a nonempty controller ID matching the controller's supported current-task context. If creation or registration fails, leave the truthful `migration_pending` phase, stop the controller before it writes any target title, and report that nothing is running plus the exact retry action. -```sh -~/.local/bin/threadbear migration \ - --phase migration_running \ - --controller-task-id "$CONTROLLER_TASK_ID" --json -``` +The controller is the only migration title writer. It processes explicit targets in bounded concurrent waves of at most eight distinct task IDs and skips only rows already reporting `applied:true`. Immediately before each title wave it concurrently calls `codex_app__read_thread` for every target as a read-only activation gate, requires each exact task ID and inventory-planned title, and starts the setter wave within 15 seconds without navigating or selecting; a failed or mismatched read is reconciled through fresh inventory and never followed by a blind write. From the first inventory snapshot, it starts the first stable deterministic batch of at most 25 and one bounded concurrent spawn wave of fresh read-only Luna-low workers in stable batches of at most 10 genuinely ambiguous tasks at the same time, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. Worker creation uses the fixed `codex_app__create_thread` surface with `model:"gpt-5.6-luna"` and `thinking:"low"`, records every settled successful handle, and never lets a failed spawn hide an earlier success. The controller derives every assigned list mechanically from the parsed inventory `task_id` fields, never retypes or synthesizes an ID, and asserts exact membership in the same inventory snapshot before spawning. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; retained classifier IDs are excluded from title waves, and all currently validated workers are archived together in one concurrent native explicit-target call before their batches are applied. A missing, mismatched, or unconfirmed worker archive fails closed. Classifier results may arrive out of order. The classifier's `phase:"final_answer"` item must contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block; separate worker commentary is not part of the result grammar. Because a `wait_threads` snapshot may normalize the one separator newline into a space, the controller locates the array's closing bracket and accepts one or more whitespace characters before the exact footer instead of requiring a physical newline; it still rejects any other prose. The controller ignores the footer only while parsing the array. Any other final-answer prose, malformed content, or mismatched batch is discarded and retried once without writing titles. The controller passes the compact footer grammar—not a subject, visible status icon, or pre-rendered title—so the Pre hook alone expands the authoritative subject. Every footer setter value begins with the full literal `🧵🐻` mark (`title:"🧵🐻 complete"`, for example); bare classification labels such as `complete`, `blocked`, or `next_steps` are never setter values. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Every title wave awaits all native results without a client-created `Promise.race` or other synthetic timeout; the trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so the controller never compares the native return title with the compact input, requires the exact returned task ID, treats an expected hook-expanded title as provisional success, and uses fresh inventory as the authoritative applied result. It reconciles the whole wave through inventory before continuing; every ready stable queue runs in one orchestrated loop with concurrent gates and setters plus fresh inventory after each max-eight wave, without commentary or a model round trip between settled waves. Only an explicit timeout from the native tool is a timeout. A target that explicitly becomes inactive or not found may be dropped only after a fresh authoritative inventory no longer contains its exact ID and every other wave target reconciles as applied; it naturally left the addressable catalog and is not counted as applied. Any other native failure, or an ID that remains present, fails closed. Each worker has an eight-minute deadline, a timed-out or invalid read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also fails, it reports failure and complete accounting to the home. It never runs migration phase commands itself. An explicit timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. -The controller is the only migration writer. It processes one explicit target at a time and skips only rows already reporting `applied:true`. When deterministic rows exist, it begins a stable batch of at most 25 before discovering or spawning Luna workers, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. It then uses the fixed `codex_app__create_thread` surface with Luna medium to launch adaptive waves of fresh read-only Luna-medium workers for genuinely ambiguous classifications while deterministic writes continue serially. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; results may arrive out of order, but title writes remain serial. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Each worker has an eight-minute deadline, a timed-out read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also times out, it records `migration_failed`. Every non-successful exit records `migration_failed` before returning; it never leaves an idle controller described as `migration_running`. A timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. On success it performs a final inventory with zero remaining rows, then records: +For a failed title wave, the controller leaves pending proposals fail-closed and reports complete accounting to the home, including whether every admitted native call returned a terminal result. It never runs a migration phase command or uses `--settled` itself. -```sh -~/.local/bin/threadbear migration \ - --phase migration_complete \ - --controller-task-id "$CONTROLLER_TASK_ID" --json -``` +A bounded `wait_threads` response with `timedOut:true` is normal polling, not a classifier timeout. Continue polling every retained worker until its own eight-minute deadline; only that per-worker deadline can trigger the documented retry or failure path. -Only after that command succeeds may the controller archive itself. A stopped controller resumes with the same ID; do not create a replacement controller. Never claim installation completion from a setter return, accepted call, or partial count. +The controller finishes only when a final inventory reports zero remaining rows and returns that exact result to the home. It remains visible until the home records the terminal phase. -Keep this ThreadBear task selected and supervise the controller with compact task waits. Report only each 25-title milestone or phase change. When the controller returns, run `status --json` and `inventory --json`. `migration_pending` always means no controller was recorded; status repairs an older running-without-controller state to pending. `migration_running` always names the active controller. Status reconciles a missing controller or a terminal lifecycle event from the current attempt from stale `migration_running` to `migration_failed`; it never infers failure from age, slow progress, or a prior attempt's terminal event. If the controller or this turn is interrupted, begin the next turn with status so the durable phase is truthful. +Keep this ThreadBear task selected and supervise the controller with compact task waits. Report only each 25-title milestone or phase change. When the controller returns, first run fresh inventory. Only at zero remaining rows with no unresolved native call does the home record `migration_complete` with the status-reported controller ID; otherwise it records `migration_failed`, adding `--settled` only when complete controller accounting proves every admitted native call returned a terminal result and omitting it for any timeout, unknown result, or interruption. Then run `status --json` and verify the terminal state. Only after the successful transition may the home archive the controller. `migration_pending` always means no controller was recorded; status repairs an older running-without-controller state to pending. `migration_running` always names the active native runtime controller. Status reconciles a missing controller or a terminal lifecycle event from the current attempt from stale `migration_running` to `migration_failed`; it never infers failure from age, slow progress, or a prior attempt's terminal event. If the controller or this turn is interrupted, begin the next turn with status so the durable phase is truthful. Do not send a final installation answer while status still says `migration_pending` or `migration_running`. At `migration_pending`, say that migration has not started and nothing is running, then give the exact start action. At `migration_failed`, say plainly that migration stopped and is not still working, give the applied and remaining counts, name the cause, and give one exact resume action. Keep maintenance paused in every non-complete phase. At `migration_complete`, require zero remaining native-addressable local rows, activate the exact owned heartbeat, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. @@ -187,7 +179,7 @@ After consent, pause the exact owned `threadbear-maintenance` heartbeat; refuse ~/.local/bin/threadbear uninstall --prepare --initiator-task-id INITIATOR_ID --json ``` -If the returned original main state is archived, unarchive that exact task once through native archive control and verify it without opening, selecting, navigating to, or waking the task. The prepared initiating task cleans active titles serially through explicit native target calls, including an active distinct controller, then cleans `main_task_id` last. Every title mutation gets one four-second attempt, exact authoritative readback, and no blind retry. A clean title on resume is already settled and must not be rewritten. +If the returned original main state is archived, unarchive that exact task once through native archive control and verify it without opening, selecting, navigating to, or waking the task. The prepared initiating task cleans active titles serially through explicit native target calls, including an active distinct controller, then cleans `main_task_id` last. A quiescent pre-controller `migration_pending` uninstall leaves every inventory task untouched and cleans only the persistent home when it is decorated or its stripped title is the exact install sentinel. Every title mutation gets one four-second attempt, exact authoritative readback, and no blind retry. A clean non-sentinel title on resume is already settled and must not be rewritten. Restore and verify the main task's exact original archive state before deleting the owned automation. On a recoverable interruption, leave ThreadBear installed; the same initiating task resumes without duplicating settled mutations, and drifted or missing title targets remain user-owned. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, and require `aborted:true`. Once the exact automation is deleted and verified absent, cross the local commit boundary and run: @@ -195,10 +187,10 @@ Restore and verify the main task's exact original archive state before deleting ~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json ``` -Commit refuses unless the prepared owner, clean main title, restored archive state, completed migration, and settled native operations all match. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary; partial local teardown is rerunnable and the installed binary is removed last. It preserves unrelated content, hook order, unrelated automations, and archive states. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. +Commit refuses unless the prepared owner, clean main title, restored archive state, eligible stopped phase, and settled native operations all match. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill file, private state, and binary; partial local teardown is rerunnable and the installed binary is removed last. It preserves unrelated content—including user-created files beside the managed skill—hook order, unrelated automations, and archive states, and removes the skill directory only when empty. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. ## Maintainer verification -A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, and controller resume, interruption, and failure cases. +A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, bounded write-wave overlap, and controller resume, interruption, and failure cases. Tests and fixtures alone are not shipping proof. Also execute every lifecycle command printed here against the reviewed release candidate and exercise each changed native lifecycle against a real, recoverable Codex test task through the supported native control. Exercise changed download/update behavior against the official release service in an isolated installation. Record exact candidate SHA, task/release IDs, before/after state, results, and cleanup without visual inspection. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. diff --git a/README.md b/README.md index 311822b..3495cfc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The canonical shape is ` [ → ]`. ThreadBear owns only d Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, persistent-home setup, and supervised controller migration. -ThreadBear installs a standalone Go binary, one small private state file, managed guidance, two Codex hooks, and one consented hourly Luna heartbeat. The initiating task becomes the persistent `🧵🐻 ThreadBear 🐻🧵` home; one ephemeral controller owns installation migration so that home returns promptly, while the heartbeat later handles quiet housekeeping from that task. +ThreadBear installs a standalone Go binary, one small private state file, managed guidance, two Codex hook entries, and one consented hourly Luna heartbeat. The initiating task becomes the persistent `🧵🐻 ThreadBear 🐻🧵` home; one ephemeral controller owns installation migration so that home returns promptly, while the heartbeat later handles quiet housekeeping from that task. ## Commands @@ -36,10 +36,10 @@ threadbear version Every command accepts `--json`. `inventory` is read-only and includes every native-addressable unarchived local Codex Desktop or CLI task, including projectless tasks, excluding the persisted main and controller tasks. Rollout-only internal records and older signed-in ChatGPT chat-history rows that Codex's native title setter cannot enumerate or rename are excluded. Those chat-history rows may remain unchanged in the Desktop sidebar even after local migration completes. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. -From the persistent ThreadBear task, ask to “strip title icons” or “check for updates now” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. The same task's hourly Luna helper can archive only deterministically eligible, ThreadBear-owned complete user tasks after 14 quiet days, restore only archives recorded in its private ownership ledger, and run the deterministic verified update check last. Guided uninstall may start from any active native task, persists that task as the sole operation owner, temporarily restores an archived ThreadBear home only for final title cleanup, puts it back in its original archive state, and removes the binary only after native settlement and every other local artifact. +From the persistent ThreadBear task, ask to “strip title icons” or “check for updates now” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. The same task's hourly Luna helper can archive only deterministically eligible, ThreadBear-owned complete user tasks after 14 quiet days, restore only archives recorded in its private ownership ledger, and run the deterministic verified update check last. Guided uninstall may start from any active native task, persists that task as the sole operation owner, temporarily restores an archived ThreadBear home only for final title cleanup, puts it back in its original archive state, and removes the binary only after native settlement and every other ThreadBear-owned local artifact while preserving user-created files beside the managed skill. ## Boundaries -ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, reconciles ownership, and chooses the exact Darwin asset from the official release manifest. Luna calls supported native controls and communicates typed results; it never edits private UI storage, interprets prose to add targets, or chooses/downloads/checksums a release. Updates refuse while archive work is pending, verify repository URLs, SHA-256, embedded version, candidate self-test, candidate install, and installed status, and never downgrade. ThreadBear adds no token counts, model call, or narration to ordinary turns. Installation uses one serial native-writing controller and adaptive waves of read-only Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. +ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, reconciles ownership, and chooses the exact Darwin asset from the official release manifest. Luna calls supported native controls and communicates typed results; it never edits private UI storage, interprets prose to add targets, or chooses/downloads/checksums a release. Updates refuse while archive work is pending, verify repository URLs, SHA-256, embedded version, candidate self-test, candidate install, and installed status, and never downgrade. ThreadBear adds no token counts, model call, or narration to ordinary turns. Installation uses one Terra-medium controller, bounded concurrent native-title waves, and bounded read-only Luna-low classifier waves only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. 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 0fb12df..eee31a9 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -9,16 +9,26 @@ For every ordinary interactive turn in a main Codex Desktop task: For both title moments, replace the title literal and execute this exact shape: ```js +const attempt = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); const result = await Promise.race([ - tools.codex_app__set_thread_title({title:"REPLACE WITH THE REQUIRED TITLE"}) + tools.codex_app__set_thread_title({title:"REPLACE WITH THE REQUIRED TITLE" + "⁣" + attempt}) .then(value => ({status:"returned", value})) .catch(error => ({status:"failed", error:String(error)})), new Promise(resolve => setTimeout(() => resolve({status:"timeout"}), 4000)) ]); +if (result.status === "failed") { + const recovery = await tools.exec_command({ + cmd: `THREADBEAR_TITLE_ATTEMPT='${attempt}' ~/.local/bin/threadbear maintenance --cancel "$CODEX_THREAD_ID" --json`, + yield_time_ms: 4000, max_output_tokens: 1000 + }); + result.recovery = recovery.exit_code === 0 ? "settled" : "failed"; +} text(result); ``` -Make exactly one native attempt. The four-second timer is the total wait budget. If it wins, the write result is unknown: end the cell, never retry or await that promise, and continue the turn. Also continue after an explicit returned failure. Do not call the native title tool directly outside this bounded cell. +Make exactly one native attempt. The hook strips the opaque attempt suffix before the title reaches Codex. The four-second timer is the native wait budget. If it wins, the write result is unknown: end the cell, never retry or await that promise, never cancel it, and continue the turn. Only an explicit returned failure runs the local recovery command; it clears solely the exact caller's proposal carrying that same attempt ID while native readback still equals its recorded prior title. Continue even when that guarded recovery fails. Do not call the native title tool directly outside this bounded cell. + +This bounded current-task shape applies only to the two ordinary title moments above, where `threadId` is omitted. Explicit-target lifecycle mutations are governed by the installed ThreadBear skill instead. Follow that lifecycle's batching and wait rules; do not add this `Promise.race` unless the skill explicitly requires a four-second attempt. ThreadBear uses that first-call subject only when a fresh task still exposes its raw first message; explicit names, generated short titles, committed subjects, and later user renames remain authoritative. Its hooks expand the compact inputs into the stable visible title. They do not repair stopped turns. If the user stops a turn, the running title remains until the next real turn replaces it. diff --git a/assets/help.txt b/assets/help.txt index 60ccc89..241df2b 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -21,6 +21,7 @@ Install flags: Migration flags: --phase PHASE migration_running, migration_complete, or migration_failed --controller-task-id ID + --settled Confirm a failed wave has no unknown native calls Maintenance flags: --archive TASK_ID Stage or reconcile one eligible archive diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index eef247e..1a25206 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -32,12 +32,12 @@ Show a command before running it. Ask for explicit consent before any lifecycle 2. Run the exact dry run with `--control-task-id CURRENT_TASK_ID`. Explain the complete effect: adopting that task as the persistent home, the local binary, one small private state file, one managed AGENTS block, this skill, two hook entries, and one owned hourly Luna heartbeat. Explain that migration covers native-addressable local Codex tasks; older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and stay unchanged. 3. Show the recommended setup and include: “A small Luna helper checks in hourly, then stays quiet when there is nothing to do.”, “Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.”, and “ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat.” Ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. 4. Run the confirmed install with the same ID and verify `version`, `self-test`, and `inventory`. A fresh result is `migration_pending`: no controller has started and nothing is running yet. On reinstall, omit the flag only when `status --json` already reports the persisted main task; never replace it with the launching task. -5. Before any migration, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep that task selected. -6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If the call fails, report the partial install and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. +5. Before any migration, call `codex_app__set_thread_title` once for the initiating task with title exactly `🧵🐻 ThreadBear 🐻🧵` and no attempt suffix. Await the native result normally and require its exact task ID and exact title. This fixed, input-independent sentinel deliberately passes through without a staged proposal, so first installs and same-task reinstalls remain byte-exact even when Codex has not reloaded the newly written hook. On a failure or unknown result, report the partial install and stop; never retry. Then use `codex_app__set_thread_pinned` to pin the task and keep it selected. +6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” On creation omit `id`; Codex derives it from the exact name. Require the returned `automationId` to equal `threadbear-maintenance`. If it differs, delete only that returned ID, verify it is absent, report the partial install, and stop. A create request's status is not proof of persisted state: immediately update that exact returned ID with the complete expected fields and `status:"PAUSED"`, then verify the full exact match before creating the controller. For an existing exact match, likewise pause and verify it before migration. Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If creation, pause, or verification fails, delete a just-created automation only after an exact ownership read, verify it is absent, report the partial install, and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. 7. For an ordinary guided installation, never use visual inspection, computer control, screenshots, or Codex `/hooks`, and never ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate. Visual hook verification belongs only to **Debug canaries** when the install result explicitly contains `debug_canaries:true`. -8. Create exactly one background migration controller without opening, selecting, or navigating to it, then immediately record it with `migration --phase migration_running`. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave `migration_pending` unchanged and report that nothing is running plus the exact retry action. +8. Create exactly one projectless background migration controller with `codex_app__create_thread` using `model:"gpt-5.6-terra"` and `thinking:"medium"`, without opening, selecting, or navigating to it. Begin its delegated input with the exact sentence `ThreadBear controller registration.` Its required first current-task running-title call atomically registers the native runtime task ID, so retain the create result only as a supervision handle and never persist or compare it as controller identity. Require fresh status to reach `migration_running` with a nonempty controller ID before any migration work. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation or registration fails, leave `migration_pending` unchanged, stop the controller before it writes any target title, and report that nothing is running plus the exact retry action. 9. Give the controller the **Migration controller** protocol below. Supervise it from the persistent task with compact task waits, reporting only each 25-title milestone or phase change. Do not end the installation turn while durable status is `migration_pending` or `migration_running`. -10. After the controller returns, run `status --json` and `inventory --json`, then verify and activate the exact owned heartbeat with native automation controls. Claim success only at `migration_complete` with zero remaining native-addressable local rows and one active healthy maintenance automation, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. Leave the heartbeat paused at `migration_pending`, `migration_running`, or `migration_failed`. At `migration_failed`, say migration stopped and is not still working, report applied and remaining counts, name the cause, and give one exact resume action using the same controller ID. +10. The projectless controller performs native title work but does not run migration phase commands. After it returns, the home first runs fresh inventory and records `migration_complete` with the status-reported controller ID only at zero remaining with no unresolved call; otherwise it records `migration_failed`, adding `--settled` only when the controller's complete wave accounting proves every admitted native call returned a terminal result and omitting it for any timeout, unknown result, or interruption. Then run `status --json`, verify, and activate the exact owned heartbeat with native automation controls. Claim success only at `migration_complete` with zero remaining native-addressable local rows and one active healthy maintenance automation, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. Leave the heartbeat paused at `migration_pending`, `migration_running`, or `migration_failed`. At `migration_failed`, say migration stopped and is not still working, report applied and remaining counts, name the cause, and give one exact resume action using the same controller ID. For a large existing workspace, say this before migration: @@ -68,25 +68,18 @@ To change the quiet window, pass the requested positive `--archive-after-days N` ## Migration controller -The controller is the only installation-migration writer and is rerunnable under one persisted controller ID: +The controller is the only installation-migration title writer and is rerunnable under one persisted native runtime ID. Its first action after the required current-task title call is `status --json`; require `phase:"migration_running"` and `controller_task_id` equal to its supported current-task context before reading inventory. It never runs migration phase commands; the supervising home records the terminal phase. -1. Run `~/.local/bin/threadbear inventory --json` and use its `status`, `action`, `task_id`, and `applied` fields. The main and controller tasks are already excluded. If any unapplied deterministic rows exist, immediately process the first stable batch of at most 25 through the serial title path before resolving or spawning any Luna worker. Issue the first in-scope title mutation within 60 seconds of controller start and within 15 seconds of the inventory result. -2. Accept exact historical footers deterministically. Split only genuinely ambiguous rows into stable batches of at most 20 tasks. Workers classify and never write titles. When a completed classification remains ambiguous, call the native setter with title exactly `❔ ThreadBear could not classify`; never invent a compact unknown footer. -3. Only after the first deterministic batch has applied, or when no deterministic row exists, classify ambiguous batches with `codex_app__create_thread` using `model:"gpt-5.6-luna"`, `thinking:"medium"`, and a projectless background target. Do not inspect or compare alternative agent surfaces at runtime. Run adaptive waves of fresh, read-only Luna-medium workers, with at most eight workers active. After every successful spawn, immediately record its exact handle and assigned task IDs before attempting another spawn. At the first agent-capacity error, stop launching that wave. Never reinterpret that error as zero workers when earlier spawns succeeded. -4. Account for every retained worker even when results arrive out of order. Wait in bounded snapshots and give each worker eight minutes from spawn. When a worker completes, validate and record its result by handle, then process that batch in stable order through the serial title path while other read-only workers may continue. If a worker reaches its deadline, stop it, discard only that batch's uncommitted classifications, finish accounting for the rest of the wave, and retry the timed-out batch once in the next wave. A second timeout records `migration_failed`. -5. Process one native title write at a time. Convert each row's `status` and `action` to the exact compact ThreadBear footer grammar, using the exact unknown marker from step 2, then call the native setter with the explicit `threadId`. After the first fast batch, continue deterministic rows serially while Luna workers classify; never wait for ambiguous classification before exhausting deterministic progress. The Pre hook immediately re-reads the target and preserves any newer user rename; Post accepts only the exact target/title result and commits the proposal. Re-run inventory after each write or bounded batch and skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. -6. Do not start another wave or return while a retained worker is still active or unaccounted for. If zero workers can start, wait 30 seconds and retry for at most two minutes; then record `migration_failed`. Do not degrade unclassified rows to unknown merely because worker capacity is temporarily unavailable. -7. A timeout, unknown native result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Record `migration_failed` with the same controller ID before every non-successful return and leave the controller visible. Resume only after authoritative inventory establishes whether a prior native write applied. -8. Report progress after each 25 newly applied rows or phase change. Finish only when a final inventory reports zero remaining rows, then run: +1. Run `~/.local/bin/threadbear inventory --json` and use its `status`, `action`, `task_id`, and `applied` fields. The main and controller tasks are already excluded. If any unapplied deterministic rows exist, immediately start the first stable batch of at most 25 through the bounded title-wave path. Do not wait for that batch to reconcile before launching classifier workers: start the initial bounded worker-spawn wave concurrently with the first deterministic activation-and-setter wave from the same inventory snapshot. Worker setup must never delay the first in-scope title mutation, which is issued within 60 seconds of controller start and within 15 seconds of the inventory result. +2. Accept exact historical footers deterministically. Split only genuinely ambiguous rows into stable batches of at most 10 tasks. Derive each assigned list mechanically from the parsed inventory `task_id` fields in stable order; never retype, transform, or synthesize an ID, and before spawning assert that every assigned ID is an exact member of that same inventory snapshot. Workers classify and never write titles. Give each worker the exact assigned ID list and require its `phase:"final_answer"` item to contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block. Validate only that final-answer item; separate worker commentary is not part of the result grammar. A `wait_threads` snapshot may normalize the one separator newline before the footer into a space, so locate the JSON array's closing bracket and accept one or more whitespace characters followed by the exact required footer; do not require a physical newline. Ignore that required footer only while parsing the array; any other final-answer prose or malformed content invalidates the result. When a completed classification remains ambiguous, call the native setter with title exactly `❔ ThreadBear could not classify`; never invent a compact unknown footer. +3. Classify ambiguous batches with `codex_app__create_thread` using `model:"gpt-5.6-luna"`, `thinking:"low"`, and a projectless background target. Do not inspect or compare alternative agent surfaces at runtime. Launch the mechanically derived initial batches as one bounded concurrent wave of fresh, read-only Luna-low workers, with at most eight workers active, using settled results so one failed spawn cannot hide successful handles. Immediately record every successful handle with its assigned task IDs. Retained classifier worker IDs are installation internals: exclude them from every migration title wave. At an agent-capacity error, account for every earlier success and stop adding to that wave. Never reinterpret that error as zero workers when earlier spawns succeeded. +4. Account for every retained worker even when results arrive out of order. Wait in bounded snapshots and give each worker eight minutes from spawn. A `wait_threads` response with `timedOut:true` is only a normal polling snapshot and is never a worker timeout; continue polling every retained worker until it completes, needs attention, or reaches its own eight-minute deadline. As soon as workers complete, validate each exact assigned ID set and response grammar from step 2, record the results, and archive all currently validated workers together in one concurrent `Promise.all` call through their distinct native explicit targets; never serialize those archives. A missing, mismatched, or unconfirmed archive result fails closed. Immediately process those archived batches in stable order through bounded title waves while other read-only workers continue; never wait for every worker before applying an already validated and archived batch. If a worker reaches its deadline or returns malformed JSON, disallowed prose, a duplicate, an omission, or an unassigned ID, discard only that batch's uncommitted classifications, finish accounting for the rest of the wave, archive the accounted worker, and retry that read-only batch once in the next wave. A second invalid result or actual worker deadline reports failure and complete accounting to the home. +5. Process native title writes in bounded waves of at most eight distinct targets. Translate each classification to the native `title` exactly as follows: status `complete` requires `title:"🧵🐻 complete"`; status `automation` requires `title:"🧵🐻 automation"`; status `needs_input` requires `title:"🧵🐻 needs input (you): ACTION"`; status `blocked` requires `title:"🧵🐻 blocked (external): ACTION"`; status `next_steps` requires `title:"🧵🐻 next steps (agent): ACTION"`; a completed ambiguous classification requires the exact unknown marker from step 2. The status labels are classification names only and are never setter values: every footer setter value begins with the full literal `🧵🐻` mark, and bare inputs such as `complete`, `blocked`, or `next_steps` are invalid. Never prepend the visible status icon, insert the word `ThreadBear`, include the task subject, or pre-render the visible title; the Pre hook alone expands the compact input around the authoritative subject. Immediately before a title wave, call `codex_app__read_thread` concurrently for every target as a bounded read-only activation gate; require each response's exact task ID and the inventory-planned title, do not navigate or select, and begin the setter wave within 15 seconds. A failed or mismatched activation read is reconciled through fresh inventory under step 7 and is never followed by a blind write. Call the native setter once with that compact `title` and explicit `threadId`; never include the same target twice in a wave. Launch the wave concurrently and await every native result without a client-created `Promise.race` or other synthetic timeout. Only an explicit timeout from the native tool is a timeout. The trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so never compare the native return title with the compact input; require the exact returned task ID, treat an expected hook-expanded title as provisional success, and use fresh inventory as the authoritative applied result. Account for the whole wave before reconciling it with inventory, and do not start another write wave until every target is applied, is definitively inactive under step 7, or the migration has failed closed. Execute every ready stable queue in one orchestrated script loop: for each max-eight wave, concurrently gate, concurrently set, run fresh inventory, and validate before the loop advances; do not add commentary, model deliberation, or a separate outer tool round trip between settled waves. After the first fast batch, continue deterministic waves while Luna workers classify; never wait for ambiguous classification before exhausting deterministic progress. Each Pre hook briefly serializes target read-and-stage work, preserves any newer user rename, and releases before the native calls run; Post accepts only the exact target/title result and commits the per-target proposal. Re-run inventory after every write wave and skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. +6. Do not start another wave or return while a retained worker is still active or unaccounted for. If zero workers can start, wait 30 seconds and retry for at most two minutes; then report failure and complete accounting to the home. Do not degrade unclassified rows to unknown merely because worker capacity is temporarily unavailable. +7. After a write wave, an explicit native timeout, unknown result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Finish accounting for the wave. There is one narrow stale-snapshot exception: when the native result explicitly says a target is inactive or not found, immediately refresh authoritative inventory. Continue without that target only when its exact ID is absent from the refreshed inventory and every other target in the wave reconciles as applied; the task naturally left the addressable catalog and is not counted as applied. If that ID remains, the result is ambiguous, or any other failure occurred, fail closed and report the complete accounting to the home, including whether every admitted native call returned a terminal result; leave the controller visible. The failed phase denies every new title proposal, binding the home attestation to the complete existing pending set. On the next running transition, ThreadBear holds the title lock, commits an exact non-noop proposed title, clears an exact unchanged or disappeared proposal only after a settled attestation, and refuses every other drift. Unknown proposals remain pending for manual fail-closed recovery. +8. Report progress after each 25 newly applied rows or phase change. Finish only when a final inventory reports zero remaining rows, then report that exact result to the home. The home records `migration_complete` and archives the controller only after that command succeeds. A successful transition is the durable completion evidence. - ```sh - ~/.local/bin/threadbear migration --phase migration_complete \ - --controller-task-id CONTROLLER_TASK_ID --json - ``` - - Archive the controller only after that command succeeds. A successful transition is the durable completion evidence. - -The native setter has no compare-and-set argument. Do not claim it can prevent a rename that races the setter itself. If the controller is interrupted before it can record a terminal phase, the next `status --json` reconciles the definitively stopped task to `migration_failed`. +The native setter has no compare-and-set argument. Do not claim it can prevent a rename that races the setter itself. If the controller is interrupted before the home can record a terminal phase, the next `status --json` reconciles the definitively stopped task to `migration_failed`. ## Debug canaries @@ -108,7 +101,7 @@ Capture privacy-safe evidence when preparing a release. Title cleanup is an on-demand, idempotent control-task operation. It removes every consecutive leading ThreadBear status icon while preserving ordinary emoji and every remaining title byte. A later ordinary turn may add one current status icon again; cleanup prevents old decoration from becoming part of the durable subject. 1. Run `status --json`. For ordinary cleanup, verify this task's exact ID equals `main_task_id`; no other task may request it. During uninstall, the exact prepared uninstall task may request cleanup while its persisted operation is active. -2. Run `inventory --json`. Add the active persisted controller task, if any, to the target set; the inventory intentionally excludes it and the main task. +2. Run `inventory --json`. For a quiescent pre-controller `migration_pending` uninstall, do not clean inventory tasks because migration never owned them; inspect only the persistent home and continue at step 4 when it is decorated or its stripped title is the exact install sentinel. Otherwise add the active persisted controller task, if any, to the target set; the inventory intentionally excludes it and the main task. 3. Select every active title beginning with one or more exact ThreadBear status icons: `⏳`, `🚨`, `🙋`, `🤖`, `➡️`, `✅`, or `❔`. Ordinary leading emoji are not decoration. 4. In stable order, re-read one target and require its exact planned title. Call the native title setter with that explicit `threadId` and title exactly `🧵🐻 strip title icons`. The Pre hook re-reads the target, strips every leading ThreadBear status icon, uses `Untitled task` only when no subject remains, and stages the result through normal ownership state. Require the exact returned task ID/title and re-read the live title before continuing. Never retry an unknown result blindly. 5. Re-run the complete inventory plus controller read. Finish only when every selected title has no leading ThreadBear status icon and every native result reconciles. On drift, mismatch, or inaccessible state, stop with artifacts and private ownership state intact so the same control task can safely resume. @@ -119,13 +112,13 @@ For ordinary on-demand cleanup, do not target the active control task: its requi You can uninstall from any active native Codex task—even when the ThreadBear home is archived. Do not ask the user to open, select, navigate to, or unarchive the ThreadBear home. -1. Run `help`, `status --json`, `inventory --json`, and inspect the exact owned `threadbear-maintenance` heartbeat. Resolve the exact current task ID, `main_task_id`, and distinct `controller_task_id`. If migration or archive work is pending, reconcile it first. Refuse any automation ID/name/kind/target mismatch. +1. Run `help`, `status --json`, `inventory --json`, and inspect the exact owned `threadbear-maintenance` heartbeat. Resolve the exact current task ID, `main_task_id`, and distinct `controller_task_id`. A stopped `migration_failed` installation is uninstallable; running migration, pending archive work, and unsettled title work must still stop for reconciliation. Refuse any automation ID/name/kind/target mismatch. 2. Ask: “Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. If the ThreadBear home is archived, I'll briefly bring it out of the archive for cleanup and tuck it back exactly where it was. Your other archived tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. Should I go ahead?” Continue only after a clear yes. 3. Pause the exact owned heartbeat so it cannot race cleanup. Run `~/.local/bin/threadbear uninstall --prepare --initiator-task-id INITIATOR_ID --json`. Require the returned IDs to match the current task, persisted main task, and distinct controller snapshot. Re-running this command from the same initiating task resumes the same operation; a different task must stop. 4. If the returned original main state is archived, call `codex_app__set_thread_archived` once with that exact `threadId` and `archived:false`, then verify the task through `codex_app__read_thread` without opening it or sending it a turn. The prepared uninstall task remains the only writer. -5. Follow **Title cleanup** serially for every decorated active regular task and an active distinct controller. Re-read and clean `main_task_id` last through one explicit-target native call from the prepared uninstall task. Skip only a target whose authoritative title is already clean. Every mutation gets one four-second attempt, exact readback, and no blind retry after an unknown result. -6. Require the persisted main title to be clean. Restore its exact original archive state with one `codex_app__set_thread_archived` call and verify it through `codex_app__read_thread` before continuing. On a recoverable interruption, leave the automation and local artifacts intact; the same task resumes through step 3 without repeating settled mutations. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, verify `aborted:true`, and leave ThreadBear installed. Drifted or missing title targets are reported in `drifted_titles` and left user-owned instead of being rewritten. -7. Delete the exact owned heartbeat and verify it is absent. This is the commit boundary: make no later native task or automation mutation. Run `~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json`. It revalidates the clean main title and restored archive state, tolerates already-removed owned local entries, preserves unrelated config order, and removes the installed binary last. +5. Follow **Title cleanup** serially for every decorated active regular task and an active distinct controller, except that a quiescent pre-controller `migration_pending` uninstall leaves every inventory task untouched. Re-read `main_task_id` last and clean it through one explicit-target native call when it is decorated or its stripped title is the exact install sentinel. Every mutation gets one four-second attempt, exact readback, and no blind retry after an unknown result. +6. Require the persisted main title to be clean. Restore its exact original archive state with one `codex_app__set_thread_archived` call and verify it through `codex_app__read_thread` before continuing. On a recoverable interruption, leave the automation and local artifacts intact; the same task resumes through step 3 without repeating settled mutations. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, verify `aborted:true`, and leave ThreadBear installed. A missing, drifted, or no-op pending title remains fail-closed until its exact supported settlement; never clear or retry it from a single title snapshot. +7. Delete the exact owned heartbeat and verify it is absent. This is the commit boundary: make no later native task or automation mutation. Run `~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json`. It revalidates the clean main title and restored archive state, tolerates already-removed owned local entries, preserves unrelated config order and user-created files beside the managed skill, removes the skill directory only when empty, and removes the installed binary last. 8. From successful prepare through commit, make no running-title or terminal-title call and append no ThreadBear footer. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. Thank the user and invite optional feedback at `eric@litman.org`. Never remove artifacts before title cleanup has completed. diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index 7ab797a..026b80e 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -5,10 +5,13 @@ import ( "context" "database/sql" "encoding/json" + "errors" + "fmt" "os" "path/filepath" "strings" "testing" + "time" _ "modernc.org/sqlite" ) @@ -113,7 +116,7 @@ func TestRolloutFooterUsesLatestExactTerminalMessage(t *testing.T) { } } -func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { +func TestOrdinaryHooksRewriteVerifyAndBlockLostPost(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { @@ -142,7 +145,7 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { t.Fatalf("committed state = %#v", saved.Tasks["task"]) } - // A setter success with a lost Post remains provisional ownership on the next turn. + // A setter success with a lost Post remains the sole admitted proposal. pre = hookPayload("PreToolUse", "task", "call-2", map[string]any{"title": "🧵🐻 next steps (agent): finish the tests"}, nil) output.Reset() if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { @@ -154,11 +157,8 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { } pre = hookPayload("PreToolUse", "task", "call-3", map[string]any{"title": runningMarker + ": Changed model seed"}, nil) output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - if got := rewrittenTitle(t, output.Bytes()); got != "⏳ Stable subject" { - t.Fatalf("lost-Post recovery duplicated ownership: %q", got) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("lost-Post proposal was not kept fail-closed: %q, %v", output.String(), err) } } @@ -168,22 +168,23 @@ func TestPlainTitlePassThroughStagesAndSettles(t *testing.T) { if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { t.Fatal(err) } - pre := hookPayload("PreToolUse", "task", "plain", map[string]any{"title": "User rename"}, nil) + requested := "User⁣ rename" + pre := hookPayload("PreToolUse", "task", "plain", map[string]any{"title": requested}, nil) var output bytes.Buffer if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || output.Len() != 0 { t.Fatalf("plain Pre = %q, %v", output.String(), err) } saved, _ := newStore(stateDir()).read() - if pending := saved.Tasks["task"].Pending; pending == nil || pending.Proposed != "User rename" { + if pending := saved.Tasks["task"].Pending; pending == nil || pending.Prior != "Stable subject" || pending.Proposed != requested || pending.Attempt != "" { t.Fatalf("plain proposal = %#v", pending) } - response, _ := json.Marshal(map[string]string{"threadId": "task", "title": "User rename"}) - post := hookPayload("PostToolUse", "task", "plain", map[string]any{"title": "User rename"}, string(response)) + response, _ := json.Marshal(map[string]string{"threadId": "task", "title": requested}) + post := hookPayload("PostToolUse", "task", "plain", map[string]any{"title": requested}, string(response)) if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { t.Fatal(err) } saved, _ = newStore(stateDir()).read() - if got := saved.Tasks["task"]; got.Subject != "User rename" || got.Last != "User rename" || got.Pending != nil { + if got := saved.Tasks["task"]; got.Subject != "User⁣ rename" || got.Last != requested || got.Pending != nil { t.Fatalf("plain committed state = %#v", got) } } @@ -205,7 +206,7 @@ func BenchmarkOrdinaryPreToolUse(b *testing.B) { } } -func TestPreToolUseRefusesWhileTitleLifecycleIsLocked(t *testing.T) { +func TestPreToolUseWaitsWhileTitleLifecycleIsLocked(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { @@ -215,15 +216,227 @@ func TestPreToolUseRefusesWhileTitleLifecycleIsLocked(t *testing.T) { if err != nil { t.Fatal(err) } - defer unlock(lock) payload := hookPayload("PreToolUse", "task", "locked", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + type result struct { + output string + err error + } + done := make(chan result, 1) + go func() { + var output bytes.Buffer + err := hook(context.Background(), strings.NewReader(payload), &output) + done <- result{output: output.String(), err: err} + }() + select { + case got := <-done: + unlock(lock) + t.Fatalf("locked PreToolUse returned early: %q, %v", got.output, got.err) + case <-time.After(25 * time.Millisecond): + } + unlock(lock) + var got result + select { + case got = <-done: + case <-time.After(2 * time.Second): + t.Fatal("PreToolUse did not continue after title lock released") + } + if got.err != nil || !strings.Contains(got.output, `"permissionDecision":"allow"`) { + t.Fatalf("released PreToolUse = %q, %v", got.output, got.err) + } + saved, err := newStore(stateDir()).read() + if err != nil || saved.Tasks["task"].Pending == nil { + t.Fatalf("released PreToolUse did not stage state: %#v, %v", saved.Tasks["task"], err) + } +} + +func TestPreToolUseQueuedBehindTeardownCannotRecreateState(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + lock, err := newStore(stateDir()).titleLock() + if err != nil { + t.Fatal(err) + } + payload := hookPayload("PreToolUse", "task", "teardown", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + done := make(chan string, 1) + go func() { + var output bytes.Buffer + _ = hook(context.Background(), strings.NewReader(payload), &output) + done <- output.String() + }() + select { + case output := <-done: + unlock(lock) + t.Fatalf("queued PreToolUse returned before teardown: %q", output) + case <-time.After(25 * time.Millisecond): + } + if err := os.RemoveAll(stateDir()); err != nil { + unlock(lock) + t.Fatal(err) + } + unlock(lock) + select { + case output := <-done: + if !strings.Contains(output, `"permissionDecision":"deny"`) { + t.Fatalf("post-teardown PreToolUse = %q", output) + } + case <-time.After(2 * time.Second): + t.Fatal("post-teardown PreToolUse did not return") + } + if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("queued PreToolUse recreated state: %v", err) + } +} + +func TestPostToolUseQueuedBehindTeardownCannotRecreateState(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("locked PreToolUse = %q, %v", output.String(), err) + pre := hookPayload("PreToolUse", "task", "delayed-post", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + proposed := rewrittenTitle(t, output.Bytes()) + response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) + post := hookPayload("PostToolUse", "task", "delayed-post", map[string]any{"title": proposed}, string(response)) + lock, err := newStore(stateDir()).titleLock() + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}) }() + select { + case err := <-done: + unlock(lock) + t.Fatalf("queued PostToolUse returned before teardown: %v", err) + case <-time.After(25 * time.Millisecond): + } + if err := os.RemoveAll(stateDir()); err != nil { + unlock(lock) + t.Fatal(err) + } + unlock(lock) + select { + case err := <-done: + if err == nil { + t.Fatal("post-teardown PostToolUse unexpectedly succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("post-teardown PostToolUse did not return") + } + if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("queued PostToolUse recreated state: %v", err) + } +} + +func TestDeniedSecondExplicitCallCannotClearFirstProposal(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "target", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "controller", "failed-call", map[string]any{"threadId": "target", "title": "🧵🐻 complete"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + output.Reset() + second := hookPayload("PreToolUse", "controller", "denied-call", map[string]any{"threadId": "target", "title": "🧵🐻 next steps (agent): retry"}, nil) + if err := hook(context.Background(), strings.NewReader(second), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("second proposal = %q, %v", output.String(), err) } saved, err := newStore(stateDir()).read() - if err != nil || saved.Tasks["task"].Pending != nil { - t.Fatalf("locked PreToolUse staged state: %#v, %v", saved.Tasks["task"], err) + if pending := saved.Tasks["target"].Pending; err != nil || pending == nil || pending.ToolUseID != "failed-call" || pending.CallerTaskID != "controller" { + t.Fatalf("denied second call changed the first proposal: %#v, %v", saved.Tasks["target"], err) + } +} + +func TestConcurrentMigrationTitleWaveCommitsDistinctTargets(t *testing.T) { + root, db := testIndex(t) + const size = 8 + for i := range size { + id := fmt.Sprintf("target-%d", i) + addTask(t, db, root, id, "Subject "+id, nil, "vscode", 0) + } + if err := newStore(stateDir()).update(func(saved *state) (bool, error) { + saved.MainTaskID, saved.ControllerTaskID, saved.Phase = "main", "controller", phaseMigrationRunning + return true, nil + }); err != nil { + t.Fatal(err) + } + lock, err := newStore(stateDir()).titleLock() + if err != nil { + t.Fatal(err) + } + unlock(lock) + type result struct { + id, title, output string + err error + } + prepared := make(chan result, size) + for i := range size { + id := fmt.Sprintf("target-%d", i) + go func() { + var output bytes.Buffer + payload := hookPayload("PreToolUse", "controller", "call-"+id, map[string]any{"threadId": id, "title": "🧵🐻 complete"}, nil) + err := hook(context.Background(), strings.NewReader(payload), &output) + if err != nil { + prepared <- result{id: id, output: output.String(), err: err} + return + } + var value struct { + Hook struct { + Updated map[string]json.RawMessage `json:"updatedInput"` + } `json:"hookSpecificOutput"` + } + err = json.Unmarshal(output.Bytes(), &value) + var title string + if err == nil { + err = json.Unmarshal(value.Hook.Updated["title"], &title) + } + prepared <- result{id: id, title: title, output: output.String(), err: err} + }() + } + posts := make(chan result, size) + items := make([]result, 0, size) + for range size { + items = append(items, <-prepared) + } + for _, item := range items { + if item.err != nil || item.title != "✅ Subject "+item.id { + t.Fatalf("prepared %s = title %q, output %q, %v", item.id, item.title, item.output, item.err) + } + } + for _, item := range items { + go func() { + response, _ := json.Marshal(map[string]string{"threadId": item.id, "title": item.title}) + payload := hookPayload("PostToolUse", "controller", "call-"+item.id, map[string]any{"threadId": item.id, "title": item.title}, string(response)) + posts <- result{id: item.id, title: item.title, err: hook(context.Background(), strings.NewReader(payload), &bytes.Buffer{})} + }() + } + var postFailures []string + for range size { + if item := <-posts; item.err != nil { + postFailures = append(postFailures, fmt.Sprintf("%s: %v", item.id, item.err)) + } + } + if len(postFailures) > 0 { + t.Fatalf("commit failures: %s", strings.Join(postFailures, "; ")) + } + saved, err := newStore(stateDir()).read() + if err != nil { + t.Fatal(err) + } + for i := range size { + id := fmt.Sprintf("target-%d", i) + if got := saved.Tasks[id]; got.Pending != nil || got.Last != "✅ Subject "+id || got.Status != "complete" { + t.Fatalf("target state %s = %#v", id, got) + } } } @@ -322,16 +535,18 @@ func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { } var homeOutput bytes.Buffer homePre := hookPayload("PreToolUse", "task", "home", map[string]any{"title": homeTitle}, nil) - if err := hook(context.Background(), strings.NewReader(homePre), &homeOutput); err != nil || homeOutput.Len() != 0 { + if err := hook(context.Background(), strings.NewReader(homePre), &homeOutput); err != nil || rewrittenTitle(t, homeOutput.Bytes()) != homeTitle { t.Fatalf("persistent home title was not passed through: %q, %v", homeOutput.String(), err) } - homeState, _ := currentStateOrEmpty() - if homeState.Tasks["task"].Pending == nil { - t.Fatal("persistent home title was not staged") - } - if err := newStore(stateDir()).update(func(value *state) (bool, error) { delete(value.Tasks, "task"); return true, nil }); err != nil { + homeResponse, _ := json.Marshal(map[string]string{"threadId": "task", "title": homeTitle}) + homePost := hookPayload("PostToolUse", "task", "home", map[string]any{"title": homeTitle}, string(homeResponse)) + if err := hook(context.Background(), strings.NewReader(homePost), &bytes.Buffer{}); err != nil { t.Fatal(err) } + homeState, _ := currentStateOrEmpty() + if got := homeState.Tasks["task"]; len(homeState.Tasks) != 1 || got.Original != first || got.Subject != "" || got.Last != homeTitle || got.Pending != nil { + t.Fatalf("persistent home title did not retain its prior subject: %#v", homeState.Tasks) + } for _, marker := range []string{runningMarker, runningMarker + ":", runningMarker + ": ", runningMarker + ": bad spacing", runningMarker + ": " + strings.Repeat("x", 59), homeTitle + " extra"} { var output bytes.Buffer pre := hookPayload("PreToolUse", "task", marker, map[string]any{"title": marker}, nil) @@ -340,7 +555,7 @@ func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { } } stateAfter, err := currentStateOrEmpty() - if err != nil || len(stateAfter.Tasks) != 0 { + if err != nil || len(stateAfter.Tasks) != 1 { t.Fatalf("denied markers changed state: %#v, %v", stateAfter, err) } var output bytes.Buffer @@ -364,6 +579,26 @@ func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { } } +func TestRunningHomeSubjectStillStagesOwnership(t *testing.T) { + root, db := testIndex(t) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + addTask(t, db, root, "task", "fresh", nil, "vscode", 0) + if _, err := db.Exec(`UPDATE threads SET first_user_message='fresh' WHERE id='task'`); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "task", "running-home", map[string]any{"title": runningMarker + ": " + homeTitle}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != "⏳ "+homeTitle { + t.Fatalf("running home subject rewrite = %q, %v", output.String(), err) + } + saved, _ := currentStateOrEmpty() + if saved.Tasks["task"].Pending == nil { + t.Fatal("running home subject was not staged") + } +} + func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { root, db := testIndex(t) first := "Restarted task exposes this long raw first message before Codex restores the committed title." @@ -384,7 +619,7 @@ func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { if err := newStore(stateDir()).update(func(saved *state) (bool, error) { saved.Tasks["raw"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} saved.Tasks["truncated"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} - saved.Tasks["pending"] = taskState{Pending: &pendingProposal{BaseSubject: "Pending owner"}} + saved.Tasks["pending"] = taskState{Pending: &pendingProposal{BaseSubject: "Pending owner", Proposed: first}} return true, nil }); err != nil { t.Fatal(err) @@ -392,7 +627,6 @@ func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { for id, call := range map[string][2]string{ "raw": {runningMarker + ": Replacement seed", "⏳ Committed owner"}, "truncated": {"🧵🐻 complete", "✅ Committed owner"}, - "pending": {runningMarker + ": Replacement seed", "⏳ Pending owner"}, "renamed": {runningMarker + ": Replacement seed", "⏳ Manual user rename"}, "named": {runningMarker + ": Replacement seed", "⏳ Explicit name"}, } { @@ -402,6 +636,11 @@ func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { t.Fatalf("%s restart rewrite = %q, %v", id, output.String(), err) } } + var pendingOutput bytes.Buffer + pendingPre := hookPayload("PreToolUse", "pending", "call-pending", map[string]any{"title": runningMarker + ": Replacement seed"}, nil) + if err := hook(context.Background(), strings.NewReader(pendingPre), &pendingOutput); err != nil || !strings.Contains(pendingOutput.String(), `"permissionDecision":"deny"`) { + t.Fatalf("unsettled proposal was not kept fail-closed: %q, %v", pendingOutput.String(), err) + } for _, id := range []string{"fresh", "delegated"} { var output bytes.Buffer pre := hookPayload("PreToolUse", id, "terminal-"+id, map[string]any{"title": "🧵🐻 complete"}, nil) @@ -453,6 +692,69 @@ func TestRunningMigrationControllerOwnsHistoricalFirstMessage(t *testing.T) { } } +func TestPendingMigrationRegistersExactRuntimeControllerFromMarkedHomeDelegation(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) + addTask(t, db, root, "runtime-controller", "Migration controller", nil, "vscode", 0) + first := "\nmain\n" + controllerMarker + " Follow the migration protocol.\n" + if _, err := db.Exec(`UPDATE threads SET thread_source='subagent', first_user_message=? WHERE id='runtime-controller'`, first); err != nil { + t.Fatal(err) + } + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "runtime-controller", "register", map[string]any{"title": runningMarker + ": Migration controller"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("controller registration = %q, %v", output.String(), err) + } + saved, err := newStore(stateDir()).read() + if err != nil || saved.Phase != phaseMigrationRunning || saved.ControllerTaskID != "runtime-controller" { + t.Fatalf("registered controller state = %#v, %v", saved, err) + } +} + +func TestPendingMigrationRejectsUnmarkedControllerClaim(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) + addTask(t, db, root, "other", "Other task", nil, "vscode", 0) + if _, err := db.Exec(`UPDATE threads SET first_user_message=' main Other work' WHERE id='other'`); err != nil { + t.Fatal(err) + } + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + pre := hookPayload("PreToolUse", "other", "ordinary", map[string]any{"title": runningMarker + ": Other work"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + saved, _ := newStore(stateDir()).read() + if saved.Phase != phaseMigrationPending || saved.ControllerTaskID != "" { + t.Fatalf("unmarked task claimed controller: %#v", saved) + } +} + +func TestPendingMigrationRejectsMarkedOrdinaryTaskClaim(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) + addTask(t, db, root, "ordinary", "Ordinary task", nil, "vscode", 0) + first := "\nmain\n" + controllerMarker + " Follow the migration protocol.\n" + if _, err := db.Exec(`UPDATE threads SET thread_source='user', first_user_message=? WHERE id='ordinary'`, first); err != nil { + t.Fatal(err) + } + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + pre := hookPayload("PreToolUse", "ordinary", "forged", map[string]any{"title": runningMarker + ": Migration controller"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + saved, _ := newStore(stateDir()).read() + if saved.Phase != phaseMigrationPending || saved.ControllerTaskID != "" { + t.Fatalf("ordinary task claimed controller: %#v", saved) + } +} + func TestControlTaskCleanupStagesAndCommitsStrippedSubject(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "target", "✅ ✅ ❔ hello", nil, "vscode", 0) @@ -487,14 +789,13 @@ func TestControlTaskCleanupStagesAndCommitsStrippedSubject(t *testing.T) { } } -func TestControlTaskCleanupHandlesPendingIconOnlyAndLiteralEmoji(t *testing.T) { +func TestControlTaskCleanupHandlesIconOnlyAndLiteralEmoji(t *testing.T) { root, db := testIndex(t) for id, title := range map[string]string{"icons": "❔ ❔ ❔", "emoji": "🎉 ✅ user title", "main": "✅ ✅ ThreadBear"} { addTask(t, db, root, id, title, nil, "vscode", 0) } if err := newStore(stateDir()).update(func(saved *state) (bool, error) { saved.MainTaskID = "main" - saved.Tasks["icons"] = taskState{Pending: &pendingProposal{ToolUseID: "stale", Proposed: "❔ stale"}} return true, nil }); err != nil { t.Fatal(err) @@ -569,7 +870,7 @@ func TestBulkMarkerRereadsExplicitTargetAndAdoptsRename(t *testing.T) { t.Fatalf("bulk title = %q", desired) } saved, _ := newStore(stateDir()).read() - if saved.Tasks["target"].Pending.ToolUseID != "bulk-1" { + if pending := saved.Tasks["target"].Pending; pending.ToolUseID != "bulk-1" || pending.CallerTaskID != "installer" { t.Fatal("bulk proposal was not bound to native call") } response, _ := json.Marshal(map[string]string{"threadId": "target", "title": desired}) diff --git a/cmd/threadbear/hook.go b/cmd/threadbear/hook.go index 71870c3..4f808cc 100644 --- a/cmd/threadbear/hook.go +++ b/cmd/threadbear/hook.go @@ -10,8 +10,7 @@ import ( "strings" ) -const titleTool, runningMarker, homeTitle, cleanupMarker = "codex_appset_thread_title", "⏳ ThreadBear is working", "🧵🐻 ThreadBear 🐻🧵", "🧵🐻 strip title icons" -const unknownMarker, maxHookBytes = "❔ ThreadBear could not classify", 1 << 20 +const titleTool, runningMarker, homeTitle, cleanupMarker, unknownMarker, controllerMarker, maxHookBytes = "codex_appset_thread_title", "⏳ ThreadBear is working", "🧵🐻 ThreadBear 🐻🧵", "🧵🐻 strip title icons", "❔ ThreadBear could not classify", "ThreadBear controller registration.", 1 << 20 type hookInput struct { Event string `json:"hook_event_name"` @@ -43,10 +42,7 @@ func stringField(values map[string]json.RawMessage, key string, required bool) ( func titleTarget(event hookInput) (string, string, error) { title, titleErr := stringField(event.ToolInput, "title", true) target, targetErr := stringField(event.ToolInput, "threadId", false) - if target == "" { - target = event.SessionID - } - return title, target, errors.Join(titleErr, targetErr) + return title, cmp.Or(target, event.SessionID), errors.Join(titleErr, targetErr) } func hook(ctx context.Context, in io.Reader, out io.Writer) error { var event hookInput @@ -56,9 +52,21 @@ func hook(ctx context.Context, in io.Reader, out io.Writer) error { if event.ToolName != titleTool { return nil } + store := newStore(stateDir()) + titleLock, err := store.titleLock() + if err != nil { + return err + } + defer unlock(titleLock) + if _, err = store.read(); err != nil && event.Event != "PreToolUse" { + return err + } switch event.Event { case "PreToolUse": - if err := preTitle(ctx, event, out); err != nil { + if err == nil { + err = preTitle(ctx, event, out) + } + if err != nil { return json.NewEncoder(out).Encode(map[string]any{"hookSpecificOutput": map[string]any{ "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ThreadBear could not safely prepare this title: " + err.Error(), @@ -72,7 +80,8 @@ func hook(ctx context.Context, in io.Reader, out io.Writer) error { } } func preTitle(ctx context.Context, event hookInput, out io.Writer) error { - title, target, err := titleTarget(event) + raw, target, err := titleTarget(event) + title, attempt, tagged := strings.Cut(raw, "⁣") if err != nil { return err } @@ -87,15 +96,20 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { result, terminal = footer{Status: "unknown"}, true } else if title == cleanupMarker { result, terminal = footer{Status: "cleanup"}, true + } else if title == homeTitle { + terminal, seed = true, homeTitle } if !terminal { + if tagged { + title, attempt = raw, "" + } if title != homeTitle && (strings.HasPrefix(title, runningMarker) || strings.HasPrefix(title, "🧵🐻 ")) { return errors.New("invalid ThreadBear marker") } - _, err = stageTitle(ctx, target, "", "", title, event.SessionID, event.ToolUseID) + _, err = stageTitle(ctx, target, "", "", title, event.SessionID, event.ToolUseID, "") return err } - proposed, err := stageTitle(ctx, target, result.Status, result.Action, seed, event.SessionID, event.ToolUseID) + proposed, err := stageTitle(ctx, target, result.Status, result.Action, seed, event.SessionID, event.ToolUseID, attempt) if err != nil { return err } @@ -104,28 +118,33 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { "hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": event.ToolInput, }}) } -func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID string) (string, error) { - store := newStore(stateDir()) - titleLock, err := store.titleLock() - if err != nil { - return "", err - } - defer unlock(titleLock) - task, found := indexedTask{Title: seed, Name: seed}, true - if status != "" { - task, found, err = oneTask(ctx, id) +func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID, attempt string) (string, error) { + task, found, err := oneTask(ctx, id) + if status == "" { + known, ok, readErr := archiveTaskByID(ctx, id) + task, found, err = indexedTask{Title: known.Title}, ok, readErr } if err != nil || !found { return "", errors.Join(err, errors.New("task is not active in Codex")) } + first := strings.Join(strings.Fields(task.FirstMessage), " ") var proposed string - err = store.update(func(saved *state) (bool, error) { - if pending := saved.UninstallPending; pending != nil && (pending.InitiatorTaskID != caller || status != "cleanup") { - return false, errors.New("title changes are paused for the prepared uninstall task") + err = newStore(stateDir()).update(func(saved *state) (bool, error) { + if saved.Phase == phaseMigrationPending && saved.ControllerTaskID == "" && id == caller && status == "running" && task.ThreadSource == "subagent" && strings.HasPrefix(first, " "+saved.MainTaskID+" "+controllerMarker) { + saved.ControllerTaskID, saved.Phase = caller, phaseMigrationRunning + } + if pending := saved.UninstallPending; saved.Phase == phaseMigrationFailed && pending == nil || pending != nil && (pending.InitiatorTaskID != caller || status != "cleanup") { + return false, errors.New("title changes are paused for failed migration or prepared uninstall") } record := saved.Tasks[id] - current, first := strings.Join(strings.Fields(task.Title), " "), strings.Join(strings.Fields(task.FirstMessage), " ") + if record.Pending != nil { + return false, errors.New("native title operation is already pending") + } + current := strings.Join(strings.Fields(task.Title), " ") subject := canonicalSubject(task.Title, record) + if status == "" { + subject = strings.Join(strings.Fields(seed), " ") + } if record.Subject == "" && saved.Phase == phaseMigrationRunning && saved.ControllerTaskID == caller && caller != id { subject = stripStatusIcons(subject) } @@ -134,7 +153,7 @@ func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID if saved.MainTaskID != caller && !owner { return false, errors.New("title cleanup requires the ThreadBear control task") } - subject = cmp.Or(stripStatusIcons(task.Title), "Untitled task") + subject = cmp.Or(map[bool]string{true: record.Original}[stripStatusIcons(task.Title) == homeTitle], stripStatusIcons(task.Title), "Untitled task") } else if status != "" && task.Name == "" && first != "" && (current == first || current == truncateUTF16(first, 60)) { subject = record.Subject if record.Pending != nil && record.Pending.BaseSubject != "" { @@ -151,7 +170,12 @@ func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID } } proposed = map[bool]string{true: seed, false: renderTitle(status, subject, action)}[status == ""] - record.Pending = &pendingProposal{ToolUseID: toolUseID, BaseSubject: subject, Prior: task.Title, Proposed: proposed, Status: status, Action: action} + if status == "" && seed == homeTitle && attempt == "" { + record.Original, record.Last = cmp.Or(record.Original, stripStatusIcons(current)), homeTitle + saved.Tasks[id] = record + return true, nil + } + record.Pending = &pendingProposal{CallerTaskID: caller, ToolUseID: toolUseID, BaseSubject: subject, Prior: task.Title, Proposed: proposed, Status: status, Action: action, Attempt: attempt} saved.Tasks[id] = record return true, nil }) @@ -168,15 +192,11 @@ func postTitle(event hookInput) error { return false, nil } pending := record.Pending - if pending.ToolUseID != event.ToolUseID || pending.Proposed != title { + if pending.ToolUseID != event.ToolUseID || pending.CallerTaskID != "" && pending.CallerTaskID != event.SessionID || pending.Proposed != title { return false, errors.New("native title call does not match its proposal") } - 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 { + result, encoded := map[string]string{}, "" + if json.Unmarshal(event.ToolResponse, &encoded) != nil || json.Unmarshal([]byte(encoded), &result) != nil || len(result) != 2 || result["threadId"] != target || result["title"] != title { return false, errors.New("native title result mismatch") } record.Subject, record.Last, record.Status, record.Action, record.Pending = pending.BaseSubject, pending.Proposed, pending.Status, pending.Action, nil diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 768e6e6..bd10b90 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -1,16 +1,19 @@ package main import ( + "cmp" "context" "encoding/json" "errors" "fmt" "github.com/ericlitman/threadbear/assets" + "maps" "os" "path/filepath" "runtime" "slices" "strings" + "syscall" ) const blockStart, blockEnd, managedHeading, managedProtocol = "", "", "# ThreadBear", "For every ordinary interactive turn" @@ -18,14 +21,12 @@ const blockStart, blockEnd, managedHeading, managedProtocol = "