feat(cli): thread list defaults to active threads only
CI / check (pull_request) Successful in 2m52s

Closes #147. Changes default behavior of `uwf thread list` to show only
active threads (idle + running). Adds `--all` flag to opt into the
previous full-list behavior. Explicit `--status` still wins over `--all`.

- cmdThreadList gains a `showAll: boolean` parameter (default false)
- CLI registers `--all` option and passes it through
- Test suite includes new `default behavior (issue #147)` describe block
  covering 9 scenarios; existing tests updated where they implicitly
  relied on the old "show everything" behavior
- README, cli-reference, and usage-reference updated to document the
  new default and the `--all` flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:46:25 +00:00
parent e1c7e3d267
commit ae757e4d44
9 changed files with 326 additions and 15 deletions
+3 -1
View File
@@ -49,7 +49,7 @@ bun link packages/cli
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
| `uwf thread show <thread-id>` | Show thread head pointer |
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
| `uwf thread list [--status <status>] [--all] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads (defaults to active: idle + running). Use `--all` to include completed/cancelled/suspended, or `--status` to filter explicitly (idle, running, suspended, completed, cancelled, active, or comma-separated). Supports time range and pagination. |
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
`thread read`, `step list`, and `step show` work on both active and completed threads.
@@ -63,6 +63,8 @@ uwf thread start solve-issue -p "Fix the login redirect bug"
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
uwf thread list
uwf thread list --all
uwf thread list --status running
uwf thread list --status active
uwf thread list --status idle,completed
@@ -384,7 +384,7 @@ describe("currentRole field", () => {
const _compHead = loadActiveThreads(uwfForIndex.varStore)[compId]!.head;
completeThread(uwfForIndex.varStore, compId, "completed");
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100, true);
const idleItem = list.find((i) => i.thread === idleId);
expect(idleItem).toBeDefined();
@@ -167,7 +167,7 @@ describe("cmdThreadList status filter", () => {
expect(result[0]?.status).toBe("completed");
});
test("should return all threads when no status filter provided", async () => {
test("should return only active threads when no filter and no --all", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
@@ -185,8 +185,290 @@ describe("cmdThreadList status filter", () => {
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
// Default behavior (issue #147): only active threads (idle + running)
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
// Clean up marker
await deleteMarker(tmpDir, thread2);
});
test("should return all threads when --all (showAll=true)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const uwfIdx = await createUwfStore(tmpDir);
const index = loadAllThreads(uwfIdx.varStore);
const thread3Head = index[thread3]!.head;
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, null, null, null, null, null, true);
expect(result).toHaveLength(3);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
// Clean up marker
await deleteMarker(tmpDir, thread2);
});
});
// ── default behavior tests (issue #147) ───────────────────────────────────────
describe("cmdThreadList default behavior (issue #147)", () => {
test("default returns only idle + running threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threadA = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const threadC = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const threadD = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, threadB, workflowHash);
const uwfIdx = await createUwfStore(tmpDir);
const index = loadAllThreads(uwfIdx.varStore);
const threadCHead = index[threadC]!.head;
if (threadCHead === undefined) throw new Error("threadC head not found");
await completeThread(tmpDir, threadC, workflowHash, threadCHead);
// Cancel threadD
const threadDHead = index[threadD]!.head;
if (threadDHead === undefined) throw new Error("threadD head not found");
const uwfCancel = await createUwfStore(tmpDir);
completeThreadInStore(uwfCancel.varStore, threadD, "cancelled");
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
await deleteMarker(tmpDir, threadB);
});
test("default excludes completed threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 6000);
const completedThreads: ThreadId[] = [];
for (let i = 0; i < 5; i++) {
const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (5 - i) * 1000);
completedThreads.push(t);
const uwfIdx = await createUwfStore(tmpDir);
const index = loadAllThreads(uwfIdx.varStore);
const head = index[t]!.head;
if (head === undefined) throw new Error("head not found");
await completeThread(tmpDir, t, workflowHash, head);
}
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(idleThread);
});
test("default excludes cancelled threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const cancelled: ThreadId[] = [];
for (let i = 0; i < 3; i++) {
const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (3 - i) * 1000);
cancelled.push(t);
const uwfIdx = await createUwfStore(tmpDir);
completeThreadInStore(uwfIdx.varStore, t, "cancelled");
}
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(runningThread);
await deleteMarker(tmpDir, runningThread);
});
test("--all (showAll=true) returns every status", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const cancelledThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
completeThreadInStore(uwfIdx.varStore, cancelledThread, "cancelled");
const result = await cmdThreadList(tmpDir, null, null, null, null, null, true);
expect(result).toHaveLength(4);
expect(result.map((r) => r.thread).sort()).toEqual(
[idleThread, runningThread, completedThread, cancelledThread].sort(),
);
await deleteMarker(tmpDir, runningThread);
});
test("explicit --status overrides default (still returns just the filtered statuses)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(completedThread);
expect(result[0]?.status).toBe("completed");
await deleteMarker(tmpDir, runningThread);
});
test("--status active keeps working", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([idleThread, runningThread].sort());
await deleteMarker(tmpDir, runningThread);
});
test("--status + --all — explicit status wins", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null, true);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(completedThread);
await deleteMarker(tmpDir, runningThread);
});
test("default returns empty when no threads", async () => {
await makeUwfStore(tmpDir);
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(0);
});
test("default + time range filter composes correctly", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0);
const ts5 = Date.UTC(2026, 4, 24, 0, 0, 0);
const _t1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const t2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const t3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const t4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
const _t5 = await createTestThread(uwf, tmpDir, workflowHash, ts5);
// Mark t3 running
await markThreadRunning(tmpDir, t3, workflowHash);
// Complete t4 (should be excluded by default)
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const t4head = idx[t4]!.head;
if (t4head === undefined) throw new Error("t4 head not found");
await completeThread(tmpDir, t4, workflowHash, t4head);
// afterMs in middle of range to exclude _t1
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
// Expected: t2 (idle), t3 (running), _t5 (idle); excludes t4 (completed) and _t1 (filtered by time)
expect(result).toHaveLength(3);
const ids = result.map((r) => r.thread).sort();
expect(ids).toEqual([t2, t3, _t5].sort());
await deleteMarker(tmpDir, t3);
});
test("default + pagination composes correctly", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
// Create 10 idle threads + 5 completed threads
const idleThreads: ThreadId[] = [];
for (let i = 0; i < 10; i++) {
idleThreads.push(
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (15 - i) * 1000),
);
}
for (let i = 0; i < 5; i++) {
const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (5 - i) * 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const head = idx[t]!.head;
if (head === undefined) throw new Error("head not found");
await completeThread(tmpDir, t, workflowHash, head);
}
const result = await cmdThreadList(tmpDir, null, null, null, 2, 3);
expect(result).toHaveLength(3);
// All results should be idle (default excludes completed)
for (const r of result) {
expect(r.status).toBe("idle");
}
});
});
@@ -118,8 +118,8 @@ describe("suspended thread display", () => {
[idleThreadId]: idleEntry,
});
// Test thread list
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null);
// Test thread list — pass showAll=true to include suspended threads
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null, true);
// Find the suspended and idle threads in results
const suspendedItem = listResult.find((item) => item.thread === suspendedThreadId);
+5 -1
View File
@@ -233,11 +233,12 @@ function parsePaginationOptions(
thread
.command("list")
.description("List threads")
.description("List threads (defaults to active: idle + running)")
.option(
"--status <status>",
"Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
)
.option("--all", "Show all threads regardless of status (overrides default active-only filter)")
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
.option("--skip <n>", "Skip first n threads")
@@ -245,6 +246,7 @@ thread
.action(
(opts: {
status: string | undefined;
all: boolean | undefined;
after: string | undefined;
before: string | undefined;
skip: string | undefined;
@@ -256,6 +258,7 @@ thread
const nowMs = Date.now();
const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
const showAll = opts.all === true;
const result = await cmdThreadList(
storageRoot,
@@ -264,6 +267,7 @@ thread
beforeMs,
skip,
take,
showAll,
);
writeOutput(result);
});
+12 -5
View File
@@ -650,18 +650,25 @@ export async function cmdThreadList(
beforeMs: number | null,
skip: number | null,
take: number | null,
showAll: boolean = false,
): Promise<ThreadListItemWithStatus[]> {
const uwf = await createUwfStore(storageRoot);
const index = loadActiveThreads(uwf.varStore);
// Resolve the effective filter:
// - explicit --status wins (showAll has no effect)
// - otherwise: --all → no filter; default → ["idle", "running"]
const effectiveFilter: ThreadStatus[] | null =
statusFilter !== null ? statusFilter : showAll ? null : ["idle", "running"];
// Collect active threads
let items = await collectActiveThreads(storageRoot, uwf, index);
// Collect completed threads (if relevant for status filter)
const includeCompleted =
statusFilter === null ||
statusFilter.includes("completed") ||
statusFilter.includes("cancelled");
effectiveFilter === null ||
effectiveFilter.includes("completed") ||
effectiveFilter.includes("cancelled");
if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread));
const completedItems = collectCompletedThreads(uwf, activeIds);
@@ -669,8 +676,8 @@ export async function cmdThreadList(
}
// Apply status filter
if (statusFilter !== null) {
items = items.filter((item) => statusFilter.includes(item.status));
if (effectiveFilter !== null) {
items = items.filter((item) => effectiveFilter.includes(item.status));
}
// Apply time range filters
+3 -2
View File
@@ -29,8 +29,9 @@ uwf thread exec <thread-id> # execute one moderator→agen
[-c, --count <number>] # run multiple steps (default: 1)
[--background] # run in background
uwf thread show <thread-id> # show thread head pointer
uwf thread list # list threads
[--status <status>] # filter: idle, running, or completed
uwf thread list # list active threads (idle + running)
[--all] # include completed/cancelled/suspended
[--status <status>] # filter: idle, running, suspended, completed, cancelled, active
uwf thread read <thread-id> # render thread context as markdown
[--quota <chars>] # max output characters (default 32000)
[--before <step-hash>] # load steps before this hash (exclusive)
+3 -2
View File
@@ -67,8 +67,9 @@ uwf thread exec <thread-id> # execute one step
[-c, --count <n>] # run n steps
[--background] # run in background
uwf thread show <thread-id> # show head pointer
uwf thread list # list all threads
[--status <filter>] # idle, running, completed, cancelled, active (comma-separated)
uwf thread list # list active threads (idle + running)
[--all] # include completed/cancelled/suspended
[--status <filter>] # idle, running, suspended, completed, cancelled, active (comma-separated)
[--after <thread-id>] # pagination: after this thread
[--before <thread-id>] # pagination: before this thread
[--skip <n>] # skip first n results