@tintinweb/pi-subagents 0.7.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.1] - 2026-05-07
11
+
12
+ > **Heads-up — behavior change:**
13
+ > - `isolation: "worktree"` now fails loud (returns an error) instead of silently falling back to the main tree. Affects users running pi in a non-git directory or a fresh repo with no commits.
14
+
15
+ ### Changed
16
+ - **`isolation: "worktree"` now fails loud instead of silently falling back.** Previously when `createWorktree` returned undefined (not a git repo, no commits yet, or `git worktree add` failed), the agent ran in the main `cwd` with a `[WARNING: ...]` block prepended to its prompt — visible only to the LLM, never surfaced to the caller. Now the failure throws a structured error that propagates back to the `Agent` tool response; no agent record is created. Failed scheduled fires are recorded as `lastStatus: "error"` with the reason in the `subagents:scheduled` error event. Queued background spawns whose worktree creation fails when they dequeue are marked terminal-error and don't block the rest of the queue.
17
+
18
+ ### Fixed
19
+
20
+ - **Headless `pi --print` runs no longer hang or crash after background
21
+ subagents complete.** Cleanup timers no longer keep the process alive, and
22
+ stale completion notifications are treated as best-effort shutdown side
23
+ effects.
24
+
10
25
  ## [0.7.0] - 2026-05-04
11
26
 
12
27
  > **Heads-up — behavior changes:**
package/README.md CHANGED
@@ -453,7 +453,7 @@ The agent gets a full, isolated copy of the repository. On completion:
453
453
  - **No changes:** worktree is cleaned up automatically
454
454
  - **Changes made:** changes are committed to a new branch (`pi-agent-<id>`) and returned in the result
455
455
 
456
- If the worktree cannot be created (not a git repo, no commits), the agent falls back to the main working directory with a warning.
456
+ If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Initialize git and commit at least once, or omit `isolation`.
457
457
 
458
458
  ## Skill Preloading
459
459
 
@@ -29,6 +29,7 @@ export class AgentManager {
29
29
  this.maxConcurrent = maxConcurrent;
30
30
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
31
31
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
32
+ this.cleanupInterval.unref();
32
33
  }
33
34
  /** Update the max concurrent background agents limit. */
34
35
  setMaxConcurrent(n) {
@@ -64,11 +65,32 @@ export class AgentManager {
64
65
  this.queue.push({ id, args });
65
66
  return id;
66
67
  }
67
- this.startAgent(id, record, args);
68
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
69
+ // up the record so callers don't see an orphan in `listAgents()`.
70
+ try {
71
+ this.startAgent(id, record, args);
72
+ }
73
+ catch (err) {
74
+ this.agents.delete(id);
75
+ throw err;
76
+ }
68
77
  return id;
69
78
  }
70
79
  /** Actually start an agent (called immediately or from queue drain). */
71
80
  startAgent(id, record, { pi, ctx, type, prompt, options }) {
81
+ // Worktree isolation: try to create a temporary git worktree. Strict —
82
+ // fail loud if not possible (no silent fallback to main tree). Done
83
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
84
+ let worktreeCwd;
85
+ if (options.isolation === "worktree") {
86
+ const wt = createWorktree(ctx.cwd, id);
87
+ if (!wt) {
88
+ throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
89
+ 'Initialize git and commit at least once, or omit `isolation`.');
90
+ }
91
+ record.worktree = wt;
92
+ worktreeCwd = wt.path;
93
+ }
72
94
  record.status = "running";
73
95
  record.startedAt = Date.now();
74
96
  if (options.isBackground)
@@ -82,22 +104,7 @@ export class AgentManager {
82
104
  detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
83
105
  }
