@tyvm/knowhow 0.0.147 → 0.0.149

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 (156) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +45 -32
  3. package/src/agents/tools/execCommand.ts +102 -320
  4. package/src/agents/tools/list.ts +2 -2
  5. package/src/agents/tools/startAgentTask.ts +55 -27
  6. package/src/ai.ts +4 -1
  7. package/src/auth/keyAuth.ts +10 -4
  8. package/src/auth/registerIdentity.ts +67 -0
  9. package/src/chat/CliChatService.ts +23 -2
  10. package/src/chat/modules/AgentModule.ts +39 -7
  11. package/src/chat/modules/InternalChatModule.ts +5 -0
  12. package/src/chat/modules/ToolCommandModule.ts +102 -0
  13. package/src/chat/modules/index.ts +1 -0
  14. package/src/chat/types.ts +1 -1
  15. package/src/cli.ts +9 -2
  16. package/src/clients/http.ts +67 -7
  17. package/src/clients/index.ts +36 -1
  18. package/src/commands/agent.ts +1 -0
  19. package/src/commands/agents.ts +57 -9
  20. package/src/commands/misc.ts +17 -2
  21. package/src/commands/processes.ts +203 -0
  22. package/src/commands/services.ts +23 -1
  23. package/src/index.ts +1 -0
  24. package/src/processes/ProcessManager.ts +95 -0
  25. package/src/processes/managedProcessShim.ts +127 -0
  26. package/src/processors/Base64ImageDetector.ts +28 -4
  27. package/src/processors/JsonCompressor.ts +14 -0
  28. package/src/processors/TokenCompressor.ts +17 -14
  29. package/src/services/AgentService.ts +2 -2
  30. package/src/services/AgentSyncFs.ts +12 -1
  31. package/src/services/ExtensionsService.ts +25 -0
  32. package/src/services/LazyToolsService.ts +15 -20
  33. package/src/services/Tools.ts +10 -0
  34. package/src/services/index.ts +4 -0
  35. package/src/services/modules/computerUse.ts +3 -1
  36. package/src/services/modules/index.ts +6 -0
  37. package/src/services/modules/types.ts +4 -0
  38. package/tests/clients/AIClient.test.ts +98 -15
  39. package/tests/processes/managedProcessShim.test.ts +59 -0
  40. package/tests/processors/Base64ImageDetector.test.ts +21 -0
  41. package/tests/processors/TokenCompressor.test.ts +18 -0
  42. package/tests/unit/auth/registerIdentity.test.ts +93 -0
  43. package/tests/unit/chat/ToolCommandModule.test.ts +71 -0
  44. package/tests/unit/commands/agents.test.ts +44 -1
  45. package/tests/unit/services/LazyToolsService.test.ts +24 -0
  46. package/ts_build/package.json +1 -1
  47. package/ts_build/src/agents/base/base.d.ts +1 -1
  48. package/ts_build/src/agents/base/base.js +30 -21
  49. package/ts_build/src/agents/base/base.js.map +1 -1
  50. package/ts_build/src/agents/tools/execCommand.d.ts +4 -1
  51. package/ts_build/src/agents/tools/execCommand.js +100 -223
  52. package/ts_build/src/agents/tools/execCommand.js.map +1 -1
  53. package/ts_build/src/agents/tools/list.js +2 -2
  54. package/ts_build/src/agents/tools/list.js.map +1 -1
  55. package/ts_build/src/agents/tools/startAgentTask.d.ts +14 -1
  56. package/ts_build/src/agents/tools/startAgentTask.js +37 -21
  57. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  58. package/ts_build/src/ai.js +2 -1
  59. package/ts_build/src/ai.js.map +1 -1
  60. package/ts_build/src/auth/keyAuth.js +10 -3
  61. package/ts_build/src/auth/keyAuth.js.map +1 -1
  62. package/ts_build/src/auth/registerIdentity.d.ts +7 -0
  63. package/ts_build/src/auth/registerIdentity.js +62 -0
  64. package/ts_build/src/auth/registerIdentity.js.map +1 -0
  65. package/ts_build/src/chat/CliChatService.d.ts +6 -0
  66. package/ts_build/src/chat/CliChatService.js +17 -2
  67. package/ts_build/src/chat/CliChatService.js.map +1 -1
  68. package/ts_build/src/chat/modules/AgentModule.d.ts +2 -0
  69. package/ts_build/src/chat/modules/AgentModule.js +27 -7
  70. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  71. package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
  72. package/ts_build/src/chat/modules/InternalChatModule.js +5 -0
  73. package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
  74. package/ts_build/src/chat/modules/ToolCommandModule.d.ts +10 -0
  75. package/ts_build/src/chat/modules/ToolCommandModule.js +86 -0
  76. package/ts_build/src/chat/modules/ToolCommandModule.js.map +1 -0
  77. package/ts_build/src/chat/modules/index.d.ts +1 -0
  78. package/ts_build/src/chat/modules/index.js +3 -1
  79. package/ts_build/src/chat/modules/index.js.map +1 -1
  80. package/ts_build/src/chat/types.d.ts +1 -1
  81. package/ts_build/src/cli.js +6 -2
  82. package/ts_build/src/cli.js.map +1 -1
  83. package/ts_build/src/clients/http.d.ts +4 -0
  84. package/ts_build/src/clients/http.js +68 -6
  85. package/ts_build/src/clients/http.js.map +1 -1
  86. package/ts_build/src/clients/index.js +23 -1
  87. package/ts_build/src/clients/index.js.map +1 -1
  88. package/ts_build/src/commands/agent.js +1 -0
  89. package/ts_build/src/commands/agent.js.map +1 -1
  90. package/ts_build/src/commands/agents.d.ts +1 -0
  91. package/ts_build/src/commands/agents.js +43 -8
  92. package/ts_build/src/commands/agents.js.map +1 -1
  93. package/ts_build/src/commands/misc.js +12 -1
  94. package/ts_build/src/commands/misc.js.map +1 -1
  95. package/ts_build/src/commands/processes.d.ts +2 -0
  96. package/ts_build/src/commands/processes.js +282 -0
  97. package/ts_build/src/commands/processes.js.map +1 -0
  98. package/ts_build/src/commands/services.js +11 -1
  99. package/ts_build/src/commands/services.js.map +1 -1
  100. package/ts_build/src/index.d.ts +1 -0
  101. package/ts_build/src/index.js +1 -0
  102. package/ts_build/src/index.js.map +1 -1
  103. package/ts_build/src/processes/ProcessManager.d.ts +51 -0
  104. package/ts_build/src/processes/ProcessManager.js +160 -0
  105. package/ts_build/src/processes/ProcessManager.js.map +1 -0
  106. package/ts_build/src/processes/managedProcessShim.d.ts +1 -0
  107. package/ts_build/src/processes/managedProcessShim.js +167 -0
  108. package/ts_build/src/processes/managedProcessShim.js.map +1 -0
  109. package/ts_build/src/processors/Base64ImageDetector.d.ts +1 -0
  110. package/ts_build/src/processors/Base64ImageDetector.js +19 -2
  111. package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
  112. package/ts_build/src/processors/JsonCompressor.js +7 -0
  113. package/ts_build/src/processors/JsonCompressor.js.map +1 -1
  114. package/ts_build/src/processors/TokenCompressor.d.ts +1 -1
  115. package/ts_build/src/processors/TokenCompressor.js +9 -8
  116. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  117. package/ts_build/src/services/AgentService.d.ts +1 -1
  118. package/ts_build/src/services/AgentService.js.map +1 -1
  119. package/ts_build/src/services/AgentSyncFs.js +6 -1
  120. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  121. package/ts_build/src/services/ExtensionsService.d.ts +10 -0
  122. package/ts_build/src/services/ExtensionsService.js +17 -0
  123. package/ts_build/src/services/ExtensionsService.js.map +1 -0
  124. package/ts_build/src/services/LazyToolsService.d.ts +1 -0
  125. package/ts_build/src/services/LazyToolsService.js +5 -7
  126. package/ts_build/src/services/LazyToolsService.js.map +1 -1
  127. package/ts_build/src/services/Tools.d.ts +1 -0
  128. package/ts_build/src/services/Tools.js +3 -0
  129. package/ts_build/src/services/Tools.js.map +1 -1
  130. package/ts_build/src/services/index.d.ts +3 -0
  131. package/ts_build/src/services/index.js +3 -0
  132. package/ts_build/src/services/index.js.map +1 -1
  133. package/ts_build/src/services/modules/computerUse.d.ts +3 -1
  134. package/ts_build/src/services/modules/index.d.ts +2 -0
  135. package/ts_build/src/services/modules/index.js +5 -0
  136. package/ts_build/src/services/modules/index.js.map +1 -1
  137. package/ts_build/src/services/modules/types.d.ts +3 -0
  138. package/ts_build/tests/clients/AIClient.test.js +73 -11
  139. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  140. package/ts_build/tests/processes/managedProcessShim.test.d.ts +1 -0
  141. package/ts_build/tests/processes/managedProcessShim.test.js +92 -0
  142. package/ts_build/tests/processes/managedProcessShim.test.js.map +1 -0
  143. package/ts_build/tests/processors/Base64ImageDetector.test.js +18 -0
  144. package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
  145. package/ts_build/tests/processors/TokenCompressor.test.js +15 -0
  146. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  147. package/ts_build/tests/unit/auth/registerIdentity.test.d.ts +1 -0
  148. package/ts_build/tests/unit/auth/registerIdentity.test.js +86 -0
  149. package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -0
  150. package/ts_build/tests/unit/chat/ToolCommandModule.test.d.ts +1 -0
  151. package/ts_build/tests/unit/chat/ToolCommandModule.test.js +61 -0
  152. package/ts_build/tests/unit/chat/ToolCommandModule.test.js.map +1 -0
  153. package/ts_build/tests/unit/commands/agents.test.js +28 -0
  154. package/ts_build/tests/unit/commands/agents.test.js.map +1 -1
  155. package/ts_build/tests/unit/services/LazyToolsService.test.js +20 -0
  156. package/ts_build/tests/unit/services/LazyToolsService.test.js.map +1 -1
