plumb-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ // PLUMB — Executor
2
+ // Bridges A2A tasks to CLI processes. Writes every lifecycle event to the ledger.
3
+ // Supports oneshot (process-per-task) and persistent (single long-running process) modes.
4
+
5
+ import { randomUUID } from 'node:crypto';
6
+ import type { AgentExecutor, RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
7
+ import type { AgentAdapter, AgentTask, PlumbConfig } from '../types.ts';
8
+ import { ProcessManager, PersistentProcess } from './process.ts';
9
+ import { Ledger } from './ledger.ts';
10
+
11
+ export class PlumbExecutor implements AgentExecutor {
12
+ private pm = new ProcessManager();
13
+ private persistent: PersistentProcess | null = null;
14
+ private adapter: AgentAdapter;
15
+ private config: PlumbConfig;
16
+ private ledger: Ledger;
17
+ private contextByTaskId = new Map<string, string>();
18
+
19
+ constructor(adapter: AgentAdapter, config: PlumbConfig, ledger: Ledger) {
20
+ this.adapter = adapter;
21
+ this.config = config;
22
+ this.ledger = ledger;
23
+ }
24
+
25
+ async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
26
+ const { taskId, contextId } = ctx;
27
+
28
+ const parts = (ctx.userMessage.parts ?? []) as Array<{ kind?: string; text?: string }>;
29
+ const text = parts
30
+ .filter(p => p.kind === 'text' && typeof p.text === 'string')
31
+ .map(p => p.text!)
32
+ .join('\n').trim();
33
+
34
+ if (!text) {
35
+ this.fail(bus, taskId, contextId, 'No message text provided.', 'rejected');
36
+ bus.finished();
37
+ return;
38
+ }
39
+
40
+ this.ledger.append({
41
+ type: 'task_submitted',
42
+ taskId,
43
+ cli: this.config.cli,
44
+ message: text,
45
+ timestamp: new Date().toISOString(),
46
+ });
47
+
48
+ this.contextByTaskId.set(taskId, contextId);
49
+ const task: AgentTask = { id: taskId, message: text, context: { workdir: this.config.workdir } };
50
+
51
+ if (this.adapter.mode === 'persistent') {
52
+ await this.executePersistent(ctx, bus, task);
53
+ } else {
54
+ await this.executeOneshot(ctx, bus, task);
55
+ }
56
+ }
57
+
58
+ private async executeOneshot(
59
+ ctx: RequestContext,
60
+ bus: ExecutionEventBus,
61
+ task: AgentTask,
62
+ ): Promise<void> {
63
+ const { taskId, contextId } = ctx;
64
+ const { adapter, config, ledger } = this;
65
+ const timeout = config.taskTimeout ?? 300;
66
+
67
+ bus.publish({
68
+ kind: 'task',
69
+ id: taskId,
70
+ contextId,
71
+ status: { state: 'working', timestamp: new Date().toISOString() },
72
+ history: [],
73
+ });
74
+
75
+ ledger.append({ type: 'task_running', taskId, timestamp: new Date().toISOString() });
76
+
77
+ const [cmd, ...cliArgs] = this.splitCli(config.cli);
78
+ const extraArgs = adapter.buildArgs(task, config);
79
+ let accumulated = '';
80
+ let settled = false;
81
+
82
+ return new Promise<void>((resolve) => {
83
+ const timer = setTimeout(() => {
84
+ if (settled) return;
85
+ settled = true;
86
+ this.pm.kill(taskId);
87
+ ledger.append({ type: 'task_failed', taskId, error: `timed out after ${timeout}s`, timestamp: new Date().toISOString() });
88
+ this.fail(bus, taskId, contextId, `Task timed out after ${timeout}s`);
89
+ bus.finished();
90
+ resolve();
91
+ }, timeout * 1000);
92
+
93
+ this.pm.spawn(
94
+ taskId, cmd!, [...cliArgs, ...extraArgs],
95
+ { cwd: config.workdir, env: config.env },
96
+ {
97
+ onLine: (line) => {
98
+ if (settled) return;
99
+ 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
+ }
129
+ },
130
+ onError: (text) => {
131
+ ledger.append({ type: 'log', taskId, level: 'error', text, timestamp: new Date().toISOString() });
132
+ bus.publish({
133
+ kind: 'artifact-update', taskId, contextId,
134
+ artifact: { artifactId: 'stderr', name: 'errors', parts: [{ kind: 'text', text }] },
135
+ });
136
+ },
137
+ onExit: (code) => {
138
+ clearTimeout(timer);
139
+ if (settled) { resolve(); return; }
140
+ settled = true;
141
+ this.contextByTaskId.delete(taskId);
142
+ if (code === 0) {
143
+ 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)' }] });
145
+ } else {
146
+ const errMsg = `Process exited with code ${code}`;
147
+ ledger.append({ type: 'task_failed', taskId, error: errMsg, timestamp: new Date().toISOString() });
148
+ bus.publish({ kind: 'message', messageId: randomUUID(), role: 'agent', parts: [{ kind: 'text', text: errMsg }] });
149
+ }
150
+ bus.finished();
151
+ resolve();
152
+ },
153
+ },
154
+ );
155
+
156
+ this.pm.stdin(taskId, adapter.formatInput(task), true);
157
+ });
158
+ }
159
+
160
+ private async executePersistent(
161
+ ctx: RequestContext,
162
+ bus: ExecutionEventBus,
163
+ task: AgentTask,
164
+ ): Promise<void> {
165
+ const { taskId, contextId } = ctx;
166
+ const { adapter, config, ledger } = this;
167
+ const timeout = config.taskTimeout ?? 300;
168
+
169
+ // Ensure persistent process is running
170
+ if (!this.persistent) {
171
+ const [cmd, ...cliArgs] = this.splitCli(config.cli);
172
+ const extraArgs = adapter.buildArgs(task, config);
173
+ this.persistent = new PersistentProcess(cmd!, [...cliArgs, ...extraArgs], {
174
+ cwd: config.workdir,
175
+ env: config.env,
176
+ });
177
+ }
178
+ await this.persistent.ensure();
179
+
180
+ bus.publish({
181
+ kind: 'task',
182
+ id: taskId,
183
+ contextId,
184
+ status: { state: 'working', timestamp: new Date().toISOString() },
185
+ history: [],
186
+ });
187
+
188
+ ledger.append({ type: 'task_running', taskId, timestamp: new Date().toISOString() });
189
+
190
+ let accumulated = '';
191
+ let settled = false;
192
+
193
+ return new Promise<void>((resolve) => {
194
+ const timer = setTimeout(() => {
195
+ if (settled) return;
196
+ settled = true;
197
+ this.persistent?.removeLineHandler(taskId);
198
+ ledger.append({ type: 'task_failed', taskId, error: `timed out after ${timeout}s`, timestamp: new Date().toISOString() });
199
+ this.fail(bus, taskId, contextId, `Task timed out after ${timeout}s`);
200
+ bus.finished();
201
+ resolve();
202
+ }, timeout * 1000);
203
+
204
+ this.persistent!.setLineHandler(taskId, (line) => {
205
+ if (settled) return;
206
+ 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
+ }
238
+ });
239
+
240
+ // Send task input to the persistent process
241
+ this.persistent!.writeWhenActive(taskId, adapter.formatInput(task));
242
+ });
243
+ }
244
+
245
+ async cancelTask(taskId: string, bus: ExecutionEventBus): Promise<void> {
246
+ const contextId = this.contextByTaskId.get(taskId) ?? taskId;
247
+
248
+ if (this.adapter.mode === 'persistent' && this.persistent) {
249
+ this.persistent.removeLineHandler(taskId);
250
+ } else {
251
+ this.pm.kill(taskId, this.config.killTimeout ?? 5000);
252
+ }
253
+
254
+ this.contextByTaskId.delete(taskId);
255
+ this.ledger.append({ type: 'task_cancelled', taskId, timestamp: new Date().toISOString() });
256
+ bus.publish({
257
+ kind: 'status-update', taskId, contextId, final: true,
258
+ status: {
259
+ state: 'canceled',
260
+ message: { kind: 'message', role: 'agent', messageId: randomUUID(), parts: [{ kind: 'text', text: 'Task cancelled.' }] },
261
+ timestamp: new Date().toISOString(),
262
+ },
263
+ });
264
+ bus.finished();
265
+ }
266
+
267
+ async shutdown(): Promise<void> {
268
+ this.contextByTaskId.clear();
269
+ await this.pm.killAll();
270
+ if (this.persistent) {
271
+ await this.persistent.kill();
272
+ this.persistent = null;
273
+ }
274
+ }
275
+
276
+ private fail(
277
+ bus: ExecutionEventBus,
278
+ taskId: string,
279
+ contextId: string,
280
+ text: string,
281
+ state: 'failed' | 'rejected' = 'failed',
282
+ ): void {
283
+ this.contextByTaskId.delete(taskId);
284
+ bus.publish({
285
+ kind: 'status-update', taskId, contextId, final: true,
286
+ status: {
287
+ state,
288
+ message: { kind: 'message', role: 'agent', messageId: randomUUID(), parts: [{ kind: 'text', text }] },
289
+ timestamp: new Date().toISOString(),
290
+ },
291
+ });
292
+ }
293
+
294
+ private splitCli(cli: string): string[] {
295
+ const parts: string[] = [];
296
+ let cur = '';
297
+ let inQ: string | null = null;
298
+ for (const ch of cli) {
299
+ if (inQ) { if (ch === inQ) inQ = null; else cur += ch; }
300
+ else if (ch === '"' || ch === "'") inQ = ch;
301
+ else if (ch === ' ' || ch === '\t') { if (cur) { parts.push(cur); cur = ''; } }
302
+ else cur += ch;
303
+ }
304
+ if (cur) parts.push(cur);
305
+ return parts;
306
+ }
307
+ }
@@ -0,0 +1,39 @@
1
+ // PLUMB — Ledger
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
+
5
+ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import type { LedgerEvent } from '../types.ts';
8
+
9
+ const LEDGER_DIR = '.plumb/ledger';
10
+
11
+ export class Ledger {
12
+ private path: string;
13
+
14
+ constructor() {
15
+ if (!existsSync(LEDGER_DIR)) {
16
+ mkdirSync(LEDGER_DIR, { recursive: true });
17
+ }
18
+ const date = new Date().toISOString().slice(0, 10);
19
+ this.path = join(LEDGER_DIR, `${date}.jsonl`);
20
+ }
21
+
22
+ append(event: LedgerEvent): void {
23
+ try {
24
+ appendFileSync(this.path, JSON.stringify(event) + '\n');
25
+ } 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');
33
+ }
34
+ }
35
+
36
+ getPath(): string {
37
+ return this.path;
38
+ }
39
+ }
@@ -0,0 +1,297 @@
1
+ // PLUMB — Process Manager
2
+ // Spawns CLI agents, routes stdout/stderr, enforces SIGTERM→SIGKILL cancellation.
3
+ // Stolen from fangai's ProcessManager + attachJsonlReader.
4
+ // LF-only JSONL reader: does NOT use Node readline (which splits on U+2028/U+2029,
5
+ // breaking Pi's JSONL protocol where those codepoints are valid inside strings).
6
+
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
+ }
18
+
19
+ export function attachJsonlReader(
20
+ stream: NodeJS.ReadableStream,
21
+ onLine: (line: string) => void,
22
+ ): () => void {
23
+ let buffer = '';
24
+
25
+ const onData = (chunk: Buffer | string) => {
26
+ buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
27
+ while (true) {
28
+ const idx = buffer.indexOf('\n');
29
+ if (idx === -1) break;
30
+ let line = buffer.slice(0, idx);
31
+ buffer = buffer.slice(idx + 1);
32
+ if (line.endsWith('\r')) line = line.slice(0, -1);
33
+ if (line.length > 0) onLine(line);
34
+ }
35
+ };
36
+
37
+ const onEnd = () => {
38
+ if (buffer.length > 0) {
39
+ const remaining = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer;
40
+ if (remaining.length > 0) onLine(remaining);
41
+ buffer = '';
42
+ }
43
+ };
44
+
45
+ stream.on('data', onData);
46
+ stream.on('end', onEnd);
47
+ return () => {
48
+ stream.removeListener('data', onData);
49
+ stream.removeListener('end', onEnd);
50
+ };
51
+ }
52
+
53
+ export class ProcessManager {
54
+ private processes = new Map<string, ChildProcess>();
55
+ private killTimers = new Map<string, NodeJS.Timeout>();
56
+ private cleanups = new Map<string, () => void>();
57
+
58
+ spawn(
59
+ taskId: string,
60
+ cmd: string,
61
+ args: string[],
62
+ opts: { cwd?: string; env?: Record<string, string> },
63
+ handlers: {
64
+ onLine: (line: string) => void;
65
+ onError: (text: string) => void;
66
+ onExit: (code: number | null, signal: string | null) => void;
67
+ },
68
+ ): ChildProcess {
69
+ const proc = spawn(cmd, args, {
70
+ stdio: ['pipe', 'pipe', 'pipe'],
71
+ cwd: opts.cwd,
72
+ env: { ...(process.env as Record<string, string>), ...opts.env },
73
+ });
74
+ this.processes.set(taskId, proc);
75
+ log('info', 'process_spawned', { taskId, cmd, pid: proc.pid });
76
+
77
+ const detach = attachJsonlReader(proc.stdout!, handlers.onLine);
78
+ this.cleanups.set(taskId, detach);
79
+
80
+ let stderrBuf = '';
81
+ proc.stderr!.on('data', (chunk: Buffer) => {
82
+ stderrBuf += chunk.toString();
83
+ const lines = stderrBuf.split('\n');
84
+ stderrBuf = lines.pop()!;
85
+ for (const line of lines) {
86
+ if (line.trim()) handlers.onError(line);
87
+ }
88
+ });
89
+
90
+ proc.on('exit', (code, signal) => {
91
+ log('info', 'process_exited', { taskId, code, signal });
92
+ detach();
93
+ this.processes.delete(taskId);
94
+ this.cleanups.delete(taskId);
95
+ const timer = this.killTimers.get(taskId);
96
+ if (timer) { clearTimeout(timer); this.killTimers.delete(taskId); }
97
+ handlers.onExit(code, signal);
98
+ });
99
+
100
+ proc.on('error', (err) => {
101
+ log('error', 'process_error', { taskId, error: err.message });
102
+ detach();
103
+ this.processes.delete(taskId);
104
+ this.cleanups.delete(taskId);
105
+ handlers.onError(err.message);
106
+ handlers.onExit(1, null);
107
+ });
108
+
109
+ return proc;
110
+ }
111
+
112
+ stdin(taskId: string, data: string, close = false): void {
113
+ const proc = this.processes.get(taskId);
114
+ if (!proc) return;
115
+ proc.stdin!.write(data);
116
+ if (close) proc.stdin!.end();
117
+ }
118
+
119
+ kill(taskId: string, timeout = 5000): void {
120
+ const proc = this.processes.get(taskId);
121
+ if (!proc) return;
122
+ log('warn', 'process_kill', { taskId, timeout });
123
+ proc.kill('SIGTERM');
124
+ const timer = setTimeout(() => {
125
+ try { proc.kill('SIGKILL'); } catch { /* gone */ }
126
+ }, timeout);
127
+ this.killTimers.set(taskId, timer);
128
+ }
129
+
130
+ async killAll(timeout = 5000): Promise<void> {
131
+ const ids = [...this.processes.keys()];
132
+ if (ids.length === 0) return;
133
+ await Promise.all(ids.map(id => new Promise<void>(resolve => {
134
+ const proc = this.processes.get(id);
135
+ if (!proc) { resolve(); return; }
136
+ const timer = setTimeout(() => {
137
+ try { proc.kill('SIGKILL'); } catch { /* gone */ }
138
+ resolve();
139
+ }, timeout);
140
+ proc.on('exit', () => { clearTimeout(timer); resolve(); });
141
+ proc.kill('SIGTERM');
142
+ })));
143
+ }
144
+
145
+ has(taskId: string): boolean { return this.processes.has(taskId); }
146
+ }
147
+
148
+ // ─── Persistent Process Manager ───────────────────────────────────────────
149
+ // Single long-running process for adapters with mode: 'persistent'.
150
+ // Tasks are queued. Lines route to active task. Task completes on protocol signal.
151
+
152
+ export class PersistentProcess {
153
+ private proc: ChildProcess | null = null;
154
+ private detachReader: (() => void) | null = null;
155
+ private readonly cmd: string;
156
+ private readonly args: string[];
157
+ private readonly opts: { cwd?: string; env?: Record<string, string> };
158
+
159
+ private taskHandlers = new Map<string, (line: string) => void>();
160
+ private taskQueue: string[] = [];
161
+ private activeTaskId: string | null = null;
162
+ private writeBuffer = new Map<string, string[]>();
163
+
164
+ readonly onCrash?: (crashedTaskId: string, remainingCount: number) => void;
165
+
166
+ constructor(
167
+ cmd: string,
168
+ args: string[],
169
+ opts: { cwd?: string; env?: Record<string, string> },
170
+ callbacks?: { onCrash?: (crashedTaskId: string, remainingCount: number) => void },
171
+ ) {
172
+ this.cmd = cmd;
173
+ this.args = args;
174
+ this.opts = opts;
175
+ this.onCrash = callbacks?.onCrash;
176
+ }
177
+
178
+ async ensure(): Promise<void> {
179
+ if (this.proc && this.proc.exitCode === null) return;
180
+
181
+ this.proc = spawn(this.cmd, this.args, {
182
+ stdio: ['pipe', 'pipe', 'pipe'],
183
+ cwd: this.opts.cwd,
184
+ env: { ...(process.env as Record<string, string>), ...this.opts.env },
185
+ });
186
+
187
+ log('info', 'persistent_spawned', { cmd: this.cmd, pid: this.proc.pid });
188
+
189
+ this.detachReader = attachJsonlReader(this.proc.stdout!, (line) => {
190
+ this.routeLine(line);
191
+ });
192
+
193
+ this.proc.stderr!.on('data', () => {
194
+ // stderr from persistent process — ignored
195
+ });
196
+
197
+ this.proc.on('exit', () => {
198
+ log('warn', 'persistent_exited', { activeTaskId: this.activeTaskId, queuedCount: this.taskQueue.length });
199
+ if (this.detachReader) { this.detachReader(); this.detachReader = null; }
200
+
201
+ if (this.activeTaskId && this.taskHandlers.size > 0) {
202
+ const crashedId = this.activeTaskId;
203
+ const remaining = this.taskHandlers.size;
204
+ const errorLine = JSON.stringify({ type: 'error', message: 'Process crashed unexpectedly' });
205
+ for (const [, handler] of this.taskHandlers) {
206
+ try { handler(errorLine); } catch { /* swallow */ }
207
+ }
208
+ this.onCrash?.(crashedId, remaining);
209
+ }
210
+ this.proc = null;
211
+ this.activeTaskId = null;
212
+ this.writeBuffer.clear();
213
+ });
214
+
215
+ await new Promise<void>((resolve) => {
216
+ this.proc!.once('spawn', () => resolve());
217
+ this.proc!.once('error', () => { this.proc = null; resolve(); });
218
+ });
219
+ }
220
+
221
+ private routeLine(line: string): void {
222
+ if (!this.activeTaskId) return;
223
+ const handler = this.taskHandlers.get(this.activeTaskId);
224
+ handler?.(line);
225
+ }
226
+
227
+ setLineHandler(taskId: string, handler: (line: string) => void): void {
228
+ this.taskHandlers.set(taskId, handler);
229
+ if (!this.activeTaskId && this.taskQueue.length === 0) {
230
+ this.activeTaskId = taskId;
231
+ this.taskQueue.push(taskId);
232
+ this.flushBuffer(taskId);
233
+ } else {
234
+ this.taskQueue.push(taskId);
235
+ }
236
+ }
237
+
238
+ removeLineHandler(taskId: string): void {
239
+ this.taskHandlers.delete(taskId);
240
+ this.writeBuffer.delete(taskId);
241
+ this.taskQueue = this.taskQueue.filter(id => id !== taskId);
242
+ if (this.activeTaskId === taskId) {
243
+ this.activeTaskId = null;
244
+ this.advanceQueue();
245
+ }
246
+ }
247
+
248
+ private advanceQueue(): void {
249
+ if (this.activeTaskId) return;
250
+ while (this.taskQueue.length > 0) {
251
+ const next = this.taskQueue[0];
252
+ if (this.taskHandlers.has(next)) {
253
+ this.activeTaskId = next;
254
+ this.flushBuffer(next);
255
+ return;
256
+ }
257
+ this.taskQueue.shift();
258
+ }
259
+ }
260
+
261
+ private flushBuffer(taskId: string): void {
262
+ const chunks = this.writeBuffer.get(taskId);
263
+ if (!chunks || chunks.length === 0) return;
264
+ for (const chunk of chunks) this.write(chunk);
265
+ this.writeBuffer.delete(taskId);
266
+ }
267
+
268
+ writeWhenActive(taskId: string, data: string): void {
269
+ if (!this.proc) return;
270
+ if (this.activeTaskId === taskId) {
271
+ this.write(data);
272
+ return;
273
+ }
274
+ let chunks = this.writeBuffer.get(taskId);
275
+ if (!chunks) { chunks = []; this.writeBuffer.set(taskId, chunks); }
276
+ chunks.push(data);
277
+ }
278
+
279
+ write(data: string): void {
280
+ if (!this.proc) return;
281
+ this.proc.stdin!.write(data);
282
+ }
283
+
284
+ async kill(): Promise<void> {
285
+ if (this.proc) {
286
+ log('info', 'persistent_kill', {});
287
+ if (this.detachReader) { this.detachReader(); this.detachReader = null; }
288
+ this.proc.kill('SIGTERM');
289
+ this.proc = null;
290
+ this.activeTaskId = null;
291
+ this.writeBuffer.clear();
292
+ }
293
+ }
294
+
295
+ get isAlive(): boolean { return this.proc !== null && this.proc.exitCode === null; }
296
+ getActiveTaskId(): string | null { return this.activeTaskId; }
297
+ }