@agnishc/edb-bridge 0.12.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.
package/src/index.ts ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * edb-bridge — lightweight inter-session message bus for orchestrator/sub-agent workflows.
3
+ *
4
+ * Since edb-subagents creates in-process sessions (not separate processes), per-session
5
+ * bridge context is injected via the system prompt as XML tags rather than env vars.
6
+ *
7
+ * System prompt tags (injected by edb-subagents into sub-agent prompts):
8
+ * <bridge_parent_session>{broker session ID of parent}</bridge_parent_session>
9
+ * <bridge_agent_id>{edb-subagents agent ID}</bridge_agent_id>
10
+ *
11
+ * Internal pi.events API:
12
+ * "bridge:ready" → { sessionId: string } emitted on broker connect
13
+ * "bridge:task_updated" → { storePath?: string } from edb-todo; routed to parent session
14
+ *
15
+ * LLM tools:
16
+ * ask_supervisor — blocking question to orchestrator (sub-agent only, when bridge context found)
17
+ * notify_parent — fire-and-forget progress update (sub-agent only, updates task widget spinner)
18
+ * send_to_main — fire-and-forget message that triggers an orchestrator LLM turn (sub-agent only)
19
+ * answer_subagent — reply to a pending sub-agent question (all sessions)
20
+ */
21
+
22
+ import { randomUUID } from "node:crypto";
23
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import { Text } from "@earendil-works/pi-tui";
25
+ import { Type } from "typebox";
26
+ import { BridgeClient } from "./client.js";
27
+ import { spawnBrokerIfNeeded } from "./spawn.js";
28
+ import type { BridgeMessage, SessionInfo } from "./types.js";
29
+
30
+ // ── Event names ────────────────────────────────────────────────────────────────
31
+
32
+ const EV_READY = "bridge:ready";
33
+ const EV_TASK_UPDATED = "bridge:task_updated";
34
+
35
+ const ASK_TIMEOUT_MS = 10 * 60 * 1000;
36
+
37
+ function getError(e: unknown): string {
38
+ return e instanceof Error ? e.message : String(e);
39
+ }
40
+
41
+ function textResult(text: string) {
42
+ return { content: [{ type: "text" as const, text }], details: undefined };
43
+ }
44
+
45
+ // ── Per-session bridge context ─────────────────────────────────────────────────
46
+
47
+ interface BridgeSessionContext {
48
+ parentSessionId: string;
49
+ agentId?: string;
50
+ /** Assigned task ID from <assigned_task_id> system prompt tag. */
51
+ taskId?: string;
52
+ }
53
+
54
+ // Parsed from system prompt — keyed by session ID
55
+ const sessionBridgeCtx = new Map<string, BridgeSessionContext>();
56
+
57
+ function parseBridgeContext(systemPrompt: string): BridgeSessionContext | null {
58
+ const parentMatch = systemPrompt.match(/<bridge_parent_session>(.*?)<\/bridge_parent_session>/s);
59
+ if (!parentMatch) return null;
60
+ const agentMatch = systemPrompt.match(/<bridge_agent_id>(.*?)<\/bridge_agent_id>/s);
61
+ const taskMatch = systemPrompt.match(/<assigned_task_id>(.*?)<\/assigned_task_id>/s);
62
+ return {
63
+ parentSessionId: parentMatch[1]!.trim(),
64
+ agentId: agentMatch?.[1]?.trim(),
65
+ taskId: taskMatch?.[1]?.trim(),
66
+ };
67
+ }
68
+
69
+ // ── Pending ask registry ───────────────────────────────────────────────────────
70
+
71
+ interface PendingAsk {
72
+ messageId: string;
73
+ fromSessionId: string;
74
+ agentId?: string;
75
+ /** Linked task ID — used to auto-unblock the task when answered. */
76
+ taskId?: string;
77
+ question: string;
78
+ resolve: (answer: string) => void;
79
+ reject: (err: Error) => void;
80
+ timer: ReturnType<typeof setTimeout>;
81
+ }
82
+
83
+ export default function edbBridgeExtension(pi: ExtensionAPI): void {
84
+ let client: BridgeClient | null = null;
85
+ let currentSessionId: string | null = null;
86
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
87
+ let reconnectAttempt = 0;
88
+ let shuttingDown = false;
89
+ let mainSessionId: string | null = null; // broker session ID of THIS session
90
+ /** The pi session ID of the orchestrator (main/first session_start). */
91
+ let mainPiSessionId: string | null = null;
92
+
93
+ const pendingAsks = new Map<string, PendingAsk>();
94
+
95
+ // Per-session outbound ask waiters: keyed by session ID
96
+ const outboundAskWaiters = new Map<
97
+ string,
98
+ {
99
+ replyTo: string;
100
+ resolve: (text: string) => void;
101
+ reject: (err: Error) => void;
102
+ }
103
+ >();
104
+
105
+ function waitForReply(sessionId: string, messageId: string, signal?: AbortSignal): Promise<string> {
106
+ if (outboundAskWaiters.has(sessionId)) {
107
+ return Promise.reject(new Error("Already waiting for a reply from supervisor in this session"));
108
+ }
109
+ if (signal?.aborted) return Promise.reject(new Error("Cancelled"));
110
+
111
+ return new Promise((resolve, reject) => {
112
+ const timeout = setTimeout(() => {
113
+ outboundAskWaiters.delete(sessionId);
114
+ reject(new Error("No reply from supervisor within 10 minutes"));
115
+ }, ASK_TIMEOUT_MS);
116
+
117
+ const onAbort = () => {
118
+ clearTimeout(timeout);
119
+ outboundAskWaiters.delete(sessionId);
120
+ reject(new Error("Cancelled"));
121
+ };
122
+ signal?.addEventListener("abort", onAbort, { once: true });
123
+
124
+ outboundAskWaiters.set(sessionId, {
125
+ replyTo: messageId,
126
+ resolve: (text) => {
127
+ clearTimeout(timeout);
128
+ signal?.removeEventListener("abort", onAbort);
129
+ outboundAskWaiters.delete(sessionId);
130
+ resolve(text);
131
+ },
132
+ reject: (err) => {
133
+ clearTimeout(timeout);
134
+ signal?.removeEventListener("abort", onAbort);
135
+ outboundAskWaiters.delete(sessionId);
136
+ reject(err);
137
+ },
138
+ });
139
+ });
140
+ }
141
+
142
+ function handleIncoming(from: SessionInfo, message: BridgeMessage): void {
143
+ // Check if this is a reply to an outbound ask
144
+ if (message.replyTo) {
145
+ for (const [, waiter] of outboundAskWaiters) {
146
+ if (waiter.replyTo === message.replyTo) {
147
+ waiter.resolve(message.content.text);
148
+ return;
149
+ }
150
+ }
151
+ }
152
+
153
+ // Task update notification — trigger widget refresh
154
+ if (message.type === "task_updated") {
155
+ pi.events.emit(EV_TASK_UPDATED, message.content.data ?? {});
156
+ return;
157
+ }
158
+
159
+ // Incoming ask from sub-agent
160
+ if (message.type === "ask_supervisor" && message.expectsReply) {
161
+ const agentId = message.content.data?.agentId as string | undefined;
162
+ const taskId = message.content.data?.taskId as string | undefined;
163
+
164
+ const timer = setTimeout(() => {
165
+ const ask = pendingAsks.get(message.id);
166
+ if (ask) {
167
+ pendingAsks.delete(message.id);
168
+ ask.reject(new Error("Ask timed out — no answer within 10 minutes"));
169
+ }
170
+ }, ASK_TIMEOUT_MS);
171
+
172
+ let resolveFn!: (answer: string) => void;
173
+ let rejectFn!: (err: Error) => void;
174
+ new Promise<string>((res, rej) => {
175
+ resolveFn = res;
176
+ rejectFn = rej;
177
+ });
178
+
179
+ pendingAsks.set(message.id, {
180
+ messageId: message.id,
181
+ fromSessionId: from.id,
182
+ agentId,
183
+ taskId,
184
+ question: message.content.text,
185
+ resolve: resolveFn,
186
+ reject: rejectFn,
187
+ timer,
188
+ });
189
+
190
+ // Auto-block the linked task in the widget
191
+ if (taskId) {
192
+ pi.events.emit("bridge:ask_supervisor", {
193
+ taskId,
194
+ question: message.content.text,
195
+ messageId: message.id,
196
+ });
197
+ }
198
+
199
+ const agentLabel = agentId ? `sub-agent ${agentId}` : `sub-agent (session ${from.id.slice(0, 8)})`;
200
+ const taskNote = taskId ? `\nLinked task: #${taskId}` : "";
201
+
202
+ pi.sendMessage(
203
+ {
204
+ customType: "bridge-ask",
205
+ content:
206
+ `**Question from ${agentLabel}:**\n\n${message.content.text}${taskNote}\n\n` +
207
+ `To answer: \`answer_subagent({ message_id: "${message.id}", answer: "..." })\``,
208
+ display: true,
209
+ },
210
+ { deliverAs: "followUp", triggerTurn: true },
211
+ );
212
+ return;
213
+ }
214
+
215
+ // Progress update from sub-agent
216
+ if (message.type === "notify_parent") {
217
+ const agentId = message.content.data?.agentId as string | undefined;
218
+ const taskId = message.content.data?.taskId as string | undefined;
219
+ const label = agentId ? agentId : `sub-agent (session ${from.id.slice(0, 8)})`;
220
+ // If task_id provided, fire an event so edb-todo can update activeForm in the widget
221
+ if (taskId) {
222
+ pi.events.emit("bridge:notify_parent", { taskId, message: message.content.text, agentId });
223
+ }
224
+ pi.sendMessage(
225
+ {
226
+ customType: "bridge-notify",
227
+ content: `**Update from ${label}:** ${message.content.text}`,
228
+ display: true,
229
+ },
230
+ { deliverAs: "followUp" },
231
+ );
232
+ }
233
+
234
+ // Direct message from sub-agent — triggers an orchestrator LLM turn
235
+ if (message.type === "send_to_main") {
236
+ const agentId = message.content.data?.agentId as string | undefined;
237
+ const taskId = message.content.data?.taskId as string | undefined;
238
+ const label = agentId ? agentId : `sub-agent (session ${from.id.slice(0, 8)})`;
239
+ const taskNote = taskId ? ` (task #${taskId})` : "";
240
+ pi.sendMessage(
241
+ {
242
+ customType: "bridge-send-to-main",
243
+ content: `**Message from ${label}${taskNote}:** ${message.content.text}`,
244
+ display: true,
245
+ },
246
+ { deliverAs: "followUp", triggerTurn: true },
247
+ );
248
+ }
249
+ }
250
+
251
+ async function ensureConnected(): Promise<BridgeClient> {
252
+ if (shuttingDown) throw new Error("bridge shutting down");
253
+ if (client?.isConnected()) return client;
254
+
255
+ await spawnBrokerIfNeeded();
256
+
257
+ const nextClient = new BridgeClient();
258
+ nextClient.on("message", (from: SessionInfo, message: BridgeMessage) => {
259
+ if (client === nextClient) handleIncoming(from, message);
260
+ });
261
+ nextClient.on("disconnected", () => {
262
+ if (client !== nextClient) return;
263
+ client = null;
264
+ if (!shuttingDown) scheduleReconnect();
265
+ });
266
+ nextClient.on("error", () => {
267
+ /* handled by disconnect */
268
+ });
269
+
270
+ await nextClient.connect({
271
+ cwd: process.cwd(),
272
+ pid: process.pid,
273
+ startedAt: Date.now(),
274
+ });
275
+
276
+ client = nextClient;
277
+ reconnectAttempt = 0;
278
+ mainSessionId = client.sessionId;
279
+ pi.events.emit(EV_READY, { sessionId: mainSessionId });
280
+
281
+ return nextClient;
282
+ }
283
+
284
+ function scheduleReconnect(): void {
285
+ if (reconnectTimer || shuttingDown) return;
286
+ const delays = [1000, 2000, 5000, 10000, 30000];
287
+ const delay = delays[Math.min(reconnectAttempt, delays.length - 1)]!;
288
+ reconnectTimer = setTimeout(async () => {
289
+ reconnectTimer = null;
290
+ reconnectAttempt++;
291
+ try {
292
+ await ensureConnected();
293
+ } catch {
294
+ scheduleReconnect();
295
+ }
296
+ }, delay);
297
+ }
298
+
299
+ // edb-todo emits bridge:task_updated when tasks change — route to parent
300
+ pi.events.on(EV_TASK_UPDATED, async (payload: unknown) => {
301
+ // Only route if this event came from a sub-agent session (has bridge context)
302
+ const p = payload as { storePath?: string; sessionId?: string } | undefined;
303
+ const sessionId = p?.sessionId;
304
+ if (!sessionId) return;
305
+ const ctx = sessionBridgeCtx.get(sessionId);
306
+ if (!ctx) return;
307
+
308
+ try {
309
+ const c = await ensureConnected();
310
+ await c.send(ctx.parentSessionId, {
311
+ type: "task_updated",
312
+ text: "task store updated",
313
+ data: (payload as Record<string, unknown>) ?? {},
314
+ });
315
+ } catch {
316
+ /* best-effort */
317
+ }
318
+ });
319
+
320
+ // ── Session lifecycle ──────────────────────────────────────────────────────
321
+
322
+ pi.on("session_start", async (_event, ctx) => {
323
+ currentSessionId = ctx.sessionManager.getSessionId();
324
+ // Track the first (orchestrator) session — sub-agent sessions must not shut down the client
325
+ if (!mainPiSessionId) mainPiSessionId = currentSessionId;
326
+ shuttingDown = false;
327
+ // Only connect for the main (orchestrator) session.
328
+ // Sub-agent sessions run in-process and share the same client — reconnecting would
329
+ // replace the orchestrator's broker session ID, breaking cross-session routing.
330
+ if (currentSessionId === mainPiSessionId) {
331
+ try {
332
+ await ensureConnected();
333
+ } catch {
334
+ scheduleReconnect();
335
+ }
336
+ }
337
+ });
338
+
339
+ pi.on("before_agent_start", async (event, ctx) => {
340
+ const sessionId = ctx.sessionManager.getSessionId();
341
+ currentSessionId = sessionId;
342
+ // Parse bridge context from system prompt (first time only)
343
+ if (!sessionBridgeCtx.has(sessionId)) {
344
+ const bridgeCtx = parseBridgeContext(event.systemPrompt);
345
+ if (bridgeCtx) {
346
+ sessionBridgeCtx.set(sessionId, bridgeCtx);
347
+ }
348
+ }
349
+ });
350
+
351
+ pi.on("session_shutdown", async () => {
352
+ const isMainSession = currentSessionId === mainPiSessionId;
353
+
354
+ // Clean up per-session state regardless
355
+ if (currentSessionId) sessionBridgeCtx.delete(currentSessionId);
356
+ // Clean up outbound waiters for this session only
357
+ const sessionWaiter = outboundAskWaiters.get(currentSessionId ?? "");
358
+ if (sessionWaiter) {
359
+ sessionWaiter.reject(new Error("Session shutting down"));
360
+ outboundAskWaiters.delete(currentSessionId ?? "");
361
+ }
362
+
363
+ if (isMainSession) {
364
+ // Full teardown — this is the orchestrator shutting down
365
+ shuttingDown = true;
366
+ if (reconnectTimer) {
367
+ clearTimeout(reconnectTimer);
368
+ reconnectTimer = null;
369
+ }
370
+ for (const ask of pendingAsks.values()) {
371
+ clearTimeout(ask.timer);
372
+ ask.reject(new Error("Session shutting down"));
373
+ }
374
+ pendingAsks.clear();
375
+ for (const waiter of outboundAskWaiters.values()) {
376
+ waiter.reject(new Error("Session shutting down"));
377
+ }
378
+ outboundAskWaiters.clear();
379
+ if (client) {
380
+ await client.disconnect();
381
+ client = null;
382
+ }
383
+ currentSessionId = null;
384
+ mainSessionId = null;
385
+ mainPiSessionId = null;
386
+ }
387
+ // Sub-agent session shutdown: do NOT disconnect the shared client
388
+ // The orchestrator's connection must remain active
389
+ // Restore currentSessionId to the main session after sub-agent exits
390
+ if (!isMainSession) {
391
+ currentSessionId = mainPiSessionId;
392
+ }
393
+ });
394
+
395
+ // ── Tools ──────────────────────────────────────────────────────────────────
396
+
397
+ // ── answer_subagent (all sessions) ────────────────────────────────────────
398
+
399
+ pi.registerTool({
400
+ name: "answer_subagent",
401
+ label: "Answer Sub-agent",
402
+ description:
403
+ "Reply to a pending question from a sub-agent. The sub-agent is blocking and waiting for your answer.\n\n" +
404
+ "Use the message_id shown in the question notification.",
405
+ parameters: Type.Object({
406
+ message_id: Type.String({ description: "The message ID from the sub-agent's question." }),
407
+ answer: Type.String({ description: "Your answer. The sub-agent will resume with this." }),
408
+ }),
409
+
410
+ async execute(_id, params) {
411
+ const ask = pendingAsks.get(params.message_id);
412
+ if (!ask) {
413
+ return textResult(
414
+ `No pending question with id "${params.message_id}". It may have timed out or already been answered.`,
415
+ );
416
+ }
417
+
418
+ try {
419
+ const c = await ensureConnected();
420
+ const result = await c.send(ask.fromSessionId, {
421
+ type: "supervisor_reply",
422
+ text: params.answer,
423
+ replyTo: ask.messageId,
424
+ });
425
+
426
+ clearTimeout(ask.timer);
427
+ pendingAsks.delete(ask.messageId);
428
+ ask.resolve(params.answer);
429
+
430
+ // Auto-unblock the linked task
431
+ if (ask.taskId) {
432
+ pi.events.emit("bridge:supervisor_answered", { taskId: ask.taskId });
433
+ }
434
+
435
+ if (!result.delivered) {
436
+ return textResult(
437
+ `Answer could not reach sub-agent (${result.reason ?? "disconnected"}). The ask was resolved locally.`,
438
+ );
439
+ }
440
+ return textResult(`Answer delivered to ${ask.agentId ? `sub-agent ${ask.agentId}` : "sub-agent"}.`);
441
+ } catch (err) {
442
+ return textResult(`Failed to deliver answer: ${getError(err)}`);
443
+ }
444
+ },
445
+
446
+ renderCall(args, theme) {
447
+ const preview = ((args.answer as string) ?? "").slice(0, 80);
448
+ return new Text(
449
+ `${theme.fg("toolTitle", theme.bold("answer_subagent "))}${theme.fg("muted", `→ ${preview}`)}`,
450
+ 0,
451
+ 0,
452
+ );
453
+ },
454
+ });
455
+
456
+ // ── ask_supervisor (sub-agent sessions with bridge context) ───────────────
457
+
458
+ pi.registerTool({
459
+ name: "ask_supervisor",
460
+ label: "Ask Supervisor",
461
+ description:
462
+ "Ask the orchestrator a blocking question and wait for their reply before continuing.\n\n" +
463
+ "Use when blocked, uncertain, or facing a decision. The tool blocks until the supervisor replies.\n" +
464
+ "Do NOT use for routine completion — return results normally.",
465
+ promptSnippet: "Ask the orchestrator a blocking question and wait for their reply.",
466
+ promptGuidelines: [
467
+ "Use ask_supervisor when blocked by a decision or missing critical information.",
468
+ "Do not use for routine task completion.",
469
+ ],
470
+ parameters: Type.Object({
471
+ question: Type.String({ description: "The question for the supervisor." }),
472
+ task_id: Type.Optional(Type.String({ description: "Optional: linked task ID." })),
473
+ }),
474
+
475
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
476
+ const sessionId = ctx.sessionManager.getSessionId();
477
+ const bridgeCtx = sessionBridgeCtx.get(sessionId);
478
+
479
+ if (!bridgeCtx) {
480
+ return textResult(
481
+ "ask_supervisor: not in a sub-agent session with bridge support. Is PI_BRIDGE context injected into the system prompt?",
482
+ );
483
+ }
484
+
485
+ const c = await ensureConnected().catch((e) => {
486
+ throw new Error(`Bridge not connected: ${getError(e)}`);
487
+ });
488
+
489
+ const messageId = randomUUID();
490
+ const replyPromise = waitForReply(sessionId, messageId, signal);
491
+ replyPromise.catch(() => undefined);
492
+
493
+ try {
494
+ const result = await c.send(bridgeCtx.parentSessionId, {
495
+ type: "ask_supervisor",
496
+ text: params.question,
497
+ messageId,
498
+ expectsReply: true,
499
+ data: { agentId: bridgeCtx.agentId, taskId: params.task_id ?? bridgeCtx.taskId },
500
+ });
501
+
502
+ if (!result.delivered) {
503
+ outboundAskWaiters.get(sessionId)?.reject(new Error(`Supervisor not reachable: ${result.reason}`));
504
+ return textResult(`Supervisor not reachable: ${result.reason ?? "session not found"}`);
505
+ }
506
+ } catch (err) {
507
+ outboundAskWaiters.get(sessionId)?.reject(new Error(getError(err)));
508
+ return textResult(`Failed to send question: ${getError(err)}`);
509
+ }
510
+
511
+ try {
512
+ const answer = await replyPromise;
513
+ return textResult(`Supervisor replied: ${answer}`);
514
+ } catch (err) {
515
+ return textResult(`Ask failed: ${getError(err)}`);
516
+ }
517
+ },
518
+
519
+ renderCall(args, theme) {
520
+ const q = ((args.question as string) ?? "").slice(0, 80);
521
+ return new Text(
522
+ `${theme.fg("toolTitle", theme.bold("ask_supervisor "))}${theme.fg("warning", "⏸")} ${theme.fg("muted", q)}`,
523
+ 0,
524
+ 0,
525
+ );
526
+ },
527
+
528
+ renderResult(result, { isPartial }, theme) {
529
+ if (isPartial) return new Text(theme.fg("warning", "⏸ waiting for supervisor..."), 0, 0);
530
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
531
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text.slice(0, 120)), 0, 0);
532
+ },
533
+ });
534
+
535
+ // ── notify_parent (sub-agent sessions with bridge context) ────────────────
536
+
537
+ pi.registerTool({
538
+ name: "notify_parent",
539
+ label: "Notify Parent",
540
+ description:
541
+ "Send a fire-and-forget progress update to the orchestrator.\n\n" +
542
+ "Use for meaningful milestones or plan-changing discoveries. Does not block.\n" +
543
+ "When your task_id is known (injected at spawn), it's automatically used to update the widget spinner.",
544
+ promptSnippet: "Send a non-blocking progress update to the orchestrator.",
545
+ promptGuidelines: [
546
+ "Use notify_parent only for meaningful progress or plan-changing discoveries.",
547
+ "Do not use for routine task completion.",
548
+ ],
549
+ parameters: Type.Object({
550
+ message: Type.String({ description: "Progress update for the supervisor." }),
551
+ task_id: Type.Optional(Type.String({ description: "Optional: linked task ID." })),
552
+ }),
553
+
554
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
555
+ const sessionId = ctx.sessionManager.getSessionId();
556
+ const bridgeCtx = sessionBridgeCtx.get(sessionId);
557
+
558
+ if (!bridgeCtx) {
559
+ return textResult("notify_parent: not in a sub-agent session with bridge support.");
560
+ }
561
+
562
+ try {
563
+ const c = await ensureConnected();
564
+ const result = await c.send(bridgeCtx.parentSessionId, {
565
+ type: "notify_parent",
566
+ text: params.message,
567
+ data: { agentId: bridgeCtx.agentId, taskId: params.task_id ?? bridgeCtx.taskId },
568
+ });
569
+ if (!result.delivered) {
570
+ return textResult(
571
+ `Update could not be delivered (${result.reason ?? "supervisor not reachable"}). Continuing.`,
572
+ );
573
+ }
574
+ return textResult("Update sent to supervisor.");
575
+ } catch (err) {
576
+ return textResult(`Failed to send update: ${getError(err)}. Continuing.`);
577
+ }
578
+ },
579
+
580
+ renderCall(args, theme) {
581
+ return new Text(
582
+ `${theme.fg("toolTitle", theme.bold("notify_parent "))}${theme.fg("muted", ((args.message as string) ?? "").slice(0, 80))}`,
583
+ 0,
584
+ 0,
585
+ );
586
+ },
587
+ });
588
+
589
+ // ── send_to_main (sub-agent sessions with bridge context) ────────────────────────────────────
590
+
591
+ pi.registerTool({
592
+ name: "send_to_main",
593
+ label: "Send to Main",
594
+ description:
595
+ "Send a message to the orchestrator that triggers an immediate LLM response turn.\n\n" +
596
+ "Unlike notify_parent (which only updates the task widget spinner), this delivers a message " +
597
+ "into the orchestrator's conversation and wakes it up to respond. " +
598
+ "Use for important findings that require orchestrator action or decision, " +
599
+ "or to report a result that the orchestrator should act on without waiting for you to complete fully.",
600
+ promptSnippet: "Send a message to the orchestrator that triggers an immediate response turn.",
601
+ promptGuidelines: [
602
+ "Use send_to_main for important findings or decisions that require orchestrator action now.",
603
+ "For routine progress updates that only update the spinner, use notify_parent instead.",
604
+ "Does not block — the orchestrator will process the message asynchronously.",
605
+ ],
606
+ parameters: Type.Object({
607
+ message: Type.String({ description: "The message to send to the orchestrator." }),
608
+ task_id: Type.Optional(Type.String({ description: "Optional: linked task ID shown in the message header." })),
609
+ }),
610
+
611
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
612
+ const sessionId = ctx.sessionManager.getSessionId();
613
+ const bridgeCtx = sessionBridgeCtx.get(sessionId);
614
+
615
+ if (!bridgeCtx) {
616
+ return textResult("send_to_main: not in a sub-agent session with bridge support.");
617
+ }
618
+
619
+ try {
620
+ const c = await ensureConnected();
621
+ const result = await c.send(bridgeCtx.parentSessionId, {
622
+ type: "send_to_main",
623
+ text: params.message,
624
+ data: { agentId: bridgeCtx.agentId, taskId: params.task_id ?? bridgeCtx.taskId },
625
+ });
626
+ if (!result.delivered) {
627
+ return textResult(
628
+ `Message could not be delivered (${result.reason ?? "orchestrator not reachable"}). Continuing.`,
629
+ );
630
+ }
631
+ return textResult("Message sent to orchestrator.");
632
+ } catch (err) {
633
+ return textResult(`Failed to send message: ${getError(err)}. Continuing.`);
634
+ }
635
+ },
636
+
637
+ renderCall(args, theme) {
638
+ return new Text(
639
+ `${theme.fg("toolTitle", theme.bold("send_to_main "))}${theme.fg("accent", ((args.message as string) ?? "").slice(0, 80))}`,
640
+ 0,
641
+ 0,
642
+ );
643
+ },
644
+ });
645
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ const BRIDGE_DIR = join(homedir(), ".pi", "agent", "edb-bridge");
5
+
6
+ export function getBrokerSocketPath(): string {
7
+ if (process.platform === "win32") {
8
+ const seg = homedir()
9
+ .replace(/[^a-zA-Z0-9]/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .toLowerCase();
12
+ return `\\\\.\\pipe\\edb-bridge-${seg}`;
13
+ }
14
+ return join(BRIDGE_DIR, "broker.sock");
15
+ }
16
+
17
+ export function getBrokerPidPath(): string {
18
+ return join(BRIDGE_DIR, "broker.pid");
19
+ }
20
+
21
+ export function getSpawnLockPath(): string {
22
+ return join(BRIDGE_DIR, "broker.spawn.lock");
23
+ }
24
+
25
+ export { BRIDGE_DIR };