pi-doc-injector 0.3.0 → 0.3.1

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,199 @@
1
+ # Doc-Injector & Async Subagents Bug Analysis
2
+
3
+ ## Overview
4
+
5
+ The doc-injector extension causes spinner hangs when used alongside async subagent workflows (e.g., `subagent_isolated`, `subagent_with_context` with `async: true`).
6
+
7
+ ## Root Cause
8
+
9
+ ### The Flow
10
+
11
+ 1. Parent spawns async subagents with `async: true, notifyOnComplete: "inject"`
12
+ 2. Parent streams output (e.g., "I've launched 5 agents...")
13
+ 3. Doc-injector's `message_update` fires, detects keyword match
14
+ 4. Doc-injector calls `ctx.abort()` to abort current turn
15
+ 5. `agent_end` fires → extension injects `"continue"` via `setTimeout()`
16
+ 6. Parent receives `"continue"` → immediately calls `get_subagent_result x5`
17
+ 7. Subagents still running → results not available → **spinner stuck**
18
+
19
+ ### Why This Happens
20
+
21
+ The current doc-injector design assumes it can abort the current turn and restart immediately with injected docs. This works for simple single-turn workflows but breaks async subagent workflows because:
22
+
23
+ 1. **The injected `"continue"` disrupts the expected flow** - parent should wait for `notifyOnComplete: "inject"` messages, not immediately poll for results
24
+ 2. **`get_subagent_result` blocks** - when called before subagents finish, it hangs the parent thread
25
+ 3. **The abort signal is shared** - in some subagent implementations (e.g., the `subagent` tool in pi-subagentura), async subagents explicitly don't inherit the parent abort signal. But the injected `"continue"` still breaks the flow
26
+
27
+ ### Key Code Path (doc-injector/index.ts)
28
+
29
+ ```typescript
30
+ // message_update handler - triggers abort on keyword match
31
+ pi.on("message_update", async (event, ctx) => {
32
+ if (hasNew && !ctx.isIdle() && !abortingForInjection) {
33
+ abortingForInjection = true;
34
+ ctx.abort(); // ← PROBLEM: aborts current turn
35
+ }
36
+ });
37
+
38
+ // agent_end handler - injects "continue" after abort
39
+ pi.on("agent_end", async (event, ctx) => {
40
+ if (abortingForInjection) {
41
+ abortingForInjection = false;
42
+ setTimeout(() => {
43
+ pi.sendUserMessage("continue"); // ← PROBLEM: disrupts async flow
44
+ }, 0);
45
+ }
46
+ });
47
+ ```
48
+
49
+ ## Subagentura System Context
50
+
51
+ ### How Async Subagents Work
52
+
53
+ The subagentura system (`/Users/applesucks/dev/pi-agents/subagent.ts`) spawns async subagents that:
54
+
55
+ 1. Run in the same process (not subprocess)
56
+ 2. Have independent session/context
57
+ 3. Signal via `notifyOnComplete: "inject"` - results injected as user messages when complete
58
+ 4. Use `get_subagent_result` to poll/block for results
59
+
60
+ ### Key Line (subagent.ts line 541, 744)
61
+
62
+ ```typescript
63
+ signal: undefined, // async: don't inherit parent signal (would abort subagent when tool returns)
64
+ ```
65
+
66
+ ### Option A: Remove ctx.abort() Entirely (Recommended)
67
+
68
+ **Change:** Never call `ctx.abort()` in `message_update`. Just record matches and inject on next turn.
69
+
70
+ **Pros:**
71
+ - ✅ Simplest fix
72
+ - ✅ Works with ANY async workflow automatically
73
+ - ✅ No special-casing for subagents
74
+
75
+ **Cons:**
76
+ - ❌ Loses "immediate injection during streaming" optimization
77
+ - ❌ Docs injected on next turn instead of immediately
78
+
79
+ **Implementation:**
80
+ ```typescript
81
+ pi.on("message_update", async (event, ctx) => {
82
+ if (keywordGenInFlight) return;
83
+ if (!enabled || !registry) return;
84
+ if (msg.role !== "assistant") return;
85
+
86
+ const content = (msg as unknown as { content: unknown }).content;
87
+ textBuffer = extractText(content);
88
+ if (!textBuffer) return;
89
+
90
+ const matcher = buildMatcher();
91
+ if (!matcher) return;
92
+
93
+ const results = matcher.match(textBuffer);
94
+ for (const result of results) {
95
+ if (!pendingMatches.has(result.entry.filePath)) {
96
+ hasNew = true; // Just record - don't abort
97
+ }
98
+ pendingMatches.set(result.entry.filePath, result.matchedKeywords);
99
+ }
100
+
101
+ // REMOVE: ctx.abort() entirely
102
+ // Just let the turn complete naturally
103
+ });
104
+ ```
105
+
106
+ ### Option B: Detect Async Subagents Before Aborting
107
+
108
+ **Change:** Check if assistant just spawned async subagents, skip abort if so.
109
+
110
+ **Pros:**
111
+ - ✅ Preserves immediate injection behavior
112
+ - ✅ Subagents continue running
113
+
114
+ **Cons:**
115
+ - ❌ Fragile - depends on detecting specific tool call patterns
116
+ - ❌ Doesn't scale to other async workflows
117
+
118
+ ### Option C: Only Inject on User Input
119
+
120
+ **Change:** Only trigger immediate injection on `input` event (user messages), not on assistant streaming.
121
+
122
+ **Pros:**
123
+ - ✅ Clean separation of concerns
124
+ - ✅ User intent gets immediate response
125
+
126
+ **Cons:**
127
+ - ❌ Loses streaming injection optimization
128
+ - ❌ Still may not handle all async edge cases
129
+
130
+ ## Recommendation
131
+
132
+ **Use Option A:** Remove `ctx.abort()` entirely.
133
+
134
+ The streaming injection optimization is not worth the complexity and the risk of breaking async workflows. Most keyword matches from assistant streaming are not time-critical - they can wait for the next turn.
135
+
136
+ ## Additional Notes
137
+
138
+ ### The notifyOnComplete:"inject" Flow
139
+
140
+ 1. Parent spawns `async: true, notifyOnComplete: "inject"`
141
+ 2. Parent continues immediately (doesn't wait)
142
+ 3. Each subagent runs independently
143
+ 4. When subagent completes, result is injected as user message via `pi.sendUserMessage()`
144
+ 5. Parent receives the injected result and continues naturally
145
+
146
+ This flow is designed to work without any intervention from the parent. Breaking it with `ctx.abort()` + `"continue"` defeats the purpose.
147
+
148
+ ### Session Context
149
+
150
+ Each subagent runs in its **own session** with its own doc-injector instance. Doc injection in subagent sessions is fine - they inject their own docs into their own context. The problem is only in the **parent session**.
151
+
152
+ ## References
153
+
154
+ - doc-injector: `/Users/applesucks/dev/pi-docs/index.ts`
155
+ - subagentura: `/Users/applesucks/dev/pi-agents/subagent.ts`
156
+ - subagentura helpers: `/Users/applesucks/dev/pi-agents/helpers.ts`
157
+
158
+ ## Web Research Summary
159
+
160
+ ### Official Documentation
161
+
162
+ From `pi.dev/docs/latest/extensions`, the key lifecycle events are:
163
+
164
+ - `before_agent_start` - Fires after user submits prompt, **before** agent loop. Can inject a message and/or modify system prompt.
165
+ - `agent_start` / `agent_end` - Fires once per user prompt
166
+ - `message_update` - Fires during streaming output
167
+ - `input` - Fires on user input
168
+
169
+ **Important note from docs:** "For critical events (before_agent_start, context, tool_call), if all handlers fail, the system continues with default behavior."
170
+
171
+ ### Key Extension API Points
172
+
173
+ 1. **`ctx.abort()`** - "Request a graceful shutdown of pi. Interactive mode: Deferred until the agent becomes idle (after processing all queued steering and follow-up messages)."
174
+ 2. **`ctx.signal`** - Available for extensions to forward cancellation into nested model calls, fetch(), and other abort-aware work.
175
+ 3. **`before_agent_start`** - The recommended injection point for context, as it runs before the agent loop starts.
176
+
177
+ ### Related GitHub Issues
178
+
179
+ - [Issue #624](https://github.com/earendil-works/pi/issues/624): "before_agent_start event does not properly inject message" - indicates there have been historical issues with injection timing
180
+ - [Issue #2660](https://github.com/earendil-works/pi/issues/2660): "Expose abort signal on ExtensionContext" - led to `ctx.signal` being added
181
+
182
+ ### Subagent Extensions Found
183
+
184
+ 1. **tintinweb/pi-subagents** (`pi.dev/packages/pi-subagents`)
185
+ - Claude Code-style autonomous subagents
186
+ - Spawns agents in isolated sessions
187
+ - Run in foreground or background
188
+ - Similar to subagentura
189
+
190
+ 2. **nicobailon/pi-subagents** (GitHub)
191
+ - Async subagent delegation with truncation, artifacts, session sharing
192
+
193
+ ### Conclusion from Web Research
194
+
195
+ No documented solutions exist for the specific conflict between streaming injection (via `ctx.abort()`) and async subagent workflows. The recommended approach based on official docs is:
196
+
197
+ 1. Use `before_agent_start` for context injection (runs before agent loop)
198
+ 2. Avoid `ctx.abort()` during streaming - it disrupts any workflow that expects continuous streaming
199
+ 3. The `input` event is the cleanest injection point for user-driven context since it represents new user intent
package/index.ts CHANGED
@@ -120,11 +120,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
120
120
  }
121
121
 
122
122
  const count = registry.getEntries().length;
123
- if (count > 0) {
124
- console.log(`[doc-injector] Loaded ${count} documents from ${docsPath}`);
125
- } else {
126
- console.warn(`[doc-injector] No documents found at ${docsPath}`);
127
- }
128
123
  };
129
124
 
130
125
  const buildMatcher = (): KeywordMatcher | null => {
@@ -170,7 +165,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
170
165
  const absPath = resolve(ctx.cwd, config.docsPath, item.path);
171
166
  const fileStat = await stat(absPath).catch(() => null);
172
167
  if (!fileStat) {
173
- console.warn(`[doc-injector] Skipping keyword save for ${item.path}: file not found`);
174
168
  continue;
175
169
  }
176
170
  cache.files[item.path] = {
@@ -231,7 +225,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
231
225
  }
232
226
 
233
227
  const count = registry.getEntries().length;
234
- console.log(`[doc-injector] Reloaded: ${count} documents`);
235
228
  return count;
236
229
  };
237
230
 
@@ -338,7 +331,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
338
331
  // past the model's limit.
339
332
  const usage = ctx.getContextUsage();
340
333
  if (usage && usage.tokens && usage.tokens > 0 && usage.percent && usage.percent > config.contextThreshold) {
341
- console.warn(`[doc-injector] Skipping injection: context usage > ${config.contextThreshold}%`);
342
334
  pendingMatches.clear();
343
335
  return;
344
336
  }
@@ -379,8 +371,6 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
379
371
  setTimeout(() => {
380
372
  pi.sendUserMessage("continue");
381
373
  }, 0);
382
- } else {
383
- console.log('[doc-injector] agent_end: abortingForInjection is false, skipping');
384
374
  }
385
375
  });
386
376
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Auto-inject relevant project documentation into Pi's LLM context based on keyword matching",
5
5
  "type": "module",
6
6
  "main": "./index.ts",