byterover-cli 3.10.3 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +4 -2
  2. package/dist/agent/core/domain/llm/registry.d.ts +12 -0
  3. package/dist/agent/core/domain/llm/registry.js +49 -0
  4. package/dist/agent/core/domain/llm/types.d.ts +6 -0
  5. package/dist/agent/core/interfaces/i-content-generator.d.ts +8 -0
  6. package/dist/agent/infra/llm/agent-llm-service.js +18 -6
  7. package/dist/agent/infra/llm/context/context-manager.d.ts +4 -1
  8. package/dist/agent/infra/llm/context/context-manager.js +5 -1
  9. package/dist/agent/infra/llm/generators/ai-sdk-content-generator.d.ts +13 -0
  10. package/dist/agent/infra/llm/generators/ai-sdk-content-generator.js +19 -6
  11. package/dist/agent/infra/llm/generators/ai-sdk-message-converter.js +16 -4
  12. package/dist/agent/infra/llm/generators/byterover-content-generator.d.ts +1 -0
  13. package/dist/agent/infra/llm/generators/byterover-content-generator.js +4 -1
  14. package/dist/agent/infra/llm/model-capabilities.d.ts +2 -1
  15. package/dist/agent/infra/llm/model-capabilities.js +6 -4
  16. package/dist/agent/infra/llm/providers/anthropic.js +2 -0
  17. package/dist/agent/infra/llm/providers/deepseek.d.ts +10 -0
  18. package/dist/agent/infra/llm/providers/deepseek.js +33 -0
  19. package/dist/agent/infra/llm/providers/glm-coding-plan.d.ts +9 -0
  20. package/dist/agent/infra/llm/providers/glm-coding-plan.js +32 -0
  21. package/dist/agent/infra/llm/providers/index.js +4 -0
  22. package/dist/agent/infra/llm/providers/openrouter.js +2 -0
  23. package/dist/agent/infra/tools/implementations/curate-tool.js +18 -8
  24. package/dist/oclif/commands/query.js +7 -1
  25. package/dist/oclif/lib/task-client.d.ts +9 -0
  26. package/dist/oclif/lib/task-client.js +11 -1
  27. package/dist/server/constants.d.ts +6 -0
  28. package/dist/server/constants.js +11 -0
  29. package/dist/server/core/domain/entities/provider-registry.js +26 -0
  30. package/dist/server/core/domain/entities/task-history-entry.d.ts +775 -0
  31. package/dist/server/core/domain/entities/task-history-entry.js +88 -0
  32. package/dist/server/core/domain/transport/schemas.d.ts +1403 -11
  33. package/dist/server/core/domain/transport/schemas.js +157 -6
  34. package/dist/server/core/domain/transport/task-info.d.ts +18 -0
  35. package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +7 -0
  36. package/dist/server/core/interfaces/storage/i-task-history-store.d.ts +62 -0
  37. package/dist/server/core/interfaces/storage/i-task-history-store.js +1 -0
  38. package/dist/server/infra/daemon/brv-server.js +43 -18
  39. package/dist/server/infra/dream/dream-response-schemas.d.ts +24 -0
  40. package/dist/server/infra/dream/dream-response-schemas.js +7 -0
  41. package/dist/server/infra/dream/operations/consolidate.js +21 -8
  42. package/dist/server/infra/dream/operations/synthesize.js +35 -8
  43. package/dist/server/infra/http/provider-model-fetcher-registry.js +5 -0
  44. package/dist/server/infra/http/provider-model-fetchers.js +54 -27
  45. package/dist/server/infra/process/query-log-handler.d.ts +6 -0
  46. package/dist/server/infra/process/query-log-handler.js +23 -0
  47. package/dist/server/infra/process/task-history-entry-builder.d.ts +36 -0
  48. package/dist/server/infra/process/task-history-entry-builder.js +101 -0
  49. package/dist/server/infra/process/task-history-hook.d.ts +37 -0
  50. package/dist/server/infra/process/task-history-hook.js +70 -0
  51. package/dist/server/infra/process/task-history-store-cache.d.ts +25 -0
  52. package/dist/server/infra/process/task-history-store-cache.js +106 -0
  53. package/dist/server/infra/process/task-router.d.ts +72 -0
  54. package/dist/server/infra/process/task-router.js +690 -15
  55. package/dist/server/infra/process/transport-handlers.d.ts +8 -0
  56. package/dist/server/infra/process/transport-handlers.js +2 -0
  57. package/dist/server/infra/storage/file-task-history-store.d.ts +294 -0
  58. package/dist/server/infra/storage/file-task-history-store.js +912 -0
  59. package/dist/shared/transport/events/index.d.ts +5 -0
  60. package/dist/shared/transport/events/task-events.d.ts +204 -1
  61. package/dist/shared/transport/events/task-events.js +11 -0
  62. package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +7 -0
  63. package/dist/tui/features/tasks/stores/tasks-store.d.ts +4 -16
  64. package/dist/tui/features/tasks/stores/tasks-store.js +7 -0
  65. package/dist/tui/types/messages.d.ts +2 -9
  66. package/dist/webui/assets/index-DyVvFoM6.css +1 -0
  67. package/dist/webui/assets/index-lr0byHh9.js +130 -0
  68. package/dist/webui/index.html +2 -2
  69. package/dist/webui/sw.js +1 -1
  70. package/dist/webui/workbox-9c191d2f.js +1 -0
  71. package/oclif.manifest.json +985 -985
  72. package/package.json +1 -1
  73. package/dist/webui/assets/index-CvcqpMYn.css +0 -1
  74. package/dist/webui/assets/index-thSZZahh.js +0 -130
  75. package/dist/webui/workbox-8c29f6e4.js +0 -1
@@ -21,14 +21,101 @@ import { transportLog } from '../../utils/process-logger.js';
21
21
  import { isValidTaskType } from '../../utils/type-guards.js';
22
22
  import { resolveProject } from '../project/resolve-project.js';
23
23
  import { broadcastToProjectRoom } from './broadcast-utils.js';
24
+ import { buildTaskHistoryEntry } from './task-history-entry-builder.js';
24
25
  /**
25
26
  * Grace period (in ms) to keep completed tasks in memory for late-arriving events.
26
27
  * Prevents silent event drops when llmservice:* events arrive after task:completed.
27
28
  */
