diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 40cde87..54afd9a 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -53,7 +53,7 @@ jobs: echo "- Release: \`$RELEASE_TAG\`" echo "- Result: \`$SMOKE_OUTCOME\`" echo "- Runner architecture: \`$(uname -m)\`" - echo "- Proved: live release manifest, checksum, candidate self-test, binary install, native PreToolUse/PostToolUse title finalization against an unarchived task and settled rollout, committed state, status, inventory convergence, control-task title cleanup, and uninstall." + echo "- Proved: live release manifest, checksum, candidate self-test, binary install, native PreToolUse/PostToolUse title finalization, settled state and inventory, prepared active-task uninstall, fixture-simulated archived-control cleanup/restoration, completed-controller preservation, and binary-last teardown. Real native archive control remains a separate release canary." echo "- Not proved: real Codex auth, rendered Desktop titles, Luna behavior, or architectures other than this runner." echo "- Deployment timing: a Pages/CDN lag can make the live bootstrap older than the release commit; that red result still requires operator investigation." echo "- A red result marks the published release for operator action; this workflow does not delete, demote, or retry a release." diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d1688..b78a205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### 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. + ## v2.2.0 - 2026-08-03 ### Added diff --git a/INSTALL.md b/INSTALL.md index cf12c03..60915ab 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -173,19 +173,29 @@ The installed binary's help is the authoritative public command list. Run `updat ## Uninstall -Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, then ask: +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. -> 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. Tasks already tucked into the archive will stay there, and your other 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. +Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, resolve this initiating task's exact ID plus the persisted main and distinct controller IDs, inspect the exact owned automation, then ask: + +> Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. If the ThreadBear home is archived, I'll briefly bring it out for cleanup and tuck it back exactly where it was. Your other archived tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. > > Should I go ahead? -Pause the exact owned `threadbear-maintenance` heartbeat before title cleanup; refuse an ID, kind, name, or target mismatch. Title cleanup must finish through serial explicit native target calls with exact returned IDs and titles. Clean the persistent ThreadBear task last, delete the exact owned automation, then show and run: +After consent, pause the exact owned `threadbear-maintenance` heartbeat; refuse an ID, kind, name, or target mismatch. Prepare the durable operation before changing a title or archive state: + +```sh +~/.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. + +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: ```sh -~/.local/bin/threadbear uninstall --noninteractive --confirm --json +~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json ``` -Uninstall refuses while `phase:migration_running` or a native archive operation is pending; reconcile it and stop the controller first. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content, hook order, unrelated automations, and the archived state of already archived tasks. 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. +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. ## Maintainer verification diff --git a/README.md b/README.md index 493c9fe..311822b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ 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 always pauses that helper and completes title cleanup before removing ThreadBear's local state, hooks, and owned automation. +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. ## Boundaries diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index 59baf2b..0fb12df 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -36,4 +36,4 @@ Use the matching literal example as the footer's shape: The footer must be the final non-empty line. Never write the literal placeholder words `STATUS`, `OWNER`, or `ACTION`. Report the turn's actual disposition. Use `complete` unless the substantive response already establishes one clear, concrete, warranted next step. Generic offers, speculative possibilities, and mentions of recorded work do not qualify. -A confirmed uninstall turn is the sole exception to the terminal-title call and footer. After the control task has stripped title icons from every active task, stripped its own title last, and the uninstall command has removed the managed hooks and guidance, respond without another title call or ThreadBear footer so the clean control-task title is not decorated again. +A prepared uninstall suspends this turn protocol from the moment `uninstall --prepare` succeeds until that operation is aborted or committed: make no running-title or footer call while it is pending. After commit has removed the managed hooks and guidance, respond without another title call or ThreadBear footer so the clean control-task title is not decorated again. After abort, ordinary title calls resume on the next turn. diff --git a/assets/help.txt b/assets/help.txt index c1e0409..60ccc89 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -27,3 +27,8 @@ Maintenance flags: --restore TASK_ID Stage or reconcile one ThreadBear-owned restore --cancel TASK_ID Clear one known-unapplied pending operation --archive-after-days N Quiet completed days required (default 14) + +Uninstall flags: + --prepare Persist the active initiating task and original home state + --abort Abandon a prepared uninstall after restoring the home archive state + --initiator-task-id ID Active task that owns preparation, cleanup, and commit diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 636b174..eef247e 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -107,7 +107,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` and verify this task's exact ID equals `main_task_id`. If it does not, continue in the persisted ThreadBear task; no other task may request cleanup. +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. 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. @@ -117,11 +117,15 @@ For ordinary on-demand cleanup, do not target the active control task: its requi ## Uninstall -1. Run `help`, `status --json`, `inventory --json`, and inspect the exact owned `threadbear-maintenance` heartbeat. If migration or archive work is pending, reconcile it first. 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. Tasks already tucked into the archive will stay there, and your other 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. -4. Follow **Title cleanup** for every regular and controller task. Re-read and clean this persisted ThreadBear task last. Any drift, unknown result, or remaining decorated title stops uninstall before artifacts or ownership state are removed. -5. Delete the exact owned heartbeat, then run the confirmed uninstall. It refuses while migration or archive mutation is pending, preserves unrelated automation/config order, and leaves already archived tasks archived while removing ThreadBear's local ownership ledger. -6. Use the managed uninstall-turn exception: make no terminal title call and append no ThreadBear footer. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance. +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. +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. +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 539e64b..7ab797a 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -162,6 +162,32 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { } } +func TestPlainTitlePassThroughStagesAndSettles(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + pre := hookPayload("PreToolUse", "task", "plain", map[string]any{"title": "User rename"}, 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" { + 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)) + 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 { + t.Fatalf("plain committed state = %#v", got) + } +} + func BenchmarkOrdinaryPreToolUse(b *testing.B) { root, db := testIndex(b) addTask(b, db, root, "task", "Stable subject", nil, "vscode", 0) @@ -179,6 +205,46 @@ func BenchmarkOrdinaryPreToolUse(b *testing.B) { } } +func TestPreToolUseRefusesWhileTitleLifecycleIsLocked(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) + } + defer unlock(lock) + payload := hookPayload("PreToolUse", "task", "locked", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + var output bytes.Buffer + if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("locked PreToolUse = %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) + } +} + +func TestPreToolUseContinuesWhileMaintenanceOperationIsLocked(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + lock, err := newStore(stateDir()).operationLock() + if err != nil { + t.Fatal(err) + } + defer unlock(lock) + payload := hookPayload("PreToolUse", "task", "update-overlap", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + var output bytes.Buffer + if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"allow"`) { + t.Fatalf("operation-overlap PreToolUse = %q, %v", output.String(), err) + } +} + func BenchmarkOrdinaryPostToolUse(b *testing.B) { root, db := testIndex(b) addTask(b, db, root, "task", "Stable subject", nil, "vscode", 0) @@ -209,6 +275,9 @@ func BenchmarkOrdinaryPostToolUse(b *testing.B) { func TestFreshRunningSubjectSeedClosesFirstTitleRace(t *testing.T) { root, db := testIndex(t) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } first := "Fix the login redirect. First call the title tool, then inspect the failure." addTask(t, db, root, "raw", first, nil, "vscode", 0) addTask(t, db, root, "short", "Fix login redirect", nil, "vscode", 0) @@ -235,6 +304,9 @@ func TestFreshRunningSubjectSeedClosesFirstTitleRace(t *testing.T) { func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { root, db := testIndex(t) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } first := "Investigate the first title race and preserve the stable subject." addTask(t, db, root, "task", first, nil, "vscode", 0) if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='task'`, first); err != nil { @@ -253,6 +325,13 @@ func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { if err := hook(context.Background(), strings.NewReader(homePre), &homeOutput); err != nil || homeOutput.Len() != 0 { 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 { + t.Fatal(err) + } for _, marker := range []string{runningMarker, runningMarker + ":", runningMarker + ": ", runningMarker + ": bad spacing", runningMarker + ": " + strings.Repeat("x", 59), homeTitle + " extra"} { var output bytes.Buffer pre := hookPayload("PreToolUse", "task", marker, map[string]any{"title": marker}, nil) diff --git a/cmd/threadbear/hook.go b/cmd/threadbear/hook.go index d41e5cc..71870c3 100644 --- a/cmd/threadbear/hook.go +++ b/cmd/threadbear/hook.go @@ -76,9 +76,6 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { if err != nil { return err } - if title == homeTitle { - return nil - } result, terminal := parseFooter(title) seed, seeded := strings.CutPrefix(title, runningMarker+": ") if seeded && (seed == "" || seed != strings.Join(strings.Fields(seed), " ") || seed != truncateUTF16(seed, 58)) { @@ -92,10 +89,11 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { result, terminal = footer{Status: "cleanup"}, true } if !terminal { - if strings.HasPrefix(title, runningMarker) || strings.HasPrefix(title, "🧵🐻 ") { + if title != homeTitle && (strings.HasPrefix(title, runningMarker) || strings.HasPrefix(title, "🧵🐻 ")) { return errors.New("invalid ThreadBear marker") } - return nil + _, 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) if err != nil { @@ -107,12 +105,24 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { }}) } func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID string) (string, error) { - task, found, err := oneTask(ctx, id) + 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) + } if err != nil || !found { return "", errors.Join(err, errors.New("task is not active in Codex")) } var proposed string - err = newStore(stateDir()).update(func(saved *state) (bool, error) { + 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") + } record := saved.Tasks[id] current, first := strings.Join(strings.Fields(task.Title), " "), strings.Join(strings.Fields(task.FirstMessage), " ") subject := canonicalSubject(task.Title, record) @@ -120,11 +130,12 @@ func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID subject = stripStatusIcons(subject) } if status == "cleanup" { - if saved.MainTaskID != caller { + owner := saved.UninstallPending != nil && saved.UninstallPending.InitiatorTaskID == caller + if saved.MainTaskID != caller && !owner { return false, errors.New("title cleanup requires the ThreadBear control task") } subject = cmp.Or(stripStatusIcons(task.Title), "Untitled task") - } else if task.Name == "" && first != "" && (current == first || current == truncateUTF16(first, 60)) { + } else if status != "" && task.Name == "" && first != "" && (current == first || current == truncateUTF16(first, 60)) { subject = record.Subject if record.Pending != nil && record.Pending.BaseSubject != "" { subject = record.Pending.BaseSubject @@ -139,7 +150,7 @@ func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID return false, errors.New("fresh task has no subject owner") } } - proposed = renderTitle(status, subject, action) + 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} saved.Tasks[id] = record return true, nil diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 0608046..768e6e6 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -13,7 +13,7 @@ import ( "strings" ) -const blockStart, blockEnd = "", "" +const blockStart, blockEnd, managedHeading, managedProtocol = "", "", "# ThreadBear", "For every ordinary interactive turn" type lifecyclePaths struct{ binary, agents, skill, hooks string } type rawObject map[string]json.RawMessage @@ -102,24 +102,161 @@ func install(controlTaskID string, dry, confirmed, debugCanaries bool) (any, err } return result, err } -func uninstall(ctx context.Context, confirmed bool) (any, error) { - if !confirmed { +func prepareUninstall(ctx context.Context, initiatorTaskID string) (any, error) { + if initiatorTaskID = strings.TrimSpace(initiatorTaskID); initiatorTaskID == "" { + return nil, errors.New("uninstall prepare requires the active initiating task ID") + } + return withUninstallLocks(func() (any, error) { + value, err := newStore(stateDir()).read() + if err != nil { + return nil, err + } + if value.Phase != phaseMigrationComplete || value.ArchivePending != nil { + return nil, errors.New("uninstall requires a completed installation with no pending archive") + } + initiator, found, err := archiveTaskByID(ctx, initiatorTaskID) + if err != nil || !found || !initiator.User || !initiator.Visible || initiator.Archived { + return nil, errors.Join(err, errors.New("uninstall initiator is not an active user task in Codex")) + } + if pending := value.UninstallPending; pending != nil { + if pending.InitiatorTaskID != initiatorTaskID || pending.MainTaskID != value.MainTaskID || pending.ControllerTaskID != value.ControllerTaskID { + return nil, errors.New("uninstall is already owned by another task or installation identity") + } + reconciled, drifted, err := reconcileUninstallTitles(ctx) + if err != nil { + return nil, err + } + return uninstallPreparationResult(pending, true, reconciled, drifted), nil + } + for _, record := range value.Tasks { + if record.Pending != nil { + return nil, errors.New("cannot prepare uninstall while a native title operation is pending; reconcile it first") + } + } + main, found, err := archiveTaskByID(ctx, value.MainTaskID) + if err != nil || !found || !main.User { + return nil, errors.Join(err, errors.New("persisted ThreadBear control task is not available in Codex")) + } + pending := &uninstallOperation{InitiatorTaskID: initiatorTaskID, MainTaskID: value.MainTaskID, MainArchived: main.Archived, ControllerTaskID: value.ControllerTaskID} + err = newStore(stateDir()).update(func(saved *state) (bool, error) { + saved.UninstallPending = pending + return true, nil + }) + if err != nil { + return nil, err + } + return uninstallPreparationResult(pending, false, 0, 0), nil + }) +} +func uninstallPreparationResult(pending *uninstallOperation, resumed bool, reconciled, drifted int) any { + return map[string]any{"ready": true, "prepared": true, "resumed": resumed, "reconciled_titles": reconciled, "drifted_titles": drifted, "initiator_task_id": pending.InitiatorTaskID, "main_task_id": pending.MainTaskID, "main_archived": pending.MainArchived, "controller_task_id": pending.ControllerTaskID} +} +func reconcileUninstallTitles(ctx context.Context) (count, drifted int, err error) { + err = newStore(stateDir()).update(func(value *state) (bool, error) { + for id, record := range value.Tasks { + pending := record.Pending + if pending == nil { + continue + } + task, found, readErr := archiveTaskByID(ctx, id) + if readErr != nil { + return false, readErr + } + if !found || !task.User || !task.Visible || task.Title != pending.Proposed && task.Title != pending.Prior { + drifted++ + } else { + if task.Title == pending.Proposed { + record.Subject, record.Last, record.Status, record.Action = pending.BaseSubject, pending.Proposed, pending.Status, pending.Action + } + count++ + } + record.Pending = nil + value.Tasks[id] = record + } + return count+drifted > 0, nil + }) + return count, drifted, err +} +func completeUninstall(ctx context.Context, initiatorTaskID string, confirmed, abort bool) (any, error) { + if !confirmed && !abort { return nil, errors.New("uninstall requires --noninteractive --confirm") } - operationLock, err := newStore(stateDir()).blockingOperationLock() + if initiatorTaskID = strings.TrimSpace(initiatorTaskID); initiatorTaskID == "" { + return nil, errors.New("uninstall requires the initiating task ID") + } + if !abort { + if committed, err := finishCommittedUninstall(); err != nil || committed { + return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err + } + } + return withUninstallLocks(func() (any, error) { + value, err := newStore(stateDir()).read() + if err != nil { + return nil, err + } + pending := value.UninstallPending + if pending == nil || pending.InitiatorTaskID != initiatorTaskID || pending.MainTaskID != value.MainTaskID || pending.ControllerTaskID != value.ControllerTaskID { + return nil, errors.New("uninstall commit requires the exact prepared initiating task") + } + main, found, err := archiveTaskByID(ctx, pending.MainTaskID) + if err != nil || !found || !main.User { + return nil, errors.Join(err, errors.New("persisted ThreadBear control task is not available in Codex")) + } + if main.Archived != pending.MainArchived { + return nil, errors.New("control task archive state must be restored before uninstall completion") + } + if abort { + err = newStore(stateDir()).update(func(saved *state) (bool, error) { + for id, record := range saved.Tasks { + record.Pending = nil + saved.Tasks[id] = record + } + saved.UninstallPending = nil + return true, nil + }) + return map[string]any{"ready": err == nil, "aborted": err == nil, "main_archived": pending.MainArchived}, err + } + if stripStatusIcons(main.Title) != main.Title { + return nil, errors.New("uninstall requires title cleanup from the ThreadBear control task") + } + return uninstallLocked(ctx, value) + }) +} +func withUninstallLocks(action func() (any, error)) (any, error) { + store := newStore(stateDir()) + operationLock, err := store.operationLock() if err != nil { return nil, err } defer unlock(operationLock) - value, err := currentStateOrEmpty() + titleLock, err := store.titleLock() if err != nil { return nil, err } - if value.Phase == phaseMigrationRunning { - return nil, errors.New("cannot uninstall while installation migration is running; stop the controller first") + defer unlock(titleLock) + return action() +} +func finishCommittedUninstall() (bool, error) { + p := installPaths() + if _, err := os.Stat(newStore(stateDir()).path()); !errors.Is(err, os.ErrNotExist) { + return false, err + } + _, skillErr := os.Stat(p.skill) + agents, agentsErr := os.ReadFile(p.agents) + _, hooksChanged, hooksErr := editHooks(p.hooks, p.binary, false) + if !errors.Is(skillErr, os.ErrNotExist) || agentsErr == nil && (strings.Contains(string(agents), blockStart) || strings.Contains(string(agents), blockEnd) || strings.Contains(string(agents), managedHeading) || strings.Contains(string(agents), managedProtocol)) || agentsErr != nil && !errors.Is(agentsErr, os.ErrNotExist) || hooksErr != nil || hooksChanged { + return false, errors.Join(agentsErr, hooksErr, errors.New("uninstall state is missing before local artifacts were settled")) + } + if err := os.RemoveAll(stateDir()); err != nil { + return false, err } - if value.ArchivePending != nil { - return nil, errors.New("cannot uninstall while a native archive operation is pending; reconcile it first") + return true, removeFiles(p.binary) +} +func uninstallLocked(ctx context.Context, value state) (any, error) { + for _, record := range value.Tasks { + if record.Pending != nil { + return nil, errors.New("cannot uninstall while a native title operation is pending; reconcile it first") + } } if value.MainTaskID != "" { tasks, scanErr := inventory(ctx) @@ -147,11 +284,14 @@ func uninstall(ctx context.Context, confirmed bool) (any, error) { } } if err == nil { - err = removeFiles(p.binary, p.skill) + err = removeFiles(p.skill) } if err == nil { err = os.RemoveAll(stateDir()) } + if err == nil { + err = removeFiles(p.binary) + } return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err } func status(ctx context.Context) (any, error) { @@ -167,7 +307,7 @@ func status(ctx context.Context) (any, error) { } value, stateErr := reconcileMigration(ctx) err = errors.Join(err, validateFile(p.skill, assets.SkillManagedContent), validateFile(p.agents, managedBlock()), stateErr) - result := map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "maintenance_automation_id": maintenanceAutomationID, "archive_pending": value.ArchivePending != nil, "owned_archives": len(value.Archives)} + result := map[string]any{"ready": err == nil && value.Phase == phaseMigrationComplete && value.MainTaskID != "", "installed": err == nil, "version": version, "phase": value.Phase, "main_task_id": value.MainTaskID, "controller_task_id": value.ControllerTaskID, "maintenance_automation_id": maintenanceAutomationID, "archive_pending": value.ArchivePending != nil, "uninstall_pending": value.UninstallPending != nil, "owned_archives": len(value.Archives)} if value.MigrationFailure != "" { result["migration_failure"] = value.MigrationFailure result["next_action"] = "resume migration from the ThreadBear task" @@ -259,7 +399,7 @@ func validateFile(path, content string) error { return err } func managedBlock() string { - return strings.Join([]string{blockStart, strings.TrimSpace(assets.AgentsManagedContent), blockEnd}, "\n") + return blockStart + "\n" + strings.TrimSpace(assets.AgentsManagedContent) + "\n" + blockEnd } func manageBlock(path, content string) error { data, err := os.ReadFile(path) @@ -274,6 +414,12 @@ func manageBlock(path, content string) error { if strings.Count(text, blockStart) > 1 || strings.Count(text, blockEnd) > 1 || start < 0 != (end < 0) || end >= 0 && end < start { return errors.New("invalid ThreadBear managed block") } + if content == "" && (start < 0 && (strings.Contains(text, managedHeading) || strings.Contains(text, managedProtocol)) || start >= 0 && !strings.Contains(text, managedBlock())) { + return errors.New("managed file was modified: " + path) + } + if content == "" && start < 0 { + return nil + } if content != "" { block := managedBlock() if start >= 0 { diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 1992117..e630aad 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -140,7 +140,7 @@ func TestUninstallWaitsForOperationLockBeforeDeleting(t *testing.T) { func TestOperationLockDoesNotRecreateRemovedInstallation(t *testing.T) { dir := filepath.Join(t.TempDir(), "state") store := newStore(dir) - operationLock, err := store.blockingOperationLock() + operationLock, err := store.waitLock() if err != nil { t.Fatal(err) } @@ -199,6 +199,87 @@ func TestStatusRejectsModifiedManagedGuidance(t *testing.T) { } } +func TestUninstallRejectsModifiedManagedGuidanceBeforeMutation(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + agents, _ := os.ReadFile(p.agents) + hooks, _ := os.ReadFile(p.hooks) + mustWrite(t, p.agents, strings.Replace(string(agents), "# ThreadBear", "# ThreadBear edited", 1)) + if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "managed file was modified") { + t.Fatalf("modified guidance uninstall = %v", err) + } + if got, _ := os.ReadFile(p.hooks); !reflect.DeepEqual(got, hooks) { + t.Fatal("blocked uninstall changed hooks") + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("blocked uninstall removed binary: %v", err) + } +} + +func TestUninstallRejectsMarkerlessManagedGuidance(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + agents, _ := os.ReadFile(p.agents) + markerless := strings.ReplaceAll(strings.ReplaceAll(string(agents), blockStart, ""), blockEnd, "") + markerless = strings.Replace(markerless, "The footer must be", "The footer remains", 1) + mustWrite(t, p.agents, markerless) + if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "managed file was modified") { + t.Fatalf("markerless guidance uninstall = %v", err) + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("blocked uninstall removed binary: %v", err) + } +} + +func TestUninstallKeepsBinaryUntilStateRemovalCommits(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + stateParent := filepath.Dir(stateDir()) + if err := os.Chmod(stateParent, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(stateParent, 0o700) }) + if _, err := uninstall(context.Background(), true); err == nil { + t.Fatal("uninstall succeeded while state directory could not be removed") + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("failed state removal deleted retry binary: %v", err) + } + if err := os.Chmod(stateParent, 0o700); err != nil { + t.Fatal(err) + } + if _, err := uninstall(context.Background(), true); err != nil { + t.Fatalf("resumed teardown: %v", err) + } + if _, err := os.Stat(p.binary); !os.IsNotExist(err) { + t.Fatalf("resumed teardown left binary: %v", err) + } +} + func TestMalformedHooksFailBeforeLifecycleMutation(t *testing.T) { p := isolatedLifecycle(t) malformed := []byte(`{"hooks":{"PreToolUse":{"not":"an array"}}}`) diff --git a/cmd/threadbear/lifecycle_test.go b/cmd/threadbear/lifecycle_test.go index a1159e3..e6b9877 100644 --- a/cmd/threadbear/lifecycle_test.go +++ b/cmd/threadbear/lifecycle_test.go @@ -199,3 +199,31 @@ func TestUninstallRefusesDecoratedActiveTitles(t *testing.T) { t.Fatal(err) } } + +func TestUninstallRefusesDecoratedArchivedMainAndIgnoresArchivedController(t *testing.T) { + root, db := testIndex(t) + p := installPaths() + addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) + addTask(t, db, root, "controller", "⏳ Completed controller", nil, "vscode", 1) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.ControllerTaskID, value.Phase = "controller", phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := uninstall(context.Background(), true); err == nil || !strings.Contains(err.Error(), "requires title cleanup") { + t.Fatalf("decorated archived main uninstall = %v", err) + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("blocked uninstall removed the binary: %v", err) + } + if _, err := db.Exec(`UPDATE threads SET title='Control task' WHERE id='main'`); err != nil { + t.Fatal(err) + } + if _, err := uninstall(context.Background(), true); err != nil { + t.Fatalf("clean archived main with distinct archived controller: %v", err) + } +} diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index 570d602..53e011c 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -76,9 +76,23 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. flags.Bool("candidate", false, "validate this binary before installation") action = selfTest case "uninstall": + prepare := flags.Bool("prepare", false, "persist the initiating task and original control-task state") + abort := flags.Bool("abort", false, "abandon the prepared uninstall after restoring the control-task archive state") + initiatorTaskID := flags.String("initiator-task-id", "", "active task that owns this uninstall operation") noninteractive := flags.Bool("noninteractive", false, "run without prompts") confirm := flags.Bool("confirm", false, "confirm the previewed uninstall") - action = func() (any, error) { return uninstall(ctx, *noninteractive && *confirm) } + action = func() (any, error) { + switch { + case *prepare && *abort: + return nil, errors.New("uninstall accepts only one of --prepare or --abort") + case *prepare: + return prepareUninstall(ctx, *initiatorTaskID) + case *abort: + return completeUninstall(ctx, *initiatorTaskID, false, true) + default: + return completeUninstall(ctx, *initiatorTaskID, *noninteractive && *confirm, false) + } + } case "version": action = func() (any, error) { return map[string]any{"version": version}, nil } default: diff --git a/cmd/threadbear/maintenance.go b/cmd/threadbear/maintenance.go index 49a37ed..d5d9d31 100644 --- a/cmd/threadbear/maintenance.go +++ b/cmd/threadbear/maintenance.go @@ -41,7 +41,6 @@ func archiveTasks(ctx context.Context) ([]archiveTask, error) { } return tasks, rows.Err() } - func archiveTaskByID(ctx context.Context, id string) (archiveTask, bool, error) { db, err := openIndex() if err != nil { @@ -57,7 +56,6 @@ func archiveTaskByID(ctx context.Context, id string) (archiveTask, bool, error) } return task, err == nil, err } - func archiveSnapshot(task archiveTask, value *state) (string, bool) { if !task.User { return "", false @@ -74,7 +72,6 @@ func archiveSnapshot(task archiveTask, value *state) (string, bool) { } return activity.Format(time.RFC3339Nano), true } - func archiveEligibility(task archiveTask, value *state, days int) (string, bool) { if task.Archived || !task.Visible || task.ID == value.MainTaskID || task.ID == value.ControllerTaskID { return "", false @@ -83,7 +80,6 @@ func archiveEligibility(task archiveTask, value *state, days int) (string, bool) parsed, err := time.Parse(time.RFC3339Nano, activity) return activity, valid && err == nil && !parsed.After(maintenanceNow().UTC().AddDate(0, 0, -days)) } - func maintenance(ctx context.Context, archiveID, restoreID, cancelID string, days int) (any, error) { operationLock, err := newStore(stateDir()).operationLock() if err != nil { @@ -101,8 +97,8 @@ func maintenance(ctx context.Context, archiveID, restoreID, cancelID string, day } result := map[string]any{"ready": true, "automation_id": maintenanceAutomationID, "archive_after_days": days} err = newStore(stateDir()).update(func(value *state) (bool, error) { - if value.MainTaskID == "" || value.Phase != phaseMigrationComplete { - return false, errors.New("maintenance requires a completed ThreadBear installation") + if value.MainTaskID == "" || value.Phase != phaseMigrationComplete || value.UninstallPending != nil { + return false, errors.New("maintenance requires a completed installation with no prepared uninstall") } changed := false if value.Archives == nil { diff --git a/cmd/threadbear/maintenance_test.go b/cmd/threadbear/maintenance_test.go index 4da4198..953c42f 100644 --- a/cmd/threadbear/maintenance_test.go +++ b/cmd/threadbear/maintenance_test.go @@ -113,6 +113,24 @@ func TestMaintenanceArchiveRestoreAndInterruptionReconcile(t *testing.T) { } } +func TestMaintenanceRefusesPreparedUninstall(t *testing.T) { + testIndex(t) + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.MainTaskID, value.Phase = "main", phaseMigrationComplete + value.UninstallPending = &uninstallOperation{InitiatorTaskID: "owner"} + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := maintenance(context.Background(), "target", "", "", 14); err == nil || err.Error() != "maintenance requires a completed installation with no prepared uninstall" { + t.Fatalf("maintenance during uninstall = %v", err) + } + value, _ := newStore(stateDir()).read() + if value.ArchivePending != nil { + t.Fatal("blocked maintenance changed archive state") + } +} + func TestMaintenanceRejectsDriftAndUninstallWithPendingArchive(t *testing.T) { root, db := testIndex(t) now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 9a95252..199fa25 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -84,7 +84,11 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "status still says `migration_pending` or `migration_running`", "migration stopped and is not still working", "two native title calls per ordinary turn", - "~/.local/bin/threadbear uninstall --noninteractive --confirm --json", + "~/.local/bin/threadbear uninstall --prepare --initiator-task-id", + "~/.local/bin/threadbear uninstall --initiator-task-id", + "You can uninstall from any active native Codex task—even when the ThreadBear home is archived.", + "Do not ask the user to open, select, navigate to, or unarchive the ThreadBear home.", + "same initiating task", "Want me to uninstall ThreadBear?", } { if !strings.Contains(text, required) { @@ -228,8 +232,8 @@ func TestHomepageMatchesNativeMaintenanceCapabilities(t *testing.T) { func TestManagedCleanupContractIsShipped(t *testing.T) { root := filepath.Join("..", "..", "assets") for path, required := range map[string][]string{ - filepath.Join(root, "skill", "SKILL.md"): {"## Title cleanup", "🧵🐻 strip title icons", "For uninstall, target the control task last"}, - filepath.Join(root, "AGENTS.threadbear.md"): {"A confirmed uninstall turn is the sole exception", "respond without another title call or ThreadBear footer"}, + filepath.Join(root, "skill", "SKILL.md"): {"## Title cleanup", "🧵🐻 strip title icons", "prepared uninstall task", "For uninstall, target the control task last"}, + filepath.Join(root, "AGENTS.threadbear.md"): {"A prepared uninstall suspends this turn protocol", "respond without another title call or ThreadBear footer"}, } { data, err := os.ReadFile(path) if err != nil { @@ -249,7 +253,7 @@ func TestShippedLogicStaysBelowAbsoluteLineCeiling(t *testing.T) { if err != nil { t.Fatal(err) } - paths = append(paths, filepath.Join(root, "install.sh")) + paths = append(paths, filepath.Join(root, "assets", "embed.go"), filepath.Join(root, "install.sh")) count := 0 for _, path := range paths { if strings.HasSuffix(path, "_test.go") { diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index a32edc2..8fd35ac 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -37,6 +37,12 @@ type archiveOperation struct { Title string `json:"title"` Activity string `json:"activity,omitempty"` } +type uninstallOperation struct { + InitiatorTaskID string `json:"initiator_task_id"` + MainTaskID string `json:"main_task_id"` + MainArchived bool `json:"main_archived"` + ControllerTaskID string `json:"controller_task_id,omitempty"` +} type state struct { Format int `json:"format"` MainTaskID string `json:"main_task_id,omitempty"` @@ -47,6 +53,7 @@ type state struct { Tasks map[string]taskState `json:"tasks"` Archives map[string]bool `json:"archives,omitempty"` ArchivePending *archiveOperation `json:"archive_pending,omitempty"` + UninstallPending *uninstallOperation `json:"uninstall_pending,omitempty"` } type footer struct{ Status, Action string } type store struct{ dir string } @@ -89,8 +96,8 @@ func (s store) operationLock() (*os.File, error) { } return lock, err } -func (s store) blockingOperationLock() (*os.File, error) { - return s.openLock("operation.lock", unix.LOCK_EX, true) +func (s store) titleLock() (*os.File, error) { + return s.openLock("title.lock", unix.LOCK_EX|unix.LOCK_NB, false) } func unlock(lock *os.File) { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) @@ -174,8 +181,7 @@ func parseFooter(message string) (footer, bool) { return footer{Status: status, Action: action}, true } -var statusIcons = map[string]string{"running": "⏳", "blocked": "🚨", "needs_input": "🙋", "automation": "🤖", "next_steps": "➡️", "complete": "✅", "unknown": "❔", "cleanup": " "} -var statusPrefix = regexp.MustCompile(`^(?:(?:⏳|🚨|🙋|🤖|➡️?|✅|❔) *)+`) +var statusIcons, statusPrefix = map[string]string{"": " ", "running": "⏳", "blocked": "🚨", "needs_input": "🙋", "automation": "🤖", "next_steps": "➡️", "complete": "✅", "unknown": "❔", "cleanup": " "}, regexp.MustCompile(`^(?:(?:⏳|🚨|🙋|🤖|➡️?|✅|❔) *)+`) func stripStatusIcons(title string) string { return statusPrefix.ReplaceAllString(title, "") } func renderTitle(status, subject, action string) string { diff --git a/cmd/threadbear/uninstall_protocol_test.go b/cmd/threadbear/uninstall_protocol_test.go new file mode 100644 index 0000000..f7902a9 --- /dev/null +++ b/cmd/threadbear/uninstall_protocol_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func (s store) waitLock() (*os.File, error) { return s.openLock("operation.lock", unix.LOCK_EX, true) } + +func uninstall(ctx context.Context, confirmed bool) (any, error) { + if !confirmed { + return nil, os.ErrPermission + } + operationLock, err := newStore(stateDir()).waitLock() + if err != nil { + return nil, err + } + defer unlock(operationLock) + value, err := currentStateOrEmpty() + if err != nil { + return nil, err + } + if value.Phase == phaseMigrationRunning { + return nil, errors.New("cannot uninstall while installation migration is running; stop the controller first") + } + if value.ArchivePending != nil { + return nil, errors.New("cannot uninstall while a native archive operation is pending; reconcile it first") + } + if value.MainTaskID != "" { + main, found, err := archiveTaskByID(ctx, value.MainTaskID) + if err != nil { + return nil, err + } + if found && stripStatusIcons(main.Title) != main.Title { + return nil, errors.New("uninstall requires title cleanup from the ThreadBear control task") + } + } + return uninstallLocked(ctx, value) +} + +func addUninstallOwner(t testing.TB, db *sql.DB, root string) { + t.Helper() + addTask(t, db, root, "requester", "Uninstall owner", nil, "vscode", 0) +} + +func TestArchivedControlUninstallPersistsInitiatorAndAuthorizesCleanup(t *testing.T) { + root, db := testIndex(t) + p := installPaths() + addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) + addTask(t, db, root, "controller", "⏳ Completed controller", nil, "vscode", 1) + addUninstallOwner(t, db, root) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.ControllerTaskID, value.Phase = "controller", phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { + t.Fatalf("prepare code %d: %s", code, output.String()) + } + for _, want := range []string{`"prepared":true`, `"initiator_task_id":"requester"`, `"main_task_id":"main"`, `"main_archived":true`} { + if !strings.Contains(output.String(), want) { + t.Fatalf("prepare output missing %s: %s", want, output.String()) + } + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"resumed":true`) { + t.Fatalf("same-owner resume code %d: %s", code, output.String()) + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "other", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 { + t.Fatalf("second owner code %d: %s", code, output.String()) + } + ordinary := hookPayload("PreToolUse", "requester", "owner-running", map[string]any{"title": runningMarker + ": Uninstall owner"}, nil) + output.Reset() + if err := hook(context.Background(), strings.NewReader(ordinary), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("ordinary title during uninstall = %q, %v", output.String(), err) + } + for _, plain := range []string{"Renamed", homeTitle} { + output.Reset() + payload := hookPayload("PreToolUse", "other", "plain-during-uninstall", map[string]any{"threadId": "main", "title": plain}, nil) + if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("plain title %q during uninstall = %q, %v", plain, output.String(), err) + } + } + + if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='main'`); err != nil { + t.Fatal(err) + } + denied := hookPayload("PreToolUse", "other", "other-cleanup", map[string]any{"threadId": "main", "title": cleanupMarker}, nil) + output.Reset() + if err := hook(context.Background(), strings.NewReader(denied), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("other cleanup = %q, %v", output.String(), err) + } + pre := hookPayload("PreToolUse", "requester", "owner-cleanup", map[string]any{"threadId": "main", "title": cleanupMarker}, nil) + output.Reset() + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + proposed := rewrittenTitle(t, output.Bytes()) + if proposed != "Control task" { + t.Fatalf("owner cleanup title = %q", proposed) + } + if _, err := db.Exec(`UPDATE threads SET title=?, archived=1 WHERE id='main'`, proposed); err != nil { + t.Fatal(err) + } + response, _ := json.Marshal(map[string]string{"threadId": "main", "title": proposed}) + post := hookPayload("PostToolUse", "requester", "owner-cleanup", map[string]any{"threadId": "main", "title": proposed}, string(response)) + if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { + t.Fatalf("commit code %d: %s", code, output.String()) + } + for _, path := range []string{p.binary, p.skill, stateDir()} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("uninstall left %s: %v", path, err) + } + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"uninstalled":true`) { + t.Fatalf("retained-candidate no-op code %d: %s", code, output.String()) + } +} + +func TestArchivedControlUninstallPrepareRequiresActiveUserInitiator(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "Control task", nil, "vscode", 1) + addTask(t, db, root, "archived", "Archived", nil, "vscode", 1) + addTask(t, db, root, "invisible", "Invisible", nil, "vscode", 0) + addTask(t, db, root, "nonuser", "Automation", nil, "mcp", 0) + if _, err := db.Exec(`UPDATE threads SET preview='' WHERE id='invisible'`); err != nil { + t.Fatal(err) + } + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + for _, id := range []string{"missing", "archived", "invisible", "nonuser"} { + if _, err := prepareUninstall(context.Background(), id); err == nil { + t.Fatalf("prepare accepted initiator %q", id) + } + value, _ := newStore(stateDir()).read() + if value.UninstallPending != nil { + t.Fatalf("failed prepare persisted owner %q", id) + } + } +} + +func TestArchivedControlUninstallAbortRestoresOrdinaryOperation(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) + addUninstallOwner(t, db, root) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := prepareUninstall(context.Background(), "requester"); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='main'`); err != nil { + t.Fatal(err) + } + if _, err := completeUninstall(context.Background(), "requester", false, true); err == nil { + t.Fatal("abort accepted unrestored archive state") + } + if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='main'`); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Tasks["requester"] = taskState{Pending: &pendingProposal{ToolUseID: "unknown", Prior: "Uninstall owner", Proposed: "Owner"}} + return true, nil + }); err != nil { + t.Fatal(err) + } + result, err := completeUninstall(context.Background(), "requester", false, true) + if err != nil || result.(map[string]any)["aborted"] != true { + t.Fatalf("abort = %#v, %v", result, err) + } + value, _ := newStore(stateDir()).read() + if value.UninstallPending != nil || value.Tasks["requester"].Pending != nil { + t.Fatalf("abort left pending state: %#v", value) + } + if _, err := os.Stat(installPaths().binary); err != nil { + t.Fatalf("abort removed ThreadBear: %v", err) + } +} + +func TestArchivedControlUninstallPrepareRejectsPendingNativeTitle(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "Control task", nil, "vscode", 1) + addUninstallOwner(t, db, root) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { value.Phase = phaseMigrationComplete; return true, nil }); err != nil { + t.Fatal(err) + } + plain := hookPayload("PreToolUse", "other", "in-flight-plain", map[string]any{"threadId": "main", "title": "Renamed"}, nil) + if err := hook(context.Background(), strings.NewReader(plain), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "native title operation is pending") { + t.Fatalf("pending title prepare code %d: %s", code, output.String()) + } +} + +func TestRetainedCandidateFinishesBinaryRemovalAfterStateCommit(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "Control task", nil, "vscode", 1) + p := installPaths() + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + if err := manageBlock(p.agents, ""); err != nil { + t.Fatal(err) + } + hooks, write, err := editHooks(p.hooks, p.binary, false) + if err != nil { + t.Fatal(err) + } + if write { + if len(hooks) == 0 { + if err := removeFiles(p.hooks); err != nil { + t.Fatal(err) + } + } else if err := writeAtomic(p.hooks, hooks, 0o600); err != nil { + t.Fatal(err) + } + } + if err := removeFiles(p.skill); err != nil { + t.Fatal(err) + } + if err := removeFiles(newStore(stateDir()).path()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("fixture lost installed binary: %v", err) + } + var output bytes.Buffer + if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { + t.Fatalf("retained candidate finish code %d: %s", code, output.String()) + } + if _, err := os.Stat(p.binary); !os.IsNotExist(err) { + t.Fatalf("retained candidate left binary: %v", err) + } +} + +func TestArchivedControlUninstallResumeReconcilesUnknownAppliedTitle(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) + addUninstallOwner(t, db, root) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { + t.Fatalf("prepare code %d: %s", code, output.String()) + } + if _, err := db.Exec(`UPDATE threads SET archived=0 WHERE id='main'`); err != nil { + t.Fatal(err) + } + pre := hookPayload("PreToolUse", "requester", "unknown-cleanup", map[string]any{"threadId": "main", "title": cleanupMarker}, nil) + output.Reset() + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + proposed := rewrittenTitle(t, output.Bytes()) + if _, err := db.Exec(`UPDATE threads SET title=?, archived=1 WHERE id='main'`, proposed); err != nil { + t.Fatal(err) + } + addTask(t, db, root, "prior", "⏳ Prior", nil, "vscode", 0) + addTask(t, db, root, "renamed", "User rename", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Tasks["prior"] = taskState{Last: "previous", Pending: &pendingProposal{Prior: "⏳ Prior", Proposed: "Prior"}} + value.Tasks["renamed"] = taskState{Pending: &pendingProposal{Prior: "⏳ Old", Proposed: "Old"}} + return true, nil + }); err != nil { + t.Fatal(err) + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"reconciled_titles":2`) || !strings.Contains(output.String(), `"drifted_titles":1`) { + t.Fatalf("unknown-result resume code %d: %s", code, output.String()) + } + value, err := newStore(stateDir()).read() + if err != nil || value.Tasks["main"].Pending != nil || value.Tasks["main"].Last != proposed || value.Tasks["prior"].Last != "previous" || value.Tasks["renamed"].Pending != nil { + t.Fatalf("reconciled unknown title = %#v, %v", value.Tasks["main"], err) + } +} + +func TestArchivedControlUninstallCommitRequiresRestoredArchiveAndSettledTitles(t *testing.T) { + root, db := testIndex(t) + p := installPaths() + addTask(t, db, root, "main", "⏳ Control task", nil, "vscode", 1) + addUninstallOwner(t, db, root) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { + t.Fatalf("prepare code %d: %s", code, output.String()) + } + if _, err := db.Exec(`UPDATE threads SET title='Control task', archived=0 WHERE id='main'`); err != nil { + t.Fatal(err) + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "archive state") { + t.Fatalf("unrestored archive commit code %d: %s", code, output.String()) + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("blocked commit removed retry binary: %v", err) + } + if _, err := db.Exec(`UPDATE threads SET archived=1 WHERE id='main'`); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Tasks["main"] = taskState{Pending: &pendingProposal{ToolUseID: "unknown", Proposed: "Control task"}} + return true, nil + }); err != nil { + t.Fatal(err) + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "native title operation is pending") { + t.Fatalf("unknown title commit code %d: %s", code, output.String()) + } + if _, err := os.Stat(p.binary); err != nil { + t.Fatalf("unknown title commit removed retry binary: %v", err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + record := value.Tasks["main"] + record.Pending = nil + value.Tasks["main"] = record + return true, nil + }); err != nil { + t.Fatal(err) + } + output.Reset() + if code := run(context.Background(), []string{"uninstall", "--initiator-task-id", "requester", "--noninteractive", "--confirm", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 { + t.Fatalf("settled resumed commit code %d: %s", code, output.String()) + } +} diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go index 5136377..ef5a9ff 100644 --- a/cmd/threadbear/update.go +++ b/cmd/threadbear/update.go @@ -70,6 +70,9 @@ func update(ctx context.Context) (any, error) { if value.ArchivePending != nil { return nil, updateFailure("archive_pending", errors.New("reconcile the pending native archive operation before updating")) } + if value.UninstallPending != nil { + return nil, updateFailure("uninstall_pending", errors.New("finish the prepared uninstall before updating")) + } assetKey, assetName, err := updatePlatform() if err != nil { return nil, updateFailure("platform", err) diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go index 4f94247..2fa6b35 100644 --- a/cmd/threadbear/update_test.go +++ b/cmd/threadbear/update_test.go @@ -85,6 +85,22 @@ func TestUpdateRefusesPendingArchiveBeforeNetwork(t *testing.T) { } } +func TestUpdateRefusesPreparedUninstallBeforeNetwork(t *testing.T) { + prepareUpdate(t, "2.0.0", true) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1"}) + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.UninstallPending = &uninstallOperation{InitiatorTaskID: "owner"} + return true, nil + }); err != nil { + t.Fatal(err) + } + _, err := update(context.Background()) + requireUpdateStage(t, err, "uninstall_pending") + if fixture.count("manifest") != 0 { + t.Fatal("prepared uninstall allowed update network access") + } +} + func TestUpdateRefusesConcurrentMaintenanceBeforeNetwork(t *testing.T) { prepareUpdate(t, "2.1.2", true) fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.1.3"}) diff --git a/docs/architecture.md b/docs/architecture.md index fde5963..971f363 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ Each title moment makes exactly one native attempt. A timeout leaves the write r The canonical title is ` [ → ]`. ThreadBear owns only a leading status and action suffix from its last exact committed rendering. Any different current title is a user rename and becomes the complete subject, even if it contains an icon or arrow. A first-call seed is ignored after ownership exists. -State is keyed by task ID and contains the persistent main-task ID, the single migration-controller ID, one migration phase, the canonical subject, the last verified rendering, and at most one pending proposal. A pending proposal lets a later call recognize setter success when Post was lost. State is private, locked, and atomically replaced. Ordinary title proposals are never queued for later repair. +State is keyed by task ID and contains the persistent main-task ID, the single migration-controller ID, one migration phase, the canonical subject, the last verified rendering, and at most one pending proposal. A pending proposal lets a later call recognize setter success when Post was lost. A prepared uninstall additionally records the exact active initiator, main/controller identities, and the main task's original archive state. While that operation exists, maintenance, update, and ordinary title calls are denied; only cleanup markers from the persisted initiator may stage. An exact-owner abort clears the operation only after the original main archive state is restored. State is private, locked, and atomically replaced. Ordinary title proposals are never queued for later repair. ## Maintenance and archive ownership @@ -31,7 +31,7 @@ For a newer or repairing release, the command downloads bounded checksum and bin The visible title is limited to 60 UTF-16 units. Rendering first computes the bounded standalone status-and-subject display, then truncates or omits only the appended action without changing canonical state. -The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. The marker is denied for every other caller. Guided uninstall uses this serial operation for all active titles before deleting the hooks or ownership state; the same operation is available on demand from the persistent task. +The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. During a prepared uninstall, the persisted active initiator receives that cleanup authority and every other title call is denied. The initiator serially cleans active tasks, temporarily unarchives the persisted main only when required, cleans it last, restores its original archive state, and verifies both title and archive settlement before deleting the exact automation. Local teardown is then a rerunnable commit that validates managed content, removes state before the installed executable, and never performs another native mutation. On-demand cleanup outside uninstall remains restricted to the persisted main task. ## Installation and migration diff --git a/docs/compatibility.md b/docs/compatibility.md index de073d0..632ebc8 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -8,4 +8,4 @@ ThreadBear reads the highest local Codex state database and fails closed when th Visible titles are at most 60 UTF-16 units and never split a surrogate pair. Native setter success is the runtime acknowledgement. Each release must separately prove the rendered active header and sidebar in a fresh Codex Desktop task. -The supported public commands are `install`, `inventory`, `status`, `self-test`, `uninstall`, and `version`. +The supported public commands are `install`, `inventory`, `migration`, `maintenance`, `update`, `status`, `self-test`, `uninstall`, and `version`. Guided uninstall uses a prepared active-task owner and one explicit-target native writer; an archived main task is temporarily unarchived and restored through native archive control without opening or navigating to it. diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index a036115..0fabe8e 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-08-02", + "updated_at": "2026-08-03", "canonical_for": "ThreadBear title-path architecture and live experiments", "capabilities": [ { @@ -45,6 +45,17 @@ ], "decision": "Repeated fresh and delegated tasks must prove stable subject ownership before a candidate can advance to migration or release, even when native hook calls themselves succeed.", "next_preflight": null + }, + { + "id": "TB-CAP-ARCHIVED-CONTROL-HANDOFF", + "premise": "A previously archived unloaded ThreadBear control task can be temporarily activated, woken, and used as the current-task native title writer during uninstall.", + "status": "rejected", + "supported_by": [], + "contradicted_by": [ + "TB-EXP-0113-001" + ], + "decision": "The exact bounded live probe timed out after the archived unloaded task was unarchived and woken, and authoritative readback stayed unchanged. BEAR-113 must not depend on wake-and-handoff.", + "next_preflight": null } ], "experiments": [ @@ -612,6 +623,46 @@ "TB-EXP-0107-002", "TB-EXP-0108-001" ] + }, + { + "id": "TB-EXP-0113-001", + "date": "2026-08-03", + "issue": "BEAR-113", + "preflight_id": "TB-PRE-0113-001", + "question": "Can a previously archived unloaded task be unarchived, woken, and then perform one bounded current-task native title write?", + "invariant": "Archived-control uninstall may depend on handoff only when one bounded native write settles and authoritative readback matches.", + "environment": { + "threadbear_version": "not installed; v2.2.0 repository base", + "git_sha": "ae7c2c6db6d31c97ab2021a7c14b881327708b08", + "codex_version": "Desktop 26.727.51351; CLI 0.146.0-alpha.9.2", + "codex_source": "vscode", + "host": "local macOS", + "task_state": "dedicated previously archived unloaded user canary, temporarily unarchived and woken by background message delivery", + "restart_state": "no application restart", + "hook_fingerprint": "not installed", + "guidance_fingerprint": "historical task snapshot; probe supplied the exact one-call contract" + }, + "invocation": { + "outer_tool": "functions.exec inside the resumed canary task", + "code": "const result = await Promise.race([tools.codex_app__set_thread_title({title:\"BEAR-113 archived-control canary\"}).then(value => ({status:\"returned\", value})).catch(error => ({status:\"failed\", error:String(error)})), new Promise(resolve => setTimeout(() => resolve({status:\"timeout\"}), 4000))]); text(result);", + "native_tool_identity": "codex_appset_thread_title", + "target_identity_mode": "current task by omitted threadId" + }, + "evidence": [ + "codex-rollout:019fbe0e-9b4f-7c60-b4bf-ed7c9889fe5f", + "linear:BEAR-113" + ], + "result": { + "status": "observed", + "summary": "The resumed task reported timeout after the single four-second call. Authoritative readback remained BEAR-104 restart owner canary, and the parent restored the task to archived state.", + "timing_ms": "4000 call bound; 10135 complete resumed turn", + "hook_participation": "not applicable: ThreadBear was not installed", + "rendered_proof": "not used; native readback remained unchanged" + }, + "confidence": "high", + "applicability": "Rules out wake-and-handoff in the current Desktop session without installed hooks. It does not test an exact installed BEAR-113 candidate or a post-restart trusted hook snapshot.", + "supersedes": [], + "conflicts": [] } ], "preflights": [ @@ -661,6 +712,26 @@ ], "stop_condition": "Stop after one bounded current-task call, its exact result, ThreadBear state inspection, and rendered-header/sidebar observation. Do not reinstall, retry, vary another field, resume architecture, or run the release matrix.", "result_experiment_id": "TB-EXP-0108-002" + }, + { + "id": "TB-PRE-0113-001", + "issue": "BEAR-113", + "capability_id": "TB-CAP-ARCHIVED-CONTROL-HANDOFF", + "status": "closed", + "consulted": [ + "TB-EXP-0059-002", + "TB-EXP-0107-001", + "TB-EXP-0108-002" + ], + "remaining_unknown": "Whether a previously archived unloaded control task can be unarchived, woken by native background message delivery, and then perform one bounded current-task title mutation from its resumed turn.", + "single_changed_variable": "Task lifecycle: use a previously archived unloaded task that is unarchived and woken by background message delivery before the exact current-task native call, instead of an already active fresh task.", + "held_constant": "Desktop 26.727.51351 and CLI 0.146.0-alpha.9.2; local macOS host; native codex_appset_thread_title identity; current-task target by omitted threadId; one four-second bounded attempt; authoritative native readback; no visual or computer control; no second title write.", + "predicted_outcomes": [ + "If wake-and-handoff is a viable uninstall route, the resumed task will execute the exact native call and authoritative readback will show the requested title.", + "If an unloaded task cannot reliably actuate its own title after wakeup, the call will fail or time out, or authoritative readback will remain unchanged; BEAR-113 must not depend on handoff." + ], + "stop_condition": "Stop after one unarchive, one background wakeup, one bounded current-task title call, authoritative readback, and archive restoration. Do not retry the title call, test explicit-target mutation, add another variable, or select an implementation route until the result is recorded and this preflight is closed.", + "result_experiment_id": "TB-EXP-0113-001" } ] } diff --git a/docs/live-eval.md b/docs/live-eval.md index a42a457..326a44c 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -9,3 +9,5 @@ Prove complete, all three next-step owners, needs input, blocked, automation, to Run this development-only matrix after installing the candidate with `threadbear install --debug-canaries`; ordinary guided installation never runs it. Rendered proof is mandatory. Verify the running and terminal titles in both the active header and sidebar before their corresponding boundaries. Use one explicit-target migration canary and prove that only the intended mounted row repaints. Confirm that Stop removes the official spinner, leaves the running title, and creates no additional ThreadBear turn. Capture privacy-safe screenshots. Exercise installation against 0-, 1-, and 200-task inventories, including projectless tasks, exact historical footers, genuine ambiguity, v2-owned decoration, user-authored icons and arrows, concurrent rename/archive, interruption, and clean rerun. Luna-medium workers must be used only for genuine ambiguity and never write titles. Prove adaptive waves under partial capacity, a capacity error after successful spawns, out-of-order completion, one timed-out worker, completed-result preservation, and a final zero-remaining inventory with no retained worker left active or unaccounted for. + +Exercise archived-control uninstall without visual or computer control. Use a decorated persisted main that begins archived, a different completed archived controller sentinel, and a separate active initiator. Record candidate SHA and binary hash, native title/archive readbacks, automation identity and deletion, config hashes, and final artifact absence. Interrupt a fresh run after temporary main activation, restore the original archive state, then resume from the same initiator and prove no settled native mutation is duplicated. Separately force local teardown failure before state removal, resume with the still-installed candidate, and require one retained-candidate no-op uninstall after completion. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3293c45..0a97796 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -5,8 +5,9 @@ Before tagging a stable release: 1. Run `python3 scripts/validate-experiments.py`. For every load-bearing title mechanism claimed by the release, cite its `TB-CAP-*` record and supporting `TB-EXP-*` evidence in the implementing issue and pull request. When the release depends on a new probe, also cite its closed `TB-PRE-*` record and bidirectionally linked result experiment. Do not release an unresolved capability or present contradictory evidence as a global conclusion. Review must judge the declared unknown and changed variable; validator success is not semantic approval. 2. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. 3. Run `gofmt`, `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity checks. Count tracked non-test Go plus shipped bootstrap shell, report the 1,500-line target comparison, and fail the release above the 2,000-line absolute ceiling. -4. In isolated homes, prove install, reinstall, status, inventory, and both uninstall title choices while preserving unrelated AGENTS content and hook definitions in order. +4. In isolated homes, prove install, reinstall, status, inventory, and both uninstall title choices while preserving unrelated AGENTS content and hook definitions in order. Force local teardown to stop before state removal, prove the installed binary remains runnable, resume, and require a retained-candidate second uninstall to be a clean no-op. 5. Exercise 0-, 1-, and 200-task controller migrations. Prove deterministic exact-footer classification, adaptive ambiguity-only Luna waves, retention of successful workers after a later capacity error, bounded worker timeout, out-of-order result accounting, serial explicit native writes, concurrent rename/archive handling, interruption, same-controller resume, clean rerun, and final zero-remaining convergence. 6. Install with `threadbear install --debug-canaries`, then run the exact-candidate release matrix in `docs/live-eval.md` from fresh Codex Desktop tasks. Verify the rendered active header and sidebar, capture privacy-safe screenshots outside the public repository, and restore controlled canary titles through the supported native path. +7. With a distinct completed archived controller retained as a sentinel, archive a decorated persisted main task through native control. From another active task, prepare uninstall, clean active titles and the temporarily unarchived main through the exact installed candidate, restore the main archive state, delete the exact automation, and commit local teardown. Verify all native and filesystem results by supported readback without visual or computer control. Repeat with one interruption after temporary activation and resume from the same initiator without a duplicate title mutation. After tagging, confirm the release workflow publishes both Darwin architectures, checksums, and the manifest. Then run the hosted smoke test through `threadbear.sh`, including checksum verification, candidate self-test, install, status, inventory, and uninstall. diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index d277dc8..4d39333 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -32,6 +32,14 @@ INSERT INTO threads VALUES ( 0, 'cli', 'cli', '$rollout', 'Release smoke raw first message with extra instructions', 'Release smoke raw first message with extra instructions' ); +INSERT INTO threads VALUES ( + 'release-smoke-home', 1, '⏳ Release smoke home', NULL, + 1, 'cli', '', '$rollout', 'Release smoke home', 'Release smoke home' +); +INSERT INTO threads VALUES ( + 'release-smoke-controller', 1, '⏳ Completed controller sentinel', NULL, + 1, 'cli', '', '$rollout', 'Completed controller sentinel', 'Completed controller sentinel' +); SQL env HOME="$home" CODEX_HOME="$codex_home" \ @@ -89,16 +97,39 @@ HOME="$home" CODEX_HOME="$codex_home" "$binary" migration \ status=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" status --json) printf '%s\n' "$status" | grep -F '"ready":true' >/dev/null printf '%s\n' "$status" | grep -F '"phase":"migration_complete"' >/dev/null +sqlite3 "$codex_home/state_1.sqlite" "INSERT INTO threads VALUES ('release-smoke-owner',1,'Uninstall owner',NULL,0,'cli','','${rollout}','Uninstall owner','Uninstall owner');" + +prepared=$(HOME="$home" CODEX_HOME="$codex_home" "$binary" uninstall \ + --prepare --initiator-task-id release-smoke-owner --json) +printf '%s\n' "$prepared" | grep -F '"prepared":true' >/dev/null +printf '%s\n' "$prepared" | grep -F '"main_task_id":"release-smoke-home"' >/dev/null +printf '%s\n' "$prepared" | grep -F '"main_archived":true' >/dev/null +sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET archived=0 WHERE id='release-smoke-home';" -pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke-home","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-cleanup","tool_input":{"threadId":"release-smoke","title":"🧵🐻 strip title icons"}}' +pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-cleanup","tool_input":{"threadId":"release-smoke","title":"🧵🐻 strip title icons"}}' prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) expected_title='Release smoke seeded subject' printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke';" -post='{"hook_event_name":"PostToolUse","session_id":"release-smoke-home","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-cleanup","tool_input":{"threadId":"release-smoke","title":"Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"Release smoke seeded subject\"}"}' +post='{"hook_event_name":"PostToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-cleanup","tool_input":{"threadId":"release-smoke","title":"Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"Release smoke seeded subject\"}"}' printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook grep -E '"subject"[[:space:]]*:[[:space:]]*"Release smoke seeded subject"' "$state" >/dev/null grep -E '"last"[[:space:]]*:[[:space:]]*"Release smoke seeded subject"' "$state" >/dev/null -HOME="$home" CODEX_HOME="$codex_home" "$binary" uninstall --noninteractive --confirm --json + +pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-home-cleanup","tool_input":{"threadId":"release-smoke-home","title":"🧵🐻 strip title icons"}}' +prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) +expected_title='Release smoke home' +printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null +printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null +sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke-home';" +post='{"hook_event_name":"PostToolUse","session_id":"release-smoke-owner","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-home-cleanup","tool_input":{"threadId":"release-smoke-home","title":"Release smoke home"},"tool_response":"{\"threadId\":\"release-smoke-home\",\"title\":\"Release smoke home\"}"}' +printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook +sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET archived=1 WHERE id='release-smoke-home';" +HOME="$home" CODEX_HOME="$codex_home" "$binary" uninstall --initiator-task-id release-smoke-owner --noninteractive --confirm --json test ! -e "$binary" +test ! -d "$home/.local/share/threadbear" +test "$(sqlite3 "$codex_home/state_1.sqlite" "SELECT title || ':' || archived FROM threads WHERE id='release-smoke-home';")" = 'Release smoke home:1' +test "$(sqlite3 "$codex_home/state_1.sqlite" "SELECT title || ':' || archived FROM threads WHERE id='release-smoke-controller';")" = '⏳ Completed controller sentinel:1' +! grep -F 'THREADBEAR MANAGED BLOCK' "$codex_home/AGENTS.md" +! grep -F "threadbear' hook" "$codex_home/hooks.json" diff --git a/site/install b/site/install index cf12c03..60915ab 100644 --- a/site/install +++ b/site/install @@ -173,19 +173,29 @@ The installed binary's help is the authoritative public command list. Run `updat ## Uninstall -Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, then ask: +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. -> 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. Tasks already tucked into the archive will stay there, and your other 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. +Read the installed skill's **Title cleanup** and **Uninstall** sections. Run status and inventory, resolve this initiating task's exact ID plus the persisted main and distinct controller IDs, inspect the exact owned automation, then ask: + +> Want me to uninstall ThreadBear? I'll pause its Luna helper, tidy the ThreadBear icons from native-addressable local Codex task titles, and remove ThreadBear's local files, two hooks, and owned automation. If the ThreadBear home is archived, I'll briefly bring it out for cleanup and tuck it back exactly where it was. Your other archived tasks and Codex settings will be left alone. Older signed-in ChatGPT chat-history rows are outside this cleanup. When it's done, I'll ask you to restart Codex. > > Should I go ahead? -Pause the exact owned `threadbear-maintenance` heartbeat before title cleanup; refuse an ID, kind, name, or target mismatch. Title cleanup must finish through serial explicit native target calls with exact returned IDs and titles. Clean the persistent ThreadBear task last, delete the exact owned automation, then show and run: +After consent, pause the exact owned `threadbear-maintenance` heartbeat; refuse an ID, kind, name, or target mismatch. Prepare the durable operation before changing a title or archive state: + +```sh +~/.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. + +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: ```sh -~/.local/bin/threadbear uninstall --noninteractive --confirm --json +~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json ``` -Uninstall refuses while `phase:migration_running` or a native archive operation is pending; reconcile it and stop the controller first. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary. It preserves unrelated content, hook order, unrelated automations, and the archived state of already archived tasks. 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. +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. ## Maintainer verification