byterover-cli 1.4.0 → 1.5.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 (106) hide show
  1. package/README.md +61 -1
  2. package/dist/core/domain/cipher/process/types.d.ts +1 -1
  3. package/dist/core/domain/entities/provider-config.d.ts +92 -0
  4. package/dist/core/domain/entities/provider-config.js +181 -0
  5. package/dist/core/domain/entities/provider-registry.d.ts +55 -0
  6. package/dist/core/domain/entities/provider-registry.js +74 -0
  7. package/dist/core/interfaces/cipher/i-content-generator.d.ts +30 -0
  8. package/dist/core/interfaces/cipher/i-content-generator.js +12 -1
  9. package/dist/core/interfaces/cipher/message-factory.d.ts +4 -1
  10. package/dist/core/interfaces/cipher/message-factory.js +5 -0
  11. package/dist/core/interfaces/cipher/message-types.d.ts +19 -1
  12. package/dist/core/interfaces/i-provider-config-store.d.ts +88 -0
  13. package/dist/core/interfaces/i-provider-config-store.js +1 -0
  14. package/dist/core/interfaces/i-provider-keychain-store.d.ts +33 -0
  15. package/dist/core/interfaces/i-provider-keychain-store.js +1 -0
  16. package/dist/infra/cipher/file-system/file-system-service.js +5 -5
  17. package/dist/infra/cipher/http/internal-llm-http-service.d.ts +40 -0
  18. package/dist/infra/cipher/http/internal-llm-http-service.js +152 -2
  19. package/dist/infra/cipher/llm/formatters/gemini-formatter.js +8 -1
  20. package/dist/infra/cipher/llm/generators/byterover-content-generator.d.ts +2 -3
  21. package/dist/infra/cipher/llm/generators/byterover-content-generator.js +20 -11
  22. package/dist/infra/cipher/llm/generators/openrouter-content-generator.d.ts +1 -0
  23. package/dist/infra/cipher/llm/generators/openrouter-content-generator.js +26 -0
  24. package/dist/infra/cipher/llm/internal-llm-service.d.ts +13 -0
  25. package/dist/infra/cipher/llm/internal-llm-service.js +75 -4
  26. package/dist/infra/cipher/llm/model-capabilities.d.ts +74 -0
  27. package/dist/infra/cipher/llm/model-capabilities.js +157 -0
  28. package/dist/infra/cipher/llm/openrouter-llm-service.d.ts +35 -1
  29. package/dist/infra/cipher/llm/openrouter-llm-service.js +216 -28
  30. package/dist/infra/cipher/llm/stream-processor.d.ts +22 -2
  31. package/dist/infra/cipher/llm/stream-processor.js +78 -4
  32. package/dist/infra/cipher/llm/thought-parser.d.ts +1 -1
  33. package/dist/infra/cipher/llm/thought-parser.js +5 -5
  34. package/dist/infra/cipher/llm/transformers/openrouter-stream-transformer.d.ts +49 -0
  35. package/dist/infra/cipher/llm/transformers/openrouter-stream-transformer.js +272 -0
  36. package/dist/infra/cipher/llm/transformers/reasoning-extractor.d.ts +71 -0
  37. package/dist/infra/cipher/llm/transformers/reasoning-extractor.js +253 -0
  38. package/dist/infra/cipher/process/process-service.js +1 -1
  39. package/dist/infra/cipher/session/chat-session.d.ts +2 -0
  40. package/dist/infra/cipher/session/chat-session.js +13 -2
  41. package/dist/infra/cipher/storage/message-storage-service.js +4 -0
  42. package/dist/infra/cipher/tools/implementations/bash-exec-tool.js +3 -3
  43. package/dist/infra/cipher/tools/implementations/task-tool.js +1 -1
  44. package/dist/infra/http/openrouter-api-client.d.ts +148 -0
  45. package/dist/infra/http/openrouter-api-client.js +161 -0
  46. package/dist/infra/mcp/tools/task-result-waiter.js +9 -1
  47. package/dist/infra/process/agent-worker.js +178 -70
  48. package/dist/infra/process/transport-handlers.d.ts +25 -4
  49. package/dist/infra/process/transport-handlers.js +57 -10
  50. package/dist/infra/repl/commands/connectors-command.js +2 -2
  51. package/dist/infra/repl/commands/index.js +5 -0
  52. package/dist/infra/repl/commands/model-command.d.ts +13 -0
  53. package/dist/infra/repl/commands/model-command.js +212 -0
  54. package/dist/infra/repl/commands/provider-command.d.ts +13 -0
  55. package/dist/infra/repl/commands/provider-command.js +181 -0
  56. package/dist/infra/repl/transport-client-helper.js +6 -2
  57. package/dist/infra/storage/file-provider-config-store.d.ts +83 -0
  58. package/dist/infra/storage/file-provider-config-store.js +157 -0
  59. package/dist/infra/storage/provider-keychain-store.d.ts +37 -0
  60. package/dist/infra/storage/provider-keychain-store.js +75 -0
  61. package/dist/infra/transport/socket-io-transport-client.d.ts +20 -0
  62. package/dist/infra/transport/socket-io-transport-client.js +88 -1
  63. package/dist/resources/tools/bash_exec.txt +1 -1
  64. package/dist/tui/components/api-key-dialog.d.ts +39 -0
  65. package/dist/tui/components/api-key-dialog.js +94 -0
  66. package/dist/tui/components/execution/execution-changes.d.ts +3 -1
  67. package/dist/tui/components/execution/execution-changes.js +4 -4
  68. package/dist/tui/components/execution/execution-content.d.ts +1 -1
  69. package/dist/tui/components/execution/execution-content.js +4 -12
  70. package/dist/tui/components/execution/execution-input.js +1 -1
  71. package/dist/tui/components/execution/execution-progress.d.ts +10 -13
  72. package/dist/tui/components/execution/execution-progress.js +70 -17
  73. package/dist/tui/components/execution/execution-reasoning.d.ts +16 -0
  74. package/dist/tui/components/execution/execution-reasoning.js +34 -0
  75. package/dist/tui/components/execution/execution-tool.d.ts +23 -0
  76. package/dist/tui/components/execution/execution-tool.js +125 -0
  77. package/dist/tui/components/execution/expanded-log-view.js +3 -3
  78. package/dist/tui/components/execution/log-item.d.ts +2 -0
  79. package/dist/tui/components/execution/log-item.js +6 -4
  80. package/dist/tui/components/index.d.ts +2 -0
  81. package/dist/tui/components/index.js +2 -0
  82. package/dist/tui/components/inline-prompts/inline-select.js +3 -2
  83. package/dist/tui/components/model-dialog.d.ts +63 -0
  84. package/dist/tui/components/model-dialog.js +89 -0
  85. package/dist/tui/components/onboarding/onboarding-flow.js +8 -2
  86. package/dist/tui/components/provider-dialog.d.ts +27 -0
  87. package/dist/tui/components/provider-dialog.js +31 -0
  88. package/dist/tui/components/reasoning-text.d.ts +26 -0
  89. package/dist/tui/components/reasoning-text.js +49 -0
  90. package/dist/tui/components/selectable-list.d.ts +54 -0
  91. package/dist/tui/components/selectable-list.js +180 -0
  92. package/dist/tui/components/streaming-text.d.ts +30 -0
  93. package/dist/tui/components/streaming-text.js +52 -0
  94. package/dist/tui/contexts/tasks-context.d.ts +15 -0
  95. package/dist/tui/contexts/tasks-context.js +224 -40
  96. package/dist/tui/contexts/theme-context.d.ts +1 -0
  97. package/dist/tui/contexts/theme-context.js +3 -2
  98. package/dist/tui/hooks/use-activity-logs.js +7 -1
  99. package/dist/tui/types/messages.d.ts +32 -5
  100. package/dist/tui/utils/index.d.ts +1 -1
  101. package/dist/tui/utils/index.js +1 -1
  102. package/dist/tui/utils/log.d.ts +0 -9
  103. package/dist/tui/utils/log.js +2 -53
  104. package/dist/tui/views/command-view.js +4 -1
  105. package/oclif.manifest.json +1 -1
  106. package/package.json +1 -1
