diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f866eb..b021eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # mycoder +## 0.0.14 + +### Patch Changes + +- Add new version check, use sub-agents for context more freely. + ## 0.0.13 ### Patch Changes diff --git a/package.json b/package.json index afc61d3..3dcc762 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mycoder", "description": "A command line tool using agent that can do arbitrary tasks, including coding tasks", - "version": "0.0.13", + "version": "0.0.14", "type": "module", "bin": "./bin/cli.js", "main": "./dist/index.js", @@ -52,6 +52,7 @@ "chalk": "^5", "dotenv": "^16", "source-map-support": "^0.5", + "uuid": "^11.0.5", "yargs": "^17", "yargs-file-commands": "^0.0.17", "zod": "^3", @@ -61,6 +62,7 @@ "@changesets/cli": "^2", "@eslint/js": "^9", "@types/node": "^18", + "@types/uuid": "^10.0.0", "@types/yargs": "^17", "@typescript-eslint/eslint-plugin": "^8.23.0", "@typescript-eslint/parser": "^8.23.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cd6787..d035867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: source-map-support: specifier: ^0.5 version: 0.5.21 + uuid: + specifier: ^11.0.5 + version: 11.0.5 yargs: specifier: ^17 version: 17.7.2 @@ -42,6 +45,9 @@ importers: '@types/node': specifier: ^18 version: 18.19.75 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@types/yargs': specifier: ^17 version: 17.0.33 @@ -506,6 +512,9 @@ packages: '@types/node@18.19.75': resolution: {integrity: sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1867,6 +1876,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@11.0.5: + resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} + hasBin: true + vite-node@3.0.5: resolution: {integrity: sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2432,6 +2445,8 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/uuid@10.0.0': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -3970,6 +3985,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@11.0.5: {} + vite-node@3.0.5(@types/node@18.19.75): dependencies: cac: 6.7.14 diff --git a/src/tools/getTools.ts b/src/tools/getTools.ts index 408afb3..32133b7 100644 --- a/src/tools/getTools.ts +++ b/src/tools/getTools.ts @@ -1,20 +1,23 @@ import { subAgentTool } from "../tools/interaction/subAgent.js"; -import { shellExecuteTool } from "../tools/system/shellExecute.js"; import { readFileTool } from "../tools/io/readFile.js"; import { userPromptTool } from "../tools/interaction/userPrompt.js"; import { sequenceCompleteTool } from "../tools/system/sequenceComplete.js"; import { fetchTool } from "../tools/io/fetch.js"; import { Tool } from "../core/types.js"; import { updateFileTool } from "./io/updateFile.js"; +import { shellStartTool } from "./system/shellStart.js"; +import { shellMessageTool } from "./system/shellMessage.js"; export async function getTools(): Promise { return [ subAgentTool, readFileTool, updateFileTool, - shellExecuteTool, + //shellExecuteTool, - remove for now. userPromptTool, sequenceCompleteTool, fetchTool, + shellStartTool, + shellMessageTool, ] as Tool[]; } diff --git a/src/tools/system/shellMessage.test.ts b/src/tools/system/shellMessage.test.ts new file mode 100644 index 0000000..844d2b6 --- /dev/null +++ b/src/tools/system/shellMessage.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { processStates, shellStartTool } from "./shellStart.js"; +import { MockLogger } from "../../utils/mockLogger.js"; +import { shellMessageTool } from "./shellMessage.js"; + +// eslint-disable-next-line max-lines-per-function +describe("shellMessageTool", () => { + const mockLogger = new MockLogger(); + + let testInstanceId = ""; + + beforeEach(() => { + processStates.clear(); + }); + + afterEach(() => { + for (const processState of processStates.values()) { + processState.process.kill(); + } + processStates.clear(); + }); + + it("should interact with a running process", async () => { + // Start a test process + const startResult = await shellStartTool.execute( + { + command: "cat", // cat will echo back input + description: "Test interactive process", + }, + { logger: mockLogger } + ); + + testInstanceId = startResult.instanceId; + + // Send input and get response + const result = await shellMessageTool.execute( + { + instanceId: testInstanceId, + stdin: "hello world", + description: "Test interaction", + }, + { logger: mockLogger } + ); + + expect(result.stdout).toBe("hello world"); + expect(result.stderr).toBe(""); + expect(result.completed).toBe(false); + }); + + it("should handle nonexistent process", async () => { + const result = await shellMessageTool.execute( + { + instanceId: "nonexistent-id", + description: "Test invalid process", + }, + { logger: mockLogger } + ); + + expect(result.error).toBeDefined(); + expect(result.completed).toBe(false); + }); + + it("should handle process completion", async () => { + // Start a quick process + const startResult = await shellStartTool.execute( + { + command: 'echo "test" && exit', + description: "Test completion", + }, + { logger: mockLogger } + ); + + // Wait a moment for process to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + description: "Check completion", + }, + { logger: mockLogger } + ); + + expect(result.completed).toBe(true); + // Process should still be in processStates even after completion + expect(processStates.has(startResult.instanceId)).toBe(true); + }); + + it("should handle SIGTERM signal correctly", async () => { + // Start a long-running process + const startResult = await shellStartTool.execute( + { + command: "sleep 10", + description: "Test SIGTERM handling", + }, + { logger: mockLogger } + ); + + const result = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + signal: "SIGTERM", + description: "Send SIGTERM", + }, + { logger: mockLogger } + ); + expect(result.signaled).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result2 = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + description: "Check on status", + }, + { logger: mockLogger } + ); + + expect(result2.completed).toBe(true); + expect(result2.error).toBeUndefined(); + }); + + it("should handle signals on terminated process gracefully", async () => { + // Start a process + const startResult = await shellStartTool.execute( + { + command: "sleep 1", + description: "Test signal handling on terminated process", + }, + { logger: mockLogger } + ); + + // Wait for process to complete + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Try to send signal to completed process + const result = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + signal: "SIGTERM", + description: "Send signal to terminated process", + }, + { logger: mockLogger } + ); + + expect(result.error).toBeDefined(); + expect(result.signaled).toBe(false); + expect(result.completed).toBe(true); + }); + + it("should verify signaled flag after process termination", async () => { + // Start a process + const startResult = await shellStartTool.execute( + { + command: "sleep 5", + description: "Test signal flag verification", + }, + { logger: mockLogger } + ); + + // Send SIGTERM + await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + signal: "SIGTERM", + description: "Send SIGTERM", + }, + { logger: mockLogger } + ); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Check process state after signal + const checkResult = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + description: "Check signal state", + }, + { logger: mockLogger } + ); + + expect(checkResult.signaled).toBe(true); + expect(checkResult.completed).toBe(true); + expect(processStates.has(startResult.instanceId)).toBe(true); + }); +}); diff --git a/src/tools/system/shellMessage.ts b/src/tools/system/shellMessage.ts new file mode 100644 index 0000000..801dfa5 --- /dev/null +++ b/src/tools/system/shellMessage.ts @@ -0,0 +1,204 @@ +import { Tool } from "../../core/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { processStates } from "./shellStart.js"; + +// Define valid NodeJS signals as a union type +type NodeSignals = + | "SIGABRT" + | "SIGALRM" + | "SIGBUS" + | "SIGCHLD" + | "SIGCONT" + | "SIGFPE" + | "SIGHUP" + | "SIGILL" + | "SIGINT" + | "SIGIO" + | "SIGIOT" + | "SIGKILL" + | "SIGPIPE" + | "SIGPOLL" + | "SIGPROF" + | "SIGPWR" + | "SIGQUIT" + | "SIGSEGV" + | "SIGSTKFLT" + | "SIGSTOP" + | "SIGSYS" + | "SIGTERM" + | "SIGTRAP" + | "SIGTSTP" + | "SIGTTIN" + | "SIGTTOU" + | "SIGUNUSED" + | "SIGURG" + | "SIGUSR1" + | "SIGUSR2" + | "SIGVTALRM" + | "SIGWINCH" + | "SIGXCPU" + | "SIGXFSZ"; + +const parameterSchema = z.object({ + instanceId: z.string().describe("The ID returned by shellStart"), + stdin: z.string().optional().describe("Input to send to process"), + signal: z + .enum([ + "SIGABRT", + "SIGALRM", + "SIGBUS", + "SIGCHLD", + "SIGCONT", + "SIGFPE", + "SIGHUP", + "SIGILL", + "SIGINT", + "SIGIO", + "SIGIOT", + "SIGKILL", + "SIGPIPE", + "SIGPOLL", + "SIGPROF", + "SIGPWR", + "SIGQUIT", + "SIGSEGV", + "SIGSTKFLT", + "SIGSTOP", + "SIGSYS", + "SIGTERM", + "SIGTRAP", + "SIGTSTP", + "SIGTTIN", + "SIGTTOU", + "SIGUNUSED", + "SIGURG", + "SIGUSR1", + "SIGUSR2", + "SIGVTALRM", + "SIGWINCH", + "SIGXCPU", + "SIGXFSZ", + ] as const) + .optional() + .describe("Signal to send to the process (e.g., SIGTERM, SIGINT)"), + description: z + .string() + .max(80) + .describe("The reason for this shell interaction (max 80 chars)"), +}); + +const returnSchema = z + .object({ + stdout: z.string(), + stderr: z.string(), + completed: z.boolean(), + error: z.string().optional(), + signaled: z.boolean().optional(), + }) + .describe( + "Process interaction results including stdout, stderr, and completion status" + ); + +type Parameters = z.infer; +type ReturnType = z.infer; + +export const shellMessageTool: Tool = { + name: "shellMessage", + description: + "Interacts with a running shell process, sending input and receiving output", + parameters: zodToJsonSchema(parameterSchema), + returns: zodToJsonSchema(returnSchema), + + execute: async ( + { instanceId, stdin, signal }, + { logger } + ): Promise => { + logger.verbose( + `Interacting with shell process ${instanceId}${stdin ? " with input" : ""}${signal ? ` with signal ${signal}` : ""}` + ); + + try { + const processState = processStates.get(instanceId); + if (!processState) { + throw new Error(`No process found with ID ${instanceId}`); + } + + // Send signal if provided + if (signal) { + const wasKilled = processState.process.kill(signal); + if (!wasKilled) { + return { + stdout: "", + stderr: "", + completed: processState.state.completed, + signaled: false, + error: `Failed to send signal ${signal} to process (process may have already terminated)`, + }; + } + processState.state.signaled = true; + } + + // Send input if provided + if (stdin) { + if (!processState.process.stdin?.writable) { + throw new Error("Process stdin is not available"); + } + processState.process.stdin.write(`${stdin}\n`); + } + + // Wait a brief moment for output to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Get accumulated output + const stdout = processState.stdout.join(""); + const stderr = processState.stderr.join(""); + + // Clear the buffers + processState.stdout = []; + processState.stderr = []; + + logger.verbose("Interaction completed successfully"); + if (stdout) { + logger.verbose(`stdout: ${stdout.trim()}`); + } + if (stderr) { + logger.verbose(`stderr: ${stderr.trim()}`); + } + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + completed: processState.state.completed, + signaled: processState.state.signaled, + }; + } catch (error) { + if (error instanceof Error) { + logger.verbose(`Process interaction failed: ${error.message}`); + + return { + stdout: "", + stderr: "", + completed: false, + error: error.message, + }; + } + + const errorMessage = String(error); + logger.error(`Unknown error during process interaction: ${errorMessage}`); + return { + stdout: "", + stderr: "", + completed: false, + error: `Unknown error occurred: ${errorMessage}`, + }; + } + }, + + logParameters: (input, { logger }) => { + logger.info( + `Interacting with process ${input.instanceId}, ${input.description}` + ); + }, + logReturns: () => {}, +}; diff --git a/src/tools/system/shellStart.test.ts b/src/tools/system/shellStart.test.ts new file mode 100644 index 0000000..a615b90 --- /dev/null +++ b/src/tools/system/shellStart.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { processStates, shellStartTool } from "./shellStart.js"; +import { MockLogger } from "../../utils/mockLogger.js"; + +describe("shellStartTool", () => { + const mockLogger = new MockLogger(); + + beforeEach(() => { + processStates.clear(); + }); + + afterEach(() => { + for (const processState of processStates.values()) { + processState.process.kill(); + } + processStates.clear(); + }); + + it("should start a process and return instance ID", async () => { + const result = await shellStartTool.execute( + { + command: 'echo "test"', + description: "Test process", + }, + { logger: mockLogger } + ); + + expect(result.instanceId).toBeDefined(); + expect(result.error).toBeUndefined(); + }); + + it("should handle invalid commands", async () => { + const result = await shellStartTool.execute( + { + command: "nonexistentcommand", + description: "Invalid command test", + }, + { logger: mockLogger } + ); + + expect(result.error).toBeDefined(); + }); + + it("should keep process in processStates after completion", async () => { + const result = await shellStartTool.execute( + { + command: 'echo "test"', + description: "Completion test", + }, + { logger: mockLogger } + ); + + // Wait for process to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Process should still be in processStates + expect(processStates.has(result.instanceId)).toBe(true); + }); + + it("should handle piped commands correctly", async () => { + // Start a process that uses pipes + const result = await shellStartTool.execute( + { + command: 'grep "test"', // Just grep waiting for stdin + description: "Pipe test", + }, + { logger: mockLogger } + ); + + expect(result.instanceId).toBeDefined(); + expect(result.error).toBeUndefined(); + + // Process should be in processStates + expect(processStates.has(result.instanceId)).toBe(true); + + // Get the process + const processState = processStates.get(result.instanceId); + expect(processState).toBeDefined(); + + // Write to stdin and check output + if (processState?.process.stdin) { + processState.process.stdin.write("this is a test line\n"); + processState.process.stdin.write("not matching line\n"); + processState.process.stdin.write("another test here\n"); + + // Wait for output + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Process should have filtered only lines with "test" + // This part might need adjustment based on how output is captured + } + }); +}); diff --git a/src/tools/system/shellStart.ts b/src/tools/system/shellStart.ts new file mode 100644 index 0000000..d764b15 --- /dev/null +++ b/src/tools/system/shellStart.ts @@ -0,0 +1,152 @@ +import { spawn } from "child_process"; +import type { ChildProcess } from "child_process"; +import { Tool } from "../../core/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { v4 as uuidv4 } from "uuid"; + +// Define ProcessState type +type ProcessState = { + process: ChildProcess; + + stdout: string[]; + stderr: string[]; + + state: { + completed: boolean; + signaled: boolean; + }; +}; + +// Global map to store process state + +export const processStates: Map = new Map(); + +const parameterSchema = z.object({ + command: z.string().describe("The shell command to execute"), + description: z + .string() + .max(80) + .describe("The reason this shell command is being run (max 80 chars)"), +}); + +const returnSchema = z + .object({ + instanceId: z.string(), + stdout: z.string(), + stderr: z.string(), + error: z.string().optional(), + }) + .describe("Process start results including instance ID and initial output"); + +type Parameters = z.infer; +type ReturnType = z.infer; + +export const shellStartTool: Tool = { + name: "shellStart", + description: + "Starts a long-running shell command and returns a process instance ID, progress can be checked with shellMessage command", + parameters: zodToJsonSchema(parameterSchema), + returns: zodToJsonSchema(returnSchema), + + execute: async ({ command }, { logger }): Promise => { + logger.verbose(`Starting shell command: ${command}`); + + return new Promise((resolve) => { + try { + const instanceId = uuidv4(); + let hasResolved = false; + + // Split command into command and args + // Use command directly with shell: true + const process = spawn(command, [], { shell: true }); + + const processState: ProcessState = { + process, + stdout: [], + stderr: [], + state: { completed: false, signaled: false }, + }; + + // Initialize combined process state + processStates.set(instanceId, processState); + + // Handle process events + if (process.stdout) + process.stdout.on("data", (data) => { + const output = data.toString(); + processState.stdout.push(output); + logger.verbose(`[${instanceId}] stdout: ${output.trim()}`); + }); + + if (process.stderr) + process.stderr.on("data", (data) => { + const output = data.toString(); + processState.stderr.push(output); + logger.verbose(`[${instanceId}] stderr: ${output.trim()}`); + }); + + process.on("error", (error) => { + logger.error(`[${instanceId}] Process error: ${error.message}`); + processState.state.completed = true; + if (!hasResolved) { + hasResolved = true; + resolve({ + instanceId, + stdout: processState.stdout.join("").trim(), + stderr: processState.stderr.join("").trim(), + error: error.message, + }); + } + }); + + process.on("exit", (code, signal) => { + logger.verbose( + `[${instanceId}] Process exited with code ${code} and signal ${signal}` + ); + + processState.state.completed = true; + processState.state.signaled = signal !== null; + + if (code !== 0 && !hasResolved) { + hasResolved = true; + + resolve({ + instanceId, + stdout: processState.stdout.join("").trim(), + stderr: processState.stderr.join("").trim(), + error: `Process exited with code ${code}${signal ? ` and signal ${signal}` : ""}`, + }); + } + }); + + // Set timeout to return initial results + setTimeout(() => { + if (!hasResolved) { + hasResolved = true; + resolve({ + instanceId, + stdout: processState.stdout.join("").trim(), + stderr: processState.stderr.join("").trim(), + }); + } + }, 1000); // Wait 1 second for initial output + } catch (error) { + logger.error(`Failed to start process: ${error}`); + resolve({ + instanceId: "", + stdout: "", + stderr: "", + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }, + + logParameters: (input, { logger }) => { + logger.info(`Starting "${input.command}", ${input.description}`); + }, + logReturns: (output, { logger }) => { + logger.info(`Process started with instance ID: ${output.instanceId}`); + }, +}; diff --git a/src/utils/mockLogger.ts b/src/utils/mockLogger.ts new file mode 100644 index 0000000..30cde14 --- /dev/null +++ b/src/utils/mockLogger.ts @@ -0,0 +1,13 @@ +import { Logger } from './logger.js'; + +export class MockLogger extends Logger { + constructor() { + super({ name: 'mock' }); + } + + debug(..._messages: any[]): void {} + verbose(..._messages: any[]): void {} + info(..._messages: any[]): void {} + warn(..._messages: any[]): void {} + error(..._messages: any[]): void {} +} \ No newline at end of file