byterover-cli 3.10.2 → 3.11.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 (90) 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/oclif/commands/query.js +7 -1
  24. package/dist/oclif/lib/task-client.d.ts +9 -0
  25. package/dist/oclif/lib/task-client.js +11 -1
  26. package/dist/server/core/domain/entities/provider-registry.js +26 -0
  27. package/dist/server/infra/daemon/brv-server.js +4 -0
  28. package/dist/server/infra/http/provider-model-fetcher-registry.js +5 -0
  29. package/dist/server/infra/http/provider-model-fetchers.js +54 -27
  30. package/dist/server/infra/mcp/mcp-server.d.ts +6 -0
  31. package/dist/server/infra/mcp/mcp-server.js +15 -3
  32. package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +1 -1
  33. package/dist/server/infra/mcp/tools/brv-curate-tool.js +4 -2
  34. package/dist/server/infra/mcp/tools/brv-query-tool.d.ts +1 -1
  35. package/dist/server/infra/mcp/tools/brv-query-tool.js +3 -2
  36. package/dist/server/infra/mcp/tools/drift-footer.d.ts +8 -0
  37. package/dist/server/infra/mcp/tools/drift-footer.js +16 -0
  38. package/dist/server/infra/process/connection-coordinator.d.ts +7 -0
  39. package/dist/server/infra/process/connection-coordinator.js +5 -0
  40. package/dist/server/infra/process/query-log-handler.d.ts +6 -0
  41. package/dist/server/infra/process/query-log-handler.js +23 -0
  42. package/dist/server/infra/process/transport-handlers.d.ts +5 -0
  43. package/dist/server/infra/process/transport-handlers.js +1 -0
  44. package/dist/tui/components/header.js +7 -1
  45. package/dist/tui/components/logo.d.ts +6 -0
  46. package/dist/tui/components/logo.js +18 -5
  47. package/dist/tui/features/transport/components/transport-initializer.js +8 -2
  48. package/dist/tui/stores/transport-store.d.ts +8 -0
  49. package/dist/tui/stores/transport-store.js +2 -0
  50. package/dist/webui/assets/index--sXE__bc.css +1 -0
  51. package/dist/webui/assets/{index-thSZZahh.js → index-Bkkx961b.js} +63 -63
  52. package/dist/webui/index.html +2 -2
  53. package/dist/webui/sw.js +1 -1
  54. package/dist/webui/workbox-9c191d2f.js +1 -0
  55. package/node_modules/@campfirein/brv-transport-client/dist/core/interfaces/i-client.d.ts +14 -0
  56. package/node_modules/@campfirein/brv-transport-client/dist/core/interfaces/i-client.d.ts.map +1 -1
  57. package/node_modules/@campfirein/brv-transport-client/dist/index.d.ts +1 -0
  58. package/node_modules/@campfirein/brv-transport-client/dist/index.d.ts.map +1 -1
  59. package/node_modules/@campfirein/brv-transport-client/dist/index.js +2 -0
  60. package/node_modules/@campfirein/brv-transport-client/dist/index.js.map +1 -1
  61. package/node_modules/@campfirein/brv-transport-client/dist/infra/client-factory.d.ts.map +1 -1
  62. package/node_modules/@campfirein/brv-transport-client/dist/infra/client-factory.js +5 -0
  63. package/node_modules/@campfirein/brv-transport-client/dist/infra/client-factory.js.map +1 -1
  64. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-discovery-sync.d.ts +9 -7
  65. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-discovery-sync.d.ts.map +1 -1
  66. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-discovery-sync.js +11 -9
  67. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-discovery-sync.js.map +1 -1
  68. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-health.d.ts +23 -6
  69. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-health.d.ts.map +1 -1
  70. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-health.js +11 -5
  71. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-health.js.map +1 -1
  72. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-spawner.js +7 -7
  73. package/node_modules/@campfirein/brv-transport-client/dist/infra/daemon-spawner.js.map +1 -1
  74. package/node_modules/@campfirein/brv-transport-client/dist/infra/schemas/schemas.d.ts +7 -0
  75. package/node_modules/@campfirein/brv-transport-client/dist/infra/schemas/schemas.d.ts.map +1 -1
  76. package/node_modules/@campfirein/brv-transport-client/dist/infra/schemas/schemas.js +5 -0
  77. package/node_modules/@campfirein/brv-transport-client/dist/infra/schemas/schemas.js.map +1 -1
  78. package/node_modules/@campfirein/brv-transport-client/dist/infra/socket-io-client.d.ts +8 -0
  79. package/node_modules/@campfirein/brv-transport-client/dist/infra/socket-io-client.d.ts.map +1 -1
  80. package/node_modules/@campfirein/brv-transport-client/dist/infra/socket-io-client.js +15 -0
  81. package/node_modules/@campfirein/brv-transport-client/dist/infra/socket-io-client.js.map +1 -1
  82. package/node_modules/@campfirein/brv-transport-client/dist/infra/version-utils.d.ts +35 -0
  83. package/node_modules/@campfirein/brv-transport-client/dist/infra/version-utils.d.ts.map +1 -0
  84. package/node_modules/@campfirein/brv-transport-client/dist/infra/version-utils.js +59 -0
  85. package/node_modules/@campfirein/brv-transport-client/dist/infra/version-utils.js.map +1 -0
  86. package/node_modules/@campfirein/brv-transport-client/package.json +1 -1
  87. package/oclif.manifest.json +206 -206
  88. package/package.json +4 -4
  89. package/dist/webui/assets/index-CvcqpMYn.css +0 -1
  90. package/dist/webui/workbox-8c29f6e4.js +0 -1