@@ -27,6 +27,8 @@ import { ProjectConfigStore } from '../config/file-config-store.js';
27
27
  import { CurateExecutor } from '../core/executors/curate-executor.js';
28
28
  import { QueryExecutor } from '../core/executors/query-executor.js';
29
29
  import { createTaskProcessor } from '../core/task-processor.js';
30
+ import { FileProviderConfigStore } from '../storage/file-provider-config-store.js';
31
+ import { ProviderKeychainStore } from '../storage/provider-keychain-store.js';
30
32
  import { createTokenStore } from '../storage/token-store.js';
31
33
  import { createTransportClient } from '../transport/transport-factory.js';
32
34
  import { createParentHeartbeat } from './parent-heartbeat.js';
@@ -44,6 +46,41 @@ function logTransportError(error) {
44
46
  const message = error instanceof Error ? error.message : String(error);
45
47
  agentLog(`Transport error (non-fatal): ${message}`);
46
48
  }
49
+ /**
50
+ * Send a critical transport event with retry logic.
51
+ * Uses exponential backoff for retries to handle transient network issues.
52
+ * Inspired by opencode's robust event forwarding pattern.
53
+ *
54
+ * Critical events include: task:completed, task:error, task:cancelled
55
+ * These should be retried because if they fail, TUI won't know the task ended.
56
+ *
57
+ * @param eventName - The event name to send
58
+ * @param data - The event payload
59
+ * @param maxRetries - Maximum number of retry attempts (default: 3)
60
+ */
61
+ function sendCriticalEvent(eventName, data, maxRetries = 3) {
62
+ const BASE_DELAY_MS = 100;
63
+ const attemptSend = (attempt) => {
64
+ transportClient
65
+ ?.request(eventName, data)
66
+ .then(() => {
67
+ // Success - nothing more to do
68
+ })
69
+ .catch((error) => {
70
+ const isLastAttempt = attempt >= maxRetries;
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ if (isLastAttempt) {
73
+ agentLog(`Critical event ${eventName} failed after ${maxRetries + 1} attempts: ${message}`);
74
+ return;
75
+ }
76
+ // Retry with exponential backoff
77
+ const delay = BASE_DELAY_MS * 2 ** attempt;
78
+ agentLog(`Critical event ${eventName} failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms: ${message}`);
79
+ setTimeout(() => attemptSend(attempt + 1), delay);
80
+ });
81
+ };
82
+ attemptSend(0);
83
+ }
47
84
  // Task types imported from core/domain/transport/schemas.ts:
