@tyvm/knowhow 0.0.154 → 0.0.156
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.
- package/package.json +1 -1
- package/src/agents/base/base.ts +44 -26
- package/src/agents/base/prompt.ts +3 -2
- package/src/agents/tools/index.ts +1 -0
- package/src/agents/tools/list.ts +2 -0
- package/src/agents/tools/loadImageAsBase64.ts +83 -0
- package/src/auth/keyAuth.ts +40 -8
- package/src/chat/modules/AgentModule.ts +98 -42
- package/src/clients/index.ts +7 -4
- package/src/clients/openai.ts +37 -4
- package/src/commands/services.ts +23 -18
- package/src/login.ts +22 -6
- package/src/processors/Base64ImageDetector.ts +12 -11
- package/src/services/KnowhowClient.ts +6 -5
- package/src/services/watchers/FsSyncer.ts +40 -2
- package/src/services/watchers/RemoteSyncer.ts +40 -2
- package/src/tunnel.ts +29 -13
- package/src/worker.ts +62 -12
- package/tests/clients/reasoningEffort.test.ts +57 -0
- package/tests/processors/Base64ImageDetector.test.ts +32 -0
- package/tests/unit/agents/tools/loadImageAsBase64.test.ts +44 -0
- package/tests/unit/auth/keyAuth.test.ts +52 -2
- package/tests/unit/login.test.ts +39 -1
- package/tests/unit/services/KnowhowClient.test.ts +84 -0
- package/tests/unit/services/Mcp.test.ts +56 -0
- package/tests/unit/services/SyncedAgentWatcher.test.ts +89 -0
- package/tests/unit/tunnel.test.ts +51 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.d.ts +1 -0
- package/ts_build/src/agents/base/base.js +27 -22
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/base/prompt.d.ts +1 -1
- package/ts_build/src/agents/base/prompt.js +3 -2
- package/ts_build/src/agents/base/prompt.js.map +1 -1
- package/ts_build/src/agents/tools/index.d.ts +1 -0
- package/ts_build/src/agents/tools/index.js +1 -0
- package/ts_build/src/agents/tools/index.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +2 -0
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/loadImageAsBase64.d.ts +3 -0
- package/ts_build/src/agents/tools/loadImageAsBase64.js +79 -0
- package/ts_build/src/agents/tools/loadImageAsBase64.js.map +1 -0
- package/ts_build/src/auth/keyAuth.d.ts +1 -0
- package/ts_build/src/auth/keyAuth.js +24 -7
- package/ts_build/src/auth/keyAuth.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.d.ts +3 -0
- package/ts_build/src/chat/modules/AgentModule.js +74 -34
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/clients/index.js +7 -4
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.js +29 -4
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/commands/services.js +15 -8
- package/ts_build/src/commands/services.js.map +1 -1
- package/ts_build/src/login.js +19 -5
- package/ts_build/src/login.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.js +7 -3
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/services/KnowhowClient.js +5 -3
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/watchers/FsSyncer.d.ts +4 -0
- package/ts_build/src/services/watchers/FsSyncer.js +36 -2
- package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
- package/ts_build/src/services/watchers/RemoteSyncer.d.ts +4 -0
- package/ts_build/src/services/watchers/RemoteSyncer.js +36 -2
- package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
- package/ts_build/src/tunnel.js +19 -8
- package/ts_build/src/tunnel.js.map +1 -1
- package/ts_build/src/worker.js +47 -10
- package/ts_build/src/worker.js.map +1 -1
- package/ts_build/tests/clients/reasoningEffort.test.js +47 -0
- package/ts_build/tests/clients/reasoningEffort.test.js.map +1 -1
- package/ts_build/tests/processors/Base64ImageDetector.test.js +28 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.d.ts +1 -0
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js +67 -0
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js.map +1 -0
- package/ts_build/tests/unit/auth/keyAuth.test.js +43 -2
- package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -1
- package/ts_build/tests/unit/login.test.js +29 -1
- package/ts_build/tests/unit/login.test.js.map +1 -1
- package/ts_build/tests/unit/services/KnowhowClient.test.d.ts +1 -0
- package/ts_build/tests/unit/services/KnowhowClient.test.js +69 -0
- package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -0
- package/ts_build/tests/unit/services/Mcp.test.d.ts +1 -0
- package/ts_build/tests/unit/services/Mcp.test.js +45 -0
- package/ts_build/tests/unit/services/Mcp.test.js.map +1 -0
- package/ts_build/tests/unit/services/SyncedAgentWatcher.test.d.ts +1 -0
- package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js +78 -0
- package/ts_build/tests/unit/services/SyncedAgentWatcher.test.js.map +1 -0
- package/ts_build/tests/unit/tunnel.test.d.ts +1 -0
- package/ts_build/tests/unit/tunnel.test.js +47 -0
- package/ts_build/tests/unit/tunnel.test.js.map +1 -0
package/src/commands/services.ts
CHANGED
|
@@ -69,32 +69,37 @@ export async function setupServices() {
|
|
|
69
69
|
|
|
70
70
|
Agents.setAgentContext(agentContext);
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await Mcp.connectToConfigured(Tools);
|
|
75
|
-
} catch (mcpError) {
|
|
76
|
-
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
77
|
-
console.warn(
|
|
78
|
-
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
// Attempt silent JWT refresh via SSH identity before registering models.
|
|
82
|
-
// This handles the case where the JWT has expired but the user has a valid
|
|
83
|
-
// SSH identity registered — they shouldn't need to run `knowhow login` again
|
|
84
|
-
// unless they want to switch organizations.
|
|
72
|
+
// Refresh authentication before MCP transports, model clients, or remote
|
|
73
|
+
// sync modules can read the project JWT file.
|
|
85
74
|
try {
|
|
86
75
|
const startupConfig = getConfigSync();
|
|
87
76
|
if (startupConfig.orgId && !process.env.KNOWHOW_JWT) {
|
|
88
|
-
await authenticateWithKey(
|
|
77
|
+
const refreshed = await authenticateWithKey(
|
|
89
78
|
startupConfig.orgId,
|
|
90
79
|
process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai",
|
|
91
80
|
startupConfig.cliIdentityPath
|
|
92
81
|
);
|
|
82
|
+
if (!refreshed) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`⚠ Could not renew the Knowhow session: no available CLI identity is registered ` +
|
|
85
|
+
`for configured organization ${startupConfig.orgId}. Run \`knowhow login\` to select ` +
|
|
86
|
+
`an organization and register the global identity.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
93
89
|
}
|
|
94
|
-
} catch (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
console.warn(`⚠ Could not renew the Knowhow session before startup: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log("🔌 Connecting to MCP...");
|
|
96
|
+
try {
|
|
97
|
+
await Mcp.connectToConfigured(Tools);
|
|
98
|
+
} catch (mcpError) {
|
|
99
|
+
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
100
|
+
console.warn(
|
|
101
|
+
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
102
|
+
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
console.log("Connecting to clients...");
|
package/src/login.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import http from "./utils/http";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import { ask } from "./utils";
|
|
5
6
|
import { getConfig, updateConfig } from "./config";
|
|
6
7
|
import { KNOWHOW_API_URL } from "./services/KnowhowClient";
|
|
7
8
|
import { BrowserLoginService } from "./auth/browserLogin";
|
|
8
9
|
import { authenticateWithKey, hasKeyPair, registerPublicKey } from "./auth/keyAuth";
|
|
9
|
-
import { getOrCreatePublicKey } from "./auth/keyManager";
|
|
10
|
+
import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManager";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Log in to Knowhow.
|
|
@@ -40,18 +41,19 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
40
41
|
fs.chmodSync(jwtFile, 0o600);
|
|
41
42
|
console.log("JWT updated successfully.");
|
|
42
43
|
} else {
|
|
44
|
+
const selectedIdentityPath = identityPath ?? getDefaultPrivateKeyPath();
|
|
43
45
|
const config = await getConfig();
|
|
44
46
|
const orgId = config.orgId;
|
|
45
47
|
|
|
46
48
|
// A local key is not proof of registration. Let the selected environment
|
|
47
49
|
// check it, but only when an organization is available to bind the attempt.
|
|
48
|
-
if (orgId && hasKeyPair(
|
|
50
|
+
if (orgId && hasKeyPair(selectedIdentityPath)) {
|
|
49
51
|
console.log("Found CLI identity — authenticating with public key...");
|
|
50
52
|
try {
|
|
51
|
-
const success = await authenticateWithKey(orgId, KNOWHOW_API_URL,
|
|
53
|
+
const success = await authenticateWithKey(orgId, KNOWHOW_API_URL, selectedIdentityPath);
|
|
52
54
|
if (success) {
|
|
53
55
|
console.log("✅ Successfully authenticated via public key!");
|
|
54
|
-
return await postLoginConfigUpdate(
|
|
56
|
+
return await postLoginConfigUpdate(selectedIdentityPath);
|
|
55
57
|
}
|
|
56
58
|
console.warn("Key authentication returned no JWT, falling back to browser login...");
|
|
57
59
|
} catch (error: unknown) {
|
|
@@ -61,7 +63,8 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// Fall back when no org/key is configured or when key auth is unavailable.
|
|
64
|
-
await doBrowserLogin(
|
|
66
|
+
await doBrowserLogin(selectedIdentityPath);
|
|
67
|
+
identityPath = selectedIdentityPath;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
await postLoginConfigUpdate(identityPath);
|
|
@@ -101,6 +104,15 @@ async function doBrowserLogin(identityPath?: string): Promise<void> {
|
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
function isTemporaryIdentityPath(identityPath: string): boolean {
|
|
108
|
+
const resolvedIdentity = path.resolve(identityPath);
|
|
109
|
+
const temporaryRoots = new Set([os.tmpdir(), "/tmp", "/var/tmp"]);
|
|
110
|
+
return Array.from(temporaryRoots).some((temporaryRoot) => {
|
|
111
|
+
const relativePath = path.relative(path.resolve(temporaryRoot), resolvedIdentity);
|
|
112
|
+
return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
/** After any successful login, update the local config with the current user/org. */
|
|
105
117
|
async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
106
118
|
try {
|
|
@@ -129,8 +141,12 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
|
129
141
|
config.orgId = orgId;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
if (identityPath) {
|
|
144
|
+
if (identityPath && !isTemporaryIdentityPath(identityPath)) {
|
|
133
145
|
config.cliIdentityPath = path.resolve(identityPath);
|
|
146
|
+
} else if (
|
|
147
|
+
config.cliIdentityPath && isTemporaryIdentityPath(config.cliIdentityPath)
|
|
148
|
+
) {
|
|
149
|
+
delete config.cliIdentityPath;
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
await updateConfig(config);
|
|
@@ -316,25 +316,26 @@ export class Base64ImageProcessor {
|
|
|
316
316
|
|
|
317
317
|
createProcessor(): MessageProcessorFunction {
|
|
318
318
|
return (originalMessages: Message[], modifiedMessages: Message[]) => {
|
|
319
|
-
// Only process the last (newest) message for hint injection.
|
|
320
|
-
// Processing all historical messages on every call would re-append hints
|
|
321
|
-
// to already-processed messages, causing the hint to multiply and busting
|
|
322
|
-
// Anthropic's prefix cache (which requires byte-identical prior messages).
|
|
323
319
|
const lastIndex = modifiedMessages.length - 1;
|
|
324
320
|
if (lastIndex < 0) return;
|
|
325
321
|
|
|
326
322
|
const lastMessage = modifiedMessages[lastIndex];
|
|
327
323
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
324
|
+
// Every request starts with a fresh clone of the persisted thread. Normalize
|
|
325
|
+
// all images: parallel calls can place another tool result after an image,
|
|
326
|
+
// while historical image results remain persisted as JSON strings.
|
|
327
|
+
for (const message of modifiedMessages) {
|
|
328
|
+
if (message.role === "user") {
|
|
329
|
+
this.processMessageContent(message);
|
|
330
|
+
}
|
|
331
|
+
if (message.role === "tool") {
|
|
332
|
+
this.processToolMessageContent(message);
|
|
333
|
+
}
|
|
331
334
|
}
|
|
332
335
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// and converted to proper image content before the agent sees them
|
|
336
|
+
// Add path hints only to the newest message. Re-appending hints to history
|
|
337
|
+
// would multiply them and invalidate the provider's prompt cache.
|
|
336
338
|
if (lastMessage.role === "tool") {
|
|
337
|
-
this.processToolMessageContent(lastMessage);
|
|
338
339
|
// After processing tool content (which may not convert to image if it's plain text
|
|
339
340
|
// describing a screenshot path), add hints for any image file paths found in the text.
|
|
340
341
|
this.applyImagePathHintsToMessage(lastMessage);
|
|
@@ -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 {
|
|
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
|
|
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,13 @@ 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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Resolve the tunnel domain and protocol from the environment and API URL.
|
|
17
|
+
* TUNNEL_WORKER_DOMAIN takes precedence. Otherwise localhost API URLs use the
|
|
18
|
+
* local tunnel proxy, dev API URLs use the dev tunnel domain, and all other API
|
|
19
|
+
* URLs use the production tunnel domain.
|
|
17
20
|
*/
|
|
18
21
|
export function extractTunnelDomain(apiUrl: string): {
|
|
19
22
|
domain: string;
|
|
@@ -22,18 +25,31 @@ export function extractTunnelDomain(apiUrl: string): {
|
|
|
22
25
|
try {
|
|
23
26
|
const url = new URL(apiUrl);
|
|
24
27
|
const useHttps = url.protocol === "https:";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const configuredDomain = process.env.TUNNEL_WORKER_DOMAIN?.trim();
|
|
29
|
+
|
|
30
|
+
const isLocalApi =
|
|
31
|
+
url.hostname === "localhost" ||
|
|
32
|
+
url.hostname.endsWith(".localhost") ||
|
|
33
|
+
url.hostname === "127.0.0.1" ||
|
|
34
|
+
url.hostname === "[::1]";
|
|
35
|
+
const isDevApi = url.hostname.split(".").includes("dev");
|
|
36
|
+
const domain =
|
|
37
|
+
configuredDomain ||
|
|
38
|
+
(isLocalApi
|
|
39
|
+
? `worker.localhost:${url.port || (useHttps ? "443" : "80")}`
|
|
40
|
+
: isDevApi
|
|
41
|
+
? `worker.dev.tyvm-apps.com`
|
|
42
|
+
: PROD_TUNNEL_WORKER_DOMAIN);
|
|
43
|
+
|
|
44
|
+
return { domain, useHttps };
|
|
34
45
|
} catch (err) {
|
|
35
46
|
console.error("Failed to parse API_URL for tunnel domain:", err);
|
|
36
|
-
return {
|
|
47
|
+
return {
|
|
48
|
+
domain:
|
|
49
|
+
process.env.TUNNEL_WORKER_DOMAIN?.trim() ||
|
|
50
|
+
PROD_TUNNEL_WORKER_DOMAIN,
|
|
51
|
+
useHttps: false,
|
|
52
|
+
};
|
|
37
53
|
}
|
|
38
54
|
}
|
|
39
55
|
|
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 =
|
|
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("
|
|
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 =
|
|
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
|
-
"
|
|
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
|
-
//
|
|
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
|
-
|
|
581
|
+
let currentJwt = await loadJwt().catch(() => null);
|
|
572
582
|
if (currentJwt === unauthorizedJwt) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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...");
|
|
@@ -45,6 +45,63 @@ describe("reasoning effort values", () => {
|
|
|
45
45
|
expect(client.resolveReasoningEffortForModel(options("unknown", "max"))).toBe("xhigh");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("explicitly disables reasoning in Responses API requests", async () => {
|
|
49
|
+
const client = new GenericOpenAiClient("fake-key");
|
|
50
|
+
const create = jest.fn().mockResolvedValue({
|
|
51
|
+
id: "resp_disabled",
|
|
52
|
+
status: "completed",
|
|
53
|
+
output: [{ type: "message", content: [{ type: "output_text", text: "summary" }] }],
|
|
54
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
55
|
+
});
|
|
56
|
+
(client.client as any).responses.create = create;
|
|
57
|
+
|
|
58
|
+
await client.createChatResponse({
|
|
59
|
+
model: Models.openai.GPT_56_Sol,
|
|
60
|
+
messages: [{ role: "user", content: "summarize" }],
|
|
61
|
+
reasoning_effort: "none",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(create).toHaveBeenCalledWith(
|
|
65
|
+
expect.objectContaining({ reasoning: { effort: "none" } })
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("diagnoses empty Responses output but not valid tool-only output", async () => {
|
|
70
|
+
const client = new GenericOpenAiClient("fake-key");
|
|
71
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
72
|
+
const create = jest.fn()
|
|
73
|
+
.mockResolvedValueOnce({
|
|
74
|
+
id: "resp_empty",
|
|
75
|
+
status: "incomplete",
|
|
76
|
+
incomplete_details: { reason: "max_output_tokens" },
|
|
77
|
+
output: [{ type: "reasoning", summary: [] }],
|
|
78
|
+
usage: { input_tokens: 2, output_tokens: 3 },
|
|
79
|
+
})
|
|
80
|
+
.mockResolvedValueOnce({
|
|
81
|
+
id: "resp_tool",
|
|
82
|
+
status: "completed",
|
|
83
|
+
output: [{
|
|
84
|
+
type: "function_call", call_id: "call_1", name: "readFile", arguments: "{}",
|
|
85
|
+
}],
|
|
86
|
+
usage: { input_tokens: 2, output_tokens: 3 },
|
|
87
|
+
});
|
|
88
|
+
(client.client as any).responses.create = create;
|
|
89
|
+
const request = {
|
|
90
|
+
model: Models.openai.GPT_56_Sol,
|
|
91
|
+
messages: [{ role: "user" as const, content: "continue" }],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
await client.createChatResponse(request);
|
|
95
|
+
expect(warn).toHaveBeenCalledWith(
|
|
96
|
+
"[Responses API] Response contained no text or tool calls",
|
|
97
|
+
expect.objectContaining({ responseId: "resp_empty", status: "incomplete" })
|
|
98
|
+
);
|
|
99
|
+
warn.mockClear();
|
|
100
|
+
await client.createChatResponse(request);
|
|
101
|
+
expect(warn).not.toHaveBeenCalled();
|
|
102
|
+
warn.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
|
|
48
105
|
it("maps all values to valid Gemini thinking levels and budgets", () => {
|
|
49
106
|
const client = new GenericGeminiClient("fake-key");
|
|
50
107
|
const levelModel = GoogleThinkingLevelModels.find(
|
|
@@ -220,6 +220,38 @@ describe("Base64ImageDetector", () => {
|
|
|
220
220
|
expect(content[0].image_url.url).toBe(imageDataUrl);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it("should process an image result that is not the newest parallel tool result", () => {
|
|
224
|
+
const imageDataUrl =
|
|
225
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
226
|
+
const modifiedMessages: Message[] = [
|
|
227
|
+
{
|
|
228
|
+
role: "tool",
|
|
229
|
+
content: JSON.stringify({
|
|
230
|
+
type: "image_url",
|
|
231
|
+
image_url: { url: imageDataUrl, detail: "high" },
|
|
232
|
+
}),
|
|
233
|
+
tool_call_id: "call_image",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
role: "tool",
|
|
237
|
+
content: "A later readFile result",
|
|
238
|
+
tool_call_id: "call_file",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
role: "user",
|
|
242
|
+
content: "A pending workflow update",
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
detector.createProcessor()([], modifiedMessages);
|
|
247
|
+
|
|
248
|
+
expect(Array.isArray(modifiedMessages[0].content)).toBe(true);
|
|
249
|
+
expect((modifiedMessages[0].content as any[])[0]).toEqual({
|
|
250
|
+
type: "image_url",
|
|
251
|
+
image_url: { url: imageDataUrl, detail: "high" },
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
223
255
|
it("should not promote a compressed image URL placeholder to image content", () => {
|
|
224
256
|
const toolResponseJson = JSON.stringify({
|
|
225
257
|
type: "image_url",
|