@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
@@ -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,
@@ -235,43 +249,54 @@ export async function startAgentTask(params: StartAgentTaskParams): Promise<stri
235
249
  child.stdin!.write(promptWithCaps, "utf8");
236
250
  child.stdin!.end();
237
251
 
238
- return new Promise<string>((resolve) => {
252
+ return new Promise<StartAgentTaskResult>((resolve) => {
239
253
  let settled = false;
240
- const done = (msg: string) => {
254
+ let exited = false;
255
+ const done = (result: StartAgentTaskResult) => {
241
256
  if (settled) return;
242
257
  settled = true;
243
258
  try { fs.closeSync(fd); } catch {}
244
- resolve(msg);
259
+ resolve(result);
245
260
  };
246
261
 
247
262
  child.once("error", (e) => {
248
- done(`Failed to start agent: ${String(e)}\nLogs: ${logPath}`);
263
+ done({
264
+ success: false, status: "failed", taskId, pid, syncFs: useSyncFs,
265
+ syncReady: false, agentDir: useSyncFs ? agentTaskDir : undefined,
266
+ logPath, parentTaskId, error: String(e),
267
+ });
249
268
  });
250
269
 
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
270
  child.once("exit", (code) => {
258
- done(
259
- `Agent finished with exit code ${code}.\nLogs: ${logPath}\n` +
260
- syncFsNote
261
- );
271
+ exited = true;
272
+ done({
273
+ success: code === 0, status: code === 0 ? "completed" : "failed",
274
+ taskId, pid, syncFs: useSyncFs,
275
+ syncReady: useSyncFs && fs.existsSync(path.join(agentTaskDir, "metadata.json")),
276
+ agentDir: useSyncFs ? agentTaskDir : undefined, logPath, parentTaskId,
277
+ exitCode: code, ...(code === 0 ? {} : { error: `Agent exited with code ${code}` }),
278
+ });
262
279
  });
263
280
 
264
281
  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);
282
+ // A spawn is not the same as an initialized fs-sync agent. Poll briefly
283
+ // so callers can distinguish those states without parsing log prose.
284
+ const deadline = Date.now() + (useSyncFs ? 5000 : 100);
285
+ const acknowledge = () => {
286
+ if (settled || exited) return;
287
+ const syncReady = useSyncFs && fs.existsSync(path.join(agentTaskDir, "metadata.json"));
288
+ if (!useSyncFs || syncReady || Date.now() >= deadline) {
289
+ try { child.unref(); } catch {}
290
+ done({
291
+ success: true, status: "started", taskId, pid, syncFs: useSyncFs,
292
+ syncReady, agentDir: useSyncFs ? agentTaskDir : undefined,
293
+ logPath, parentTaskId,
294
+ });
295
+ return;
296
+ }
297
+ setTimeout(acknowledge, 100);
298
+ };
299
+ setTimeout(acknowledge, 50);
275
300
  }
276
301
  });
277
302
  }
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) {
@@ -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) {
@@ -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();
@@ -1,4 +1,4 @@
1
- import http from "../utils/http";
1
+ import http, { HTTP_UNAUTHORIZED_HANDLER, RefreshableHeaders } from "../utils/http";
2
2
  import {
3
3
  GenericClient,
4
4
  CompletionOptions,
@@ -17,16 +17,72 @@ export interface HttpClientOptions {
17
17
  extra_body?: Record<string, any>;
18
18
  }
19
19
 
20
+ /**
21
+ * Optional callback that, when set, is called whenever a 401 is received
22
+ * (reactive refresh) or whenever `needsRefresh()` returns true before a
23
+ * request (proactive refresh). Override this in a subclass or set it
24
+ * directly on an instance:
25
+ *
26
+ * client.refreshToken = async () => { const newJwt = await ...; client.setJwt(newJwt); };
27
+ */
28
+ export type RefreshTokenFn = () => Promise<void>;
29
+
20
30
  export class HttpClient implements GenericClient {
21
31
  /** Timeout in milliseconds for HTTP requests. Default: 30000 (30s). Use 0 to disable. */
22
32
  private timeout: number;
23
- private headers: Record<string, string>;
33
+ private headers: RefreshableHeaders;
24
34
  private extra_body: Record<string, any>;
25
35
  /** Optional pricing table: model id → per-million-token prices */
26
36
  private pricingMap: Record<string, ModelPricing> = {};
27
37
 
38
+ /**
39
+ * Override this to provide automatic token refresh. Called reactively on
40
+ * 401 responses (via HTTP_UNAUTHORIZED_HANDLER) and proactively before
41
+ * each request when `needsRefresh()` returns true.
42
+ *
43
+ * The implementation should obtain a new token and call `this.setJwt()`
44
+ * (or `this.setKey()`) before returning.
45
+ */
46
+ refreshToken: RefreshTokenFn | undefined = undefined;
47
+
48
+ /**
49
+ * Override this to provide proactive expiry detection. When it returns
50
+ * `true`, `refreshToken()` is called before the next outgoing request.
51
+ * The default implementation decodes the JWT `exp` claim (< 5 min window).
52
+ */
53
+ needsRefresh(): boolean {
54
+ const authHeader = (this.headers as Record<string, string>)["Authorization"];
55
+ if (!authHeader) return false;
56
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
57
+ try {
58
+ const parts = token.split(".");
59
+ if (parts.length !== 3) return false;
60
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
61
+ return typeof payload.exp === "number" && payload.exp * 1000 - Date.now() < 5 * 60 * 1000;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Call before each outgoing request to proactively refresh an expiring
69
+ * token. No-ops when no `refreshToken` hook is set.
70
+ */
71
+ private async refreshIfNeeded(): Promise<void> {
72
+ if (this.refreshToken && this.needsRefresh()) {
73
+ await this.refreshToken();
74
+ }
75
+ }
76
+
28
77
  constructor(private baseUrl: string, options: HttpClientOptions = {}) {
29
78
  this.headers = options.headers ?? {};
79
+ // Wire the reactive 401 handler so the low-level http util can retry once
80
+ // after a token refresh without callers needing to do anything extra.
81
+ Object.defineProperty(this.headers, HTTP_UNAUTHORIZED_HANDLER, {
82
+ enumerable: false,
83
+ configurable: true,
84
+ get: () => this.refreshToken ? () => this.refreshToken!() : undefined,
85
+ });
30
86
  this.timeout = options.timeout ?? 30000;
31
87
  this.extra_body = options.extra_body ?? {};
32
88
  }
@@ -69,10 +125,8 @@ export class HttpClient implements GenericClient {
69
125
  }
70
126
 
71
127
  setJwt(jwt: string) {
72
- this.headers = {
73
- ...this.headers,
74
- Authorization: `Bearer ${jwt}`,
75
- };
128
+ // Mutate in-place to preserve the HTTP_UNAUTHORIZED_HANDLER symbol property.
129
+ this.headers.Authorization = `Bearer ${jwt}`;
76
130
  }
77
131
 
78
132
  setBaseUrl(baseUrl: string) {
@@ -163,7 +217,8 @@ export class HttpClient implements GenericClient {
163
217
  if (options.timeout !== undefined) this.timeout = options.timeout;
164
218
  if (options.extra_body !== undefined) this.extra_body = options.extra_body;
165
219
  if (options.headers) {
166
- this.headers = { ...this.headers, ...options.headers };
220
+ // Mutate in-place to preserve the HTTP_UNAUTHORIZED_HANDLER symbol property.
221
+ Object.assign(this.headers, options.headers);
167
222
  }
168
223
  }
169
224
 
@@ -183,6 +238,7 @@ export class HttpClient implements GenericClient {
183
238
  async createChatCompletion(
184
239
  options: CompletionOptions
185
240
  ): Promise<CompletionResponse> {
241
+ await this.refreshIfNeeded();
186
242
  return this.withRetry(async () => {
187
243
  const body = {
188
244
  ...options,
@@ -232,6 +288,7 @@ export class HttpClient implements GenericClient {
232
288
  async *createChatCompletionStream(
233
289
  options: CompletionOptions
234
290
  ): AsyncGenerator<StreamChunk> {
291
+ await this.refreshIfNeeded();
235
292
  const body = {
236
293
  ...options,
237
294
  model: options.model,
@@ -316,6 +373,7 @@ export class HttpClient implements GenericClient {
316
373
  options: CompletionOptions,
317
374
  store = false
318
375
  ): Promise<CompletionResponse> {
376
+ await this.refreshIfNeeded();
319
377
  return this.withRetry(async () => {
320
378
  // Extract system messages as instructions
321
379
  const systemMessages = options.messages.filter((m) => m.role === "system");
@@ -484,6 +542,7 @@ export class HttpClient implements GenericClient {
484
542
  }
485
543
 
486
544
  async createEmbedding(options: EmbeddingOptions): Promise<EmbeddingResponse> {
545
+ await this.refreshIfNeeded();
487
546
  return this.withRetry(async () => {
488
547
  const response = await http.post(
489
548
  `${this.baseUrl}/v1/embeddings`,
@@ -511,6 +570,7 @@ export class HttpClient implements GenericClient {
511
570
  }
512
571
 
513
572
  async getModels(type = "all") {
573
+ await this.refreshIfNeeded();
514
574
  return this.withRetry(async () => {
515
575
  const response = await http.get(`${this.baseUrl}/v1/models?type=${type}`, {
516
576
  headers: this.headers as Record<string, string>,
@@ -528,12 +528,14 @@ export class AIClient {
528
528
  }
529
529
 
530
530
  providerHasModel(provider: string, model: string): boolean {
531
+ if (!provider) return false;
531
532
  const models = this.clientModels[provider];
532
533
  if (!models) return false;
533
534
  return models.includes(model);
534
535
  }
535
536
 
536
537
  findModel(modelPrefix: string) {
538
+ if (!modelPrefix) return undefined;
537
539
  for (const provider of Object.keys(this.clientModels)) {
538
540
  const models = this.clientModels[provider] as string[];
539
541
  const foundModel = models.find((m) => m.startsWith(modelPrefix));
@@ -35,6 +35,18 @@ import { getConfig } from "../config";
35
35
  const AGENTS_DIR = path.join(".knowhow", "processes", "agents");
36
36
  const SESSIONS_DIR = path.join(".knowhow", "chats", "sessions");
37
37
 
38
+ const TERMINAL_STATUSES = new Set([
39
+ "completed",
40
+ "failed",
41
+ "killed",
42
+ "error",
43
+ "done",
44
+ ]);
45
+ const ACTIVE_STATUSES = new Set(["running", "paused"]);
46
+ // Older task records did not persist a pid. After this grace period, report
47
+ // them as unverifiable rather than continuing to claim they are running.
48
+ const UNVERIFIED_TASK_GRACE_MS = 5 * 60 * 1000;
49
+
38
50
  // ---------------------------------------------------------------------------
39
51
  // Helpers
40
52
  // ---------------------------------------------------------------------------
@@ -68,11 +80,42 @@ function loadStatus(taskId: string): string {
68
80
  }
69
81
  }
70
82
 
71
- /** Get the canonical status — meta.status is live/preferred; status.txt is fallback */
72
- function getStatus(taskId: string, meta: any | null): string {
73
- if (meta?.status) return meta.status;
74
- const fromFile = loadStatus(taskId);
75
- return fromFile ?? "unknown";
83
+ function pidAlive(pid: number): boolean {
84
+ try {
85
+ process.kill(pid, 0);
86
+ return true;
87
+ } catch (error: any) {
88
+ // EPERM means the process exists but cannot be signalled by this user.
89
+ return error?.code === "EPERM";
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get the effective status, reconciling persisted state with process liveness.
95
+ * An agent can be terminated before AgentSyncFs gets a chance to finalize its
96
+ * files; those records must not remain "running" forever.
97
+ */
98
+ export function getStatus(taskId: string, meta: any | null): string {
99
+ const persisted = meta?.status || loadStatus(taskId) || "unknown";
100
+ if (TERMINAL_STATUSES.has(persisted)) return persisted;
101
+ if (meta?.inProgress === false) return "completed";
102
+ if (!ACTIVE_STATUSES.has(persisted)) return persisted;
103
+
104
+ const pid = meta?.pid ?? meta?.processId;
105
+ if (typeof pid === "number") {
106
+ return pidAlive(pid) ? persisted : "failed";
107
+ }
108
+
109
+ const lastKnownTime = meta?.lastUpdate ?? meta?.startTime;
110
+ const lastKnownMs = lastKnownTime ? new Date(lastKnownTime).getTime() : NaN;
111
+ if (
112
+ !Number.isFinite(lastKnownMs) ||
113
+ Date.now() - lastKnownMs > UNVERIFIED_TASK_GRACE_MS
114
+ ) {
115
+ return "stale";
116
+ }
117
+
118
+ return persisted;
76
119
  }
77
120
 
78
121
  function elapsedStr(startIso: string | undefined, endIso?: string): string {
@@ -203,9 +246,9 @@ function buildRows(includeAll: boolean): Row[] {
203
246
 
204
247
  for (const taskId of taskIds) {
205
248
  const meta = loadMeta(taskId);
206
- const status = meta?.status ?? loadStatus(taskId);
249
+ const status = getStatus(taskId, meta);
207
250
 
208
- if (!includeAll && (status === "completed" || status === "killed")) {
251
+ if (!includeAll && (TERMINAL_STATUSES.has(status) || status === "stale")) {
209
252
  const lastUpdate = meta?.lastUpdate ?? "";
210
253
  if (lastUpdate) {
211
254
  const age = Date.now() - new Date(lastUpdate).getTime();
@@ -221,7 +264,9 @@ function buildRows(includeAll: boolean): Row[] {
221
264
  : "";
222
265
  const elapsed = elapsedStr(
223
266
  meta?.startTime,
224
- meta?.status === "completed" ? meta?.lastUpdate : undefined
267
+ TERMINAL_STATUSES.has(status) || status === "stale"
268
+ ? meta?.lastUpdate
269
+ : undefined
225
270
  );
226
271
  const lastUpdate = meta?.lastUpdate
227
272
  ? new Date(meta.lastUpdate).toLocaleString()