@@ -7,6 +7,7 @@
7
7
  * Used by: curate, query commands.
8
8
  */
9
9
  import type { ITransportClient } from '@campfirein/brv-transport-client';
10
+ import type { QueryLogMatchedDoc, QueryLogTier } from '../../server/core/domain/entities/query-log-entry.js';
10
11
  /** Collected tool call with result (mirrors TUI ToolCallEvent) */
11
12
  export interface ToolCallRecord {
12
13
  args: Record<string, unknown>;
@@ -19,7 +20,11 @@ export interface ToolCallRecord {
19
20
  }
20
21
  /** Completion result passed to onCompleted callback */
21
22
  export interface TaskCompletionResult {
23
+ /** Wall-clock execution time for query tasks, in milliseconds. Absent for non-query tasks. */
24
+ durationMs?: number;
22
25
  logId?: string;
26
+ /** Documents matched by the query. Empty array on cache hits; absent for non-query tasks. */
27
+ matchedDocs?: QueryLogMatchedDoc[];
23
28
  /** Pending review notification from the server, present when review is required after task completion. */
24
29
  pendingReview?: {
25
30
  pendingCount: number;
@@ -27,7 +32,11 @@ export interface TaskCompletionResult {
27
32
  };
28
33
  result?: string;
29
34
  taskId: string;
35
+ /** Resolution tier for query tasks. Absent for non-query tasks. */
36
+ tier?: QueryLogTier;
30
37
  toolCalls: ToolCallRecord[];
38
+ /** Top compound score across matchedDocs. Absent for cache hits and non-query tasks. */
39
+ topScore?: number;
31
40
  }
32
41
  /** Error result passed to onError callback */
33
42
  export interface TaskErrorResult {
@@ -195,7 +195,17 @@ export function waitForTaskCompletion(options, log) {
195
195
  const resolvedPendingReview = payload.pendingReviewCount !== undefined && payload.pendingReviewCount > 0
196
196
  ? { pendingCount: payload.pendingReviewCount, reviewUrl: pendingReview?.reviewUrl ?? '' }
197
197
  : pendingReview;
198
- onCompleted({ logId: payload.logId, pendingReview: resolvedPendingReview, result: payload.result, taskId, toolCalls });
198
+ onCompleted({
199
+ durationMs: payload.durationMs,
200
+ logId: payload.logId,
201
+ matchedDocs: payload.matchedDocs,
202
+ pendingReview: resolvedPendingReview,
203
+ result: payload.result,
204
+ taskId,
205
+ tier: payload.tier,
206
+ toolCalls,
207
+ topScore: payload.topScore,
208
+ });
199
209
  resolve();
200
210
  }),
201
211
  // Task error
@@ -72,6 +72,19 @@ export const PROVIDER_REGISTRY = {
72
72
  name: 'DeepInfra',
73
73
  priority: 10,
74
74
  },
75
+ deepseek: {
76
+ apiKeyUrl: 'https://platform.deepseek.com/api_keys',
77
+ baseUrl: 'https://api.deepseek.com/v1',
78
+ category: 'other',
79
+ defaultModel: 'deepseek-chat',
80
+ description: 'DeepSeek V3 and R1 reasoning models',
81
+ envVars: ['DEEPSEEK_API_KEY'],
82
+ headers: {},
83
+ id: 'deepseek',
84
+ modelsEndpoint: '/models',
85
+ name: 'DeepSeek',
86
+ priority: 19,
87
+ },
75
88
  glm: {
76
89
  apiKeyUrl: 'https://open.z.ai',
77
90
  baseUrl: 'https://api.z.ai/api/paas/v4',
@@ -85,6 +98,19 @@ export const PROVIDER_REGISTRY = {
85
98
  name: 'GLM (Z.AI)',
86
99
  priority: 17,
87
100
  },
101
+ 'glm-coding-plan': {
102
+ apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list',
103
+ baseUrl: 'https://api.z.ai/api/coding/paas/v4',
104
+ category: 'other',
105
+ defaultModel: 'glm-4.7',
106
+ description: 'GLM models on the Z.AI Coding Plan subscription',
107
+ envVars: ['ZHIPU_API_KEY'],
108
+ headers: {},
109
+ id: 'glm-coding-plan',
110
+ modelsEndpoint: '',
111
+ name: 'GLM Coding Plan (Z.AI)',
112
+ priority: 17.5,
113
+ },
88
114
  google: {
89
115
  apiKeyUrl: 'https://aistudio.google.com/apikey',
90
116
  baseUrl: '',
@@ -337,6 +337,10 @@ async function main() {
337
337
  const transportHandlers = new TransportHandlers({
338
338
  agentPool,
339
339
  clientManager,
340
+ // The version we read at startup gets relayed in the client:register ack
341
+ // so peer clients (TUI / MCP) can render drift indicators without an
342
+ // extra round-trip.
343
+ daemonVersion: version,
340
344
  // Resolves the project's review-disabled flag once at task-create. The result
341
345
  // is stamped onto TaskInfo + TaskExecute so daemon hooks (CurateLogHandler) and
342
346
  // the agent process (curate-tool backups, dream review entries) all observe a
@@ -45,6 +45,7 @@ export async function getModelFetcher(providerId) {
45
45
  case 'cerebras': // falls through
46
46
  case 'cohere': // falls through
47
47
  case 'deepinfra': // falls through
48
+ case 'deepseek': // falls through
48
49
  case 'groq': // falls through
49
50
  case 'mistral': // falls through
50
51
  case 'togetherai': // falls through
@@ -59,6 +60,10 @@ export async function getModelFetcher(providerId) {
59
60
  fetcher = new ChatBasedModelFetcher('https://api.z.ai/api/paas/v4', 'GLM (Z.AI)', ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-flash']);
60
61
  break;
61
62
  }
63
+ case 'glm-coding-plan': {
64
+ fetcher = new ChatBasedModelFetcher('https://api.z.ai/api/coding/paas/v4', 'GLM Coding Plan (Z.AI)', ['glm-4.7', 'glm-4.7-flash', 'glm-4.7-flashx', 'glm-5-turbo', 'glm-4.5', 'glm-4.5-flash']);
65
+ break;
66
+ }
62
67
  case 'google': {
63
68
  fetcher = new GoogleModelFetcher();
64
69
  break;
@@ -429,36 +429,63 @@ export class ChatBasedModelFetcher {
429
429
  return this.knownModels;
430
430
  }
431
431
  async validateApiKey(apiKey) {
432
- try {
433
- await axios.post(`${this.baseUrl}/chat/completions`, {
434
- max_tokens: 1,
435
- messages: [{ content: 'hi', role: 'user' }],
436
- model: this.knownModels[0]?.id ?? 'default',
437
- }, {
438
- headers: {
439
- Authorization: `Bearer ${apiKey}`,
440
- 'Content-Type': 'application/json',
441
- },
442
- httpAgent: ProxyConfig.getProxyAgent(),
443
- httpsAgent: ProxyConfig.getProxyAgent(),
444
- proxy: false,
445
- timeout: 15_000,
446
- });
447
- return { isValid: true };
448
- }
449
- catch (error) {
450
- if (isAxiosError(error)) {
451
- if (error.response?.status === 401) {
452
- return { error: 'Invalid API key', isValid: false };
453
- }
454
- if (error.response?.status === 403) {
455
- return { error: 'API key does not have required permissions', isValid: false };
456
- }
457
- // Other errors (429, 400, etc.) mean the key was accepted
432
+ // Iterate through known models so a single missing model on a tier (e.g.
433
+ // GLM Coding Plan doesn't yet serve the latest glm-4.7) doesn't
434
+ // misclassify a valid key as invalid. We accept the key as soon as ANY
435
+ // model responds successfully, OR returns a non-auth error like 429/5xx
436
+ // (which still proves the key passed auth).
437
+ const candidates = this.knownModels.length > 0 ? this.knownModels : [{ id: 'default' }];
438
+ let lastNonAuthError;
439
+ for (const candidate of candidates) {
440
+ try {
441
+ // eslint-disable-next-line no-await-in-loop
442
+ await axios.post(`${this.baseUrl}/chat/completions`, {
443
+ max_tokens: 1,
444
+ messages: [{ content: 'hi', role: 'user' }],
445
+ model: candidate.id,
446
+ }, {
447
+ headers: {
448
+ Authorization: `Bearer ${apiKey}`,
449
+ 'Content-Type': 'application/json',
450
+ },
451
+ httpAgent: ProxyConfig.getProxyAgent(),
452
+ httpsAgent: ProxyConfig.getProxyAgent(),
453
+ proxy: false,
454
+ timeout: 15_000,
455
+ });
458
456
  return { isValid: true };
459
457
  }
460
- return { error: error instanceof Error ? error.message : 'Unknown error', isValid: false };
458
+ catch (error) {
459
+ if (isAxiosError(error)) {
460
+ if (error.response?.status === 401) {
461
+ return { error: 'Invalid API key', isValid: false };
462
+ }
463
+ if (error.response?.status === 403) {
464
+ return { error: 'API key does not have required permissions', isValid: false };
465
+ }
466
+ // 400/404 may mean "model not available on this tier" — try next.
467
+ if (error.response?.status === 400 || error.response?.status === 404) {
468
+ lastNonAuthError = error;
469
+ continue;
470
+ }
471
+ // Axios errors that are not 401/403/400/404 (e.g. 429, 5xx, or
472
+ // network-level errors with no response like ECONNREFUSED) are
473
+ // treated as "key accepted" — either auth was passed (429/5xx) or
474
+ // we can't determine otherwise (no response). Optimistic: prefer a
475
+ // false-positive valid over a false-negative invalid.
476
+ return { isValid: true };
477
+ }
478
+ lastNonAuthError = error;
479
+ }
461
480
  }
481
+ // Every candidate model returned 400/404 or a non-axios error and none
482
+ // gave us a positive auth signal. Treat the key as inconclusive — but
483
+ // since 401/403 was never observed, surface the last error so the user
484
+ // can see the real cause (often a model-availability issue, not auth).
485
+ return {
486
+ error: lastNonAuthError instanceof Error ? lastNonAuthError.message : 'Validation failed for all known models',
487
+ isValid: false,
488
+ };
462
489
  }
463
490
  }
464
491
  // ============================================================================
@@ -57,6 +57,12 @@ export declare class ByteRoverMcpServer {
57
57
  * Log to stderr (stdout is reserved for MCP protocol).
58
58
  */
59
59
  private log;
60
+ /**
61
+ * Logs a one-line drift notice when the running daemon's version differs
62
+ * from this MCP's. Helps users notice an out-of-sync IDE without forcing
63
+ * a reconnect — the protocol is backward-compatible across the gap.
64
+ */
65
+ private logDaemonVersionDrift;
60
66
  /**
61
67
  * Sends the cached agent name to the daemon (fire-and-forget).
62
68
  * Called after MCP initialize handshake and on Socket.IO reconnection.
@@ -1,4 +1,4 @@
1
- import { connectToDaemon, createDaemonReconnector, } from '@campfirein/brv-transport-client';
1
+ import { connectToDaemon, createDaemonReconnector, versionsAreEquivalent, } from '@campfirein/brv-transport-client';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { TransportClientEventNames } from '../../core/domain/transport/schemas.js';
@@ -55,8 +55,8 @@ export class ByteRoverMcpServer {
55
55
  : undefined;
56
56
  // Register tools with lazy client getter
57
57
  // Client will be set when start() is called
58
- registerBrvQueryTool(this.server, () => this.client, () => this.getWorkingDirectory(), getStartupProjectContext);
59
- registerBrvCurateTool(this.server, () => this.client, () => this.getWorkingDirectory(), getStartupProjectContext);
58
+ registerBrvQueryTool(this.server, () => this.client, () => this.getWorkingDirectory(), getStartupProjectContext, config.version);
59
+ registerBrvCurateTool(this.server, () => this.client, () => this.getWorkingDirectory(), getStartupProjectContext, config.version);
60
60
  }
61
61
  /**
62
62
  * Starts the MCP server.
@@ -80,12 +80,14 @@ export class ByteRoverMcpServer {
80
80
  this.log(`Connected to brv instance at ${result.projectRoot}`);
81
81
  this.log(`Client ID: ${result.client.getClientId()}`);
82
82
  this.log(`Initial connection state: ${result.client.getState()}`);
83
+ this.logDaemonVersionDrift(result.client.getDaemonVersion?.());
83
84
  // Auto-reconnect on disconnect (shared logic from brv-transport-client)
84
85
  this.reconnectorHandle = createDaemonReconnector(result.client, {
85
86
  connectOptions: this.connectOptions,
86
87
  onReconnected: (newClient) => {
87
88
  this.client = newClient;
88
89
  this.log(`Reconnected successfully! Client ID: ${newClient.getClientId()}`);
90
+ this.logDaemonVersionDrift(newClient.getDaemonVersion?.());
89
91
  this.sendAgentName();
90
92
  },
91
93
  onStateChange: (state) => {
@@ -157,6 +159,16 @@ export class ByteRoverMcpServer {
157
159
  log(msg) {
158
160
  process.stderr.write(`[brv-mcp] ${msg}\n`);
159
161
  }
162
+ /**
163
+ * Logs a one-line drift notice when the running daemon's version differs
164
+ * from this MCP's. Helps users notice an out-of-sync IDE without forcing
165
+ * a reconnect — the protocol is backward-compatible across the gap.
166
+ */
167
+ logDaemonVersionDrift(daemonVersion) {
168
+ if (daemonVersion && !versionsAreEquivalent(this.config.version, daemonVersion)) {
169
+ this.log(`connected to daemon ${daemonVersion}; this MCP is ${this.config.version} (backward-compatible protocol)`);
170
+ }
171
+ }
160
172
  /**
161
173
  * Sends the cached agent name to the daemon (fire-and-forget).
162
174
  * Called after MCP initialize handshake and on Socket.IO reconnection.
@@ -27,4 +27,4 @@ export declare const BrvCurateInputSchema: z.ZodObject<{
27
27
  * Uses fire-and-forget pattern: returns immediately after queueing the task.
28
28
  * The curation is processed asynchronously by the ByteRover agent.
29
29
  */
30
- export declare function registerBrvCurateTool(server: McpServer, getClient: () => ITransportClient | undefined, getWorkingDirectory: () => string | undefined, getStartupProjectContext: () => McpStartupProjectContext | undefined): void;
30
+ export declare function registerBrvCurateTool(server: McpServer, getClient: () => ITransportClient | undefined, getWorkingDirectory: () => string | undefined, getStartupProjectContext: () => McpStartupProjectContext | undefined, clientVersion: string): void;
@@ -2,6 +2,7 @@ import { waitForConnectedClient } from '@campfirein/brv-transport-client';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { z } from 'zod';
4
4
  import { TransportTaskEventNames } from '../../../core/domain/transport/schemas.js';
5
+ import { appendDriftFooter } from './drift-footer.js';
5
6
  import { associateProjectWithRetry, resolveMcpTaskContext } from './mcp-project-context.js';
6
7
  import { resolveClientCwd } from './resolve-client-cwd.js';
7
8
  import { cwdField } from './shared-schema.js';
@@ -30,7 +31,7 @@ export const BrvCurateInputSchema = z.object({
30
31
  * Uses fire-and-forget pattern: returns immediately after queueing the task.
31
32
  * The curation is processed asynchronously by the ByteRover agent.
32
33
  */
33
- export function registerBrvCurateTool(server, getClient, getWorkingDirectory, getStartupProjectContext) {
34
+ export function registerBrvCurateTool(server, getClient, getWorkingDirectory, getStartupProjectContext, clientVersion) {
34
35
  server.registerTool('brv-curate', {
35
36
  description: 'Store context to the ByteRover context tree. Save patterns, decisions, or insights. ' +
36
37
  'Curation is processed asynchronously — the tool returns immediately after queueing.',
@@ -92,10 +93,11 @@ export function registerBrvCurateTool(server, getClient, getWorkingDirectory, ge
92
93
  const logId = ack?.logId;
93
94
  const modeDescription = hasFolder ? 'folder pack' : 'curation';
94
95
  const logSuffix = logId ? `, logId: ${logId}` : '';
96
+ const queuedMessage = `✓ Context queued for ${modeDescription} (taskId: ${taskId}${logSuffix}). The curation will be processed asynchronously.`;
95
97
  return {
96
98
  content: [
97
99
  {
98
- text: `✓ Context queued for ${modeDescription} (taskId: ${taskId}${logSuffix}). The curation will be processed asynchronously.`,
100
+ text: appendDriftFooter(queuedMessage, clientVersion, client.getDaemonVersion?.()),
99
101
  type: 'text',
100
102
  },
101
103
  ],
@@ -18,4 +18,4 @@ export declare const BrvQueryInputSchema: z.ZodObject<{
18
18
  * This tool allows coding agents to query the ByteRover context tree
19
19
  * for patterns, decisions, implementation details, or any stored knowledge.
20
20
  */
21
- export declare function registerBrvQueryTool(server: McpServer, getClient: () => ITransportClient | undefined, getWorkingDirectory: () => string | undefined, getStartupProjectContext: () => McpStartupProjectContext | undefined): void;
21
+ export declare function registerBrvQueryTool(server: McpServer, getClient: () => ITransportClient | undefined, getWorkingDirectory: () => string | undefined, getStartupProjectContext: () => McpStartupProjectContext | undefined, clientVersion: string): void;
@@ -2,6 +2,7 @@ import { waitForConnectedClient } from '@campfirein/brv-transport-client';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { z } from 'zod';
4
4
  import { TransportTaskEventNames } from '../../../core/domain/transport/schemas.js';
5
+ import { appendDriftFooter } from './drift-footer.js';
5
6
  import { associateProjectWithRetry, resolveMcpTaskContext } from './mcp-project-context.js';
6
7
  import { resolveClientCwd } from './resolve-client-cwd.js';
7
8
  import { cwdField } from './shared-schema.js';
@@ -16,7 +17,7 @@ export const BrvQueryInputSchema = z.object({
16
17
  * This tool allows coding agents to query the ByteRover context tree
17
18
  * for patterns, decisions, implementation details, or any stored knowledge.
18
19
  */
19
- export function registerBrvQueryTool(server, getClient, getWorkingDirectory, getStartupProjectContext) {
20
+ export function registerBrvQueryTool(server, getClient, getWorkingDirectory, getStartupProjectContext, clientVersion) {
20
21
  server.registerTool('brv-query', {
21
22
  description: 'Query the ByteRover context tree for patterns, decisions, or implementation details.',
22
23
  inputSchema: BrvQueryInputSchema,
@@ -64,7 +65,7 @@ export function registerBrvQueryTool(server, getClient, getWorkingDirectory, get
64
65
  // Wait for the already-listening result promise
65
66
  const result = await resultPromise;
66
67
  return {
67
- content: [{ text: result, type: 'text' }],
68
+ content: [{ text: appendDriftFooter(result, clientVersion, client.getDaemonVersion?.()), type: 'text' }],
68
69
  };
69
70
  }
70
71
  catch (error) {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Appends a version-drift footer to MCP tool text responses when the MCP
3
+ * client and the running daemon are at different versions.
4
+ *
5
+ * Returns the body unchanged when versions match or when the daemon hasn't
6
+ * reported its version yet (pre-fix daemon during a rolling upgrade).
7
+ */
8
+ export declare function appendDriftFooter(body: string, clientVersion: string, daemonVersion?: string): string;
@@ -0,0 +1,16 @@
1
+ import { versionsAreEquivalent } from '@campfirein/brv-transport-client';
2
+ /**
3
+ * Appends a version-drift footer to MCP tool text responses when the MCP
4
+ * client and the running daemon are at different versions.
5
+ *
6
+ * Returns the body unchanged when versions match or when the daemon hasn't
7
+ * reported its version yet (pre-fix daemon during a rolling upgrade).
8
+ */
9
+ export function appendDriftFooter(body, clientVersion, daemonVersion) {
10
+ if (!daemonVersion || versionsAreEquivalent(clientVersion, daemonVersion)) {
11
+ return body;
12
+ }
13
+ return (body +
14
+ `\n\n---\nNote: this brv MCP is at ${clientVersion} while the running daemon is at ${daemonVersion}. ` +
15
+ `The protocol is backward-compatible. Restart your IDE to align versions.`);
16
+ }
@@ -18,6 +18,12 @@ import type { TaskRouter } from './task-router.js';
18
18
  type ConnectionCoordinatorOptions = {
19
19
  agentPool?: IAgentPool;
20
20
  clientManager?: IClientManager;
21
+ /**
22
+ * Daemon version surfaced in `client:register` ack. Lets clients render
23
+ * drift indicators without a separate round-trip. Optional so older
24
+ * deployments still build.
25
+ */
26
+ daemonVersion?: string;
21
27
  projectRegistry?: IProjectRegistry;
22
28
  projectRouter?: IProjectRouter;
23
29
  taskRouter: TaskRouter;
@@ -31,6 +37,7 @@ export declare class ConnectionCoordinator {
31
37
  private agentClients;
32
38
  private readonly agentPool;
33
39
  private readonly clientManager;
40
+ private readonly daemonVersion;
34
41
  private readonly projectRegistry;
35
42
  private readonly projectRouter;
36
43
  private readonly taskRouter;
@@ -24,6 +24,7 @@ export class ConnectionCoordinator {
24
24
  agentClients = new Map();
25
25
  agentPool;
26
26
  clientManager;
27
+ daemonVersion;
27
28
  projectRegistry;
28
29
  projectRouter;
29
30
  taskRouter;
@@ -32,6 +33,7 @@ export class ConnectionCoordinator {
32
33
  this.transport = options.transport;
33
34
  this.agentPool = options.agentPool;
34
35
  this.clientManager = options.clientManager;
36
+ this.daemonVersion = options.daemonVersion;
35
37
  this.projectRouter = options.projectRouter;
36
38
  this.projectRegistry = options.projectRegistry;
37
39
  this.taskRouter = options.taskRouter;
@@ -189,6 +191,9 @@ export class ConnectionCoordinator {
189
191
  if (data.projectPath) {
190
192
  this.addToProjectRoom(clientId, data.projectPath);
191
193
  }
194
+ if (this.daemonVersion) {
195
+ return { daemonVersion: this.daemonVersion, success: true };
196
+ }
192
197
  return { success: true };
193
198
  }
194
199
  handleClientUpdateAgentName(clientId, data) {
@@ -25,6 +25,12 @@ export declare class QueryLogHandler implements ITaskLifecycleHook {
25
25
  private readonly tasks;
26
26
  constructor(createStore?: ((projectPath: string) => IQueryLogStore) | undefined);
27
27
  cleanup(taskId: string): void;
28
+ /**
29
+ * Expose query metadata via the lifecycle-hook contract so TaskRouter can merge it into
30
+ * the task:completed payload sent to the originating client. Returning {} when no metadata
31
+ * is available keeps the merge a no-op and lets the daemon emit task:completed unchanged.
32
+ */
33
+ getTaskCompletionData(taskId: string): Record<string, unknown>;
28
34
  onTaskCancelled(taskId: string, _task: TaskInfo): Promise<void>;
29
35
  onTaskCompleted(taskId: string, result: string, _task: TaskInfo): Promise<void>;
30
36
  onTaskCreate(task: TaskInfo): Promise<void | {
@@ -39,6 +39,29 @@ export class QueryLogHandler {
39
39
  }
40
40
  }
41
41
  }
42
+ /**
43
+ * Expose query metadata via the lifecycle-hook contract so TaskRouter can merge it into
44
+ * the task:completed payload sent to the originating client. Returning {} when no metadata
45
+ * is available keeps the merge a no-op and lets the daemon emit task:completed unchanged.
46
+ */
47
+ getTaskCompletionData(taskId) {
48
+ const state = this.tasks.get(taskId);
49
+ if (!state?.queryResult)
50
+ return {};
51
+ // Flatten the QueryExecutorResult's nested shape onto the task:completed payload so
52
+ // it matches the public RecallResult contract (flat `durationMs` / `topScore`).
53
+ // `timing` is always populated by every QueryExecutor branch, so no guard.
54
+ // `searchMetadata` is omitted on cache hits (Tier 0/1), so guard before extracting.
55
+ const out = {
56
+ durationMs: state.queryResult.timing.durationMs,
57
+ matchedDocs: state.queryResult.matchedDocs,
58
+ tier: state.queryResult.tier,
59
+ };
60
+ if (state.queryResult.searchMetadata !== undefined) {
61
+ out.topScore = state.queryResult.searchMetadata.topScore;
62
+ }
63
+ return out;
64
+ }
42
65
  async onTaskCancelled(taskId, _task) {
43
66
  const state = this.tasks.get(taskId);
44
67
  if (!state)
@@ -38,6 +38,11 @@ export type { TaskInfo } from './types.js';
38
38
  type TransportHandlersOptions = {
39
39
  agentPool?: IAgentPool;
40
40
  clientManager?: IClientManager;
41
+ /**
42
+ * Daemon's CLI version (read from package.json at startup). Surfaced in the
43
+ * `client:register` ack so clients can render version-drift indicators.
44
+ */
45
+ daemonVersion?: string;
41
46
  /** Resolves project's review-disabled flag at task-create. Snapshotted once into TaskInfo + TaskExecute. */
42
47
  isReviewDisabled?: IsReviewDisabledResolver;
43
48
  /** Lifecycle hooks for task events (e.g. CurateLogHandler). */
@@ -52,6 +52,7 @@ export class TransportHandlers {
52
52
  this.connectionCoordinator = new ConnectionCoordinator({
53
53
  agentPool: options.agentPool,
54
54
  clientManager: options.clientManager,
55
+ daemonVersion: options.daemonVersion,
55
56
  projectRegistry: options.projectRegistry,
56
57
  projectRouter: options.projectRouter,
57
58
  taskRouter: this.taskRouter,
@@ -7,10 +7,16 @@ import { jsx as _jsx } from "react/jsx-runtime";
7
7
  * - Connected agent status
8
8
  * - Queue stats (pending/processing)
9
9
  */
10
+ import { versionsAreEquivalent } from '@campfirein/brv-transport-client';
10
11
  import { Box } from 'ink';
11
12
  import { useTransportStore } from '../stores/transport-store.js';
12
13
  import { Logo } from './logo.js';
13
14
  export const Header = ({ compact }) => {
14
15
  const version = useTransportStore((s) => s.version);
15
- return (_jsx(Box, { flexDirection: "column", marginBottom: 1, width: "100%", children: _jsx(Logo, { compact: compact, version: version }) }));
16
+ const daemonVersion = useTransportStore((s) => s.daemonVersion);
17
+ // Drift indicator surfaces when this brv build connects to a daemon spawned
18
+ // by a different build. Rendered inline by Logo so the banner stays a single
19
+ // line; hidden when versions match or the daemon is too old to advertise.
20
+ const isOutdated = daemonVersion !== undefined && !versionsAreEquivalent(version, daemonVersion);
21
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, width: "100%", children: _jsx(Logo, { compact: compact, driftDaemonVersion: isOutdated ? daemonVersion : undefined, version: version }) }));
16
22
  };
@@ -25,6 +25,12 @@ interface LogoProps {
25
25
  * Compact mode, only show text logo
26
26
  */
27
27
  compact?: boolean;
28
+ /**
29
+ * Daemon version to surface inline as a drift indicator. Pass undefined when
30
+ * the local CLI and the running daemon agree on version (or the daemon is
31
+ * too old to advertise its version) so the indicator stays hidden.
32
+ */
33
+ driftDaemonVersion?: string;
28
34
  /**
29
35
  * Optional version to display
30
36
  */
@@ -80,7 +80,7 @@ function getLogoLines(variant, terminalWidth) {
80
80
  *
81
81
  * Automatically selects the best logo variant based on terminal dimensions.
82
82
  */
83
- export const Logo = ({ compact, version }) => {
83
+ export const Logo = ({ compact, driftDaemonVersion, version }) => {
84
84
  const { stdout } = useStdout();
85
85
  const { theme: { colors }, } = useTheme();
86
86
  const isLatestVersion = useIsLatestVersion(version ?? '');
@@ -88,15 +88,28 @@ export const Logo = ({ compact, version }) => {
88
88
  const terminalHeight = stdout?.rows ?? 24;
89
89
  const variant = useMemo(() => (compact ? 'text' : selectLogoVariant(terminalWidth, terminalHeight)), [compact, terminalWidth, terminalHeight]);
90
90
  const logoLines = useMemo(() => getLogoLines(variant, terminalWidth), [variant, terminalWidth]);
91
- const headerLine = useMemo(() => (variant === 'full' && LOGO_FULL[0] ? getHeaderLine(LOGO_FULL[0], version ?? '', terminalWidth) : null), [variant, version, terminalWidth]);
91
+ // Inline drift token, e.g. " [outdated, daemon v3.99.0]". Empty when the
92
+ // header has no daemon-version drift to surface — keeps the banner length
93
+ // calculation symmetric with the no-drift case.
94
+ const driftText = driftDaemonVersion ? ` [outdated, daemon v${driftDaemonVersion}]` : '';
95
+ const headerLine = useMemo(() => {
96
+ if (variant !== 'full' || !LOGO_FULL[0])
97
+ return null;
98
+ const base = getHeaderLine(LOGO_FULL[0], version ?? '', terminalWidth);
99
+ if (!driftText)
100
+ return base;
101
+ // Re-pad so the trailing `/////` fills the row after the drift token.
102
+ const contentLength = base.brv.length + base.spaces.length + base.version.length + driftText.length;
103
+ return { ...base, padEnd: calculatePadEnd(contentLength, terminalWidth) };
104
+ }, [variant, version, terminalWidth, driftText]);
92
105
  // Text-only logo for minimal terminals
93
106
  if (variant === 'text') {
94
- const textContent = MINI_LOGO + (version ? ` v${version}` : '') + (isLatestVersion ? ' (latest)' : '');
107
+ const textContent = MINI_LOGO + (version ? ` v${version}` : '') + (isLatestVersion ? ' (latest)' : '') + driftText;
95
108
  const padEnd = calculatePadEnd(textContent.length, terminalWidth);
96
- return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: PAD_START }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.primary, children: MINI_LOGO }), version && _jsxs(Text, { color: colors.primary, children: [" v", version] }), isLatestVersion && _jsx(Text, { color: colors.primary, children: " (latest)" })] }), _jsx(Text, { color: colors.primary, children: padEnd })] }));
109
+ return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: PAD_START }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.primary, children: MINI_LOGO }), version && _jsxs(Text, { color: colors.primary, children: [" v", version] }), isLatestVersion && _jsx(Text, { color: colors.primary, children: " (latest)" }), driftText && _jsx(Text, { color: colors.warning, children: driftText })] }), _jsx(Text, { color: colors.primary, children: padEnd })] }));
97
110
  }
98
111
  // ASCII logo with header line and version
99
- return (_jsxs(Box, { flexDirection: "column", children: [headerLine && (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: headerLine.padStart }), _jsxs(Text, { children: [_jsx(Text, { children: headerLine.brv }), _jsx(Text, { children: headerLine.spaces }), _jsx(Text, { color: colors.primary, children: headerLine.version })] }), _jsx(Text, { color: colors.primary, children: headerLine.padEnd })] })), logoLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: line.padStart }), _jsx(Text, { color: colors.primary, children: line.content }), _jsx(Text, { color: colors.primary, children: line.padEnd })] }, index)))] }));
112
+ return (_jsxs(Box, { flexDirection: "column", children: [headerLine && (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: headerLine.padStart }), _jsxs(Text, { children: [_jsx(Text, { children: headerLine.brv }), _jsx(Text, { children: headerLine.spaces }), _jsx(Text, { color: colors.primary, children: headerLine.version }), driftText && _jsx(Text, { color: colors.warning, children: driftText })] }), _jsx(Text, { color: colors.primary, children: headerLine.padEnd })] })), logoLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: line.padStart }), _jsx(Text, { color: colors.primary, children: line.content }), _jsx(Text, { color: colors.primary, children: line.padEnd })] }, index)))] }));
100
113
  };
