diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c15b6..4ebc237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,15 @@ ## Unreleased +## v2.1.1 - 2026-08-02 + ### Changed - Made the initiating task ThreadBear's persistent home and moved installation migration to one resumable, serial native-title controller with honest phase reporting. ### Fixed +- Prevented fresh tasks from freezing a raw first message or delegated envelope as their stable subject by carrying a concise seed in the mandatory first native title call. - Kept the guided installer welcome, readiness result, complete recommendation, and consent question visible together after the first turn finishes. ## v2.1.0 - 2026-07-31 diff --git a/INSTALL.md b/INSTALL.md index f2c3792..6383abb 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -113,7 +113,7 @@ Before migration, tell the user: > The deterministic scan is already done and highly token-efficient. A large workspace can spend about three to five minutes in the native Desktop handoff. I'll report only real progress. Luna medium runs only for genuinely ambiguous legacy history. -Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. Then use Codex `/hooks` to inspect and trust the two installed definitions, create a genuinely fresh Codex Desktop task, and prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. +Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. Then use Codex `/hooks` to inspect and trust the two installed definitions, create a genuinely fresh Codex Desktop task, and prove that its first action is the reserved `⏳ ThreadBear is working: ` native call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. Confirm that ThreadBear's read-only inventory count matches Codex's live native task catalog on the verified Codex version; a mismatch stops migration. After that canary passes, create exactly one ephemeral migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Persist its exact ID before it starts: diff --git a/README.md b/README.md index 3d8839d..5b58016 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ threadbear uninstall threadbear version ``` -Every command accepts `--json`. `inventory` is read-only and includes every local unarchived task across source shapes, including projectless tasks, excluding the persisted main and controller tasks. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. +Every command accepts `--json`. `inventory` is read-only and includes every native-addressable unarchived Desktop or CLI task, including projectless tasks, excluding the persisted main and controller tasks. Rollout-only internal records that Codex's native title setter cannot rename are excluded. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. ## Boundaries diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index 4853e49..756b4b5 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -2,11 +2,11 @@ For every ordinary interactive turn in a main Codex Desktop task: -1. Your first action must be the native current-task title call with `title` exactly `⏳ ThreadBear is working`. Omit `threadId`. Do not send commentary or call another tool first. If the call fails, retry it once, then continue the turn. +1. Your first action must be the native current-task title call with `title` beginning exactly `⏳ ThreadBear is working: ` followed by a concise 2–6 word subject for this task. Keep the complete title to one line and at most 58 UTF-16 units after the colon. Omit `threadId`. Do not send commentary or call another tool first. If the call fails, retry it once, then continue the turn. 2. End the response with exactly one compact status footer chosen from the forms below. 3. Immediately before the final response, call the same native title tool with `title` exactly equal to that footer line and no `threadId`. If the call fails, retry it once, then deliver the response. -ThreadBear's hooks expand those compact inputs into the task's stable visible title. They do not repair stopped turns. If the user stops a turn, the running title remains until the next real turn replaces it. +ThreadBear uses that first-call subject only when a fresh task still exposes its raw first message; explicit names, generated short titles, committed subjects, and later user renames remain authoritative. Its hooks expand the compact inputs into the stable visible title. They do not repair stopped turns. If the user stops a turn, the running title remains until the next real turn replaces it. ## Status footer diff --git a/assets/help.txt b/assets/help.txt index 7b5f0b6..79b45ce 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -5,7 +5,7 @@ Usage: Commands: install Preview or install ThreadBear - inventory Classify local unarchived tasks for guided setup + inventory Classify native-addressable tasks for guided setup migration Record the single installation migration controller phase status Check the installed helper, hooks, and migration phase self-test Validate a release candidate diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index e166265..8bad8b5 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -29,9 +29,10 @@ Show a command before running it. Ask for explicit consent before any lifecycle 3. Show the recommended setup and ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. 4. Run the confirmed install with the same ID and verify `version`, `self-test`, and `status`. On reinstall, omit the flag only when `status --json` already reports the persisted main task; never replace it with the launching task. 5. Before any migration, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. -6. Use Codex `/hooks` to inspect and trust the two installed definitions. Then open a genuinely fresh Codex Desktop task and prove the first native call, the terminal native call, and the exact hook results before changing existing titles. Existing sessions may retain the hook snapshot they started with. -7. After the canary passes, create exactly one ephemeral migration controller, record it with `migration --phase migration_running`, and end the persistent task promptly. The persistent task never performs, awaits, or polls bulk migration. -8. Give the controller the **Migration controller** protocol below. Do not claim completion until it records `migration_complete` after final inventory convergence. +6. Use Codex `/hooks` to inspect and trust the two installed definitions. Then open a genuinely fresh Codex Desktop task and prove its first native call carries the reserved `⏳ ThreadBear is working: ` handoff, the terminal native call matches its footer, and both exact hook results pass before changing existing titles. Existing sessions may retain the hook snapshot they started with. +7. Confirm the read-only ThreadBear inventory count matches Codex's native task catalog on the verified Codex version. A mismatch stops migration; do not add runtime repair or a second writer. +8. After the canary passes, create exactly one ephemeral migration controller, record it with `migration --phase migration_running`, and end the persistent task promptly. The persistent task never performs, awaits, or polls bulk migration. +9. Give the controller the **Migration controller** protocol below. Do not claim completion until it records `migration_complete` after final inventory convergence. For a large existing workspace, say this before migration: @@ -43,7 +44,7 @@ Do not claim success until the installed checks, fresh-task canary, exact native `status --json` checks the installed binary, managed files, hooks, and state readability. It reports `installed:true` while artifacts are present, but `ready:true` only for `phase:migration_complete`. It does not mutate titles. -`inventory --json` reads every local, unarchived Codex task across source shapes, including projectless tasks, excluding the persisted main and controller IDs. Treat its deterministic classifications, `status`, `action`, and `applied` evidence as authoritative. Do not infer ThreadBear ownership from an icon or arrow alone. +`inventory --json` reads every native-addressable, unarchived Desktop or CLI task, including projectless tasks, excluding the persisted main and controller IDs. It excludes rollout-only internal records that Codex's native title setter cannot rename. Treat its deterministic classifications, `status`, `action`, and `applied` evidence as authoritative. Do not infer ThreadBear ownership from an icon or arrow alone. ## Migration controller diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index b7b366d..7502096 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -27,7 +27,7 @@ func testIndex(t *testing.T) (string, *sql.DB) { t.Cleanup(func() { db.Close() }) _, err = db.Exec(`CREATE TABLE threads ( id TEXT PRIMARY KEY, updated_at_ms INTEGER, title TEXT, name TEXT, archived INTEGER, - source TEXT, thread_source TEXT, rollout_path TEXT)`) + source TEXT, thread_source TEXT, rollout_path TEXT, first_user_message TEXT, preview TEXT)`) if err != nil { t.Fatal(err) } @@ -42,28 +42,35 @@ func addTask(t *testing.T, db *sql.DB, root, id, title string, name any, source if err := os.WriteFile(rollout, nil, 0o600); err != nil { t.Fatal(err) } - if _, err := db.Exec(`INSERT INTO threads VALUES (?,1,?,?,?,?,'',?)`, id, title, name, archived, source, rollout); err != nil { + if _, err := db.Exec(`INSERT INTO threads VALUES (?,1,?,?,?,?,'',?,'',?)`, id, title, name, archived, source, rollout, id); err != nil { t.Fatal(err) } return rollout } -func TestInventoryIncludesEveryUnarchivedSourceAndExactTask(t *testing.T) { +func TestInventoryMatchesNativeAddressableTasks(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "desktop", "generated", "renamed", "vscode", 0) - addTask(t, db, root, "exec", "exec title", nil, "exec", 0) addTask(t, db, root, "cli", "", nil, "cli", 0) + addTask(t, db, root, "mcp", "mcp title", nil, "mcp", 0) + addTask(t, db, root, "exec", "exec title", nil, "exec", 0) + addTask(t, db, root, "empty", "empty preview", nil, "vscode", 0) addTask(t, db, root, "archived", "old", nil, "vscode", 1) + if _, err := db.Exec(`UPDATE threads SET preview='' WHERE id='empty'`); err != nil { + t.Fatal(err) + } tasks, err := inventory(context.Background()) - if err != nil || len(tasks) != 3 || tasks[0].ID != "cli" || tasks[1].ID != "desktop" || tasks[1].Title != "renamed" { + if err != nil || len(tasks) != 2 || tasks[0].ID != "cli" || tasks[1].ID != "desktop" || tasks[1].Title != "renamed" { t.Fatalf("inventory = %#v, %v", tasks, err) } got, found, err := oneTask(context.Background(), "desktop") if err != nil || !found || got.Title != "renamed" { t.Fatalf("oneTask = %#v, %v, %v", got, found, err) } - if _, found, _ := oneTask(context.Background(), "archived"); found { - t.Fatal("archived task was addressable") + for _, id := range []string{"mcp", "exec", "empty", "archived"} { + if _, found, _ := oneTask(context.Background(), id); found { + t.Fatalf("%s task was addressable", id) + } } readOnly, err := openIndex() if err != nil { @@ -112,7 +119,7 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { t.Fatal(err) } - pre := hookPayload("PreToolUse", "task", "call-1", map[string]any{"title": runningMarker}, nil) + pre := hookPayload("PreToolUse", "task", "call-1", map[string]any{"title": runningMarker + ": Stable subject"}, nil) var output bytes.Buffer if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { t.Fatal(err) @@ -145,7 +152,7 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { if _, err := db.Exec(`UPDATE threads SET name=? WHERE id='task'`, proposed); err != nil { t.Fatal(err) } - pre = hookPayload("PreToolUse", "task", "call-3", map[string]any{"title": runningMarker}, nil) + pre = hookPayload("PreToolUse", "task", "call-3", map[string]any{"title": runningMarker + ": Changed model seed"}, nil) output.Reset() if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { t.Fatal(err) @@ -155,13 +162,180 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { } } +func TestFreshRunningSubjectSeedClosesFirstTitleRace(t *testing.T) { + root, db := testIndex(t) + first := "Fix the login redirect. First call the title tool, then inspect the failure." + addTask(t, db, root, "raw", first, nil, "vscode", 0) + addTask(t, db, root, "short", "Fix login redirect", nil, "vscode", 0) + addTask(t, db, root, "named", first, "Customer login", "vscode", 0) + addTask(t, db, root, "truncated", truncateUTF16(first, 60), nil, "vscode", 0) + addTask(t, db, root, "delegated", " private Fix login", nil, "vscode", 0) + if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id IN ('raw','short','named','truncated')`, first); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET first_user_message=title WHERE id='delegated'`); err != nil { + t.Fatal(err) + } + for id, want := range map[string]string{ + "raw": "⏳ Model subject seed", "short": "⏳ Fix login redirect", "named": "⏳ Customer login", + "truncated": "⏳ Model subject seed", "delegated": "⏳ Model subject seed", + } { + var output bytes.Buffer + pre := hookPayload("PreToolUse", id, "call-"+id, map[string]any{"title": runningMarker + ": Model subject seed"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != want { + t.Fatalf("%s rewrite = %q, %v", id, output.String(), err) + } + } +} + +func TestFreshRunningSubjectSeedFailsClosedAndThenStaysOwned(t *testing.T) { + root, db := testIndex(t) + first := "Investigate the first title race and preserve the stable subject." + addTask(t, db, root, "task", first, nil, "vscode", 0) + if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='task'`, first); err != nil { + t.Fatal(err) + } + indexed, found, err := oneTask(context.Background(), "task") + if err != nil || !found || indexed.Title != first || indexed.FirstMessage != first || indexed.Name != "" { + t.Fatalf("fresh task index = %#v, %v, %v", indexed, found, err) + } + stateBefore, err := currentStateOrEmpty() + if err != nil || len(stateBefore.Tasks) != 0 { + t.Fatalf("fresh state = %#v, %v", stateBefore, err) + } + var homeOutput bytes.Buffer + homePre := hookPayload("PreToolUse", "task", "home", map[string]any{"title": homeTitle}, nil) + if err := hook(context.Background(), strings.NewReader(homePre), &homeOutput); err != nil || homeOutput.Len() != 0 { + t.Fatalf("persistent home title was not passed through: %q, %v", homeOutput.String(), err) + } + for _, marker := range []string{runningMarker, runningMarker + ":", runningMarker + ": ", runningMarker + ": bad spacing", runningMarker + ": " + strings.Repeat("x", 59), homeTitle + " extra"} { + var output bytes.Buffer + pre := hookPayload("PreToolUse", "task", marker, map[string]any{"title": marker}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("marker %q did not fail closed: %q, %v", marker, output.String(), err) + } + } + stateAfter, err := currentStateOrEmpty() + if err != nil || len(stateAfter.Tasks) != 0 { + t.Fatalf("denied markers changed state: %#v, %v", stateAfter, err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "task", "seed", map[string]any{"title": runningMarker + ": First title race"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + proposed := rewrittenTitle(t, output.Bytes()) + response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) + post := hookPayload("PostToolUse", "task", "seed", map[string]any{"title": proposed}, string(response)) + if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET title=? WHERE id='task'`, proposed); err != nil { + t.Fatal(err) + } + output.Reset() + pre = hookPayload("PreToolUse", "task", "later", map[string]any{"title": runningMarker + ": Ignore this replacement"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != "⏳ First title race" { + t.Fatalf("owned subject changed: %q, %v", output.String(), err) + } +} + +func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { + root, db := testIndex(t) + first := "Restarted task exposes this long raw first message before Codex restores the committed title." + delegation := "privateFix login" + addTask(t, db, root, "raw", first, nil, "vscode", 0) + addTask(t, db, root, "truncated", truncateUTF16(first, 60), nil, "vscode", 0) + addTask(t, db, root, "pending", first, nil, "vscode", 0) + addTask(t, db, root, "fresh", first, nil, "vscode", 0) + addTask(t, db, root, "delegated", delegation, nil, "vscode", 0) + addTask(t, db, root, "renamed", "Manual user rename", nil, "vscode", 0) + addTask(t, db, root, "named", first, "Explicit name", "vscode", 0) + if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id IN ('raw','truncated','pending','fresh','renamed','named')`, first); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='delegated'`, delegation); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(saved *state) (bool, error) { + saved.Tasks["raw"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} + saved.Tasks["truncated"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} + saved.Tasks["pending"] = taskState{Pending: &pendingProposal{BaseSubject: "Pending owner"}} + return true, nil + }); err != nil { + t.Fatal(err) + } + for id, call := range map[string][2]string{ + "raw": {runningMarker + ": Replacement seed", "⏳ Committed owner"}, + "truncated": {"🧵🐻 complete", "✅ Committed owner"}, + "pending": {runningMarker + ": Replacement seed", "⏳ Pending owner"}, + "renamed": {runningMarker + ": Replacement seed", "⏳ Manual user rename"}, + "named": {runningMarker + ": Replacement seed", "⏳ Explicit name"}, + } { + var output bytes.Buffer + pre := hookPayload("PreToolUse", id, "call-"+id, map[string]any{"title": call[0]}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || rewrittenTitle(t, output.Bytes()) != call[1] { + t.Fatalf("%s restart rewrite = %q, %v", id, output.String(), err) + } + } + for _, id := range []string{"fresh", "delegated"} { + var output bytes.Buffer + pre := hookPayload("PreToolUse", id, "terminal-"+id, map[string]any{"title": "🧵🐻 complete"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("%s ownerless terminal did not fail closed: %q, %v", id, output.String(), err) + } + } + saved, err := currentStateOrEmpty() + if err != nil || saved.Tasks["fresh"].Pending != nil || saved.Tasks["delegated"].Pending != nil { + t.Fatalf("ownerless terminal changed state: %#v, %v", saved, err) + } +} + +func TestRunningMigrationControllerOwnsHistoricalFirstMessage(t *testing.T) { + root, db := testIndex(t) + first := "echo hello" + addTask(t, db, root, "target", first, nil, "vscode", 0) + if _, err := db.Exec(`UPDATE threads SET first_user_message=? WHERE id='target'`, first); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(saved *state) (bool, error) { + saved.MainTaskID, saved.ControllerTaskID, saved.Phase = "main", "controller", phaseMigrationRunning + return true, nil + }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "other", "denied", map[string]any{"threadId": "target", "title": unknownMarker}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("non-controller ownerless migration was not denied: %q, %v", output.String(), err) + } + output.Reset() + pre = hookPayload("PreToolUse", "controller", "allowed", map[string]any{"threadId": "target", "title": unknownMarker}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + proposed := rewrittenTitle(t, output.Bytes()) + if proposed != "❔ echo hello" { + t.Fatalf("controller migration title = %q", proposed) + } + response, _ := json.Marshal(map[string]string{"threadId": "target", "title": proposed}) + post := hookPayload("PostToolUse", "controller", "allowed", map[string]any{"threadId": "target", "title": proposed}, string(response)) + if err := hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + saved, _ := newStore(stateDir()).read() + if got := saved.Tasks["target"]; got.Subject != first || got.Last != proposed || got.Pending != nil { + t.Fatalf("controller migration ownership = %#v", got) + } +} + func TestPostMismatchFailsClosed(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "task", "Subject", nil, "vscode", 0) _ = db _ = newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }) var output bytes.Buffer - pre := hookPayload("PreToolUse", "task", "call", map[string]any{"title": runningMarker}, nil) + pre := hookPayload("PreToolUse", "task", "call", map[string]any{"title": runningMarker + ": Subject"}, nil) if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { t.Fatal(err) } @@ -184,7 +358,7 @@ func TestPostMismatchFailsClosed(t *testing.T) { func TestBulkMarkerRereadsExplicitTargetAndAdoptsRename(t *testing.T) { root, db := testIndex(t) - addTask(t, db, root, "target", "Bulk subject", nil, "exec", 0) + addTask(t, db, root, "target", "Bulk subject", nil, "vscode", 0) _ = newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }) pre := hookPayload("PreToolUse", "installer", "bulk-1", map[string]any{"threadId": "target", "title": "🧵🐻 complete"}, nil) var output bytes.Buffer diff --git a/cmd/threadbear/hook.go b/cmd/threadbear/hook.go index 20aae29..81e9a99 100644 --- a/cmd/threadbear/hook.go +++ b/cmd/threadbear/hook.go @@ -9,12 +9,8 @@ import ( "strings" ) -const ( - titleTool = "codex_appset_thread_title" - runningMarker = "⏳ ThreadBear is working" - unknownMarker = "❔ ThreadBear could not classify" - maxHookBytes = 1 << 20 -) +const titleTool, runningMarker, homeTitle = "codex_appset_thread_title", "⏳ ThreadBear is working", "🧵🐻 ThreadBear 🐻🧵" +const unknownMarker, maxHookBytes = "❔ ThreadBear could not classify", 1 << 20 type hookInput struct { Event string `json:"hook_event_name"` @@ -27,13 +23,10 @@ type hookInput struct { func readBoundedJSON(r io.Reader, value any) error { data, err := io.ReadAll(io.LimitReader(r, maxHookBytes+1)) - if err != nil { - return err - } if len(data) > maxHookBytes { - return errors.New("input exceeds 1 MiB") + return errors.Join(err, errors.New("input exceeds 1 MiB")) } - return json.Unmarshal(data, value) + return errors.Join(err, json.Unmarshal(data, value)) } func stringField(values map[string]json.RawMessage, key string, required bool) (string, error) { raw, ok := values[key] @@ -47,15 +40,12 @@ func stringField(values map[string]json.RawMessage, key string, required bool) ( return value, nil } func titleTarget(event hookInput) (string, string, error) { - title, err := stringField(event.ToolInput, "title", true) - if err != nil { - return "", "", err - } - target, err := stringField(event.ToolInput, "threadId", false) + title, titleErr := stringField(event.ToolInput, "title", true) + target, targetErr := stringField(event.ToolInput, "threadId", false) if target == "" { target = event.SessionID } - return title, target, err + return title, target, errors.Join(titleErr, targetErr) } func hook(ctx context.Context, in io.Reader, out io.Writer) error { var event hookInput @@ -85,19 +75,26 @@ 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) - if title == runningMarker { + seed, seeded := strings.CutPrefix(title, runningMarker+": ") + if seeded && (seed == "" || seed != strings.Join(strings.Fields(seed), " ") || utf16Len(seed) > 58) { + return errors.New("invalid running subject seed") + } + if seeded { result, terminal = footer{Status: "running"}, true } else if title == unknownMarker { result, terminal = footer{Status: "unknown"}, true } if !terminal { - if strings.HasPrefix(title, "🧵🐻 ") { - return errors.New("invalid ThreadBear footer marker") + if strings.HasPrefix(title, runningMarker) || strings.HasPrefix(title, "🧵🐻 ") { + return errors.New("invalid ThreadBear marker") } return nil } - proposed, err := stageTitle(ctx, target, result.Status, result.Action, event.ToolUseID) + proposed, err := stageTitle(ctx, target, result.Status, result.Action, seed, event.SessionID, event.ToolUseID) if err != nil { return err } @@ -106,20 +103,32 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { "hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": event.ToolInput, }}) } -func stageTitle(ctx context.Context, id, status, action, toolUseID string) (string, error) { +func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID string) (string, error) { task, found, err := oneTask(ctx, id) - if err != nil { - return "", err - } - if !found { - return "", errors.New("task is not active in Codex") + 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) { record := saved.Tasks[id] + current, first := strings.Join(strings.Fields(task.Title), " "), strings.Join(strings.Fields(task.FirstMessage), " ") subject := canonicalSubject(task.Title, record) + if task.Name == "" && first != "" && (current == first || current == truncateUTF16(first, 60)) { + subject = record.Subject + if record.Pending != nil && record.Pending.BaseSubject != "" { + subject = record.Pending.BaseSubject + } + if subject == "" && status == "running" { + subject = seed + } + if subject == "" && saved.Phase == phaseMigrationRunning && saved.ControllerTaskID == caller && caller != id { + subject = current + } + if subject == "" { + return false, errors.New("fresh task has no subject owner") + } + } proposed = renderTitle(status, subject, action) - record.Subject = subject record.Pending = &pendingProposal{ToolUseID: toolUseID, BaseSubject: subject, Prior: task.Title, Proposed: proposed, Status: status, Action: action} saved.Tasks[id] = record return true, nil diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 2f86abf..d56da05 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -206,9 +206,7 @@ func sameJSON(a, b []byte) bool { var left, right any return json.Unmarshal(a, &left) == nil && json.Unmarshal(b, &right) == nil && reflect.DeepEqual(left, right) } -func quoteCommand(binary string) string { - return "'" + strings.ReplaceAll(binary, "'", "'\"'\"'") + "' hook" -} +func quoteCommand(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "' hook" } func validateFile(path, content string) error { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { @@ -270,8 +268,7 @@ func writeAtomic(path string, data []byte, mode os.FileMode) error { if err != nil { return err } - name := f.Name() - defer os.Remove(name) + defer os.Remove(f.Name()) err = f.Chmod(mode) if err == nil { _, err = f.Write(data) @@ -279,11 +276,10 @@ func writeAtomic(path string, data []byte, mode os.FileMode) error { if err == nil { err = f.Sync() } - err = errors.Join(err, f.Close()) - if err == nil { - err = os.Rename(name, path) + if err = errors.Join(err, f.Close()); err != nil { + return err } - return err + return os.Rename(f.Name(), path) } func removeFiles(paths ...string) error { for _, path := range paths { diff --git a/cmd/threadbear/lifecycle_test.go b/cmd/threadbear/lifecycle_test.go index d510116..e8caa4c 100644 --- a/cmd/threadbear/lifecycle_test.go +++ b/cmd/threadbear/lifecycle_test.go @@ -10,7 +10,7 @@ func TestMigrationInventoryExcludesMainAndController(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - targetRollout := addTask(t, db, root, "target", "Target", nil, "exec", 0) + targetRollout := addTask(t, db, root, "target", "Target", nil, "vscode", 0) writeMigrationRollout(t, targetRollout, "🧵🐻 complete") if err := newStore(stateDir()).update(func(value *state) (bool, error) { value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning @@ -28,7 +28,7 @@ func TestMigrationControllerRequiresAppliedFinalConvergence(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "main", "ThreadBear", nil, "vscode", 0) addTask(t, db, root, "controller", "Migration controller", nil, "vscode", 0) - targetRollout := addTask(t, db, root, "target", "Target", nil, "exec", 0) + targetRollout := addTask(t, db, root, "target", "Target", nil, "vscode", 0) writeMigrationRollout(t, targetRollout, "🧵🐻 complete") if err := newStore(stateDir()).update(func(value *state) (bool, error) { value.MainTaskID, value.ControllerTaskID, value.Phase = "main", "controller", phaseMigrationRunning diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index 0500818..0f6714f 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -17,7 +17,7 @@ import ( "strings" ) -type indexedTask struct{ ID, Title, RolloutPath string } +type indexedTask struct{ ID, Title, RolloutPath, Name, FirstMessage string } func inventory(ctx context.Context) ([]indexedTask, error) { db, err := openIndex() @@ -25,8 +25,8 @@ func inventory(ctx context.Context) ([]indexedTask, error) { return nil, err } defer db.Close() - rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,'') - FROM threads WHERE archived=0 ORDER BY id`) + rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,'') + FROM threads WHERE archived=0 AND preview<>'' AND source IN ('vscode','cli') ORDER BY id`) if err != nil { return nil, fmt.Errorf("read Codex task index: %w", err) } @@ -34,7 +34,7 @@ func inventory(ctx context.Context) ([]indexedTask, error) { var tasks []indexedTask for rows.Next() { var task indexedTask - if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath); err != nil { + if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage); err != nil { return nil, err } tasks = append(tasks, task) @@ -51,8 +51,8 @@ func oneTask(ctx context.Context, id string) (indexedTask, bool, error) { } defer db.Close() var task indexedTask - err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,'') - FROM threads WHERE id=? AND archived=0`, id).Scan(&task.ID, &task.Title, &task.RolloutPath) + err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,'') + FROM threads WHERE id=? AND archived=0 AND preview<>'' AND source IN ('vscode','cli')`, id).Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage) if errors.Is(err, sql.ErrNoRows) { return indexedTask{}, false, nil } @@ -100,7 +100,6 @@ func sqliteHome() (string, error) { } return value, nil } - func rolloutFooter(path string) (footer, bool) { if path == "" { return footer{}, false diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index afca6e4..c19f73e 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -53,6 +53,7 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "codex_app__set_thread_title", "codex_app__set_thread_pinned", "genuinely fresh Codex Desktop task", + "⏳ ThreadBear is working: ", "two native title calls per ordinary turn", "~/.local/bin/threadbear uninstall --noninteractive --confirm --json", } { diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index eae72df..9578a46 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -40,7 +40,6 @@ type store struct{ dir string } func newStore(dir string) store { return store{dir: dir} } func (s store) path() string { return filepath.Join(s.dir, "native.json") } - func (s store) lock() (*os.File, error) { if err := os.MkdirAll(s.dir, 0o700); err != nil { return nil, err @@ -105,12 +104,9 @@ func (s store) update(change func(*state) (bool, error)) (err error) { return err } changed, err := change(&value) - if err != nil { + if err != nil || !created && !changed { return err } - if !created && !changed { - return nil - } return s.save(value) } func (s store) save(value state) error { diff --git a/docs/architecture.md b/docs/architecture.md index 3192447..2ff7ae7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,8 @@ ThreadBear is one small Go executable, one private atomic JSON file, one managed ## Ordinary turn -1. Managed guidance makes the native current-task title setter the turn's first action with the compact input `⏳ ThreadBear is working` and no explicit task ID. -2. `PreToolUse` reads the calling task's current title from the local Codex index, preserves the stable user-owned subject, records one pending proposal, and rewrites the title input to `⏳ `. +1. Managed guidance makes the native current-task title setter the turn's first action with `⏳ ThreadBear is working: ` and no explicit task ID. The same model already answering the user supplies the subject; ThreadBear adds no model call. +2. `PreToolUse` reads the current title, explicit name, and first message from the local Codex index. It preserves an explicit name, a generated short title, exact prior ownership, or a later user rename. Only when an unowned title is still the raw or truncated first message does it adopt the reserved subject handoff. A missing or malformed handoff fails closed. 3. `PostToolUse` accepts only the exact returned task ID and rewritten title, then commits the subject and rendering. 4. Immediately before the final response, the task calls the same native setter with its exact ThreadBear footer. The hooks expand and commit the matching terminal title; the response ends with that footer. @@ -13,7 +13,7 @@ Each title moment may be retried once. There is no Stop hook: an interrupted tur ## Ownership and state -The canonical title is ` [ → ]`. ThreadBear owns only a leading status and action suffix from its last exact committed rendering. Any different current title is a user rename and becomes the complete subject, even if it contains an icon or arrow. +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. @@ -21,6 +21,6 @@ The visible title is limited to 60 UTF-16 units. Rendering truncates displayed s ## Installation and migration -Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order. The initiating task is recorded as the persistent ThreadBear home, then a fresh-task canary proves the native boundary. That task creates exactly one ephemeral migration controller and returns promptly; the controller inventories all local unarchived tasks, excludes the main/controller IDs, classifies exact footers deterministically, and uses Luna medium only for genuinely ambiguous history. Immediately before each explicit-target native write, the hook re-reads the target and adopts any newer rename. +Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order. The initiating task is recorded as the persistent ThreadBear home, then a fresh-task canary proves the native boundary. That task creates exactly one ephemeral migration controller and returns promptly; the controller inventories native-addressable unarchived Desktop and CLI tasks, excludes rollout-only internal records plus the main/controller IDs, classifies exact footers deterministically, and uses Luna medium only for genuinely ambiguous history. Immediately before each explicit-target native write, the hook re-reads the target and adopts any newer rename. Migration is rerunnable from the same controller ID and skips only inventory rows proven `applied: true` from exact committed ownership state. Native writes are serial. A timeout or unknown result records `migration_failed` until authoritative reconciliation; only a final zero-remaining inventory may record `migration_complete`. The persistent task never performs, awaits, or polls the migration. Rendered active-header and sidebar verification belongs in release QA. diff --git a/docs/compatibility.md b/docs/compatibility.md index 0b7b4ea..694c9da 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,10 +1,10 @@ # Compatibility -ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop tasks indexed in the current local `state_N.sqlite`, Codex `PreToolUse` and `PostToolUse` hooks, and the native current-task and explicit-target title setter. +ThreadBear supports macOS 12 or newer on Apple silicon and Intel, Codex Desktop tasks indexed in the current local `state_N.sqlite`, Codex `PreToolUse` and `PostToolUse` hooks, and the native current-task and explicit-target title setter. The native task-catalog contract is verified against Codex 0.146.0. The hook matcher is the plain literal `codex_appset_thread_title`. The anchored-regex form is not supported because Codex 0.146.0 treated it as match-all. Hook installation preserves unrelated definitions and their array order. -ThreadBear reads the highest local Codex state database and fails closed when the required thread schema, calling session ID, current title, hook payload, or exact native result is unavailable. It does not write the Codex database, Desktop caches, or private UI storage. +ThreadBear reads the highest local Codex state database and fails closed when the required thread schema, calling session ID, current title, hook payload, or exact native result is unavailable. Inventory mirrors the verified native catalog: unarchived records with a nonempty preview and source `vscode` or `cli`. A release or migration must stop if a read-only inventory-count canary differs from the live native task catalog. ThreadBear does not run an app-server subprocess or write the Codex database, Desktop caches, or private UI storage. 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. diff --git a/docs/live-eval.md b/docs/live-eval.md index 4ccb55b..6157d4a 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -2,7 +2,7 @@ Run release QA in genuinely fresh Codex Desktop tasks so hook and managed-guidance snapshots cannot mask installation defects. -Prove complete, all three next-step owners, needs input, blocked, automation, tool-free, continued, stopped, long-subject, duplicate-title, user-rename, and hook-failure turns. For each ordinary turn, record that the native running call was the first action, the terminal call immediately preceded the final response, the exact footer was final, and each failed call was attempted at most twice. +Prove complete, all three next-step owners, needs input, blocked, automation, tool-free, continued, stopped, long-subject, duplicate-title, user-rename, and hook-failure turns. Include fresh tasks where Codex has already generated a short title and where the index still contains the exact or truncated first message, plus a delegated envelope. For each ordinary turn, record that the seeded native running call was the first action, the terminal call immediately preceded the final response, the exact footer was final, and each failed call was attempted at most twice. Rendered proof is mandatory. Verify the running and terminal titles in both the active header and sidebar before their corresponding boundaries. Use one explicit-target migration canary and prove that only the intended mounted row repaints. Confirm that Stop removes the official spinner, leaves the running title, and creates no additional ThreadBear turn. Capture privacy-safe screenshots. diff --git a/docs/status-convention.md b/docs/status-convention.md index 441c80a..c53ad86 100644 --- a/docs/status-convention.md +++ b/docs/status-convention.md @@ -24,4 +24,4 @@ The same exact line is passed to the native current-task title setter immediatel | `blocked (external)` | `🚨 ` | | `automation` | `🤖 ` | -At turn start, `⏳ ThreadBear is working` maps to `⏳ `. `❔` is reserved for legacy items that remain unknown during installation; ordinary turns do not emit it. +At turn start, `⏳ ThreadBear is working: ` maps to `⏳ `. The seed is used only when a fresh task still exposes its raw first message; established subjects and user renames win. `❔` is reserved for legacy items that remain unknown during installation; ordinary turns do not emit it. diff --git a/scripts/release-smoke.sh b/scripts/release-smoke.sh index 49fc1cd..47fa7d5 100755 --- a/scripts/release-smoke.sh +++ b/scripts/release-smoke.sh @@ -24,11 +24,13 @@ printf '%s\n' \ sqlite3 "$codex_home/state_1.sqlite" </dev/null printf '%s\n' "$inventory" | grep -F '"task_id":"release-smoke"' >/dev/null printf '%s\n' "$inventory" | grep -F '"status":"complete"' >/dev/null +pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-running","tool_input":{"title":"⏳ ThreadBear is working: Release smoke seeded subject"}}' +prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) +expected_title='⏳ Release smoke seeded subject' +printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null +printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null +sqlite3 "$codex_home/state_1.sqlite" "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke';" +post='{"hook_event_name":"PostToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-running","tool_input":{"title":"⏳ Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"⏳ Release smoke seeded subject\"}"}' +printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook + pre='{"hook_event_name":"PreToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-final","tool_input":{"title":"🧵🐻 complete"}}' prepared=$(printf '%s\n' "$pre" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook) -expected_title='✅ Release smoke subject' +expected_title='✅ Release smoke seeded subject' printf '%s\n' "$prepared" | grep -F '"permissionDecision":"allow"' >/dev/null printf '%s\n' "$prepared" | grep -F "\"title\":\"$expected_title\"" >/dev/null sqlite3 "$codex_home/state_1.sqlite" \ "UPDATE threads SET title = '$expected_title' WHERE id = 'release-smoke';" -post='{"hook_event_name":"PostToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-final","tool_input":{"title":"✅ Release smoke subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"✅ Release smoke subject\"}"}' +post='{"hook_event_name":"PostToolUse","session_id":"release-smoke","tool_name":"codex_appset_thread_title","tool_use_id":"release-smoke-final","tool_input":{"title":"✅ Release smoke seeded subject"},"tool_response":"{\"threadId\":\"release-smoke\",\"title\":\"✅ Release smoke seeded subject\"}"}' printf '%s\n' "$post" | HOME="$home" CODEX_HOME="$codex_home" "$binary" hook state=$home/.local/share/threadbear/native.json grep -F '"release-smoke"' "$state" >/dev/null -grep -E '"subject"[[:space:]]*:[[:space:]]*"Release smoke subject"' "$state" >/dev/null -grep -E '"last"[[:space:]]*:[[:space:]]*"✅ Release smoke subject"' "$state" >/dev/null +grep -E '"subject"[[:space:]]*:[[:space:]]*"Release smoke seeded subject"' "$state" >/dev/null +grep -E '"last"[[:space:]]*:[[:space:]]*"✅ Release smoke seeded subject"' "$state" >/dev/null if grep -F '"pending"' "$state" >/dev/null; then echo "release smoke title remained pending" >&2 exit 1 diff --git a/site/install b/site/install index f2c3792..6383abb 100644 --- a/site/install +++ b/site/install @@ -113,7 +113,7 @@ Before migration, tell the user: > The deterministic scan is already done and highly token-efficient. A large workspace can spend about three to five minutes in the native Desktop handoff. I'll report only real progress. Luna medium runs only for genuinely ambiguous legacy history. -Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. Then use Codex `/hooks` to inspect and trust the two installed definitions, create a genuinely fresh Codex Desktop task, and prove that its first action is the native running-title call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. +Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and prove that exact title in the active header and mounted sidebar. Then use Codex `/hooks` to inspect and trust the two installed definitions, create a genuinely fresh Codex Desktop task, and prove that its first action is the reserved `⏳ ThreadBear is working: ` native call, its terminal call immediately precedes the footer, both exact native results pass through the two hooks, and both titles render in the active header and sidebar. Also prove that one explicit-target canary repaints only the intended mounted sidebar row. Confirm that ThreadBear's read-only inventory count matches Codex's live native task catalog on the verified Codex version; a mismatch stops migration. After that canary passes, create exactly one ephemeral migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Persist its exact ID before it starts: