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/src/core/executor.ts
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import type { AgentExecutor, RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
7
|
-
import type { AgentAdapter, AgentTask, PlumbConfig } from '../types.ts';
|
|
7
|
+
import type { AgentAdapter, AgentTask, AdapterEvent, PlumbConfig } from '../types.ts';
|
|
8
8
|
import { ProcessManager, PersistentProcess } from './process.ts';
|
|
9
9
|
import { Ledger } from './ledger.ts';
|
|
10
10
|
|
|
11
|
+
/** Fang Post-Parse hook: transforms events after parseLine, before executor processes them. */
|
|
12
|
+
export type FangPostParse = (events: AdapterEvent[], ctx: { taskId: string; adapterId: string }) => AdapterEvent[];
|
|
13
|
+
|
|
11
14
|
export class PlumbExecutor implements AgentExecutor {
|
|
12
15
|
private pm = new ProcessManager();
|
|
13
16
|
private persistent: PersistentProcess | null = null;
|
|
@@ -16,18 +19,23 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
16
19
|
private ledger: Ledger;
|
|
17
20
|
private contextByTaskId = new Map<string, string>();
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
private fangHook?: FangPostParse;
|
|
23
|
+
|
|
24
|
+
constructor(adapter: AgentAdapter, config: PlumbConfig, ledger: Ledger, fangHook?: FangPostParse) {
|
|
20
25
|
this.adapter = adapter;
|
|
21
26
|
this.config = config;
|
|
22
27
|
this.ledger = ledger;
|
|
28
|
+
this.fangHook = fangHook;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
|
|
26
32
|
const { taskId, contextId } = ctx;
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
// Normalize A2A part boundary: accept both `type` (A2A standard) and `kind` (Plumb internal)
|
|
35
|
+
type PartEntry = { kind?: string; type?: string; text?: string };
|
|
36
|
+
const parts = (ctx.userMessage.parts ?? []) as PartEntry[];
|
|
29
37
|
const text = parts
|
|
30
|
-
.filter(p => p.kind === 'text' && typeof p.text === 'string')
|
|
38
|
+
.filter(p => (p.kind === 'text' || p.type === 'text') && typeof p.text === 'string')
|
|
31
39
|
.map(p => p.text!)
|
|
32
40
|
.join('\n').trim();
|
|
33
41
|
|
|
@@ -55,6 +63,68 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/** Unified event processor — shared by both oneshot and persistent loops.
|
|
67
|
+
* Applies Fang post-parse hook first, then handles each event type. */
|
|
68
|
+
private handleEvents(
|
|
69
|
+
rawEvents: AdapterEvent[],
|
|
70
|
+
accumulated: { text: string },
|
|
71
|
+
settled: { value: boolean },
|
|
72
|
+
taskId: string,
|
|
73
|
+
contextId: string,
|
|
74
|
+
ledger: Ledger,
|
|
75
|
+
bus: ExecutionEventBus,
|
|
76
|
+
timer: ReturnType<typeof setTimeout>,
|
|
77
|
+
resolve: () => void,
|
|
78
|
+
cleanup: () => void,
|
|
79
|
+
): void {
|
|
80
|
+
// Fang Post-Parse: transform events before processing
|
|
81
|
+
const events = this.fangHook
|
|
82
|
+
? this.fangHook(rawEvents, { taskId, adapterId: this.adapter.id })
|
|
83
|
+
: rawEvents;
|
|
84
|
+
|
|
85
|
+
for (const ev of events) {
|
|
86
|
+
if (ev.type === 'text-delta' && ev.text) {
|
|
87
|
+
accumulated.text += ev.text;
|
|
88
|
+
ledger.append({ type: 'progress', taskId, text: ev.text, timestamp: new Date().toISOString() });
|
|
89
|
+
bus.publish({
|
|
90
|
+
kind: 'artifact-update', taskId, contextId,
|
|
91
|
+
artifact: { artifactId: 'stdout', name: 'output', parts: [{ kind: 'text', text: ev.text }] },
|
|
92
|
+
append: true, lastChunk: false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (ev.type === 'tool-call' && ev.tool) {
|
|
96
|
+
const label = `[${ev.tool}]${ev.input ? ' ' + JSON.stringify(ev.input) : ''}\n`;
|
|
97
|
+
accumulated.text += label;
|
|
98
|
+
ledger.append({ type: 'progress', taskId, text: label, timestamp: new Date().toISOString() });
|
|
99
|
+
bus.publish({ kind: 'artifact-update', taskId, contextId, artifact: { artifactId: 'stdout', name: 'output', parts: [{ kind: 'text', text: label }] }, append: true, lastChunk: false });
|
|
100
|
+
}
|
|
101
|
+
if (ev.type === 'tool-result' && ev.output) {
|
|
102
|
+
const label = `→ ${ev.isError ? '✗' : '✓'} ${ev.output}\n`;
|
|
103
|
+
accumulated.text += label;
|
|
104
|
+
ledger.append({ type: 'progress', taskId, text: label, timestamp: new Date().toISOString() });
|
|
105
|
+
bus.publish({ kind: 'artifact-update', taskId, contextId, artifact: { artifactId: 'stdout', name: 'output', parts: [{ kind: 'text', text: label }] }, append: true, lastChunk: false });
|
|
106
|
+
}
|
|
107
|
+
if (ev.type === 'status' && ev.state === 'completed') {
|
|
108
|
+
settled.value = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
cleanup();
|
|
111
|
+
ledger.append({ type: 'task_completed', taskId, timestamp: new Date().toISOString() });
|
|
112
|
+
bus.publish({ kind: 'message', messageId: randomUUID(), role: 'agent', parts: [{ kind: 'text', text: accumulated.text || 'Done' }] });
|
|
113
|
+
bus.finished();
|
|
114
|
+
resolve();
|
|
115
|
+
}
|
|
116
|
+
if (ev.type === 'error') {
|
|
117
|
+
settled.value = true;
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
cleanup();
|
|
120
|
+
ledger.append({ type: 'task_failed', taskId, error: ev.message, timestamp: new Date().toISOString() });
|
|
121
|
+
this.fail(bus, taskId, contextId, ev.message);
|
|
122
|
+
bus.finished();
|
|
123
|
+
resolve();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
58
128
|
private async executeOneshot(
|
|
59
129
|
ctx: RequestContext,
|
|
60
130
|
bus: ExecutionEventBus,
|
|
@@ -76,13 +146,13 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
76
146
|
|
|
77
147
|
const [cmd, ...cliArgs] = this.splitCli(config.cli);
|
|
78
148
|
const extraArgs = adapter.buildArgs(task, config);
|
|
79
|
-
|
|
80
|
-
|
|
149
|
+
const accumulated = { text: '' };
|
|
150
|
+
const settled = { value: false };
|
|
81
151
|
|
|
82
152
|
return new Promise<void>((resolve) => {
|
|
83
153
|
const timer = setTimeout(() => {
|
|
84
|
-
if (settled) return;
|
|
85
|
-
settled = true;
|
|
154
|
+
if (settled.value) return;
|
|
155
|
+
settled.value = true;
|
|
86
156
|
this.pm.kill(taskId);
|
|
87
157
|
ledger.append({ type: 'task_failed', taskId, error: `timed out after ${timeout}s`, timestamp: new Date().toISOString() });
|
|
88
158
|
this.fail(bus, taskId, contextId, `Task timed out after ${timeout}s`);
|
|
@@ -95,37 +165,11 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
95
165
|
{ cwd: config.workdir, env: config.env },
|
|
96
166
|
{
|
|
97
167
|
onLine: (line) => {
|
|
98
|
-
if (settled) return;
|
|
168
|
+
if (settled.value) return;
|
|
99
169
|
const events = adapter.parseLine(line);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
ledger.append({ type: 'progress', taskId, text: ev.text, timestamp: new Date().toISOString() });
|
|
104
|
-
bus.publish({
|
|
105
|
-
kind: 'artifact-update', taskId, contextId,
|
|
106
|
-
artifact: { artifactId: 'stdout', name: 'output', parts: [{ kind: 'text', text: ev.text }] },
|
|
107
|
-
append: true, lastChunk: false,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
if (ev.type === 'status' && ev.state === 'completed') {
|
|
111
|
-
settled = true;
|
|
112
|
-
clearTimeout(timer);
|
|
113
|
-
this.contextByTaskId.delete(taskId);
|
|
114
|
-
ledger.append({ type: 'task_completed', taskId, timestamp: new Date().toISOString() });
|
|
115
|
-
bus.publish({ kind: 'message', messageId: randomUUID(), role: 'agent', parts: [{ kind: 'text', text: accumulated || 'Done' }] });
|
|
116
|
-
bus.finished();
|
|
117
|
-
resolve();
|
|
118
|
-
}
|
|
119
|
-
if (ev.type === 'error') {
|
|
120
|
-
settled = true;
|
|
121
|
-
clearTimeout(timer);
|
|
122
|
-
this.contextByTaskId.delete(taskId);
|
|
123
|
-
ledger.append({ type: 'task_failed', taskId, error: ev.message, timestamp: new Date().toISOString() });
|
|
124
|
-
this.fail(bus, taskId, contextId, ev.message);
|
|
125
|
-
bus.finished();
|
|
126
|
-
resolve();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
170
|
+
this.handleEvents(events, accumulated, settled, taskId, contextId, ledger, bus, timer, resolve, () => {
|
|
171
|
+
this.contextByTaskId.delete(taskId);
|
|
172
|
+
});
|
|
129
173
|
},
|
|
130
174
|
onError: (text) => {
|
|
131
175
|
ledger.append({ type: 'log', taskId, level: 'error', text, timestamp: new Date().toISOString() });
|
|
@@ -136,12 +180,12 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
136
180
|
},
|
|
137
181
|
onExit: (code) => {
|
|
138
182
|
clearTimeout(timer);
|
|
139
|
-
if (settled) { resolve(); return; }
|
|
140
|
-
settled = true;
|
|
183
|
+
if (settled.value) { resolve(); return; }
|
|
184
|
+
settled.value = true;
|
|
141
185
|
this.contextByTaskId.delete(taskId);
|
|
142
186
|
if (code === 0) {
|
|
143
187
|
ledger.append({ type: 'task_completed', taskId, timestamp: new Date().toISOString() });
|
|
144
|
-
bus.publish({ kind: 'message', messageId: randomUUID(), role: 'agent', parts: [{ kind: 'text', text: accumulated || '(no output)' }] });
|
|
188
|
+
bus.publish({ kind: 'message', messageId: randomUUID(), role: 'agent', parts: [{ kind: 'text', text: accumulated.text || '(no output)' }] });
|
|
145
189
|
} else {
|
|
146
190
|
const errMsg = `Process exited with code ${code}`;
|
|
147
191
|
ledger.append({ type: 'task_failed', taskId, error: errMsg, timestamp: new Date().toISOString() });
|
|
@@ -153,6 +197,11 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
153
197
|
},
|
|
154
198
|
);
|
|
155
199
|
|
|
200
|
+
// Notify adapter of user message (Cursor session tracking)
|
|
201
|
+
const adapterAny = adapter as unknown as Record<string, unknown>;
|
|
202
|
+
if (typeof adapterAny.setUserMessage === 'function') {
|
|
203
|
+
(adapterAny as { setUserMessage(msg: string): void }).setUserMessage(task.message);
|
|
204
|
+
}
|
|
156
205
|
this.pm.stdin(taskId, adapter.formatInput(task), true);
|
|
157
206
|
});
|
|
158
207
|
}
|
|
@@ -176,6 +225,8 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
176
225
|
});
|
|
177
226
|
}
|
|
178
227
|
await this.persistent.ensure();
|
|
228
|
+
// Short ready-wait (30s). If agent never emits ready frame but is alive, proceed.
|
|
229
|
+
await this.persistent.waitUntilReady(30_000);
|
|
179
230
|
|
|
180
231
|
bus.publish({
|
|
181
232
|
kind: 'task',
|
|
@@ -187,13 +238,13 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
187
238
|
|
|
188
239
|
ledger.append({ type: 'task_running', taskId, timestamp: new Date().toISOString() });
|
|
189
240
|
|
|
190
|
-
|
|
191
|
-
|
|
241
|
+
const accumulated = { text: '' };
|
|
242
|
+
const settled = { value: false };
|
|
192
243
|
|
|
193
244
|
return new Promise<void>((resolve) => {
|
|
194
245
|
const timer = setTimeout(() => {
|
|
195
|
-
if (settled) return;
|
|
196
|
-
settled = true;
|
|
246
|
+
if (settled.value) return;
|
|
247
|
+
settled.value = true;
|
|
197
248
|
this.persistent?.removeLineHandler(taskId);
|
|
198
249
|
ledger.append({ type: 'task_failed', taskId, error: `timed out after ${timeout}s`, timestamp: new Date().toISOString() });
|
|
199
250
|
this.fail(bus, taskId, contextId, `Task timed out after ${timeout}s`);
|
|
@@ -202,41 +253,20 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
202
253
|
}, timeout * 1000);
|
|
203
254
|
|
|
204
255
|
this.persistent!.setLineHandler(taskId, (line) => {
|
|
205
|
-
if (settled) return;
|
|
256
|
+
if (settled.value) return;
|
|
206
257
|
const events = adapter.parseLine(line);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
kind: 'artifact-update', taskId, contextId,
|
|
213
|
-
artifact: { artifactId: 'stdout', name: 'output', parts: [{ kind: 'text', text: ev.text }] },
|
|
214
|
-
append: true, lastChunk: false,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
if (ev.type === 'status' && ev.state === 'completed') {
|
|
218
|
-
settled = true;
|
|
219
|
-
clearTimeout(timer);
|
|
220
|
-
this.contextByTaskId.delete(taskId);
|
|
221
|
-
this.persistent?.removeLineHandler(taskId);
|
|
222
|
-
ledger.append({ type: 'task_completed', taskId, timestamp: new Date().toISOString() });
|
|
223
|
-
bus.publish({ kind: 'message', messageId: randomUUID(), role: 'agent', parts: [{ kind: 'text', text: accumulated || 'Done' }] });
|
|
224
|
-
bus.finished();
|
|
225
|
-
resolve();
|
|
226
|
-
}
|
|
227
|
-
if (ev.type === 'error') {
|
|
228
|
-
settled = true;
|
|
229
|
-
clearTimeout(timer);
|
|
230
|
-
this.contextByTaskId.delete(taskId);
|
|
231
|
-
this.persistent?.removeLineHandler(taskId);
|
|
232
|
-
ledger.append({ type: 'task_failed', taskId, error: ev.message, timestamp: new Date().toISOString() });
|
|
233
|
-
this.fail(bus, taskId, contextId, ev.message);
|
|
234
|
-
bus.finished();
|
|
235
|
-
resolve();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
258
|
+
this.handleEvents(events, accumulated, settled, taskId, contextId, ledger, bus, timer, resolve, () => {
|
|
259
|
+
this.contextByTaskId.delete(taskId);
|
|
260
|
+
this.persistent?.removeLineHandler(taskId);
|
|
261
|
+
});
|
|
262
|
+
|
|
238
263
|
});
|
|
239
264
|
|
|
265
|
+
// Notify adapter of user message (Cursor session tracking)
|
|
266
|
+
const adapterAny = adapter as unknown as Record<string, unknown>;
|
|
267
|
+
if (typeof adapterAny.setUserMessage === 'function') {
|
|
268
|
+
(adapterAny as { setUserMessage(msg: string): void }).setUserMessage(task.message);
|
|
269
|
+
}
|
|
240
270
|
// Send task input to the persistent process
|
|
241
271
|
this.persistent!.writeWhenActive(taskId, adapter.formatInput(task));
|
|
242
272
|
});
|
|
@@ -273,6 +303,12 @@ export class PlumbExecutor implements AgentExecutor {
|
|
|
273
303
|
}
|
|
274
304
|
}
|
|
275
305
|
|
|
306
|
+
/** Persistent agent liveness. null for oneshot, true/false for persistent. */
|
|
307
|
+
isPersistentAlive(): boolean | null {
|
|
308
|
+
if (this.adapter.mode !== 'persistent') return null;
|
|
309
|
+
return this.persistent?.isAlive ?? false;
|
|
310
|
+
}
|
|
311
|
+
|
|
276
312
|
private fail(
|
|
277
313
|
bus: ExecutionEventBus,
|
|
278
314
|
taskId: string,
|
package/src/core/ledger.ts
CHANGED
|
@@ -1,39 +1,44 @@
|
|
|
1
1
|
// PLUMB — Ledger
|
|
2
2
|
// Append-only JSONL. Every task event is one line. Crash-survivable. Query with jq.
|
|
3
|
-
// Stolen from pi's append-only tree storage, simplified to linear per-day files.
|
|
4
3
|
|
|
5
4
|
import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import type { LedgerEvent } from '../types.ts';
|
|
7
|
+
import { log } from './log.ts';
|
|
8
8
|
|
|
9
9
|
const LEDGER_DIR = '.plumb/ledger';
|
|
10
10
|
|
|
11
11
|
export class Ledger {
|
|
12
|
+
private currentDate: string;
|
|
12
13
|
private path: string;
|
|
13
14
|
|
|
14
15
|
constructor() {
|
|
15
16
|
if (!existsSync(LEDGER_DIR)) {
|
|
16
17
|
mkdirSync(LEDGER_DIR, { recursive: true });
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
this.path = join(LEDGER_DIR, `${
|
|
19
|
+
this.currentDate = new Date().toISOString().slice(0, 10);
|
|
20
|
+
this.path = join(LEDGER_DIR, `${this.currentDate}.jsonl`);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
append(event: LedgerEvent): void {
|
|
23
24
|
try {
|
|
25
|
+
this.rollIfNeeded();
|
|
24
26
|
appendFileSync(this.path, JSON.stringify(event) + '\n');
|
|
25
27
|
} catch {
|
|
26
|
-
|
|
27
|
-
process.stderr.write(JSON.stringify({
|
|
28
|
-
ts: new Date().toISOString(),
|
|
29
|
-
l: 'error',
|
|
30
|
-
m: 'ledger_write_failed',
|
|
31
|
-
event_type: event.type,
|
|
32
|
-
}) + '\n');
|
|
28
|
+
log('error', 'ledger_write_failed', { event_type: event.type });
|
|
33
29
|
}
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
getPath(): string {
|
|
33
|
+
this.rollIfNeeded();
|
|
37
34
|
return this.path;
|
|
38
35
|
}
|
|
36
|
+
|
|
37
|
+
private rollIfNeeded(): void {
|
|
38
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
39
|
+
if (today !== this.currentDate) {
|
|
40
|
+
this.currentDate = today;
|
|
41
|
+
this.path = join(LEDGER_DIR, `${today}.jsonl`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
39
44
|
}
|
package/src/core/log.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// PLUMB — Shared JSONL logger
|
|
2
|
+
// Structured stderr output used by process manager, CLI, and ledger fallback.
|
|
3
|
+
|
|
4
|
+
export function log(level: string, msg: string, data?: Record<string, unknown>): void {
|
|
5
|
+
const entry: Record<string, unknown> = {
|
|
6
|
+
ts: new Date().toISOString(),
|
|
7
|
+
l: level,
|
|
8
|
+
m: msg,
|
|
9
|
+
...(data ?? {}),
|
|
10
|
+
};
|
|
11
|
+
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
12
|
+
}
|
package/src/core/process.ts
CHANGED
|
@@ -5,16 +5,9 @@
|
|
|
5
5
|
// breaking Pi's JSONL protocol where those codepoints are valid inside strings).
|
|
6
6
|
|
|
7
7
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
ts: new Date().toISOString(),
|
|
12
|
-
l: level,
|
|
13
|
-
m: msg,
|
|
14
|
-
...(data ?? {}),
|
|
15
|
-
};
|
|
16
|
-
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
17
|
-
}
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import type { RpcHostToolExecutor, RpcParsedResponse } from '../types.ts';
|
|
10
|
+
import { log } from './log.ts';
|
|
18
11
|
|
|
19
12
|
export function attachJsonlReader(
|
|
20
13
|
stream: NodeJS.ReadableStream,
|
|
@@ -161,6 +154,19 @@ export class PersistentProcess {
|
|
|
161
154
|
private activeTaskId: string | null = null;
|
|
162
155
|
private writeBuffer = new Map<string, string[]>();
|
|
163
156
|
|
|
157
|
+
/** Ready-frame detection — resolves waitUntilReady on { "type": "ready" }. */
|
|
158
|
+
private readyEmitted = false;
|
|
159
|
+
private readyWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
|
|
160
|
+
|
|
161
|
+
/** RPC correlated request/response. */
|
|
162
|
+
private pendingResponses = new Map<string, {
|
|
163
|
+
settle: (r: RpcParsedResponse | null) => void;
|
|
164
|
+
timer: NodeJS.Timeout;
|
|
165
|
+
}>();
|
|
166
|
+
private rpcDefaultTimeoutMs = 120_000;
|
|
167
|
+
private rpcHostExecutor: RpcHostToolExecutor | undefined;
|
|
168
|
+
private hostAbortByRequestId = new Map<string, AbortController>();
|
|
169
|
+
|
|
164
170
|
readonly onCrash?: (crashedTaskId: string, remainingCount: number) => void;
|
|
165
171
|
|
|
166
172
|
constructor(
|
|
@@ -175,6 +181,85 @@ export class PersistentProcess {
|
|
|
175
181
|
this.onCrash = callbacks?.onCrash;
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Resolves once the child emits { "type": "ready" } over stdout,
|
|
186
|
+
* or rejects on timeout after ensure() spawned the process.
|
|
187
|
+
* Returns immediately if already ready.
|
|
188
|
+
*/
|
|
189
|
+
async waitUntilReady(timeoutMs = 30_000): Promise<void> {
|
|
190
|
+
if (this.readyEmitted) return;
|
|
191
|
+
await new Promise<void>((resolve, reject) => {
|
|
192
|
+
const t = setTimeout(() => {
|
|
193
|
+
// If process is alive but never emitted the ready frame, proceed anyway.
|
|
194
|
+
// Some persistent agents (e.g. Pi in RPC mode) don't output {"type":"ready"}.
|
|
195
|
+
if (this.isAlive) {
|
|
196
|
+
resolve();
|
|
197
|
+
} else {
|
|
198
|
+
reject(new Error('Timed out waiting for persistent agent ready frame'));
|
|
199
|
+
}
|
|
200
|
+
}, timeoutMs);
|
|
201
|
+
this.readyWaiters.push({
|
|
202
|
+
resolve: () => { clearTimeout(t); resolve(); },
|
|
203
|
+
reject: (e: Error) => { clearTimeout(t); reject(e); },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private signalReady(): void {
|
|
209
|
+
if (this.readyEmitted) return;
|
|
210
|
+
this.readyEmitted = true;
|
|
211
|
+
for (const w of this.readyWaiters) {
|
|
212
|
+
try { w.resolve(); } catch { /* noop */ }
|
|
213
|
+
}
|
|
214
|
+
this.readyWaiters.length = 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Replace / clear custom host tool execution (wired by adapters). */
|
|
218
|
+
setHostToolExecutor(exec: RpcHostToolExecutor | undefined): void {
|
|
219
|
+
this.rpcHostExecutor = exec;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Default timeout for correlated RPC awaits (milliseconds). */
|
|
223
|
+
setRpcTimeoutMs(ms: number): void {
|
|
224
|
+
this.rpcDefaultTimeoutMs = Math.max(1000, ms);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Send a correlated RPC command payload (stdin JSON line).
|
|
229
|
+
* Automatically assigns `id` if omitted.
|
|
230
|
+
* Resolves only on matching { type: "response", id } stdout line (consumed internally).
|
|
231
|
+
*/
|
|
232
|
+
async sendRpcCommand(
|
|
233
|
+
command: Record<string, unknown>,
|
|
234
|
+
opts?: { timeoutMs?: number; correlationId?: string },
|
|
235
|
+
): Promise<RpcParsedResponse> {
|
|
236
|
+
if (!this.isAlive || !this.proc?.stdin) {
|
|
237
|
+
throw new Error('Persistent process is not alive');
|
|
238
|
+
}
|
|
239
|
+
const cid = opts?.correlationId ?? (command.id !== undefined ? String(command.id) : randomUUID());
|
|
240
|
+
const body: Record<string, unknown> = { ...command, id: cid };
|
|
241
|
+
const timeoutMs = opts?.timeoutMs ?? this.rpcDefaultTimeoutMs;
|
|
242
|
+
const cmdHint = typeof body.type === 'string' ? body.type : 'rpc';
|
|
243
|
+
|
|
244
|
+
const responsePromise = new Promise<RpcParsedResponse>((resolve) => {
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
this.pendingResponses.delete(cid);
|
|
247
|
+
log('warn', 'rpc_timeout', { correlationId: cid, command: cmdHint, timeoutMs });
|
|
248
|
+
resolve({
|
|
249
|
+
success: false,
|
|
250
|
+
error: `RPC correlation timeout (${timeoutMs}ms) for command ${cmdHint}`,
|
|
251
|
+
});
|
|
252
|
+
}, timeoutMs);
|
|
253
|
+
this.pendingResponses.set(cid, {
|
|
254
|
+
settle: (r) => { clearTimeout(timer); resolve(r ?? { success: false, error: 'Empty RPC response' }); },
|
|
255
|
+
timer,
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
this.proc.stdin.write(JSON.stringify(body) + '\n');
|
|
260
|
+
return responsePromise;
|
|
261
|
+
}
|
|
262
|
+
|
|
178
263
|
async ensure(): Promise<void> {
|
|
179
264
|
if (this.proc && this.proc.exitCode === null) return;
|
|
180
265
|
|
|
@@ -198,6 +283,23 @@ export class PersistentProcess {
|
|
|
198
283
|
log('warn', 'persistent_exited', { activeTaskId: this.activeTaskId, queuedCount: this.taskQueue.length });
|
|
199
284
|
if (this.detachReader) { this.detachReader(); this.detachReader = null; }
|
|
200
285
|
|
|
286
|
+
// Reject pending ready-waiters on crash
|
|
287
|
+
if (this.readyWaiters.length > 0) {
|
|
288
|
+
for (const w of this.readyWaiters) {
|
|
289
|
+
try { w.reject(new Error('Persistent process exited — ready wait aborted')); } catch { /* noop */ }
|
|
290
|
+
}
|
|
291
|
+
this.readyWaiters.length = 0;
|
|
292
|
+
}
|
|
293
|
+
this.readyEmitted = false;
|
|
294
|
+
|
|
295
|
+
// Reject pending RPC awaits on crash
|
|
296
|
+
for (const [, pend] of this.pendingResponses) {
|
|
297
|
+
try { pend.settle({ success: false, error: 'Persistent process exited — RPC await cleared' }); } catch { /* noop */ }
|
|
298
|
+
clearTimeout(pend.timer);
|
|
299
|
+
}
|
|
300
|
+
this.pendingResponses.clear();
|
|
301
|
+
this.hostAbortByRequestId.clear();
|
|
302
|
+
|
|
201
303
|
if (this.activeTaskId && this.taskHandlers.size > 0) {
|
|
202
304
|
const crashedId = this.activeTaskId;
|
|
203
305
|
const remaining = this.taskHandlers.size;
|
|
@@ -219,11 +321,89 @@ export class PersistentProcess {
|
|
|
219
321
|
}
|
|
220
322
|
|
|
221
323
|
private routeLine(line: string): void {
|
|
324
|
+
// Intercept protocol frames before task routing
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(line);
|
|
327
|
+
if (parsed && typeof parsed === 'object') {
|
|
328
|
+
if (this.dispatchProtocolFrame(parsed)) return; // swallowed by protocol handler
|
|
329
|
+
}
|
|
330
|
+
} catch { /* not JSON — fall through to task routing */ }
|
|
331
|
+
|
|
222
332
|
if (!this.activeTaskId) return;
|
|
223
333
|
const handler = this.taskHandlers.get(this.activeTaskId);
|
|
224
334
|
handler?.(line);
|
|
225
335
|
}
|
|
226
336
|
|
|
337
|
+
/** Internal: handle protocol frames. Returns true if swallowed. */
|
|
338
|
+
private dispatchProtocolFrame(parsed: Record<string, unknown>): boolean {
|
|
339
|
+
if (parsed.type === 'ready') {
|
|
340
|
+
this.signalReady();
|
|
341
|
+
return true; // swallow
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (parsed.type === 'response') {
|
|
345
|
+
const rid = parsed.id !== undefined ? String(parsed.id) : '';
|
|
346
|
+
if (rid && this.pendingResponses.has(rid)) {
|
|
347
|
+
const slot = this.pendingResponses.get(rid)!;
|
|
348
|
+
this.pendingResponses.delete(rid);
|
|
349
|
+
const success = !!parsed.success;
|
|
350
|
+
const resp: RpcParsedResponse = success
|
|
351
|
+
? { success: true, command: typeof parsed.command === 'string' ? parsed.command : undefined, data: parsed.data }
|
|
352
|
+
: { success: false, command: typeof parsed.command === 'string' ? parsed.command : undefined, error: typeof parsed.error === 'string' ? parsed.error : 'RPC failure' };
|
|
353
|
+
slot.settle(resp);
|
|
354
|
+
return true; // swallow
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (parsed.type === 'host_tool_call' && typeof parsed.id === 'string' && this.rpcHostExecutor) {
|
|
360
|
+
void this.invokeHostToolAndReply(parsed).catch(() => { /* reply sent inside */ });
|
|
361
|
+
return true; // swallow — protocol frame, not agent output
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (parsed.type === 'host_tool_cancel' && typeof parsed.targetId === 'string') {
|
|
365
|
+
const ac = this.hostAbortByRequestId.get(parsed.targetId);
|
|
366
|
+
ac?.abort();
|
|
367
|
+
return true; // swallow — control frame, not agent output
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async invokeHostToolAndReply(raw: Record<string, unknown>): Promise<void> {
|
|
374
|
+
const exec = this.rpcHostExecutor;
|
|
375
|
+
if (!exec || !this.proc?.stdin) return;
|
|
376
|
+
|
|
377
|
+
const id = typeof raw.id === 'string' ? raw.id : '';
|
|
378
|
+
const toolCallId = typeof raw.toolCallId === 'string' ? raw.toolCallId : '';
|
|
379
|
+
const toolName = typeof raw.toolName === 'string' ? raw.toolName : 'unknown_tool';
|
|
380
|
+
const args = typeof raw.arguments === 'object' && raw.arguments !== null ? raw.arguments as Record<string, unknown> : {};
|
|
381
|
+
|
|
382
|
+
const ac = new AbortController();
|
|
383
|
+
this.hostAbortByRequestId.set(id, ac);
|
|
384
|
+
|
|
385
|
+
const sendResult = (result: Array<Record<string, unknown>>, isError?: boolean): void => {
|
|
386
|
+
if (!this.proc?.stdin) return;
|
|
387
|
+
const frame: Record<string, unknown> = {
|
|
388
|
+
type: 'host_tool_result',
|
|
389
|
+
id,
|
|
390
|
+
result: { content: [...result] },
|
|
391
|
+
};
|
|
392
|
+
if (isError) frame.isError = true;
|
|
393
|
+
this.proc.stdin.write(JSON.stringify(frame) + '\n');
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const outcome = await exec({ requestId: id, toolCallId, toolName, args, abortSignal: ac.signal });
|
|
398
|
+
sendResult([...outcome.content], !!outcome.isError);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
401
|
+
sendResult([{ type: 'text', text: `Host tool error: ${msg}` }], true);
|
|
402
|
+
} finally {
|
|
403
|
+
this.hostAbortByRequestId.delete(id);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
227
407
|
setLineHandler(taskId: string, handler: (line: string) => void): void {
|
|
228
408
|
this.taskHandlers.set(taskId, handler);
|
|
229
409
|
if (!this.activeTaskId && this.taskQueue.length === 0) {
|
|
@@ -289,6 +469,9 @@ export class PersistentProcess {
|
|
|
289
469
|
this.proc = null;
|
|
290
470
|
this.activeTaskId = null;
|
|
291
471
|
this.writeBuffer.clear();
|
|
472
|
+
this.pendingResponses.clear();
|
|
473
|
+
this.hostAbortByRequestId.clear();
|
|
474
|
+
this.readyEmitted = false;
|
|
292
475
|
}
|
|
293
476
|
}
|
|
294
477
|
|