pi-maestro-teammate 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-maestro-teammate",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension for teammate dispatch with P0 three-axis decoupling (name, reply_to, lifecycle)",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Teammate Extension Entry Point
3
3
  *
4
- * Registers the 'teammate' tool with pi via ExtensionAPI.
5
- * Handles session lifecycle (start, shutdown) and dispatches
6
- * to the execution engine. Supports single, parallel, and chain modes.
4
+ * Registers tools:
5
+ * - teammate: dispatch tasks to sub-agents (single/parallel/chain, await/detach)
6
+ * - teammate-send: send messages to named running agents
7
+ * - teammate-list: list active/named agents
8
+ *
9
+ * P1: inbox message queue + named agent registry + detach mode
7
10
  */
8
11
 
9
12
  import { randomUUID } from "node:crypto";
@@ -13,11 +16,12 @@ import type {
13
16
  ToolDefinition,
14
17
  } from "@earendil-works/pi-coding-agent";
15
18
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
- import { TeammateParams } from "./schemas.ts";
19
+ import { TeammateParams, TeammateSendParams, TeammateListParams } from "./schemas.ts";
17
20
  import {
18
21
  runTeammate,
19
22
  runParallel,
20
23
  runChain,
24
+ sendToChildStdin,
21
25
  type RunTeammateParams,
22
26
  } from "../runs/execution.ts";
