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.
@@ -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
- constructor(adapter: AgentAdapter, config: PlumbConfig, ledger: Ledger) {
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
- const parts = (ctx.userMessage.parts ?? []) as Array<{ kind?: string; text?: string }>;
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
- let accumulated = '';
80
- let settled = false;
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
- for (const ev of events) {
101
- if (ev.type === 'text-delta' && ev.text) {
102
- accumulated += ev.text;
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
- let accumulated = '';
191
- let settled = false;
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
- for (const ev of events) {
208
- if (ev.type === 'text-delta' && ev.text) {
209
- accumulated += ev.text;
210
- ledger.append({ type: 'progress', taskId, text: ev.text, timestamp: new Date().toISOString() });
211
- bus.publish({
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,
@@ -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
- const date = new Date().toISOString().slice(0, 10);
19
- this.path = join(LEDGER_DIR, `${date}.jsonl`);
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
- // Ledger failure is non-fatal. Log to stderr and continue.
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
  }
@@ -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
+ }
@@ -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
- function log(level: string, msg: string, data?: Record<string, unknown>): void {
10
- const entry: Record<string, unknown> = {
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