101
114
  /**
102
115
  * Export utilities for external use
@@ -14,7 +14,7 @@ import { getAllEventValues } from '../../../../shared/transport/events/index.js'
14
14
  import { initTransportLog, logTransportEvent } from '../../../lib/transport-logger.js';
15
15
  import { useTransportStore } from '../../../stores/transport-store.js';
16
16
  export function TransportInitializer({ children }) {
17
- const { incrementReconnectCount, setClient, setConnectionState, setError } = useTransportStore();
17
+ const { incrementReconnectCount, setClient, setConnectionState, setDaemonVersion, setError } = useTransportStore();
18
18
  useEffect(() => {
19
19
  let mounted = true;
20
20
  let reconnectorHandle;
@@ -87,6 +87,9 @@ export function TransportInitializer({ children }) {
87
87
  logTransportEvent('_connection', { clientId: newClient.getClientId(), state: 'initialized' });
88
88
  // Set client in store (this also creates apiClient)
89
89
  setClient(newClient);
90
+ // Capture daemon version from register ack so the header can render
91
+ // a drift indicator when the daemon was started by a different brv build.
92
+ setDaemonVersion(newClient.getDaemonVersion?.());
90
93
  // Auto-reconnect on disconnect (shared logic from brv-transport-client)
91
94
  reconnectorHandle = createDaemonReconnector(newClient, {
92
95
  connectOptions,
@@ -95,6 +98,9 @@ export function TransportInitializer({ children }) {
95
98
  return;
96
99
  registerEventHandlers(reconnectedClient);
97
100
  setClient(reconnectedClient);
101
+ // Refresh on reconnect — the daemon may have been replaced by a
102
+ // peer client at a different version.
103
+ setDaemonVersion(reconnectedClient.getDaemonVersion?.());
98
104
  logTransportEvent('_reconnect', { clientId: reconnectedClient.getClientId(), state: 'success' });
99
105
  },
100
106
  onStateChange(state, client) {
@@ -137,6 +143,6 @@ export function TransportInitializer({ children }) {
137
143
  });
138
144
  }
139
145
  };
140
- }, [incrementReconnectCount, setClient, setConnectionState, setError]);
146
+ }, [incrementReconnectCount, setClient, setConnectionState, setDaemonVersion, setError]);
141
147
  return _jsx(_Fragment, { children: children });
142
148
  }
@@ -14,6 +14,12 @@ export interface TransportState {
14
14
  client: ITransportClient | null;
15
15
  /** Current connection state */
16
16
  connectionState: ConnectionState;
17
+ /**
18
+ * Daemon version reported in the most recent client:register ack.
19
+ * Undefined when the daemon is too old to advertise its version.
20
+ * Drives the version-drift indicator in the TUI header.
21
+ */
22
+ daemonVersion: string | undefined;
17
23
  /** Connection error if any */
18
24
  error: Error | null;
19
25
  /** Whether the client is connected */
@@ -36,6 +42,8 @@ export interface TransportActions {
36
42
  setClient: (client: ITransportClient) => void;
37
43
  /** Update connection state */
38
44
  setConnectionState: (state: ConnectionState) => void;
45
+ /** Set or clear the daemon version (called after every connect / reconnect) */
46
+ setDaemonVersion: (daemonVersion: string | undefined) => void;
39
47
  /** Set connection error */
40
48
  setError: (error: Error | null) => void;
41
49
  /** Set resolved project info from oclif main */