@zhijiewang/openharness 1.0.0 → 1.2.0

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * A2A Protocol — Agent-to-Agent discovery and routing.
3
+ *
4
+ * Enables agents running in separate processes (or machines) to:
5
+ * - Advertise their capabilities via Agent Cards
6
+ * - Discover other agents via a shared registry
7
+ * - Route messages to agents by name or capability
8
+ * - Delegate tasks with typed request/response
9
+ *
10
+ * Registry is file-based (~/.oh/agents/) for same-machine agents.
11
+ * Each running agent writes a card file on startup and removes it on exit.
12
+ *
13
+ * Based on the emerging A2A (Agent-to-Agent) protocol standard.
14
+ */
15
+ export type AgentCard = {
16
+ /** Unique agent instance ID */
17
+ id: string;
18
+ /** Human-readable name */
19
+ name: string;
20
+ /** Agent version */
21
+ version: string;
22
+ /** What this agent can do */
23
+ capabilities: AgentCapability[];
24
+ /** How to reach this agent */
25
+ endpoint: AgentEndpoint;
26
+ /** When this card was published */
27
+ registeredAt: number;
28
+ /** PID of the agent process */
29
+ pid: number;
30
+ /** Provider and model info */
31
+ provider?: string;
32
+ model?: string;
33
+ /** Working directory */
34
+ workingDir?: string;
35
+ };
36
+ export type AgentCapability = {
37
+ /** Capability identifier (e.g., 'code-review', 'test-generation') */
38
+ name: string;
39
+ /** Human description */
40
+ description: string;
41
+ /** Input schema (JSON Schema format) */
42
+ inputSchema?: Record<string, unknown>;
43
+ /** Output schema */
44
+ outputSchema?: Record<string, unknown>;
45
+ };
46
+ export type AgentEndpoint = {
47
+ /** Transport type */
48
+ type: 'http' | 'ipc' | 'stdio';
49
+ /** Address (URL for http, socket path for ipc, pid for stdio) */
50
+ address: string;
51
+ /** Port for HTTP transport */
52
+ port?: number;
53
+ };
54
+ export type A2AMessage = {
55
+ /** Message ID */
56
+ id: string;
57
+ /** Source agent ID */
58
+ from: string;
59
+ /** Target agent ID or capability name */
60
+ to: string;
61
+ /** Message type */
62
+ type: 'task' | 'result' | 'status' | 'cancel' | 'discover';
63
+ /** Payload */
64
+ payload: A2APayload;
65
+ /** Timestamp */
66
+ timestamp: number;
67
+ };
68
+ export type A2APayload = {
69
+ kind: 'task';
70
+ capability: string;
71
+ input: unknown;
72
+ timeout?: number;
73
+ } | {
74
+ kind: 'result';
75
+ taskId: string;
76
+ output: unknown;
77
+ error?: string;
78
+ } | {
79
+ kind: 'status';
80
+ state: 'idle' | 'working' | 'done' | 'error';
81
+ progress?: string;
82
+ } | {
83
+ kind: 'cancel';
84
+ taskId: string;
85
+ reason?: string;
86
+ } | {
87
+ kind: 'discover';
88
+ filter?: {
89
+ capability?: string;
90
+ name?: string;
91
+ };
92
+ };
93
+ /** Publish an agent card to the shared registry */
94
+ export declare function publishCard(card: AgentCard): void;
95
+ /** Remove an agent card from the registry */
96
+ export declare function unpublishCard(agentId: string): void;
97
+ /** Discover all registered agents */
98
+ export declare function discoverAgents(): AgentCard[];
99
+ /** Find agents by capability name */
100
+ export declare function findAgentsByCapability(capabilityName: string): AgentCard[];
101
+ /** Find an agent by name */
102
+ export declare function findAgentByName(name: string): AgentCard | null;
103
+ /**
104
+ * Route a message to an agent.
105
+ * For HTTP endpoints: sends via fetch.
106
+ * For IPC/stdio: writes to the agent's inbox file.
107
+ */
108
+ export declare function routeMessage(message: A2AMessage): Promise<A2AMessage | null>;
109
+ /** Read pending messages from an agent's inbox */
110
+ export declare function readInbox(agentId: string): A2AMessage[];
111
+ /** Generate a unique message ID */
112
+ export declare function generateMessageId(): string;
113
+ /** Create a standard agent card for the current openHarness session */
114
+ export declare function createSessionCard(sessionId: string, opts?: {
115
+ provider?: string;
116
+ model?: string;
117
+ port?: number;
118
+ }): AgentCard;
119
+ //# sourceMappingURL=a2a.d.ts.map
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A2A Protocol — Agent-to-Agent discovery and routing.
3
+ *
4
+ * Enables agents running in separate processes (or machines) to:
5
+ * - Advertise their capabilities via Agent Cards
6
+ * - Discover other agents via a shared registry
7
+ * - Route messages to agents by name or capability
8
+ * - Delegate tasks with typed request/response
9
+ *
10
+ * Registry is file-based (~/.oh/agents/) for same-machine agents.
11
+ * Each running agent writes a card file on startup and removes it on exit.
12
+ *
13
+ * Based on the emerging A2A (Agent-to-Agent) protocol standard.
14
+ */
15
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, unlinkSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ const AGENT_REGISTRY_DIR = join(homedir(), '.oh', 'agents');
19
+ // ── Registry Operations ──
20
+ /** Publish an agent card to the shared registry */
21
+ export function publishCard(card) {
22
+ mkdirSync(AGENT_REGISTRY_DIR, { recursive: true });
23
+ const filePath = join(AGENT_REGISTRY_DIR, `${card.id}.json`);
24
+ writeFileSync(filePath, JSON.stringify(card, null, 2));
25
+ }
26
+ /** Remove an agent card from the registry */
27
+ export function unpublishCard(agentId) {
28
+ const filePath = join(AGENT_REGISTRY_DIR, `${agentId}.json`);
29
+ try {
30
+ unlinkSync(filePath);
31
+ }
32
+ catch { /* ignore */ }
33
+ }
34
+ /** Discover all registered agents */
35
+ export function discoverAgents() {
36
+ if (!existsSync(AGENT_REGISTRY_DIR))
37
+ return [];
38
+ const cards = [];
39
+ for (const file of readdirSync(AGENT_REGISTRY_DIR).filter(f => f.endsWith('.json'))) {
40
+ try {
41
+ const raw = readFileSync(join(AGENT_REGISTRY_DIR, file), 'utf-8');
42
+ const card = JSON.parse(raw);
43
+ // Check if the agent process is still alive
44
+ if (isProcessAlive(card.pid)) {
45
+ cards.push(card);
46
+ }
47
+ else {
48
+ // Stale card — clean up
49
+ try {
50
+ unlinkSync(join(AGENT_REGISTRY_DIR, file));
51
+ }
52
+ catch { /* ignore */ }
53
+ }
54
+ }
55
+ catch { /* skip malformed cards */ }
56
+ }
57
+ return cards;
58
+ }
59
+ /** Find agents by capability name */
60
+ export function findAgentsByCapability(capabilityName) {
61
+ return discoverAgents().filter(card => card.capabilities.some(c => c.name.toLowerCase() === capabilityName.toLowerCase()));
62
+ }
63
+ /** Find an agent by name */
64
+ export function findAgentByName(name) {
65
+ return discoverAgents().find(c => c.name.toLowerCase() === name.toLowerCase()) ?? null;
66
+ }
67
+ // ── Message Routing ──
68
+ /**
69
+ * Route a message to an agent.
70
+ * For HTTP endpoints: sends via fetch.
71
+ * For IPC/stdio: writes to the agent's inbox file.
72
+ */
73
+ export async function routeMessage(message) {
74
+ // Find the target agent
75
+ let targetCard = null;
76
+ // Try by agent ID first
77
+ const agents = discoverAgents();
78
+ targetCard = agents.find(a => a.id === message.to) ?? null;
79
+ // Try by name
80
+ if (!targetCard) {
81
+ targetCard = agents.find(a => a.name.toLowerCase() === message.to.toLowerCase()) ?? null;
82
+ }
83
+ // Try by capability
84
+ if (!targetCard && message.type === 'task' && message.payload.kind === 'task') {
85
+ const capable = findAgentsByCapability(message.payload.capability);
86
+ if (capable.length > 0)
87
+ targetCard = capable[0];
88
+ }
89
+ if (!targetCard)
90
+ return null;
91
+ // Route based on endpoint type
92
+ switch (targetCard.endpoint.type) {
93
+ case 'http': {
94
+ try {
95
+ const url = `${targetCard.endpoint.address}${targetCard.endpoint.port ? ':' + targetCard.endpoint.port : ''}/a2a`;
96
+ const res = await fetch(url, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(message),
100
+ signal: AbortSignal.timeout(30_000),
101
+ });
102
+ if (res.ok) {
103
+ return await res.json();
104
+ }
105
+ }
106
+ catch { /* delivery failed */ }
107
+ return null;
108
+ }
109
+ case 'ipc': {
110
+ // File-based inbox for local IPC
111
+ const inboxDir = join(AGENT_REGISTRY_DIR, 'inboxes', targetCard.id);
112
+ mkdirSync(inboxDir, { recursive: true });
113
+ const msgFile = join(inboxDir, `${message.id}.json`);
114
+ writeFileSync(msgFile, JSON.stringify(message, null, 2));
115
+ return null; // Async — no immediate response
116
+ }
117
+ default:
118
+ return null;
119
+ }
120
+ }
121
+ /** Read pending messages from an agent's inbox */
122
+ export function readInbox(agentId) {
123
+ const inboxDir = join(AGENT_REGISTRY_DIR, 'inboxes', agentId);
124
+ if (!existsSync(inboxDir))
125
+ return [];
126
+ const messages = [];
127
+ for (const file of readdirSync(inboxDir).filter(f => f.endsWith('.json'))) {
128
+ try {
129
+ const raw = readFileSync(join(inboxDir, file), 'utf-8');
130
+ messages.push(JSON.parse(raw));
131
+ // Remove after reading
132
+ unlinkSync(join(inboxDir, file));
133
+ }
134
+ catch { /* skip */ }
135
+ }
136
+ return messages.sort((a, b) => a.timestamp - b.timestamp);
137
+ }
138
+ // ── Helpers ──
139
+ /** Check if a process is still alive */
140
+ function isProcessAlive(pid) {
141
+ try {
142
+ process.kill(pid, 0); // Signal 0 = check existence only
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /** Generate a unique message ID */
150
+ export function generateMessageId() {
151
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
152
+ }
153
+ /** Create a standard agent card for the current openHarness session */
154
+ export function createSessionCard(sessionId, opts = {}) {
155
+ return {
156
+ id: `oh-${sessionId}`,
157
+ name: `openharness-${sessionId.slice(0, 6)}`,
158
+ version: '1.0.0',
159
+ capabilities: [
160
+ { name: 'code-generation', description: 'Generate, edit, and review code' },
161
+ { name: 'code-review', description: 'Review code for bugs and quality' },
162
+ { name: 'test-generation', description: 'Write tests for existing code' },
163
+ { name: 'file-operations', description: 'Read, write, search files' },
164
+ { name: 'bash-execution', description: 'Run shell commands' },
165
+ ],
166
+ endpoint: opts.port
167
+ ? { type: 'http', address: 'http://localhost', port: opts.port }
168
+ : { type: 'ipc', address: join(AGENT_REGISTRY_DIR, 'inboxes', `oh-${sessionId}`) },
169
+ registeredAt: Date.now(),
170
+ pid: process.pid,
171
+ provider: opts.provider,
172
+ model: opts.model,
173
+ workingDir: process.cwd(),
174
+ };
175
+ }
176
+ //# sourceMappingURL=a2a.js.map
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../Tool.js";
3
+ declare const inputSchema: z.ZodObject<{
4
+ steps: z.ZodArray<z.ZodObject<{
5
+ id: z.ZodString;
6
+ tool: z.ZodString;
7
+ args: z.ZodRecord<z.ZodString, z.ZodUnknown>;
8
+ dependsOn: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
9
+ }, "strip", z.ZodTypeAny, {
10
+ tool: string;
11
+ args: Record<string, unknown>;
12
+ id: string;
13
+ dependsOn?: string[] | undefined;
14
+ }, {
15
+ tool: string;
16
+ args: Record<string, unknown>;
17
+ id: string;
18
+ dependsOn?: string[] | undefined;
19
+ }>, "many">;
20
+ description: z.ZodOptional<z.ZodString>;
21
+ }, "strip", z.ZodTypeAny, {
22
+ steps: {
23
+ tool: string;
24
+ args: Record<string, unknown>;
25
+ id: string;
26
+ dependsOn?: string[] | undefined;
27
+ }[];
28
+ description?: string | undefined;
29
+ }, {
30
+ steps: {
31
+ tool: string;
32
+ args: Record<string, unknown>;
33
+ id: string;
34
+ dependsOn?: string[] | undefined;
35
+ }[];
36
+ description?: string | undefined;
37
+ }>;
38
+ export declare const PipelineTool: Tool<typeof inputSchema>;
39
+ export {};
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { PipelineExecutor, formatPipelineResults } from "../../services/PipelineExecutor.js";
3
+ const stepSchema = z.object({
4
+ id: z.string().describe("Unique step identifier"),
5
+ tool: z.string().describe("Tool name to execute (Glob, Grep, Read, Bash, etc.)"),
6
+ args: z.record(z.unknown()).describe("Tool arguments. Use $stepId to reference output of a prior step."),
7
+ dependsOn: z.array(z.string()).optional().describe("Step IDs that must complete before this step runs"),
8
+ });
9
+ const inputSchema = z.object({
10
+ steps: z.array(stepSchema).min(1).describe("Pipeline steps to execute in dependency order"),
11
+ description: z.string().optional().describe("What this pipeline does"),
12
+ });
13
+ export const PipelineTool = {
14
+ name: "Pipeline",
15
+ description: "Execute a declarative multi-step tool pipeline. Steps run in dependency order with variable substitution.",
16
+ inputSchema,
17
+ riskLevel: "medium",
18
+ isReadOnly(input) {
19
+ // Pipeline is read-only only if ALL steps use read-only tools
20
+ // Conservative: assume not read-only
21
+ return false;
22
+ },
23
+ isConcurrencySafe() {
24
+ return false;
25
+ },
26
+ async call(input, context) {
27
+ if (!context.tools) {
28
+ return { output: "Pipeline unavailable: no tools in context.", isError: true };
29
+ }
30
+ const executor = new PipelineExecutor(context.tools, context);
31
+ const results = await executor.execute(input.steps);
32
+ const summary = formatPipelineResults(results);
33
+ const hasErrors = results.some(r => r.isError);
34
+ return { output: summary, isError: hasErrors };
35
+ },
36
+ prompt() {
37
+ return `Execute a declarative multi-step tool pipeline. Each step specifies a tool and its arguments, with optional dependencies on prior steps. Use $stepId in args to reference the output of a completed step.
38
+
39
+ Example:
40
+ {
41
+ "steps": [
42
+ { "id": "find", "tool": "Glob", "args": { "pattern": "src/**/*.ts" } },
43
+ { "id": "search", "tool": "Grep", "args": { "pattern": "TODO", "path": "$find" }, "dependsOn": ["find"] }
44
+ ],
45
+ "description": "Find all TODO comments in TypeScript files"
46
+ }
47
+
48
+ Parameters:
49
+ - steps (array, required): Pipeline steps with id, tool, args, and optional dependsOn
50
+ - description (string, optional): What this pipeline does`;
51
+ },
52
+ };
53
+ //# sourceMappingURL=index.js.map
@@ -95,8 +95,8 @@ export const WebFetchTool = {
95
95
  }
96
96
  },
97
97
  prompt() {
98
- return `Fetch a URL and return its text content. Parameters:
99
- - url (string, required): The URL to fetch (http/https only).
98
+ return `Fetch a URL and return its text content. Parameters:
99
+ - url (string, required): The URL to fetch (http/https only).
100
100
  HTML tags are stripped. Output is truncated at 50K characters. Private/internal hosts are blocked for security.`;
101
101
  },
102
102
  };
package/dist/tools.js CHANGED
@@ -41,6 +41,7 @@ import { ExitWorktreeTool } from "./tools/ExitWorktreeTool/index.js";
41
41
  import { KillProcessTool } from "./tools/KillProcessTool/index.js";
42
42
  import { RemoteTriggerTool } from "./tools/RemoteTriggerTool/index.js";
43
43
  import { MultiEditTool } from "./tools/MultiEditTool/index.js";
44
+ import { PipelineTool } from "./tools/PipelineTool/index.js";
44
45
  /**
45
46
  * Returns all registered tools.
46
47
  *
@@ -71,6 +72,8 @@ export function getAllTools() {
71
72
  ExitPlanModeTool,
72
73
  // Tool Discovery
73
74
  ToolSearchTool,
75
+ // Pipelines
76
+ PipelineTool,
74
77
  // Memory management
75
78
  MemoryTool,
76
79
  ];
package/package.json CHANGED
@@ -1,73 +1,73 @@
1
- {
2
- "name": "@zhijiewang/openharness",
3
- "version": "1.0.0",
4
- "description": "Open-source terminal coding agent. Works with any LLM.",
5
- "type": "module",
6
- "bin": {
7
- "openharness": "./dist/main.js",
8
- "oh": "./dist/main.js"
9
- },
10
- "main": "./dist/main.js",
11
- "files": [
12
- "dist/**/*.js",
13
- "dist/**/*.d.ts",
14
- "!dist/**/*.test.*",
15
- "!dist/**/test-helpers.*",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "scripts": {
20
- "dev": "tsx src/main.tsx",
21
- "build": "tsc",
22
- "prepare": "tsc",
23
- "prepublishOnly": "npm run build",
24
- "test": "node scripts/test.mjs",
25
- "test:coverage": "node scripts/coverage.mjs",
26
- "typecheck": "tsc --noEmit",
27
- "start": "node dist/main.js"
28
- },
29
- "dependencies": {
30
- "@types/marked": "^5.0.2",
31
- "chalk": "^5.4.1",
32
- "commander": "^13.0.0",
33
- "ink": "^5.2.0",
34
- "ink-spinner": "^5.0.0",
35
- "ink-text-input": "^6.0.0",
36
- "marked": "^17.0.5",
37
- "react": "^18.3.1",
38
- "yaml": "^2.7.0",
39
- "zod": "^3.24.0"
40
- },
41
- "devDependencies": {
42
- "@types/node": "^22.0.0",
43
- "@types/react": "^18.3.0",
44
- "c8": "^11.0.0",
45
- "sharp": "^0.34.5",
46
- "tsx": "^4.19.0",
47
- "typescript": "^5.8.0"
48
- },
49
- "engines": {
50
- "node": ">=18.0.0"
51
- },
52
- "keywords": [
53
- "ai",
54
- "agent",
55
- "llm",
56
- "cli",
57
- "coding-agent",
58
- "terminal",
59
- "coding-assistant",
60
- "ollama",
61
- "openai",
62
- "anthropic"
63
- ],
64
- "license": "MIT",
65
- "repository": {
66
- "type": "git",
67
- "url": "https://github.com/zhijiewong/openharness"
68
- },
69
- "bugs": {
70
- "url": "https://github.com/zhijiewong/openharness/issues"
71
- },
72
- "homepage": "https://github.com/zhijiewong/openharness#readme"
73
- }
1
+ {
2
+ "name": "@zhijiewang/openharness",
3
+ "version": "1.2.0",
4
+ "description": "Open-source terminal coding agent. Works with any LLM.",
5
+ "type": "module",
6
+ "bin": {
7
+ "openharness": "./dist/main.js",
8
+ "oh": "./dist/main.js"
9
+ },
10
+ "main": "./dist/main.js",
11
+ "files": [
12
+ "dist/**/*.js",
13
+ "dist/**/*.d.ts",
14
+ "!dist/**/*.test.*",
15
+ "!dist/**/test-helpers.*",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "dev": "tsx src/main.tsx",
21
+ "build": "tsc",
22
+ "prepare": "tsc",
23
+ "prepublishOnly": "npm run build",
24
+ "test": "node scripts/test.mjs",
25
+ "test:coverage": "node scripts/coverage.mjs",
26
+ "typecheck": "tsc --noEmit",
27
+ "start": "node dist/main.js"
28
+ },
29
+ "dependencies": {
30
+ "@types/marked": "^5.0.2",
31
+ "chalk": "^5.4.1",
32
+ "commander": "^13.0.0",
33
+ "ink": "^5.2.0",
34
+ "ink-spinner": "^5.0.0",
35
+ "ink-text-input": "^6.0.0",
36
+ "marked": "^17.0.5",
37
+ "react": "^18.3.1",
38
+ "yaml": "^2.7.0",
39
+ "zod": "^3.24.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "@types/react": "^18.3.0",
44
+ "c8": "^11.0.0",
45
+ "sharp": "^0.34.5",
46
+ "tsx": "^4.19.0",
47
+ "typescript": "^5.8.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ },
52
+ "keywords": [
53
+ "ai",
54
+ "agent",
55
+ "llm",
56
+ "cli",
57
+ "coding-agent",
58
+ "terminal",
59
+ "coding-assistant",
60
+ "ollama",
61
+ "openai",
62
+ "anthropic"
63
+ ],
64
+ "license": "MIT",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "https://github.com/zhijiewong/openharness"
68
+ },
69
+ "bugs": {
70
+ "url": "https://github.com/zhijiewong/openharness/issues"
71
+ },
72
+ "homepage": "https://github.com/zhijiewong/openharness#readme"
73
+ }