48
85
  // - TaskExecute: Transport → Agent (task:execute event)
49
86
  // - TaskCancel: Transport → Agent (task:cancel event)
@@ -345,7 +382,7 @@ function setupTaskExecutor() {
345
382
  if (!initialized) {
346
383
  agentLog(`Task ${taskId} rejected - lazy initialization failed`);
347
384
  const error = serializeTaskError(initializationError ?? new AgentNotInitializedError('Agent initialization failed'));
348
- transportClient?.request('task:error', { error, taskId }).catch(logTransportError);
385
+ sendCriticalEvent('task:error', { error, taskId });
349
386
  return;
350
387
  }
351
388
  agentLog(`Task ${taskId} - lazy initialization successful, proceeding`);
@@ -356,7 +393,7 @@ function setupTaskExecutor() {
356
393
  if (!isAgentInitialized || !taskProcessor) {
357
394
  agentLog(`Task ${taskId} rejected - agent stopped during queue wait`);
358
395
  const error = serializeTaskError(new AgentNotInitializedError('Agent stopped during execution wait'));
359
- transportClient?.request('task:error', { error, taskId }).catch(logTransportError);
396
+ sendCriticalEvent('task:error', { error, taskId });
360
397
  return;
361
398
  }
362
399
  // Track timeout state for error handling
@@ -381,13 +418,13 @@ function setupTaskExecutor() {
381
418
  if (timedOut) {
382
419
  agentLog(`Task ${taskId} cancelled due to timeout`);
383
420
  const errorData = serializeTaskError(new Error('Task exceeded 5 minute timeout'));
384
- transportClient?.request('task:error', { error: errorData, taskId }).catch(logTransportError);
421
+ sendCriticalEvent('task:error', { error: errorData, taskId });
385
422
  return;
386
423
  }
387
424
  // Handle other errors (not timeout)
388
425
  agentLog(`Task execution failed: ${error}`);
389
426
  const errorData = serializeTaskError(error);
390
- transportClient?.request('task:error', { error: errorData, taskId }).catch(logTransportError);
427
+ sendCriticalEvent('task:error', { error: errorData, taskId });
391
428
  }
392
429
  finally {
393
430
  // Always clear timeout to prevent memory leak
@@ -482,6 +519,118 @@ async function stopExistingAgentForReinit() {
482
519
  taskProcessor = undefined;
483
520
  isAgentInitialized = false;
484
521
  }
522
+ /**
523
+ * Prepare for reinitialization by draining queue and stopping existing agent.
524
+ *
525
+ * NOTE: We do NOT cleanup transport event handlers here because they are meant
526
+ * to be long-lived for the entire worker process lifecycle. Only agent event
527
+ * forwarders (which are tied to the CipherAgent instance) are cleaned up via
528
+ * cleanupAgentEventForwarding() in stopExistingAgentForReinit().
529
+ */
530
+ async function prepareForReinit() {
531
+ // Drain task queue before reinit to prevent tasks executing with stale processor
532
+ agentLog('Draining task queue before reinit...');
533
+ notifyQueuedTasksAboutDropAndClear('credential/config change');
534
+ // Wait for active tasks to complete (with timeout)
535
+ await waitForActiveTasksToComplete(10_000);
536
+ await stopExistingAgentForReinit();
537
+ }
538
+ /**
539
+ * Load provider configuration and return API key and model if using external provider.
540
+ */
541
+ async function loadProviderConfiguration() {
542
+ const providerConfigStore = new FileProviderConfigStore();
543
+ const providerKeychainStore = new ProviderKeychainStore();
544
+ const providerConfig = await providerConfigStore.read();
545
+ const activeProviderId = providerConfig.activeProvider;
546
+ // Get OpenRouter API key if using external provider
547
+ let openRouterApiKey;
548
+ let modelFromProvider;
549
+ if (activeProviderId !== 'byterover') {
550
+ openRouterApiKey = await providerKeychainStore.getApiKey(activeProviderId);
551
+ modelFromProvider = await providerConfigStore.getActiveModel(activeProviderId);
552
+ if (openRouterApiKey) {
553
+ agentLog(`Using external provider: ${activeProviderId}${modelFromProvider ? ` with model: ${modelFromProvider}` : ''}`);
554
+ }
555
+ }
556
+ return { modelFromProvider, openRouterApiKey };
557
+ }
558
+ /**
559
+ * Build agent configuration object from auth token and provider settings.
560
+ */
561
+ function buildAgentConfig(authToken, modelFromProvider, openRouterApiKey) {
562
+ const envConfig = getCurrentConfig();
563
+ return {
564
+ accessToken: authToken.accessToken,
565
+ apiBaseUrl: envConfig.llmApiBaseUrl,
566
+ fileSystem: { workingDirectory: process.cwd() },
567
+ llm: {
568
+ maxIterations: 10,
569
+ maxTokens: 4096,
570
+ temperature: 0.7,
571
+ topK: 10,
572
+ topP: 0.95,
573
+ verbose: false,
574
+ },
575
+ model: modelFromProvider ?? DEFAULT_LLM_MODEL,
576
+ openRouterApiKey,
577
+ projectId: PROJECT,
578
+ sessionKey: authToken.sessionKey,
579
+ };
580
+ }
581
+ /**
582
+ * Initialize agent instance by starting it and creating a session.
583
+ * Returns the initialized agent or undefined if cleanup was triggered.
584
+ */
585
+ async function initializeAgentInstance(agent) {
586
+ // Wrap agent.start() with timeout to prevent isInitializing from getting stuck
587
+ await withTimeout(agent.start(), AGENT_INIT_TIMEOUT_MS, 'CipherAgent.start()');
588
+ agentLog('CipherAgent started');
589
+ // Check if cleanup started during agent.start() (fixes zombie agent race)
590
+ if (await shouldAbortInitForCleanup(agent, 'agent.start()')) {
591
+ return undefined;
592
+ }
593
+ // Create ChatSession (also with timeout to prevent hanging)
594
+ chatSessionId = `agent-session-${randomUUID()}`;
595
+ await withTimeout(agent.createSession(chatSessionId), AGENT_INIT_TIMEOUT_MS, 'CipherAgent.createSession()');
596
+ agentLog(`ChatSession created: ${chatSessionId}`);
597
+ // Check if cleanup started during createSession() (fixes zombie agent race)
598
+ if (await shouldAbortInitForCleanup(agent, 'createSession()')) {
599
+ return undefined;
600
+ }
601
+ return agent;
602
+ }
603
+ /**
604
+ * Finalize agent initialization by setting up event forwarding, creating task processor,
605
+ * and updating cached credentials.
606
+ */
607
+ function finalizeAgentInitialization(params) {
608
+ const { agent, authToken, brvConfig, curateExecutor, queryExecutor } = params;
609
+ // Setup event forwarding
610
+ setupAgentEventForwarding(agent);
611
+ cipherAgent = agent;
612
+ // Create TaskProcessor
613
+ taskProcessor = createTaskProcessor({
614
+ curateExecutor,
615
+ queryExecutor,
616
+ });
617
+ taskProcessor.setAgent(cipherAgent);
618
+ // NOTE: setupTaskExecutor() is called once in startAgent() before tryInitializeAgent()
619
+ // No need to call again here - executor is already set and handles lazy init
620
+ // Mark as initialized
621
+ isAgentInitialized = true;
622
+ initializationError = undefined;
623
+ // Cache credentials for change detection polling
624
+ updateCachedCredentials(authToken.accessToken, authToken.sessionKey, brvConfig ? { spaceId: brvConfig.spaceId, teamId: brvConfig.teamId } : undefined);
625
+ if (brvConfig) {
626
+ agentLog(`Fully initialized with auth and config (team=${brvConfig.teamId}, space=${brvConfig.spaceId})`);
627
+ }
628
+ else {
629
+ agentLog('Initialized with auth only (no project config yet - will reinit when config available)');
630
+ }
631
+ // Broadcast status change to Transport (init success)
632
+ broadcastStatusChange();
633
+ }
485
634
  /**
486
635
  * Try to initialize/reinitialize the CipherAgent.
487
636
  * Called on startup and lazily when tasks arrive but agent is not initialized.
@@ -516,11 +665,7 @@ async function tryInitializeAgent(forceReinit = false) {
516
665
  try {
517
666
  // If forcing reinit, drain queue and stop existing agent first
518
667
  if (forceReinit) {
519
- agentLog('Draining task queue before reinit...');
520
- notifyQueuedTasksAboutDropAndClear('credential/config change');
521
- // Wait for active tasks to complete (with timeout)
522
- await waitForActiveTasksToComplete(10_000);
523
- await stopExistingAgentForReinit();
668
+ await prepareForReinit();
524
669
  }
525
670
  const tokenStore = createTokenStore();
526
671
  const configStore = new ProjectConfigStore();
@@ -534,65 +679,25 @@ async function tryInitializeAgent(forceReinit = false) {
534
679
  // Create Executors
535
680
  const curateExecutor = new CurateExecutor();
536
681
  const queryExecutor = new QueryExecutor();
537
- // Initialize CipherAgent
538
- const envConfig = getCurrentConfig();
539
- const agentConfig = {
540
- accessToken: authToken.accessToken,
541
- apiBaseUrl: envConfig.llmApiBaseUrl,
542
- fileSystem: { workingDirectory: process.cwd() },
543
- llm: {
544
- maxIterations: 10,
545
- maxTokens: 4096,
546
- temperature: 0.7,
547
- topK: 10,
548
- topP: 0.95,
549
- verbose: false,
550
- },
551
- model: DEFAULT_LLM_MODEL,
552
- projectId: PROJECT,
553
- sessionKey: authToken.sessionKey,
554
- };
682
+ // Read provider configuration
683
+ const { modelFromProvider, openRouterApiKey } = await loadProviderConfiguration();
684
+ // Build agent configuration
685
+ const agentConfig = buildAgentConfig(authToken, modelFromProvider, openRouterApiKey);
686
+ // Initialize agent instance
555
687
  pendingAgent = new CipherAgent(agentConfig, brvConfig ?? undefined);
556
- // Wrap agent.start() with timeout to prevent isInitializing from getting stuck
557
- await withTimeout(pendingAgent.start(), AGENT_INIT_TIMEOUT_MS, 'CipherAgent.start()');
558
- agentLog('CipherAgent started');
559
- // Check if cleanup started during agent.start() (fixes zombie agent race)
560
- if (await shouldAbortInitForCleanup(pendingAgent, 'agent.start()')) {
561
- return false;
562
- }
563
- // Create ChatSession (also with timeout to prevent hanging)
564
- chatSessionId = `agent-session-${randomUUID()}`;
565
- await withTimeout(pendingAgent.createSession(chatSessionId), AGENT_INIT_TIMEOUT_MS, 'CipherAgent.createSession()');
566
- agentLog(`ChatSession created: ${chatSessionId}`);
567
- // Check if cleanup started during createSession() (fixes zombie agent race)
568
- if (await shouldAbortInitForCleanup(pendingAgent, 'createSession()')) {
688
+ const initializedAgent = await initializeAgentInstance(pendingAgent);
689
+ if (!initializedAgent) {
569
690
  return false;
570
691
  }
571
- // Setup event forwarding
572
- setupAgentEventForwarding(pendingAgent);
573
- cipherAgent = pendingAgent;
574
- pendingAgent = undefined; // Clear local ref - cipherAgent now owns it
575
- // Create TaskProcessor
576
- taskProcessor = createTaskProcessor({
692
+ // Finalize initialization
693
+ finalizeAgentInitialization({
694
+ agent: initializedAgent,
695
+ authToken,
696
+ brvConfig,
577
697
  curateExecutor,
578
698
  queryExecutor,
579
699
  });
580
- taskProcessor.setAgent(cipherAgent);
581
- // NOTE: setupTaskExecutor() is called once in startAgent() before tryInitializeAgent()
582
- // No need to call again here - executor is already set and handles lazy init
583
- // Mark as initialized
584
- isAgentInitialized = true;
585
- initializationError = undefined;
586
- // Cache credentials for change detection polling
587
- updateCachedCredentials(authToken.accessToken, authToken.sessionKey, brvConfig ? { spaceId: brvConfig.spaceId, teamId: brvConfig.teamId } : undefined);
588
- if (brvConfig) {
589
- agentLog(`Fully initialized with auth and config (team=${brvConfig.teamId}, space=${brvConfig.spaceId})`);
590
- }
591
- else {
592
- agentLog('Initialized with auth only (no project config yet - will reinit when config available)');
593
- }
594
- // Broadcast status change to Transport (init success)
595
- broadcastStatusChange();
700
+ pendingAgent = undefined; // Clear local ref - cipherAgent now owns it
596
701
  return true;
597
702
  }
598
703
  catch (error) {
@@ -630,11 +735,11 @@ async function handleTaskExecute(data) {
630
735
  if (!taskProcessor) {
631
736
  agentLog('TaskProcessor not initialized');
632
737
  const error = serializeTaskError(new ProcessorNotInitError());
633
- transportClient?.request('task:error', { error, taskId }).catch(logTransportError);
738
+ sendCriticalEvent('task:error', { error, taskId });
634
739
  return;
635
740
  }
636
- // Notify task started
637
- transportClient?.request('task:started', { taskId }).catch(logTransportError);
741
+ // Notify task started - use sendCriticalEvent for reliability
742
+ sendCriticalEvent('task:started', { taskId });
638
743
  try {
639
744
  // Process task - events stream via agentEventBus subscription
640
745
  // Response is forwarded via llmservice:response event (no manual send needed)
@@ -649,13 +754,15 @@ async function handleTaskExecute(data) {
649
754
  type,
650
755
  });
651
756
  // Notify completion with result (required by TaskCompletedEventSchema)
757
+ // Use sendCriticalEvent for retry logic - TUI must know task ended
652
758
  agentLog(`Task completed: ${taskId}`);
653
- transportClient?.request('task:completed', { result, taskId }).catch(logTransportError);
759
+ sendCriticalEvent('task:completed', { result, taskId });
654
760
  }
655
761
  catch (error) {
656
762
  const errorData = serializeTaskError(error);
657
763
  agentLog(`Task error: ${taskId} - [${errorData.name}] ${errorData.message}`);
658
- transportClient?.request('task:error', { error: errorData, taskId }).catch(logTransportError);
764
+ // Use sendCriticalEvent for retry logic - TUI must know task ended
765
+ sendCriticalEvent('task:error', { error: errorData, taskId });
659
766
  }
660
767
  }
661
768
  /**
@@ -670,8 +777,8 @@ function handleTaskCancel(data) {
670
777
  if (result.wasQueued) {
671
778
  // Task was in queue, not yet processing - removed by queue manager
672
779
  agentLog(`Task ${taskId} removed from ${result.taskType} queue (was waiting)`);
673
- // Notify transport that task was cancelled
674
- transportClient?.request('task:cancelled', { taskId }).catch(logTransportError);
780
+ // Notify transport that task was cancelled - use retry for reliability
781
+ sendCriticalEvent('task:cancelled', { taskId });
675
782
  }
676
783
  else {
677
784
  // Task is currently processing - cancel via taskProcessor
@@ -733,12 +840,13 @@ async function startAgent() {
733
840
  agentLog('Initial setup incomplete - will retry when tasks arrive (lazy init)');
734
841
  }
735
842
  // Setup event handlers - TaskQueueManager handles queueing and deduplication
843
+ // These handlers are registered once and persist for the worker's lifetime
736
844
  transportClient.on('task:execute', (data) => {
737
845
  // Reject tasks during reinit to prevent TOCTOU race condition
738
846
  if (isReinitializing) {
739
847
  agentLog(`Task ${data.taskId} rejected - agent reinitializing`);
740
848
  const error = serializeTaskError(new AgentNotInitializedError('Agent is reinitializing'));
741
- transportClient?.request('task:error', { error, taskId: data.taskId }).catch(logTransportError);
849
+ sendCriticalEvent('task:error', { error, taskId: data.taskId });
742
850
  return;
743
851
  }
744
852
  const result = taskQueueManager.enqueue(data);
@@ -42,6 +42,12 @@ export declare class TransportHandlers {
42
42
  private agentClientId;
43
43
  /** Cached agent status from last status:changed broadcast */
44
44
  private cachedAgentStatus;
45
+ /**
46
+ * Track recently completed tasks for grace period.
47
+ * Allows late-arriving llmservice:* events to be routed even after task:completed.
48
+ * Key: taskId, Value: {task: TaskInfo, completedAt: timestamp}
49
+ */
50
+ private completedTasks;
45
51
  /** Track active tasks */
46
52
  private tasks;
47
53
  /** Transport server reference */
@@ -55,6 +61,11 @@ export declare class TransportHandlers {
55
61
  * Setup all message handlers.
56
62
  */
57
63
  setup(): void;
64
+ /**
65
+ * Get task info from either active or recently completed tasks.
66
+ * Returns undefined if task is not found in either map.
67
+ */
68
+ private getTaskInfo;
58
69
  /**
59
70
  * Handle Agent registration.
60
71
  * Agent connects as Socket.IO client and sends 'agent:register'.
@@ -70,12 +81,13 @@ export declare class TransportHandlers {
70
81
  /**
71
82
  * Handle task:cancelled from Agent.
72
83
  * Terminal event: task was cancelled before completion.
73
- * Route to task owner + broadcast-room, then cleanup.
84
+ * Route to task owner + broadcast-room, then cleanup with grace period.
74
85
  */
75
86
  private handleTaskCancelled;
76
87
  /**
77
88
  * Handle task:completed from Agent.
78
89
  * Route directly to task owner + broadcast-room for monitoring.
90
+ * Uses grace period cleanup to allow late-arriving llmservice:* events.
79
91
  */
80
92
  private handleTaskCompleted;
81
93
  /**
@@ -86,6 +98,7 @@ export declare class TransportHandlers {
86
98
  /**
87
99
  * Handle task:error from Agent.
88
100
  * Route directly to task owner + broadcast-room for monitoring.
101
+ * Uses grace period cleanup to allow late-arriving llmservice:* events.
89
102
  */
90
103
  private handleTaskError;
91
104
  /**
@@ -93,6 +106,11 @@ export declare class TransportHandlers {
93
106
  * Route directly to task owner + broadcast-room for monitoring.
94
107
  */
95
108
  private handleTaskStarted;
109
+ /**
110
+ * Move a task to the completed tasks map with grace period cleanup.
111
+ * This allows late-arriving llmservice:* events to still be routed.
112
+ */
113
+ private moveToCompleted;
96
114
  private registerLlmEvent;
97
115
  /**
98
116
  * Generic handler for routing LLM events from Agent to clients.
@@ -100,9 +118,12 @@ export declare class TransportHandlers {
100
118
  *
101
119
  * All llmservice:* events follow the same routing pattern:
102
120
  * 1. Extract taskId from payload
103
- * 2. Check if task is still active (not completed/cancelled/errored)
104
- * 3. Send to task owner + broadcast-room if active
105
- * 4. Drop silently if task already ended (prevents events-after-terminal)
121
+ * 2. Check if task is active OR recently completed (within grace period)
122
+ * 3. Send to task owner + broadcast-room if found
123
+ * 4. Drop silently if task not found (truly ended beyond grace period)
124
+ *
125
+ * The grace period allows late-arriving events (due to network delays or
126
+ * out-of-order delivery) to still be routed to clients.
106
127
  */
107
128
  private routeLlmEvent;
108
129
  /**
@@ -39,6 +39,15 @@ import { isValidTaskType } from '../../utils/type-guards.js';
39
39
  // - TaskStartedEvent, TaskCompletedEvent, TaskErrorEvent: Agent → Transport (task lifecycle events)
40
40
  // - LlmThinkingEvent, LlmChunkEvent, LlmResponseEvent, etc: Agent → Transport (LLM events)
41
41
  // ============================================================================
42
+ // Constants
43
+ // ============================================================================
44
+ /**
45
+ * Grace period (in ms) to keep completed tasks in memory for late-arriving events.
46
+ * This prevents silent event drops when llmservice:* events arrive after task:completed.
47
+ * Inspired by opencode's session callback queuing pattern.
48
+ */
49
+ const TASK_CLEANUP_GRACE_PERIOD_MS = 5000;
50
+ // ============================================================================
42
51
  // Transport Handlers
43
52
  // ============================================================================
44
53
  /**
@@ -52,6 +61,12 @@ export class TransportHandlers {
52
61
  agentClientId;
53
62
  /** Cached agent status from last status:changed broadcast */
54
63
  cachedAgentStatus;
64
+ /**
65
+ * Track recently completed tasks for grace period.
66
+ * Allows late-arriving llmservice:* events to be routed even after task:completed.
67
+ * Key: taskId, Value: {task: TaskInfo, completedAt: timestamp}
68
+ */
69
+ completedTasks = new Map();
55
70
  /** Track active tasks */
56
71
  tasks = new Map();
57
72
  /** Transport server reference */
@@ -64,6 +79,7 @@ export class TransportHandlers {
64
79
  */
65
80
  cleanup() {
66
81
  this.tasks.clear();
82
+ this.completedTasks.clear();
67
83
  this.agentClientId = undefined;
68
84
  this.cachedAgentStatus = undefined;
69
85
  }
@@ -76,6 +92,13 @@ export class TransportHandlers {
76
92
  this.setupClientHandlers();
77
93
  this.setupAgentControlHandlers();
78
94
  }
95
+ /**
96
+ * Get task info from either active or recently completed tasks.
97
+ * Returns undefined if task is not found in either map.
98
+ */
99
+ getTaskInfo(taskId) {
100
+ return this.tasks.get(taskId) ?? this.completedTasks.get(taskId)?.task;
101
+ }
79
102
  /**
80
103
  * Handle Agent registration.
81
104
  * Agent connects as Socket.IO client and sends 'agent:register'.
@@ -119,7 +142,7 @@ export class TransportHandlers {
119
142
  /**
120
143
  * Handle task:cancelled from Agent.
121
144
  * Terminal event: task was cancelled before completion.
122
- * Route to task owner + broadcast-room, then cleanup.
145
+ * Route to task owner + broadcast-room, then cleanup with grace period.
123
146
  */
124
147
  handleTaskCancelled(data) {
125
148
  const { taskId } = data;
@@ -129,11 +152,13 @@ export class TransportHandlers {
129
152
  this.transport.sendTo(task.clientId, TransportTaskEventNames.CANCELLED, { taskId });
130
153
  }
131
154
  this.transport.broadcastTo('broadcast-room', TransportTaskEventNames.CANCELLED, { taskId });
132
- this.tasks.delete(taskId);
155
+ // Move to completed tasks with grace period instead of immediate deletion
156
+ this.moveToCompleted(taskId);
133
157
  }
134
158
  /**
135
159
  * Handle task:completed from Agent.
136
160
  * Route directly to task owner + broadcast-room for monitoring.
161
+ * Uses grace period cleanup to allow late-arriving llmservice:* events.
137
162
  */
138
163
  handleTaskCompleted(data) {
139
164
  const { result, taskId } = data;
@@ -143,7 +168,9 @@ export class TransportHandlers {
143
168
  this.transport.sendTo(task.clientId, TransportTaskEventNames.COMPLETED, { result, taskId });
144
169
  }
145
170
  this.transport.broadcastTo('broadcast-room', TransportTaskEventNames.COMPLETED, { result, taskId });
146
- this.tasks.delete(taskId);
171
+ // Move to completed tasks with grace period instead of immediate deletion
172
+ // This allows late-arriving llmservice:* events to still be routed
173
+ this.moveToCompleted(taskId);
147
174
  }
148
175
  /**
149
176
  * Handle task:create request from client.
@@ -221,6 +248,7 @@ export class TransportHandlers {
221
248
  /**
222
249
  * Handle task:error from Agent.
223
250
  * Route directly to task owner + broadcast-room for monitoring.
251
+ * Uses grace period cleanup to allow late-arriving llmservice:* events.
224
252
  */
225
253
  handleTaskError(data) {
226
254
  const { error, taskId } = data;
@@ -230,7 +258,8 @@ export class TransportHandlers {
230
258
  this.transport.sendTo(task.clientId, TransportTaskEventNames.ERROR, { error, taskId });
231
259
  }
232
260
  this.transport.broadcastTo('broadcast-room', TransportTaskEventNames.ERROR, { error, taskId });
233
- this.tasks.delete(taskId);
261
+ // Move to completed tasks with grace period instead of immediate deletion
262
+ this.moveToCompleted(taskId);
234
263
  }
235
264
  /**
236
265
  * Handle task:started from Agent.
@@ -257,6 +286,21 @@ export class TransportHandlers {
257
286
  this.transport.broadcastTo('broadcast-room', TransportTaskEventNames.STARTED, { taskId });
258
287
  }
259
288
  }
289
+ /**
290
+ * Move a task to the completed tasks map with grace period cleanup.
291
+ * This allows late-arriving llmservice:* events to still be routed.
292
+ */
293
+ moveToCompleted(taskId) {
294
+ const task = this.tasks.get(taskId);
295
+ if (task) {
296
+ this.completedTasks.set(taskId, { completedAt: Date.now(), task });
297
+ this.tasks.delete(taskId);
298
+ // Schedule cleanup after grace period
299
+ setTimeout(() => {
300
+ this.completedTasks.delete(taskId);
301
+ }, TASK_CLEANUP_GRACE_PERIOD_MS);
302
+ }
303
+ }
260
304
  registerLlmEvent(eventName) {
261
305
  this.transport.onRequest(eventName, (data) => {
262
306
  this.routeLlmEvent(eventName, data);
@@ -268,15 +312,18 @@ export class TransportHandlers {
268
312
  *
269
313
  * All llmservice:* events follow the same routing pattern:
270
314
  * 1. Extract taskId from payload
271
- * 2. Check if task is still active (not completed/cancelled/errored)
272
- * 3. Send to task owner + broadcast-room if active
273
- * 4. Drop silently if task already ended (prevents events-after-terminal)
315
+ * 2. Check if task is active OR recently completed (within grace period)
316
+ * 3. Send to task owner + broadcast-room if found
317
+ * 4. Drop silently if task not found (truly ended beyond grace period)
318
+ *
319
+ * The grace period allows late-arriving events (due to network delays or
320
+ * out-of-order delivery) to still be routed to clients.
274
321
  */
275
322
  routeLlmEvent(eventName, data) {
276
323
  const { taskId, ...rest } = data;
277
- const task = this.tasks.get(taskId);
278
- // Guard: Drop events for tasks that have already ended (terminal state reached)
279
- // This prevents "ghost events" arriving after task:completed/cancelled/error
324
+ // Use getTaskInfo to check both active and recently completed tasks
325
+ const task = this.getTaskInfo(taskId);
326
+ // Guard: Drop events for tasks not found in either active or completed maps
280
327
  if (!task) {
281
328
  return;
282
329
  }
@@ -41,9 +41,9 @@ export const connectorsCommand = {
41
41
  },
42
42
  type: 'streaming',
43
43
  }),
44
- aliases: [],
44
+ aliases: ['connectors'],
45
45
  autoExecute: true,
46
46
  description: 'Manage agent connectors (rules, hook, mcp, or skill)',
47
47
  kind: CommandKind.BUILT_IN,
48
- name: 'connectors',
48
+ name: 'connector',
49
49
  };
@@ -3,7 +3,9 @@ import { curateCommand } from './curate-command.js';
3
3
  import { initCommand } from './init-command.js';
4
4
  import { loginCommand } from './login-command.js';
5
5
  import { logoutCommand } from './logout-command.js';
6
+ import { modelCommand } from './model-command.js';
6
7
  import { newCommand } from './new-command.js';
8
+ import { providerCommand } from './provider-command.js';
7
9
  import { pullCommand } from './pull-command.js';
8
10
  import { pushCommand } from './push-command.js';
9
11
  import { queryCommand } from './query-command.js';
@@ -23,6 +25,9 @@ export const load = () => [
23
25
  queryCommand, // Query context tree
24
26
  // Connectors management
25
27
  connectorsCommand, // Manage agent connectors (rules/hook)
28
+ // Provider management
29
+ providerCommand, // Connect to LLM providers
30
+ modelCommand, // Select model from provider
26
31
  // Sync operations
27
32
  pushCommand, // Push to cloud
28
33
  pullCommand, // Pull from cloud
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Model Command
3
+ *
4
+ * Interactive command for selecting LLM models from the active provider.
5
+ * Uses the streaming command pattern with inline prompts.
6
+ *
7
+ * Usage: /model
8
+ */
9
+ import type { SlashCommand } from '../../../tui/types.js';
10
+ /**
11
+ * Model command definition.
12
+ */
13
+ export declare const modelCommand: SlashCommand;