23
27
  import {
@@ -28,10 +32,13 @@ import type {
28
32
  Details,
29
33
  TeammateState,
30
34
  AgentProgress,
35
+ ActiveAgent,
36
+ MessageEnvelope,
31
37
  } from "../shared/types.ts";
32
38
  import {
33
39
  TEAMMATE_COMPLETE_EVENT,
34
40
  TEAMMATE_STARTED_EVENT,
41
+ TEAMMATE_MESSAGE_EVENT,
35
42
  } from "../shared/types.ts";
36
43
 
37
44
  export default function registerTeammateExtension(pi: ExtensionAPI): void {
@@ -43,8 +50,13 @@ export default function registerTeammateExtension(pi: ExtensionAPI): void {
43
50
  baseCwd: "",
44
51
  currentSessionId: null,
45
52
  activeRuns: new Map(),
53
+ namedAgents: new Map(),
46
54
  };
47
55
 
56
+ // =========================================================================
57
+ // Tool 1: teammate — dispatch
58
+ // =========================================================================
59
+
48
60
  const tool: ToolDefinition<typeof TeammateParams, Details> = {
49
61
  name: "teammate",
50
62
  label: "Teammate",
@@ -56,9 +68,12 @@ Modes:
56
68
  - Chain: { chain: [{ agent: "scout", task: "Find auth code" }, { agent: "delegate", task: "Fix: {previous}" }] }
57
69
 
58
70
  Three-axis control:
59
- - name: Optional addressable name for cross-agent routing
71
+ - name: Optional addressable name for cross-agent routing (enables teammate-send)
60
72
  - reply_to: Result routing — "caller" (direct return) or "main" (broadcast to parent session)
61
- - lifecycle: "ephemeral" (default, one-shot) or "resident" (persistent)
73
+ - lifecycle: "ephemeral" (default, one-shot) or "resident" (persistent, stays alive for messages)
74
+
75
+ Execution mode:
76
+ - mode: "await" (default) blocks until result; "detach" returns immediately with a handle
62
77
 
63
78
  Structured output:
64
79
  - outputSchema: JSON Schema to validate and parse child output as structured data`,
@@ -77,16 +92,42 @@ Structured output:
77
92
  const correlationId = randomUUID();
78
93
 
79
94
  const abortController = new AbortController();
80
- state.activeRuns.set(correlationId, {
95
+ // P2-1: Deadlock detection — check if reply_to creates a cycle
96
+ if (params.name && params.reply_to !== "main" && params.reply_to !== "caller") {
97
+ const wouldCycle = detectReplyCycle(state, params.name, params.reply_to);
98
+ if (wouldCycle) {
99
+ return {
100
+ content: [{
101
+ type: "text",
102
+ text: `[deadlock] reply_to="${params.reply_to}" would create a cycle with agent "${params.name}". Falling back to reply_to="caller".`,
103
+ }],
104
+ isError: false,
105
+ details: { mode: "single", results: [] },
106
+ };
107
+ }
108
+ }
109
+
110
+ const activeAgent: ActiveAgent = {
81
111
  agent: params.agent ?? "parallel",
112
+ name: params.name,
82
113
  correlationId,
83
114
  startedAt: Date.now(),
84
115
  abortController,
85
- });
116
+ lifecycle: params.lifecycle ?? "ephemeral",
117
+ inbox: [],
118
+ lastActivityAt: Date.now(),
119
+ replyTo: params.reply_to,
120
+ };
121
+ state.activeRuns.set(correlationId, activeAgent);
122
+
123
+ if (params.name) {
124
+ state.namedAgents.set(params.name, correlationId);
125
+ }
86
126
 
87
127
  pi.events.emit(TEAMMATE_STARTED_EVENT, {
88
128
  id,
89
129
  agent: params.agent ?? "parallel",
130
+ name: params.name,
90
131
  correlationId,
91
132
  });
92
133
 
@@ -99,7 +140,12 @@ Structured output:
99
140
  baseCwd: state.baseCwd || ctx.cwd,
100
141
  signal: abortController.signal,
101
142
  parentSessionFile,
143
+ onChildSpawned: (stdin: import("node:stream").Writable) => {
144
+ activeAgent.stdin = stdin;
145
+ drainInbox(activeAgent);
146
+ },
102
147
  onProgress: (data: AgentProgress) => {
148
+ activeAgent.lastActivityAt = Date.now();
103
149
  if (!onUpdate) return;
104
150
  onUpdate({
105
151
  content: [{
@@ -137,13 +183,8 @@ Structured output:
137
183
  .map((r) => `[${r.agent}] ${r.exitCode === 0 ? "OK" : "FAIL"}: ${r.messages[r.messages.length - 1]?.content ?? "(no output)"}`)
138
184
  .join("\n\n");
139
185
 
140
- pi.events.emit(TEAMMATE_COMPLETE_EVENT, {
141
- id,
142
- agent: "parallel",
143
- correlationId,
144
- exitCode: hasError ? 1 : 0,
145
- durationMs: Math.max(...results.map((r) => r.durationMs)),
146
- });
186
+ emitComplete(pi, id, "parallel", correlationId, hasError ? 1 : 0,
187
+ Math.max(...results.map((r) => r.durationMs)));
147
188
 
148
189
  return {
149
190
  content: [{ type: "text", text: summaries }],
@@ -164,13 +205,8 @@ Structured output:
164
205
  const hasError = results.some((r) => r.exitCode !== 0);
165
206
  const lastMessage = lastResult?.messages[lastResult.messages.length - 1]?.content ?? "(no output)";
166
207
 
167
- pi.events.emit(TEAMMATE_COMPLETE_EVENT, {
168
- id,
169
- agent: "chain",
170
- correlationId,
171
- exitCode: hasError ? 1 : 0,
172
- durationMs: results.reduce((sum, r) => sum + r.durationMs, 0),
173
- });
208
+ emitComplete(pi, id, "chain", correlationId, hasError ? 1 : 0,
209
+ results.reduce((sum, r) => sum + r.durationMs, 0));
174
210
 
175
211
  return {
176
212
  content: [{ type: "text", text: lastMessage }],
@@ -179,7 +215,26 @@ Structured output:
179
215
  };
180
216
  }
181
217
 
182
- // --- SINGLE MODE ---
218
+ // --- DETACH MODE ---
219
+ if (params.mode === "detach") {
220
+ const detachPromise = runTeammate(params, makeOptions());
221
+
222
+ detachPromise.then((result) => {
223
+ emitComplete(pi, id, params.agent, correlationId, result.exitCode, result.durationMs);
224
+ cleanupAgent(state, correlationId, params.name);
225
+ });
226
+
227
+ return {
228
+ content: [{
229
+ type: "text",
230
+ text: `[detached] Agent "${params.agent}" dispatched in background. correlationId=${correlationId}${params.name ? `, name="${params.name}"` : ""}. Use teammate-send to inject messages, teammate-list to check status.`,
231
+ }],
232
+ isError: false,
233
+ details: { mode: "single", results: [] },
234
+ };
235
+ }
236
+
237
+ // --- SINGLE AWAIT MODE ---
183
238
  const result = await runTeammate(params, makeOptions());
184
239
 
185
240
  const lastMessage =
@@ -195,17 +250,13 @@ Structured output:
195
250
  toolResult.details!.structuredOutput = result.structuredOutput;
196
251
  }
197
252
 
198
- pi.events.emit(TEAMMATE_COMPLETE_EVENT, {
199
- id,
200
- agent: params.agent,
201
- correlationId,
202
- exitCode: result.exitCode,
203
- durationMs: result.durationMs,
204
- });
253
+ emitComplete(pi, id, params.agent, correlationId, result.exitCode, result.durationMs);
205
254
 
206
255
  return toolResult;
207
256
  } finally {
208
- state.activeRuns.delete(correlationId);
257
+ if (params.mode !== "detach") {
258
+ cleanupAgent(state, correlationId, params.name);
259
+ }
209
260
  signal.removeEventListener("abort", abortForward);
210
261
  }
211
262
  },
@@ -219,18 +270,286 @@ Structured output:
219
270
  },
220
271
  };
221
272
 
273
+ // =========================================================================
274
+ // Tool 2: teammate-send — send message to named agent
275
+ // =========================================================================
276
+
277
+ const sendTool: ToolDefinition<typeof TeammateSendParams, { delivered: boolean }> = {
278
+ name: "teammate-send",
279
+ label: "Teammate Send",
280
+ description: `Send a message to a named, running teammate agent.
281
+
282
+ The target agent must have been dispatched with a "name" field and still be running.
283
+ Messages are injected into the agent's stdin as user messages.
284
+
285
+ Use "kind: notification" for context injection (fire-and-forget).
286
+ Use "kind: task" to assign additional work.`,
287
+
288
+ parameters: TeammateSendParams,
289
+
290
+ async execute(
291
+ _id: string,
292
+ params: { to: string; message: string; kind?: "notification" | "task" },
293
+ ): Promise<AgentToolResult<{ delivered: boolean }>> {
294
+ const correlationId = state.namedAgents.get(params.to);
295
+ if (!correlationId) {
296
+ const available = Array.from(state.namedAgents.keys());
297
+ return {
298
+ content: [{
299
+ type: "text",
300
+ text: `Agent "${params.to}" not found. ${available.length > 0 ? `Available named agents: ${available.join(", ")}` : "No named agents are currently running."}`,
301
+ }],
302
+ isError: true,
303
+ details: { delivered: false },
304
+ };
305
+ }
306
+
307
+ const agent = state.activeRuns.get(correlationId);
308
+ if (!agent) {
309
+ state.namedAgents.delete(params.to);
310
+ return {
311
+ content: [{ type: "text", text: `Agent "${params.to}" is no longer running.` }],
312
+ isError: true,
313
+ details: { delivered: false },
314
+ };
315
+ }
316
+
317
+ const envelope: MessageEnvelope = {
318
+ id: randomUUID(),
319
+ from: "caller",
320
+ to: params.to,
321
+ kind: params.kind ?? "notification",
322
+ payload: params.message,
323
+ timestamp: Date.now(),
324
+ };
325
+
326
+ if (agent.stdin) {
327
+ const sent = sendToChildStdin(agent.stdin, params.message);
328
+ if (sent) {
329
+ agent.lastActivityAt = Date.now();
330
+ pi.events.emit(TEAMMATE_MESSAGE_EVENT, envelope);
331
+ return {
332
+ content: [{
333
+ type: "text",
334
+ text: `Message delivered to "${params.to}" (kind: ${envelope.kind}).`,
335
+ }],
336
+ isError: false,
337
+ details: { delivered: true },
338
+ };
339
+ }
340
+ }
341
+
342
+ agent.inbox.push(envelope);
343
+ return {
344
+ content: [{
345
+ type: "text",
346
+ text: `Message queued for "${params.to}" (stdin not ready yet, will deliver when available).`,
347
+ }],
348
+ isError: false,
349
+ details: { delivered: false },
350
+ };
351
+ },
352
+ };
353
+
354
+ // =========================================================================
355
+ // Tool 3: teammate-list — list active agents
356
+ // =========================================================================
357
+
358
+ const listTool: ToolDefinition<typeof TeammateListParams, { agents: unknown[] }> = {
359
+ name: "teammate-list",
360
+ label: "Teammate List",
361
+ description: `List active teammate agents.
362
+
363
+ Views:
364
+ - "active": All running agents (default)
365
+ - "named": Only named/addressable agents
366
+ - "all": All agents including completed metadata`,
367
+
368
+ parameters: TeammateListParams,
369
+
370
+ async execute(
371
+ _id: string,
372
+ params: { view?: "active" | "named" | "all" },
373
+ ): Promise<AgentToolResult<{ agents: unknown[] }>> {
374
+ const view = params.view ?? "active";
375
+ const agents: Array<{
376
+ agent: string;
377
+ name?: string;
378
+ correlationId: string;
379
+ lifecycle: string;
380
+ startedAt: string;
381
+ durationMs: number;
382
+ inboxSize: number;
383
+ hasStdin: boolean;
384
+ }> = [];
385
+
386
+ for (const [cid, entry] of state.activeRuns) {
387
+ if (view === "named" && !entry.name) continue;
388
+
389
+ agents.push({
390
+ agent: entry.agent,
391
+ name: entry.name,
392
+ correlationId: cid,
393
+ lifecycle: entry.lifecycle,
394
+ startedAt: new Date(entry.startedAt).toISOString(),
395
+ durationMs: Date.now() - entry.startedAt,
396
+ idleMs: Date.now() - entry.lastActivityAt,
397
+ inboxSize: entry.inbox.length,
398
+ hasStdin: Boolean(entry.stdin?.writable),
399
+ });
400
+ }
401
+
402
+ const lines = agents.length > 0
403
+ ? agents.map((a) =>
404
+ `[${a.agent}]${a.name ? ` name="${a.name}"` : ""} ${a.lifecycle} | up ${Math.round(a.durationMs / 1000)}s | idle ${Math.round(a.idleMs / 1000)}s | inbox: ${a.inboxSize} | stdin: ${a.hasStdin ? "ready" : "pending"}`
405
+ ).join("\n")
406
+ : "No active teammate agents.";
407
+
408
+ return {
409
+ content: [{ type: "text", text: lines }],
410
+ isError: false,
411
+ details: { agents },
412
+ };
413
+ },
414
+ };
415
+
416
+ // =========================================================================
417
+ // Register all tools
418
+ // =========================================================================
419
+
222
420
  pi.registerTool(tool);
421
+ pi.registerTool(sendTool);
422
+ pi.registerTool(listTool);
423
+
424
+ // =========================================================================
425
+ // Session lifecycle + P2-2 reaper
426
+ // =========================================================================
427
+
428
+ let reaperTimer: ReturnType<typeof setInterval> | null = null;
223
429
 
224
430
  pi.on("session_start", (_event, ctx) => {
225
431
  state.baseCwd = ctx.cwd;
226
432
  state.currentSessionId = ctx.sessionManager?.getSessionId() ?? null;
433
+ reaperTimer = startReaper(state, pi);
227
434
  });
228
435
 
229
436
  pi.on("session_shutdown", () => {
437
+ if (reaperTimer) {
438
+ clearInterval(reaperTimer);
439
+ reaperTimer = null;
440
+ }
230
441
  for (const run of state.activeRuns.values()) {
231
442
  run.abortController.abort();
232
443
  }
233
444
  state.activeRuns.clear();
445
+ state.namedAgents.clear();
234
446
  state.currentSessionId = null;
235
447
  });
236
448
  }
449
+
450
+ // ===========================================================================
451
+ // Helpers
452
+ // ===========================================================================
453
+
454
+ function emitComplete(
455
+ pi: ExtensionAPI,
456
+ id: string,
457
+ agent: string,
458
+ correlationId: string,
459
+ exitCode: number,
460
+ durationMs: number,
461
+ ): void {
462
+ pi.events.emit(TEAMMATE_COMPLETE_EVENT, {
463
+ id, agent, correlationId, exitCode, durationMs,
464
+ });
465
+ }
466
+
467
+ function cleanupAgent(
468
+ state: TeammateState,
469
+ correlationId: string,
470
+ name?: string,
471
+ ): void {
472
+ state.activeRuns.delete(correlationId);
473
+ if (name) {
474
+ state.namedAgents.delete(name);
475
+ }
476
+ }
477
+
478
+ function drainInbox(agent: ActiveAgent): void {
479
+ if (!agent.stdin || !agent.inbox.length) return;
480
+ for (const msg of agent.inbox) {
481
+ sendToChildStdin(agent.stdin, msg.payload);
482
+ }
483
+ agent.inbox.length = 0;
484
+ }
485
+
486
+ // ===========================================================================
487
+ // P2-1: Reply-cycle deadlock detection
488
+ // ===========================================================================
489
+
490
+ function detectReplyCycle(
491
+ state: TeammateState,
492
+ fromName: string | undefined,
493
+ replyToName: string | undefined,
494
+ ): boolean {
495
+ if (!fromName || !replyToName) return false;
496
+ if (fromName === replyToName) return true;
497
+
498
+ const visited = new Set<string>([fromName]);
499
+ let current = replyToName;
500
+
501
+ while (current) {
502
+ if (visited.has(current)) return true;
503
+ visited.add(current);
504
+
505
+ const cid = state.namedAgents.get(current);
506
+ if (!cid) break;
507
+ const agent = state.activeRuns.get(cid);
508
+ if (!agent?.replyTo) break;
509
+ current = agent.replyTo;
510
+ }
511
+
512
+ return false;
513
+ }
514
+
515
+ // ===========================================================================
516
+ // P2-2: Resident agent idle timeout + reaper
517
+ // ===========================================================================
518
+
519
+ const IDLE_WARN_MS = 5 * 60 * 1000; // 5 min: send nudge
520
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min: force terminate
521
+ const REAPER_INTERVAL_MS = 60 * 1000;
522
+
523
+ function startReaper(state: TeammateState, pi: ExtensionAPI): ReturnType<typeof setInterval> {
524
+ const nudged = new Set<string>();
525
+
526
+ return setInterval(() => {
527
+ const now = Date.now();
528
+
529
+ for (const [cid, agent] of state.activeRuns) {
530
+ if (agent.lifecycle !== "resident") continue;
531
+ const idle = now - agent.lastActivityAt;
532
+
533
+ if (idle > IDLE_TIMEOUT_MS) {
534
+ agent.abortController.abort();
535
+ pi.events.emit(TEAMMATE_COMPLETE_EVENT, {
536
+ id: cid,
537
+ agent: agent.agent,
538
+ correlationId: agent.correlationId,
539
+ exitCode: 0,
540
+ durationMs: now - agent.startedAt,
541
+ reason: "idle_timeout",
542
+ });
543
+ nudged.delete(cid);
544
+ cleanupAgent(state, cid, agent.name);
545
+ } else if (idle > IDLE_WARN_MS && !nudged.has(cid)) {
546
+ if (agent.stdin) {
547
+ sendToChildStdin(agent.stdin,
548
+ `[system] Idle for ${Math.round(idle / 60000)} minutes. Continue your current task or wrap up. You will be terminated after ${Math.round(IDLE_TIMEOUT_MS / 60000)} minutes of inactivity.`);
549
+ agent.lastActivityAt = now;
550
+ }
551
+ nudged.add(cid);
552
+ }
553
+ }
554
+ }, REAPER_INTERVAL_MS);
555
+ }
@@ -146,3 +146,21 @@ export const TeammateParams = Type.Object({
146
146
  }),
147
147
  ),
148
148
  });
149
+
150
+ export const TeammateSendParams = Type.Object({
151
+ to: Type.String({
152
+ description: "Target agent name (must be a named, running agent)",
153
+ }),
154
+ message: Type.String({
155
+ description: "Message content to send to the agent",
156
+ }),
157
+ kind: Type.Optional(
158
+ StringEnum(["notification", "task"]),
159
+ ),
160
+ });
161
+
162
+ export const TeammateListParams = Type.Object({
163
+ view: Type.Optional(
164
+ StringEnum(["active", "named", "all"]),
165
+ ),
166
+ });
@@ -15,7 +15,8 @@ import * as os from "node:os";
15
15
  import * as path from "node:path";
16
16
  import { resolveAgent, type AgentConfig } from "../agents/agents.ts";
17
17
  import { resolveReplyTo, type ReplyTarget } from "../shared/routing.ts";
18
- import type { SingleResult, Usage, AgentProgress } from "../shared/types.ts";
18
+ import type { SingleResult, Usage, AgentProgress, ActiveAgent } from "../shared/types.ts";
19
+ import type { Writable } from "node:stream";
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Public param / option interfaces
@@ -45,6 +46,7 @@ export interface RunTeammateOptions {
45
46
  signal?: AbortSignal;
46
47
  onProgress?: (data: AgentProgress) => void;
47
48
  parentSessionFile?: string;
49
+ onChildSpawned?: (stdin: Writable) => void;
48
50
  }
49
51
 
50
52
  // ---------------------------------------------------------------------------
@@ -406,6 +408,11 @@ async function runSingleAttempt(
406
408
  return;
407
409
  }
408
410
 
411
+ // P1: Expose stdin for message injection
412
+ if (child.stdin) {
413
+ options.onChildSpawned?.(child.stdin);
414
+ }
415
+
409
416
  // Report initial progress
410
417
  options.onProgress?.(progress);
411
418
 
@@ -673,3 +680,17 @@ export async function runChain(
673
680
 
674
681
  return results;
675
682
  }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // P1: Send message to running agent via stdin
686
+ // ---------------------------------------------------------------------------
687
+
688
+ export function sendToChildStdin(stdin: Writable, message: string): boolean {
689
+ if (!stdin.writable) return false;
690
+ const payload = JSON.stringify({
691
+ type: "user",
692
+ content: message,
693
+ });
694
+ stdin.write(payload + "\n");
695
+ return true;
696
+ }
@@ -47,16 +47,39 @@ export interface Details {
47
47
  }>;
48
48
  }
49
49
 
50
+ export type MessageKind = "task" | "notification" | "result";
51
+
52
+ export interface MessageEnvelope {
53
+ id: string;
54
+ from: string;
55
+ to: string;
56
+ kind: MessageKind;
57
+ correlation_id?: string;
58
+ payload: string;
59
+ timestamp: number;
60
+ }
61
+
62
+ export interface ActiveAgent {
63
+ agent: string;
64
+ name?: string;
65
+ correlationId: string;
66
+ startedAt: number;
67
+ abortController: AbortController;
68
+ stdin?: import("node:stream").Writable;
69
+ lifecycle: "ephemeral" | "resident";
70
+ inbox: MessageEnvelope[];
71
+ pendingResolve?: (result: SingleResult) => void;
72
+ lastActivityAt: number;
73
+ replyTo?: string;
74
+ }
75
+
50
76
  export interface TeammateState {
51
77
  baseCwd: string;
52
78
  currentSessionId: string | null;
53
- activeRuns: Map<string, {
54
- agent: string;
55
- correlationId: string;
56
- startedAt: number;
57
- abortController: AbortController;
58
- }>;
79
+ activeRuns: Map<string, ActiveAgent>;
80
+ namedAgents: Map<string, string>;
59
81
  }
60
82
 
61
83
  export const TEAMMATE_COMPLETE_EVENT = "teammate:complete";
62
84
  export const TEAMMATE_STARTED_EVENT = "teammate:started";
85
+ export const TEAMMATE_MESSAGE_EVENT = "teammate:message";