pi-subagents 0.9.2 → 0.11.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 +33 -0
- package/README.md +56 -6
- package/async-execution.ts +64 -30
- package/chain-clarify.ts +16 -4
- package/chain-execution.ts +31 -1
- package/execution.ts +16 -1
- package/index.ts +234 -25
- package/package.json +11 -2
- package/parallel-utils.ts +93 -0
- package/render.ts +78 -8
- package/schemas.ts +1 -1
- package/settings.ts +16 -14
- package/skills.ts +25 -1
- package/subagent-runner.ts +360 -176
- package/utils.ts +23 -7
package/index.ts
CHANGED
|
@@ -67,9 +67,31 @@ function loadConfig(): ExtensionConfig {
|
|
|
67
67
|
return {};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Create a directory and verify it is actually accessible.
|
|
72
|
+
* On Windows with Azure AD/Entra ID, directories created shortly after
|
|
73
|
+
* wake-from-sleep can end up with broken NTFS ACLs (null DACL) when the
|
|
74
|
+
* cloud SID cannot be resolved without network connectivity. This leaves
|
|
75
|
+
* the directory completely inaccessible to the creating user.
|
|
76
|
+
*/
|
|
77
|
+
function ensureAccessibleDir(dirPath: string): void {
|
|
78
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
79
|
+
try {
|
|
80
|
+
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
|
81
|
+
} catch {
|
|
82
|
+
// Directory exists but is inaccessible — remove and recreate
|
|
83
|
+
try {
|
|
84
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
85
|
+
} catch {}
|
|
86
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
87
|
+
// Verify recovery succeeded
|
|
88
|
+
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
ensureAccessibleDir(RESULTS_DIR);
|
|
94
|
+
ensureAccessibleDir(ASYNC_DIR);
|
|
73
95
|
|
|
74
96
|
// Cleanup old chain directories on startup (after 24h)
|
|
75
97
|
cleanupOldChainDirs();
|
|
@@ -152,13 +174,42 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
152
174
|
};
|
|
153
175
|
|
|
154
176
|
const resultFileCoalescer = createFileCoalescer(handleResult, 50);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
177
|
+
let watcher: fs.FSWatcher | null = null;
|
|
178
|
+
let watcherRestartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
179
|
+
|
|
180
|
+
function startResultWatcher(): void {
|
|
181
|
+
watcherRestartTimer = null;
|
|
182
|
+
try {
|
|
183
|
+
watcher = fs.watch(RESULTS_DIR, (ev, file) => {
|
|
184
|
+
if (ev !== "rename" || !file) return;
|
|
185
|
+
const fileName = file.toString();
|
|
186
|
+
if (!fileName.endsWith(".json")) return;
|
|
187
|
+
resultFileCoalescer.schedule(fileName);
|
|
188
|
+
});
|
|
189
|
+
watcher.on("error", () => {
|
|
190
|
+
// Watcher died (directory deleted, ACL change, etc.) — restart after delay
|
|
191
|
+
watcher = null;
|
|
192
|
+
watcherRestartTimer = setTimeout(() => {
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(RESULTS_DIR, { recursive: true });
|
|
195
|
+
startResultWatcher();
|
|
196
|
+
} catch {}
|
|
197
|
+
}, 3000);
|
|
198
|
+
});
|
|
199
|
+
watcher.unref?.();
|
|
200
|
+
} catch {
|
|
201
|
+
// fs.watch can throw if directory is inaccessible — retry after delay
|
|
202
|
+
watcher = null;
|
|
203
|
+
watcherRestartTimer = setTimeout(() => {
|
|
204
|
+
try {
|
|
205
|
+
fs.mkdirSync(RESULTS_DIR, { recursive: true });
|
|
206
|
+
startResultWatcher();
|
|
207
|
+
} catch {}
|
|
208
|
+
}, 3000);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
startResultWatcher();
|
|
162
213
|
fs.readdirSync(RESULTS_DIR)
|
|
163
214
|
.filter((f) => f.endsWith(".json"))
|
|
164
215
|
.forEach((file) => resultFileCoalescer.schedule(file, 0));
|
|
@@ -229,7 +280,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
229
280
|
currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
230
281
|
const agents = discoverAgents(ctx.cwd, scope).agents;
|
|
231
282
|
const runId = randomUUID().slice(0, 8);
|
|
232
|
-
const shareEnabled = params.share
|
|
283
|
+
const shareEnabled = params.share === true;
|
|
233
284
|
const sessionEnabled = shareEnabled || Boolean(params.sessionDir);
|
|
234
285
|
const sessionRoot = sessionEnabled
|
|
235
286
|
? params.sessionDir
|
|
@@ -400,7 +451,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
400
451
|
const normalized = normalizeSkillInput(params.skill);
|
|
401
452
|
const chainSkills = normalized === false ? [] : (normalized ?? []);
|
|
402
453
|
// Use extracted chain execution module
|
|
403
|
-
|
|
454
|
+
const chainResult = await executeChain({
|
|
404
455
|
chain: params.chain as ChainStep[],
|
|
405
456
|
task: params.task,
|
|
406
457
|
agents,
|
|
@@ -418,6 +469,33 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
418
469
|
chainSkills,
|
|
419
470
|
chainDir: params.chainDir,
|
|
420
471
|
});
|
|
472
|
+
|
|
473
|
+
// User requested async via TUI - dispatch to async executor
|
|
474
|
+
if (chainResult.requestedAsync) {
|
|
475
|
+
if (!isAsyncAvailable()) {
|
|
476
|
+
return {
|
|
477
|
+
content: [{ type: "text", text: "Background mode requires jiti for TypeScript execution but it could not be found." }],
|
|
478
|
+
isError: true,
|
|
479
|
+
details: { mode: "chain" as const, results: [] },
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const id = randomUUID();
|
|
483
|
+
const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: currentSessionId! };
|
|
484
|
+
return executeAsyncChain(id, {
|
|
485
|
+
chain: chainResult.requestedAsync.chain,
|
|
486
|
+
agents,
|
|
487
|
+
ctx: asyncCtx,
|
|
488
|
+
cwd: params.cwd,
|
|
489
|
+
maxOutput: params.maxOutput,
|
|
490
|
+
artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
|
|
491
|
+
artifactConfig,
|
|
492
|
+
shareEnabled,
|
|
493
|
+
sessionRoot,
|
|
494
|
+
chainSkills: chainResult.requestedAsync.chainSkills,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return chainResult;
|
|
421
499
|
}
|
|
422
500
|
|
|
423
501
|
if (hasTasks && params.tasks) {
|
|
@@ -494,6 +572,39 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
494
572
|
if (override?.model) modelOverrides[i] = override.model;
|
|
495
573
|
if (override?.skills !== undefined) skillOverrides[i] = override.skills;
|
|
496
574
|
}
|
|
575
|
+
|
|
576
|
+
// User requested background execution
|
|
577
|
+
if (result.runInBackground) {
|
|
578
|
+
if (!isAsyncAvailable()) {
|
|
579
|
+
return {
|
|
580
|
+
content: [{ type: "text", text: "Background mode requires jiti for TypeScript execution but it could not be found." }],
|
|
581
|
+
isError: true,
|
|
582
|
+
details: { mode: "parallel" as const, results: [] },
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const id = randomUUID();
|
|
586
|
+
const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: currentSessionId! };
|
|
587
|
+
// Convert parallel tasks to a chain with a single parallel step
|
|
588
|
+
const parallelTasks = params.tasks!.map((t, i) => ({
|
|
589
|
+
agent: t.agent,
|
|
590
|
+
task: tasks[i],
|
|
591
|
+
cwd: t.cwd,
|
|
592
|
+
...(modelOverrides[i] ? { model: modelOverrides[i] } : {}),
|
|
593
|
+
...(skillOverrides[i] !== undefined ? { skill: skillOverrides[i] } : {}),
|
|
594
|
+
}));
|
|
595
|
+
return executeAsyncChain(id, {
|
|
596
|
+
chain: [{ parallel: parallelTasks }],
|
|
597
|
+
agents,
|
|
598
|
+
ctx: asyncCtx,
|
|
599
|
+
cwd: params.cwd,
|
|
600
|
+
maxOutput: params.maxOutput,
|
|
601
|
+
artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
|
|
602
|
+
artifactConfig,
|
|
603
|
+
shareEnabled,
|
|
604
|
+
sessionRoot,
|
|
605
|
+
chainSkills: [],
|
|
606
|
+
});
|
|
607
|
+
}
|
|
497
608
|
}
|
|
498
609
|
|
|
499
610
|
// Execute with overrides (tasks array has same length as params.tasks)
|
|
@@ -548,8 +659,32 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
548
659
|
|
|
549
660
|
const ok = results.filter((r) => r.exitCode === 0).length;
|
|
550
661
|
const downgradeNote = parallelDowngraded ? " (async not supported for parallel)" : "";
|
|
662
|
+
|
|
663
|
+
// Aggregate outputs from all parallel tasks
|
|
664
|
+
const aggregatedOutput = results
|
|
665
|
+
.map((r, i) => {
|
|
666
|
+
const header = `=== Task ${i + 1}: ${r.agent} ===`;
|
|
667
|
+
const output = r.truncation?.text || getFinalOutput(r.messages);
|
|
668
|
+
const hasOutput = Boolean(output?.trim());
|
|
669
|
+
const status = r.exitCode !== 0
|
|
670
|
+
? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
|
|
671
|
+
: r.error
|
|
672
|
+
? `⚠️ WARNING: ${r.error}`
|
|
673
|
+
: !hasOutput
|
|
674
|
+
? "⚠️ EMPTY OUTPUT"
|
|
675
|
+
: "";
|
|
676
|
+
const body = status
|
|
677
|
+
? (hasOutput ? `${status}\n${output}` : status)
|
|
678
|
+
: output;
|
|
679
|
+
return `${header}\n${body}`;
|
|
680
|
+
})
|
|
681
|
+
.join("\n\n");
|
|
682
|
+
|
|
683
|
+
const summary = `${ok}/${results.length} succeeded${downgradeNote}`;
|
|
684
|
+
const fullContent = `${summary}\n\n${aggregatedOutput}`;
|
|
685
|
+
|
|
551
686
|
return {
|
|
552
|
-
content: [{ type: "text", text:
|
|
687
|
+
content: [{ type: "text", text: fullContent }],
|
|
553
688
|
details: {
|
|
554
689
|
mode: "parallel",
|
|
555
690
|
results,
|
|
@@ -616,6 +751,33 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
616
751
|
if (override?.model) modelOverride = override.model;
|
|
617
752
|
if (override?.output !== undefined) effectiveOutput = override.output;
|
|
618
753
|
if (override?.skills !== undefined) skillOverride = override.skills;
|
|
754
|
+
|
|
755
|
+
// User requested background execution
|
|
756
|
+
if (result.runInBackground) {
|
|
757
|
+
if (!isAsyncAvailable()) {
|
|
758
|
+
return {
|
|
759
|
+
content: [{ type: "text", text: "Background mode requires jiti for TypeScript execution but it could not be found." }],
|
|
760
|
+
isError: true,
|
|
761
|
+
details: { mode: "single" as const, results: [] },
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
const id = randomUUID();
|
|
765
|
+
const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: currentSessionId! };
|
|
766
|
+
return executeAsyncSingle(id, {
|
|
767
|
+
agent: params.agent!,
|
|
768
|
+
task,
|
|
769
|
+
agentConfig,
|
|
770
|
+
ctx: asyncCtx,
|
|
771
|
+
cwd: params.cwd,
|
|
772
|
+
maxOutput: params.maxOutput,
|
|
773
|
+
artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
|
|
774
|
+
artifactConfig,
|
|
775
|
+
shareEnabled,
|
|
776
|
+
sessionRoot,
|
|
777
|
+
skills: skillOverride === false ? [] : skillOverride,
|
|
778
|
+
output: effectiveOutput,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
619
781
|
}
|
|
620
782
|
|
|
621
783
|
const cleanTask = task;
|
|
@@ -846,12 +1008,21 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
846
1008
|
return { name: token.slice(0, bracket), config: parseInlineConfig(token.slice(bracket + 1, end !== -1 ? end : undefined)) };
|
|
847
1009
|
};
|
|
848
1010
|
|
|
1011
|
+
/** Extract --bg flag from end of args, return cleaned args and whether flag was present */
|
|
1012
|
+
const extractBgFlag = (args: string): { args: string; bg: boolean } => {
|
|
1013
|
+
// Only match --bg at the very end to avoid false positives in quoted strings
|
|
1014
|
+
if (args.endsWith(" --bg") || args === "--bg") {
|
|
1015
|
+
return { args: args.slice(0, args.length - (args === "--bg" ? 4 : 5)).trim(), bg: true };
|
|
1016
|
+
}
|
|
1017
|
+
return { args, bg: false };
|
|
1018
|
+
};
|
|
1019
|
+
|
|
849
1020
|
const setupDirectRun = (ctx: ExtensionContext) => {
|
|
850
1021
|
const runId = randomUUID().slice(0, 8);
|
|
851
1022
|
const sessionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
|
|
852
1023
|
return {
|
|
853
1024
|
runId,
|
|
854
|
-
shareEnabled:
|
|
1025
|
+
shareEnabled: false,
|
|
855
1026
|
sessionDirForIndex: (idx?: number) => path.join(sessionRoot, `run-${idx ?? 0}`),
|
|
856
1027
|
artifactsDir: getArtifactsDir(ctx.sessionManager.getSessionFile() ?? null),
|
|
857
1028
|
artifactConfig: { ...DEFAULT_ARTIFACT_CONFIG } as ArtifactConfig,
|
|
@@ -905,7 +1076,35 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
905
1076
|
...(i === 0 ? { task: result.task } : {}),
|
|
906
1077
|
}));
|
|
907
1078
|
executeChain({ chain, task: result.task, agents, ctx, ...exec, clarify: true })
|
|
908
|
-
.then((r) =>
|
|
1079
|
+
.then((r) => {
|
|
1080
|
+
// User requested async via TUI - dispatch to async executor
|
|
1081
|
+
if (r.requestedAsync) {
|
|
1082
|
+
if (!isAsyncAvailable()) {
|
|
1083
|
+
pi.sendUserMessage("Background mode requires jiti for TypeScript execution but it could not be found.");
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const id = randomUUID();
|
|
1087
|
+
const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: ctx.sessionManager.getSessionId() ?? id };
|
|
1088
|
+
const sessionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
|
|
1089
|
+
executeAsyncChain(id, {
|
|
1090
|
+
chain: r.requestedAsync.chain,
|
|
1091
|
+
agents,
|
|
1092
|
+
ctx: asyncCtx,
|
|
1093
|
+
maxOutput: undefined,
|
|
1094
|
+
artifactsDir: exec.artifactsDir,
|
|
1095
|
+
artifactConfig: exec.artifactConfig,
|
|
1096
|
+
shareEnabled: false,
|
|
1097
|
+
sessionRoot,
|
|
1098
|
+
chainSkills: r.requestedAsync.chainSkills,
|
|
1099
|
+
}).then((asyncResult) => {
|
|
1100
|
+
pi.sendUserMessage(asyncResult.content[0]?.text || "(launched in background)");
|
|
1101
|
+
}).catch((err) => {
|
|
1102
|
+
pi.sendUserMessage(`Async launch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1103
|
+
});
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
pi.sendUserMessage(r.content[0]?.text || "(no output)");
|
|
1107
|
+
})
|
|
909
1108
|
.catch((err) => pi.sendUserMessage(`Chain failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
910
1109
|
return;
|
|
911
1110
|
}
|
|
@@ -942,15 +1141,16 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
942
1141
|
});
|
|
943
1142
|
|
|
944
1143
|
pi.registerCommand("run", {
|
|
945
|
-
description: "Run a subagent directly: /run agent[output=file] task",
|
|
1144
|
+
description: "Run a subagent directly: /run agent[output=file] task [--bg]",
|
|
946
1145
|
getArgumentCompletions: makeAgentCompletions(false),
|
|
947
1146
|
handler: async (args, ctx) => {
|
|
948
|
-
const
|
|
1147
|
+
const { args: cleanedArgs, bg } = extractBgFlag(args);
|
|
1148
|
+
const input = cleanedArgs.trim();
|
|
949
1149
|
const firstSpace = input.indexOf(" ");
|
|
950
|
-
if (firstSpace === -1) { ctx.ui.notify("Usage: /run <agent> <task>", "error"); return; }
|
|
1150
|
+
if (firstSpace === -1) { ctx.ui.notify("Usage: /run <agent> <task> [--bg]", "error"); return; }
|
|
951
1151
|
const { name: agentName, config: inline } = parseAgentToken(input.slice(0, firstSpace));
|
|
952
1152
|
const task = input.slice(firstSpace + 1).trim();
|
|
953
|
-
if (!task) { ctx.ui.notify("Usage: /run <agent> <task>", "error"); return; }
|
|
1153
|
+
if (!task) { ctx.ui.notify("Usage: /run <agent> <task> [--bg]", "error"); return; }
|
|
954
1154
|
|
|
955
1155
|
const agents = discoverAgents(baseCwd, "both").agents;
|
|
956
1156
|
if (!agents.find((a) => a.name === agentName)) { ctx.ui.notify(`Unknown agent: ${agentName}`, "error"); return; }
|
|
@@ -963,6 +1163,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
963
1163
|
if (inline.output !== undefined) params.output = inline.output;
|
|
964
1164
|
if (inline.skill !== undefined) params.skill = inline.skill;
|
|
965
1165
|
if (inline.model) params.model = inline.model;
|
|
1166
|
+
if (bg) params.async = true;
|
|
966
1167
|
pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify({ ...params, agentScope: "both" })}`);
|
|
967
1168
|
},
|
|
968
1169
|
});
|
|
@@ -1040,10 +1241,11 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1040
1241
|
};
|
|
1041
1242
|
|
|
1042
1243
|
pi.registerCommand("chain", {
|
|
1043
|
-
description: "Run agents in sequence: /chain scout \"
|
|
1244
|
+
description: "Run agents in sequence: /chain scout \"task\" -> planner [--bg]",
|
|
1044
1245
|
getArgumentCompletions: makeAgentCompletions(true),
|
|
1045
1246
|
handler: async (args, ctx) => {
|
|
1046
|
-
const
|
|
1247
|
+
const { args: cleanedArgs, bg } = extractBgFlag(args);
|
|
1248
|
+
const parsed = parseAgentArgs(cleanedArgs, "chain", ctx);
|
|
1047
1249
|
if (!parsed) return;
|
|
1048
1250
|
const chain = parsed.steps.map(({ name, config, task: stepTask }, i) => ({
|
|
1049
1251
|
agent: name,
|
|
@@ -1054,15 +1256,18 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1054
1256
|
...(config.skill !== undefined ? { skill: config.skill } : {}),
|
|
1055
1257
|
...(config.progress !== undefined ? { progress: config.progress } : {}),
|
|
1056
1258
|
}));
|
|
1057
|
-
|
|
1259
|
+
const params: Record<string, unknown> = { chain, task: parsed.task, clarify: false, agentScope: "both" };
|
|
1260
|
+
if (bg) params.async = true;
|
|
1261
|
+
pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify(params)}`);
|
|
1058
1262
|
},
|
|
1059
1263
|
});
|
|
1060
1264
|
|
|
1061
1265
|
pi.registerCommand("parallel", {
|
|
1062
|
-
description: "Run agents in parallel: /parallel scout \"
|
|
1266
|
+
description: "Run agents in parallel: /parallel scout \"task1\" -> reviewer \"task2\" [--bg]",
|
|
1063
1267
|
getArgumentCompletions: makeAgentCompletions(true),
|
|
1064
1268
|
handler: async (args, ctx) => {
|
|
1065
|
-
const
|
|
1269
|
+
const { args: cleanedArgs, bg } = extractBgFlag(args);
|
|
1270
|
+
const parsed = parseAgentArgs(cleanedArgs, "parallel", ctx);
|
|
1066
1271
|
if (!parsed) return;
|
|
1067
1272
|
if (parsed.steps.length > MAX_PARALLEL) { ctx.ui.notify(`Max ${MAX_PARALLEL} parallel tasks`, "error"); return; }
|
|
1068
1273
|
const tasks = parsed.steps.map(({ name, config, task: stepTask }) => ({
|
|
@@ -1074,7 +1279,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1074
1279
|
...(config.skill !== undefined ? { skill: config.skill } : {}),
|
|
1075
1280
|
...(config.progress !== undefined ? { progress: config.progress } : {}),
|
|
1076
1281
|
}));
|
|
1077
|
-
|
|
1282
|
+
const params: Record<string, unknown> = { chain: [{ parallel: tasks }], task: parsed.task, clarify: false, agentScope: "both" };
|
|
1283
|
+
if (bg) params.async = true;
|
|
1284
|
+
pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify(params)}`);
|
|
1078
1285
|
},
|
|
1079
1286
|
});
|
|
1080
1287
|
|
|
@@ -1192,7 +1399,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1192
1399
|
}
|
|
1193
1400
|
});
|
|
1194
1401
|
pi.on("session_shutdown", () => {
|
|
1195
|
-
watcher
|
|
1402
|
+
watcher?.close();
|
|
1403
|
+
if (watcherRestartTimer) clearTimeout(watcherRestartTimer);
|
|
1404
|
+
watcherRestartTimer = null;
|
|
1196
1405
|
if (poller) clearInterval(poller);
|
|
1197
1406
|
poller = null;
|
|
1198
1407
|
// Clear all pending cleanup timers
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
|
|
5
5
|
"author": "Nico Bailon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,12 +34,21 @@
|
|
|
34
34
|
"CHANGELOG.md"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --experimental-strip-types --test *.test.ts"
|
|
37
|
+
"test": "node --experimental-strip-types --test *.test.ts",
|
|
38
|
+
"test:integration": "node --experimental-transform-types --import ./test/register-loader.mjs --test test/*.test.ts",
|
|
39
|
+
"test:e2e": "node --experimental-transform-types --import ./test/register-loader.mjs --test test/e2e-*.test.ts",
|
|
40
|
+
"test:all": "node --experimental-transform-types --import ./test/register-loader.mjs --test *.test.ts test/*.test.ts"
|
|
38
41
|
},
|
|
39
42
|
"pi": {
|
|
40
43
|
"extensions": [
|
|
41
44
|
"./index.ts",
|
|
42
45
|
"./notify.ts"
|
|
43
46
|
]
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@marcfargas/pi-test-harness": "^0.5.0",
|
|
50
|
+
"@mariozechner/pi-agent-core": "^0.54.0",
|
|
51
|
+
"@mariozechner/pi-ai": "^0.54.0",
|
|
52
|
+
"@mariozechner/pi-coding-agent": "^0.54.0"
|
|
44
53
|
}
|
|
45
54
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel execution utilities for the async runner.
|
|
3
|
+
* Kept minimal and self-contained so the standalone runner can use them
|
|
4
|
+
* without pulling in the full extension dependency tree.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A single agent step in the runner config */
|
|
8
|
+
export interface RunnerSubagentStep {
|
|
9
|
+
agent: string;
|
|
10
|
+
task: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
tools?: string[];
|
|
14
|
+
extensions?: string[];
|
|
15
|
+
mcpDirectTools?: string[];
|
|
16
|
+
systemPrompt?: string | null;
|
|
17
|
+
skills?: string[];
|
|
18
|
+
outputPath?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parallel step group — multiple agents running concurrently */
|
|
22
|
+
export interface ParallelStepGroup {
|
|
23
|
+
parallel: RunnerSubagentStep[];
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
failFast?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RunnerStep = RunnerSubagentStep | ParallelStepGroup;
|
|
29
|
+
|
|
30
|
+
export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
|
|
31
|
+
return "parallel" in step && Array.isArray((step as ParallelStepGroup).parallel);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Flatten runner steps into individual SubagentSteps for status tracking */
|
|
35
|
+
export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
|
|
36
|
+
const flat: RunnerSubagentStep[] = [];
|
|
37
|
+
for (const step of steps) {
|
|
38
|
+
if (isParallelGroup(step)) {
|
|
39
|
+
for (const task of step.parallel) flat.push(task);
|
|
40
|
+
} else {
|
|
41
|
+
flat.push(step);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return flat;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Run async tasks with bounded concurrency, preserving result order */
|
|
48
|
+
export async function mapConcurrent<T, R>(
|
|
49
|
+
items: T[],
|
|
50
|
+
limit: number,
|
|
51
|
+
fn: (item: T, i: number) => Promise<R>,
|
|
52
|
+
): Promise<R[]> {
|
|
53
|
+
// Clamp to at least 1; NaN/undefined/0/negative all become 1
|
|
54
|
+
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
55
|
+
const results: R[] = new Array(items.length);
|
|
56
|
+
let next = 0;
|
|
57
|
+
|
|
58
|
+
async function worker(): Promise<void> {
|
|
59
|
+
while (next < items.length) {
|
|
60
|
+
const i = next++;
|
|
61
|
+
results[i] = await fn(items[i], i);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await Promise.all(
|
|
66
|
+
Array.from({ length: Math.min(safeLimit, items.length) }, () => worker()),
|
|
67
|
+
);
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Aggregate outputs from parallel tasks into a single string for {previous} */
|
|
72
|
+
export function aggregateParallelOutputs(
|
|
73
|
+
results: Array<{ agent: string; output: string; exitCode: number | null; error?: string }>,
|
|
74
|
+
): string {
|
|
75
|
+
return results
|
|
76
|
+
.map((r, i) => {
|
|
77
|
+
const header = `=== Parallel Task ${i + 1} (${r.agent}) ===`;
|
|
78
|
+
const hasOutput = Boolean(r.output?.trim());
|
|
79
|
+
const status =
|
|
80
|
+
r.exitCode === -1
|
|
81
|
+
? "⏭️ SKIPPED"
|
|
82
|
+
: r.exitCode !== 0
|
|
83
|
+
? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
|
|
84
|
+
: !hasOutput
|
|
85
|
+
? "⚠️ EMPTY OUTPUT"
|
|
86
|
+
: "";
|
|
87
|
+
const body = status ? (hasOutput ? `${status}\n${r.output}` : status) : r.output;
|
|
88
|
+
return `${header}\n${body}`;
|
|
89
|
+
})
|
|
90
|
+
.join("\n\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const MAX_PARALLEL_CONCURRENCY = 4;
|
package/render.ts
CHANGED
|
@@ -20,9 +20,68 @@ function getTermWidth(): number {
|
|
|
20
20
|
return process.stdout.columns || 120;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Grapheme segmenter for proper Unicode handling (shared instance)
|
|
24
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Truncate a line to maxWidth, preserving ANSI styling through the ellipsis.
|
|
28
|
+
*
|
|
29
|
+
* pi-tui's truncateToWidth adds \x1b[0m before ellipsis which resets all styling,
|
|
30
|
+
* causing background color bleed in the TUI. This implementation tracks active
|
|
31
|
+
* ANSI styles and re-applies them before the ellipsis.
|
|
32
|
+
*
|
|
33
|
+
* Uses Intl.Segmenter for proper Unicode/emoji handling (not char-by-char).
|
|
34
|
+
*/
|
|
23
35
|
function truncLine(text: string, maxWidth: number): string {
|
|
24
36
|
if (visibleWidth(text) <= maxWidth) return text;
|
|
25
|
-
|
|
37
|
+
|
|
38
|
+
const targetWidth = maxWidth - 1; // Room for single ellipsis character
|
|
39
|
+
let result = "";
|
|
40
|
+
let currentWidth = 0;
|
|
41
|
+
let activeStyles: string[] = []; // Track ALL active styles (not just last)
|
|
42
|
+
let i = 0;
|
|
43
|
+
|
|
44
|
+
while (i < text.length) {
|
|
45
|
+
// Check for ANSI escape code
|
|
46
|
+
const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
47
|
+
if (ansiMatch) {
|
|
48
|
+
const code = ansiMatch[0];
|
|
49
|
+
result += code;
|
|
50
|
+
|
|
51
|
+
if (code === "\x1b[0m" || code === "\x1b[m") {
|
|
52
|
+
activeStyles = []; // Reset clears all styles
|
|
53
|
+
} else {
|
|
54
|
+
activeStyles.push(code); // Stack styles (bold + color, etc.)
|
|
55
|
+
}
|
|
56
|
+
i += code.length;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find end of non-ANSI text segment
|
|
61
|
+
let end = i;
|
|
62
|
+
while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) {
|
|
63
|
+
end++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Segment into graphemes for proper Unicode handling
|
|
67
|
+
const textPortion = text.slice(i, end);
|
|
68
|
+
for (const seg of segmenter.segment(textPortion)) {
|
|
69
|
+
const grapheme = seg.segment;
|
|
70
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
71
|
+
|
|
72
|
+
if (currentWidth + graphemeWidth > targetWidth) {
|
|
73
|
+
// Re-apply all active styles before ellipsis to preserve background/colors
|
|
74
|
+
return result + activeStyles.join("") + "…";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
result += grapheme;
|
|
78
|
+
currentWidth += graphemeWidth;
|
|
79
|
+
}
|
|
80
|
+
i = end;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Reached end without exceeding width (shouldn't happen given initial check)
|
|
84
|
+
return result + activeStyles.join("") + "…";
|
|
26
85
|
}
|
|
27
86
|
|
|
28
87
|
// Track last rendered widget state to avoid no-op re-renders
|
|
@@ -319,7 +378,9 @@ export function renderSubagentResult(
|
|
|
319
378
|
c.addChild(new Text(truncLine(stepHeader, w), 0, 0));
|
|
320
379
|
|
|
321
380
|
const taskMaxLen = Math.max(20, w - 12);
|
|
322
|
-
const taskPreview = r.task.
|
|
381
|
+
const taskPreview = r.task.length > taskMaxLen
|
|
382
|
+
? `${r.task.slice(0, taskMaxLen)}...`
|
|
383
|
+
: r.task;
|
|
323
384
|
c.addChild(new Text(truncLine(theme.fg("dim", ` task: ${taskPreview}`), w), 0, 0));
|
|
324
385
|
|
|
325
386
|
const outputTarget = extractOutputTarget(r.task);
|
|
@@ -340,22 +401,31 @@ export function renderSubagentResult(
|
|
|
340
401
|
}
|
|
341
402
|
// Current tool for running step
|
|
342
403
|
if (rProg.currentTool) {
|
|
343
|
-
const
|
|
344
|
-
|
|
404
|
+
const maxToolArgsLen = Math.max(50, w - 20);
|
|
405
|
+
const toolArgsPreview = rProg.currentToolArgs
|
|
406
|
+
? (rProg.currentToolArgs.length > maxToolArgsLen
|
|
407
|
+
? `${rProg.currentToolArgs.slice(0, maxToolArgsLen)}...`
|
|
408
|
+
: rProg.currentToolArgs)
|
|
409
|
+
: "";
|
|
410
|
+
const toolLine = toolArgsPreview
|
|
411
|
+
? `${rProg.currentTool}: ${toolArgsPreview}`
|
|
345
412
|
: rProg.currentTool;
|
|
346
413
|
c.addChild(new Text(truncLine(theme.fg("warning", ` > ${toolLine}`), w), 0, 0));
|
|
347
414
|
}
|
|
348
415
|
// Recent tools
|
|
349
416
|
if (rProg.recentTools?.length) {
|
|
350
417
|
for (const t of rProg.recentTools.slice(0, 3)) {
|
|
351
|
-
const
|
|
352
|
-
|
|
418
|
+
const maxArgsLen = Math.max(40, w - 30);
|
|
419
|
+
const argsPreview = t.args.length > maxArgsLen
|
|
420
|
+
? `${t.args.slice(0, maxArgsLen)}...`
|
|
421
|
+
: t.args;
|
|
422
|
+
c.addChild(new Text(truncLine(theme.fg("dim", ` ${t.tool}: ${argsPreview}`), w), 0, 0));
|
|
353
423
|
}
|
|
354
424
|
}
|
|
355
|
-
// Recent output
|
|
425
|
+
// Recent output - let truncLine handle truncation entirely
|
|
356
426
|
const recentLines = (rProg.recentOutput ?? []).slice(-5);
|
|
357
427
|
for (const line of recentLines) {
|
|
358
|
-
c.addChild(new Text(truncLine(theme.fg("dim", ` ${line
|
|
428
|
+
c.addChild(new Text(truncLine(theme.fg("dim", ` ${line}`), w), 0, 0));
|
|
359
429
|
}
|
|
360
430
|
}
|
|
361
431
|
|
package/schemas.ts
CHANGED
|
@@ -83,7 +83,7 @@ export const SubagentParams = Type.Object({
|
|
|
83
83
|
maxOutput: MaxOutputSchema,
|
|
84
84
|
artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
|
|
85
85
|
includeProgress: Type.Optional(Type.Boolean({ description: "Include full progress in result (default: false)" })),
|
|
86
|
-
share: Type.Optional(Type.Boolean({ description: "
|
|
86
|
+
share: Type.Optional(Type.Boolean({ description: "Upload session to GitHub Gist for sharing (default: false)" })),
|
|
87
87
|
sessionDir: Type.Optional(
|
|
88
88
|
Type.String({ description: "Directory to store session logs (default: temp; enables sessions even if share=false)" }),
|
|
89
89
|
),
|