@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
@@ -34,6 +34,20 @@ export interface StartAgentTaskParams {
34
34
  _ctx?: { caller?: any; taskId?: string; [key: string]: any };
35
35
  }
36
36
 
37
+ export interface StartAgentTaskResult {
38
+ success: boolean;
39
+ status: "started" | "completed" | "failed";
40
+ taskId: string;
41
+ pid?: number;
42
+ syncFs: boolean;
43
+ syncReady: boolean;
44
+ agentDir?: string;
45
+ logPath: string;
46
+ parentTaskId?: string;
47
+ exitCode?: number | null;
48
+ error?: string;
49
+ }
50
+
37
51
  const PROCESSES_DIR = path.join(process.cwd(), ".knowhow", "processes");
38
52
  const AGENTS_DIR = path.join(process.cwd(), ".knowhow", "processes", "agents");
39
53
 
@@ -100,7 +114,7 @@ function subagentCapabilitiesNote(): string {
100
114
  * .knowhow/processes/agents/{taskId}/input.txt
101
115
  * The agent will pick up the new content and process it as a new message.
102
116
  */
103
- export async function startAgentTask(params: StartAgentTaskParams): Promise<string> {
117
+ export async function startAgentTask(params: StartAgentTaskParams): Promise<StartAgentTaskResult> {
104
118
  const {
105
119
  messageId,
106
120
  prompt,
@@ -138,7 +152,9 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
138
152
  // Default filesystem synchronization ON unless the caller explicitly opts out
139
153
  // (syncFs: false) or is using a messageId-based web sync. This ensures spawned
140
154
  // subagents always appear in `knowhow agents list` and can be attached/tailed.
141
- const useSyncFs = syncFs !== false && !messageId;
155
+ // If the caller explicitly passes syncFs: true, honor it even when a messageId
156
+ // is also provided (messageId handles remote sync; syncFs handles local fs sync).
157
+ const useSyncFs = syncFs === true ? true : (syncFs !== false && !messageId);
142
158
 
143
159
  // Use provided taskId if given, otherwise generate one from the prompt
144
160
  const taskId = providedTaskId ?? generateTaskId(prompt);
@@ -161,7 +177,8 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
161
177
 
162
178
  if (messageId) {
163
179
  args.push("--message-id", messageId);
164
- } else if (useSyncFs) {
180
+ }
181
+ if (useSyncFs) {
165
182
  args.push("--sync-fs");
166
183
  }
167
184
  // When syncRemote is requested, pass it through so the spawned agent pushes
@@ -235,43 +252,54 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
235
252
  child.stdin!.write(promptWithCaps, "utf8");
236
253
  child.stdin!.end();
237
254
 
238
- return new Promise<string>((resolve) => {
255
+ return new Promise<StartAgentTaskResult>((resolve) => {
239
256
  let settled = false;
240
- const done = (msg: string) => {
257
+ let exited = false;
258
+ const done = (result: StartAgentTaskResult) => {
241
259
  if (settled) return;
242
260
  settled = true;
243
261
  try { fs.closeSync(fd); } catch {}
244
- resolve(msg);
262
+ resolve(result);
245
263
  };
246
264
 
247
265
  child.once("error", (e) => {
248
- done(`Failed to start agent: ${String(e)}\nLogs: ${logPath}`);
266
+ done({
267
+ success: false, status: "failed", taskId, pid, syncFs: useSyncFs,
268
+ syncReady: false, agentDir: useSyncFs ? agentTaskDir : undefined,
269
+ logPath, parentTaskId, error: String(e),
270
+ });
249
271
  });
250
272
 
251
- const syncFsNote = useSyncFs
252
- ? `\nTask ID: ${taskId}\nAgent dir: ${agentTaskDir}\n` +
253
- `To send agent messages, write to: ${agentTaskDir}/input.txt\n` +
254
- `To check status, read: ${agentTaskDir}/status.txt\n`
255
- : "";
256
-
257
273
  child.once("exit", (code) => {
258
- done(
259
- `Agent finished with exit code ${code}.\nLogs: ${logPath}\n` +
260
- syncFsNote
261
- );
274
+ exited = true;
275
+ done({
276
+ success: code === 0, status: code === 0 ? "completed" : "failed",
277
+ taskId, pid, syncFs: useSyncFs,
278
+ syncReady: useSyncFs && fs.existsSync(path.join(agentTaskDir, "metadata.json")),
279
+ agentDir: useSyncFs ? agentTaskDir : undefined, logPath, parentTaskId,
280
+ exitCode: code, ...(code === 0 ? {} : { error: `Agent exited with code ${code}` }),
281
+ });
262
282
  });
263
283
 
264
284
  if (!waitForCompletion) {
265
- // Give the agent 5 seconds to finish before detaching (fire-and-forget mode)
266
- const detachTime = 5 * 1000;
267
- setTimeout(() => {
268
- try { child.unref(); } catch {}
269
- done(
270
- `Agent started (pid=${pid}), running in background.\n` +
271
- `Logs: ${logPath}\n` +
272
- syncFsNote
273
- );
274
- }, detachTime);
285
+ // A spawn is not the same as an initialized fs-sync agent. Poll briefly
286
+ // so callers can distinguish those states without parsing log prose.
287
+ const deadline = Date.now() + (useSyncFs ? 5000 : 100);
288
+ const acknowledge = () => {
289
+ if (settled || exited) return;
290
+ const syncReady = useSyncFs && fs.existsSync(path.join(agentTaskDir, "metadata.json"));
291
+ if (!useSyncFs || syncReady || Date.now() >= deadline) {
292
+ try { child.unref(); } catch {}
293
+ done({
294
+ success: true, status: "started", taskId, pid, syncFs: useSyncFs,
295
+ syncReady, agentDir: useSyncFs ? agentTaskDir : undefined,
296
+ logPath, parentTaskId,
297
+ });
298
+ return;
299
+ }
300
+ setTimeout(acknowledge, 100);
301
+ };
302
+ setTimeout(acknowledge, 50);
275
303
  }
276
304
  });
277
305
  }
package/src/ai.ts CHANGED
@@ -65,13 +65,16 @@ export async function singlePrompt(
65
65
  // This ensures generate agent runs produce readable output and are observable
66
66
  // via `knowhow agents list/tail/status`, matching `knowhow agent --input` behaviour.
67
67
  const { startAgentTask } = await import("./agents/tools/startAgentTask");
68
- return startAgentTask({
68
+ const result = await startAgentTask({
69
69
  agentName: agent,
70
70
  prompt: userPrompt,
71
71
  model: model || undefined,
72
72
  waitForCompletion: true,
73
73
  ...(agentOptions ?? {}),
74
74
  });
75
+ // singlePrompt is a text API; keep generation pipelines compatible while
76
+ // the tool itself exposes a structured orchestration result.
77
+ return JSON.stringify(result);
75
78
  }
76
79
 
77
80
  if (!model) {
@@ -81,7 +81,7 @@ export async function authenticateWithKey(
81
81
  }
82
82
  }
83
83
 
84
- /** Register an Ed25519 CLI-auth key using the JWT obtained by browser PKCE. */
84
+ /** Register an Ed25519 identity; the backend derives its scope from the source JWT. */
85
85
  export async function registerPublicKey(
86
86
  jwt: string,
87
87
  publicKeyBase64: string,
@@ -109,7 +109,13 @@ export async function registerPublicKey(
109
109
 
110
110
  export function storeJwt(jwt: string): void {
111
111
  const directory = path.dirname(JWT_FILE_PATH);
112
- fs.mkdirSync(directory, { recursive: true });
113
- fs.writeFileSync(JWT_FILE_PATH, jwt, { mode: 0o600 });
114
- fs.chmodSync(JWT_FILE_PATH, 0o600);
112
+ const temporaryPath = `${JWT_FILE_PATH}.${process.pid}.tmp`;
113
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
114
+ try {
115
+ fs.writeFileSync(temporaryPath, jwt, { mode: 0o600, flag: "wx" });
116
+ fs.renameSync(temporaryPath, JWT_FILE_PATH);
117
+ fs.chmodSync(JWT_FILE_PATH, 0o600);
118
+ } finally {
119
+ fs.rmSync(temporaryPath, { force: true });
120
+ }
115
121
  }
@@ -0,0 +1,67 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getOrCreatePublicKey } from "./keyManager";
4
+ import { registerPublicKey, exchangePublicKeyJwt, storeJwt } from "./keyAuth";
5
+ import { getConfig, updateConfig } from "../config";
6
+
7
+ export interface RegisterIdentityOptions {
8
+ /** Optional Ed25519 private-key path. Defaults to ~/.knowhow/keys/id_ed25519. */
9
+ identityPath?: string;
10
+ /** API base URL. Defaults to KNOWHOW_API_URL or the production public API. */
11
+ apiUrl?: string;
12
+ }
13
+
14
+ /** Decode an unverified JWT payload for local routing information only. */
15
+ export function decodeJwtPayload(jwt: string): Record<string, unknown> {
16
+ const parts = jwt.split(".");
17
+ if (parts.length !== 3) throw new Error("Invalid JWT format: expected 3 parts");
18
+ const base64 = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
19
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
20
+ try {
21
+ const payload: unknown = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
22
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error();
23
+ return payload as Record<string, unknown>;
24
+ } catch {
25
+ throw new Error("Invalid JWT payload: could not parse JSON");
26
+ }
27
+ }
28
+
29
+ export function extractOrgIdFromJwt(jwt: string): string {
30
+ const payload = decodeJwtPayload(jwt);
31
+ const orgId = payload["org"] ?? payload["orgId"] ?? payload["org_id"] ?? payload["organizationId"];
32
+ if (typeof orgId !== "string" || !orgId) {
33
+ throw new Error("JWT does not contain an organization claim (expected org, orgId, org_id, or organizationId)");
34
+ }
35
+ return orgId;
36
+ }
37
+
38
+ /**
39
+ * Register a CLI identity using the JWT already stored in the current project's
40
+ * .knowhow/.jwt, then replace that JWT with one obtained via key authentication.
41
+ * This is intentionally generic: authorization scope is derived by the backend
42
+ * from the verified source JWT.
43
+ */
44
+ export async function registerIdentity(options: RegisterIdentityOptions = {}): Promise<void> {
45
+ const apiUrl = options.apiUrl ?? process.env.KNOWHOW_API_URL ?? "https://api.knowhow.tyvm.ai";
46
+ const jwtPath = path.join(process.cwd(), ".knowhow", ".jwt");
47
+ if (!fs.existsSync(jwtPath)) throw new Error(`JWT file not found: ${jwtPath}`);
48
+ const sourceJwt = fs.readFileSync(jwtPath, "utf8").trim();
49
+ if (!sourceJwt) throw new Error(`JWT file is empty: ${jwtPath}`);
50
+
51
+ const orgId = extractOrgIdFromJwt(sourceJwt);
52
+ const keyPair = getOrCreatePublicKey(options.identityPath);
53
+ await registerPublicKey(sourceJwt, keyPair.publicKeyBase64, apiUrl);
54
+ const identityJwt = await exchangePublicKeyJwt(orgId, apiUrl, options.identityPath);
55
+ storeJwt(identityJwt);
56
+
57
+ const config = await getConfig();
58
+ if (!config.modelProviders) config.modelProviders = [];
59
+ if (!config.modelProviders.some((provider: { provider: string }) => provider.provider === "knowhow")) {
60
+ config.modelProviders.push({ provider: "knowhow" });
61
+ }
62
+ config.orgId = orgId;
63
+ config.cliIdentityPath = path.resolve(options.identityPath ?? keyPair.privateKeyPath);
64
+ await updateConfig(config);
65
+
66
+ console.log(`✅ Identity registered. Identity: ${keyPair.fingerprint}, Org: ${orgId}`);
67
+ }
@@ -20,6 +20,12 @@ import fs from "fs";
20
20
  import path from "path";
21
21
  import { services } from "../services";
22
22
  import { logger } from "../logger";
23
+ import { ModuleExtension } from "../services/ExtensionsService";
24
+
25
+ export interface ChatExtension extends ModuleExtension {
26
+ type: "chat";
27
+ commands: ChatCommand[];
28
+ }
23
29
 
24
30
  export class CliChatService implements ChatService {
25
31
  private context: ChatContext;
@@ -59,6 +65,7 @@ export class CliChatService implements ChatService {
59
65
  }
60
66
  },
61
67
  });
68
+ this.refreshExtensions();
62
69
  this.loadInputHistory();
63
70
 
64
71
  // Set up callback to add entries to inputHistory immediately when user presses Enter
@@ -151,7 +158,12 @@ export class CliChatService implements ChatService {
151
158
  }
152
159
 
153
160
  registerCommand(command: ChatCommand): void {
154
- this.commands.push(command);
161
+ const existing = this.commands.findIndex((item) => item.name === command.name);
162
+ if (existing >= 0) {
163
+ this.commands[existing] = command;
164
+ } else {
165
+ this.commands.push(command);
166
+ }
155
167
  }
156
168
 
157
169
  registerMode(mode: ChatMode): void {
@@ -163,9 +175,17 @@ export class CliChatService implements ChatService {
163
175
  }
164
176
 
165
177
  getCommands(): ChatCommand[] {
178
+ this.refreshExtensions();
166
179
  return this.commands;
167
180
  }
168
181
 
182
+ /** Re-read the registry so services created before module setup still work. */
183
+ refreshExtensions(): void {
184
+ for (const extension of services().Extensions.list<ChatExtension>("chat")) {
185
+ for (const command of extension.commands ?? []) this.registerCommand(command);
186
+ }
187
+ }
188
+
169
189
  /**
170
190
  * Get commands available in the current mode
171
191
  */
@@ -176,6 +196,7 @@ export class CliChatService implements ChatService {
176
196
  }
177
197
 
178
198
  getCommandsForActiveModes(): ChatCommand[] {
199
+ this.refreshExtensions();
179
200
  const activeModes = this.modes
180
201
  .filter((mode) => mode.active)
181
202
  .map((mode) => mode.name);
@@ -216,7 +237,7 @@ export class CliChatService implements ChatService {
216
237
  const command = availableCommands.find((cmd) => cmd.name === commandName);
217
238
 
218
239
  if (command) {
219
- const result = await command.handler(args);
240
+ const result = await command.handler(args, this);
220
241
 
221
242
  // If handler returns a CommandResult and it's not handled, pass to modules
222
243
  if (result && typeof result === "object" && "handled" in result) {
@@ -8,6 +8,7 @@ import {
8
8
  TaskRegistry,
9
9
  SyncedAgentWatcher,
10
10
  SyncerService,
11
+ AgentService,
11
12
  } from "../../services/index";
12
13
  import { AttachableAgent } from "../../services/SyncedAgentWatcher";
13
14
  import * as fs from "fs";
@@ -77,10 +78,15 @@ export class AgentModule extends BaseChatModule {
77
78
  | { toolCall?: string; toolUsed?: string; agentSay?: string; done: string }
78
79
  | undefined;
79
80
 
81
+ private agentService: AgentService;
82
+ private eventService: EventService;
83
+
80
84
  constructor() {
81
85
  super();
82
86
  this.taskRegistry = new TaskRegistry();
83
87
  this.sessionManager = new SessionManager();
88
+ this.agentService = services().Agents;
89
+ this.eventService = services().Events;
84
90
  }
85
91
 
86
92
  /**
@@ -327,7 +333,7 @@ export class AgentModule extends BaseChatModule {
327
333
  await super.initialize(service);
328
334
 
329
335
  // Set up plugin log event handler - use setListener so re-init doesn't double-subscribe
330
- const Events = services().Events;
336
+ const Events = this.eventService;
331
337
  Events.setListener(
332
338
  { key: "agentModule:pluginLog", event: Events.eventTypes.pluginLog },
333
339
  (logEvent: any) => {
@@ -405,7 +411,7 @@ export class AgentModule extends BaseChatModule {
405
411
  // Set selected agent in context and enable agent mode
406
412
  if (context) {
407
413
  // Create a temporary agent instance to read its default model/provider
408
- const agentContext = services().Agents.getAgentContext();
414
+ const agentContext = this.agentService.getAgentContext();
409
415
  const tempAgent = createAgent(
410
416
  agentName as AgentName,
411
417
  agentContext
@@ -427,6 +433,25 @@ export class AgentModule extends BaseChatModule {
427
433
  this.chatService.setMode("agent");
428
434
  }
429
435
 
436
+ console.log(
437
+ `Agent mode enabled. Selected agent: ${agentName}. Type your task to get started.`
438
+ );
439
+ } else if (this.agentService.listAgents().includes(agentName)) {
440
+ // Config agent from knowhow.json
441
+ if (context) {
442
+ const configAgent = this.agentService.getAgent<BaseAgent>(agentName);
443
+ context.selectedAgent = configAgent;
444
+ context.agentMode = true;
445
+ context.currentAgent = agentName;
446
+ const clientInfo = await configAgent.clientService.getClient(
447
+ undefined,
448
+ configAgent.getModel()
449
+ );
450
+ context.currentModel = clientInfo.model;
451
+ context.currentProvider = clientInfo.provider;
452
+
453
+ this.chatService.setMode("agent");
454
+ }
430
455
  console.log(
431
456
  `Agent mode enabled. Selected agent: ${agentName}. Type your task to get started.`
432
457
  );
@@ -601,12 +626,16 @@ export class AgentModule extends BaseChatModule {
601
626
  */
602
627
  async handleAgentsCommand(args: string[]): Promise<void> {
603
628
  try {
604
- const agentNames = Object.keys(agentConstructors);
629
+ const builtinNames = Object.keys(agentConstructors);
630
+ const configAgentNames = this.agentService.listAgents();
631
+ // Merge, deduplicate, preserving built-ins first
632
+ const agentNames = [...new Set([...builtinNames, ...configAgentNames])];
605
633
 
606
634
  if (agentNames.length > 0) {
607
635
  console.log("\nAvailable agents:");
608
636
  agentNames.forEach((name) => {
609
- console.log(` - ${name}`);
637
+ const isConfig = !builtinNames.includes(name);
638
+ console.log(` - ${name}${isConfig ? " (config)" : ""}`);
610
639
  });
611
640
  console.log("─".repeat(80), "\n");
612
641
 
@@ -700,11 +729,14 @@ export class AgentModule extends BaseChatModule {
700
729
  const agentName = session.agentName || context.currentAgent;
701
730
  const previousAgentMode = context?.agentMode;
702
731
 
703
- if (!agentName || !agentConstructors[agentName as AgentName]) {
732
+ if (!agentName) {
704
733
  console.error(`Agent ${agentName} not found.`);
705
734
  return;
706
735
  }
707
736
 
737
+ // setupAgent resolves both built-in agents and custom ConfigAgents. Do
738
+ // not reject config-defined agents here before it gets that opportunity.
739
+
708
740
  // Start agent with Knowhow task context and restored message history
709
741
  const remoteTaskId = asRemoteTaskId(session.knowhowTaskId);
710
742
  const { agent, taskId } = await this.setupAgent({
@@ -817,7 +849,7 @@ export class AgentModule extends BaseChatModule {
817
849
  enabledTools?: string[];
818
850
  }) {
819
851
  const { input, chatHistory = [], agentName } = options;
820
- const agentContext = services().Agents.getAgentContext();
852
+ const agentContext = this.agentService.getAgentContext();
821
853
 
822
854
  // Resolve agent: built-in agents first, then fall back to ConfigAgents from knowhow.json
823
855
  let agent: BaseAgent;
@@ -826,7 +858,7 @@ export class AgentModule extends BaseChatModule {
826
858
  } else {
827
859
  // Try to find a ConfigAgent registered via loadAgentsFromConfig (from knowhow.json agents[])
828
860
  try {
829
- const configAgent = services().Agents.getAgent(options.agentName);
861
+ const configAgent = this.agentService.getAgent(options.agentName);
830
862
  agent = configAgent as unknown as BaseAgent;
831
863
  } catch {
832
864
  throw new Error(
@@ -10,6 +10,7 @@ import { SystemModule } from "./SystemModule";
10
10
  import { SetupModule } from "./SetupModule";
11
11
  import { CustomCommandsModule } from "./CustomCommandsModule";
12
12
  import { ShellCommandModule } from "./ShellCommandModule";
13
+ import { ToolCommandModule } from "./ToolCommandModule";
13
14
  import { RendererModule } from "./RendererModule";
14
15
  import { SessionsModule } from "./SessionsModule";
15
16
  import { RemoteSyncModule } from "./RemoteSyncModule";
@@ -32,6 +33,7 @@ export class InternalChatModule implements ChatModule {
32
33
  private setupModule: SetupModule;
33
34
  private customCommandsModule = new CustomCommandsModule();
34
35
  private shellCommandModule = new ShellCommandModule();
36
+ private toolCommandModule = new ToolCommandModule();
35
37
  private rendererModule: RendererModule;
36
38
  private remoteSyncModule: RemoteSyncModule;
37
39
 
@@ -61,6 +63,7 @@ export class InternalChatModule implements ChatModule {
61
63
  await this.customCommandsModule.initialize(chatService);
62
64
  await this.remoteSyncModule.initialize(chatService);
63
65
  await this.shellCommandModule.initialize(chatService);
66
+ await this.toolCommandModule.initialize(chatService);
64
67
  await this.clipboardImageModule.initialize(chatService);
65
68
 
66
69
  // Register our own commands (exit and multi) - not duplicated by BaseChatModule
@@ -93,6 +96,7 @@ export class InternalChatModule implements ChatModule {
93
96
  ...this.setupModule.getCommands(),
94
97
  ...this.customCommandsModule.getCommands(),
95
98
  ...this.shellCommandModule.getCommands(),
99
+ ...this.toolCommandModule.getCommands(),
96
100
  ...this.rendererModule.getCommands(),
97
101
  ...this.remoteSyncModule.getCommands(),
98
102
  {
@@ -120,6 +124,7 @@ export class InternalChatModule implements ChatModule {
120
124
  ...this.setupModule.getModes(),
121
125
  ...this.customCommandsModule.getModes(),
122
126
  ...this.shellCommandModule.getModes(),
127
+ ...this.toolCommandModule.getModes(),
123
128
  ...this.rendererModule.getModes(),
124
129
  ];
125
130
  }
@@ -0,0 +1,102 @@
1
+ import { ToolCall } from "../../clients/types";
2
+ import { services } from "../../services";
3
+ import { BaseChatModule } from "./BaseChatModule";
4
+ import { ChatCommand, ChatContext, CommandResult } from "../types";
5
+
6
+ /** Execute registered tools directly from interactive chat. */
7
+ export class ToolCommandModule extends BaseChatModule {
8
+ name = "tool-command";
9
+ description = "Execute a tool with /tool";
10
+
11
+ public getCommands(): ChatCommand[] {
12
+ return [
13
+ {
14
+ name: "tool",
15
+ description: "Execute a tool call from JSON",
16
+ handler: (args: string[]) => this.handleToolCommand(args),
17
+ },
18
+ ];
19
+ }
20
+
21
+ private async handleToolCommand(args: string[]): Promise<CommandResult> {
22
+ const input = args.join(" ").trim();
23
+ if (!input) {
24
+ console.log('Usage: /tool {"name":"toolName","arguments":{"key":"value"}}');
25
+ return { handled: true };
26
+ }
27
+
28
+ let toolCall: ToolCall;
29
+ try {
30
+ toolCall = this.parseToolCall(input);
31
+ } catch (error: any) {
32
+ console.error(`Invalid tool call JSON: ${error.message}`);
33
+ return { handled: true };
34
+ }
35
+
36
+ const tools = this.chatService.getTools() || services().Tools;
37
+ if (!tools) {
38
+ console.error("Tool execution failed: no tool service is available");
39
+ return { handled: true };
40
+ }
41
+
42
+ try {
43
+ const context = this.chatService.getContext();
44
+ const result = await tools.callTool(
45
+ toolCall,
46
+ tools.getFunctionNames(),
47
+ {
48
+ caller: context.selectedAgent,
49
+ taskId: context.activeAgentTaskId,
50
+ }
51
+ );
52
+
53
+ const toolError = result.toolMessages?.find(
54
+ (message: any) => message.name === "error"
55
+ );
56
+ if (toolError) {
57
+ console.error(`Tool execution failed: ${toolError.content}`);
58
+ } else {
59
+ const output = result.functionResp;
60
+ console.log(
61
+ typeof output === "object"
62
+ ? JSON.stringify(output, null, 2)
63
+ : String(output)
64
+ );
65
+ }
66
+ } catch (error: any) {
67
+ console.error(`Tool execution failed: ${error.message || String(error)}`);
68
+ }
69
+
70
+ return { handled: true };
71
+ }
72
+
73
+ private parseToolCall(input: string): ToolCall {
74
+ const parsed = JSON.parse(input);
75
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
76
+ throw new Error("expected a JSON object");
77
+ }
78
+
79
+ const fn = parsed.function || parsed;
80
+ if (!fn || typeof fn.name !== "string" || !fn.name.trim()) {
81
+ throw new Error('expected a non-empty "name" (or "function.name")');
82
+ }
83
+
84
+ const args = fn.arguments === undefined ? {} : fn.arguments;
85
+ if (typeof args !== "string" && (args === null || typeof args !== "object")) {
86
+ throw new Error('"arguments" must be a JSON object, array, or JSON string');
87
+ }
88
+
89
+ return {
90
+ id: typeof parsed.id === "string" ? parsed.id : `chat_tool_${Date.now()}`,
91
+ type: "function",
92
+ function: {
93
+ name: fn.name,
94
+ arguments: typeof args === "string" ? args : JSON.stringify(args),
95
+ },
96
+ };
97
+ }
98
+
99
+ async handleInput(input: string, context: ChatContext): Promise<boolean> {
100
+ return false;
101
+ }
102
+ }
@@ -4,4 +4,5 @@ export { AgentModule } from './AgentModule';
4
4
  export { VoiceModule } from './VoiceModule';
5
5
  export { InternalChatModule } from './InternalChatModule';
6
6
  export { ShellCommandModule } from './ShellCommandModule';
7
+ export { ToolCommandModule } from './ToolCommandModule';
7
8
  export { RendererModule } from './RendererModule';
package/src/chat/types.ts CHANGED
@@ -45,7 +45,7 @@ export interface CommandResult {
45
45
  export interface ChatCommand {
46
46
  name: string;
47
47
  description: string;
48
- handler: (args: string[]) => Promise<void | CommandResult>;
48
+ handler: (args: string[], chatService?: ChatService) => Promise<void | CommandResult>;
49
49
  modes?: string[]; // Commands can specify which modes they're available in
50
50
  }
51
51
 
package/src/cli.ts CHANGED
@@ -62,6 +62,7 @@ import { addSkillsCommand } from "./commands/skills";
62
62
  import { addAgentsCommand } from "./commands/agents";
63
63
  import { addHashCommand } from "./commands/hash";
64
64
  import { addSandboxCommand } from "./commands/sandbox";
65
+ import { addProcessesCommand } from "./commands/processes";
65
66
 
66
67
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
67
68
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -131,15 +132,21 @@ async function main() {
131
132
  addAgentsCommand(program, getChatService);
132
133
  addHashCommand(program);
133
134
  addSandboxCommand(program);
135
+ addProcessesCommand(program);
134
136
 
135
137
  // Load global modules early (before parse) so they can register CLI subcommands.
136
138
  // We pass only the Program in context — no services are spun up at this stage.
137
139
  // Each module's command action is responsible for calling setupServices() as needed.
138
140
  try {
139
141
  const globalConfig = await getGlobalConfig();
142
+ const globalModules = [...new Set(globalConfig.modules || [])];
143
+ const localModules = [...new Set(config.modules || [])];
144
+ // Local modules take precedence: if a module appears in both global and
145
+ // local configs, use the local version so it resolves against local
146
+ // node_modules first. This prevents duplicate command registration errors.
140
147
  const allModulePaths = [
141
- ...(globalConfig.modules || []),
142
- ...(config.modules || []),
148
+ ...globalModules.filter((m) => !localModules.includes(m)),
149
+ ...localModules,
143
150
  ];
144
151
  if (allModulePaths.length) {
145
152
  const earlyModulesService = new ModulesService();