← reports

#2052 · bb CLI: human output of many commands is raw JSON.stringify, identical to --json, with epoch timestamps (thread history, project history, machine show, ...)

Type: Bug / UX Priority: Medium Effort: low–medium cli open on GitHub 2026-08-24 · base 494f66526

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

Sixteen bb subcommands advertise a --json flag but print exactly the same bytes with and without it: their "human" branch is literally console.log(JSON.stringify(result, null, 2)), the same statement the outputJson() helper runs for --json. Every affected command is a read/inspect command (thread history|search|queue list|tabs show, project history|branches|paths|files|commands, machine show, machine provider-cli status, terminal show, settings show|keyboard list|usage|version). The pretty-printed JSON is 4–5x larger than a one-line-per-row rendering, nests the interesting text two levels deep, and prints every time as a 13-digit epoch-millisecond number ("createdAt": 1787592687628). Agents that call these commands inside a long context pay that cost on every call; bb settings show alone is 82 KB (~20k tokens) on a fresh dev instance. This is not a regression: the fallbacks were written this way when the commands were added in fc77a6e3f (2026-07-12, #638) and nothing on origin/main after the base commit touches them. Reproduced live on an isolated dev instance and with a failing vitest that drives the real commander program against a stub HTTP server.

2. Claims vs findings

Claim from the issueStatusEvidence
bb thread history <id> --limit 1 prints the same bytes with and without --jsonVerifiedcmp reports IDENTICAL (203 bytes each) on the dev instance; vitest assertion expect(human).not.toEqual(json) fails. See §4.
Human output contains epoch-millisecond timestamps (createdAt, lastSeenAt, updatedAt)Verified"createdAt": 1787592687628 in thread history; "lastSeenAt": 1787592615388 in machine show. The repro test's regex /"createdAt":\s*\d{13}/ matches.
bb thread search, history, queue list, tabs show go through printHumanJsonVerifiedapps/cli/src/commands/thread/organization.ts L71–73 defines it; call sites L162, L178, L235, L426. All four IDENTICAL in the live capture.
bb project history|branches|paths|commands|files print raw JSONVerifiedproject.ts L365, L403, L424, L441, L460. All five IDENTICAL in the live capture (project commands is 39 KB).
bb machine show and bb machine provider-cli status print raw JSONVerifiedmachine.ts L154 and L237 (issue cites L158/L241 at its older commit; same statements). Both IDENTICAL.
bb terminal show prints raw JSONVerified (code)terminal.ts L163 console.log(JSON.stringify(session, null, 2)). Not exercised live (no terminal needed to prove the code path; identical construct to the others).
bb settings show, keyboard list, usage, version print raw JSONVerifiedsettings.ts L136, L233, L307, L322. show/keyboard list/version IDENTICAL; usage differs only in the millisecond part of live-computed resetsAt strings (two separate requests), the shape is identical raw JSON.
bb thread log "events path" is also a JSON fallbackRefuted (mis-filed)show.ts L485 prints JSON only when --format json / --json is given. The default (--format minimal) is a compact timeline (1,294 bytes vs 20,069 bytes for --json on the same thread). That is correct behaviour, not an instance of this bug.
bb thread list and bb machine list already have a human tableVerifiedBoth differ from --json in the capture (143 vs 1,108 bytes; 183 vs 302 bytes). They are the controls in the repro test and pass.
Still happens on mainVerifiedgit log 494f66526..origin/main -- apps/cli/src is empty as of 2026-08-24. Present at base 494f66526.
Example in Discord was a 9 KB pretty JSON array for thread historyUnverified (plausible)Each history entry costs ~200 bytes pretty-printed; 9 KB ≈ 45 entries. The default server limit is PROMPT_HISTORY_ENTRY_LIMIT, so the size scales with history length. Not reproduced at that size (two entries here = 397 bytes).
Not a duplicate of #1768 / #1648Verified#1768 (closed by #2174) is thread log truncation/paging; #1648 (closed) added the thread list table. Neither touches the commands listed here.

3. Environment

4. Minimal reproduction

4a. Live, against a running bb (any version ≥ 0.39)

  1. Have a thread with at least one follow-up message (the very first prompt of a thread is recorded under the project's history, follow-ups under the thread's — see resolveAcceptedPromptHistoryScope in apps/server/src/services/prompt-history.ts L159–175). On the dev instance:
    bb thread spawn --project proj_v6ghvjp9fm --environment /tmp/bb-2052-qa --provider codex --title "qa 2052" --prompt "Reply only with ok."
    bb thread wait thr_wjkkq48mtn --status idle
    bb thread tell thr_wjkkq48mtn "Reply only with ok."
    bb thread wait thr_wjkkq48mtn --status idle
    bb thread tell thr_wjkkq48mtn "Reply only with ok again."
    bb thread wait thr_wjkkq48mtn --status idle
  2. Compare human vs --json output:
    $ bb thread history thr_wjkkq48mtn | wc -c
         397
    $ bb thread history thr_wjkkq48mtn --json | wc -c
         397
    $ cmp <(bb thread history thr_wjkkq48mtn) <(bb thread history thr_wjkkq48mtn --json) && echo IDENTICAL
    IDENTICAL
    Expected: a compact human listing without --json (the way bb thread list behaves). Actual (verbatim, no --json):
    [
      {
        "id": "phist_wmbamyyfde",
        "createdAt": 1787592687628,
        "input": [
          {
            "type": "text",
            "text": "Reply only with ok again.",
            "mentions": []
          }
        ]
      },
      {
        "id": "phist_2s49exhptw",
        "createdAt": 1787592686101,
        "input": [
          {
            "type": "text",
            "text": "Reply only with ok.",
            "mentions": []
          }
        ]
      }
    ]
    The same two entries rendered one-per-line with an ISO time are 90 bytes instead of 397 (≈23 vs ≈99 tokens):
    2026-08-24T17:31:27Z  Reply only with ok again.
    2026-08-24T17:31:26Z  Reply only with ok.
  3. bb machine show (no --json) — every timestamp is epoch milliseconds, and machine list right next to it already renders "just now":
    $ bb machine show host_7bpbfi5mci
    {
      "id": "host_7bpbfi5mci",
      "name": "OWNER’s MacBook Pro",
      "type": "persistent",
      "status": "connected",
      "maxPermissionMode": "full",
      "lastSeenAt": 1787592615388,
      "lastRejectedProtocolVersion": null,
      "createdAt": 1787592521114,
      "updatedAt": 1787592615388
    }
    $ bb machine list
    
    Name                  ID               Status     Last seen
    --------------------  ---------------  ---------  ---------
    OWNER’s MacBook Pro  host_7bpbfi5mci  connected  just now
  4. Run the whole matrix with 2052/repro/capture.sh (it calls bbdev.sh, which points the worktree CLI at the dev instance). Result (out/summary.txt; per-command outputs are in 2052/repro/out/):
    command                         human     json  verdict
    machine-list                      183      302  differs      <- control (has a table)
    machine-provider-cli-status      1916     1916  IDENTICAL
    machine-show                      276      276  IDENTICAL
    project-branches                  483      483  IDENTICAL
    project-commands                39248    39248  IDENTICAL
    project-files                     108      108  IDENTICAL
    project-history                   197      197  IDENTICAL
    project-paths                     171      171  IDENTICAL
    settings-keyboard-list          39985    39985  IDENTICAL
    settings-show                   82663    82663  IDENTICAL
    settings-usage                    782      782  differs      <- only the ms part of live "resetsAt" strings; same raw JSON shape
    settings-version                  174      174  IDENTICAL
    thread-history                    397      397  IDENTICAL
    thread-history-limit1             203      203  IDENTICAL
    thread-list                       143     1108  differs      <- control (has a table)
    thread-queue-list                   3        3  IDENTICAL
    thread-search                    2047     2047  IDENTICAL
    thread-tabs-show                   34       34  IDENTICAL

4b. Unit-level, no server needed (fails on main)

File: 2052/repro/human-output-json-fallback.test.ts — drop it in apps/cli/src/__tests__/ and run pnpm --filter @bb/cli exec vitest run src/__tests__/human-output-json-fallback.test.ts. It builds the real commander program with registerThreadCommands/registerProjectCommands/registerMachineCommands/registerSettingsCommands, points getUrl() at a throwaway node:http server that answers the six routes, captures console.log, and asserts human ≠ json. On 494f66526: 7 failed, 2 passed (the two controls). Full log: vitest-main.clean.txt.

× `bb thread history thr_f3ndpkf6iz` human mode differs from --json
× `bb thread tabs show thr_f3ndpkf6iz` human mode differs from --json
× `bb thread queue list thr_f3ndpkf6iz` human mode differs from --json
× `bb project history proj_3azzxemfid` human mode differs from --json
× `bb machine show HOST` human mode differs from --json
× `bb settings version` human mode differs from --json
× `bb thread history` human mode does not print raw epoch timestamps
✓ control: `bb thread list` already differs from --json
✓ control: `bb machine list` already differs from --json

AssertionError: expected '[\n  {\n    "id": "phist_nisrgxjasn",…' to not deeply equal '[\n  {\n    "id": "phist_nisrgxjasn",…'
AssertionError: expected '{\n  "revision": 0,\n  "tabs": []\n}' to not deeply equal '{\n  "revision": 0,\n  "tabs": []\n}'
AssertionError: expected '[]' to not deeply equal '[]'
AssertionError: expected '{\n  "id": "host_j8fm4di6ds",\n  "nam…' to not deeply equal '{\n  "id": "host_j8fm4di6ds",\n  "nam…'
AssertionError: expected '{\n  "currentVersion": "0.39.0",\n  "…' to not deeply equal '{\n  "currentVersion": "0.39.0",\n  "…'
AssertionError: expected '[\n  {\n    "id": "phist_nisrgxjasn",…' not to match /"createdAt":\s*\d{13}/

Test Files  1 failed (1)
     Tests  7 failed | 2 passed (9)
/**
 * Repro for get-bb/bb#2052: several `bb` commands print the raw SDK result
 * as `JSON.stringify(result, null, 2)` when `--json` is NOT passed, so the
 * human output is byte-identical to the `--json` output.
 *
 * The test drives the real commander program against a throwaway HTTP server
 * that answers the routes the commands call, captures `console.log`, and
 * asserts that the human output differs from the `--json` output.
 *
 * On main (494f66526) every `it` in the affected block FAILS with
 * "expected <json> not to deeply equal <json>" — that failure IS the bug.
 * The control cases (`thread list`, `machine list`) pass because those
 * commands already have a real human table.
 */
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { Command } from "commander";
import {
  afterAll,
  afterEach,
  beforeAll,
  describe,
  expect,
  it,
  vi,
} from "vitest";

import { registerMachineCommands } from "../commands/machine.js";
import { registerProjectCommands } from "../commands/project.js";
import { registerSettingsCommands } from "../commands/settings.js";
import { registerThreadCommands } from "../commands/thread/index.js";

const HOST = {
  id: "host_j8fm4di6ds",
  name: "HOST",
  type: "persistent",
  status: "connected",
  maxPermissionMode: "full",
  lastSeenAt: 1787248793022,
  lastRejectedProtocolVersion: null,
  createdAt: 1784415215437,
  updatedAt: 1787248793022,
};

const HISTORY = [
  {
    id: "phist_nisrgxjasn",
    createdAt: 1784845035019,
    input: [{ type: "text", text: "what is the status", mentions: [] }],
  },
  {
    id: "phist_sj8cpvb4ax",
    createdAt: 1784841813000,
    input: [{ type: "text", text: "what is taking so long now", mentions: [] }],
  },
];

const THREAD = {
  id: "thr_f3ndpkf6iz",
  projectId: "proj_3azzxemfid",
  environmentId: null,
  providerId: "codex",
  title: "status check",
  titleFallback: null,
  sectionId: null,
  status: "idle",
  parentThreadId: null,
  sourceThreadId: null,
  originKind: null,
  originPluginId: null,
  visibility: "visible",
  archivedAt: null,
  pinnedAt: null,
  deletedAt: null,
  lastReadAt: 1784845035019,
  latestAttentionAt: 1784845035019,
  createdAt: 1784841813000,
  updatedAt: 1784845035019,
  runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
  activeBackgroundAgentCount: 0,
  canSpawnChild: true,
};

const ROUTES: Record<string, unknown> = {
  "/api/v1/threads/thr_f3ndpkf6iz/prompt-history": HISTORY,
  "/api/v1/projects/proj_3azzxemfid/prompt-history": HISTORY,
  "/api/v1/threads/thr_f3ndpkf6iz/tabs": { revision: 0, tabs: [] },
  "/api/v1/threads/thr_f3ndpkf6iz/queued-messages": [],
  "/api/v1/hosts": [HOST],
  "/api/v1/hosts/host_j8fm4di6ds": HOST,
  "/api/v1/threads": [THREAD],
  "/api/v1/projects": [],
  "/api/v1/system/version": {
    currentVersion: "0.39.0",
    latestVersion: "0.39.0",
    source: "npm",
    updateAvailable: false,
    isDevelopment: false,
    upgradeCommand: "npx bb-app@latest",
  },
};

let server: Server;
let baseUrl = "";
const unhandled: string[] = [];

beforeAll(async () => {
  server = createServer((req, res) => {
    const path = new URL(req.url ?? "/", "http://localhost").pathname;
    const body = ROUTES[path];
    if (body === undefined) {
      unhandled.push(`${req.method} ${path}`);
      res.statusCode = 404;
      res.end(JSON.stringify({ error: `unhandled ${req.method} ${path}` }));
      return;
    }
    res.setHeader("content-type", "application/json");
    res.end(JSON.stringify(body));
  });
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const { port } = server.address() as AddressInfo;
  baseUrl = `http://127.0.0.1:${port}`;
});

afterAll(async () => {
  await new Promise<void>((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve())),
  );
});

