@tyvm/knowhow 0.0.147 → 0.0.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +27 -23
  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 +50 -25
  6. package/src/ai.ts +4 -1
  7. package/src/chat/CliChatService.ts +23 -2
  8. package/src/chat/modules/InternalChatModule.ts +5 -0
  9. package/src/chat/modules/ToolCommandModule.ts +102 -0
  10. package/src/chat/modules/index.ts +1 -0
  11. package/src/chat/types.ts +1 -1
  12. package/src/cli.ts +9 -2
  13. package/src/clients/http.ts +67 -7
  14. package/src/clients/index.ts +2 -0
  15. package/src/commands/agents.ts +53 -8
  16. package/src/commands/processes.ts +138 -0
  17. package/src/commands/services.ts +23 -1
  18. package/src/index.ts +1 -0
  19. package/src/processes/ProcessManager.ts +95 -0
  20. package/src/processes/managedProcessShim.ts +115 -0
  21. package/src/services/AgentSyncFs.ts +12 -1
  22. package/src/services/ExtensionsService.ts +25 -0
  23. package/src/services/LazyToolsService.ts +15 -20
  24. package/src/services/Tools.ts +10 -0
  25. package/src/services/index.ts +4 -0
  26. package/src/services/modules/index.ts +6 -0
  27. package/src/services/modules/types.ts +4 -0
  28. package/tests/unit/chat/ToolCommandModule.test.ts +71 -0
  29. package/tests/unit/commands/agents.test.ts +44 -1
  30. package/tests/unit/services/LazyToolsService.test.ts +24 -0
  31. package/ts_build/package.json +1 -1
  32. package/ts_build/src/agents/base/base.d.ts +1 -1
  33. package/ts_build/src/agents/base/base.js +16 -12
  34. package/ts_build/src/agents/base/base.js.map +1 -1
  35. package/ts_build/src/agents/tools/execCommand.d.ts +4 -1
  36. package/ts_build/src/agents/tools/execCommand.js +100 -223
  37. package/ts_build/src/agents/tools/execCommand.js.map +1 -1
  38. package/ts_build/src/agents/tools/list.js +2 -2
  39. package/ts_build/src/agents/tools/list.js.map +1 -1
  40. package/ts_build/src/agents/tools/startAgentTask.d.ts +14 -1
  41. package/ts_build/src/agents/tools/startAgentTask.js +35 -19
  42. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  43. package/ts_build/src/ai.js +2 -1
  44. package/ts_build/src/ai.js.map +1 -1
  45. package/ts_build/src/chat/CliChatService.d.ts +6 -0
  46. package/ts_build/src/chat/CliChatService.js +17 -2
  47. package/ts_build/src/chat/CliChatService.js.map +1 -1
  48. package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
  49. package/ts_build/src/chat/modules/InternalChatModule.js +5 -0
  50. package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
  51. package/ts_build/src/chat/modules/ToolCommandModule.d.ts +10 -0
  52. package/ts_build/src/chat/modules/ToolCommandModule.js +86 -0
  53. package/ts_build/src/chat/modules/ToolCommandModule.js.map +1 -0
  54. package/ts_build/src/chat/modules/index.d.ts +1 -0
  55. package/ts_build/src/chat/modules/index.js +3 -1
  56. package/ts_build/src/chat/modules/index.js.map +1 -1
  57. package/ts_build/src/chat/types.d.ts +1 -1
  58. package/ts_build/src/cli.js +6 -2
  59. package/ts_build/src/cli.js.map +1 -1
  60. package/ts_build/src/clients/http.d.ts +4 -0
  61. package/ts_build/src/clients/http.js +68 -6
  62. package/ts_build/src/clients/http.js.map +1 -1
  63. package/ts_build/src/clients/index.js +4 -0
  64. package/ts_build/src/clients/index.js.map +1 -1
  65. package/ts_build/src/commands/agents.d.ts +1 -0
  66. package/ts_build/src/commands/agents.js +42 -7
  67. package/ts_build/src/commands/agents.js.map +1 -1
  68. package/ts_build/src/commands/processes.d.ts +2 -0
  69. package/ts_build/src/commands/processes.js +213 -0
  70. package/ts_build/src/commands/processes.js.map +1 -0
  71. package/ts_build/src/commands/services.js +11 -1
  72. package/ts_build/src/commands/services.js.map +1 -1
  73. package/ts_build/src/index.d.ts +1 -0
  74. package/ts_build/src/index.js +1 -0
  75. package/ts_build/src/index.js.map +1 -1
  76. package/ts_build/src/processes/ProcessManager.d.ts +51 -0
  77. package/ts_build/src/processes/ProcessManager.js +160 -0
  78. package/ts_build/src/processes/ProcessManager.js.map +1 -0
  79. package/ts_build/src/processes/managedProcessShim.d.ts +1 -0
  80. package/ts_build/src/processes/managedProcessShim.js +161 -0
  81. package/ts_build/src/processes/managedProcessShim.js.map +1 -0
  82. package/ts_build/src/services/AgentSyncFs.js +6 -1
  83. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  84. package/ts_build/src/services/ExtensionsService.d.ts +10 -0
  85. package/ts_build/src/services/ExtensionsService.js +17 -0
  86. package/ts_build/src/services/ExtensionsService.js.map +1 -0
  87. package/ts_build/src/services/LazyToolsService.d.ts +1 -0
  88. package/ts_build/src/services/LazyToolsService.js +5 -7
  89. package/ts_build/src/services/LazyToolsService.js.map +1 -1
  90. package/ts_build/src/services/Tools.d.ts +1 -0
  91. package/ts_build/src/services/Tools.js +3 -0
  92. package/ts_build/src/services/Tools.js.map +1 -1
  93. package/ts_build/src/services/index.d.ts +3 -0
  94. package/ts_build/src/services/index.js +3 -0
  95. package/ts_build/src/services/index.js.map +1 -1
  96. package/ts_build/src/services/modules/index.d.ts +2 -0
  97. package/ts_build/src/services/modules/index.js +5 -0
  98. package/ts_build/src/services/modules/index.js.map +1 -1
  99. package/ts_build/src/services/modules/types.d.ts +3 -0
  100. package/ts_build/tests/unit/chat/ToolCommandModule.test.d.ts +1 -0
  101. package/ts_build/tests/unit/chat/ToolCommandModule.test.js +61 -0
  102. package/ts_build/tests/unit/chat/ToolCommandModule.test.js.map +1 -0
  103. package/ts_build/tests/unit/commands/agents.test.js +28 -0
  104. package/ts_build/tests/unit/commands/agents.test.js.map +1 -1
  105. package/ts_build/tests/unit/services/LazyToolsService.test.js +20 -0
  106. package/ts_build/tests/unit/services/LazyToolsService.test.js.map +1 -1
