@tyvm/knowhow 0.0.153 → 0.0.155

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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +72 -28
  3. package/src/agents/base/prompt.ts +3 -2
  4. package/src/agents/tools/list.ts +31 -28
  5. package/src/auth/keyAuth.ts +35 -3
  6. package/src/chat/modules/AgentModule.ts +98 -42
  7. package/src/clients/anthropic.ts +3 -1
  8. package/src/clients/contextLimits.ts +3 -1
  9. package/src/clients/gemini.ts +16 -4
  10. package/src/clients/http.ts +8 -5
  11. package/src/clients/index.ts +7 -4
  12. package/src/clients/knowhowMcp.ts +2 -2
  13. package/src/clients/openai.ts +44 -8
  14. package/src/clients/pricing/google.ts +11 -2
  15. package/src/clients/pricing/xai.ts +5 -1
  16. package/src/clients/xai.ts +6 -3
  17. package/src/services/KnowhowClient.ts +6 -5
  18. package/src/services/watchers/FsSyncer.ts +40 -2
  19. package/src/services/watchers/RemoteSyncer.ts +40 -2
  20. package/src/tunnel.ts +18 -12
  21. package/src/worker.ts +62 -12
  22. package/tests/clients/reasoningEffort.test.ts +62 -1
  23. package/tests/unit/auth/keyAuth.test.ts +31 -2
  24. package/tests/unit/services/KnowhowClient.test.ts +84 -0
  25. package/tests/unit/services/SyncedAgentWatcher.test.ts +89 -0
  26. package/ts_build/package.json +1 -1
  27. package/ts_build/src/agents/base/base.d.ts +2 -1
  28. package/ts_build/src/agents/base/base.js +39 -26
  29. package/ts_build/src/agents/base/base.js.map +1 -1
  30. package/ts_build/src/agents/base/prompt.d.ts +1 -1
  31. package/ts_build/src/agents/base/prompt.js +3 -2
  32. package/ts_build/src/agents/base/prompt.js.map +1 -1
  33. package/ts_build/src/agents/tools/list.js +0 -26
  34. package/ts_build/src/agents/tools/list.js.map +1 -1
  35. package/ts_build/src/auth/keyAuth.d.ts +1 -0
  36. package/ts_build/src/auth/keyAuth.js +19 -2
  37. package/ts_build/src/auth/keyAuth.js.map +1 -1
  38. package/ts_build/src/chat/modules/AgentModule.d.ts +3 -0
  39. package/ts_build/src/chat/modules/AgentModule.js +74 -34
  40. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  41. package/ts_build/src/clients/anthropic.js +3 -1
  42. package/ts_build/src/clients/anthropic.js.map +1 -1
  43. package/ts_build/src/clients/contextLimits.js +2 -0
  44. package/ts_build/src/clients/contextLimits.js.map +1 -1
  45. package/ts_build/src/clients/gemini.js +14 -3
  46. package/ts_build/src/clients/gemini.js.map +1 -1
  47. package/ts_build/src/clients/http.js +8 -5
  48. package/ts_build/src/clients/http.js.map +1 -1
  49. package/ts_build/src/clients/index.js +7 -4
  50. package/ts_build/src/clients/index.js.map +1 -1
  51. package/ts_build/src/clients/knowhowMcp.js +2 -2
  52. package/ts_build/src/clients/knowhowMcp.js.map +1 -1
  53. package/ts_build/src/clients/openai.js +36 -8
  54. package/ts_build/src/clients/openai.js.map +1 -1
  55. package/ts_build/src/clients/pricing/google.d.ts +1 -0
  56. package/ts_build/src/clients/pricing/google.js +7 -0
  57. package/ts_build/src/clients/pricing/google.js.map +1 -1
  58. package/ts_build/src/clients/pricing/xai.d.ts +1 -0
  59. package/ts_build/src/clients/pricing/xai.js +4 -0
  60. package/ts_build/src/clients/pricing/xai.js.map +1 -1
  61. package/ts_build/src/clients/xai.js +6 -3
  62. package/ts_build/src/clients/xai.js.map +1 -1
  63. package/ts_build/src/services/KnowhowClient.js +5 -3
  64. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  65. package/ts_build/src/services/watchers/FsSyncer.d.ts +4 -0
  66. package/ts_build/src/services/watchers/FsSyncer.js +36 -2
  67. package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
  68. package/ts_build/src/services/watchers/RemoteSyncer.d.ts +4 -0
  69. package/ts_build/src/services/watchers/RemoteSyncer.js +36 -2
  70. package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
  71. package/ts_build/src/tunnel.js +11 -8
  72. package/ts_build/src/tunnel.js.map +1 -1
  73. package/ts_build/src/types.d.ts +2 -0
  74. package/ts_build/src/worker.js +47 -10
  75. package/ts_build/src/worker.js.map +1 -1
  76. package/ts_build/tests/clients/reasoningEffort.test.js +49 -1
  77. package/ts_build/tests/clients/reasoningEffort.test.js.map +1 -1
  78. package/ts_build/tests/unit/auth/keyAuth.test.js +22 -2
  79. package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -1
  80. package/ts_build/tests/unit/services/KnowhowClient.test.d.ts +1 -0
  81. package/ts_build/tests/unit/services/KnowhowClient.test.js +69 -0
  82. package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -0
  83. package/ts_build/tests/unit/services/SyncedAgentWatcher.test.d.ts +1 -0
  84. package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js +78 -0
  85. package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js.map +1 -0
