@oai404iao/pi-subagent 0.2.0 → 0.4.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/providers.ts CHANGED
@@ -1,7 +1,22 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
- import type { SubagentMode, SubagentProviderName } from "./types.ts";
4
+ import type {
5
+ ContextInheritance,
6
+ SubagentMode,
7
+ SubagentProviderName,
8
+ } from "./types.ts";
9
+
10
+ const INHERITED_COMPACTION_CUSTOM_TYPE =
11
+ "pi-subagent/inherited-compaction-summary";
12
+ const INHERITED_BRANCH_CUSTOM_TYPE =
13
+ "pi-subagent/inherited-branch-summary";
14
+ const COMPACTION_SUMMARY_PREFIX =
15
+ "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
16
+ const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
17
+ const BRANCH_SUMMARY_PREFIX =
18
+ "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
19
+ const BRANCH_SUMMARY_SUFFIX = "</summary>";
5
20
 
6
21
  export interface SessionView {
7
22
  getBranch(): SessionEntry[];
@@ -9,6 +24,9 @@ export interface SessionView {
9
24
  getSessionDir(): string;
10
25
  getSessionFile(): string | undefined;
11
26
  getSessionId(): string;
27
+ getEntries(): SessionEntry[];
28
+ buildContextEntries(): SessionEntry[];
29
+ appendCustomEntry(customType: string, data?: unknown): string;
12
30
  }
13
31
 
14
32
  export interface ProviderParent {
@@ -25,7 +43,11 @@ export interface ChildProvider {
25
43
  name: SubagentProviderName;
26
44
  inheritsParentContext: boolean;
27
45
  supportsContinuable: boolean;
28
- prepare(parent: ProviderParent, mode: SubagentMode): Promise<PreparedChildSession>;
46
+ prepare(
47
+ parent: ProviderParent,
48
+ mode: SubagentMode,
49
+ context?: ContextInheritance,
50
+ ): Promise<PreparedChildSession>;
29
51
  }
30
52
 
31
53
  async function removeOwnedSession(path: string | undefined): Promise<void> {
@@ -66,33 +88,195 @@ export function completedTurnBoundaryId(entries: readonly SessionEntry[]): strin
66
88
  return undefined;
67
89
  }
68
90
 
69
- function forkedSession(parent: ProviderParent): PreparedChildSession {
91
+ interface CompletedTurnSpan {
92
+ start: number;
93
+ end: number;
94
+ }
95
+
96
+ function isSummaryEntry(entry: SessionEntry): boolean {
97
+ return (
98
+ entry.type === "compaction"
99
+ || entry.type === "branch_summary"
100
+ || (
101
+ entry.type === "message"
102
+ && (
103
+ entry.message.role === "compactionSummary"
104
+ || entry.message.role === "branchSummary"
105
+ )
106
+ )
107
+ );
108
+ }
109
+
110
+ function startsModelTurn(entry: SessionEntry): boolean {
111
+ if (entry.type === "custom_message") return true;
112
+ if (entry.type !== "message") return false;
113
+ switch (entry.message.role) {
114
+ case "user":
115
+ case "custom":
116
+ return true;
117
+ case "bashExecution":
118
+ return !entry.message.excludeFromContext;
119
+ default:
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function completedTurnSpans(
125
+ entries: readonly SessionEntry[],
126
+ ): {
127
+ turns: CompletedTurnSpan[];
128
+ latestSummary?: number;
129
+ } {
130
+ const turns: CompletedTurnSpan[] = [];
131
+ let latestSummary: number | undefined;
132
+ let currentStart: number | undefined;
133
+ let currentEnd: number | undefined;
134
+ const finishCurrent = () => {
135
+ if (currentStart !== undefined && currentEnd !== undefined) {
136
+ turns.push({ start: currentStart, end: currentEnd });
137
+ }
138
+ currentStart = undefined;
139
+ currentEnd = undefined;
140
+ };
141
+
142
+ for (let index = 0; index < entries.length; index++) {
143
+ const entry = entries[index]!;
144
+ if (isSummaryEntry(entry)) {
145
+ finishCurrent();
146
+ latestSummary = index;
147
+ continue;
148
+ }
149
+ if (startsModelTurn(entry)) {
150
+ finishCurrent();
151
+ currentStart = index;
152
+ continue;
153
+ }
154
+ if (entry.type !== "message") continue;
155
+ if (
156
+ currentStart === undefined
157
+ && (
158
+ entry.message.role === "assistant"
159
+ || entry.message.role === "toolResult"
160
+ )
161
+ ) {
162
+ currentStart = latestSummary ?? 0;
163
+ }
164
+ if (
165
+ entry.message.role === "assistant"
166
+ && entry.message.stopReason !== "toolUse"
167
+ ) {
168
+ currentEnd = index;
169
+ }
170
+ }
171
+ finishCurrent();
172
+ return {
173
+ turns,
174
+ ...(latestSummary !== undefined ? { latestSummary } : {}),
175
+ };
176
+ }
177
+
178
+ export function completedContextEntries(
179
+ entries: readonly SessionEntry[],
180
+ context: Exclude<ContextInheritance, { mode: "fresh" }>,
181
+ ): SessionEntry[] {
182
+ const { turns, latestSummary } = completedTurnSpans(entries);
183
+ const end = Math.max(
184
+ turns.at(-1)?.end ?? -1,
185
+ latestSummary ?? -1,
186
+ );
187
+ if (end < 0) return [];
188
+ let start = 0;
189
+ if (context.mode === "last_n_completed") {
190
+ if (turns.length >= context.completedTurns) {
191
+ start = turns[turns.length - context.completedTurns]!.start;
192
+ }
193
+ }
194
+ return entries
195
+ .slice(start, end + 1)
196
+ .map((entry) => structuredClone(entry));
197
+ }
198
+
199
+ function appendInheritedEntry(
200
+ session: SessionManager,
201
+ entry: SessionEntry,
202
+ ): void {
203
+ if (entry.type === "message") {
204
+ if (entry.message.role === "compactionSummary") {
205
+ session.appendCustomMessageEntry(
206
+ INHERITED_COMPACTION_CUSTOM_TYPE,
207
+ `${COMPACTION_SUMMARY_PREFIX}${entry.message.summary}${COMPACTION_SUMMARY_SUFFIX}`,
208
+ false,
209
+ { tokensBefore: entry.message.tokensBefore },
210
+ );
211
+ return;
212
+ }
213
+ if (entry.message.role === "branchSummary") {
214
+ session.appendCustomMessageEntry(
215
+ INHERITED_BRANCH_CUSTOM_TYPE,
216
+ `${BRANCH_SUMMARY_PREFIX}${entry.message.summary}${BRANCH_SUMMARY_SUFFIX}`,
217
+ false,
218
+ { fromId: entry.message.fromId },
219
+ );
220
+ return;
221
+ }
222
+ session.appendMessage(
223
+ structuredClone(
224
+ entry.message,
225
+ ) as Parameters<SessionManager["appendMessage"]>[0],
226
+ );
227
+ return;
228
+ }
229
+ if (entry.type === "custom_message") {
230
+ session.appendCustomMessageEntry(
231
+ entry.customType,
232
+ structuredClone(entry.content),
233
+ entry.display,
234
+ structuredClone(entry.details),
235
+ );
236
+ return;
237
+ }
238
+ if (entry.type === "compaction") {
239
+ session.appendCustomMessageEntry(
240
+ INHERITED_COMPACTION_CUSTOM_TYPE,
241
+ `${COMPACTION_SUMMARY_PREFIX}${entry.summary}${COMPACTION_SUMMARY_SUFFIX}`,
242
+ false,
243
+ { tokensBefore: entry.tokensBefore },
244
+ );
245
+ return;
246
+ }
247
+ if (entry.type === "branch_summary") {
248
+ session.appendCustomMessageEntry(
249
+ INHERITED_BRANCH_CUSTOM_TYPE,
250
+ `${BRANCH_SUMMARY_PREFIX}${entry.summary}${BRANCH_SUMMARY_SUFFIX}`,
251
+ false,
252
+ { fromId: entry.fromId },
253
+ );
254
+ }
255
+ }
256
+
257
+ function forkedSession(
258
+ parent: ProviderParent,
259
+ context: Exclude<ContextInheritance, { mode: "fresh" }>,
260
+ ): PreparedChildSession {
70
261
  const parentFile = parent.sessionManager.getSessionFile();
71
- const boundaryId = completedTurnBoundaryId(parent.sessionManager.getBranch());
72
- if (!boundaryId) return freshSession(parent);
262
+ const inherited = completedContextEntries(
263
+ parent.sessionManager.buildContextEntries(),
264
+ context,
265
+ );
266
+ if (inherited.length === 0) return freshSession(parent);
73
267
  if (!parentFile) {
74
268
  throw new Error(
75
269
  "fork provider cannot copy completed history from an ephemeral parent session; use spawn instead",
76
270
  );
77
271
  }
78
-
79
- const clone = SessionManager.open(
80
- parentFile,
81
- parent.sessionManager.getSessionDir(),
82
- parent.sessionManager.getCwd(),
83
- );
84
- if (!clone.getEntry(boundaryId)) return freshSession(parent);
85
- const childFile = clone.createBranchedSession(boundaryId);
86
- if (!childFile) return freshSession(parent);
87
- const sessionManager = SessionManager.open(
88
- childFile,
89
- parent.sessionManager.getSessionDir(),
90
- parent.sessionManager.getCwd(),
91
- );
272
+ const prepared = freshSession(parent);
273
+ for (const entry of inherited) {
274
+ appendInheritedEntry(prepared.sessionManager, entry);
275
+ }
92
276
  return {
93
- sessionManager,
94
- seedMessageCount: sessionManager.buildSessionContext().messages.length,
95
- rollback: () => removeOwnedSession(childFile),
277
+ ...prepared,
278
+ seedMessageCount:
279
+ prepared.sessionManager.buildSessionContext().messages.length,
96
280
  };
97
281
  }
98
282
 
@@ -101,7 +285,14 @@ export class SpawnProvider implements ChildProvider {
101
285
  readonly inheritsParentContext = false;
102
286
  readonly supportsContinuable = true;
103
287
 
104
- prepare(parent: ProviderParent, _mode: SubagentMode): Promise<PreparedChildSession> {
288
+ prepare(
289
+ parent: ProviderParent,
290
+ _mode: SubagentMode,
291
+ context: ContextInheritance = { mode: "fresh" },
292
+ ): Promise<PreparedChildSession> {
293
+ if (context.mode !== "fresh") {
294
+ throw new Error("spawn provider requires fresh context");
295
+ }
105
296
  return Promise.resolve(freshSession(parent));
106
297
  }
107
298
  }
@@ -109,13 +300,17 @@ export class SpawnProvider implements ChildProvider {
109
300
  export class ForkProvider implements ChildProvider {
110
301
  readonly name = "fork";
111
302
  readonly inheritsParentContext = true;
112
- readonly supportsContinuable = false;
303
+ readonly supportsContinuable = true;
113
304
 
114
- async prepare(parent: ProviderParent, mode: SubagentMode): Promise<PreparedChildSession> {
115
- if (mode === "continuable") {
116
- throw new Error("fork provider is one-shot only; use spawn for continuable background work");
305
+ async prepare(
306
+ parent: ProviderParent,
307
+ _mode: SubagentMode,
308
+ context: ContextInheritance = { mode: "all_completed" },
309
+ ): Promise<PreparedChildSession> {
310
+ if (context.mode === "fresh") {
311
+ throw new Error("fork provider requires inherited context");
117
312
  }
118
- return forkedSession(parent);
313
+ return forkedSession(parent, context);
119
314
  }
120
315
  }
121
316
 
package/src/render.ts CHANGED
@@ -4,7 +4,14 @@ import type { DelegationDetails, ParentMessageDetails } from "./types.ts";
4
4
  import { formatUsage } from "./result.ts";
5
5
 
6
6
  export function renderDelegationCall(
7
- args: { agent?: string; description?: string; prompt?: string; run_in_background?: boolean },
7
+ args: {
8
+ agent?: string;
9
+ task_name?: string;
10
+ description?: string;
11
+ prompt?: string;
12
+ run_in_background?: boolean;
13
+ context?: { mode?: string };
14
+ },
8
15
  theme: {
9
16
  fg(color: any, text: string): string;
10
17
  bold(text: string): string;
@@ -13,17 +20,20 @@ export function renderDelegationCall(
13
20
  ): Text {
14
21
  const mode =
15
22
  provider === "fork"
16
- ? "fork · foreground"
23
+ ? args.run_in_background === true
24
+ ? "fork · background"
25
+ : "fork · foreground"
17
26
  : args.run_in_background === false
18
- ? "spawn · foreground"
27
+ ? `${args.context?.mode ?? "fresh"} · foreground`
19
28
  : args.run_in_background === true
20
- ? "spawn · background"
21
- : "spawn · configured default";
29
+ ? `${args.context?.mode ?? "fresh"} · background`
30
+ : `${args.context?.mode ?? "fresh"} · configured default`;
22
31
  let text =
23
32
  theme.fg("toolTitle", theme.bold(provider === "fork" ? "subagent_fork " : "subagent ")) +
24
33
  theme.fg("accent", args.agent ?? "…") +
25
34
  theme.fg("muted", ` [${mode}]`);
26
35
  if (args.description) text += `\n ${theme.fg("dim", args.description)}`;
36
+ if (args.task_name) text += `\n ${theme.fg("dim", `path: ${args.task_name}`)}`;
27
37
  return new Text(text, 0, 0);
28
38
  }
29
39
 
@@ -37,6 +47,10 @@ export function renderDelegationResult(
37
47
  },
38
48
  ): Text | Container {
39
49
  if (!details) return new Text(content || "(no output)", 0, 0);
50
+ const taskPath = details.taskPath || details.agentId;
51
+ const contextMode =
52
+ details.context?.mode
53
+ ?? (details.provider === "fork" ? "all_completed" : "fresh");
40
54
  const running = options.isPartial || details.status === "starting" || details.status === "running";
41
55
  const icon = running
42
56
  ? theme.fg("warning", "◌")
@@ -45,8 +59,8 @@ export function renderDelegationResult(
45
59
  : theme.fg("success", "✓");
46
60
  const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(
47
61
  "muted",
48
- `[${details.provider}/${details.mode}]`,
49
- )} ${theme.fg("dim", details.id)}`;
62
+ `[${details.provider}/${details.mode}/${contextMode}]`,
63
+ )} ${theme.fg("accent", taskPath)} ${theme.fg("dim", details.agentId)}`;
50
64
 
51
65
  if (!options.expanded) {
52
66
  const lines = [header, theme.fg("muted", details.label)];
@@ -102,7 +116,10 @@ export function renderParentMessage(
102
116
  theme.fg("accent", icon) +
103
117
  " " +
104
118
  theme.fg("toolTitle", theme.bold(`subagent ${kind}`)) +
105
- theme.fg("muted", ` ${details?.childId ?? "unknown"}${label}`),
119
+ theme.fg(
120
+ "muted",
121
+ ` ${details?.taskPath ?? details?.childAgentId ?? "unknown"}${label}`,
122
+ ),
106
123
  outputPad,
107
124
  0,
108
125
  );
@@ -0,0 +1,173 @@
1
+ export interface BackgroundRunPermit {
2
+ release(): void;
3
+ }
4
+
5
+ export interface BackgroundRunAcquireOptions {
6
+ signal?: AbortSignal;
7
+ waitForCapacity?: boolean;
8
+ }
9
+
10
+ export class BackgroundConcurrencyLimitError extends Error {
11
+ constructor(readonly limit: number) {
12
+ super(
13
+ `background subagent concurrency limit (${limit}) is already in use; wait for an active background run to finish`,
14
+ );
15
+ this.name = "BackgroundConcurrencyLimitError";
16
+ }
17
+ }
18
+
19
+ interface BackgroundRunWaiter {
20
+ resolve: (permit: BackgroundRunPermit) => void;
21
+ reject: (error: Error) => void;
22
+ signal?: AbortSignal;
23
+ onAbort?: () => void;
24
+ }
25
+
26
+ export class BackgroundRunLimiter {
27
+ private active = 0;
28
+ private readonly waiters: BackgroundRunWaiter[] = [];
29
+ private closedError: Error | undefined;
30
+
31
+ constructor(private limit = 4) {
32
+ validateLimit(limit);
33
+ }
34
+
35
+ get activeCount(): number {
36
+ return this.active;
37
+ }
38
+
39
+ get pendingCount(): number {
40
+ return this.waiters.length;
41
+ }
42
+
43
+ configure(limit: number): void {
44
+ validateLimit(limit);
45
+ if (limit === this.limit) return;
46
+ this.limit = limit;
47
+ this.drain();
48
+ }
49
+
50
+ acquire(
51
+ options: BackgroundRunAcquireOptions = {},
52
+ ): Promise<BackgroundRunPermit> {
53
+ if (this.closedError) return Promise.reject(this.closedError);
54
+ if (options.signal?.aborted) {
55
+ return Promise.reject(abortReason(options.signal));
56
+ }
57
+ if (this.waiters.length === 0 && this.active < this.limit) {
58
+ this.active += 1;
59
+ return Promise.resolve(this.createPermit());
60
+ }
61
+ if (options.waitForCapacity === false) {
62
+ return Promise.reject(new BackgroundConcurrencyLimitError(this.limit));
63
+ }
64
+
65
+ return new Promise<BackgroundRunPermit>((resolve, reject) => {
66
+ const waiter: BackgroundRunWaiter = {
67
+ resolve,
68
+ reject,
69
+ ...(options.signal ? { signal: options.signal } : {}),
70
+ };
71
+ if (options.signal) {
72
+ waiter.onAbort = () => {
73
+ const index = this.waiters.indexOf(waiter);
74
+ if (index >= 0) this.waiters.splice(index, 1);
75
+ reject(abortReason(options.signal!));
76
+ this.drain();
77
+ };
78
+ options.signal.addEventListener("abort", waiter.onAbort, { once: true });
79
+ }
80
+ this.waiters.push(waiter);
81
+ });
82
+ }
83
+
84
+ close(error = new Error("background subagent scheduler is shutting down")): void {
85
+ if (this.closedError) return;
86
+ this.closedError = error;
87
+ for (const waiter of this.waiters.splice(0)) {
88
+ this.removeAbortListener(waiter);
89
+ waiter.reject(error);
90
+ }
91
+ }
92
+
93
+ private createPermit(): BackgroundRunPermit {
94
+ let released = false;
95
+ return {
96
+ release: () => {
97
+ if (released) return;
98
+ released = true;
99
+ this.active = Math.max(0, this.active - 1);
100
+ this.drain();
101
+ },
102
+ };
103
+ }
104
+
105
+ private drain(): void {
106
+ while (this.waiters.length > 0) {
107
+ const waiter = this.waiters[0]!;
108
+ if (waiter.signal?.aborted) {
109
+ this.waiters.shift();
110
+ this.removeAbortListener(waiter);
111
+ waiter.reject(abortReason(waiter.signal));
112
+ continue;
113
+ }
114
+ if (this.closedError) {
115
+ this.waiters.shift();
116
+ this.removeAbortListener(waiter);
117
+ waiter.reject(this.closedError);
118
+ continue;
119
+ }
120
+ if (this.active >= this.limit) return;
121
+ this.waiters.shift();
122
+ this.removeAbortListener(waiter);
123
+ this.active += 1;
124
+ waiter.resolve(this.createPermit());
125
+ }
126
+ }
127
+
128
+ private removeAbortListener(waiter: BackgroundRunWaiter): void {
129
+ if (waiter.signal && waiter.onAbort) {
130
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
131
+ }
132
+ }
133
+ }
134
+
135
+ export class AgentOperationQueue {
136
+ private readonly tails = new Map<string, Promise<void>>();
137
+
138
+ async run<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
139
+ const predecessor = this.tails.get(agentId) ?? Promise.resolve();
140
+ let release!: () => void;
141
+ const gate = new Promise<void>((resolve) => {
142
+ release = resolve;
143
+ });
144
+ const tail = predecessor.catch(() => {}).then(() => gate);
145
+ this.tails.set(agentId, tail);
146
+
147
+ await predecessor.catch(() => {});
148
+ try {
149
+ return await operation();
150
+ } finally {
151
+ release();
152
+ if (this.tails.get(agentId) === tail) this.tails.delete(agentId);
153
+ }
154
+ }
155
+
156
+ async waitForIdle(): Promise<void> {
157
+ while (this.tails.size > 0) {
158
+ await Promise.all([...this.tails.values()].map((tail) => tail.catch(() => {})));
159
+ }
160
+ }
161
+ }
162
+
163
+ function validateLimit(limit: number): void {
164
+ if (!Number.isSafeInteger(limit) || limit < 1) {
165
+ throw new Error("background run limit must be a positive safe integer");
166
+ }
167
+ }
168
+
169
+ function abortReason(signal: AbortSignal): Error {
170
+ return signal.reason instanceof Error
171
+ ? signal.reason
172
+ : new Error(signal.reason ? String(signal.reason) : "background run scheduling aborted");
173
+ }