afterEach(() => {
  vi.restoreAllMocks();
});

function buildProgram(): Command {
  const program = new Command();
  program.exitOverride();
  const getUrl = () => baseUrl;
  registerThreadCommands(program, getUrl);
  registerProjectCommands(program, getUrl);
  registerMachineCommands(program, getUrl);
  registerSettingsCommands(program, getUrl);
  return program;
}

async function runCapturingStdout(argv: string[]): Promise<string> {
  const lines: string[] = [];
  const errors: string[] = [];
  const logSpy = vi
    .spyOn(console, "log")
    .mockImplementation((...args: unknown[]) => {
      lines.push(args.map(String).join(" "));
    });
  const errSpy = vi
    .spyOn(console, "error")
    .mockImplementation((...args: unknown[]) => {
      errors.push(args.map(String).join(" "));
    });
  const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code) => {
    throw new Error(`process.exit:${String(code)} ${errors.join(" | ")}`);
  }) as typeof process.exit);
  try {
    await buildProgram().parseAsync(["node", "bb", ...argv]);
  } finally {
    logSpy.mockRestore();
    errSpy.mockRestore();
    exitSpy.mockRestore();
  }
  return lines.join("\n");
}

const AFFECTED: readonly string[][] = [
  ["thread", "history", "thr_f3ndpkf6iz"],
  ["thread", "tabs", "show", "thr_f3ndpkf6iz"],
  ["thread", "queue", "list", "thr_f3ndpkf6iz"],
  ["project", "history", "proj_3azzxemfid"],
  ["machine", "show", "HOST"],
  ["settings", "version"],
];

