@agnishc/edb-subagents 0.10.9 → 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/CHANGELOG.md CHANGED
@@ -1,7 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-05-22
4
+
5
+ ### Fixed
6
+ - Add missing `unlinkSync` import
7
+
8
+ ### Added
9
+ - Bridge context threading and promptGuidelines on tools
10
+
3
11
  ## [0.10.9] - 2026-05-18
4
12
 
13
+ ### Added
14
+ - **edb-bridge integration** — injects `<bridge_parent_session>`, `<bridge_agent_id>`, and `<task_store_path>` XML tags into sub-agent system prompts at spawn time
15
+ - **`bridgeContext` option** — new optional field on `SpawnOptions` and `RunOptions`; when present, bridge metadata is embedded into the sub-agent's prompt extras
16
+ - **`bridge:ready` listener** — captures the parent session's broker session ID when edb-bridge connects
17
+ - **`todo:store_path` listener** — captures the active edb-todo task store path for injection into sub-agents
18
+ - **`bridgeContext` threading** — passes bridge context through `manager.spawn()`, `manager.spawnAndWait()`, and the `sharedRunOptions` in `agent-manager.ts`
19
+
20
+ ### Changed
21
+ - `prompts.ts` `PromptExtras` gains optional `bridgeContext` field; when set, a `<bridge_context>` XML block is appended to the sub-agent system prompt
22
+ - `agent-runner.ts` reads `options.bridgeContext` and populates `extras.bridgeContext` before building the agent prompt
23
+
5
24
  ## [0.10.8] - 2026-05-18
6
25
 
7
26
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-subagents",
3
- "version": "0.10.9",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension: Claude Code-style autonomous sub-agents with live widget, parallel execution, mid-run steering, and custom agent types",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -34,6 +34,8 @@ interface SpawnArgs {
34
34
 
