#2052 · bb CLI: human output of many commands is raw JSON.stringify, identical to --json, with epoch timestamps (thread history, project history, machine show, ...)
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 issue | Status | Evidence |
|---|---|---|
bb thread history <id> --limit 1 prints the same bytes with and without --json | Verified | cmp 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 printHumanJson | Verified | apps/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 JSON | Verified | project.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 JSON | Verified | machine.ts L154 and L237 (issue cites L158/L241 at its older commit; same statements). Both IDENTICAL. |
bb terminal show prints raw JSON | Verified (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 JSON | Verified | settings.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 fallback | Refuted (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 table | Verified | Both 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 main | Verified | git 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 history | Unverified (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 / #1648 | Verified | #1768 (closed by #2174) is thread log truncation/paging; #1648 (closed) added the thread list table. Neither touches the commands listed here. |
3. Environment
- bb source at base
494f66526913557ab076e048218236f0a6610927. Investigation worktree HEAD was21cb6b68b(3 commits ahead, 7 files, all tests/settings UI —git diff --stat 494f66526 21cb6b68b -- apps/cliis empty, so the CLI is byte-identical to base). - macOS 26.5.2 (Darwin 25.5.0, Apple Silicon), Node v22.23.1, pnpm + turbo 2.8.3.
- Providers: codex-cli 0.149.1 (used for two tiny "Reply only with ok." turns to populate prompt history). Released
bb --versionon this machine: 0.39.0 (read-only lookup; not used for the repro). - Isolated dev instance from
scripts/bb-dev-app current: Apphttp://localhost:11227, Serverhttp://localhost:19227, Host daemonhttp://127.0.0.1:27227, data dir~/.bb-dev/bb-machines-HOST.getbb.app-checkouts-bb-.claude-worktrees-wf_846839f8-f8a-33-d73718a3ccaa(deleted at cleanup). - Entities created on that instance: project
proj_v6ghvjp9fm(local path/tmp/bb-2052-qa), threadthr_wjkkq48mtn, hosthost_7bpbfi5mci. - Important gotcha: inside a bb thread the env carries
BB_CLI, and the CLI re-execs to that binary (apps/cli/src/bb-cli-reexec.ts). The helper scripts unsetBB_CLIso the worktree build is what runs.
4. Minimal reproduction
4a. Live, against a running bb (any version ≥ 0.39)
- 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
resolveAcceptedPromptHistoryScopeinapps/server/src/services/prompt-history.tsL159–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
- Compare human vs
--jsonoutput:$ 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 IDENTICALExpected: a compact human listing without--json(the waybb thread listbehaves). 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.
bb machine show(no--json) — every timestamp is epoch milliseconds, andmachine listright 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- Run the whole matrix with
2052/repro/capture.sh(it callsbbdev.sh, which points the worktree CLI at the dev instance). Result (out/summary.txt; per-command outputs are in2052/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):
| Command | Fallback | Permalink |
|---|---|---|
thread search, thread history, thread queue list, thread tabs show | printHumanJson(result) | organization.ts#L71-L73, call sites L162, L178, L235, L426 |
project history, branches, paths, commands, files | console.log(JSON.stringify(result, null, 2)) | project.ts#L365, L403, L424, L441, L460 |
machine show, machine provider-cli status | console.log(JSON.stringify(host|result, null, 2)) | machine.ts#L154, L237 |
terminal show | console.log(JSON.stringify(session, null, 2)) | terminal.ts#L163 |
settings show, settings keyboard list, settings usage, settings version | console.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).
- Make the fallback impossible to reach silently. Delete
printHumanJsonand every bareconsole.log(JSON.stringify(result, null, 2))after anoutputJson()check. Add a lint guard (a vitest that grepsapps/cli/srcforJSON.stringify\([^)]*, null, 2\)outsidehelpers.tsand the explicitthread log --format jsonpath) so new commands cannot regress. The repro test in §4b becomes the behavioural guard. - Add small shared formatters in
apps/cli/src/commands/helpers.ts(or a newformat.ts):formatTimestamp(ms, now)returning2026-08-24T17:31:27Z (3m ago)— reuse/moveformatMachineLastSeen's bucketing and extend it with Cole's suggestion (days → weeks after 14 d → months after 2 mo → years after 2 y) — andprintKeyValue(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 tableID · Title · Project · Status · Matchper group (active/archived), reusingprintThreadTablefromthread/list.tsrather than dumping fullThreadobjects (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 ofthread show.project branches|paths|files: one path/branch per line (mark the default/selected branch), plus a trailing "… truncated, use --limit" note whentruncatedis true.project commands,machine provider-cli status,settings show,settings keyboard list,settings usage: tables (name · source · description;provider · installed · version · latest · needsUpdate;key · valuefor generalSettings/experiments;command · shortcut · override;provider · window · used% · resets).settings showshould stop inlining the full keybinding table (that is whatkeyboard listis for) — 82 KB today.thread queue list/thread tabs show: one line per item; "No queued messages" / "No tabs" when empty instead of[].
- Docs in the same change (per
docs/cli-guide-and-skill.md): update the affected chapters inpackages/templates/src/templates/bb-guide-{threads,projects,machines,terminals,customization}.mdandapps/server/src/services/skills/builtin-skills/bb-cli/SKILL.mdto 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
- #1768 (closed by #2174) —
bb thread logsilent truncation and paging. Different command; its--format minimal|verbose|jsondesign is a reasonable template for the human/JSON split here. - #1648 (closed) —
bb thread listhad IDs but no titles. That fix addedprintThreadTable, which is the natural renderer forthread searchresults. - Cole's comment on this issue (2026-08-20) proposes grouping history by relative-time bucket, omitting IDs from the human lines, and adding
--after/--before=[ISO|phist_]; the proposed fix above adopts the first two and makes the third conditional on a real consumer.
9. Appendix
Files
2052/repro/bbdev.sh— run the worktree CLI against the isolated dev instance (unsetsBB_CLIto avoid re-exec to the user's global binary).2052/repro/capture.sh— runs every affected command with/without--json, writesout/andout/summary.txt.2052/repro/stats.sh— byte/approx-token sizes and the compact alternative rendering.2052/repro/human-output-json-fallback.test.ts— failing vitest; logvitest-main.clean.txt.out/thread-log.human.txtvsout/thread-log.json.txt— evidence thatthread logis not affected (1,294 vs 20,069 bytes).build.log,install.log,dev-start.log.
Output sizes (human mode, dev instance with one project and one thread)
| Command | bytes | ≈ tokens (bytes/4) |
|---|---|---|
settings show | 82,663 | 20,665 |
settings keyboard list | 39,985 | 9,996 |
project commands --provider codex | 39,248 | 9,812 |
thread search ok (1 hit) | 2,047 | 511 |
machine provider-cli status | 1,916 | 479 |
thread history (2 entries) | 397 | 99 (compact alternative: 90 bytes / ≈23 tokens) |
machine show | 276 | 69 |
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": {
...