plumb-bridge 0.1.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.
- package/bunfig.toml +2 -0
- package/package.json +40 -0
- package/src/adapters/claude.ts +109 -0
- package/src/adapters/echo.ts +50 -0
- package/src/adapters/generic.ts +45 -0
- package/src/adapters/opencode.ts +117 -0
- package/src/adapters/pi.ts +138 -0
- package/src/adapters/registry.ts +35 -0
- package/src/cli.ts +85 -0
- package/src/core/executor.ts +307 -0
- package/src/core/ledger.ts +39 -0
- package/src/core/process.ts +297 -0
- package/src/core/server.ts +83 -0
- package/src/main.ts +3 -0
- package/src/types.ts +61 -0
- package/test/conformance.test.ts +142 -0
- package/tsconfig.json +18 -0
package/bunfig.toml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plumb-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A2A bridge for CLI coding agents. Bun runtime. Example: plumb wrap cat --port 3001. Use plumb wrap opencode (not opencode run — the adapter adds run --format json).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "src/main.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"plumb": "src/main.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"test",
|
|
13
|
+
"tsconfig.json",
|
|
14
|
+
"bunfig.toml"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.1.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "bun run src/main.ts",
|
|
21
|
+
"wrap": "bun run src/main.ts wrap",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "bun test test/conformance.test.ts",
|
|
24
|
+
"prepack": "npm run typecheck",
|
|
25
|
+
"prepublishOnly": "npm run test && npm run typecheck"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@a2a-js/sdk": "^0.3.13",
|
|
29
|
+
"commander": "^14.0.3",
|
|
30
|
+
"express": "^5.2.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "latest",
|
|
34
|
+
"@types/express": "^5.0.6",
|
|
35
|
+
"typescript": "^5"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"typescript": "^5"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// PLUMB — Claude Adapter
|
|
2
|
+
// Wraps `claude --print --output-format stream-json --verbose`. Streaming JSONL.
|
|
3
|
+
// Filters system/rate-limit events. Captures assistant messages and results.
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
interface ClaudeStreamEvent {
|
|
12
|
+
type: string;
|
|
13
|
+
subtype?: string;
|
|
14
|
+
message?: {
|
|
15
|
+
content?: Array<{ type: string; text?: string }>;
|
|
16
|
+
};
|
|
17
|
+
result?: string;
|
|
18
|
+
is_error?: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ClaudeAdapter implements AgentAdapter {
|
|
24
|
+
readonly id = 'claude';
|
|
25
|
+
readonly binary = 'claude';
|
|
26
|
+
readonly tier = 1 as const;
|
|
27
|
+
readonly displayName = 'Claude';
|
|
28
|
+
readonly mode = 'oneshot' as const;
|
|
29
|
+
|
|
30
|
+
skills = [
|
|
31
|
+
{ id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write'] },
|
|
32
|
+
{ id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
|
|
33
|
+
{ id: 'read', name: 'Read files', tags: ['read', 'file'] },
|
|
34
|
+
{ id: 'web', name: 'Web search and fetch', tags: ['web', 'search', 'fetch'] },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
|
|
38
|
+
return ['--print', '--output-format', 'stream-json', '--verbose'];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
formatInput(task: AgentTask): string {
|
|
42
|
+
return task.message + '\n';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
parseLine(line: string): AdapterEvent[] {
|
|
46
|
+
if (!line.trim()) return [];
|
|
47
|
+
|
|
48
|
+
let event: ClaudeStreamEvent;
|
|
49
|
+
try {
|
|
50
|
+
event = JSON.parse(line);
|
|
51
|
+
} catch {
|
|
52
|
+
// Non-JSON line — treat as raw text
|
|
53
|
+
return [{ type: 'text-delta', text: line + '\n' }];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Filter non-content events
|
|
57
|
+
if (event.type === 'rate_limit_event') return [];
|
|
58
|
+
if (event.type === 'system') return [];
|
|
59
|
+
|
|
60
|
+
// Assistant message — extract text content
|
|
61
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
62
|
+
const texts = event.message.content
|
|
63
|
+
.filter(c => c.type === 'text' && c.text)
|
|
64
|
+
.map(c => c.text!);
|
|
65
|
+
if (texts.length > 0) {
|
|
66
|
+
return [{ type: 'text-delta', text: texts.join('\n') }];
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Result event — final output
|
|
72
|
+
if (event.type === 'result') {
|
|
73
|
+
if (event.is_error || event.error) {
|
|
74
|
+
return [{ type: 'error', message: event.error ?? 'Unknown error' }];
|
|
75
|
+
}
|
|
76
|
+
// Result text is already captured from assistant message, signal completion
|
|
77
|
+
return [{ type: 'status', state: 'completed' }];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Error event
|
|
81
|
+
if (event.type === 'error') {
|
|
82
|
+
return [{ type: 'error', message: String(event.error ?? event.message ?? 'Unknown error') }];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async detect(): Promise<DetectionResult | null> {
|
|
89
|
+
try {
|
|
90
|
+
const { stdout } = await execFileAsync('which', ['claude'], { timeout: 5000 });
|
|
91
|
+
let version = 'unknown';
|
|
92
|
+
try {
|
|
93
|
+
const { stdout: vOut } = await execFileAsync('claude', ['--version'], { timeout: 5000 });
|
|
94
|
+
version = vOut.trim().split('\n')[0] ?? 'unknown';
|
|
95
|
+
} catch {
|
|
96
|
+
// Version check failed
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
binary: 'claude',
|
|
100
|
+
version,
|
|
101
|
+
path: stdout.trim(),
|
|
102
|
+
tier: 1,
|
|
103
|
+
protocol: 'stream-json',
|
|
104
|
+
};
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// PLUMB — Echo Adapter
|
|
2
|
+
// Wraps `cat`. Proves the bridge works. Emits a real A2A task lifecycle.
|
|
3
|
+
// Every line cat echoes becomes a progress event. Exit 0 → completed.
|
|
4
|
+
// Stolen from fangai's Echo First pattern, implemented as a real CLI wrapper.
|
|
5
|
+
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
export class EchoAdapter implements AgentAdapter {
|
|
13
|
+
readonly id = 'echo';
|
|
14
|
+
readonly binary = 'cat';
|
|
15
|
+
readonly tier = 1 as const;
|
|
16
|
+
readonly displayName = 'Echo';
|
|
17
|
+
readonly mode = 'oneshot' as const;
|
|
18
|
+
|
|
19
|
+
skills = [
|
|
20
|
+
{ id: 'echo', name: 'Echo task input', tags: ['echo', 'test', 'conformance'] },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
formatInput(task: AgentTask): string {
|
|
28
|
+
return task.message + '\n';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
parseLine(line: string): AdapterEvent[] {
|
|
32
|
+
if (!line.trim()) return [];
|
|
33
|
+
return [{ type: 'text-delta', text: line + '\n' }];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async detect(): Promise<DetectionResult | null> {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFileAsync('which', ['cat'], { timeout: 5000 });
|
|
39
|
+
return {
|
|
40
|
+
binary: 'cat',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
path: stdout.trim(),
|
|
43
|
+
tier: 1,
|
|
44
|
+
protocol: 'text',
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// PLUMB — Generic Adapter
|
|
2
|
+
// Text passthrough. Wraps any CLI that reads stdin and writes to stdout.
|
|
3
|
+
// Every non-empty line becomes a progress event. Exit 0 = complete.
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export class GenericAdapter implements AgentAdapter {
|
|
12
|
+
readonly id = 'generic';
|
|
13
|
+
readonly binary = '';
|
|
14
|
+
readonly tier = 3 as const;
|
|
15
|
+
readonly displayName = 'Generic CLI';
|
|
16
|
+
readonly mode = 'oneshot' as const;
|
|
17
|
+
private cliCommand: string;
|
|
18
|
+
|
|
19
|
+
skills = [{ id: 'generic', name: 'CLI task', tags: ['code'] }];
|
|
20
|
+
|
|
21
|
+
constructor(cliCommand = '') {
|
|
22
|
+
this.cliCommand = cliCommand;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
buildArgs(_task: AgentTask, _config: PlumbConfig): string[] { return []; }
|
|
26
|
+
|
|
27
|
+
formatInput(task: AgentTask): string { return task.message + '\n'; }
|
|
28
|
+
|
|
29
|
+
parseLine(line: string): AdapterEvent[] {
|
|
30
|
+
if (!line.trim()) return [];
|
|
31
|
+
return [{ type: 'text-delta', text: line.trim() + '\n' }];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async detect(): Promise<DetectionResult | null> {
|
|
35
|
+
if (!this.cliCommand) return null;
|
|
36
|
+
try {
|
|
37
|
+
const [cmd] = this.cliCommand.trim().split(/\s+/);
|
|
38
|
+
if (!cmd) return null;
|
|
39
|
+
const { stdout } = await execFileAsync('which', [cmd], { timeout: 5000 });
|
|
40
|
+
return { binary: cmd, version: 'unknown', path: stdout.trim(), tier: 3, protocol: 'text' };
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// PLUMB — OpenCode Adapter
|
|
2
|
+
// Wraps `opencode run --format json`. JSON event stream.
|
|
3
|
+
// Filters log/status events. Captures text content and results.
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
interface OpenCodePart {
|
|
12
|
+
type?: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface OpenCodeEvent {
|
|
19
|
+
type: string;
|
|
20
|
+
content?: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
part?: OpenCodePart;
|
|
23
|
+
delta?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
message?: string;
|
|
26
|
+
result?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class OpenCodeAdapter implements AgentAdapter {
|
|
31
|
+
readonly id = 'opencode';
|
|
32
|
+
readonly binary = 'opencode';
|
|
33
|
+
readonly tier = 2 as const;
|
|
34
|
+
readonly displayName = 'OpenCode';
|
|
35
|
+
readonly mode = 'oneshot' as const;
|
|
36
|
+
|
|
37
|
+
skills = [
|
|
38
|
+
{ id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write'] },
|
|
39
|
+
{ id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
|
|
40
|
+
{ id: 'read', name: 'Read files', tags: ['read', 'file'] },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
|
|
44
|
+
return ['run', '--format', 'json'];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
formatInput(task: AgentTask): string {
|
|
48
|
+
// OpenCode reads one JSON line per stdin message with `prompt` (see fangai OpenCodeAdapter).
|
|
49
|
+
return JSON.stringify({ prompt: task.message }) + '\n';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
parseLine(line: string): AdapterEvent[] {
|
|
53
|
+
if (!line.trim()) return [];
|
|
54
|
+
|
|
55
|
+
let event: OpenCodeEvent;
|
|
56
|
+
try {
|
|
57
|
+
event = JSON.parse(line);
|
|
58
|
+
} catch {
|
|
59
|
+
// Non-JSON line — treat as raw text
|
|
60
|
+
return [{ type: 'text-delta', text: line + '\n' }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Text content — OpenCode JSON uses part.text for assistant output
|
|
64
|
+
if (event.type === 'text' || event.type === 'content' || event.type === 'text-delta') {
|
|
65
|
+
const fromPart = typeof event.part?.text === 'string' ? event.part.text : '';
|
|
66
|
+
const text = fromPart || (event.text ?? event.content ?? event.delta ?? '');
|
|
67
|
+
if (text) return [{ type: 'text-delta', text }];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Message part updated — streaming content
|
|
72
|
+
if (event.type === 'message.part.updated') {
|
|
73
|
+
const text = event.content ?? event.text ?? '';
|
|
74
|
+
if (text) return [{ type: 'text-delta', text }];
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// End of agent step — primary completion signal for `opencode run --format json`
|
|
79
|
+
if (event.type === 'step_finish' && event.part?.reason === 'stop') {
|
|
80
|
+
return [{ type: 'status', state: 'completed' }];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Session / run completed (alternate event names)
|
|
84
|
+
if (event.type === 'session.completed' || event.type === 'done' || event.type === 'complete') {
|
|
85
|
+
return [{ type: 'status', state: 'completed' }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Error events
|
|
89
|
+
if (event.type === 'error') {
|
|
90
|
+
return [{ type: 'error', message: event.error ?? event.message ?? 'Unknown error' }];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async detect(): Promise<DetectionResult | null> {
|
|
97
|
+
try {
|
|
98
|
+
const { stdout } = await execFileAsync('which', ['opencode'], { timeout: 5000 });
|
|
99
|
+
let version = 'unknown';
|
|
100
|
+
try {
|
|
101
|
+
const { stdout: vOut } = await execFileAsync('opencode', ['--version'], { timeout: 5000 });
|
|
102
|
+
version = vOut.trim().split('\n')[0] ?? 'unknown';
|
|
103
|
+
} catch {
|
|
104
|
+
// Version check failed
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
binary: 'opencode',
|
|
108
|
+
version,
|
|
109
|
+
path: stdout.trim(),
|
|
110
|
+
tier: 2,
|
|
111
|
+
protocol: 'json-stream',
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// PLUMB — Pi Adapter
|
|
2
|
+
// Wraps `pi --mode rpc --print`. JSONL protocol. Highest value adapter.
|
|
3
|
+
// Extension UI requests are filtered. Text deltas and responses are captured.
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
interface PiRpcEvent {
|
|
12
|
+
type: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
content?: string;
|
|
15
|
+
success?: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
delta?: string;
|
|
18
|
+
assistantMessageEvent?: { type?: string; delta?: string };
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class PiAdapter implements AgentAdapter {
|
|
23
|
+
readonly id = 'pi';
|
|
24
|
+
readonly binary = 'pi';
|
|
25
|
+
readonly tier = 1 as const;
|
|
26
|
+
readonly displayName = 'Pi';
|
|
27
|
+
readonly mode = 'persistent' as const;
|
|
28
|
+
|
|
29
|
+
skills = [
|
|
30
|
+
{ id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write'] },
|
|
31
|
+
{ id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
|
|
32
|
+
{ id: 'read', name: 'Read files', tags: ['read', 'file'] },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
|
|
36
|
+
return ['--mode', 'rpc', '--print', '--no-session'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
formatInput(task: AgentTask): string {
|
|
40
|
+
// Pi RPC mode expects JSONL input. Send prompt command.
|
|
41
|
+
const cmd = {
|
|
42
|
+
type: 'prompt',
|
|
43
|
+
message: task.message,
|
|
44
|
+
};
|
|
45
|
+
return JSON.stringify(cmd) + '\n';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
parseLine(line: string): AdapterEvent[] {
|
|
49
|
+
if (!line.trim()) return [];
|
|
50
|
+
|
|
51
|
+
let event: PiRpcEvent;
|
|
52
|
+
try {
|
|
53
|
+
event = JSON.parse(line);
|
|
54
|
+
} catch {
|
|
55
|
+
// Non-JSON line — treat as raw text
|
|
56
|
+
return [{ type: 'text-delta', text: line + '\n' }];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Filter extension UI requests — these are status updates, not task output
|
|
60
|
+
if (event.type === 'extension_ui_request') {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Text delta events — streaming output
|
|
65
|
+
if (event.type === 'text-delta' || event.type === 'delta') {
|
|
66
|
+
const text = event.text ?? event.delta ?? event.content ?? '';
|
|
67
|
+
if (text) return [{ type: 'text-delta', text }];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Pi message_update — streaming text from assistant
|
|
72
|
+
// Format: { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'text' } }
|
|
73
|
+
if (event.type === 'message_update') {
|
|
74
|
+
const ame = event.assistantMessageEvent as { type?: string; delta?: string } | undefined;
|
|
75
|
+
if (ame?.type === 'text_delta' && typeof ame.delta === 'string') {
|
|
76
|
+
return [{ type: 'text-delta', text: ame.delta }];
|
|
77
|
+
}
|
|
78
|
+
const text = event.text ?? event.delta ?? event.content ?? '';
|
|
79
|
+
if (text) return [{ type: 'text-delta', text }];
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Content block — full text
|
|
84
|
+
if (event.type === 'content' || event.type === 'text') {
|
|
85
|
+
const text = event.text ?? event.content ?? '';
|
|
86
|
+
if (text) return [{ type: 'text-delta', text }];
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Response event — task result
|
|
91
|
+
if (event.type === 'response') {
|
|
92
|
+
if (event.success === false && event.error) {
|
|
93
|
+
return [{ type: 'error', message: event.error }];
|
|
94
|
+
}
|
|
95
|
+
// Successful response with content
|
|
96
|
+
const text = event.text ?? event.content ?? '';
|
|
97
|
+
if (text) return [{ type: 'text-delta', text }];
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Error event
|
|
102
|
+
if (event.type === 'error') {
|
|
103
|
+
return [{ type: 'error', message: event.error ?? 'Unknown error' }];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Done/complete signals (Pi uses agent_end and turn_end)
|
|
107
|
+
if (event.type === 'done' || event.type === 'complete' || event.type === 'finished' ||
|
|
108
|
+
event.type === 'agent_end' || event.type === 'turn_end') {
|
|
109
|
+
return [{ type: 'status', state: 'completed' }];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unknown event type — log but don't emit
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async detect(): Promise<DetectionResult | null> {
|
|
117
|
+
try {
|
|
118
|
+
const { stdout } = await execFileAsync('which', ['pi'], { timeout: 5000 });
|
|
119
|
+
// Get version
|
|
120
|
+
let version = 'unknown';
|
|
121
|
+
try {
|
|
122
|
+
const { stdout: vOut } = await execFileAsync('pi', ['--version'], { timeout: 5000 });
|
|
123
|
+
version = vOut.trim().split('\n')[0] ?? 'unknown';
|
|
124
|
+
} catch {
|
|
125
|
+
// Version check failed, continue with unknown
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
binary: 'pi',
|
|
129
|
+
version,
|
|
130
|
+
path: stdout.trim(),
|
|
131
|
+
tier: 1,
|
|
132
|
+
protocol: 'jsonl-rpc',
|
|
133
|
+
};
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// PLUMB — Adapter Registry
|
|
2
|
+
// detectAdapter: returns the first adapter whose binary matches the CLI command.
|
|
3
|
+
// EchoAdapter for 'cat'. GenericAdapter for everything else.
|
|
4
|
+
// Add real adapters here as they are built (Pi, Claude, Cursor...).
|
|
5
|
+
|
|
6
|
+
import { EchoAdapter } from './echo.ts';
|
|
7
|
+
import { PiAdapter } from './pi.ts';
|
|
8
|
+
import { ClaudeAdapter } from './claude.ts';
|
|
9
|
+
import { OpenCodeAdapter } from './opencode.ts';
|
|
10
|
+
import { GenericAdapter } from './generic.ts';
|
|
11
|
+
import type { AgentAdapter } from '../types.ts';
|
|
12
|
+
|
|
13
|
+
// Priority order: first match wins. GenericAdapter always last.
|
|
14
|
+
const KNOWN_ADAPTERS: AgentAdapter[] = [
|
|
15
|
+
new EchoAdapter(),
|
|
16
|
+
new PiAdapter(),
|
|
17
|
+
new ClaudeAdapter(),
|
|
18
|
+
new OpenCodeAdapter(),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function escapeRegex(s: string): string {
|
|
22
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function detectAdapter(cli: string): AgentAdapter {
|
|
26
|
+
for (const adapter of KNOWN_ADAPTERS) {
|
|
27
|
+
if (!adapter.binary) continue;
|
|
28
|
+
// Match binary name at word boundaries — /usr/bin/cat matches 'cat', some-cat-wrapper does not.
|
|
29
|
+
const re = new RegExp('(?:^|[/\\s])' + escapeRegex(adapter.binary) + '(?:$|[/\\s])');
|
|
30
|
+
if (re.test(cli)) return adapter;
|
|
31
|
+
// Also match exact
|
|
32
|
+
if (cli.trim() === adapter.binary) return adapter;
|
|
33
|
+
}
|
|
34
|
+
return new GenericAdapter(cli);
|
|
35
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// PLUMB — CLI
|
|
2
|
+
// plumb wrap <cli> --port <n>
|
|
3
|
+
// That's the interface. Nothing else.
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import { createPlumbServer } from './core/server.ts';
|
|
8
|
+
import { detectAdapter } from './adapters/registry.ts';
|
|
9
|
+
|
|
10
|
+
function log(level: string, msg: string, data?: Record<string, unknown>): void {
|
|
11
|
+
process.stderr.write(JSON.stringify({
|
|
12
|
+
ts: new Date().toISOString(),
|
|
13
|
+
l: level,
|
|
14
|
+
m: msg,
|
|
15
|
+
...(data ?? {}),
|
|
16
|
+
}) + '\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const program = new Command()
|
|
20
|
+
.name('plumb')
|
|
21
|
+
.description('Quiet pipes for noisy agents. A2A bridge for any CLI coding agent.')
|
|
22
|
+
.version('0.1.0');
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command('wrap <cli>')
|
|
26
|
+
.description('Wrap a CLI agent as an A2A server')
|
|
27
|
+
.option('-p, --port <number>', 'Port to listen on', '3001')
|
|
28
|
+
.option('--name <name>', 'Agent name override')
|
|
29
|
+
.option('--workdir <dir>', 'Working directory for the CLI agent')
|
|
30
|
+
.option('--timeout <seconds>', 'Task timeout in seconds', '300')
|
|
31
|
+
.option('--key <apiKey>', 'Bearer token for /a2a endpoints')
|
|
32
|
+
.action((cli: string, opts: {
|
|
33
|
+
port: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
workdir?: string;
|
|
36
|
+
timeout: string;
|
|
37
|
+
key?: string;
|
|
38
|
+
}) => {
|
|
39
|
+
const port = parseInt(opts.port, 10);
|
|
40
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
41
|
+
log('error', 'invalid_port', { port: opts.port });
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const adapter = detectAdapter(cli);
|
|
46
|
+
log('info', 'adapter_detected', { cli, adapter: adapter.id, mode: adapter.mode, tier: adapter.tier });
|
|
47
|
+
|
|
48
|
+
const { executor, setupApp } = createPlumbServer({
|
|
49
|
+
adapter,
|
|
50
|
+
cli,
|
|
51
|
+
port,
|
|
52
|
+
name: opts.name,
|
|
53
|
+
workdir: opts.workdir,
|
|
54
|
+
taskTimeout: parseInt(opts.timeout, 10),
|
|
55
|
+
apiKey: opts.key,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const app = express();
|
|
59
|
+
setupApp(app);
|
|
60
|
+
|
|
61
|
+
const server = app.listen(port, () => {
|
|
62
|
+
log('info', 'plumb_listening', {
|
|
63
|
+
port,
|
|
64
|
+
adapter: adapter.id,
|
|
65
|
+
mode: adapter.mode,
|
|
66
|
+
endpoints: {
|
|
67
|
+
agentCard: `http://localhost:${port}/.well-known/agent-card.json`,
|
|
68
|
+
jsonrpc: `http://localhost:${port}/a2a/jsonrpc`,
|
|
69
|
+
rest: `http://localhost:${port}/a2a/rest`,
|
|
70
|
+
health: `http://localhost:${port}/health`,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const shutdown = async () => {
|
|
76
|
+
log('info', 'plumb_shutdown', {});
|
|
77
|
+
await executor.shutdown();
|
|
78
|
+
server.close(() => process.exit(0));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
process.on('SIGINT', shutdown);
|
|
82
|
+
process.on('SIGTERM', shutdown);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export { program };
|