35
35
  interface SpawnOptions {
36
36
  description: string;
37
+ /** Short memorable name (e.g. 'Quinn'). Shown in agent widget. Falls back to description if not set. */
38
+ agentName?: string;
37
39
  model?: Model<any>;
38
40
  maxTurns?: number;
39
41
  isolated?: boolean;
@@ -54,6 +56,19 @@ interface SpawnOptions {
54
56
  invocation?: AgentInvocation;
55
57
  /** Parent abort signal — when aborted, the subagent is also stopped. */
56
58
  signal?: AbortSignal;
59
+ /**
60
+ * edb-bridge context: injected into sub-agent system prompt so it can communicate back.
61
+ * Set by edb-subagents when bridgeSessionId is available.
62
+ */
63
+ bridgeContext?: {
64
+ parentSessionId: string;
65
+ agentId: string;
66
+ storePath?: string;
67
+ taskId?: string;
68
+ agentIsSubtask?: boolean;
69
+ };
70
+ /** Called when the agent lifecycle changes: start, complete (success/steered/aborted), or failed (error/stopped). */
71
+ onTaskLifecycle?: (event: "start" | "complete" | "failed") => void;
57
72
  /** Called on tool start/end with activity info (for streaming progress to UI). */
58
73
  onToolActivity?: (activity: ToolActivity) => void;
59
74
  /** Called on streaming text deltas from the assistant response. */
@@ -144,6 +159,8 @@ export class AgentManager {
144
159
  id,
145
160
  type,
146
161
  description: options.description,
162
+ agentName: options.agentName,
163
+ taskId: options.bridgeContext?.taskId,
147
164
  status: options.isBackground ? "queued" : "running",
148
165
  toolUses: 0,
149
166
  startedAt: Date.now(),
@@ -194,6 +211,11 @@ export class AgentManager {
194
211
  record.status = "running";
195
212
  record.startedAt = Date.now();
196
213
  if (options.isBackground) this.runningBackground++;
214
+ try {
215
+ options.onTaskLifecycle?.("start");
216
+ } catch {
217
+ /* ignore */
218
+ }
197
219
  this.onStart?.(record);
198
220
 
199
221
  // Wire parent abort signal to stop the subagent when the parent is interrupted
@@ -218,6 +240,8 @@ export class AgentManager {
218
240
  thinkingLevel: options.thinkingLevel,
219
241
  cwd: worktreeCwd,
220
242
  signal: record.abortController!.signal,
243
+ // Pass through edb-bridge context if provided
244
+ ...(options.bridgeContext ? { bridgeContext: options.bridgeContext } : {}),
221
245
  onToolActivity: (activity: ToolActivity) => {
222
246
  if (activity.type === "end") record.toolUses++;
223
247
  options.onToolActivity?.(activity);
@@ -303,6 +327,13 @@ export class AgentManager {
303
327
  }
304
328
  }
305
329
 
330
+ // Auto-lifecycle: notify task management regardless of foreground/background
331
+ try {
332
+ options.onTaskLifecycle?.("complete");
333
+ } catch {
334
+ /* ignore */
335
+ }
336
+
306
337
  if (options.isBackground) {
307
338
  this.runningBackground--;
308
339
  try {
@@ -344,6 +375,13 @@ export class AgentManager {
344
375
  }
345
376
  }
346
377
 
378
+ // Auto-lifecycle: notify task management on failure
379
+ try {
380
+ options.onTaskLifecycle?.("failed");
381
+ } catch {
382
+ /* ignore */
383
+ }
384
+
347
385
  if (options.isBackground) {
348
386
  this.runningBackground--;
349
387
  this.onComplete?.(record);
@@ -391,6 +429,58 @@ export class AgentManager {
391
429
  return record;
392
430
  }
393
431
 
432
+ /**
433
+ * Resume an existing agent session with a new prompt in the background (non-blocking).
434
+ * Returns immediately; the record's promise resolves when done.
435
+ */
436
+ resumeInBackground(id: string, prompt: string): AgentRecord | undefined {
437
+ const record = this.agents.get(id);
438
+ if (!record?.session) return undefined;
439
+
440
+ record.status = "running";
441
+ record.startedAt = Date.now();
442
+ record.completedAt = undefined;
443
+ record.result = undefined;
444
+ record.error = undefined;
445
+
446
+ record.promise = resumeAgent(record.session, prompt, {
447
+ onToolActivity: (activity) => {
448
+ if (activity.type === "end") record.toolUses++;
449
+ },
450
+ onAssistantUsage: (usage) => {
451
+ addUsage(record.lifetimeUsage, usage);
452
+ },
453
+ onCompaction: (info) => {
454
+ record.compactionCount++;
455
+ this.onCompact?.(record, info);
456
+ },
457
+ })
458
+ .then((responseText) => {
459
+ record.status = "completed";
460
+ record.result = responseText;
461
+ record.completedAt = Date.now();
462
+ try {
463
+ this.onComplete?.(record);
464
+ } catch {
465
+ /* ignore */
466
+ }
467
+ return responseText;
468
+ })
469
+ .catch((err) => {
470
+ record.status = "error";
471
+ record.error = err instanceof Error ? err.message : String(err);
472
+ record.completedAt = Date.now();
473
+ try {
474
+ this.onComplete?.(record);
475
+ } catch {
476
+ /* ignore */
477
+ }
478
+ return "";
479
+ });
480
+
481
+ return record;
482
+ }
483
+
394
484
  /**
395
485
  * Resume an existing agent session with a new prompt.
396
486
  */
@@ -2,6 +2,7 @@
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
4
 
5
+ import { existsSync } from "node:fs";
5
6
  import type { Model } from "@earendil-works/pi-ai";
6
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
8
  import {
@@ -15,6 +16,7 @@ import {
15
16
  SettingsManager,
16
17
  } from "@earendil-works/pi-coding-agent";
17
18
  import {
19
+ BUILTIN_TOOL_NAMES,
18
20
  getAgentConfig,
19
21
  getConfig,
20
22
  getMemoryToolNames,
@@ -30,7 +32,40 @@ import { preloadSkills } from "./skill-loader.js";
30
32
  import type { SubagentType, ThinkingLevel } from "./types.js";
31
33
 
32
34
  /** Names of tools registered by this extension that subagents must NOT inherit. */
33
- const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
35
+ const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent", "send_to_agent"];
36
+
37
+ /**
38
+ * Extract parent session extension file paths from the parent's tool registry.
39
+ * These are passed to the sub-agent's DefaultResourceLoader as additionalExtensionPaths,
40
+ * so sub-agents get access to the same extension tools as the parent session
41
+ * (e.g., TaskCreate, TaskUpdate from edb-todo).
42
+ *
43
+ * Excludes:
44
+ * - Built-in / SDK tools (paths inside node_modules)
45
+ * - Synthetic paths (non-existent on disk)
46
+ * - Extensions where ALL registered tools are in the EXCLUDED_TOOL_NAMES list
47
+ * (i.e., edb-subagents itself — only has Agent, get_subagent_result, steer_subagent)
48
+ */
49
+ function extractParentExtensionPaths(pi: ExtensionAPI): string[] {
50
+ const excluded = new Set(EXCLUDED_TOOL_NAMES);
51
+ const pathTools = new Map<string, string[]>();
52
+
53
+ for (const tool of pi.getAllTools()) {
54
+ const p = tool.sourceInfo?.path ?? "";
55
+ if (!p) continue;
56
+ if (!pathTools.has(p)) pathTools.set(p, []);
57
+ pathTools.get(p)!.push(tool.name);
58
+ }
59
+
60
+ const result: string[] = [];
61
+ for (const [p, toolNames] of pathTools) {
62
+ if (p.includes("node_modules")) continue;
63
+ if (!existsSync(p)) continue;
64
+ if (toolNames.every((name) => excluded.has(name))) continue;
65
+ result.push(p);
66
+ }
67
+ return result;
68
+ }
34
69
 
35
70
  /** Default max turns. undefined = unlimited (no turn limit). */
36
71
  let defaultMaxTurns: number | undefined;
@@ -109,6 +144,17 @@ export interface RunOptions {
109
144
  thinkingLevel?: ThinkingLevel;
110
145
  /** Override working directory (e.g. for worktree isolation). */
111
146
  cwd?: string;
147
+ /**
148
+ * edb-bridge context: injected into the sub-agent's system prompt so edb-bridge + edb-todo
149
+ * extensions in the sub-agent session can connect back to the orchestrator.
150
+ */
151
+ bridgeContext?: {
152
+ parentSessionId: string;
153
+ agentId: string;
154
+ storePath?: string;
155
+ taskId?: string;
156
+ agentIsSubtask?: boolean;
157
+ };
112
158
  /** Called on tool start/end with activity info. */
113
159
  onToolActivity?: (activity: ToolActivity) => void;
114
160
  /** Called on streaming text deltas from the assistant response. */
@@ -197,10 +243,21 @@ export async function runAgent(
197
243
  // Build prompt extras (memory, skill preloading)
198
244
  const extras: PromptExtras = {};
199
245
 
246
+ // Inject edb-bridge context if provided (enables ask_supervisor, notify_parent, shared task store)
247
+ if (options.bridgeContext) {
248
+ extras.bridgeContext = options.bridgeContext;
249
+ }
250
+
200
251
  // Resolve extensions/skills: isolated overrides to false
201
252
  const extensions = options.isolated ? false : config.extensions;
202
253
  const skills = options.isolated ? false : config.skills;
203
254
 
255
+ // Extract parent session extension paths so sub-agents inherit the same extension
256
+ // tools (e.g. TaskCreate/TaskUpdate from edb-todo). Without this, sub-agents
257
+ // running in a session started with `-ne -e <path>` would have no extension tools
258
+ // because the DefaultResourceLoader doesn't know about the parent's explicit -e paths.
259
+ const parentExtensionPaths = extensions !== false ? extractParentExtensionPaths(options.pi) : [];
260
+
204
261
  // Skill preloading: when skills is string[], preload their content into prompt
205
262
  if (Array.isArray(skills)) {
206
263
  const loaded = preloadSkills(skills, effectiveCwd);
@@ -259,7 +316,12 @@ export async function runAgent(
259
316
  const loader = new DefaultResourceLoader({
260
317
  cwd: effectiveCwd,
261
318
  agentDir,
262
- noExtensions: extensions === false,
319
+ // When we have explicit parent extension paths, suppress standard discovery so
320
+ // sub-agents don't accidentally pick up globally installed extensions the parent
321
+ // didn't opt into (e.g. when parent was started with -ne). Standard discovery
322
+ // is kept when there are no explicit paths (parent has no extensions).
323
+ noExtensions: extensions === false || parentExtensionPaths.length > 0,
324
+ additionalExtensionPaths: parentExtensionPaths,
263
325
  noSkills,
264
326
  noPromptTemplates: true,
265
327
  noThemes: true,
@@ -282,7 +344,13 @@ export async function runAgent(
282
344
  settingsManager: SettingsManager.create(effectiveCwd, agentDir),
283
345
  modelRegistry: ctx.modelRegistry,
284
346
  model,
285
- tools: toolNames,
347
+ // Do NOT pass tools: [...] or noTools here. Passing tools: toolNames creates a
348
+ // hard allowedToolNames restriction that permanently blocks extension tools from
349
+ // the registry even after bindExtensions() registers them (setActiveToolsByName
350
+ // silently ignores names not in the registry). Using noTools: "all" empties the
351
+ // registry entirely. Instead, let the session use its default (full registry:
352
+ // all built-ins + all extension tools from the loader), then restrict via
353
+ // setActiveToolsByName after bindExtensions().
286
354
  resourceLoader: loader,
287
355
  };
288
356
  if (thinkingLevel) {
@@ -294,21 +362,56 @@ export async function runAgent(
294
362
  const baseSessionName = agentConfig?.name ?? type;
295
363
  session.setSessionName(options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName);
296
364
 
365
+ // Bind extensions FIRST so that extension tools (e.g. TaskCreate from edb-todo)
366
+ // are registered before we query the active tool set. This ensures extension
367
+ // tools are available for filtering (vs. being invisible at query time).
368
+ await session.bindExtensions({
369
+ onError: (err) => {
370
+ options.onToolActivity?.({
371
+ type: "end",
372
+ toolName: `extension-error:${err.extensionPath}`,
373
+ });
374
+ },
375
+ });
376
+
297
377
  // Build disallowed tools set from agent config
298
378
  const disallowedSet = agentConfig?.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
299
379
 
300
380
  // Filter active tools: remove our own tools to prevent nesting,
301
- // apply extension allowlist if specified, and apply disallowedTools denylist
381
+ // apply extension allowlist if specified, and apply disallowedTools denylist.
382
+ //
383
+ // SOURCE: Use extensionRunner.getAllRegisteredTools() for extension tools — this
384
+ // bypasses any session-level allowlist restriction and gives us ALL tools registered
385
+ // by extensions. Combined with BUILTIN_TOOL_NAMES we get a complete picture.
386
+ //
387
+ // createAgentSession is called without tools/noTools so the session uses its default
388
+ // (full registry, all built-ins + extension tools from the loader, no allowlist).
389
+ // setActiveToolsByName then restricts to exactly what this agent type should have.
390
+ //
391
+ // Built-in tools (bash, read, edit, write, grep, find, ls) are gated by the
392
+ // per-agent builtinToolNames config. Extension tools (TaskCreate, ask_supervisor…)
393
+ // are gated by the extensions config (true = all, string[] = allowlist, false = none).
302
394
  if (extensions !== false) {
303
395
  const builtinToolNameSet = new Set(toolNames);
304
- const activeTools = session.getActiveToolNames().filter((t) => {
396
+ const allBuiltins = new Set(BUILTIN_TOOL_NAMES);
397
+ // Get all extension tools from the extension runner (not filtered by any allowlist)
398
+ const registeredExtensionToolNames = session.extensionRunner
399
+ .getAllRegisteredTools()
400
+ .map((t) => t.definition.name);
401
+ // Combine: built-in tools + all registered extension tools
402
+ const allKnown = [...new Set([...BUILTIN_TOOL_NAMES, ...registeredExtensionToolNames])];
403
+ const activeTools = allKnown.filter((t) => {
305
404
  if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
306
- if (disallowedSet?.has(t)) return false;
307
- if (builtinToolNameSet.has(t)) return true;
405
+ // Built-in tools: respect the per-agent builtinToolNames allowlist
406
+ if (allBuiltins.has(t)) return builtinToolNameSet.has(t);
407
+ // Extension tools: whitelist (allowed_tools) wins over blacklist (disallowed_tools)
308
408
  if (Array.isArray(extensions)) {
409
+ // Whitelist mode — disallowedTools is ignored
309
410
  return extensions.some((ext) => t.startsWith(ext) || t.includes(ext));
310
411
  }
311
- return true;
412
+ // Blacklist mode — exclude disallowed tools
413
+ if (disallowedSet?.has(t)) return false;
414
+ return true; // extensions === true → include all extension tools
312
415
  });
313
416
  session.setActiveToolsByName(activeTools);
314
417
  } else if (disallowedSet) {
@@ -317,19 +420,6 @@ export async function runAgent(
317
420
  session.setActiveToolsByName(activeTools);
318
421
  }
319
422
 
320
- // Bind extensions so that session_start fires and extensions can initialize
321
- // (e.g. loading credentials, setting up state). Placed after tool filtering
322
- // so extension-provided skills/prompts from extendResourcesFromExtensions()
323
- // respect the active tool set. All ExtensionBindings fields are optional.
324
- await session.bindExtensions({
325
- onError: (err) => {
326
- options.onToolActivity?.({
327
- type: "end",
328
- toolName: `extension-error:${err.extensionPath}`,
329
- });
330
- },
331
- });
332
-
333
423
  options.onSessionCreated?.(session);
334
424
 
335
425
  // Track turns for graceful max_turns enforcement
@@ -54,9 +54,12 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
54
54
  name,
55
55
  displayName: str(fm.display_name),
56
56
  description: str(fm.description) ?? name,
57
+ context: str(fm.context),
57
58
  builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
58
59
  disallowedTools: csvListOptional(fm.disallowed_tools),
59
- extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
60
+ // allowed_tools: whitelist for extension tools (wins over disallowed_tools when both set)
61
+ // extensions: legacy field — string[] format is treated as allowed_tools whitelist
62
+ extensions: parseExtensions(fm.allowed_tools ?? fm.extensions),
60
63
  skills: inheritField(fm.skills ?? fm.inherit_skills),
61
64
  model: str(fm.model),
62
65
  fallbackModels: csvListOptional(fm.fallback_models),
@@ -138,3 +141,22 @@ function inheritField(val: unknown): true | string[] | false {
138
141
  const items = csvList(val, []);
139
142
  return items.length > 0 ? items : false;
140
143
  }
144
+
145
+ /**
146
+ * Parse the extensions/allowed_tools field.
147
+ * - undefined/null/true → all extension tools (true)
148
+ * - false/"none" → no extension tools (false)
149
+ * - "TaskCreate, ask_supervisor" → whitelist of tool name patterns (string[])
150
+ * - absolute path (legacy executor.md style) → treat as true (path-based extensions
151
+ * are handled by extractParentExtensionPaths; ignore the path here)
152
+ */
153
+ function parseExtensions(val: unknown): true | string[] | false {
154
+ if (val === undefined || val === null || val === true) return true;
155
+ if (val === false || val === "none") return false;
156
+ // Legacy: absolute path string → treat as "all extensions enabled"
157
+ if (typeof val === "string" && (val.startsWith("/") || val.startsWith(".") || val.includes("node_modules"))) {
158
+ return true;
159
+ }
160
+ const items = csvList(val, []);
161
+ return items.length > 0 ? items : true;
162
+ }
@@ -15,6 +15,8 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
15
15
  name: "general-purpose",
16
16
  displayName: "Agent",
17
17
  description: "General-purpose agent for complex, multi-step tasks",
18
+ context:
19
+ "Use for any multi-step task that requires reasoning, writing code, or making changes. The default choice when no specialized agent fits.",
18
20
  // builtinToolNames omitted — means "all available tools" (resolved at lookup time)
19
21
  // inheritContext / runInBackground / isolated omitted — strategy fields, callers decide per-call.
20
22
  // Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
@@ -31,6 +33,8 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
31
33
  name: "Explore",
32
34
  displayName: "Explore",
33
35
  description: "Fast codebase exploration agent (read-only)",
36
+ context:
37
+ "Use when you need to understand an unfamiliar codebase, find where something is implemented, or answer 'where is X' / 'how does Y work' questions. Read-only, fast, and cheap.",
34
38
  builtinToolNames: READ_ONLY_TOOLS,
35
39
  extensions: true,
36
40
  skills: true,
@@ -73,6 +77,8 @@ Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find,
73
77
  name: "Plan",
74
78
  displayName: "Plan",
75
79
  description: "Software architect for implementation planning (read-only)",
80
+ context:
81
+ "Use when you need a detailed implementation plan before writing code. Analyzes the codebase and produces a step-by-step plan without making any changes.",
76
82
  builtinToolNames: READ_ONLY_TOOLS,
77
83
  extensions: true,
78
84
  skills: true,
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Agent — LLM-callable: spawn a sub-agent
6
6
  * get_subagent_result — LLM-callable: check background agent status/result
7
7
  * steer_subagent — LLM-callable: send a steering message to a running agent
8
+ * send_to_agent — LLM-callable: send a follow-up to a completed agent (context preserved)
8
9
  *
9
10
  * Commands:
10
11
  * /agents — Interactive agent management menu
@@ -518,6 +519,63 @@ export default function (pi: ExtensionAPI) {
518
519
  // --- Cross-extension RPC via pi.events ---
519
520
  let currentCtx: ExtensionContext | undefined;
520
521
 
522
+ // edb-bridge + edb-todo integration: track current session's bridge ID and todo store path
523
+ let bridgeSessionId: string | undefined;
524
+ let todoStorePath: string | undefined;
525
+
526
+ // Promise that resolves when bridge:ready fires (or immediately if already ready).
527
+ // Used to avoid a race where a sub-agent is spawned before the bridge connects.
528
+ let bridgeReadyResolve: (() => void) | undefined;
529
+ const bridgeReadyPromise = new Promise<void>((resolve) => {
530
+ bridgeReadyResolve = resolve;
531
+ });
532
+
533
+ pi.events.on("bridge:ready", (payload: unknown) => {
534
+ const p = payload as { sessionId?: string } | undefined;
535
+ if (p?.sessionId) {
536
+ bridgeSessionId = p.sessionId;
537
+ bridgeReadyResolve?.();
538
+ }
539
+ });
540
+
541
+ pi.events.on("todo:store_path", (payload: unknown) => {
542
+ const p = payload as { path?: string } | undefined;
543
+ if (p?.path) todoStorePath = p.path;
544
+ });
545
+
546
+ /**
547
+ * Read a task from the shared task store (synchronous, best-effort).
548
+ * Returns undefined if the store is not available or the task doesn't exist.
549
+ */
550
+ function readTaskFromStore(
551
+ taskId: string,
552
+ ): { parentId?: string; status?: string; blockQuestion?: string; blockMessageId?: string } | undefined {
553
+ if (!todoStorePath) return undefined;
554
+ try {
555
+ const raw = readFileSync(todoStorePath, "utf-8");
556
+ const data = JSON.parse(raw) as {
557
+ tasks?: Array<{
558
+ id: string;
559
+ parentId?: string;
560
+ status?: string;
561
+ blockQuestion?: string;
562
+ blockMessageId?: string;
563
+ }>;
564
+ };
565
+ return data.tasks?.find((t) => t.id === taskId);
566
+ } catch {
567
+ return undefined;
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Update a task via pi.events — edb-todo handles the write.
573
+ * Fires "todo:update_task" which edb-todo listens for and applies synchronously.
574
+ */
575
+ function updateTask(taskId: string, fields: { status?: string; owner?: string }): void {
576
+ pi.events.emit("todo:update_task", { taskId, fields });
577
+ }
578
+
521
579
  // ---- Subagent scheduler ----
522
580
  // Session-scoped: store is constructed inside session_start once sessionId
523
581
  // is available. Mirrors pi-chonky-tasks's session-scoped task store —
@@ -660,21 +718,21 @@ export default function (pi: ExtensionAPI) {
660
718
  const defaultNames = getDefaultAgentNames().filter((n) => getAgentConfig(n)?.enabled !== false);
661
719
  const userNames = getUserAgentNames().filter((n) => getAgentConfig(n)?.enabled !== false);
662
720
 
663
- const defaultDescs = defaultNames.map((name) => {
721
+ const formatAgent = (name: string, isDefault: boolean) => {
664
722
  const cfg = getAgentConfig(name);
665
- const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
666
- return `- ${name}: ${cfg?.description ?? name}${modelSuffix}`;
667
- });
668
-
669
- const customDescs = userNames.map((name) => {
670
- const cfg = getAgentConfig(name);
671
- return `- ${name}: ${cfg?.description ?? name}`;
672
- });
723
+ const modelSuffix = isDefault && cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
724
+ const desc = `- ${name}: ${cfg?.description ?? name}${modelSuffix}`;
725
+ // Include context block if defined — tells the orchestrator when/why to use this agent
726
+ if (cfg?.context) {
727
+ return `${desc}\n When to use: ${cfg.context.trim()}`;
728
+ }
729
+ return desc;
730
+ };
673
731
 
674
732
  return [
675
733
  "Default agents:",
676
- ...defaultDescs,
677
- ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
734
+ ...defaultNames.map((n) => formatAgent(n, true)),
735
+ ...(userNames.length > 0 ? ["", "Custom agents:", ...userNames.map((n) => formatAgent(n, false))] : []),
678
736
  "",
679
737
  `Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.`,
680
738
  ].join("\n");
@@ -746,6 +804,7 @@ Guidelines:
746
804
  - Use run_in_background for work you don't need immediately. You will be notified when it completes.
747
805
  - Use resume with an agent ID to continue a previous agent's work.
748
806
  - Use steer_subagent to send mid-run messages to a running background agent.
807
+ - Use send_to_agent to follow up with a completed agent (preserves full conversation context).
749
808
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
750
809
  - Use thinking to control extended thinking level.
751
810
  - Use inherit_context if the agent needs the parent conversation history.
@@ -810,6 +869,23 @@ Guidelines:
810
869
  'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
811
870
  }),
812
871
  ),
872
+ task_id: Type.Optional(
873
+ Type.String({
874
+ description:
875
+ "Optional task ID from the shared task store to associate with this agent. " +
876
+ "When set: the agent auto-updates its task status (in_progress on start, completed/failed on finish), " +
877
+ "the agent's system prompt is given task lifecycle instructions, " +
878
+ "and subtask creation follows the two-layer rule (top-level tasks can create subtasks; subtasks cannot).",
879
+ }),
880
+ ),
881
+ agent_name: Type.Optional(
882
+ Type.String({
883
+ description:
884
+ "A short, memorable name for this agent (e.g. 'Quinn', 'Atlas', 'Hex'). " +
885
+ "Shown in the task widget as [name] when the agent owns a task. " +
886
+ "Separate from description (which is the task summary). Keep it 1-2 words, evocative of the role.",
887
+ }),
888
+ ),
813
889
  ...scheduleParam,
814
890
  }),
815
891
  promptSnippet: "Launch a specialized subagent to autonomously handle a complex multi-step task",
@@ -911,9 +987,19 @@ Guidelines:
911
987
  // ---- Execute ----
912
988
 
913
989
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
990
+ // Extract extended params — task_id and agent_name are optional Agent tool params
991
+ // not reflected in the TypeBox static type, accessed via explicit cast here.
992
+ const taskId = (params as any).task_id as string | undefined;
993
+ const agentName = (params as any).agent_name as string | undefined;
994
+
914
995
  // Ensure we have UI context for widget rendering
915
996
  widget.setUICtx(ctx.ui as UICtx);
916
997
 
998
+ // Wait for bridge:ready (up to 3s) to avoid race where bridge hasn't connected yet
999
+ if (!bridgeSessionId) {
1000
+ await Promise.race([bridgeReadyPromise, new Promise<void>((res) => setTimeout(res, 3000))]);
1001
+ }
1002
+
917
1003
  // Reload custom agents so new .pi/agents/*.md files are picked up without restart
918
1004
  reloadCustomAgents();
919
1005
 
@@ -1065,6 +1151,7 @@ Guidelines:
1065
1151
  try {
1066
1152
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1067
1153
  description: params.description,
1154
+ agentName: agentName,
1068
1155
  model,
1069
1156
  fallbackModels: params.fallback_models as string[] | undefined,
1070
1157
  maxTurns: effectiveMaxTurns,
@@ -1074,6 +1161,33 @@ Guidelines:
1074
1161
  isBackground: true,
1075
1162
  isolation,
1076
1163
  invocation: agentInvocation,
1164
+ // edb-bridge + edb-todo: inject orchestrator context into sub-agent
1165
+ ...(bridgeSessionId
1166
+ ? {
1167
+ bridgeContext: {
1168
+ parentSessionId: bridgeSessionId,
1169
+ agentId: String(agentName ?? params.description ?? subagentType),
1170
+ storePath: todoStorePath,
1171
+ taskId: taskId,
1172
+ agentIsSubtask: taskId ? !!readTaskFromStore(taskId!)?.parentId : undefined,
1173
+ },
1174
+ }
1175
+ : {}),
1176
+ // Auto task lifecycle
1177
+ ...(taskId
1178
+ ? {
1179
+ onTaskLifecycle: (event: "start" | "complete" | "failed") => {
1180
+ const agentId = String(agentName ?? params.description ?? subagentType);
1181
+ if (event === "start") {
1182
+ updateTask(taskId, { status: "in_progress", owner: agentId });
1183
+ } else if (event === "complete") {
1184
+ updateTask(taskId, { status: "completed", owner: agentId });
1185
+ } else {
1186
+ updateTask(taskId, { status: "failed", owner: agentId });
1187
+ }
1188
+ },
1189
+ }
1190
+ : {}),
1077
1191
  ...bgCallbacks,
1078
1192
  });
1079
1193
  } catch (err) {
@@ -1122,6 +1236,9 @@ Guidelines:
1122
1236
  `Description: ${params.description}\n` +
1123
1237
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1124
1238
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1239
+ (!bridgeSessionId
1240
+ ? `\nNote: edb-bridge not connected — ask_supervisor and notify_parent will not work.\n`
1241
+ : "") +
1125
1242
  `\nYou will be notified when this agent completes.\n` +
1126
1243
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1127
1244
  `Do not duplicate this agent's work.`,
@@ -1180,6 +1297,7 @@ Guidelines:
1180
1297
  try {
1181
1298
  record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1182
1299
  description: params.description,
1300
+ agentName: agentName,
1183
1301
  model,
1184
1302
  fallbackModels: params.fallback_models as string[] | undefined,
1185
1303
  maxTurns: effectiveMaxTurns,
@@ -1189,6 +1307,33 @@ Guidelines:
1189
1307
  isolation,
1190
1308
  invocation: agentInvocation,
1191
1309
  signal,
1310
+ // edb-bridge + edb-todo: inject orchestrator context into sub-agent
1311
+ ...(bridgeSessionId
1312
+ ? {
1313
+ bridgeContext: {
1314
+ parentSessionId: bridgeSessionId,
1315
+ agentId: String(agentName ?? params.description ?? "agent"),
1316
+ storePath: todoStorePath,
1317
+ taskId: taskId,
1318
+ agentIsSubtask: taskId ? !!readTaskFromStore(taskId!)?.parentId : undefined,
1319
+ },
1320
+ }
1321
+ : {}),
1322
+ // Auto task lifecycle
1323
+ ...(taskId
1324
+ ? {
1325
+ onTaskLifecycle: (event: "start" | "complete" | "failed") => {
1326
+ const agentId = String(agentName ?? params.description ?? "agent");
1327
+ if (event === "start") {
1328
+ updateTask(taskId, { status: "in_progress", owner: agentId });
1329
+ } else if (event === "complete") {
1330
+ updateTask(taskId, { status: "completed", owner: agentId });
1331
+ } else {
1332
+ updateTask(taskId, { status: "failed", owner: agentId });
1333
+ }
1334
+ },
1335
+ }
1336
+ : {}),
1192
1337
  ...fgCallbacks,
1193
1338
  });
1194
1339
  } catch (err) {
@@ -1234,7 +1379,8 @@ Guidelines:
1234
1379
  name: "get_subagent_result",
1235
1380
  label: "Get Agent Result",
1236
1381
  description:
1237
- "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1382
+ "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.\n\n" +
1383
+ "If the agent has called ask_supervisor and is waiting for your answer, wait: true will detect this and return the question and message_id immediately instead of blocking.",
1238
1384
  parameters: Type.Object({
1239
1385
  agent_id: Type.String({
1240
1386
  description: "The agent ID to check.",
@@ -1259,6 +1405,26 @@ Guidelines:
1259
1405
  }
1260
1406
 
1261
1407
  // Wait for completion if requested.
1408
+ // Check if the agent's linked task is blocked (waiting for answer_subagent).
1409
+ // If so, waiting would cause a deadlock — return immediately with the question.
1410
+ if (params.wait && record.status === "running" && record.taskId && todoStorePath) {
1411
+ try {
1412
+ const task = readTaskFromStore(record.taskId);
1413
+ if (task && (task as any).status === "blocked" && (task as any).blockQuestion) {
1414
+ const q = (task as any).blockQuestion as string;
1415
+ const msgId = (task as any).blockMessageId as string | undefined;
1416
+ return textResult(
1417
+ `Agent is blocked waiting for your answer.\n\n` +
1418
+ `Question: "${q}"\n\n` +
1419
+ (msgId ? `Call: answer_subagent({ message_id: "${msgId}", answer: "..." })\n\n` : "") +
1420
+ `After answering, call get_subagent_result again to wait for completion.`,
1421
+ );
1422
+ }
1423
+ } catch {
1424
+ /* ignore — best-effort check */
1425
+ }
1426
+ }
1427
+
1262
1428
  // Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
1263
1429
  // (attached earlier at spawn time) and always runs before this await resumes.
1264
1430
  // Setting the flag here prevents a redundant follow-up notification.
@@ -1371,6 +1537,72 @@ Guidelines:
1371
1537
  }),
1372
1538
  );
1373
1539
 
1540
+ // ---- send_to_agent tool ----
1541
+
1542
+ pi.registerTool(
1543
+ defineTool({
1544
+ name: "send_to_agent",
1545
+ label: "Send to Agent",
1546
+ description:
1547
+ "Send a follow-up message to a completed (or running) agent, continuing its existing session. " +
1548
+ "The agent retains its full conversation context — no context is lost. " +
1549
+ "Use this instead of spawning a new agent when you want to build on prior work. " +
1550
+ "Use run_in_background: true to fire and check later via get_subagent_result.",
1551
+ parameters: Type.Object({
1552
+ agent_id: Type.String({
1553
+ description: "The agent ID to send the message to.",
1554
+ }),
1555
+ message: Type.String({
1556
+ description: "The follow-up message / next task for the agent.",
1557
+ }),
1558
+ run_in_background: Type.Optional(
1559
+ Type.Boolean({
1560
+ description:
1561
+ "If true, returns immediately with the agent ID. Use get_subagent_result to collect the response later. " +
1562
+ "Default: false (waits for the agent to finish and returns its response).",
1563
+ }),
1564
+ ),
1565
+ }),
1566
+ promptSnippet: "Send a follow-up message to a previously completed agent (preserves full context)",
1567
+ execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
1568
+ const record = manager.getRecord(params.agent_id);
1569
+ if (!record) {
1570
+ return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1571
+ }
1572
+ if (!record.session) {
1573
+ return textResult(
1574
+ `Agent "${params.agent_id}" has no active session (status: ${record.status}). ` +
1575
+ "It may have been cleaned up after 10 minutes of inactivity.",
1576
+ );
1577
+ }
1578
+ if (record.status === "running") {
1579
+ return textResult(
1580
+ `Agent "${params.agent_id}" is currently running. ` +
1581
+ "Use steer_subagent to redirect it mid-run, or wait for it to finish first.",
1582
+ );
1583
+ }
1584
+
1585
+ if (params.run_in_background) {
1586
+ const updated = manager.resumeInBackground(params.agent_id, params.message);
1587
+ if (!updated) {
1588
+ return textResult(`Failed to resume agent "${params.agent_id}" in background.`);
1589
+ }
1590
+ return textResult(
1591
+ `Message sent to agent ${params.agent_id} (running in background). ` +
1592
+ "Call get_subagent_result to collect the response when ready.",
1593
+ );
1594
+ }
1595
+
1596
+ // Foreground: wait for response
1597
+ const updated = await manager.resume(params.agent_id, params.message, signal);
1598
+ if (!updated) {
1599
+ return textResult(`Failed to resume agent "${params.agent_id}".`);
1600
+ }
1601
+ return textResult(updated.result?.trim() || updated.error?.trim() || "No output.");
1602
+ },
1603
+ }),
1604
+ );
1605
+
1374
1606
  // ---- /agents interactive menu ----
1375
1607
 
1376
1608
  const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
package/src/prompts.ts CHANGED
@@ -10,6 +10,17 @@ export interface PromptExtras {
10
10
  memoryBlock?: string;
11
11
  /** Preloaded skill contents to inject. */
12
12
  skillBlocks?: { name: string; content: string }[];
13
+ /**
14
+ * edb-bridge context for sub-agent communication.
15
+ * Injected as XML tags so sub-agent's edb-bridge and edb-todo extensions can read it.
16
+ */
17
+ bridgeContext?: {
18
+ parentSessionId: string; // broker session ID of the parent
19
+ agentId: string; // edb-subagents agent ID
20
+ storePath?: string; // parent's edb-todo task store file path
21
+ taskId?: string; // assigned task ID for this agent
22
+ agentIsSubtask?: boolean; // true = already a subtask (depth 1) — no further subtasks allowed
23
+ };
13
24
  }
14
25
 
15
26
  /**
@@ -50,6 +61,19 @@ Platform: ${env.platform}`;
50
61
  extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
51
62
  }
52
63
  }
64
+ // edb-bridge context: inject XML tags so sub-agent extensions can read parent session info
65
+ if (extras?.bridgeContext) {
66
+ const { parentSessionId, agentId, storePath, taskId, agentIsSubtask } = extras.bridgeContext;
67
+ const storeTag = storePath ? `\n<task_store_path>${storePath}</task_store_path>` : "";
68
+ const taskTag = taskId ? `\n<assigned_task_id>${taskId}</assigned_task_id>` : "";
69
+ extraSections.push(
70
+ `<bridge_context>\n<bridge_parent_session>${parentSessionId}</bridge_parent_session>\n<bridge_agent_id>${agentId}</bridge_agent_id>${storeTag}${taskTag}\n</bridge_context>`,
71
+ );
72
+ // Task context instructions — only when task_id was provided
73
+ if (taskId) {
74
+ extraSections.push(buildTaskContextBlock(taskId, agentId, agentIsSubtask ?? false));
75
+ }
76
+ }
53
77
  const extrasSuffix = extraSections.length > 0 ? `\n\n${extraSections.join("\n")}` : "";
54
78
 
55
79
  if (config.promptMode === "append") {
@@ -98,3 +122,29 @@ const genericBase = `# Role
98
122
  You are a general-purpose coding agent for complex, multi-step tasks.
99
123
  You have full access to read, write, edit files, and execute commands.
100
124
  Do what has been asked; nothing more, nothing less.`;
125
+
126
+ /**
127
+ * Build the task context block injected when a task_id is assigned at spawn time.
128
+ *
129
+ * Tells the sub-agent:
130
+ * - Its assigned task ID and how to update it
131
+ * - Whether it can create subtasks (depth-0 task: yes; depth-1 subtask: no)
132
+ * - That depth-1 agents must not create further subtasks (two-layer rule)
133
+ */
134
+ function buildTaskContextBlock(taskId: string, agentId: string, agentIsSubtask: boolean): string {
135
+ const subtaskSection = agentIsSubtask
136
+ ? `**Subtasks:** You are already a subtask (${taskId}). Do NOT create further subtasks (parentId is not allowed). You are at the maximum nesting depth. If you need internal tracking, you may use TaskCreate without parentId — those tasks will not appear in the orchestrator's widget.`
137
+ : `**Subtasks (optional):** You may break your work into subtasks visible to the orchestrator:\n TaskCreate({ content: "...", parentId: "${taskId}" })\n Update them as you work: TaskUpdate({ id: "<subtask-id>", status: "in_progress" })`;
138
+
139
+ return `## Your Assigned Task
140
+ You have been assigned task **${taskId}**.
141
+
142
+ **Lifecycle (required — the orchestrator tracks these):**
143
+ 1. When you begin: \`TaskUpdate({ id: "${taskId}", status: "in_progress", owner: "${agentId}" })\`
144
+ 2. Optionally set progress: \`TaskUpdate({ id: "${taskId}", activeForm: "Doing X..." })\`
145
+ 3. When done: \`TaskUpdate({ id: "${taskId}", status: "completed", owner: "${agentId}" })\`
146
+ 4. If you need clarification from the orchestrator: \`ask_supervisor({ question: "..." })\` — this will block you until answered.
147
+ 5. For progress updates: \`notify_parent({ message: "..." })\` — task_id is auto-injected, updates the widget spinner text.
148
+
149
+ ${subtaskSection}`;
150
+ }
package/src/types.ts CHANGED
@@ -55,6 +55,12 @@ export interface AgentConfig {
55
55
  enabled?: boolean;
56
56
  /** Where this agent was loaded from */
57
57
  source?: "default" | "project" | "global";
58
+ /**
59
+ * Usage context — when and why to use this agent.
60
+ * Injected into the main agent's system prompt so it knows which agent to spawn for what tasks.
61
+ * Example: "Use when exploring an unfamiliar codebase to find where something is implemented."
62
+ */
63
+ context?: string;
58
64
  }
59
65
 
60
66
  export type JoinMode = "async" | "group" | "smart";
@@ -63,6 +69,10 @@ export interface AgentRecord {
63
69
  id: string;
64
70
  type: SubagentType;
65
71
  description: string;
72
+ /** Short memorable name given by the orchestrator (e.g. 'Quinn'). Shown in widget alongside description. */
73
+ agentName?: string;
74
+ /** Linked task ID in the shared task store. Used for blocked-state check in get_subagent_result. */
75
+ taskId?: string;
66
76
  status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
67
77
  result?: string;
68
78
  error?: string;
@@ -273,6 +273,7 @@ export class AgentWidget {
273
273
  type: SubagentType;
274
274
  status: string;
275
275
  description: string;
276
+ agentName?: string;
276
277
  toolUses: number;
277
278
  startedAt: number;
278
279
  completedAt?: number;
@@ -312,7 +313,7 @@ export class AgentWidget {
312
313
  parts.push(duration);
313
314
 
314
315
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
315
- return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
316
+ return `${icon} ${theme.fg("dim", name)}${modeTag}${a.agentName ? ` ${theme.fg("dim", a.agentName)}` : ""} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
316
317
  }
317
318
 
318
319
  /**
@@ -373,7 +374,7 @@ export class AgentWidget {
373
374
  runningLines.push([
374
375
  truncate(
375
376
  theme.fg("dim", "├─") +
376
- ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
377
+ ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag}${a.agentName ? ` ${theme.fg("accent", theme.bold(a.agentName))}` : ""} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
377
378
  ),
378
379
  truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
379
380
  ]);