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/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
- fs.mkdirSync(RESULTS_DIR, { recursive: true });
72
- fs.mkdirSync(ASYNC_DIR, { recursive: true });
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
- const watcher = fs.watch(RESULTS_DIR, (ev, file) => {
156
- if (ev !== "rename" || !file) return;
157
- const fileName = file.toString();
158
- if (!fileName.endsWith(".json")) return;
159
- resultFileCoalescer.schedule(fileName);
160
- });
161
- watcher.unref?.();
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 !== false;
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
- return executeChain({
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: `${ok}/${results.length} succeeded${downgradeNote}` }],
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: true,
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) => pi.sendUserMessage(r.content[0]?.text || "(no output)"))
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 input = args.trim();
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 \"scan code\" -> planner \"analyze auth\"",
1244
+ description: "Run agents in sequence: /chain scout \"task\" -> planner [--bg]",
1044
1245
  getArgumentCompletions: makeAgentCompletions(true),
1045
1246
  handler: async (args, ctx) => {
1046
- const parsed = parseAgentArgs(args, "chain", ctx);
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
- pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify({ chain, task: parsed.task, clarify: false, agentScope: "both" })}`);
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 \"scan bugs\" -> reviewer \"check style\"",
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 parsed = parseAgentArgs(args, "parallel", ctx);
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
- pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify({ chain: [{ parallel: tasks }], task: parsed.task, clarify: false, agentScope: "both" })}`);
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.close();
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.9.2",
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
- return truncateToWidth(text, maxWidth - 1) + "…";
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.slice(0, taskMaxLen) + (r.task.length > taskMaxLen ? "..." : "");
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 toolLine = rProg.currentToolArgs
344
- ? `${rProg.currentTool}: ${rProg.currentToolArgs.slice(0, 100)}${rProg.currentToolArgs.length > 100 ? "..." : ""}`
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 args = t.args.slice(0, 90) + (t.args.length > 90 ? "..." : "");
352
- c.addChild(new Text(truncLine(theme.fg("dim", ` ${t.tool}: ${args}`), w), 0, 0));
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 (limited)
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.slice(0, 100)}${line.length > 100 ? "..." : ""}`), w), 0, 0));
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: "Create shareable session log (default: true)", default: true })),
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
  ),