@@ -0,0 +1,138 @@
1
+ import { Command } from "commander";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import {
5
+ getProcessesDir, listManagedProcesses, ManagedProcess,
6
+ ManagedProcessInfo, processDirectory,
7
+ } from "../processes/ProcessManager";
8
+ import { runManagedProcessShim } from "../processes/managedProcessShim";
9
+
10
+ const terminal = new Set(["exited", "failed", "stopped"]);
11
+ function resolveProcess(value: string): ManagedProcess {
12
+ const root = getProcessesDir();
13
+ const matches = listManagedProcesses(root).filter((p) => p.id === value || p.id.includes(value));
14
+ if (!matches.length) throw new Error(`Managed process not found: ${value}`);
15
+ const exact = matches.find((p) => p.id === value);
16
+ if (!exact && matches.length > 1) throw new Error(`Ambiguous process id '${value}': ${matches.map((p) => p.id).join(", ")}`);
17
+ const item = exact || matches[0];
18
+ return new ManagedProcess(item.id, item.directory);
19
+ }
20
+ function commandOptions(options: any, command?: Command): any {
21
+ const fromOptions = typeof options?.optsWithGlobals === "function" ? options.optsWithGlobals() : options || {};
22
+ const fromCommand = typeof command?.optsWithGlobals === "function" ? command.optsWithGlobals() : {};
23
+ return { ...fromOptions, ...fromCommand };
24
+ }
25
+ function filtered(options: any): ManagedProcessInfo[] {
26
+ return listManagedProcesses().filter((item) => {
27
+ if (!options.all && terminal.has(item.status.state)) return false;
28
+ if (options.parent && item.status.parentProcessId !== options.parent) return false;
29
+ if (options.background !== undefined && item.status.background !== options.background) return false;
30
+ return true;
31
+ });
32
+ }
33
+ function printList(options: any): void {
34
+ const rows = filtered(options).map(({ id, status }) => ({
35
+ id, state: status.state, pid: status.pid || "-", background: status.background ? "yes" : "no",
36
+ parent: status.parentProcessId || "-", command: [status.command, ...status.args].join(" "),
37
+ }));
38
+ if (options.json) console.log(JSON.stringify(rows, null, 2));
39
+ else if (rows.length) console.table(rows);
40
+ else console.log("No managed processes.");
41
+ }
42
+ function addFilters(command: Command): Command {
43
+ return command.option("-a, --all", "include completed processes")
44
+ .option("--parent <id>", "filter by parent process/task id")
45
+ .option("--background", "show only background processes")
46
+ .option("--json", "emit JSON");
47
+ }
48
+ function tailText(file: string, lines: number): string {
49
+ if (!fs.existsSync(file)) return "";
50
+ return fs.readFileSync(file, "utf8").split("\n").slice(-lines - 1).join("\n");
51
+ }
52
+ async function followFiles(files: string[], fromEnd = true): Promise<void> {
53
+ const offsets = new Map<string, number>();
54
+ for (const file of files) offsets.set(file, fromEnd && fs.existsSync(file) ? fs.statSync(file).size : 0);
55
+ await new Promise<void>((resolve) => {
56
+ const poll = setInterval(() => {
57
+ for (const file of files) {
58
+ if (!fs.existsSync(file)) continue;
59
+ const size = fs.statSync(file).size; const offset = offsets.get(file) || 0;
60
+ if (size < offset) offsets.set(file, 0);
61
+ if (size > (offsets.get(file) || 0)) {
62
+ const start = offsets.get(file) || 0;
63
+ const fd = fs.openSync(file, "r"); const data = Buffer.alloc(size - start);
64
+ fs.readSync(fd, data, 0, data.length, start); fs.closeSync(fd);
65
+ process.stdout.write(data); offsets.set(file, size);
66
+ }
67
+ }
68
+ }, 150);
69
+ const done = () => { clearInterval(poll); resolve(); };
70
+ process.once("SIGINT", done); process.once("SIGTERM", done);
71
+ });
72
+ }
73
+
74
+ export function addProcessesCommand(program: Command): void {
75
+ const processes = addFilters(program.command("processes").description("inspect and control managed processes"));
76
+ processes.action((rawOptions, command) => printList(commandOptions(rawOptions, command)));
77
+ addFilters(processes.command("list").description("list managed processes"))
78
+ .action((rawOptions, command) => printList(commandOptions(rawOptions, command)));
79
+
80
+ processes.command("run").description("internal managed process shim").requiredOption("--config <path>")
81
+ .action(async ({ config }) => runManagedProcessShim(path.resolve(config)));
82
+
83
+ processes.command("logs <id>").alias("tail").option("-n, --lines <number>", "lines to show", "100")
84
+ .option("-f, --follow", "follow output").option("--stderr", "show stderr instead of stdout")
85
+ .option("--both", "show stdout and stderr").action(async (id, rawOptions, command) => {
86
+ const options = commandOptions(rawOptions, command);
87
+ const managed = resolveProcess(id);
88
+ const files = options.both ? [managed.stdoutPath, managed.stderrPath] : [options.stderr ? managed.stderrPath : managed.stdoutPath];
89
+ for (const file of files) process.stdout.write(tailText(file, Math.max(0, Number(options.lines) || 100)));
90
+ if (options.follow) await followFiles(files);
91
+ });
92
+
93
+ processes.command("send <id> [input...]").option("-n, --no-newline", "do not append newline")
94
+ .action(async (id, input: string[], rawOptions, command) => {
95
+ const options = commandOptions(rawOptions, command);
96
+ const managed = resolveProcess(id);
97
+ await managed.write((input || []).join(" ") + (options.newline ? "\n" : ""));
98
+ });
99
+
100
+ processes.command("signal <id> [signal]").action((id, signal = "SIGTERM") => resolveProcess(id).signal(signal.toUpperCase() as NodeJS.Signals));
101
+ processes.command("stop <id>").action((id) => resolveProcess(id).signal("SIGTERM"));
102
+ processes.command("kill <id>").action((id) => resolveProcess(id).signal("SIGKILL"));
103
+
104
+ processes.command("attach <id>").description("attach terminal (Ctrl-] to detach)").action(async (id) => {
105
+ const managed = resolveProcess(id); let detached = false;
106
+ const raw = !!process.stdin.isTTY; if (raw) process.stdin.setRawMode(true);
107
+ process.stdin.resume();
108
+ const onData = async (data: Buffer) => {
109
+ if (data.includes(0x1d)) { detached = true; return; }
110
+ try { await managed.write(data.toString()); } catch (error) { console.error(String(error)); detached = true; }
111
+ };
112
+ process.stdin.on("data", onData);
113
+ const offsets = [managed.stdoutPath, managed.stderrPath].map((f) => fs.existsSync(f) ? fs.statSync(f).size : 0);
114
+ await new Promise<void>((resolve) => {
115
+ const timer = setInterval(() => {
116
+ [managed.stdoutPath, managed.stderrPath].forEach((file, i) => {
117
+ if (!fs.existsSync(file)) return; const size = fs.statSync(file).size;
118
+ if (size > offsets[i]) { const fd = fs.openSync(file, "r"); const b = Buffer.alloc(size - offsets[i]); fs.readSync(fd, b, 0, b.length, offsets[i]); fs.closeSync(fd); process.stdout.write(b); offsets[i] = size; }
119
+ });
120
+ if (detached || terminal.has(managed.status.state)) { clearInterval(timer); resolve(); }
121
+ }, 100);
122
+ });
123
+ process.stdin.off("data", onData); process.stdin.pause(); if (raw) process.stdin.setRawMode(false);
124
+ });
125
+
126
+ processes.command("tree").option("-a, --all", "include completed processes").action((rawOptions, command) => {
127
+ const options = commandOptions(rawOptions, command);
128
+ const items = listManagedProcesses().filter((p) => options.all || !terminal.has(p.status.state));
129
+ const ids = new Set(items.map((p) => p.id));
130
+ const children = new Map<string | null, ManagedProcessInfo[]>();
131
+ for (const item of items) { const parent = item.status.parentProcessId && ids.has(item.status.parentProcessId) ? item.status.parentProcessId : null; children.set(parent, [...(children.get(parent) || []), item]); }
132
+ const render = (parent: string | null, prefix: string) => (children.get(parent) || []).forEach((item, index, array) => {
133
+ const last = index === array.length - 1; console.log(`${prefix}${last ? "└─" : "├─"}${item.id} [${item.status.state}] ${item.status.command}`);
134
+ render(item.id, prefix + (last ? " " : "│ "));
135
+ });
136
+ render(null, "");
137
+ });
138
+ }
@@ -3,7 +3,8 @@ import * as allTools from "../agents/tools";
3
3
  import { LazyToolsService, services, MinimalToolsService, TracingService } from "../services";