@@ -0,0 +1,95 @@
1
+ import { spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { randomBytes } from "crypto";
5
+
6
+ export type ManagedProcessState = "starting" | "running" | "exited" | "failed" | "stopped";
7
+ export interface ManagedProcessStatus {
8
+ id: string; pid: number | null; shimPid: number | null; state: ManagedProcessState;
9
+ command: string; args: string[]; cwd: string; parentProcessId: string | null;
10
+ parentPid: number | null; background: boolean; startedAt: string; endedAt: string | null;
11
+ exitCode: number | null; signal: string | null; error?: string;
12
+ }
13
+ export interface SpawnManagedOptions {
14
+ id?: string; cwd?: string; env?: NodeJS.ProcessEnv; background?: boolean;
15
+ parentProcessId?: string; parentPid?: number; shell?: boolean; processesDir?: string;
16
+ }
17
+ export interface ManagedProcessInfo { id: string; directory: string; status: ManagedProcessStatus; }
18
+
19
+ export function getProcessesDir(cwd = process.cwd()): string {
20
+ return process.env.KNOWHOW_PROCESSES_DIR || path.join(cwd, ".knowhow", "processes");
21
+ }
22
+ export function processDirectory(id: string, processesDir = getProcessesDir()): string {
23
+ if (!/^[a-zA-Z0-9._-]+$/.test(id)) throw new Error(`Invalid process id: ${id}`);
24
+ return path.join(processesDir, id);
25
+ }
26
+ export function readManagedStatus(id: string, processesDir = getProcessesDir()): ManagedProcessStatus {
27
+ return JSON.parse(fs.readFileSync(path.join(processDirectory(id, processesDir), "status.json"), "utf8"));
28
+ }
29
+ export function listManagedProcesses(processesDir = getProcessesDir()): ManagedProcessInfo[] {
30
+ if (!fs.existsSync(processesDir)) return [];
31
+ return fs.readdirSync(processesDir, { withFileTypes: true })
32
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(processesDir, entry.name, "status.json")))
33
+ .flatMap((entry) => { try { return [{ id: entry.name, directory: path.join(processesDir, entry.name), status: readManagedStatus(entry.name, processesDir) }]; } catch { return []; } })
34
+ .sort((a, b) => b.status.startedAt.localeCompare(a.status.startedAt));
35
+ }
36
+ function makeId(command: string): string {
37
+ const name = path.basename(command).replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 28) || "process";
38
+ return `${name}-${Date.now()}-${randomBytes(3).toString("hex")}`;
39
+ }
40
+ function waitForFile(file: string, timeoutMs: number): Promise<void> {
41
+ const started = Date.now();
42
+ return new Promise((resolve, reject) => { const check = () => {
43
+ if (fs.existsSync(file)) return resolve();
44
+ if (Date.now() - started >= timeoutMs) return reject(new Error(`Managed process did not initialize: ${file}`));
45
+ setTimeout(check, 20);
46
+ }; check(); });
47
+ }
48
+ export class ManagedProcess {
49
+ constructor(public readonly id: string, public readonly directory: string) {}
50
+ get status(): ManagedProcessStatus { return readManagedStatus(this.id, path.dirname(this.directory)); }
51
+ get stdoutPath(): string { return path.join(this.directory, "stdout"); }
52
+ get stderrPath(): string { return path.join(this.directory, "stderr"); }
53
+ get inputLogPath(): string { return path.join(this.directory, "input.log"); }
54
+ get stdinPath(): string { return path.join(this.directory, "stdin"); }
55
+ async write(input: string): Promise<void> {
56
+ if (process.platform === "win32") {
57
+ await fs.promises.appendFile(this.stdinPath, input);
58
+ return;
59
+ }
60
+ // A non-blocking open fails promptly with ENXIO if the shim is gone instead
61
+ // of leaving callers hung forever waiting for a FIFO reader.
62
+ const handle = await fs.promises.open(this.stdinPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
63
+ try { await handle.writeFile(input); } finally { await handle.close(); }
64
+ }
65
+ signal(signal: NodeJS.Signals = "SIGTERM"): void {
66
+ const status = this.status;
67
+ if (!status.pid) throw new Error(`Process ${this.id} has no active pid`);
68
+ try { process.kill(-status.pid, signal); } catch { process.kill(status.pid, signal); }
69
+ }
70
+ async wait(timeoutMs?: number): Promise<ManagedProcessStatus> {
71
+ const started = Date.now();
72
+ while (true) {
73
+ const status = this.status;
74
+ if (!["starting", "running"].includes(status.state)) return status;
75
+ if (timeoutMs !== undefined && Date.now() - started >= timeoutMs) throw new Error(`Timed out waiting for managed process ${this.id} after ${timeoutMs}ms`);
76
+ await new Promise((resolve) => setTimeout(resolve, 100));
77
+ }
78
+ }
79
+ }
80
+ export async function spawnManaged(command: string, args: string[] = [], options: SpawnManagedOptions = {}): Promise<ManagedProcess> {
81
+ const cwd = path.resolve(options.cwd || process.cwd());
82
+ const processesDir = options.processesDir || getProcessesDir(cwd);
83
+ const id = options.id || makeId(command);
84
+ const directory = processDirectory(id, processesDir);
85
+ fs.mkdirSync(processesDir, { recursive: true });
86
+ fs.mkdirSync(directory);
87
+ const configPath = path.join(directory, "launch.json");
88
+ fs.writeFileSync(configPath, JSON.stringify({ id, command, args, cwd, env: options.env || {}, background: options.background === true, parentProcessId: options.parentProcessId || null, parentPid: options.parentPid ?? process.pid, shell: options.shell === true, processesDir }, null, 2));
89
+ const cliPath = path.resolve(__dirname, "..", "cli.js");
90
+ const shim = spawn(process.execPath, [cliPath, "processes", "run", "--config", configPath], { cwd, detached: true, stdio: "ignore", env: process.env });
91
+ shim.unref();
92
+ try { await waitForFile(path.join(directory, "status.json"), 5000); }
93
+ catch (error) { try { fs.rmSync(directory, { recursive: true, force: true }); } catch {} throw error; }
94
+ return new ManagedProcess(id, directory);
95
+ }
@@ -0,0 +1,127 @@
1
+ import { spawn, spawnSync, ChildProcess } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { ManagedProcessStatus } from "./ProcessManager";
5
+
6
+ interface LaunchConfig {
7
+ id: string; command: string; args: string[]; cwd: string; env: NodeJS.ProcessEnv;
8
+ background: boolean; parentProcessId: string | null; parentPid: number | null;
9
+ shell: boolean; processesDir: string;
10
+ }
11
+
12
+ function atomicJson(file: string, value: unknown): void {
13
+ const tmp = `${file}.${process.pid}.tmp`;
14
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
15
+ fs.renameSync(tmp, file);
16
+ }
17
+ function alive(pid: number): boolean {
18
+ try { process.kill(pid, 0); return true; } catch (error: any) { return error?.code === "EPERM"; }
19
+ }
20
+ function terminateGroup(child: ChildProcess, signal: NodeJS.Signals): void {
21
+ if (!child.pid) return;
22
+ try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch {} }
23
+ }
24
+
25
+ export async function runManagedProcessShim(configPath: string): Promise<void> {
26
+ const config: LaunchConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
27
+ const dir = path.dirname(configPath);
28
+ const startedAt = new Date().toISOString();
29
+ const statusPath = path.join(dir, "status.json");
30
+ const stdoutPath = path.join(dir, "stdout");
31
+ const stderrPath = path.join(dir, "stderr");
32
+ const inputLogPath = path.join(dir, "input.log");
33
+ const fifoPath = path.join(dir, "stdin");
34
+ fs.closeSync(fs.openSync(stdoutPath, "a"));
35
+ fs.closeSync(fs.openSync(stderrPath, "a"));
36
+ fs.closeSync(fs.openSync(inputLogPath, "a"));
37
+
38
+ if (process.platform !== "win32") {
39
+ const result = spawnSync("mkfifo", [fifoPath]);
40
+ if (result.status !== 0 && !fs.existsSync(fifoPath)) throw new Error(`Unable to create stdin FIFO: ${result.stderr?.toString()}`);
41
+ } else {
42
+ // Windows has no POSIX FIFO. Keep the same path as an append-only inbox fallback.
43
+ fs.closeSync(fs.openSync(fifoPath, "a"));
44
+ }
45
+
46
+ let status: ManagedProcessStatus = {
47
+ id: config.id, pid: null, shimPid: process.pid, state: "starting", command: config.command,
48
+ args: config.args, cwd: config.cwd, parentProcessId: config.parentProcessId,
49
+ parentPid: config.parentPid, background: config.background, startedAt, endedAt: null,
50
+ exitCode: null, signal: null,
51
+ };
52
+ const writeStatus = () => atomicJson(statusPath, status);
53
+ atomicJson(path.join(dir, "metadata.json"), {
54
+ id: config.id, command: config.command, args: config.args, cwd: config.cwd,
55
+ parentProcessId: config.parentProcessId, parentPid: config.parentPid,
56
+ background: config.background, createdAt: startedAt, shimPid: process.pid,
57
+ });
58
+ fs.writeFileSync(path.join(dir, "pid"), "");
59
+ writeStatus();
60
+
61
+ const stdoutFd = fs.openSync(stdoutPath, "a");
62
+ const stderrFd = fs.openSync(stderrPath, "a");
63
+ const child = spawn(config.command, config.args, {
64
+ cwd: config.cwd, shell: config.shell, detached: process.platform !== "win32",
65
+ stdio: ["pipe", stdoutFd, stderrFd],
66
+ env: { ...process.env, ...config.env, KNOWHOW_MANAGED_PROCESS_ID: config.id, KNOWHOW_MANAGED_PROCESS_DIR: dir },
67
+ });
68
+ fs.closeSync(stdoutFd); fs.closeSync(stderrFd);
69
+ status = { ...status, pid: child.pid || null, state: "running" };
70
+ fs.writeFileSync(path.join(dir, "pid"), child.pid ? `${child.pid}\n` : "");
71
+ writeStatus();
72
+
73
+ let fifoStream: fs.ReadStream | undefined;
74
+ if (process.platform !== "win32") {
75
+ // O_RDWR prevents transient writers disconnecting from delivering EOF to the shim.
76
+ const fifoFd = fs.openSync(fifoPath, fs.constants.O_RDWR);
77
+ fifoStream = fs.createReadStream("", { fd: fifoFd, autoClose: true });
78
+ fifoStream.on("data", (chunk) => {
79
+ fs.appendFileSync(inputLogPath, chunk);
80
+ if (child.stdin?.writable) child.stdin.write(chunk);
81
+ });
82
+ }
83
+
84
+ let stoppingForParent = false;
85
+ const parentTimer = !config.background && config.parentPid
86
+ ? setInterval(() => {
87
+ if (!alive(config.parentPid!)) {
88
+ stoppingForParent = true;
89
+ terminateGroup(child, "SIGTERM");
90
+ setTimeout(() => { if (alive(child.pid || -1)) terminateGroup(child, "SIGKILL"); }, 3000).unref();
91
+ }
92
+ }, 500)
93
+ : undefined;
94
+ parentTimer?.unref();
95
+
96
+ const forward = (signal: NodeJS.Signals) => terminateGroup(child, signal);
97
+ process.once("SIGINT", () => forward("SIGINT"));
98
+ process.once("SIGTERM", () => forward("SIGTERM"));
99
+ process.once("SIGHUP", () => forward("SIGHUP"));
100
+
101
+ await new Promise<void>((resolve) => {
102
+ child.once("error", (error) => {
103
+ status = { ...status, state: "failed", error: error.message, endedAt: new Date().toISOString() };
104
+ writeStatus(); resolve();
105
+ });
106
+ child.once("exit", (code, signal) => {
107
+ status = { ...status, state: stoppingForParent || signal ? "stopped" : code === 0 ? "exited" : "failed", endedAt: new Date().toISOString(), exitCode: code, signal };
108
+ writeStatus(); resolve();
109
+ });
110
+ });
111
+ if (parentTimer) clearInterval(parentTimer);
112
+ if (fifoStream && !fifoStream.closed) {
113
+ // A FIFO read cannot always be cancelled by closing its descriptor (notably
114
+ // on macOS). Wake the pending read before asking ReadStream to close it;
115
+ // otherwise each completed command can leave its shim wedged in close(2).
116
+ const wakeFd = fs.openSync(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
117
+ try {
118
+ fs.writeSync(wakeFd, Buffer.from([0]));
119
+ } finally {
120
+ fs.closeSync(wakeFd);
121
+ }
122
+ await new Promise<void>((resolve) => {
123
+ fifoStream!.once("close", resolve);
124
+ fifoStream!.destroy();
125
+ });
126
+ }
127
+ }
@@ -251,6 +251,24 @@ export class Base64ImageProcessor {
251
251
  }
252
252
  }