describe("bb CLI human output (#2052)", () => {
  for (const argv of AFFECTED) {
    it(`\`bb ${argv.join(" ")}\` human mode differs from --json`, async () => {
      const human = await runCapturingStdout(argv);
      const json = await runCapturingStdout([...argv, "--json"]);
      expect(unhandled).toEqual([]);
      // --json must stay the machine-readable shape.
      expect(() => JSON.parse(json)).not.toThrow();
      // BUG: on main this assertion fails — the two outputs are identical.
      expect(human).not.toEqual(json);
    });
  }

  it("`bb thread history` human mode does not print raw epoch timestamps", async () => {
    const human = await runCapturingStdout([
      "thread",
      "history",
      "thr_f3ndpkf6iz",
    ]);
    // BUG: on main the human output contains `"createdAt": 1784845035019`.
    expect(human).not.toMatch(/"createdAt":\s*\d{13}/);
  });

  // Controls: commands that already have a real human format.
  for (const argv of [
    ["thread", "list"],
    ["machine", "list"],
  ]) {
    it(`control: \`bb ${argv.join(" ")}\` already differs from --json`, async () => {
      const human = await runCapturingStdout(argv);
      const json = await runCapturingStdout([...argv, "--json"]);
      expect(unhandled).toEqual([]);
      expect(human).not.toEqual(json);
    });
  }
});