28
29
  const TASK_CLEANUP_GRACE_PERIOD_MS = 5000;
30
+ /** Default page size for `task:list` when caller omits `pageSize`. Schema caps at 1000. */
31
+ const DEFAULT_TASK_LIST_PAGE_SIZE = 50;
32
+ /** Statuses considered terminal for delete refusal (M2.09). */
33
+ const TERMINAL_STATUSES = new Set(['cancelled', 'completed', 'error']);
29
34
  function hasTaskId(data) {
30
35
  return typeof data === 'object' && data !== null && 'taskId' in data && typeof data.taskId === 'string';
31
36
  }
37
+ /** Type guard for a plain JSON object — replaces ad-hoc `as Record<string, unknown>` casts. */
38
+ function isRecord(value) {
39
+ return typeof value === 'object' && value !== null;
40
+ }
41
+ /**
42
+ * Bounded-concurrency map for async I/O (M2.16 pass-2 lazy crack of data files).
43
+ * Keeps file-descriptor usage well under macOS default soft limit (256).
44
+ * No external dep (`p-limit` is not installed); ~10 lines hand-roll.
45
+ */
46
+ async function mapBounded(items, limit, fn) {
47
+ const results = Array.from({ length: items.length });
48
+ let nextIdx = 0;
49
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
50
+ while (true) {
51
+ const i = nextIdx++;
52
+ if (i >= items.length)
53
+ return;
54
+ // eslint-disable-next-line no-await-in-loop -- bounded worker pool; awaiting in sequence inside each worker is the point
55
+ results[i] = await fn(items[i]);
56
+ }
57
+ });
58
+ await Promise.all(workers);
59
+ return results;
60
+ }
61
+ /** Concurrency cap for pass-2 lazy crack — keeps FD usage minimal. */
62
+ const FULL_TEXT_CONCURRENCY = 16;
63
+ /** Build a clamped, well-typed empty response (M2.16). */
64
+ function emptyTaskListResponse(data) {
65
+ const pageSize = Math.min(Math.max(data.pageSize ?? DEFAULT_TASK_LIST_PAGE_SIZE, 1), 1000);
66
+ const page = Math.max(data.page ?? 1, 1);
67
+ return {
68
+ availableModels: [],
69
+ availableProviders: [],
70
+ counts: { all: 0, cancelled: 0, completed: 0, failed: 0, running: 0 },
71
+ page,
72
+ pageCount: 1,
73
+ pageSize,
74
+ tasks: [],
75
+ total: 0,
76
+ };
77
+ }
78
+ function matchesListFilters(item, filters) {
79
+ const taskProject = item.projectPath;
80
+ if (taskProject !== undefined && taskProject !== filters.projectFilter)
81
+ return false;
82
+ if (filters.statusFilter && filters.statusFilter.length > 0 && !filters.statusFilter.includes(item.status)) {
83
+ return false;
84
+ }
85
+ if (filters.typeFilter && filters.typeFilter.length > 0 && !filters.typeFilter.includes(item.type)) {
86
+ return false;
87
+ }
88
+ if (filters.providerFilter &&
89
+ filters.providerFilter.length > 0 &&
90
+ (item.provider === undefined || !filters.providerFilter.includes(item.provider)))
91
+ return false;
92
+ if (filters.modelFilter &&
93
+ filters.modelFilter.length > 0 &&
94
+ (item.model === undefined || !filters.modelFilter.includes(item.model)))
95
+ return false;
96
+ if (filters.createdAfter !== undefined && item.createdAt < filters.createdAfter)
97
+ return false;
98
+ if (filters.createdBefore !== undefined && item.createdAt > filters.createdBefore)
99
+ return false;
100
+ if (filters.minDurationMs !== undefined || filters.maxDurationMs !== undefined) {
101
+ if (item.startedAt === undefined || item.completedAt === undefined)
102
+ return false;
103
+ const dur = item.completedAt - item.startedAt;
104
+ if (filters.minDurationMs !== undefined && dur < filters.minDurationMs)
105
+ return false;
106
+ if (filters.maxDurationMs !== undefined && dur > filters.maxDurationMs)
107
+ return false;
108
+ }
109
+ if (filters.searchText !== undefined && filters.searchText.length > 0) {
110
+ const haystack = (item.content + '\n' + (item.error?.message ?? '')).toLowerCase();
111
+ if (!haystack.includes(filters.searchText.toLowerCase()))
112
+ return false;
113
+ }
114
+ return true;
115
+ }
116
+ // `synthesizeEntryFromTaskInfo` was extracted to `task-history-entry-builder.ts`
117
+ // alongside `TaskHistoryHook`'s identical code path. Both consumers now import
118
+ // the same builder, so the two no longer drift.
32
119
  function toListItem(task) {
33
120
  const status = task.status ?? (task.completedAt ? 'completed' : task.startedAt ? 'started' : 'created');
34
121
  return {
@@ -38,7 +125,9 @@ function toListItem(task) {
38
125
  ...(task.error ? { error: task.error } : {}),
39
126
  ...(task.files && task.files.length > 0 ? { files: task.files } : {}),
40
127
  ...(task.folderPath ? { folderPath: task.folderPath } : {}),
128
+ ...(task.model ? { model: task.model } : {}),
41
129
  ...(task.projectPath ? { projectPath: task.projectPath } : {}),
130
+ ...(task.provider ? { provider: task.provider } : {}),
42
131
  ...(task.result ? { result: task.result } : {}),
43
132
  ...(task.startedAt ? { startedAt: task.startedAt } : {}),
44
133
  status,
@@ -47,18 +136,30 @@ function toListItem(task) {
47
136
  };
48
137
  }
49
138
  export class TaskRouter {
139
+ /**
140
+ * Throttle window for `onTaskUpdate` flushes — bursts of llmservice events
141
+ * are coalesced into one save per window. 100ms keeps perceived latency low
142
+ * while bounding write volume on chatty multi-step agents.
143
+ */
144
+ static FLUSH_INTERVAL_MS = 100;
50
145
  agentPool;
51
146
  /**
52
147
  * Track recently completed tasks for grace period.
53
148
  * Allows late-arriving llmservice:* events to be routed even after task:completed.
54
149
  */
55
150
  completedTasks = new Map();
151
+ /** TaskIds with pending in-flight mutations awaiting the next throttled flush. */
152
+ dirtyTaskIds = new Set();
153
+ /** Pending throttle timer, if any. */
154
+ flushTimer;
56
155
  getAgentForProject;
156
+ getTaskHistoryStore;
57
157
  isReviewDisabled;
58
158
  lifecycleHooks;
59
159
  preDispatchCheck;
60
160
  projectRegistry;
61
161
  projectRouter;
162
+ resolveActiveProvider;
62
163
  resolveClientProjectPath;
63
164
  /** Track active tasks */
64
165
  tasks = new Map();
@@ -67,16 +168,23 @@ export class TaskRouter {
67
168
  this.transport = options.transport;
68
169
  this.agentPool = options.agentPool;
69
170
  this.getAgentForProject = options.getAgentForProject;
171
+ this.getTaskHistoryStore = options.getTaskHistoryStore;
70
172
  this.isReviewDisabled = options.isReviewDisabled;
71
173
  this.lifecycleHooks = options.lifecycleHooks ?? [];
72
174
  this.preDispatchCheck = options.preDispatchCheck;
73
175
  this.projectRegistry = options.projectRegistry;
74
176
  this.projectRouter = options.projectRouter;
177
+ this.resolveActiveProvider = options.resolveActiveProvider;
75
178
  this.resolveClientProjectPath = options.resolveClientProjectPath;
76
179
  }
77
180
  clearTasks() {
78
181
  this.tasks.clear();
79
182
  this.completedTasks.clear();
183
+ this.dirtyTaskIds.clear();
184
+ if (this.flushTimer !== undefined) {
185
+ clearTimeout(this.flushTimer);
186
+ this.flushTimer = undefined;
187
+ }
80
188
  }
81
189
  /**
82
190
  * Remove a task from tracking and send error to its client.
@@ -139,6 +247,11 @@ export class TaskRouter {
139
247
  this.transport.onRequest(TransportTaskEventNames.CANCEL, (data, clientId) => this.handleTaskCancel(data, clientId));
140
248
  // Snapshot query from clients (e.g. web UI Tasks tab)
141
249
  this.transport.onRequest(TransportTaskEventNames.LIST, (data, clientId) => this.handleTaskList(data, clientId));
250
+ // M2.09 — persistent-history handlers
251
+ this.transport.onRequest(TransportTaskEventNames.GET, (data, clientId) => this.handleTaskGet(data, clientId));
252
+ this.transport.onRequest(TransportTaskEventNames.DELETE, (data, clientId) => this.handleTaskDelete(data, clientId));
253
+ this.transport.onRequest(TransportTaskEventNames.DELETE_BULK, (data, clientId) => this.handleTaskDeleteBulk(data, clientId));
254
+ this.transport.onRequest(TransportTaskEventNames.CLEAR_COMPLETED, (data, clientId) => this.handleTaskClearCompleted(data, clientId));
142
255
  // Task lifecycle events from agent
143
256
  this.transport.onRequest(TransportTaskEventNames.STARTED, (data) => {
144
257
  this.handleTaskStarted(data);
@@ -157,6 +270,160 @@ export class TaskRouter {
157
270
  this.registerLlmEvent(eventName);
158
271
  }
159
272
  }
273
+ /**
274
+ * Mutate the live `TaskInfo` from an `llmservice:*` event so a tab refresh
275
+ * during the throttle window sees the in-flight state. Each branch:
276
+ * - thinking: push a `{isThinking: true, content: ''}` marker
277
+ * - chunk(reasoning): append to last item / flip empty marker / push fresh
278
+ * - chunk(text): IGNORED for persistence (transient stream)
279
+ * - response: set responseContent + sessionId (overwrite — multi-step keeps latest)
280
+ * - toolCall: push running entry
281
+ * - toolResult: update existing entry by callId
282
+ * - error / unsupportedInput: IGNORED (terminal hooks capture failure)
283
+ *
284
+ * Mutations use immutable `tasks.set(id, {...task, ...delta})` so consumers
285
+ * holding a captured reference (e.g. notifyHooks*) see a stable snapshot.
286
+ */
287
+ accumulateLlmEvent(taskId, eventName, data) {
288
+ const task = this.tasks.get(taskId);
289
+ if (!task)
290
+ return;
291
+ switch (eventName) {
292
+ case LlmEventNames.CHUNK: {
293
+ if (data.type !== 'reasoning')
294
+ return; // 'text' is transient — ignore
295
+ const content = typeof data.content === 'string' ? data.content : '';
296
+ const items = task.reasoningContents ?? [];
297
+ const last = items.at(-1);
298
+ let nextItems;
299
+ if (last === undefined) {
300
+ // Case C: empty array — push fresh body entry.
301
+ nextItems = [{ content, isThinking: false, timestamp: Date.now() }];
302
+ }
303
+ else if (last.isThinking === true && (last.content ?? '') === '') {
304
+ // Case A: flip the empty isThinking marker to body.
305
+ nextItems = [...items.slice(0, -1), { ...last, content, isThinking: false }];
306
+ }
307
+ else {
308
+ // Case B: append to existing body.
309
+ nextItems = [...items.slice(0, -1), { ...last, content: (last.content ?? '') + content }];
310
+ }
311
+ this.tasks.set(taskId, { ...task, reasoningContents: nextItems });
312
+ this.markDirty(taskId);
313
+ return;
314
+ }
315
+ case LlmEventNames.RESPONSE: {
316
+ const content = typeof data.content === 'string' ? data.content : '';
317
+ const sessionId = typeof data.sessionId === 'string' ? data.sessionId : task.sessionId;
318
+ this.tasks.set(taskId, {
319
+ ...task,
320
+ responseContent: content,
321
+ ...(sessionId === undefined ? {} : { sessionId }),
322
+ });
323
+ this.markDirty(taskId);
324
+ return;
325
+ }
326
+ case LlmEventNames.THINKING: {
327
+ // Dedup parity with the TUI store (`tasks-store.ts:127`): if the last
328
+ // reasoning item is already a THINKING marker, skip — the model is
329
+ // about to stream more reasoning content that will be appended to it.
330
+ // Without this, repeated THINKING events from the provider produce
331
+ // multiple empty `{isThinking: true, content: ''}` items in persisted
332
+ // entries, which the live UI never showed.
333
+ const items = task.reasoningContents ?? [];
334
+ const last = items.at(-1);
335
+ if (last?.isThinking === true)
336
+ return;
337
+ const nextItems = [...items, { content: '', isThinking: true, timestamp: Date.now() }];
338
+ this.tasks.set(taskId, { ...task, reasoningContents: nextItems });
339
+ this.markDirty(taskId);
340
+ return;
341
+ }
342
+ case LlmEventNames.TOOL_CALL: {
343
+ const args = isRecord(data.args) ? data.args : {};
344
+ const callId = typeof data.callId === 'string' ? data.callId : undefined;
345
+ const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
346
+ const toolName = typeof data.toolName === 'string' ? data.toolName : '';
347
+ const newCall = {
348
+ args,
349
+ ...(callId === undefined ? {} : { callId }),
350
+ sessionId,
351
+ status: 'running',
352
+ timestamp: Date.now(),
353
+ toolName,
354
+ };
355
+ this.tasks.set(taskId, {
356
+ ...task,
357
+ toolCalls: [...(task.toolCalls ?? []), newCall],
358
+ });
359
+ this.markDirty(taskId);
360
+ return;
361
+ }
362
+ case LlmEventNames.TOOL_RESULT: {
363
+ const callId = typeof data.callId === 'string' ? data.callId : undefined;
364
+ if (callId === undefined)
365
+ return;
366
+ const items = task.toolCalls ?? [];
367
+ const idx = items.findIndex((c) => c.callId === callId);
368
+ if (idx === -1)
369
+ return;
370
+ const success = data.success !== false;
371
+ const updated = {
372
+ ...items[idx],
373
+ ...(typeof data.error === 'string' ? { error: data.error } : {}),
374
+ ...(typeof data.errorType === 'string' ? { errorType: data.errorType } : {}),
375
+ ...(data.result === undefined ? {} : { result: data.result }),
376
+ status: success ? 'completed' : 'error',
377
+ };
378
+ const nextCalls = [...items.slice(0, idx), updated, ...items.slice(idx + 1)];
379
+ this.tasks.set(taskId, { ...task, toolCalls: nextCalls });
380
+ this.markDirty(taskId);
381
+ break;
382
+ }
383
+ // ERROR + UNSUPPORTED_INPUT: ignored — terminal lifecycle hook captures failure.
384
+ default:
385
+ // No mutation; fall through.
386
+ }
387
+ }
388
+ /**
389
+ * Emit `task:deleted` to the project room when a task is removed. Skips
390
+ * silently when no projectPath is resolvable (broadcast wouldn't reach
391
+ * any room). Clients that miss the broadcast will simply not see a row
392
+ * disappear; they reconcile on next `task:list`.
393
+ */
394
+ broadcastTaskDeleted(projectPath, taskId) {
395
+ if (projectPath === undefined)
396
+ return;
397
+ broadcastToProjectRoom(this.projectRegistry, this.projectRouter, projectPath, TransportTaskEventNames.DELETED, {
398
+ taskId,
399
+ });
400
+ }
401
+ /**
402
+ * Drain the dirty set: for each taskId still active, fire `onTaskUpdate` on
403
+ * each lifecycle hook. Tasks moved to `completedTasks` between markDirty
404
+ * and flush are skipped — their terminal lifecycle hook already saved.
405
+ */
406
+ async flushDirty() {
407
+ this.flushTimer = undefined;
408
+ if (this.dirtyTaskIds.size === 0)
409
+ return;
410
+ const ids = [...this.dirtyTaskIds];
411
+ this.dirtyTaskIds.clear();
412
+ for (const taskId of ids) {
413
+ const task = this.tasks.get(taskId);
414
+ if (!task)
415
+ continue;
416
+ for (const hook of this.lifecycleHooks) {
417
+ try {
418
+ // eslint-disable-next-line no-await-in-loop -- sequential hook calls by design
419
+ await hook.onTaskUpdate?.(task);
420
+ }
421
+ catch (error) {
422
+ transportLog(`LifecycleHook.onTaskUpdate error for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
423
+ }
424
+ }
425
+ }
426
+ }
160
427
  handleTaskCancel(data, _clientId) {
161
428
  const { taskId } = data;
162
429
  transportLog(`Task cancel requested: ${taskId}`);
@@ -196,6 +463,39 @@ export class TaskRouter {
196
463
  this.notifyHooksCancelled(taskId, task).catch(() => { });
197
464
  }
198
465
  }
466
+ async handleTaskClearCompleted(data, clientId) {
467
+ const projectFilter = data.projectPath ?? this.resolveClientProjectPath?.(clientId);
468
+ if (projectFilter === undefined)
469
+ return { deletedCount: 0 };
470
+ // In-memory: collect terminal completedTasks for the project.
471
+ const inMemoryIds = [];
472
+ for (const [taskId, { task }] of this.completedTasks) {
473
+ if (task.projectPath !== undefined && task.projectPath !== projectFilter)
474
+ continue;
475
+ inMemoryIds.push(taskId);
476
+ }
477
+ // Persistent: clear matching terminal entries from disk (default statuses).
478
+ let storeIds = [];
479
+ if (this.getTaskHistoryStore !== undefined) {
480
+ try {
481
+ const store = this.getTaskHistoryStore(projectFilter);
482
+ const result = await store.clear({ projectPath: projectFilter });
483
+ storeIds = result.taskIds;
484
+ }
485
+ catch (error) {
486
+ transportLog(`handleTaskClearCompleted: store.clear failed: ${error instanceof Error ? error.message : String(error)}`);
487
+ return { deletedCount: 0, error: 'task history store unavailable' };
488
+ }
489
+ }
490
+ // Remove from in-memory.
491
+ for (const taskId of inMemoryIds)
492
+ this.completedTasks.delete(taskId);
493
+ // Union + dedupe.
494
+ const allIds = new Set([...inMemoryIds, ...storeIds]);
495
+ for (const taskId of allIds)
496
+ this.broadcastTaskDeleted(projectFilter, taskId);
497
+ return { deletedCount: allIds.size };
498
+ }
199
499
  handleTaskCompleted(data) {
200
500
  const { logId: eventLogId, result, taskId } = data;
201
501
  const existing = this.tasks.get(taskId);
@@ -305,6 +605,12 @@ export class TaskRouter {
305
605
  return { taskId };
306
606
  }
307
607
  transportLog(`Task accepted: ${taskId} (type=${data.type}, client=${clientId})`);
608
+ // Resolve active provider/model snapshot. Conditional await preserves the
609
+ // synchronous "store → broadcast" timing when no resolver is configured —
610
+ // an unconditional await would yield a microtask even on an immediately-
611
+ // resolved Promise, breaking tests that assert on broadcasts without
612
+ // awaiting the handler.
613
+ const { model, provider } = this.resolveActiveProvider ? await this.safeResolveActiveProvider() : {};
308
614
  this.tasks.set(taskId, {
309
615
  clientId,
310
616
  content: data.content,
@@ -313,7 +619,9 @@ export class TaskRouter {
313
619
  ...(data.clientCwd ? { clientCwd: data.clientCwd } : {}),
314
620
  ...(data.files?.length ? { files: data.files } : {}),
315
621
  ...(data.folderPath ? { folderPath: data.folderPath } : {}),
622
+ ...(model ? { model } : {}),
316
623
  ...(projectPath ? { projectPath } : {}),
624
+ ...(provider ? { provider } : {}),
317
625
  taskId,
318
626
  type: data.type,
319
627
  ...(worktreeRoot ? { worktreeRoot } : {}),
@@ -324,6 +632,8 @@ export class TaskRouter {
324
632
  ...(data.clientCwd ? { clientCwd: data.clientCwd } : {}),
325
633
  ...(data.files?.length ? { files: data.files } : {}),
326
634
  ...(data.folderPath ? { folderPath: data.folderPath } : {}),
635
+ ...(model ? { model } : {}),
636
+ ...(provider ? { provider } : {}),
327
637
  taskId,
328
638
  type: data.type,
329
639
  };
@@ -441,6 +751,106 @@ export class TaskRouter {
441
751
  });
442
752
  return { ...(logId ? { logId } : {}), taskId };
443
753
  }
754
+ async handleTaskDelete(data, clientId) {
755
+ const { taskId } = data;
756
+ // Refusal: non-terminal in-memory tasks must not be deleted out from under the agent.
757
+ const liveTask = this.tasks.get(taskId);
758
+ if (liveTask !== undefined) {
759
+ const { status } = liveTask;
760
+ if (status !== undefined && !TERMINAL_STATUSES.has(status)) {
761
+ return { error: `cannot delete task in status '${status}'`, removed: false, success: false };
762
+ }
763
+ }
764
+ // Resolve projectPath: in-memory first, then the client's registered project.
765
+ const projectPath = liveTask?.projectPath ??
766
+ this.completedTasks.get(taskId)?.task.projectPath ??
767
+ this.resolveClientProjectPath?.(clientId);
768
+ const wasInMemory = this.tasks.has(taskId) || this.completedTasks.has(taskId);
769
+ this.tasks.delete(taskId);
770
+ this.completedTasks.delete(taskId);
771
+ let wasLive = false;
772
+ if (this.getTaskHistoryStore !== undefined && projectPath !== undefined) {
773
+ try {
774
+ const store = this.getTaskHistoryStore(projectPath);
775
+ wasLive = await store.delete(taskId);
776
+ }
777
+ catch (error) {
778
+ transportLog(`handleTaskDelete: store.delete failed for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
779
+ }
780
+ }
781
+ const removed = wasInMemory || wasLive;
782
+ if (removed)
783
+ this.broadcastTaskDeleted(projectPath, taskId);
784
+ // C4: `removed` distinguishes "actually deleted" from "idempotent no-op".
785
+ // `task:deleteBulk` sums on this flag so unknown / already-tombstoned ids
786
+ // never inflate `deletedCount`. The wire-level `success` stays `true` to
787
+ // preserve the documented idempotent contract for single-delete callers.
788
+ return { removed, success: true };
789
+ }
790
+ async handleTaskDeleteBulk(data, clientId) {
791
+ // N3: batch store.deleteMany per project instead of N×handleTaskDelete.
792
+ // Per-id work splits into (a) liveness/refusal checks + in-memory cleanup
793
+ // (cheap, sequential) and (b) the actual store mutation (expensive,
794
+ // batched). Bulk delete of 200 ids in one project: 1 readIndexDedup +
795
+ // 1 batched tombstone vs the previous 200 readIndexDedups.
796
+ const pending = [];
797
+ for (const taskId of data.taskIds) {
798
+ // Refusal: non-terminal in-memory tasks must not be deleted out from
799
+ // under the agent — same contract as the single-delete handler.
800
+ const liveTask = this.tasks.get(taskId);
801
+ if (liveTask !== undefined) {
802
+ const { status } = liveTask;
803
+ if (status !== undefined && !TERMINAL_STATUSES.has(status))
804
+ continue;
805
+ }
806
+ // Resolve projectPath: in-memory first, then the client's registered project.
807
+ const projectPath = liveTask?.projectPath ??
808
+ this.completedTasks.get(taskId)?.task.projectPath ??
809
+ this.resolveClientProjectPath?.(clientId);
810
+ if (projectPath === undefined)
811
+ continue;
812
+ const wasInMemory = this.tasks.has(taskId) || this.completedTasks.has(taskId);
813
+ this.tasks.delete(taskId);
814
+ this.completedTasks.delete(taskId);
815
+ pending.push({ projectPath, taskId, wasInMemory });
816
+ }
817
+ // Group by projectPath for batched store.deleteMany.
818
+ const byProject = new Map();
819
+ for (const { projectPath, taskId } of pending) {
820
+ const ids = byProject.get(projectPath) ?? [];
821
+ ids.push(taskId);
822
+ byProject.set(projectPath, ids);
823
+ }
824
+ const storeRemoved = new Set();
825
+ if (this.getTaskHistoryStore !== undefined) {
826
+ for (const [projectPath, ids] of byProject) {
827
+ try {
828
+ const store = this.getTaskHistoryStore(projectPath);
829
+ // eslint-disable-next-line no-await-in-loop -- per-project sequential; project count is small
830
+ const removed = await store.deleteMany(ids);
831
+ for (const id of removed)
832
+ storeRemoved.add(id);
833
+ }
834
+ catch (error) {
835
+ transportLog(`handleTaskDeleteBulk: store.deleteMany failed for ${projectPath}: ${error instanceof Error ? error.message : String(error)}`);
836
+ }
837
+ }
838
+ }
839
+ // Final removed set = in-memory hits ∪ store hits. Per-id broadcast for
840
+ // each — preserves the C4 contract (no broadcast for unknown ids).
841
+ const removedSet = new Set();
842
+ const projectByTaskId = new Map();
843
+ for (const { projectPath, taskId, wasInMemory } of pending) {
844
+ if (wasInMemory || storeRemoved.has(taskId)) {
845
+ removedSet.add(taskId);
846
+ projectByTaskId.set(taskId, projectPath);
847
+ }
848
+ }
849
+ for (const taskId of removedSet) {
850
+ this.broadcastTaskDeleted(projectByTaskId.get(taskId), taskId);
851
+ }
852
+ return { deletedCount: removedSet.size };
853
+ }
444
854
  handleTaskError(data) {
445
855
  const { error, taskId } = data;
446
856
  const existing = this.tasks.get(taskId);
@@ -473,25 +883,245 @@ export class TaskRouter {
473
883
  this.notifyHooksError(taskId, error.message, task).catch(() => { });
474
884
  }
475
885
  }
476
- handleTaskList(data, clientId) {
886
+ async handleTaskGet(data, clientId) {
887
+ const { taskId } = data;
888
+ // Try in-memory active first
889
+ const liveTask = this.tasks.get(taskId) ?? this.completedTasks.get(taskId)?.task;
890
+ if (liveTask !== undefined) {
891
+ const synthesized = buildTaskHistoryEntry(liveTask);
892
+ if (synthesized !== undefined)
893
+ return { task: synthesized };
894
+ }
895
+ // Fall back to disk
896
+ if (this.getTaskHistoryStore === undefined)
897
+ return { task: null };
898
+ const projectFilter = liveTask?.projectPath ?? this.resolveClientProjectPath?.(clientId);
899
+ if (projectFilter === undefined)
900
+ return { task: null };
901
+ try {
902
+ const store = this.getTaskHistoryStore(projectFilter);
903
+ const entry = await store.getById(taskId);
904
+ return { task: entry ?? null };
905
+ }
906
+ catch (error) {
907
+ transportLog(`handleTaskGet: store.getById failed for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
908
+ return { task: null };
909
+ }
910
+ }
911
+ // eslint-disable-next-line complexity
912
+ async handleTaskList(data, clientId) {
477
913
  const projectFilter = data.projectPath ?? this.resolveClientProjectPath?.(clientId);
478
- // No resolvable project — return empty rather than leaking every task.
479
- // A client that hasn't registered a project shouldn't see other projects' work.
914
+ // No resolvable project — return empty (don't leak other projects' work).
480
915
  if (projectFilter === undefined)
481
- return { tasks: [] };
482
- const matches = (taskProject) => taskProject === projectFilter || taskProject === undefined;
483
- const items = [];
484
- for (const task of this.tasks.values()) {
485
- if (!matches(task.projectPath))
486
- continue;
487
- items.push(toListItem(task));
916
+ return emptyTaskListResponse(data);
917
+ const inMemoryTaskById = new Map();
918
+ const collectInMemory = (task) => {
919
+ if (task.projectPath !== undefined && task.projectPath !== projectFilter)
920
+ return;
921
+ inMemoryTaskById.set(task.taskId, task);
922
+ };
923
+ for (const task of this.tasks.values())
924
+ collectInMemory(task);
925
+ for (const { task } of this.completedTasks.values())
926
+ collectInMemory(task);
927
+ // Persisted entries (best-effort — tolerate store outages). Push down ONLY
928
+ // non-pivot filters (project + type + time). Pivot filters (status / provider
929
+ // / model) are evaluated at the handler level so derivative sets (counts,
930
+ // availableProviders, availableModels) can apply their exclusion rules.
931
+ let persisted = [];
932
+ if (this.getTaskHistoryStore !== undefined) {
933
+ try {
934
+ const store = this.getTaskHistoryStore(projectFilter);
935
+ persisted = await store.list({
936
+ projectPath: projectFilter,
937
+ ...(data.createdAfter === undefined ? {} : { createdAfter: data.createdAfter }),
938
+ ...(data.createdBefore === undefined ? {} : { createdBefore: data.createdBefore }),
939
+ ...(data.type === undefined ? {} : { type: data.type }),
940
+ });
941
+ }
942
+ catch (error) {
943
+ transportLog(`handleTaskList: store.list failed: ${error instanceof Error ? error.message : String(error)}`);
944
+ }
945
+ }
946
+ // Step 1-2: merge candidates + apply NON-PIVOT filters with pass-1 search
947
+ // (project + type + time + duration + content/error.message search).
948
+ // Status/provider/model are pivot filters — applied later. `available*`
949
+ // dropdowns exclude status+provider+model pivots so users can switch
950
+ // selections without the dropdown shrinking. `counts` reflects the FULL
951
+ // current filter (including status) — chip count = visible row count.
952
+ //
953
+ // Pass-2 (full-result lazy crack) re-uses the same `candidatesNoSearch` map
954
+ // built below to find completed tasks whose result text matches but whose
955
+ // content/error.message did not. `pass2Filter` is the same shape as
956
+ // `nonPivotFilterArgs` minus searchText — the spread auto-inherits any
957
+ // future non-pivot filter dim added upstream.
958
+ const merged = new Map();
959
+ const nonPivotFilterArgs = {
960
+ createdAfter: data.createdAfter,
961
+ createdBefore: data.createdBefore,
962
+ maxDurationMs: data.maxDurationMs,
963
+ minDurationMs: data.minDurationMs,
964
+ projectFilter,
965
+ searchText: data.searchText,
966
+ typeFilter: data.type,
967
+ };
968
+ const pass2Filter = { ...nonPivotFilterArgs, searchText: undefined };
969
+ // Single pass over persisted + in-memory: build `candidatesNoSearch` (all non-pivot
970
+ // filters applied EXCEPT search). `merged` derives from it by re-applying the search
971
+ // predicate via `matchesListFilters(item, nonPivotFilterArgs)`. Saves a 2×N traversal
972
+ // when searchText is set; same cost when unset.
973
+ const candidatesNoSearch = new Map();
974
+ for (const item of persisted) {
975
+ if (matchesListFilters(item, pass2Filter))
976
+ candidatesNoSearch.set(item.taskId, item);
977
+ }
978
+ for (const task of inMemoryTaskById.values()) {
979
+ const item = toListItem(task);
980
+ if (matchesListFilters(item, pass2Filter))
981
+ candidatesNoSearch.set(item.taskId, item);
982
+ }
983
+ for (const [taskId, item] of candidatesNoSearch) {
984
+ if (matchesListFilters(item, nonPivotFilterArgs))
985
+ merged.set(taskId, item);
488
986
  }
489
- for (const { task } of this.completedTasks.values()) {
490
- if (!matches(task.projectPath))
987
+ // Step 3-4: pass-2 search (full-text via lazy data-file crack).
988
+ // Only when searchText is set AND the row has status='completed' AND it didn't match pass-1.
989
+ // For in-memory tasks we read result directly from TaskInfo (no I/O); for
990
+ // persisted we call store.getById, swallowing file-race errors.
991
+ if (data.searchText !== undefined && data.searchText.length > 0) {
992
+ const needle = data.searchText.toLowerCase();
993
+ const completedUnmatched = [];
994
+ for (const item of candidatesNoSearch.values()) {
995
+ if (item.status !== 'completed')
996
+ continue;
997
+ if (merged.has(item.taskId))
998
+ continue;
999
+ completedUnmatched.push(item);
1000
+ }
1001
+ // Log race errors at most once per query to avoid log spam during compaction storms.
1002
+ // The rest of the racing reads still execute; we just don't write N log lines.
1003
+ let raceLogged = false;
1004
+ const matchedIds = await mapBounded(completedUnmatched, FULL_TEXT_CONCURRENCY, async (item) => {
1005
+ // In-memory task — match against task.result directly (no I/O).
1006
+ // Invariant: in-memory tasks with status='completed' always have task.result
1007
+ // defined because handleTaskCompleted sets it synchronously with the status
1008
+ // transition (TaskCompletedEvent.result is required by schema). Persisted
1009
+ // snapshots are derived from this same TaskInfo, so no in-memory/disk
1010
+ // result divergence is possible — fallback to getById is unnecessary here.
1011
+ const inMem = inMemoryTaskById.get(item.taskId);
1012
+ if (inMem !== undefined) {
1013
+ if (inMem.result !== undefined && inMem.result.toLowerCase().includes(needle))
1014
+ return item.taskId;
1015
+ return;
1016
+ }
1017
+ // Persisted — load full entry via store.getById.
1018
+ if (this.getTaskHistoryStore === undefined)
1019
+ return;
1020
+ try {
1021
+ const store = this.getTaskHistoryStore(projectFilter);
1022
+ const entry = await store.getById(item.taskId);
1023
+ if (entry?.status === 'completed' && entry.result?.toLowerCase().includes(needle)) {
1024
+ return item.taskId;
1025
+ }
1026
+ }
1027
+ catch (error) {
1028
+ // Swallow file-race (concurrent delete/compaction) — treat as no-match.
1029
+ if (!raceLogged) {
1030
+ raceLogged = true;
1031
+ transportLog(`handleTaskList: pass-2 getById(${item.taskId}) failed: ${error instanceof Error ? error.message : String(error)} (further race errors in this query suppressed)`);
1032
+ }
1033
+ }
1034
+ });
1035
+ for (const id of matchedIds) {
1036
+ if (id === undefined)
1037
+ continue;
1038
+ const fromMap = candidatesNoSearch.get(id);
1039
+ if (fromMap !== undefined)
1040
+ merged.set(id, fromMap);
1041
+ }
1042
+ }
1043
+ // Step 5: nonPivotFull = merged. Derive availableProviders/availableModels.
1044
+ // Guard both provider AND model length > 0 — wire/index schemas accept empty
1045
+ // strings, which would otherwise emit phantom entries like {providerId: 'openai', modelId: ''}.
1046
+ const availableProviderSet = new Set();
1047
+ const availableModelMap = new Map();
1048
+ for (const item of merged.values()) {
1049
+ const providerId = item.provider;
1050
+ const modelId = item.model;
1051
+ if (providerId !== undefined && providerId.length > 0)
1052
+ availableProviderSet.add(providerId);
1053
+ if (providerId !== undefined && providerId.length > 0 && modelId !== undefined && modelId.length > 0) {
1054
+ const key = `${providerId}|${modelId}`;
1055
+ if (!availableModelMap.has(key))
1056
+ availableModelMap.set(key, { modelId, providerId });
1057
+ }
1058
+ }
1059
+ const availableProviders = [...availableProviderSet].sort((a, b) => a.localeCompare(b));
1060
+ const availableModels = [...availableModelMap.values()].sort((a, b) => {
1061
+ const p = a.providerId.localeCompare(b.providerId);
1062
+ return p === 0 ? a.modelId.localeCompare(b.modelId) : p;
1063
+ });
1064
+ // Step 6: apply pivot filters (provider + model + status) → allFiltered.
1065
+ // Hoist into consts so TS narrows the loop captures (avoids `!` assertion per CLAUDE.md).
1066
+ const providerFilter = data.provider;
1067
+ const modelFilter = data.model;
1068
+ const statusFilter = data.status;
1069
+ const allFiltered = [];
1070
+ for (const item of merged.values()) {
1071
+ if (providerFilter &&
1072
+ providerFilter.length > 0 &&
1073
+ (item.provider === undefined || !providerFilter.includes(item.provider))) {
1074
+ continue;
1075
+ }
1076
+ if (modelFilter && modelFilter.length > 0 && (item.model === undefined || !modelFilter.includes(item.model))) {
1077
+ continue;
1078
+ }
1079
+ if (statusFilter && statusFilter.length > 0 && !statusFilter.includes(item.status))
491
1080
  continue;
492
- items.push(toListItem(task));
1081
+ allFiltered.push(item);
493
1082
  }
494
- return { tasks: items };
1083
+ // Step 7: counts = status histogram of allFiltered (matches current filter scope — Model A).
1084
+ // Chip count == visible row count; counts.all === total invariant.
1085
+ const counts = { all: allFiltered.length, cancelled: 0, completed: 0, failed: 0, running: 0 };
1086
+ for (const item of allFiltered) {
1087
+ switch (item.status) {
1088
+ case 'cancelled': {
1089
+ counts.cancelled++;
1090
+ break;
1091
+ }
1092
+ case 'completed': {
1093
+ counts.completed++;
1094
+ break;
1095
+ }
1096
+ case 'created':
1097
+ case 'started': {
1098
+ counts.running++;
1099
+ break;
1100
+ }
1101
+ case 'error': {
1102
+ counts.failed++;
1103
+ break;
1104
+ }
1105
+ }
1106
+ }
1107
+ // Step 8: Sort (createdAt DESC, taskId DESC) — stable secondary order for same-millisecond clusters.
1108
+ allFiltered.sort((a, b) => {
1109
+ if (b.createdAt !== a.createdAt)
1110
+ return b.createdAt - a.createdAt;
1111
+ if (b.taskId > a.taskId)
1112
+ return 1;
1113
+ if (b.taskId < a.taskId)
1114
+ return -1;
1115
+ return 0;
1116
+ });
1117
+ // Step 9: paginate. Wire shape unchanged (result preserved per existing toListItem behavior).
1118
+ const pageSize = Math.min(Math.max(data.pageSize ?? DEFAULT_TASK_LIST_PAGE_SIZE, 1), 1000);
1119
+ const page = Math.max(data.page ?? 1, 1);
1120
+ const total = allFiltered.length;
1121
+ const pageCount = Math.max(Math.ceil(total / pageSize), 1);
1122
+ const start = (page - 1) * pageSize;
1123
+ const tasks = allFiltered.slice(start, start + pageSize);
1124
+ return { availableModels, availableProviders, counts, page, pageCount, pageSize, tasks, total };
495
1125
  }
496
1126
  /**
497
1127
  * Emit `task:completed` for a task that the daemon's pre-dispatch gate skipped
@@ -525,6 +1155,8 @@ export class TaskRouter {
525
1155
  const task = this.tasks.get(taskId);
526
1156
  if (task) {
527
1157
  this.tasks.set(taskId, { ...task, startedAt: Date.now(), status: 'started' });
1158
+ // No `onTaskStarted` hook — capture the transition via the throttled flush.
1159
+ this.markDirty(taskId);
528
1160
  this.transport.sendTo(task.clientId, TransportTaskEventNames.STARTED, { taskId });
529
1161
  broadcastToProjectRoom(this.projectRegistry, this.projectRouter, task.projectPath, TransportTaskEventNames.STARTED, {
530
1162
  content: task.content,
@@ -539,6 +1171,24 @@ export class TaskRouter {
539
1171
  transportLog(`Task started but no task context found: ${taskId}`);
540
1172
  }
541
1173
  }
1174
+ /**
1175
+ * Mark a taskId for the next throttled `onTaskUpdate` flush.
1176
+ * Schedules a timer if none is pending. Bursts of dirty marks within the
1177
+ * 100ms window coalesce into a single flush.
1178
+ */
1179
+ markDirty(taskId) {
1180
+ this.dirtyTaskIds.add(taskId);
1181
+ if (this.flushTimer !== undefined)
1182
+ return;
1183
+ this.flushTimer = setTimeout(() => {
1184
+ this.flushDirty().catch(() => {
1185
+ // flushDirty already swallows per-hook errors; this catch covers
1186
+ // unexpected scheduler-level failures.
1187
+ });
1188
+ }, TaskRouter.FLUSH_INTERVAL_MS);
1189
+ // unref so a pending flush doesn't block daemon shutdown.
1190
+ this.flushTimer.unref?.();
1191
+ }
542
1192
  /**
543
1193
  * Move a task to the completed tasks map with grace period cleanup.
544
1194
  */
@@ -547,9 +1197,11 @@ export class TaskRouter {
547
1197
  if (task) {
548
1198
  this.completedTasks.set(taskId, { completedAt: Date.now(), task });
549
1199
  this.tasks.delete(taskId);
550
- setTimeout(() => {
1200
+ const timer = setTimeout(() => {
551
1201
  this.completedTasks.delete(taskId);
552
1202
  }, TASK_CLEANUP_GRACE_PERIOD_MS);
1203
+ // Don't keep the event loop alive purely for completed-task GC.
1204
+ timer.unref?.();
553
1205
  }
554
1206
  }
555
1207
  /**
@@ -660,6 +1312,13 @@ export class TaskRouter {
660
1312
  if (!task) {
661
1313
  return;
662
1314
  }
1315
+ // Accumulator: mutate the live `TaskInfo` BEFORE broadcasting so a tab
1316
+ // refresh during the next throttle window sees the in-flight state.
1317
+ // Only mutates for ACTIVE tasks — grace-period entries already had their
1318
+ // terminal save persisted by the lifecycle hook.
1319
+ if (activeTask) {
1320
+ this.accumulateLlmEvent(taskId, eventName, data);
1321
+ }
663
1322
  // Notify onToolResult hooks only for active tasks
664
1323
  if (activeTask && eventName === LlmEventNames.TOOL_RESULT) {
665
1324
  for (const hook of this.lifecycleHooks) {
@@ -697,6 +1356,22 @@ export class TaskRouter {
697
1356
  }));
698
1357
  return logIds.find((id) => typeof id === 'string');
699
1358
  }
1359
+ /**
1360
+ * Invoke `resolveActiveProvider` with a try/catch so a thrown resolver
1361
+ * cannot block task dispatch. Returns `{}` when no resolver is configured
1362
+ * or when the resolver rejects/throws.
1363
+ */
1364
+ async safeResolveActiveProvider() {
1365
+ if (!this.resolveActiveProvider)
1366
+ return {};
1367
+ try {
1368
+ return await this.resolveActiveProvider();
1369
+ }
1370
+ catch (error) {
1371
+ transportLog(`resolveActiveProvider failed: ${error instanceof Error ? error.message : String(error)}`);
1372
+ return {};
1373
+ }
1374
+ }
700
1375
  /**
701
1376
  * Reads the project's reviewDisabled flag at task-create.
702
1377
  *