@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.147",
3
+ "version": "0.0.148",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -1035,9 +1035,6 @@ export abstract class BaseAgent implements IAgent {
1035
1035
  this.updateCurrentThread(messages);
1036
1036
  const isMissingTool = this.isRequiredToolMissing();
1037
1037
 
1038
- const startIndex = 0;
1039
- const endIndex = messages.length;
1040
-
1041
1038
  // Process messages before each AI call
1042
1039
  messages = await this.messageProcessor.processMessages(
1043
1040
  messages,
@@ -1283,14 +1280,14 @@ export abstract class BaseAgent implements IAgent {
1283
1280
  : `Compressing messages: ${contextTokens} tokens exceeds ${this.getCompressThreshold()}`
1284
1281
  );
1285
1282
  this._forceCompact = false;
1286
- messages = await this.compressMessages(messages, startIndex, endIndex);
1283
+ messages = await this.compressMessages(messages);
1287
1284
  this.startNewThread(messages);
1288
1285
  // Reset the real-token tracker. compressMessages() ran a completion over
1289
- // the FULL (pre-compression) thread, so lastPromptTokens now reflects
1290
- // that large context — not the small compressed thread. Zeroing it makes
1291
- // getContextTokenCount() fall back to the whitespace estimate of the new
1292
- // (small) thread until the next real completion reports fresh usage,
1293
- // preventing an immediate false re-compaction on the following iteration.
1286
+ // the older history, so lastPromptTokens reflects the compression input
1287
+ // rather than the newly reconstructed thread. Zeroing it makes
1288
+ // getContextTokenCount() estimate the new thread until the next regular
1289
+ // completion reports fresh usage, preventing an immediate false
1290
+ // re-compaction on the following iteration.
1294
1291
  this.lastPromptTokens = 0;
1295
1292
  }
1296
1293
 
@@ -1689,15 +1686,24 @@ export abstract class BaseAgent implements IAgent {
1689
1686
  return this.taskBreakdown;
1690
1687
  }
1691
1688
 
1692
- async compressMessages(
1693
- messages: Message[],
1694
- startIndex: number,
1695
- endIndex: number
1696
- ) {
1689
+ async compressMessages(messages: Message[]) {
1690
+ // Preserve the latest agent interaction exactly. Starting the resumed thread
1691
+ // at the final assistant message keeps its tool calls, every tool response,
1692
+ // and any subsequent user messages together as one protocol-valid unit.
1693
+ let resumeIndex = messages.length - 1;
1694
+ while (
1695
+ resumeIndex >= 0 &&
1696
+ messages[resumeIndex].role !== "assistant"
1697
+ ) {
1698
+ resumeIndex--;
1699
+ }
1700
+ const compressionEnd = resumeIndex === -1 ? messages.length : resumeIndex;
1701
+ const toCompress = messages.slice(0, compressionEnd);
1702
+ const resumeMessages = messages.slice(compressionEnd);
1703
+
1697
1704
  this.log(
1698
- `Compressing messages from ${startIndex} to ${endIndex}, total messages: ${messages.length}`
1705
+ `Compressing messages from 0 to ${compressionEnd}, resuming from ${resumeIndex}, total messages: ${messages.length}`
1699
1706
  );
1700
- const toCompress = messages.slice(startIndex, endIndex);
1701
1707
  const toCompressPrompt = `We are compressing our conversation to save memory.
1702
1708
  Please summarize the conversation so far, so that we may continue the original task with a smaller context
1703
1709
 
@@ -1709,14 +1715,12 @@ export abstract class BaseAgent implements IAgent {
1709
1715
 
1710
1716
  Our initial task breakdown: ${this.taskBreakdown}
1711
1717
 
1712
- This summary will become the agent's only memory of the past, all other messages will be dropped:
1718
+ This summary will replace the older history. The latest agent interaction will remain verbatim:
1713
1719
 
1714
1720
  `;
1715
1721
 
1716
- const model = this.getModel();
1717
-
1718
1722
  const compressMessagesPayload = [
1719
- ...messages,
1723
+ ...toCompress,
1720
1724
  {
1721
1725
  role: "user",
1722
1726
  content: toCompressPrompt,
@@ -1744,12 +1748,12 @@ export abstract class BaseAgent implements IAgent {
1744
1748
  `,
1745
1749
  },
1746
1750
  ] as Message[];
1747
- const systemMesasges = toCompress.filter((m) => m.role === "system");
1751
+ const systemMessages = toCompress.filter((m) => m.role === "system");
1748
1752
 
1749
1753
  const newMessages = [
1750
- ...systemMesasges,
1754
+ ...systemMessages,
1751
1755
  ...startMessages,
1752
- ...messages.slice(endIndex),
1756
+ ...resumeMessages,
1753
1757
  ];
1754
1758
 
1755
1759
  const oldLength = this.getMessagesLength(messages);
@@ -1,17 +1,17 @@
1
- import { exec, spawn, ExecException } from "child_process";
2
- import { promisify } from "util";
3
1
  import * as fs from "fs";
4
- import * as path from "path";
5
- import * as os from "os";
2
+ import { exec } from "child_process";
3
+ import { promisify } from "util";
6
4
  import { services, ToolsService } from "../../services";
5
+ import { getProcessesDir, ManagedProcess, processDirectory, spawnManaged } from "../../processes/ProcessManager";
7
6
 
8
7
  export const execAsync = promisify(exec);
9
8
 
10
9
  export interface ExecCommandOptions {
11
- timeout?: number; // ms; -1 = wait indefinitely
12
- continueInBackground?: boolean; // allow to keep running on timeout
13
- maxBuffer?: number; // for exec()
14
- logFileName?: string; // custom log file name for background tasks (without path or extension)
10
+ timeout?: number;
11
+ continueInBackground?: boolean;
12
+ maxBuffer?: number;
13
+ logFileName?: string;
14
+ parentProcessId?: string;
15
15
  }
16
16
 
17
17
  type ExecResult = {
@@ -20,361 +20,143 @@ type ExecResult = {
20
20
  timedOut: boolean;
21
21
  killed: boolean;
22
22
  pid?: number;
23
- logPath?: string;
23
+ processId: string;
24
+ processDirectory: string;
25
+ logPath: string;
24
26
  };
25
27
 
26
- const PROCESSES_DIR = path.join(process.cwd(), ".knowhow", "processes");
27
- fs.mkdirSync(PROCESSES_DIR, { recursive: true });
28
-
29
- const STARTED_PIDS = new Set<number>();
30
- setupProcessCleanup();
31
-
32
- // ---------- utils ----------
33
- function stripTrailingAmp(cmd: string) {
34
- const t = cmd.trim();
35
- return t.endsWith("&") ? t.replace(/&\s*$/, "").trim() : t;
28
+ function stripTrailingAmp(command: string): string {
29
+ const trimmed = command.trim();
30
+ return trimmed.endsWith("&") ? trimmed.replace(/&\s*$/, "").trim() : trimmed;
36
31
  }
37
-
38
- function commandNameFrom(cmd: string) {
39
- const cleaned = stripTrailingAmp(cmd);
40
- const first = cleaned.split(/\s+/)[0] || "command";
41
- return first.replace(/[^\w.-]+/g, "_");
42
- }
43
-
44
- function makeLogPath(cmd: string, customFileName?: string) {
45
- // Use custom filename if provided, otherwise derive from command
46
- let baseName = customFileName
47
- ? customFileName.replace(/[^\w.-]+/g, "_")
48
- : commandNameFrom(cmd);
49
-
50
- let logPath = path.join(PROCESSES_DIR, `${baseName}.txt`);
51
-
52
- // If file already exists, append epoch seconds to ensure uniqueness
53
- if (fs.existsSync(logPath)) {
54
- const epochSeconds = Math.floor(Date.now() / 1000);
55
- baseName = `${baseName}_${epochSeconds}`;
56
- logPath = path.join(PROCESSES_DIR, `${baseName}.txt`);
57
- }
58
-
59
- return logPath;
60
- }
61
-
62
- function setupProcessCleanup() {
63
- const killAll = () => {
64
- for (const pid of STARTED_PIDS) {
65
- try {
66
- if (os.platform() === "win32") {
67
- spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
68
- stdio: "ignore",
69
- detached: true,
70
- }).unref();
71
- } else {
72
- try {
73
- process.kill(-pid, "SIGTERM");
74
- } catch {
75
- try {
76
- process.kill(pid, "SIGTERM");
77
- } catch {}
78
- }
79
- }
80
- } catch {}
81
- }
32
+ function readOutput(managed: ManagedProcess, maxBuffer: number): { stdout: string; stderr: string } {
33
+ const read = (file: string) => {
34
+ try {
35
+ const value = fs.readFileSync(file, "utf8");
36
+ return value.length > maxBuffer
37
+ ? `${value.slice(0, maxBuffer)}\n[output truncated after ${maxBuffer} characters]`
38
+ : value;
39
+ } catch { return ""; }
82
40
  };
83
- process.once("exit", killAll);
84
- process.once("SIGINT", () => {
85
- killAll();
86
- process.exit(130);
87
- });
88
- process.once("SIGTERM", () => {
89
- killAll();
90
- process.exit(143);
91
- });
92
- process.once("uncaughtException", (e) => {
93
- console.error(e);
94
- killAll();
95
- process.exit(1);
96
- });
97
- process.once("unhandledRejection", (e: any) => {
98
- console.error(e);
99
- killAll();
100
- process.exit(1);
101
- });
41
+ return { stdout: read(managed.stdoutPath), stderr: read(managed.stderrPath) };
42
+ }
43
+ async function stopAfterTimeout(managed: ManagedProcess): Promise<void> {
44
+ try { managed.signal("SIGTERM"); } catch {}
45
+ try { await managed.wait(3000); return; } catch {}
46
+ try { managed.signal("SIGKILL"); } catch {}
47
+ try { await managed.wait(1000); } catch {}
102
48
  }
103
49
 
104
- // ---------- core ----------
105
50
  const execWithTimeout = async (
106
51
  command: string,
107
- opts: ExecCommandOptions = {}
52
+ options: ExecCommandOptions = {}
108
53
  ): Promise<ExecResult> => {
109
- const {
110
- timeout = 5000,
111
- continueInBackground = false,
112
- maxBuffer = 1024 * 1024 * 16,
113
- } = opts;
114
-
115
- const cleaned = stripTrailingAmp(command);
116
- const shouldBg = continueInBackground || command.trim().endsWith("&");
117
-
118
- // Foreground, indefinite wait → stream with spawn
119
- if (timeout === -1 && !shouldBg) {
120
- return new Promise<ExecResult>((resolve) => {
121
- let out = "";
122
- let err = "";
123
- const child = spawn(cleaned, { shell: true });
124
- child.stdout?.on("data", (d) => (out += d.toString()));
125
- child.stderr?.on("data", (d) => (err += d.toString()));
126
- child.once("error", (e) =>
127
- resolve({
128
- stdout: out,
129
- stderr: err || String(e),
130
- timedOut: false,
131
- killed: false,
132
- pid: child.pid ?? undefined,
133
- })
134
- );
135
- child.once("exit", () =>
136
- resolve({
137
- stdout: out,
138
- stderr: err,
139
- timedOut: false,
140
- killed: false,
141
- pid: child.pid ?? undefined,
142
- })
143
- );
144
- });
54
+ const timeout = options.timeout ?? 5000;
55
+ const maxBuffer = options.maxBuffer ?? 16 * 1024 * 1024;
56
+ const background = options.continueInBackground || command.trim().endsWith("&");
57
+ let requestedId = options.logFileName?.replace(/[^a-zA-Z0-9._-]/g, "-");
58
+ if (requestedId) {
59
+ const base = requestedId;
60
+ let suffix = 0;
61
+ while (fs.existsSync(processDirectory(requestedId, getProcessesDir()))) requestedId = `${base}-${Date.now()}-${++suffix}`;
145
62
  }
63
+ const managed = await spawnManaged(stripTrailingAmp(command), [], {
64
+ id: requestedId,
65
+ shell: true,
66
+ background,
67
+ parentPid: process.pid,
68
+ parentProcessId: options.parentProcessId,
69
+ });
70
+ const initial = managed.status;
71
+ const common = {
72
+ pid: initial.pid || undefined,
73
+ processId: managed.id,
74
+ processDirectory: managed.directory,
75
+ logPath: managed.stdoutPath,
76
+ };
146
77
 
147
- if (shouldBg) {
148
- // --- BACKGROUND MODE WITH CHILD-OWNED LOG FD ---
149
- const logPath = makeLogPath(cleaned, opts.logFileName);
150
-
151
- // Open the log file now; we'll pass this FD to the child so it writes directly.
152
- // Use 'w' to truncate old logs and guarantee our header goes first.
153
- const fd = fs.openSync(logPath, "w");
154
-
155
- // Spawn detached; bind stdout/stderr to the same file.
156
- const child = spawn(cleaned, {
157
- shell: true,
158
- detached: true,
159
- stdio: ["ignore", fd, fd], // child writes directly to file for its entire lifetime
160
- });
161
-
162
- // Immediately write header (first line includes PID), then close *our* fd.
163
- // The child keeps its own duplicated handle open.
164
- const pid = child.pid!;
165
- const header =
166
- `PID: ${pid}\n` +
167
- `CMD: ${cleaned}\n` +
168
- `START: ${new Date().toISOString()}\n` +
169
- `---\n`;
170
- fs.writeSync(fd, header);
171
- fs.fsyncSync(fd); // flush header before we let go
172
- try {
173
- fs.closeSync(fd);
174
- } catch {}
175
-
176
- // We only wait 'timeout' ms to return; process keeps running/logging after that.
177
- return await new Promise<ExecResult>((resolve) => {
178
- let settled = false;
179
-
180
- const done = (res: ExecResult) => {
181
- if (settled) return;
182
- settled = true;
183
- resolve({ ...res, pid, logPath });
184
- };
185
-
186
- child.once("error", (e) =>
187
- done({
188
- stdout: "",
189
- stderr: `Failed to start command: ${String(e)}`,
190
- timedOut: false,
191
- killed: false,
192
- })
193
- );
194
-
195
- const tid = setTimeout(() => {
196
- // fully detach from our side
197
- try {
198
- child.unref();
199
- } catch {}
200
- STARTED_PIDS.add(pid);
201
- done({
202
- stdout: "",
203
- stderr:
204
- `Command timed out after ${timeout}ms but continues in background\n` +
205
- `Logs: ${logPath}\n` +
206
- `Tip: read first line for PID; kill by PID for cleanup.\n`,
207
- timedOut: true,
208
- killed: false,
209
- });
210
- }, timeout);
211
-
212
- // If it finishes early, report success and avoid “timed out” messaging
213
- child.once("exit", () => {
214
- clearTimeout(tid);
215
- done({
216
- stdout: "",
217
- stderr: `Process finished before timeout. Logs: ${logPath}\n`,
218
- timedOut: false,
219
- killed: false,
220
- });
221
- });
222
- });
78
+ if (background) {
79
+ return { stdout: "", stderr: "", timedOut: false, killed: false, ...common };
223
80
  }
224
81
 
225
- // Foreground with timeout → use exec (buffered) or switch to spawn+manual timer if you prefer
226
- return new Promise<ExecResult>((resolve) => {
227
- let out = "";
228
- let err = "";
229
- const child = exec(
230
- cleaned,
231
- { timeout, maxBuffer },
232
- (error: ExecException | null, stdout: string, stderr: string) => {
233
- out = out || stdout;
234
- err = err || stderr;
235
- if (error) {
236
- resolve({
237
- stdout: out,
238
- stderr: err || error.message,
239
- timedOut: (error as any).killed ?? false,
240
- killed: !!(error as any).killed,
241
- pid: (child as any).pid ?? undefined,
242
- });
243
- } else {
244
- resolve({
245
- stdout: out,
246
- stderr: err,
247
- timedOut: false,
248
- killed: false,
249
- pid: (child as any).pid ?? undefined,
250
- });
251
- }
252
- }
253
- );
254
- child.stdout?.on("data", (d) => (out += d.toString()));
255
- child.stderr?.on("data", (d) => (err += d.toString()));
256
- });
82
+ let timedOut = false;
83
+ if (timeout === -1) {
84
+ await managed.wait();
85
+ } else {
86
+ try { await managed.wait(timeout); }
87
+ catch {
88
+ timedOut = true;
89
+ await stopAfterTimeout(managed);
90
+ }
91
+ }
92
+ const output = readOutput(managed, maxBuffer);
93
+ return { ...output, timedOut, killed: timedOut, ...common };
257
94
  };
258
95
 
259
- // Public tool
96
+ /** Execute a shell command through the durable managed-process subsystem. */
260
97
  export async function execCommand(
261
98
  command: string,
262
99
  timeout?: number | string,
263
100
  continueInBackground?: boolean,
264
- logFileName?: string
101
+ logFileName?: string,
102
+ _ctx?: { taskId?: string }
265
103
  ): Promise<string> {
266
104
  if (!command || typeof command !== "string") {
267
105
  throw new Error("Invalid command. We received a non-string value. Please ensure you are sending strings of 4k tokens or less.");
268
106
  }
269
107
 
270
- // Get context from bound ToolsService (same pattern as writeFile)
271
- const toolService = (
272
- this instanceof ToolsService ? this : services().Tools
273
- ) as ToolsService;
274
-
275
- // Coerce timeout to a number. Agents (and some tool-call serializers) often
276
- // pass numeric arguments as strings (e.g. "120000"). Node's exec()/setTimeout
277
- // reject non-integer values with "The value of \"timeout\" is out of range.
278
- // It must be an unsigned integer", so normalize here before any use.
108
+ const toolService = (this instanceof ToolsService ? this : services().Tools) as ToolsService;
279
109
  let normalizedTimeout: number | undefined;
280
110
  const rawTimeout = typeof timeout === "string" ? Number(timeout.trim()) : timeout;
281
- if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) {
282
- normalizedTimeout = undefined;
283
- } else if (rawTimeout === -1) {
284
- // -1 is the sentinel for "wait indefinitely" — leave as-is.
285
- normalizedTimeout = -1;
286
- } else {
287
- // Clamp to a non-negative integer so the underlying APIs never throw.
288
- normalizedTimeout = Math.max(0, Math.floor(rawTimeout));
289
- }
111
+ if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) normalizedTimeout = undefined;
112
+ else if (rawTimeout === -1) normalizedTimeout = -1;
113
+ else normalizedTimeout = Math.max(0, Math.floor(rawTimeout));
290
114
 
291
- // Detect suspiciously small timeout values — agents often pass seconds instead of ms.
292
- // Any value < 100 is almost certainly a mistake (100ms is still very fast for a shell cmd).
293
- // We auto-correct to seconds and include a warning in the output so the agent learns.
294
115
  let correctedTimeout = normalizedTimeout;
295
116
  let timeoutWarning = "";
296
- if (
297
- normalizedTimeout !== undefined &&
298
- normalizedTimeout !== -1 &&
299
- normalizedTimeout < 100
300
- ) {
117
+ if (normalizedTimeout !== undefined && normalizedTimeout !== -1 && normalizedTimeout < 100) {
301
118
  correctedTimeout = normalizedTimeout * 1000;
302
- timeoutWarning =
303
- `⚠️ Warning: timeout was ${normalizedTimeout}ms which is extremely small and likely a mistake. ` +
304
- `The timeout unit is milliseconds, not seconds. ` +
305
- `Auto-corrected to ${correctedTimeout}ms (${normalizedTimeout}s).\n\n`;
119
+ timeoutWarning = `⚠️ Warning: timeout was ${normalizedTimeout}ms which is extremely small and likely a mistake. The timeout unit is milliseconds, not seconds. Auto-corrected to ${correctedTimeout}ms (${normalizedTimeout}s).\n\n`;
306
120
  }
307
121
 
308
122
  const context = toolService.getContext();
309
-
310
- // Emit pre-run blocking event — handlers can throw to block the command
311
123
  if (context.Events) {
312
- await context.Events.emitBlocking("exec:pre-run", {
313
- command,
314
- timeout,
315
- continueInBackground,
316
- logFileName,
317
- });
124
+ await context.Events.emitBlocking("exec:pre-run", { command, timeout, continueInBackground, logFileName });
318
125
  }
319
126
 
320
- const { stdout, stderr, timedOut, killed, pid, logPath } =
321
- await execWithTimeout(command, {
322
- timeout: correctedTimeout,
323
- continueInBackground,
324
- logFileName,
325
- });
127
+
128
+ const parentProcessId = process.env.KNOWHOW_MANAGED_PROCESS_ID || _ctx?.taskId;
129
+ const result = await execWithTimeout(command, {
130
+ timeout: correctedTimeout,
131
+ continueInBackground,
132
+ logFileName,
133
+ parentProcessId,
134
+ });
135
+ const { stdout, stderr, timedOut, killed, pid, processId, processDirectory, logPath } = result;
326
136
 
327
137
  let output = "";
328
- if (stderr) output += stderr + "\n";
138
+ if (stderr) output += stderr + (stderr.endsWith("\n") ? "" : "\n");
329
139
  if (stdout) output += stdout;
330
-
331
- const statusMsg = timedOut
332
- ? killed
140
+ const background = continueInBackground || command.trim().endsWith("&");
141
+ const statusMsg = background
142
+ ? ` (managed background process, id=${processId}, pid=${pid || "starting"})`
143
+ : timedOut
333
144
  ? " (killed due to timeout)"
334
- : ` (timed out, still running${pid ? `, pid=${pid}` : ""}${
335
- logPath ? `, logs=${logPath}` : ""
336
- })`
337
- : "";
338
-
339
- /*
340
- *const lines = output.split("\n");
341
- *const maxLines = 1000;
342
- *const maxChars = 40000;
343
- *const trimmed = (lines.length > maxLines ? lines.slice(0, maxLines) : lines)
344
- * .join("\n")
345
- * .slice(0, maxChars);
346
- *const trimmedMsg =
347
- * lines.length > maxLines
348
- * ? ` (${lines.length - maxLines} results trimmed)`
349
- * : "";
350
- */
351
-
352
- const result = `$ ${command}${statusMsg}\n${output}`;
145
+ : "";
146
+ if (background) {
147
+ output += `Process directory: ${processDirectory}\nStdout: ${logPath}\nStderr: ${result.processDirectory}/stderr\n`;
148
+ }
149
+ const rendered = `$ ${command}${statusMsg}\n${output}`;
353
150
 
354
- // Emit post-run blocking event — handlers can append extra context
355
151
  let eventResults: any[] = [];
356
152
  if (context.Events) {
357
153
  eventResults = await context.Events.emitBlocking("exec:post-run", {
358
- command,
359
- timeout,
360
- continueInBackground,
361
- logFileName,
362
- stdout,
363
- stderr,
364
- timedOut,
365
- killed,
366
- pid,
367
- logPath,
368
- output,
154
+ command, timeout, continueInBackground, logFileName,
155
+ stdout, stderr, timedOut, killed, pid, logPath, processId, processDirectory, output,
369
156
  });
370
157
  }
371
-
372
- // Append any additional context returned by post-run handlers
373
- let eventResultsText = "";
374
- if (eventResults && eventResults.length > 0) {
375
- eventResultsText =
376
- "\n\nAdditional Information:\n" + JSON.stringify(eventResults, null, 2);
377
- }
378
-
379
- return timeoutWarning + result + eventResultsText;
380
- };
158
+ const eventResultsText = eventResults?.length
159
+ ? "\n\nAdditional Information:\n" + JSON.stringify(eventResults, null, 2)
160
+ : "";
161
+ return timeoutWarning + rendered + eventResultsText;
162
+ }
@@ -88,7 +88,7 @@ export const includedTools = [
88
88
  function: {
89
89
  name: "execCommand",
90
90
  description:
91
- "Execute a command in the system's command line interface. Use this to run tests and things in the terminal. Supports timeout functionality. Use timeout: -1 to wait indefinitely. Commands ending with '&' or with continueInBackground=true will run in the background and write logs to .knowhow/processes/<command_name>.txt with PID in the first line for cleanup. You can optionally specify a custom log file name for background tasks.",
91
+ "Execute a command as a durable managed process. Use this to run tests and terminal tasks. Use timeout: -1 to wait indefinitely. Commands ending with '&' or with continueInBackground=true survive the calling agent and return a process ID plus a .knowhow/processes/<process-id>/ directory containing status, stdin, and output logs. Use `knowhow processes` to list, inspect, send input, attach, or stop them.",
92
92
  parameters: {
93
93
  type: "object",
94
94
  positional: true,
@@ -110,7 +110,7 @@ export const includedTools = [
110
110
  logFileName: {
111
111
  type: "string",
112
112
  description:
113
- "Optional custom log file name for background tasks (without path or extension). If not provided, a sanitized version of the command will be used. If the file already exists, epoch seconds will be appended to ensure uniqueness.",
113
+ "Optional preferred managed process ID (sanitized for use as a directory name). If it already exists, a timestamp suffix is added.",
114
114
  },
115
115
  },
116
116
  required: ["command"],