Repro files: 2052/repro/

5. Root cause

The --json helper and the "human" fallback are the same statement. outputJson() (helpers.ts#L26-L34) prints JSON.stringify(data, null, 2) and returns true when --json is set; each affected action then continues with an unconditional console.log(JSON.stringify(result, null, 2)):

// apps/cli/src/commands/helpers.ts L30-L34
export function outputJson(opts: JsonOutputOptions, data: unknown): boolean {
  if (!opts.json) return false;
  console.log(JSON.stringify(data, null, 2));
  return true;
}

// apps/cli/src/commands/thread/organization.ts L71-L73  (used by search, history, queue list, tabs show)
function printHumanJson(value: unknown): void {
  console.log(JSON.stringify(value, null, 2));
}

// apps/cli/src/commands/thread/organization.ts L172-L180  (thread history)
const result = await createCliBbSdk(getUrl()).threads.promptHistory({ threadId: id, limit: ... });
if (outputJson(opts, result)) return;
printHumanJson(result);           // <- same bytes as the line above

Every site, at the base commit (all byte-identical to origin/main on 2026-08-24):

CommandFallbackPermalink
thread search, thread history, thread queue list, thread tabs showprintHumanJson(result)organization.ts#L71-L73, call sites L162, L178, L235, L426
project history, branches, paths, commands, filesconsole.log(JSON.stringify(result, null, 2))project.ts#L365, L403, L424, L441, L460
machine show, machine provider-cli statusconsole.log(JSON.stringify(host|result, null, 2))machine.ts#L154, L237
terminal showconsole.log(JSON.stringify(session, null, 2))terminal.ts#L163
settings show, settings keyboard list, settings usage, settings versionconsole.log(JSON.stringify(result, null, 2))settings.ts#L136, L233, L307, L322

Why the symptom follows: the SDK returns the server-contract response objects unchanged (epoch-ms createdAt/lastSeenAt/updatedAt per the @bb/server-contract schemas, nested input[].text for prompt history, the full Thread object per search hit), and the fallback dumps that object verbatim with two-space indentation. Nothing in the CLI layer ever projects or formats it. git blame shows the fallbacks were born this way in fc77a6e3f ("Expose end-user features through SDK and CLI", #638, 2026-07-12) and e83d58fba4 for terminal show (2026-07-15) — they were placeholders that never got a human renderer, while sibling commands (thread list #1648, machine list, project list, thread show) did.

Deeper issue: there is no shared "render this record for a human" layer in apps/cli. formatMachineLastSeen() (machine.ts#L89-L102) already implements relative time but is private to machine list; plugin.ts and marketplace.ts each carry their own toISOString() helper; renderBorderlessTable (apps/cli/src/table.ts) is used by 11 files but hand-wired each time. The docs also over-promise: bb guide says "All commands support --json for machine-readable output", which implies the default is not JSON.

6. Proposed fix (first principles)

Confidence: high. Two parts, both inside apps/cli (no server/daemon/protocol change; HOST_DAEMON_PROTOCOL_VERSION untouched).

  1. Make the fallback impossible to reach silently. Delete printHumanJson and every bare console.log(JSON.stringify(result, null, 2)) after an outputJson() check. Add a lint guard (a vitest that greps apps/cli/src for JSON.stringify\([^)]*, null, 2\) outside helpers.ts and the explicit thread log --format json path) so new commands cannot regress. The repro test in §4b becomes the behavioural guard.
  2. Add small shared formatters in apps/cli/src/commands/helpers.ts (or a new format.ts): formatTimestamp(ms, now) returning 2026-08-24T17:31:27Z (3m ago) — reuse/move formatMachineLastSeen's bucketing and extend it with Cole's suggestion (days → weeks after 14 d → months after 2 mo → years after 2 y) — and printKeyValue(record) for single-object "show" commands. Then per command:
    • thread history / project history: one line per entry, newest first, <ISO> <text>; group consecutive entries under a relative-time header as Cole proposed. Keep IDs out of the default line (they are in --json); add --after/--before <ISO|phist_> only if a consumer needs it — do not accept-and-ignore them.
    • thread search: a table ID · Title · Project · Status · Match per group (active/archived), reusing printThreadTable from thread/list.ts rather than dumping full Thread objects (2 KB per hit today).
    • machine show, terminal show, settings version: key/value lines with human times (Last seen: just now (2026-08-24T17:30:15Z)), matching the style of thread show.
    • project branches|paths|files: one path/branch per line (mark the default/selected branch), plus a trailing "… truncated, use --limit" note when truncated is true.
    • project commands, machine provider-cli status, settings show, settings keyboard list, settings usage: tables (name · source · description; provider · installed · version · latest · needsUpdate; key · value for generalSettings/experiments; command · shortcut · override; provider · window · used% · resets). settings show should stop inlining the full keybinding table (that is what keyboard list is for) — 82 KB today.
    • thread queue list / thread tabs show: one line per item; "No queued messages" / "No tabs" when empty instead of [].
  3. Docs in the same change (per docs/cli-guide-and-skill.md): update the affected chapters in packages/templates/src/templates/bb-guide-{threads,projects,machines,terminals,customization}.md and apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md to say what the human line format is and that IDs/epoch fields are available with --json.

What could go wrong: agents or scripts that today parse the default output as JSON will break — but that is exactly the contract the --json flag exists for, and every bb-authored skill/guide tells agents to pass --json when they need structure. Keep --json byte-for-byte unchanged. Be careful with settings usage: its resetsAt values are ISO strings while everything else is epoch-ms, so the formatter must accept both. Do not introduce --format on these commands — thread log already has one, and two flag vocabularies would be worse than one.

7. PR review

No open PR is linked to this issue (checked gh pr list --search 2052 and a title search on 2026-08-24). Nothing to review.

8. Related issues

9. Appendix

Files

Output sizes (human mode, dev instance with one project and one thread)

Commandbytes≈ tokens (bytes/4)
settings show82,66320,665
settings keyboard list39,9859,996
project commands --provider codex39,2489,812
thread search ok (1 hit)2,047511
machine provider-cli status1,916479
thread history (2 entries)39799 (compact alternative: 90 bytes / ≈23 tokens)
machine show27669

Commands run

git fetch origin main; git log 494f66526..origin/main --oneline -- apps/cli/src     # empty
grep -rn "JSON.stringify([a-zA-Z]*, null, 2)" apps/cli/src | grep -v test
pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build          # 18/18 ok, 16 cached
scripts/bb-dev-app current                                                          # App :11227, Server :19227, daemon :27227
git init /tmp/bb-2052-qa; curl -X POST $BB_SERVER_URL/api/v1/projects ... {"name":"qa","source":{"type":"local_path","path":"/tmp/bb-2052-qa","hostId":"host_7bpbfi5mci"}}
bbdev.sh thread spawn --project proj_v6ghvjp9fm --environment /tmp/bb-2052-qa --provider codex --title "qa 2052" --prompt "Reply only with ok." --json
bbdev.sh thread wait thr_wjkkq48mtn --status idle --timeout 120000
bbdev.sh thread tell thr_wjkkq48mtn "Reply only with ok."; bbdev.sh thread wait ...
bbdev.sh thread tell thr_wjkkq48mtn "Reply only with ok again."; bbdev.sh thread wait ...
capture.sh                                                                          # summary above
bbdev.sh thread log thr_wjkkq48mtn [--json]                                         # 1,294 vs 20,069 bytes
pnpm --filter @bb/cli exec vitest run src/__tests__/human-output-json-fallback.test.ts   # 7 failed | 2 passed
git blame -L 72,72 494f66526 -- apps/cli/src/commands/thread/organization.ts        # fc77a6e3f 2026-07-12
git diff --stat 494f66526 21cb6b68b -- apps/cli                                     # empty
pnpm dev:stop; rm -rf ~/.bb-dev/...wf_846839f8-f8a-33-d73718a3ccaa /tmp/bb-2052-qa   # cleanup

Observation on prompt-history scoping (not a bug, but it bit the repro)

After thread spawn --prompt alone, bb thread history returned [] while bb project history had the entry. resolveAcceptedPromptHistoryScope (prompt-history.ts#L159-L175) files a thread-start prompt under the project scope and only follow-ups under the thread scope. Anyone reproducing needs at least one thread tell.

Raw thread search human output (first 40 of 76 lines)

{
  "active": {
    "total": 1,
    "results": [
      {
        "thread": {
          "id": "thr_wjkkq48mtn",
          "projectId": "proj_v6ghvjp9fm",
          "environmentId": "env_i5nd997kzu",
          "providerId": "codex",
          "title": "qa 2052",
          "titleFallback": "Reply only with ok.",
          "sectionId": null,
          "status": "idle",
          "parentThreadId": null,
          "sourceThreadId": null,
          "originKind": null,
          "originPluginId": null,
          "visibility": "visible",
          "archivedAt": null,
          "pinnedAt": null,
          "deletedAt": null,
          "lastReadAt": 1787592578864,
          "latestAttentionAt": 1787592589001,
          "createdAt": 1787592578861,
          "updatedAt": 1787592589001,
          "activity": {
            "activeBackgroundAgentCount": 0,
            "activeBackgroundCommandCount": 0,
            "activeGoalCount": 0,
            "activePlanModeCount": 0,
            "activeWorkflowCount": 0
          },
          "pinSortKey": null,
          "environmentBranchName": "main",
          "environmentHostId": "host_7bpbfi5mci",
          "environmentName": null,
          "environmentWorkspaceDisplayKind": "other",
          "hasPendingInteraction": false,
          "runtime": {
...