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 +21 -0
- package/README.md +193 -0
- package/package.json +17 -3
- package/src/adapters/claude.ts +21 -62
- package/src/adapters/cursor.ts +213 -0
- package/src/adapters/detect.ts +27 -0
- package/src/adapters/echo.ts +5 -23
- package/src/adapters/generic.ts +5 -14
- package/src/adapters/opencode.ts +11 -59
- package/src/adapters/pi.ts +40 -66
- package/src/adapters/registry.ts +24 -1
- package/src/adapters/stream-json.ts +89 -0
- package/src/adapters/venom.ts +78 -0
- package/src/adapters/wolfy.ts +94 -0
- package/src/cli.ts +215 -10
- package/src/config.test.ts +170 -0
- package/src/config.ts +178 -0
- package/src/core/executor.ts +113 -77
- package/src/core/ledger.ts +15 -10
- package/src/core/log.ts +12 -0
- package/src/core/process.ts +193 -10
- package/src/core/server.ts +38 -7
- package/src/core/session-store.ts +158 -0
- package/src/core/task-store.ts +137 -0
- package/src/types.ts +30 -1
- package/test/adapter-parse.test.ts +328 -0
- package/test/persistent-process.test.ts +56 -0
- package/test/rpc.test.ts +57 -0
- package/test/session-store.test.ts +129 -0
- package/test/task-store.test.ts +95 -0
- package/tsconfig.json +1 -1
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
|
+
[](https://github.com/kariemSeiam/plumb/actions/workflows/ci.yml)
|
|
12
|
+
[](https://www.npmjs.com/package/plumb-bridge)
|
|
13
|
+
[](./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.
|
|
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": {
|
package/src/adapters/claude.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
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 (
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
return [{ type: 'status', state: 'completed' }];
|
|
55
|
+
return [statusEvent('completed')];
|
|
78
56
|
}
|
|
79
57
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
+
}
|
package/src/adapters/echo.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
// PLUMB — Echo Adapter
|
|
2
|
-
// Wraps `cat`. Proves the bridge works.
|
|
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
|
-
|
|
37
|
-
|
|
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
|
}
|