@@ -247,9 +247,9 @@ export class HttpClient implements GenericClient {
247
247
  max_tokens: options.max_tokens || 4000,
248
248
  ...this.extra_body,
249
249
 
250
- ...(options.tools && {
250
+ ...(options.tools?.length && {
251
251
  tools: options.tools,
252
- tool_choice: "auto",
252
+ tool_choice: options.tool_choice ?? "auto",
253
253
  }),
254
254
  };
255
255
 
@@ -297,9 +297,9 @@ export class HttpClient implements GenericClient {
297
297
  ...this.extra_body,
298
298
  stream: true,
299
299
  stream_options: { include_usage: true },
300
- ...(options.tools && {
300
+ ...(options.tools?.length && {
301
301
  tools: options.tools,
302
- tool_choice: "auto",
302
+ tool_choice: options.tool_choice ?? "auto",
303
303
  }),
304
304
  };
305
305
 
@@ -442,7 +442,10 @@ export class HttpClient implements GenericClient {
442
442
  input,
443
443
  ...(instructions && { instructions }),
444
444
  ...(options.max_tokens && { max_output_tokens: options.max_tokens }),
445
- ...(tools?.length && { tools, tool_choice: "auto" }),
445
+ ...(tools?.length && {
446
+ tools,
447
+ tool_choice: options.tool_choice ?? "auto",
448
+ }),
446
449
  // Reasoning persistence: store server-side when requested, otherwise
447
450
  // ask the provider to return encrypted reasoning content so we can
448
451
  // thread the reasoning items back into the next request ourselves.
@@ -28,8 +28,9 @@ import { GenericXAIClient } from "./xai";
28
28
  import { KnowhowGenericClient } from "./knowhow";
29
29
  import { HttpClient } from "./http";
30
30
  import { ModelProvider } from "../types";
31
- import { getConfig } from "../config";
31
+ import { getConfig, getConfigSync } from "../config";
32
32
  import { loadKnowhowJwt, KNOWHOW_API_URL } from "../services/KnowhowClient";
33
+ import { keyPairExists } from "../auth/keyManager";
33
34
  import { ContextLimits } from "./contextLimits";
34
35
  import { OpenAiTextPricing } from "./pricing/openai";
35
36
  import { AnthropicTextPricing } from "./pricing/anthropic";
@@ -108,9 +109,11 @@ const BUILT_IN_PROVIDER_REGISTRY: Record<string, ProviderRegistryEntry> = {
108
109
  qwen: { clientClass: GenericQwenClient },
109
110
  knowhow: {
110
111
  createClient: (entry: ModelProvider) => {
111
- const jwt = loadKnowhowJwt();
112
- if (!jwt) return null;
113
- return new KnowhowGenericClient(KNOWHOW_API_URL, jwt);
112
+ if (!loadKnowhowJwt()) {
113
+ const config = getConfigSync();
114
+ if (!config.orgId || (!keyPairExists(config.cliIdentityPath) && !keyPairExists())) return null;
115
+ }
116
+ return new KnowhowGenericClient(KNOWHOW_API_URL);
114
117
  },
115
118
  },
116
119
  };
@@ -62,9 +62,9 @@ export class KnowhowMcpClient implements GenericClient {
62
62
  ...options,
63
63
  max_tokens: options.max_tokens || 3000,
64
64
 
65
- ...(options.tools && {
65
+ ...(options.tools?.length && {
66
66
  tools: options.tools,
67
- tool_choice: "auto",
67
+ tool_choice: options.tool_choice ?? "auto",
68
68
  }),
69
69
  },
70
70
  }
@@ -235,9 +235,9 @@ export class GenericOpenAiClient implements GenericClient {
235
235
  max_completion_tokens: Math.max(options.max_tokens ?? 0, 16_000),
236
236
  reasoning_effort: this.resolveReasoningEffort(options),
237
237
  }),
238
- ...(options.tools && {
238
+ ...(options.tools?.length && {
239
239
  tools: options.tools,
240
- tool_choice: "auto",
240
+ tool_choice: options.tool_choice ?? "auto",
241
241
  }),
242
242
  }, { signal: options.signal });
243
243
 
@@ -296,7 +296,10 @@ export class GenericOpenAiClient implements GenericClient {
296
296
  max_tokens: options.max_tokens,
297
297
  stream: true,
298
298
  stream_options: { include_usage: true },
299
- ...(options.tools && { tools: options.tools, tool_choice: "auto" }),
299
+ ...(options.tools?.length && {
300
+ tools: options.tools,
301
+ tool_choice: options.tool_choice ?? "auto",
302
+ }),
300
303
  }, { signal: options.signal });
301
304
 
302
305
  let usage: CompletionResponse['usage'] | undefined;
@@ -492,16 +495,22 @@ export class GenericOpenAiClient implements GenericClient {
492
495
  include: ["reasoning.encrypted_content"],
493
496
  }),
494
497
  // Don't limit max_output_tokens for Responses API - codex truncates tool call arguments when limited
495
- ...(OpenAiReasoningModels.includes(options.model) && !this.isReasoningDisabled(options) && {
496
- max_output_tokens: Math.max(options.max_tokens || 0, 16000),
498
+ ...(OpenAiReasoningModels.includes(options.model) && {
497
499
  reasoning: {
498
- effort: this.resolveReasoningEffortForModel(options),
499
- ...(options.reasoning_summary && { summary: "auto" }),
500
+ ...(this.isReasoningDisabled(options)
501
+ ? { effort: "none" }
502
+ : {
503
+ effort: this.resolveReasoningEffortForModel(options),
504
+ ...(options.reasoning_summary && { summary: "auto" }),
505
+ }),
500
506
  },
501
507
  }),
508
+ ...(wantsReasoning && {
509
+ max_output_tokens: Math.max(options.max_tokens || 0, 16000),
510
+ }),
502
511
  ...(tools?.length && {
503
512
  tools,
504
- tool_choice: "auto",
513
+ tool_choice: options.tool_choice ?? "auto",
505
514
  }),
506
515
  } as any);
507
516
 
@@ -579,6 +588,33 @@ export class GenericOpenAiClient implements GenericClient {
579
588
  }
580
589
  }
581
590
 
591
+ // Tool-only responses are valid agent turns. Warn only when the response
592
+ // contains neither visible text nor any callable output, and include enough
593
+ // provider metadata to diagnose incomplete or reasoning-only responses.
594
+ if (!textContent?.trim() && toolCalls.length === 0) {
595
+ const outputShape = response.output.map((item: any) => ({
596
+ type: item.type,
597
+ contentTypes: Array.isArray(item.content)
598
+ ? item.content.map((part: any) => part?.type ?? typeof part)
599
+ : [],
600
+ }));
601
+ const reasoningSummaryCount = response.output
602
+ .filter((item: any) => item.type === "reasoning")
603
+ .reduce(
604
+ (count: number, item: any) =>
605
+ count + (Array.isArray(item.summary) ? item.summary.length : 0),
606
+ 0
607
+ );
608
+ console.warn("[Responses API] Response contained no text or tool calls", {
609
+ responseId: (response as any).id,
610
+ status: (response as any).status,
611
+ incompleteDetails: (response as any).incomplete_details,
612
+ outputShape,
613
+ reasoningSummaryCount,
614
+ usage: response.usage,
615
+ });
616
+ }
617
+
582
618
  return {
583
619
  choices: [
584
620
  {
@@ -7,7 +7,8 @@ import { completions, embeddings, images, videos, audios, liveApi, ModelCatalogE
7
7
  // ─── Model IDs ────────────────────────────────────────────────────────────────
8
8
 
9
9
  export const GoogleModels = {
10
- // Gemini 3.6 / 3.5
10
+ // Gemini 3.7 / 3.6 / 3.5
11
+ Gemini_37_Flash: "gemini-3.7-flash",
11
12
  Gemini_36_Flash: "gemini-3.6-flash",
12
13
  Gemini_35_Flash: "gemini-3.5-flash",
13
14
  Gemini_35_Flash_Lite: "gemini-3.5-flash-lite",
@@ -86,6 +87,7 @@ export const GoogleEmbeddingModels: Record<string, string> = {
86
87
  // ─── Modality arrays ──────────────────────────────────────────────────────────
87
88
 
88
89
  export const GoogleTextModels: string[] = [
90
+ GoogleModels.Gemini_37_Flash,
89
91
  GoogleModels.Gemini_36_Flash,
90
92
  GoogleModels.Gemini_35_Flash,
91
93
  GoogleModels.Gemini_35_Flash_Lite,
@@ -101,6 +103,7 @@ export const GoogleTextModels: string[] = [
101
103
 
102
104
  // Models that support thinkingLevel (Gemini 3.x series)
103
105
  export const GoogleThinkingLevelModels: string[] = [
106
+ GoogleModels.Gemini_37_Flash,
104
107
  GoogleModels.Gemini_36_Flash,
105
108
  GoogleModels.Gemini_35_Flash,
106
109
  GoogleModels.Gemini_35_Flash_Lite,
@@ -214,7 +217,13 @@ export interface GeminiModelPricing extends ModelPricing {
214
217
  }
215
218
 
216
219
  export const GeminiPricing: Record<string, GeminiModelPricing> = {
217
- // ── Gemini 3.6 / 3.5 ──────────────────────────────────────────────────────
220
+ // ── Gemini 3.7 / 3.6 / 3.5 ────────────────────────────────────────────────
221
+ // Introductory pricing through December 31, 2026. Standard pricing beginning
222
+ // January 1, 2027 is $1.50 input, $7.50 output, and $0.15 cached input.
223
+ [GoogleModels.Gemini_37_Flash]: {
224
+ input: 0.75, output: 3.75,
225
+ context_caching: 0.075, context_caching_storage: 0.5,
226
+ },
218
227
  [GoogleModels.Gemini_36_Flash]: {
219
228
  input: 1.5, output: 7.5,
220
229
  context_caching: 0.15, context_caching_storage: 1.0,
@@ -7,6 +7,7 @@ import { completions, images, videos, ModelCatalogEntry, ModelPricing } from "./
7
7
  // ─── Model IDs ────────────────────────────────────────────────────────────────
8
8
 
9
9
  export const XaiModels = {
10
+ Grok4_6: "grok-4.6",
10
11
  Grok_4_20_Reasoning: "grok-4.20-0309-reasoning",
11
12
  Grok_4_20_NonReasoning: "grok-4.20-0309-non-reasoning",
12
13
  Grok_4_20_MultiAgent: "grok-4.20-multi-agent-0309",
@@ -42,6 +43,7 @@ export const XaiModels = {
42
43
  // ─── Modality arrays ──────────────────────────────────────────────────────────
43
44
 
44
45
  export const XaiTextModels: string[] = [
46
+ XaiModels.Grok4_6,
45
47
  XaiModels.Grok_4_20_Reasoning, XaiModels.Grok_4_20_NonReasoning,
46
48
  XaiModels.Grok_4_20_MultiAgent,
47
49
  XaiModels.Grok4_1_Fast_Reasoning, XaiModels.Grok4_1_Fast_NonReasoning,
@@ -61,8 +63,9 @@ export const XaiResponsesOnlyModels: string[] = [
61
63
  ];
62
64
 
63
65
  // Models that support the reasoning_effort parameter
64
- // grok-3-mini variants support reasoning_effort; grok-3-beta, grok-4 etc. do NOT
66
+ // Grok 4.6, Grok 4.20 multi-agent, and grok-3-mini variants support reasoning_effort
65
67
  export const XaiReasoningModels: string[] = [
68
+ XaiModels.Grok4_6,
66
69
  XaiModels.Grok_4_20_MultiAgent,
67
70
  XaiModels.Grok3MiniBeta,
68
71
  XaiModels.Grok3MiniFastBeta,
@@ -84,6 +87,7 @@ export const XaiVideoModels: string[] = [XaiModels.GrokImagineVideo];
84
87
  // ─── Pricing (USD per 1M tokens / per-image / per-second) ────────────────────
85
88
 
86
89
  export const XaiTextPricing: Record<string, ModelPricing> = {
90
+ [XaiModels.Grok4_6]: { input: 2.0, input_gt_200k: 4.0, cache_hit: 0.50, output: 6.0, output_gt_200k: 12.0, reasoningLevels: ["low", "medium", "high", "xhigh"] },
87
91
  [XaiModels.Grok_4_20_Reasoning]: { input: 2.0, cache_hit: 0.20, output: 6.0, useResponsesApi: true },
88
92
  [XaiModels.Grok_4_20_NonReasoning]: { input: 2.0, cache_hit: 0.20, output: 6.0, useResponsesApi: true },
89
93
  [XaiModels.Grok_4_20_MultiAgent]: { input: 2.0, cache_hit: 0.20, output: 6.0, reasoningLevels: ["low", "medium", "high", "xhigh"], useResponsesApi: true },
@@ -90,9 +90,9 @@ export class GenericXAIClient implements GenericClient {
90
90
  // grok-3-mini models support reasoning_effort: "low" | "medium" | "high"
91
91
  reasoning_effort: reasoningEffort,
92
92
  }),
93
- ...(options.tools && {
93
+ ...(options.tools?.length && {
94
94
  tools: options.tools,
95
- tool_choice: "auto",
95
+ tool_choice: options.tool_choice ?? "auto",
96
96
  }),
97
97
  });
98
98
 
@@ -238,7 +238,10 @@ export class GenericXAIClient implements GenericClient {
238
238
  ...(instructions && { instructions }),
239
239
  ...(options.max_tokens && { max_output_tokens: Math.max(options.max_tokens, 16_000) }),
240
240
  ...(reasoningEffort && { reasoning: { effort: reasoningEffort } }),
241
- ...(tools?.length && { tools, tool_choice: "auto" }),
241
+ ...(tools?.length && {
242
+ tools,
243
+ tool_choice: options.tool_choice ?? "auto",
244
+ }),
242
245
  // Reasoning persistence (same construct as OpenAI's Responses API):
243
246
  // - store === true → xAI persists the response; next turn passes
244
247
  // previous_response_id to continue the reasoning chain server-side.
@@ -3,7 +3,7 @@ import { TraceAll } from "../util/Trace";
3
3
  import http, { HTTP_UNAUTHORIZED_HANDLER, RefreshableHeaders } from "../utils/http";
4
4
  import fs from "fs";
5
5
  import { getConfigSync } from "../config";
6
- import { exchangePublicKeyJwt, storeJwt } from "../auth/keyAuth";
6
+ import { exchangeAvailablePublicKeyJwt, storeJwt } from "../auth/keyAuth";
7
7
  import { keyPairExists } from "../auth/keyManager";
8
8
  import { Message } from "../clients/types";
9
9
  import path from "path";
@@ -189,6 +189,7 @@ export class KnowhowSimpleClient {
189
189
  }
190
190
 
191
191
  private jwtExpiresSoon(): boolean {
192
+ if (!this.jwt) return true;
192
193
  try {
193
194
  const payload = JSON.parse(Buffer.from(this.jwt.split(".")[1], "base64url").toString("utf8"));
194
195
  return typeof payload.exp === "number" && payload.exp * 1000 - Date.now() < 5 * 60 * 1000;
@@ -205,11 +206,11 @@ export class KnowhowSimpleClient {
205
206
  this.refreshPromise = (async () => {
206
207
  const config = getConfigSync();
207
208
  const identityPath = config.cliIdentityPath;
208
- if (!config.orgId || !keyPairExists(identityPath)) {
209
+ if (!config.orgId || (!keyPairExists(identityPath) && !keyPairExists())) {
209
210
  throw new Error("JWT expired and the CLI identity is unavailable. Please run `knowhow login` again.");
210
211
  }
211
212
  try {
212
- const freshJwt = await exchangePublicKeyJwt(config.orgId, this.baseUrl, identityPath);
213
+ const freshJwt = await exchangeAvailablePublicKeyJwt(config.orgId, this.baseUrl, identityPath);
213
214
  storeJwt(freshJwt);
214
215
  this.setJwt(freshJwt);
215
216
  this.jwtValidated = true;
@@ -226,12 +227,12 @@ export class KnowhowSimpleClient {
226
227
  }
227
228
 
228
229
  async checkJwt() {
230
+ await this.renewJwt();
231
+
229
232
  if (!this.jwt) {
230
233
  throw new Error("No JWT found. Please login first.");
231
234
  }
232
235
 
233
- await this.renewJwt();
234
-
235
236
  if (!this.jwtValidated) {
236
237
  try {
237
238
  this.jwtValidated = true;
@@ -15,7 +15,9 @@ export class FsSyncedAgentWatcher implements SyncedAgentWatcher {
15
15
  public taskId: string = "";
16
16
  private taskPath: string = "";
17
17
  private watcher: fs.FSWatcher | null = null;
18
+ private lastThreadIndex: number = -1;
18
19
  private lastThreadLength: number = 0;
20
+ private lastMessageJson: string | null = null;
19
21
  public agentName: string = "unknown";
20
22
  private debounceTimer: NodeJS.Timeout | null = null;
21
23
  public agentEvents = new EventService();
@@ -37,7 +39,9 @@ export class FsSyncedAgentWatcher implements SyncedAgentWatcher {
37
39
  const threads: any[][] = metadata.threads || [];
38
40
  const lastThread = threads[threads.length - 1] || [];
39
41
  this.agentName = metadata.agentName || taskId;
42
+ this.lastThreadIndex = threads.length - 1;
40
43
  this.lastThreadLength = lastThread.length;
44
+ this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
41
45
  }
42
46
 
43
47
  // Watch the directory for metadata.json changes
@@ -68,8 +72,26 @@ export class FsSyncedAgentWatcher implements SyncedAgentWatcher {
68
72
  const threads: any[][] = metadata.threads;
69
73
  const lastThread = threads[threads.length - 1] || [];
70
74
 
75
+ const threadIndex = threads.length - 1;
76
+ let newMessages: any[];
77
+ if (
78
+ threadIndex === this.lastThreadIndex &&
79
+ lastThread.length >= this.lastThreadLength
80
+ ) {
81
+ newMessages = lastThread.slice(this.lastThreadLength);
82
+ } else {
83
+ const retainedIndex = this.findLastMessage(
84
+ lastThread,
85
+ this.lastMessageJson
86
+ );
87
+ newMessages = lastThread.slice(retainedIndex + 1);
88
+ }
89
+
90
+ this.lastThreadIndex = threadIndex;
91
+ this.lastThreadLength = lastThread.length;
92
+ this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
93
+
71
94
  // Only render NEW messages since last check
72
- const newMessages = lastThread.slice(this.lastThreadLength);
73
95
  if (newMessages.length > 0) {
74
96
  const renderEvents = messagesToRenderEvents(
75
97
  newMessages,
@@ -93,7 +115,6 @@ export class FsSyncedAgentWatcher implements SyncedAgentWatcher {
93
115
  }
94
116
  }
95
117
  this.agentEvents.emit(this.eventTypes.threadUpdate, lastThread);
96
- this.lastThreadLength = lastThread.length;
97
118
  }
98
119
 
99
120
  // Emit done if the agent has completed and has a result
@@ -149,6 +170,23 @@ export class FsSyncedAgentWatcher implements SyncedAgentWatcher {
149
170
  console.log(`🫵 Interrupt written to input for agent: ${this.taskId}`);
150
171
  }
151
172
 
173
+ private messageJson(message: any): string | null {
174
+ if (message == null) return null;
175
+ try {
176
+ return JSON.stringify(message);
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ private findLastMessage(messages: any[], messageJson: string | null): number {
183
+ if (messageJson == null) return -1;
184
+ for (let index = messages.length - 1; index >= 0; index--) {
185
+ if (this.messageJson(messages[index]) === messageJson) return index;
186
+ }
187
+ return -1;
188
+ }
189
+
152
190
  private async readMetadata(): Promise<any> {
153
191
  try {
154
192
  const metaPath = path.join(this.taskPath, "metadata.json");
@@ -16,7 +16,9 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
16
16
  public taskId: string = "";
17
17
  private client: KnowhowSimpleClient;
18
18
  private pollInterval: NodeJS.Timeout | null = null;
19
+ private lastThreadIndex: number = -1;
19
20
  private lastThreadLength: number = 0;
21
+ private lastMessageJson: string | null = null;
20
22
  public agentName: string = "remote-agent";
21
23
  private stopped: boolean = false;
22
24
  public agentEvents = new EventService();
@@ -41,8 +43,10 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
41
43
  const details = await this.client.getTaskDetails(taskId);
42
44
  const threads: any[][] = details?.data?.threads || [];
43
45
  const lastThread = threads[threads.length - 1] || [];
46
+ this.lastThreadIndex = threads.length - 1;
44
47
  this.agentName = "remote-agent";
45
48
  this.lastThreadLength = lastThread.length;
49
+ this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
46
50
  } catch (err: any) {
47
51
  console.warn(
48
52
  `⚠️ Could not load initial state for task ${taskId}: ${err.message}`
@@ -68,8 +72,26 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
68
72
  const details = await this.client.getTaskDetails(this.taskId);
69
73
  const threads: any[][] = details?.data?.threads || [];
70
74
  const lastThread = threads[threads.length - 1] || [];
75
+ const threadIndex = threads.length - 1;
76
+
77
+ let newMessages: any[];
78
+ if (
79
+ threadIndex === this.lastThreadIndex &&
80
+ lastThread.length >= this.lastThreadLength
81
+ ) {
82
+ newMessages = lastThread.slice(this.lastThreadLength);
83
+ } else {
84
+ const retainedIndex = this.findLastMessage(
85
+ lastThread,
86
+ this.lastMessageJson
87
+ );
88
+ newMessages = lastThread.slice(retainedIndex + 1);
89
+ }
90
+
91
+ this.lastThreadIndex = threadIndex;
92
+ this.lastThreadLength = lastThread.length;
93
+ this.lastMessageJson = this.messageJson(lastThread[lastThread.length - 1]);
71
94
 
72
- const newMessages = lastThread.slice(this.lastThreadLength);
73
95
  if (newMessages.length > 0) {
74
96
  const renderEvents = messagesToRenderEvents(
75
97
  newMessages,
@@ -93,7 +115,6 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
93
115
  }
94
116
  }
95
117
  this.agentEvents.emit(this.eventTypes.threadUpdate, lastThread);
96
- this.lastThreadLength = lastThread.length;
97
118
  }
98
119
 
99
120
  // Stop polling and emit done if task is complete with a result
@@ -155,4 +176,21 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
155
176
  await this.client.sendMessageToAgent(this.taskId, `/poke ${message || ""}`.trim());
156
177
  console.log(`🫵 Interrupt sent to remote web agent: ${this.taskId}`);
157
178
  }
179
+
180
+ private messageJson(message: any): string | null {
181
+ if (message == null) return null;
182
+ try {
183
+ return JSON.stringify(message);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ private findLastMessage(messages: any[], messageJson: string | null): number {
190
+ if (messageJson == null) return -1;
191
+ for (let index = messages.length - 1; index >= 0; index--) {
192
+ if (this.messageJson(messages[index]) === messageJson) return index;
193
+ }
194
+ return -1;
195
+ }
158
196
  }
package/src/tunnel.ts CHANGED
@@ -10,10 +10,12 @@ import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
10
10
  import { WsMiddlewareStack } from "./workers/auth/WsMiddleware";
11
11
  import { makeAuthMiddleware } from "./workers/auth/authMiddleware";
12
12
 
13
+ const PROD_TUNNEL_WORKER_DOMAIN = "worker.tyvm-apps.com";
14
+
13
15
  /**
14
- * Extract the tunnel domain and protocol from the API URL.
15
- * e.g., "https://api.knowhow.tyvm.ai" -> { domain: "worker.knowhow.tyvm.ai", useHttps: true }
16
- * e.g., "http://localhost:4000" -> { domain: "worker.localhost:4000", useHttps: false }
16
+ * Resolve the tunnel domain and protocol from the environment and API URL.
17
+ * TUNNEL_WORKER_DOMAIN takes precedence. Otherwise dev API URLs use the dev
18
+ * tunnel domain and all other API URLs use the production tunnel domain.
17
19
  */
18
20
  export function extractTunnelDomain(apiUrl: string): {
19
21
  domain: string;
@@ -22,18 +24,22 @@ export function extractTunnelDomain(apiUrl: string): {
22
24
  try {
23
25
  const url = new URL(apiUrl);
24
26
  const useHttps = url.protocol === "https:";
27
+ const configuredDomain = process.env.TUNNEL_WORKER_DOMAIN?.trim();
25
28
 
26
- // For localhost, include port; for production, just use hostname
27
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
28
- return {
29
- domain: `worker.${url.hostname}:${url.port || "80"}`,
30
- useHttps,
31
- };
32
- }
33
- return { domain: `worker.${url.hostname}`, useHttps };
29
+ const isDevApi = url.hostname.split(".").includes("dev");
30
+ const domain =
31
+ configuredDomain ||
32
+ (isDevApi ? `worker.dev.tyvm-apps.com` : PROD_TUNNEL_WORKER_DOMAIN);
33
+
34
+ return { domain, useHttps };
34
35
  } catch (err) {
35
36
  console.error("Failed to parse API_URL for tunnel domain:", err);
36
- return { domain: "worker.localhost:4000", useHttps: false }; // fallback
37
+ return {
38
+ domain:
39
+ process.env.TUNNEL_WORKER_DOMAIN?.trim() ||
40
+ PROD_TUNNEL_WORKER_DOMAIN,
41
+ useHttps: false,
42
+ };
37
43
  }
38
44
  }
39
45
 
package/src/worker.ts CHANGED
@@ -6,6 +6,7 @@ import { loadJwt } from "./login";
6
6
  import { services } from "./services";
7
7
  import { PasskeySetupService } from "./workers/auth/PasskeySetup";
8
8
  import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
9
+ import { authenticateWithKey, hasKeyPair } from "./auth/keyAuth";
9
10
  import {
10
11
  makeUnlockTool,
11
12
  makeLockTool,
@@ -303,8 +304,8 @@ export async function worker(options?: {
303
304
  let connected = false;
304
305
  let tunnelHandler: TunnelHandler | null = null;
305
306
  let tunnelWs: WebSocket | null = null;
306
- let lastJwt: string | null = null;
307
307
  let unauthorizedJwt: string | null = null;
308
+ let refreshAttemptedJwt: string | null = null;
308
309
 
309
310
  // ---------------------------------------------------------------------------
310
311
  // Liveness watchdog state
@@ -353,7 +354,6 @@ export async function worker(options?: {
353
354
  async function connectWebSocket() {
354
355
  const jwt = await loadJwt();
355
356
  console.log(`Connecting to ${API_URL}`);
356
- lastJwt = jwt;
357
357
 
358
358
  // Reset the MCP server to avoid "Already connected to a transport" error on reconnects
359
359
  await mcpServer.reset();
@@ -476,11 +476,17 @@ export async function worker(options?: {
476
476
  },
477
477
  onClose: (code, _reason) => {
478
478
  if (code === 1008) {
479
- unauthorizedJwt = lastJwt;
479
+ unauthorizedJwt = jwt;
480
+ // The worker and tunnel sockets share a JWT and must be renewed as
481
+ // one connection cycle. Ensure a later worker "open" event cannot
482
+ // leave us connected with a permanently closed tunnel.
483
+ if (ws.readyState !== WebSocket.CLOSED) {
484
+ ws.terminate();
485
+ }
480
486
  console.error(
481
487
  "❌ Tunnel received Unauthorized (1008). The JWT may be expired."
482
488
  );
483
- console.error(" Pausing reconnection until JWT changes...");
489
+ console.error(" Attempting to refresh the JWT with the CLI identity...");
484
490
  } else {
485
491
  console.log(
486
492
  "Tunnel connection will reconnect on next connection cycle..."
@@ -532,14 +538,16 @@ export async function worker(options?: {
532
538
  // If we got an Unauthorized (1008) close, record the JWT that failed
533
539
  // so we don't keep hammering the server with the same expired token
534
540
  if (code === 1008) {
535
- unauthorizedJwt = lastJwt;
541
+ unauthorizedJwt = jwt;
542
+ if (tunnelConnection?.readyState !== WebSocket.CLOSED) {
543
+ tunnelConnection?.terminate();
544
+ }
536
545
  console.error(
537
546
  "❌ Worker received Unauthorized (1008). The JWT may be expired."
538
547
  );
539
548
  console.error(
540
- " Run 'knowhow login' to refresh your token, then restart the worker."
549
+ " Attempting to refresh the JWT with the CLI identity..."
541
550
  );
542
- console.error(" Pausing reconnection until JWT changes...");
543
551
  } else {
544
552
  console.log("Attempting to reconnect...");
545
553
  }
@@ -566,17 +574,59 @@ export async function worker(options?: {
566
574
 
567
575
  while (true) {
568
576
  if (!connected) {
569
- // If we got an Unauthorized error, check if the JWT has changed before retrying
577
+ // A registered CLI identity can renew an expired JWT without requiring an
578
+ // interactive login. Attempt this once per rejected token; if it fails,
579
+ // continue watching the JWT file so `knowhow login` in another shell works.
570
580
  if (unauthorizedJwt !== null) {
571
- const currentJwt = await loadJwt().catch(() => null);
581
+ let currentJwt = await loadJwt().catch(() => null);
572
582
  if (currentJwt === unauthorizedJwt) {
573
- // JWT hasn't changed - don't reconnect, just wait
574
- await wait(5000);
575
- continue;
583
+ let attemptedRefreshNow = false;
584
+ if (refreshAttemptedJwt !== currentJwt) {
585
+ refreshAttemptedJwt = currentJwt;
586
+ attemptedRefreshNow = true;
587
+ const freshConfig = await getConfig();
588
+ const identityPath = freshConfig.cliIdentityPath;
589
+ if (freshConfig.orgId && hasKeyPair(identityPath)) {
590
+ try {
591
+ const refreshed = await authenticateWithKey(
592
+ freshConfig.orgId,
593
+ API_URL,
594
+ identityPath
595
+ );
596
+ if (refreshed) {
597
+ currentJwt = await loadJwt();
598
+ console.log("✅ JWT refreshed automatically; reconnecting...");
599
+ } else {
600
+ console.error(
601
+ "❌ CLI identity was rejected and could not refresh the JWT."
602
+ );
603
+ }
604
+ } catch (error) {
605
+ const message =
606
+ error instanceof Error ? error.message : String(error);
607
+ console.error(`❌ Automatic JWT refresh failed: ${message}`);
608
+ }
609
+ } else {
610
+ console.error(
611
+ "❌ No registered CLI identity is available for automatic JWT refresh."
612
+ );
613
+ }
614
+ }
615
+
616
+ if (currentJwt === unauthorizedJwt) {
617
+ if (attemptedRefreshNow) {
618
+ console.error(
619
+ " Run 'knowhow login' in another shell; reconnection will resume when the JWT changes."
620
+ );
621
+ }
622
+ await wait(5000);
623
+ continue;
624
+ }
576
625
  }
577
626
  // JWT changed - clear the unauthorized state and reconnect
578
627
  console.log("🔄 JWT has changed, attempting to reconnect...");
579
628
  unauthorizedJwt = null;
629
+ refreshAttemptedJwt = null;
580
630
  }
581
631
 
582
632
  console.log("Attempting to connect...");