4
4
  import { agents } from "../agents";
5
5
  import { ModulesService } from "../services/modules";
6
- import { getConfig } from "../config";
6
+ import { getConfig, getConfigSync } from "../config";
7
+ import { authenticateWithKey } from "../auth/keyAuth";
7
8
 
8
9
  /**
9
10
  * Shared service setup used by commands that need full services (chat, agent, worker, etc.)
@@ -20,6 +21,7 @@ export async function setupServices() {
20
21
  MediaProcessor,
21
22
  Behaviors,
22
23
  RuntimeReload,
24
+ Extensions,
23
25
  } = services();
24
26
 
25
27
  await Plugins.refreshConfiguredState();
@@ -76,6 +78,25 @@ export async function setupServices() {
76
78
  `⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
77
79
  );
78
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.
85
+ try {
86
+ const startupConfig = getConfigSync();
87
+ if (startupConfig.orgId && !process.env.KNOWHOW_JWT) {
88
+ await authenticateWithKey(
89
+ startupConfig.orgId,
90
+ process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai",
91
+ startupConfig.cliIdentityPath
92
+ );
93
+ }
94
+ } catch (_err) {
95
+ // Non-fatal: if SSH refresh fails (no key, revoked, network issue), we
96
+ // continue with the existing JWT (or no JWT). The user will see model
97
+ // warnings if no valid JWT is available.
98
+ }
99
+
79
100
  console.log("Connecting to clients...");
80
101
  await Clients.registerConfiguredModels();
81
102
  console.log("✓ Services are set up and ready to go!");
@@ -92,6 +113,7 @@ export async function setupServices() {
92
113
  Behaviors,
93
114
  Events,
94
115
  Tracing: TracingService,
116
+ Extensions,
95
117
  };
96
118
 
97
119
  // Call destroy() on all modules when the process is shutting down so they
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ export * as embeddings from "./embeddings";
65
65
  export * as types from "./types";
66
66
  export * as processors from "./processors";
67
67
  export * as ai from "./ai";
68
+ export * from "./processes/ProcessManager";
68
69
 
69
70
  // Export module system types for external modules
70
71
  export * from "./services/modules/types";
@@ -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,115 @@
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 fifoFd: number | undefined;
74
+ let fifoStream: fs.ReadStream | undefined;
75
+ if (process.platform !== "win32") {
76
+ // O_RDWR prevents transient writers disconnecting from delivering EOF to the shim.
77
+ fifoFd = fs.openSync(fifoPath, fs.constants.O_RDWR);
78
+ fifoStream = fs.createReadStream("", { fd: fifoFd, autoClose: false });
79
+ fifoStream.on("data", (chunk) => {
80
+ fs.appendFileSync(inputLogPath, chunk);
81
+ if (child.stdin?.writable) child.stdin.write(chunk);
82
+ });
83
+ }
84
+
85
+ let stoppingForParent = false;
86
+ const parentTimer = !config.background && config.parentPid
87
+ ? setInterval(() => {
88
+ if (!alive(config.parentPid!)) {
89
+ stoppingForParent = true;
90
+ terminateGroup(child, "SIGTERM");
91
+ setTimeout(() => { if (alive(child.pid || -1)) terminateGroup(child, "SIGKILL"); }, 3000).unref();
92
+ }
93
+ }, 500)
94
+ : undefined;
95
+ parentTimer?.unref();
96
+
97
+ const forward = (signal: NodeJS.Signals) => terminateGroup(child, signal);
98
+ process.once("SIGINT", () => forward("SIGINT"));
99
+ process.once("SIGTERM", () => forward("SIGTERM"));
100
+ process.once("SIGHUP", () => forward("SIGHUP"));
101
+
102
+ await new Promise<void>((resolve) => {
103
+ child.once("error", (error) => {
104
+ status = { ...status, state: "failed", error: error.message, endedAt: new Date().toISOString() };
105
+ writeStatus(); resolve();
106
+ });
107
+ child.once("exit", (code, signal) => {
108
+ status = { ...status, state: stoppingForParent || signal ? "stopped" : code === 0 ? "exited" : "failed", endedAt: new Date().toISOString(), exitCode: code, signal };
109
+ writeStatus(); resolve();
110
+ });
111
+ });
112
+ if (parentTimer) clearInterval(parentTimer);
113
+ fifoStream?.destroy();
114
+ if (fifoFd !== undefined) try { fs.closeSync(fifoFd); } catch {}
115
+ }
@@ -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({
@@ -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[];