84
106
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
85
- // Worktree isolation: create a temporary git worktree if requested
86
- let worktreeCwd;
87
- let worktreeWarning = "";
88
- if (options.isolation === "worktree") {
89
- const wt = createWorktree(ctx.cwd, id);
90
- if (wt) {
91
- record.worktree = wt;
92
- worktreeCwd = wt.path;
93
- }
94
- else {
95
- worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
96
- }
97
- }
98
- // Prepend worktree warning to prompt if isolation failed
99
- const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
100
- const promise = runAgent(ctx, type, effectivePrompt, {
107
+ const promise = runAgent(ctx, type, prompt, {
101
108
  pi,
102
109
  model: options.model,
103
110
  maxTurns: options.maxTurns,
@@ -162,7 +169,10 @@ export class AgentManager {
162
169
  }
163
170
  if (options.isBackground) {
164
171
  this.runningBackground--;
165
- this.onComplete?.(record);
172
+ try {
173
+ this.onComplete?.(record);
174
+ }
175
+ catch { /* ignore completion side-effect errors */ }
166
176
  this.drainQueue();
167
177
  }
168
178
  return responseText;
@@ -207,7 +217,17 @@ export class AgentManager {
207
217
  const record = this.agents.get(next.id);
208
218
  if (!record || record.status !== "queued")
209
219
  continue;
210
- this.startAgent(next.id, record, next.args);
220
+ try {
221
+ this.startAgent(next.id, record, next.args);
222
+ }
223
+ catch (err) {
224
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
225
+ // so the user/agent can see it via /agents, then keep draining.
226
+ record.status = "error";
227
+ record.error = err instanceof Error ? err.message : String(err);
228
+ record.completedAt = Date.now();
229
+ this.onComplete?.(record);
230
+ }
211
231
  }
212
232
  }
213
233
  /**
package/dist/index.js CHANGED
@@ -235,7 +235,10 @@ export default function (pi) {
235
235
  cancelNudge(key);
236
236
  pendingNudges.set(key, setTimeout(() => {
237
237
  pendingNudges.delete(key);
238
- send();
238
+ try {
239
+ send();
240
+ }
241
+ catch { /* ignore stale completion side-effect errors */ }
239
242
  }, delay));
240
243
  }
241
244
  function cancelNudge(key) {
@@ -830,17 +833,22 @@ Guidelines:
830
833
  rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
831
834
  }
832
835
  };
833
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
834
- description: params.description,
835
- model,
836
- maxTurns: effectiveMaxTurns,
837
- isolated,
838
- inheritContext,
839
- thinkingLevel: thinking,
840
- isBackground: true,
841
- isolation,
842
- ...bgCallbacks,
843
- });
836
+ try {
837
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
838
+ description: params.description,
839
+ model,
840
+ maxTurns: effectiveMaxTurns,
841
+ isolated,
842
+ inheritContext,
843
+ thinkingLevel: thinking,
844
+ isBackground: true,
845
+ isolation,
846
+ ...bgCallbacks,
847
+ });
848
+ }
849
+ catch (err) {
850
+ return textResult(err instanceof Error ? err.message : String(err));
851
+ }
844
852
  // Set output file + join mode synchronously after spawn, before the
845
853
  // event loop yields — onSessionCreated is async so this is safe.
846
854
  const joinMode = resolveJoinMode(defaultJoinMode, true);
@@ -925,17 +933,24 @@ Guidelines:
925
933
  streamUpdate();
926
934
  }, 80);
927
935
  streamUpdate();
928
- const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
929
- description: params.description,
930
- model,
931
- maxTurns: effectiveMaxTurns,
932
- isolated,
933
- inheritContext,
934
- thinkingLevel: thinking,
935
- isolation,
936
- signal,
937
- ...fgCallbacks,
938
- });
936
+ let record;
937
+ try {
938
+ record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
939
+ description: params.description,
940
+ model,
941
+ maxTurns: effectiveMaxTurns,
942
+ isolated,
943
+ inheritContext,
944
+ thinkingLevel: thinking,
945
+ isolation,
946
+ signal,
947
+ ...fgCallbacks,
948
+ });
949
+ }
950
+ catch (err) {
951
+ clearInterval(spinnerInterval);
952
+ return textResult(err instanceof Error ? err.message : String(err));
953
+ }
939
954
  clearInterval(spinnerInterval);
940
955
  // Clean up foreground agent from widget
941
956
  if (fgId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -87,6 +87,7 @@ export class AgentManager {
87
87
  this.maxConcurrent = maxConcurrent;
88
88
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
89
89
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
90
+ this.cleanupInterval.unref();
90
91
  }
91
92
 
92
93
  /** Update the max concurrent background agents limit. */
@@ -134,12 +135,35 @@ export class AgentManager {
134
135
  return id;
135
136
  }
136
137
 
137
- this.startAgent(id, record, args);
138
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
139
+ // up the record so callers don't see an orphan in `listAgents()`.
140
+ try {
141
+ this.startAgent(id, record, args);
142
+ } catch (err) {
143
+ this.agents.delete(id);
144
+ throw err;
145
+ }
138
146
  return id;
139
147
  }
140
148
 
141
149
  /** Actually start an agent (called immediately or from queue drain). */
142
150
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
151
+ // Worktree isolation: try to create a temporary git worktree. Strict —
152
+ // fail loud if not possible (no silent fallback to main tree). Done
153
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
154
+ let worktreeCwd: string | undefined;
155
+ if (options.isolation === "worktree") {
156
+ const wt = createWorktree(ctx.cwd, id);
157
+ if (!wt) {
158
+ throw new Error(
159
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
160
+ 'Initialize git and commit at least once, or omit `isolation`.',
161
+ );
162
+ }
163
+ record.worktree = wt;
164
+ worktreeCwd = wt.path;
165
+ }
166
+
143
167
  record.status = "running";