253
253
 
254
+ private isUsableImageUrl(url: unknown): url is string {
255
+ if (typeof url !== "string") return false;
256
+
257
+ // Data URLs are only usable when they contain an image format this processor
258
+ // recognizes. In particular, reject TokenCompressor placeholders that happen
259
+ // to be located inside an otherwise valid-looking image_url content part.
260
+ if (url.startsWith("data:")) {
261
+ return this.isBase64Image(url).isImage;
262
+ }
263
+
264
+ try {
265
+ const parsed = new URL(url);
266
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+
254
272
  private processToolMessageContent(message: Message): void {
255
273
  // Tool messages have string content that might be a JSON string containing image data
256
274
  if (typeof message.content === "string" && message.content.trim()) {
@@ -267,13 +285,19 @@ export class Base64ImageProcessor {
267
285
  // model to see a raw base64 JSON string instead of an actual image.)
268
286
  const candidates = Array.isArray(parsed) ? parsed : [parsed];
269
287
  const isImagePart = (p: any) =>
270
- p && p.type === "image_url" && p.image_url?.url;
288
+ p &&
289
+ p.type === "image_url" &&
290
+ this.isUsableImageUrl(p.image_url?.url);
271
291
  const isTextPart = (p: any) =>
272
292
  p && p.type === "text" && typeof p.text === "string";
293
+ const hasInvalidImagePart = candidates.some(
294
+ (p: any) => p?.type === "image_url" && !isImagePart(p)
295
+ );
273
296
 
274
- // Only rewrite the content if it actually contains at least one image
275
- // part; otherwise leave the original tool text untouched.
276
- if (candidates.some(isImagePart)) {
297
+ // Only rewrite content containing valid provider-ready images. If an
298
+ // image URL was compressed or is malformed, preserve the JSON as text so
299
+ // the agent can inspect/expand it instead of failing the next API call.
300
+ if (!hasInvalidImagePart && candidates.some(isImagePart)) {
277
301
  message.content = candidates.filter(
278
302
  (p) => isImagePart(p) || isTextPart(p)
279
303
  );
@@ -398,6 +398,20 @@ export class JsonCompressor {
398
398
  }
399
399
 
400
400
 
401
+ // Image content parts must remain atomic. Compressing a data URL independently
402
+ // leaves a valid-looking image_url object whose URL is only a placeholder.
403
+ // The containing response can still be compressed, but expanding it must
404
+ // restore an image part with its usable URL intact.
405
+ if (
406
+ obj &&
407
+ typeof obj === "object" &&
408
+ !Array.isArray(obj) &&
409
+ obj.type === "image_url" &&
410
+ typeof obj.image_url?.url === "string"
411
+ ) {
412
+ return obj;
413
+ }
414
+
401
415
  // Handle objects - try low-signal detection first, then process properties (depth-first)
402
416
  if (obj && typeof obj === "object") {
403
417
  // Check if this exact object (by original content) is a duplicate
@@ -45,6 +45,22 @@ export class TokenCompressor implements JsonCompressorStorage {
45
45
  // JSON compression handler
46
46
  private jsonCompressor: JsonCompressor;
47
47
 
48
+
49
+ /**
50
+ * Tool names whose responses should never be compressed.
51
+ * These are compression/retrieval tools themselves - compressing their output
52
+ * would prevent the agent from seeing the uncompressed data it just retrieved.
53
+ */
54
+ static DO_NOT_COMPRESS: ReadonlySet<string> = new Set([
55
+ "expandTokens",
56
+ "grepToolResponse",
57
+ "jqToolResponse",
58
+ "tailToolResponse",
59
+ "listStoredToolResponses",
60
+ "loadImageAsBase64" // need a proper way to do this
61
+ ]);
62
+
63
+
48
64
  constructor(toolsService?: ToolsService) {
49
65
  this.jsonCompressor = new JsonCompressor(
50
66
  this,
@@ -290,19 +306,6 @@ export class TokenCompressor implements JsonCompressorStorage {
290
306
  }
291
307
  }
292
308
 
293
- /**
294
- * Tool names whose responses should never be compressed.
295
- * These are compression/retrieval tools themselves - compressing their output
296
- * would prevent the agent from seeing the uncompressed data it just retrieved.
297
- */
298
- static readonly COMPRESSION_TOOL_NAMES: ReadonlySet<string> = new Set([
299
- "expandTokens",
300
- "grepToolResponse",
301
- "jqToolResponse",
302
- "tailToolResponse",
303
- "listStoredToolResponses",
304
- ]);
305
-
306
309
  createProcessor(
307
310
  filterFn?: (msg: Message) => boolean
308
311
  ): MessageProcessorFunction {
@@ -344,7 +347,7 @@ export class TokenCompressor implements JsonCompressorStorage {
344
347
  if (
345
348
  message.role === "tool" &&
346
349
  message.name &&
347
- TokenCompressor.COMPRESSION_TOOL_NAMES.has(message.name)
350
+ TokenCompressor.DO_NOT_COMPRESS.has(message.name)
348
351
  ) {
349
352
  continue;
350
353
  }
@@ -76,14 +76,14 @@ export class AgentService {
76
76
  return this.agentContext ?? { Tools: this.tools, Events: this.events };
77
77
  }
78
78
 
79
- public getAgent(name: string): IAgent {
79
+ public getAgent<T extends IAgent = IAgent>(name: string): T {
80
80
  const agent = this.agents.get(name);
81
81
  if (!agent) {
82
82
  throw new Error(
83
83
  `Agent ${name} not found. Options are: ${this.listAgents()}`
84
84
  );
85
85
  }
86
- return agent;
86
+ return agent as T;
87
87
  }
88
88
 
89
89
  public listAgents(): string[] {
@@ -124,7 +124,18 @@ export class AgentSyncFs {
124
124
 
125
125
  try {
126
126
  const metadataPath = path.join(this.taskPath, "metadata.json");
127
- await fs.writeFile(metadataPath, JSON.stringify(data, null, 2), "utf8");
127
+ // Thread/status updates are partial snapshots. Merge them so stable
128
+ // orchestration identity written by createTask (taskId, pid and parent)
129
+ // is never discarded by a later update.
130
+ let existing: Record<string, unknown> = {};
131
+ try {
132
+ existing = JSON.parse(await fs.readFile(metadataPath, "utf8"));
133
+ } catch { /* first write or an interrupted legacy write */ }
134
+ await fs.writeFile(
135
+ metadataPath,
136
+ JSON.stringify({ ...existing, ...data }, null, 2),
137
+ "utf8"
138
+ );
128
139
  } catch (error) {
129
140
  console.error(`❌ Failed to write metadata:`, error);
130
141
  }
@@ -0,0 +1,25 @@
1
+ export interface ModuleExtension {
2
+ type: string;
3
+ [key: string]: unknown;
4
+ }
5
+
6
+ interface RegisteredExtension extends ModuleExtension {
7
+ owner: string;
8
+ }
9
+
10
+ /** Process-local registry for optional, consumer-defined module capabilities. */
11
+ export class ExtensionsService {
12
+ private extensions: RegisteredExtension[] = [];
13
+
14
+ register(owner: string, extension: ModuleExtension): void {
15
+ this.extensions.push({ ...extension, owner });
16
+ }
17
+
18
+ list<T extends ModuleExtension>(type: T["type"]): readonly T[] {
19
+ return this.extensions.filter((item) => item.type === type) as unknown as readonly T[];
20
+ }
21
+
22
+ removeOwner(owner: string): void {
23
+ this.extensions = this.extensions.filter((item) => item.owner !== owner);
24
+ }
25
+ }
@@ -82,6 +82,15 @@ export class LazyToolsService extends ToolsService {
82
82
  return this.tools; // Returns filtered subset
83
83
  }
84
84
 
85
+ // Definitions remain resolvable even when omitted from the model prompt.
86
+ getTool(name: string): Tool {
87
+ return this.allTools.find(
88
+ (tool) =>
89
+ name &&
90
+ (tool.function.name === name || tool.function.name.endsWith(name))
91
+ );
92
+ }
93
+
85
94
  /** Rebuild the lazy catalog while preserving enable/disable preferences. */
86
95
  resetTools(): void {
87
96
  this.allTools = [];
@@ -155,32 +164,18 @@ export class LazyToolsService extends ToolsService {
155
164
  };
156
165
  }
157
166
 
158
- // Override callTool to auto-enable tools that are in allTools but not yet enabled
167
+ // Prompt visibility is not authorization. Registered functions remain
168
+ // callable without changing which definitions are sent to the model.
159
169
  async callTool(
160
170
  toolCall: ToolCall,
161
171
  enabledTools?: string[],
162
172
  callContext?: ToolCallContext
163
173
  ) {
164
- const functionName = toolCall.function.name;
165
-
166
- // If the tool isn't currently visible but exists in allTools, auto-enable it.
167
- // This handles the case where the agent explicitly calls a tool without first
168
- // enabling it via listAvailableTools/enableTools.
169
- const isCurrentlyEnabled = this.tools.some(
170
- (t) => t.function.name === functionName
174
+ return super.callTool(
175
+ toolCall,
176
+ enabledTools ?? this.getFunctionNames(),
177
+ callContext
171
178
  );
172
- const existsInAll = this.allTools.some(
173
- (t) => t.function.name === functionName
174
- );
175
-
176
- if (!isCurrentlyEnabled && existsInAll) {
177
- // Auto-enable by adding the tool name as an exact pattern
178
- this.enableTools([functionName]);
179
- }
180
-
181
- // Always use the current enabled tool names after any auto-enable above,
182
- // so the base class check sees the freshly-enabled tool in the allowed list.
183
- return super.callTool(toolCall, this.getToolNames(), callContext);
184
179
  }
185
180
 
186
181
  // Internal: Update visible tools based on patterns
@@ -135,6 +135,16 @@ export class ToolsService {
135
135
  return this.tools.map((tool) => tool.function.name);
136
136
  }
137
137
 
138
+ /**
139
+ * Return every registered function implementation. Unlike getToolNames(),
140
+ * this is not a prompt-visibility list; lazy tool services may intentionally
141
+ * hide definitions from the model while keeping their functions callable by
142
+ * trusted programmatic consumers such as the script runtime.
143
+ */
144
+ getFunctionNames(): string[] {
145
+ return Object.keys(this.functions).filter((name) => typeof this.functions[name] === "function");
146
+ }
147
+
138
148
  getTool(name: string): Tool {
139
149
  return this.tools.find(
140
150
  (tool) =>
@@ -16,6 +16,7 @@ import { TaskRegistry } from "./TaskRegistry";
16
16
  import { MediaProcessorService } from "./MediaProcessorService";
17
17
  import { BehaviorsService } from "./BehaviorsService";
18
18
  import { RuntimeReloadService } from "./RuntimeReloadService";
19
+ import { ExtensionsService } from "./ExtensionsService";
19
20
 
20
21
  import { ConversionService } from "./conversion/ConversionService";
21
22
  import { TracingService } from "./TracingService";
@@ -45,6 +46,7 @@ export { TracingService } from "./TracingService";
45
46
  export type { TracerImpl, SpanHandle } from "./TracingService";
46
47
  export * from "./conversion";
47
48
  export * from "./RuntimeReloadService";
49
+ export * from "./ExtensionsService";
48
50
  export { BehaviorsService } from "./BehaviorsService";
49
51
 
50
52
  let Singletons = {} as {
@@ -64,6 +66,7 @@ let Singletons = {} as {
64
66
  Conversion: ConversionService;
65
67
  Behaviors: BehaviorsService;
66
68
  RuntimeReload: RuntimeReloadService;
69
+ Extensions: ExtensionsService;
67
70
  };
68
71
 
69
72
  export const services = (): typeof Singletons => {
@@ -98,6 +101,7 @@ export const services = (): typeof Singletons => {
98
101
  knowhowApiClient: new KnowhowSimpleClient(),
99
102
  Behaviors,
100
103
  RuntimeReload: new RuntimeReloadService(),
104
+ Extensions: new ExtensionsService(),
101
105
  };
102
106
 
103
107
  Singletons.Tools.setContext({
@@ -26,13 +26,15 @@ export interface Region extends Point, Size {}
26
26
  export type MouseButton = "left" | "right" | "middle";
27
27
 
28
28
  export interface OverlayPrimitive {
29
- kind: "rect" | "circle" | "line" | "point";
29
+ kind: "rect" | "circle" | "line" | "point" | "text";
30
30
  x: number;
31
31
  y: number;
32
32
  width?: number;
33
33
  height?: number;
34
34
  x2?: number;
35
35
  y2?: number;
36
+ text?: string;
37
+ fontSize?: number;
36
38
  color?: string;
37
39
  lineWidth?: number;
38
40
  }
@@ -207,6 +207,11 @@ export class ModulesService {
207
207
  }
208
208
  this._loadedModules.push({ modulePath, resolvedPath, module: importedModule, params: initParams });
209
209
 
210
+ context.Extensions?.removeOwner(modulePath);
211
+ for (const extension of importedModule.extensions ?? []) {
212
+ context.Extensions?.register(modulePath, extension);
213
+ }
214
+
210
215
  // Only register tools/agents/plugins/clients if the relevant services
211
216
  // are available in context (they may not be during early CLI command registration)
212
217
  if (context.Agents) {
@@ -253,6 +258,7 @@ export class ModulesService {
253
258
  // Clear first so repeated shutdown/reload attempts cannot destroy twice.
254
259
  this._loadedModules = [];
255
260
  for (const { modulePath, resolvedPath, module: mod, params } of toDestroy) {
261
+ params.context?.Extensions?.removeOwner(modulePath);
256
262
  if (typeof mod.destroy === "function") {
257
263
  try {
258
264
  await mod.destroy(params);
@@ -15,6 +15,7 @@ import { EventService } from "../EventService";
15
15
  import { ConversionService } from "../conversion/ConversionService";
16
16
  import { BehaviorsService } from "../BehaviorsService";
17
17
  import { ComputerUseService } from "./computerUse";
18
+ import { ExtensionsService, ModuleExtension } from "../ExtensionsService";
18
19
 
19
20
  import { TracingService as TracingServiceType } from "../TracingService";
20
21
  /*
@@ -66,6 +67,7 @@ export interface ModuleContext {
66
67
  Program?: Command;
67
68
  Behaviors?: BehaviorsService;
68
69
  ComputerUse?: ComputerUseService;
70
+ Extensions?: ExtensionsService;
69
71
  [key: string]: any;
70
72
  Tracing?: typeof TracingServiceType;
71
73
  }
@@ -98,6 +100,8 @@ export interface KnowhowModule {
98
100
  */
99
101
  destroy?: (params: InitParams) => Promise<void>;
100
102
  commands: ModuleChatCommand[];
103
+ /** Optional capabilities consumed by core or third-party services. */
104
+ extensions?: ModuleExtension[];
101
105
  tools: ModuleTool[];
102
106
  agents: ModuleAgent[];
103
107
  plugins: ModulePlugin[];