plumb-bridge 0.1.2 → 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kariem Seiam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ ```
2
+ ____ _ _
3
+ | _ \| |_ _ _ __ ___ | |__
4
+ | |_) | | | | | '_ ` _ \| '_ \
5
+ | __/| | |_| | | | | | | |_) |
6
+ |_| |_|\__,_|_| |_| |_|_.__/
7
+ ```
8
+
9
+ **Quiet pipes for noisy agents.**
10
+
11
+ [![CI](https://github.com/kariemSeiam/plumb/actions/workflows/ci.yml/badge.svg)](https://github.com/kariemSeiam/plumb/actions/workflows/ci.yml)
12
+ [![npm](https://img.shields.io/npm/v/plumb-bridge)](https://www.npmjs.com/package/plumb-bridge)
13
+ [![License: MIT](https://img.shields.io/badge/License-MIT-brass.svg)](./LICENSE)
14
+
15
+ ---
16
+
17
+ Plumb wraps any CLI coding agent into an [A2A](https://google.github.io/A2A/)-compliant HTTP server in one command.
18
+
19
+ ```
20
+ Orchestrator → HTTP/JSON-RPC → Plumb → stdin/stdout → CLI agent → stream parse → A2A events
21
+ ```
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ bun add -g plumb-bridge
27
+ ```
28
+
29
+ Requires [Bun](https://bun.sh) >= 1.1.0.
30
+
31
+ ## Quick Start
32
+
33
+ ```bash
34
+ # Wrap any CLI as an A2A agent
35
+ plumb wrap cat --port 3001 # Echo (conformance gate)
36
+ plumb wrap "pi --mode rpc" --port 3002 # Pi (persistent JSONL-RPC)
37
+ plumb wrap claude --port 3000 # Claude Code (stream-json)
38
+ plumb wrap cursor-agent --port 3003 # Cursor (stream-json)
39
+ plumb wrap opencode --port 3002 # OpenCode (json-stream)
40
+ plumb wrap venom --port 3004 # VENOM (stream-json)
41
+ plumb wrap wolfy --port 3007 # Wolfy (persistent, PI_CODING_AGENT_DIR)
42
+ plumb wrap "./my-tool" --port 3005 # Generic (any CLI)
43
+ ```
44
+
45
+ Once running:
46
+
47
+ ```bash
48
+ # Agent Card (public, unauthenticated)
49
+ curl http://localhost:3001/.well-known/agent-card.json
50
+
51
+ # Health check
52
+ curl http://localhost:3001/health
53
+
54
+ # Send a task via JSON-RPC
55
+ curl -X POST http://localhost:3001/a2a/jsonrpc \
56
+ -H "Content-Type: application/json" \
57
+ -d '{
58
+ "jsonrpc": "2.0",
59
+ "method": "message/send",
60
+ "params": {
61
+ "message": {
62
+ "messageId": "msg-001",
63
+ "role": "user",
64
+ "parts": [{ "kind": "text", "text": "Hello from Plumb" }]
65
+ }
66
+ },
67
+ "id": "req-1"
68
+ }'
69
+ ```
70
+
71
+ ## Fleet Mode
72
+
73
+ Define multiple agents in `plumb.yaml`:
74
+
75
+ ```yaml
76
+ version: "1"
77
+ agents:
78
+ - id: pi
79
+ cli: pi
80
+ port: 3001
81
+ mode: persistent
82
+ - id: claude
83
+ cli: claude
84
+ port: 3002
85
+ - id: cursor
86
+ cli: cursor-agent --print
87
+ port: 3003
88
+ ```
89
+
90
+ ```bash
91
+ plumb fleet validate # Check config
92
+ plumb fleet up # Boot all agents
93
+ plumb fleet status # Health check all
94
+ ```
95
+
96
+ ## Adapters
97
+
98
+ | Adapter | CLI | Mode | Tier | Protocol |
99
+ |-----------|-----------------|------------|------|--------------|
100
+ | Echo | `cat` | oneshot | 1 | text |
101
+ | Pi | `pi` | persistent | 1 | jsonl-rpc |
102
+ | Wolfy 🐺 | `wolfy` | persistent | 1 | jsonl-rpc |
103
+ | Claude | `claude` | oneshot | 1 | stream-json |
104
+ | Cursor | `cursor-agent` | oneshot | 1 | stream-json |
105
+ | OpenCode | `opencode` | oneshot | 2 | json-stream |
106
+ | VENOM | `venom` | oneshot | 3 | stream-json |
107
+ | Generic | any | oneshot | 3 | text |
108
+
109
+ Adapters implement one interface: `buildArgs`, `formatInput`, `parseLine`, `detect`. Registry matches by binary name. Generic is the implicit fallback.
110
+
111
+ ## Auth
112
+
113
+ ```bash
114
+ plumb wrap claude --port 3003 --key my-secret-token
115
+ ```
116
+
117
+ When `--key` is set, `/a2a/*` endpoints require `Authorization: Bearer <key>`. Agent Card and health remain public per A2A spec.
118
+
119
+ ## Ledger
120
+
121
+ Every task lifecycle event is appended to `.plumb/ledger/{YYYY-MM-DD}.jsonl`:
122
+
123
+ ```jsonl
124
+ {"type":"task_submitted","taskId":"abc","cli":"cat","message":"hello","timestamp":"..."}
125
+ {"type":"task_running","taskId":"abc","timestamp":"..."}
126
+ {"type":"progress","taskId":"abc","text":"hello\n","timestamp":"..."}
127
+ {"type":"task_completed","taskId":"abc","timestamp":"..."}
128
+ ```
129
+
130
+ Append-only. Never modified. Query with `jq`. Crash-survivable.
131
+
132
+ ```bash
133
+ # Failed tasks today
134
+ jq 'select(.type=="task_failed") | {taskId, error}' \
135
+ .plumb/ledger/$(date +%Y-%m-%d).jsonl
136
+
137
+ # Reconstruct output for a task
138
+ jq -r 'select(.type=="progress" and .taskId=="<id>") | .text' \
139
+ .plumb/ledger/$(date +%Y-%m-%d).jsonl
140
+ ```
141
+
142
+ ## Architecture
143
+
144
+ ```
145
+ src/
146
+ types.ts Core interfaces (AgentTask, AdapterEvent, AgentAdapter, LedgerEvent)
147
+ cli.ts CLI: plumb wrap <cli> --port <n>, fleet commands
148
+ main.ts Entry point
149
+ adapters/
150
+ stream-json.ts Shared parseLine utilities
151
+ echo.ts EchoAdapter (cat) — conformance gate
152
+ pi.ts PiAdapter — persistent JSONL-RPC
153
+ claude.ts ClaudeAdapter — stream-json
154
+ cursor.ts CursorAdapter — stream-json + session tracking
155
+ opencode.ts OpenCodeAdapter — json-stream
156
+ venom.ts VenomAdapter — stream-json
157
+ wolfy.ts WolfyAdapter — persistent JSONL-RPC, PI_CODING_AGENT_DIR
158
+ generic.ts GenericAdapter — text passthrough
159
+ registry.ts detectAdapter() — binary matching
160
+ core/
161
+ ledger.ts Append-only JSONL
162
+ process.ts ProcessManager + PersistentProcess
163
+ executor.ts PlumbExecutor (A2A AgentExecutor)
164
+ server.ts Express + @a2a-js/sdk
165
+ task-store.ts LRU + TTL bounded task store
166
+ session-store.ts Cursor multi-turn session tracking
167
+ ```
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ bun install # Install dependencies
173
+ bun test # Run all tests (90 tests)
174
+ bun run typecheck # TypeScript type checking
175
+ bun run src/main.ts wrap cat --port 3001 # Run locally
176
+ ```
177
+
178
+ ## Protocol Surface
179
+
180
+ | Method | Path | Auth |
181
+ |--------|------|------|
182
+ | GET | `/.well-known/agent-card.json` | public |
183
+ | GET | `/health` | public |
184
+ | POST | `/a2a/jsonrpc` | Bearer (if configured) |
185
+ | * | `/a2a/rest` | Bearer (if configured) |
186
+
187
+ ## License
188
+
189
+ [MIT](./LICENSE)
190
+
191
+ ---
192
+
193
+ *The plumb bob hangs true because gravity is not negotiable. The protocol gap is not negotiable either.*
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "plumb-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
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
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/kariemSeiam/plumb.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/kariemSeiam/plumb/issues"
11
+ },
12
+ "homepage": "https://github.com/kariemSeiam/plumb#readme",
13
+ "author": "Kariem Seiam <kariemSeiam@users.noreply.github.com>",
14
+ "license": "MIT",
5
15
  "type": "module",
6
16
  "module": "src/main.ts",
7
17
  "bin": {
@@ -12,7 +22,9 @@
12
22
  "src",
13
23
  "test",
14
24
  "tsconfig.json",
15
- "bunfig.toml"
25
+ "bunfig.toml",
26
+ "README.md",
27
+ "LICENSE"
16
28
  ],
17
29
  "engines": {
18
30
  "bun": ">=1.1.0"
@@ -28,11 +40,13 @@
28
40
  "dependencies": {
29
41
  "@a2a-js/sdk": "^0.3.13",
30
42
  "commander": "^14.0.3",
31
- "express": "^5.2.1"
43
+ "express": "^5.2.1",
44
+ "js-yaml": "^4.1.1"
32
45
  },
33
46
  "devDependencies": {
34
47
  "@types/bun": "latest",
35
48
  "@types/express": "^5.0.6",
49
+ "@types/js-yaml": "^4.0.9",
36
50
  "typescript": "^5"
37
51
  },
38
52
  "peerDependencies": {
@@ -1,24 +1,14 @@
1
1
  // PLUMB — Claude Adapter
2
2
  // Wraps `claude --print --output-format stream-json --verbose`. Streaming JSONL.
3
3
  // Filters system/rate-limit events. Captures assistant messages and results.
4
+ // Uses shared stream-json parser for content extraction.
4
5
 
5
6
  import { execFile } from 'node:child_process';
6
7
  import { promisify } from 'node:util';
7
8
  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
- }
9
+ import { tryParseLine, extractContentText, textDelta, statusEvent, errorEvent } from './stream-json.ts';
10
+ import type { ContentBlockEvent } from './stream-json.ts';
11
+ import { detectBinary } from './detect.ts';
22
12
 
23
13
  export class ClaudeAdapter implements AgentAdapter {
24
14
  readonly id = 'claude';
@@ -43,67 +33,36 @@ export class ClaudeAdapter implements AgentAdapter {
43
33
  }
44
34
 
45
35
  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' }];
36
+ const { json, raw } = tryParseLine(line);
37
+ if (!json) {
38
+ if (!raw) return [];
39
+ return [textDelta(raw + '\n')];
54
40
  }
55
41
 
56
42
  // Filter non-content events
57
- if (event.type === 'rate_limit_event') return [];
58
- if (event.type === 'system') return [];
43
+ if (json.type === 'rate_limit_event' || json.type === 'system') return [];
59
44
 
60
45
  // 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 [];
46
+ if (json.type === 'assistant') {
47
+ const extracted = extractContentText(json as ContentBlockEvent);
48
+ return extracted ? [textDelta(extracted)] : [];
69
49
  }
70
50
 
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' }];
51
+ if (json.type === 'result') {
52
+ if (json.is_error || json.error) {
53
+ return [errorEvent(json.error ?? 'Unknown error')];
75
54
  }
76
- // Result text is already captured from assistant message, signal completion
77
- return [{ type: 'status', state: 'completed' }];
55
+ return [statusEvent('completed')];
78
56
  }
79
57
 
80
- // Error event
81
- if (event.type === 'error') {
82
- return [{ type: 'error', message: String(event.error ?? event.message ?? 'Unknown error') }];
58
+ if (json.type === 'error') {
59
+ return [errorEvent(String(json.error ?? json.message ?? 'Unknown error'))];
83
60
  }
84
61
 
85
62
  return [];
86
63
  }
87
64
 
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
- }
65
+ detect(): Promise<DetectionResult | null> {
66
+ return detectBinary('claude', 1, 'stream-json');
108
67
  }
109
- }
68
+ }
@@ -0,0 +1,213 @@
1
+ // PLUMB — Cursor Adapter
2
+ // Wraps `cursor-agent --print --output-format stream-json`.
3
+ // Multi-turn session tracking with --continue / cold recap.
4
+
5
+ import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
6
+ import { CursorSessionStore } from '../core/session-store.ts';
7
+ import { tryParseLine, extractContentText, isConsolidatedAssistant, textDelta, statusEvent, errorEvent } from './stream-json.ts';
8
+ import type { ContentBlockEvent } from './stream-json.ts';
9
+ import { detectBinary } from './detect.ts';
10
+
11
+ export interface CursorAdapterOptions {
12
+ defaultModel?: string;
13
+ streamPartial?: boolean;
14
+ yolo?: boolean;
15
+ trust?: boolean;
16
+ maxSessionTurns?: number;
17
+ sessionStore?: CursorSessionStore;
18
+ sessionTtlMs?: number | null;
19
+ recapMaxTurns?: number;
20
+ recapMaxCharsPerLeg?: number;
21
+ }
22
+
23
+ export class CursorAdapter implements AgentAdapter {
24
+ readonly id = 'cursor';
25
+ readonly binary = 'cursor-agent';
26
+ readonly tier = 1 as const;
27
+ readonly displayName = 'Cursor';
28
+ readonly mode = 'oneshot' as const;
29
+
30
+ streamPartial: boolean;
31
+ readonly yolo: boolean;
32
+ readonly trust: boolean;
33
+ readonly maxSessionTurns: number;
34
+ readonly defaultModel: string;
35
+
36
+ // Model routing table — VENOM routes by task label → best model per job
37
+ readonly modelMap: Record<string, string> = {
38
+ build: 'gpt-5.3-codex-high-fast',
39
+ implement: 'gpt-5.3-codex-high-fast',
40
+ research: 'claude-opus-4-7-thinking-high',
41
+ deep: 'claude-opus-4-7-max-thinking-fast',
42
+ review: 'claude-4.5-opus-high',
43
+ audit: 'claude-opus-4-7-high',
44
+ fast: 'composer-2.5-fast',
45
+ default: 'composer-2.5-fast',
46
+ nano: 'gpt-5.4-nano-high',
47
+ plan: 'composer-2.5',
48
+ ask: 'composer-2.5-fast',
49
+ };
50
+
51
+ readonly sessionStore: CursorSessionStore;
52
+
53
+ private taskAssistantForRecap = '';
54
+ private lastUserMessage = '';
55
+
56
+ skills = [
57
+ { id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write', 'composer'] },
58
+ { id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
59
+ { id: 'read', name: 'Read files', tags: ['read', 'file'] },
60
+ { id: 'plan', name: 'Plan mode', tags: ['plan', 'architecture'] },
61
+ ];
62
+
63
+ constructor(opts: CursorAdapterOptions = {}) {
64
+ this.streamPartial = opts.streamPartial ?? false;
65
+ this.yolo = opts.yolo ?? true;
66
+ this.trust = opts.trust ?? true;
67
+ this.maxSessionTurns = opts.maxSessionTurns ?? 50;
68
+ this.defaultModel = opts.defaultModel ?? 'composer-2.5-fast';
69
+ this.sessionStore = opts.sessionStore ?? new CursorSessionStore({
70
+ sessionTtlMs: opts.sessionTtlMs,
71
+ recapMaxTurns: opts.recapMaxTurns,
72
+ recapMaxCharsPerLeg: opts.recapMaxCharsPerLeg,
73
+ });
74
+ }
75
+
76
+ buildArgs(task: AgentTask, config: PlumbConfig): string[] {
77
+ const args: string[] = ['--print', '--output-format', 'stream-json'];
78
+
79
+ if (this.streamPartial) args.push('--stream-partial-output');
80
+ if (this.yolo) args.push('--yolo');
81
+ if (this.trust) args.push('--trust');
82
+
83
+ // Route by task label → best model for job, else default
84
+ const labels = (task.context?.labels as string[]) ?? [];
85
+ let selectedModel = this.defaultModel;
86
+ for (const label of labels) {
87
+ if (this.modelMap[label]) {
88
+ selectedModel = this.modelMap[label];
89
+ break;
90
+ }
91
+ }
92
+ // Explicit model override wins
93
+ const model = (task.context?.metadata?.model as string) ?? selectedModel;
94
+ args.push('--model', model);
95
+
96
+ const workspace = task.context?.workdir ?? config.workdir;
97
+ if (workspace) args.push('--workspace', workspace);
98
+
99
+ const apiKey = process.env.CURSOR_API_KEY;
100
+ if (apiKey) args.push('--api-key', apiKey);
101
+
102
+ this.sessionStore.expireLastSessionIfStale();
103
+
104
+ const resumeSession = task.context?.metadata?.resumeSession as string | undefined;
105
+ const continueLast = task.context?.metadata?.continueLast as boolean | undefined;
106
+ const newChat = task.context?.metadata?.newChat as boolean | undefined;
107
+
108
+ if (resumeSession) {
109
+ args.push('--resume', resumeSession);
110
+ } else if (continueLast && this.sessionStore.lastSession) {
111
+ args.push('--continue');
112
+ } else if (!newChat && this.sessionStore.lastSession) {
113
+ const session = this.sessionStore.get(this.sessionStore.lastSession);
114
+ if (session && session.turnCount < this.maxSessionTurns) {
115
+ args.push('--continue');
116
+ }
117
+ }
118
+
119
+ if (task.context?.metadata?.planMode) {
120
+ args.push('--plan');
121
+ }
122
+
123
+ return args;
124
+ }
125
+
126
+ formatInput(task: AgentTask): string {
127
+ this.taskAssistantForRecap = '';
128
+ const newChat = task.context?.metadata?.newChat === true;
129
+ let recap: string | null = null;
130
+ if (newChat) {
131
+ this.sessionStore.consumeColdRecap();
132
+ } else {
133
+ recap = this.sessionStore.consumeColdRecap();
134
+ }
135
+ const body = `${task.message}\n`;
136
+ return recap ? `${recap}${body}` : body;
137
+ }
138
+
139
+ parseLine(line: string): AdapterEvent[] {
140
+ const { json, raw } = tryParseLine(line);
141
+ if (!json) {
142
+ if (!raw) return [];
143
+ return [textDelta(raw + '\n')];
144
+ }
145
+
146
+ if (json.type === 'system') {
147
+ if (typeof json.session_id === 'string') {
148
+ this.sessionStore.register(
149
+ json.session_id,
150
+ typeof json.cwd === 'string' ? json.cwd : '',
151
+ typeof json.model === 'string' ? json.model : '',
152
+ );
153
+ }
154
+ return [];
155
+ }
156
+
157
+ if (json.type === 'user') return [];
158
+
159
+ if (json.type === 'thinking' && typeof (json as Record<string, unknown>).text === 'string') {
160
+ return [textDelta((json as Record<string, unknown>).text as string)];
161
+ }
162
+
163
+ if (json.type === 'assistant') {
164
+ const contentEvent = json as ContentBlockEvent;
165
+ if (isConsolidatedAssistant(contentEvent, this.streamPartial)) return [];
166
+ const extracted = extractContentText(contentEvent);
167
+ if (extracted) this.taskAssistantForRecap += extracted;
168
+ return extracted ? [textDelta(extracted)] : [];
169
+ }
170
+
171
+ if (json.type === 'tool_call') {
172
+ const tc = (json as Record<string, unknown>).tool_call as { shellToolCall?: { args?: Record<string, unknown>; result?: string } } | undefined;
173
+ if (tc?.shellToolCall) {
174
+ return [{ type: 'tool-call', tool: 'shell', input: tc.shellToolCall.args ?? {} }];
175
+ }
176
+ return [];
177
+ }
178
+
179
+ if (json.type === 'result') {
180
+ if (json.subtype === 'error' || json.is_error) {
181
+ this.resetRecapTaskState();
182
+ return [errorEvent(String(json.error ?? 'Cursor execution failed'))];
183
+ }
184
+
185
+ this.sessionStore.recordCompletedTurn(
186
+ this.sessionStore.lastSession,
187
+ this.lastUserMessage,
188
+ this.taskAssistantForRecap,
189
+ );
190
+ this.resetRecapTaskState();
191
+ return [statusEvent('completed')];
192
+ }
193
+
194
+ if (json.type === 'error') {
195
+ this.resetRecapTaskState();
196
+ return [errorEvent(String(json.error ?? json.message ?? 'Unknown error'))];
197
+ }
198
+
199
+ return [];
200
+ }
201
+
202
+ detect(): Promise<DetectionResult | null> {
203
+ return detectBinary('cursor-agent', 1, 'stream-json');
204
+ }
205
+
206
+ setUserMessage(msg: string): void {
207
+ this.lastUserMessage = msg;
208
+ }
209
+
210
+ private resetRecapTaskState(): void {
211
+ this.taskAssistantForRecap = '';
212
+ }
213
+ }
@@ -0,0 +1,27 @@
1
+ // PLUMB — Shared binary detection utility
2
+ // All adapters use the same `which` + `--version` pattern. DRY it.
3
+
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import type { DetectionResult } from '../types.ts';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export async function detectBinary(
11
+ name: string,
12
+ tier: 1 | 2 | 3,
13
+ protocol: string,
14
+ versionArgs: string[] = ['--version'],
15
+ ): Promise<DetectionResult | null> {
16
+ try {
17
+ const { stdout } = await execFileAsync('which', [name], { timeout: 5000 });
18
+ let version = 'unknown';
19
+ try {
20
+ const { stdout: vOut } = await execFileAsync(name, versionArgs, { timeout: 5000 });
21
+ version = vOut.trim().split('\n')[0] ?? 'unknown';
22
+ } catch { /* version check failed */ }
23
+ return { binary: name, version, path: stdout.trim(), tier, protocol };
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
@@ -1,13 +1,8 @@
1
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.
2
+ // Wraps `cat`. Proves the bridge works. Every line becomes a progress event.
5
3
 
6
- import { execFile } from 'node:child_process';
7
- import { promisify } from 'node:util';
8
4
  import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
9
-
10
- const execFileAsync = promisify(execFile);
5
+ import { detectBinary } from './detect.ts';
11
6
 
12
7
  export class EchoAdapter implements AgentAdapter {
13
8
  readonly id = 'echo';
@@ -20,9 +15,7 @@ export class EchoAdapter implements AgentAdapter {
20
15
  { id: 'echo', name: 'Echo task input', tags: ['echo', 'test', 'conformance'] },
21
16
  ];
22
17
 
23
- buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
24
- return [];
25
- }
18
+ buildArgs(_task: AgentTask, _config: PlumbConfig): string[] { return []; }
26
19
 
27
20
  formatInput(task: AgentTask): string {
28
21
  return task.message + '\n';
@@ -33,18 +26,7 @@ export class EchoAdapter implements AgentAdapter {
33
26
  return [{ type: 'text-delta', text: line + '\n' }];
34
27
  }
35
28
 
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
- }
29
+ detect(): Promise<DetectionResult | null> {
30
+ return detectBinary('cat', 1, 'text');
49
31
  }
50
32
  }