144
168
  record.startedAt = Date.now();
145
169
  if (options.isBackground) this.runningBackground++;
@@ -154,23 +178,7 @@ export class AgentManager {
154
178
  }
155
179
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
156
180
 
157
- // Worktree isolation: create a temporary git worktree if requested
158
- let worktreeCwd: string | undefined;
159
- let worktreeWarning = "";
160
- if (options.isolation === "worktree") {
161
- const wt = createWorktree(ctx.cwd, id);
162
- if (wt) {
163
- record.worktree = wt;
164
- worktreeCwd = wt.path;
165
- } else {
166
- worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
167
- }
168
- }
169
-
170
- // Prepend worktree warning to prompt if isolation failed
171
- const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
172
-
173
- const promise = runAgent(ctx, type, effectivePrompt, {
181
+ const promise = runAgent(ctx, type, prompt, {
174
182
  pi,
175
183
  model: options.model,
176
184
  maxTurns: options.maxTurns,
@@ -235,7 +243,7 @@ export class AgentManager {
235
243
 
236
244
  if (options.isBackground) {
237
245
  this.runningBackground--;
238
- this.onComplete?.(record);
246
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
239
247
  this.drainQueue();
240
248
  }
241
249
  return responseText;
@@ -281,7 +289,16 @@ export class AgentManager {
281
289
  const next = this.queue.shift()!;
282
290
  const record = this.agents.get(next.id);
283
291
  if (!record || record.status !== "queued") continue;
284
- this.startAgent(next.id, record, next.args);
292
+ try {
293
+ this.startAgent(next.id, record, next.args);
294
+ } catch (err) {
295
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
296
+ // so the user/agent can see it via /agents, then keep draining.
297
+ record.status = "error";
298
+ record.error = err instanceof Error ? err.message : String(err);
299
+ record.completedAt = Date.now();
300
+ this.onComplete?.(record);
301
+ }
285
302
  }
286
303
  }
287
304
 
package/src/index.ts CHANGED
@@ -275,7 +275,7 @@ export default function (pi: ExtensionAPI) {
275
275
  cancelNudge(key);
276
276
  pendingNudges.set(key, setTimeout(() => {
277
277
  pendingNudges.delete(key);
278
- send();
278
+ try { send(); } catch { /* ignore stale completion side-effect errors */ }
279
279
  }, delay));
280
280
  }
281
281
 
@@ -946,17 +946,21 @@ Guidelines:
946
946
  }
947
947
  };
948
948
 
949
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
950
- description: params.description,
951
- model,
952
- maxTurns: effectiveMaxTurns,
953
- isolated,
954
- inheritContext,
955
- thinkingLevel: thinking,
956
- isBackground: true,
957
- isolation,
958
- ...bgCallbacks,
959
- });
949
+ try {
950
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
951
+ description: params.description,
952
+ model,
953
+ maxTurns: effectiveMaxTurns,
954
+ isolated,
955
+ inheritContext,
956
+ thinkingLevel: thinking,
957
+ isBackground: true,
958
+ isolation,
959
+ ...bgCallbacks,
960
+ });
961
+ } catch (err) {
962
+ return textResult(err instanceof Error ? err.message : String(err));
963
+ }
960
964
 
961
965
  // Set output file + join mode synchronously after spawn, before the
962
966
  // event loop yields — onSessionCreated is async so this is safe.
@@ -1054,17 +1058,23 @@ Guidelines:
1054
1058
 
1055
1059
  streamUpdate();
1056
1060
 
1057
- const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1058
- description: params.description,
1059
- model,
1060
- maxTurns: effectiveMaxTurns,
1061
- isolated,
1062
- inheritContext,
1063
- thinkingLevel: thinking,
1064
- isolation,
1065
- signal,
1066
- ...fgCallbacks,
1067
- });
1061
+ let record: AgentRecord;
1062
+ try {
1063
+ record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1064
+ description: params.description,
1065
+ model,
1066
+ maxTurns: effectiveMaxTurns,
1067
+ isolated,
1068
+ inheritContext,
1069
+ thinkingLevel: thinking,
1070
+ isolation,
1071
+ signal,
1072
+ ...fgCallbacks,
1073
+ });
1074
+ } catch (err) {
1075
+ clearInterval(spinnerInterval);
1076
+ return textResult(err instanceof Error ? err.message : String(err));
1077
+ }
1068
1078
 
1069
1079
  clearInterval(spinnerInterval);
1070
1080