@narumitw/pi-subagents 0.1.21 → 0.1.22

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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/src/subagents.ts +251 -168
package/README.md CHANGED
@@ -14,6 +14,7 @@ Use it to split research, planning, implementation, and review work across focus
14
14
  - Loads custom user agents from `~/.pi/agent/agents/*.md`.
15
15
  - Optionally loads project agents from `.pi/agents/*.md` with confirmation.
16
16
  - Supports per-task `cwd`, hard subprocess `timeoutMs`, abort propagation, and streaming progress.
17
+ - Publishes transient runtime status through Pi's generic extension status API while subagents are running.
17
18
  - Returns complete worker output in tool details and a concise result for the main agent.
18
19
 
19
20
  ## 📦 Install
@@ -153,6 +154,10 @@ Each subprocess has a hard timeout to avoid runaway workers.
153
154
 
154
155
  On timeout, the extension sends `SIGTERM`, escalates to `SIGKILL` after a short grace period, and returns any partial messages or stderr collected so far.
155
156
 
157
+ ## 📡 Runtime status
158
+
159
+ While the `subagent` tool is running, `pi-subagents` publishes compact activity status with `ctx.ui.setStatus("subagents", "...")`. Any statusline extension that reads Pi's generic extension status API can display it; no package-to-package dependency is required.
160
+
156
161
  ## 🔒 Safety notes
157
162
 
158
163
  Subagents are separate Pi processes and may use the tools allowed by their agent definition. Treat project-local agent prompts like executable project configuration: only enable them in trusted repositories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/subagents.ts CHANGED
@@ -33,6 +33,8 @@ const MAX_CONCURRENCY = 4;
33
33
  const COLLAPSED_ITEM_COUNT = 10;
34
34
  const DEFAULT_TIMEOUT_MS = parsePositiveInteger(process.env.PI_SUBAGENT_TIMEOUT_MS) ?? 10 * 60 * 1000;
35
35
  const KILL_GRACE_MS = 5000;
36
+ const STATUS_KEY = "subagents";
37
+ const activeStatuses = new Map<string, string>();
36
38
 
37
39
  function parsePositiveInteger(value: string | undefined): number | undefined {
38
40
  if (!value) return undefined;
@@ -40,6 +42,59 @@ function parsePositiveInteger(value: string | undefined): number | undefined {
40
42
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
41
43
  }
42
44
 
45
+ interface StatusContext {
46
+ ui: { setStatus: (key: string, value: string | undefined) => void };
47
+ }
48
+
49
+ function startSubagentStatus(ctx: StatusContext, toolCallId: string, status: string) {
50
+ let cleared = false;
51
+
52
+ const update = (nextStatus: string) => {
53
+ if (cleared) return;
54
+ activeStatuses.set(toolCallId, nextStatus);
55
+ publishSubagentStatus(ctx);
56
+ };
57
+
58
+ update(status);
59
+
60
+ return {
61
+ update,
62
+ clear() {
63
+ if (cleared) return;
64
+ cleared = true;
65
+ activeStatuses.delete(toolCallId);
66
+ publishSubagentStatus(ctx);
67
+ },
68
+ };
69
+ }
70
+
71
+ function publishSubagentStatus(ctx: StatusContext) {
72
+ const statuses = [...activeStatuses.values()];
73
+ if (statuses.length === 0) {
74
+ ctx.ui.setStatus(STATUS_KEY, undefined);
75
+ return;
76
+ }
77
+
78
+ const suffix = statuses.length > 1 ? ` +${statuses.length - 1}` : "";
79
+ ctx.ui.setStatus(STATUS_KEY, `${statuses[0]}${suffix}`);
80
+ }
81
+
82
+ function singleStatus(agent: string): string {
83
+ return `🧑‍🤝‍🧑 ${agent}`;
84
+ }
85
+
86
+ function chainStatus(step: number, total: number, agent?: string): string {
87
+ return `🧑‍🤝‍🧑 chain ${step}/${total}${agent ? ` ${agent}` : ""}`;
88
+ }
89
+
90
+ function parallelStatus(done: number, total: number, running: number): string {
91
+ return `🧑‍🤝‍🧑 parallel ${done}/${total} done${running > 0 ? ` ${running} running` : ""}`;
92
+ }
93
+
94
+ function fanInStatus(agent: string): string {
95
+ return `🧑‍🤝‍🧑 fan-in ${agent}`;
96
+ }
97
+
43
98
  function formatTokens(count: number): string {
44
99
  if (count < 1000) return count.toString();
45
100
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -544,7 +599,7 @@ export default function (pi: ExtensionAPI) {
544
599
  ].join(" "),
545
600
  parameters: SubagentParams,
546
601
 
547
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
602
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
548
603
  const agentScope: AgentScope = params.agentScope ?? "user";
549
604
  const discovery = discoverAgents(ctx.cwd, agentScope);
550
605
  const agents = discovery.agents;
@@ -612,56 +667,62 @@ export default function (pi: ExtensionAPI) {
612
667
  if (params.chain && params.chain.length > 0) {
613
668
  const results: SingleResult[] = [];
614
669
  let previousOutput = "";
670
+ const status = startSubagentStatus(ctx, toolCallId, chainStatus(0, params.chain.length));
615
671
 
616
- for (let i = 0; i < params.chain.length; i++) {
617
- const step = params.chain[i];
618
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
619
-
620
- // Create update callback that includes all previous results
621
- const chainUpdate: OnUpdateCallback | undefined = onUpdate
622
- ? (partial) => {
623
- // Combine completed results with current streaming result
624
- const currentResult = partial.details?.results[0];
625
- if (currentResult) {
626
- const allResults = [...results, currentResult];
627
- onUpdate({
628
- content: partial.content,
629
- details: makeDetails("chain")(allResults),
630
- });
672
+ try {
673
+ for (let i = 0; i < params.chain.length; i++) {
674
+ const step = params.chain[i];
675
+ status.update(chainStatus(i + 1, params.chain.length, step.agent));
676
+ const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
677
+
678
+ // Create update callback that includes all previous results
679
+ const chainUpdate: OnUpdateCallback | undefined = onUpdate
680
+ ? (partial) => {
681
+ // Combine completed results with current streaming result
682
+ const currentResult = partial.details?.results[0];
683
+ if (currentResult) {
684
+ const allResults = [...results, currentResult];
685
+ onUpdate({
686
+ content: partial.content,
687
+ details: makeDetails("chain")(allResults),
688
+ });
689
+ }
631
690
  }
632
- }
633
- : undefined;
634
-
635
- const result = await runSingleAgent(
636
- ctx.cwd,
637
- agents,
638
- step.agent,
639
- taskWithContext,
640
- step.cwd,
641
- i + 1,
642
- signal,
643
- step.timeoutMs ?? defaultTimeoutMs,
644
- chainUpdate,
645
- makeDetails("chain"),
646
- );
647
- results.push(result);
648
-
649
- const isError =
650
- result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
651
- if (isError) {
652
- const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
653
- return {
654
- content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
655
- details: makeDetails("chain")(results),
656
- isError: true,
657
- };
691
+ : undefined;
692
+
693
+ const result = await runSingleAgent(
694
+ ctx.cwd,
695
+ agents,
696
+ step.agent,
697
+ taskWithContext,
698
+ step.cwd,
699
+ i + 1,
700
+ signal,
701
+ step.timeoutMs ?? defaultTimeoutMs,
702
+ chainUpdate,
703
+ makeDetails("chain"),
704
+ );
705
+ results.push(result);
706
+
707
+ const isError =
708
+ result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
709
+ if (isError) {
710
+ const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
711
+ return {
712
+ content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
713
+ details: makeDetails("chain")(results),
714
+ isError: true,
715
+ };
716
+ }
717
+ previousOutput = getResultFinalOutput(result);
658
718
  }
659
- previousOutput = getResultFinalOutput(result);
719
+ return {
720
+ content: [{ type: "text", text: getResultFinalOutput(results[results.length - 1]) || "(no output)" }],
721
+ details: makeDetails("chain")(results),
722
+ };
723
+ } finally {
724
+ status.clear();
660
725
  }
661
- return {
662
- content: [{ type: "text", text: getResultFinalOutput(results[results.length - 1]) || "(no output)" }],
663
- details: makeDetails("chain")(results),
664
- };
665
726
  }
666
727
 
667
728
  if (params.tasks && params.tasks.length > 0) {
@@ -676,141 +737,163 @@ export default function (pi: ExtensionAPI) {
676
737
  details: makeDetails("parallel")([]),
677
738
  };
678
739
 
679
- // Track all results for streaming updates
680
- const allResults: SingleResult[] = new Array(params.tasks.length);
681
-
682
- // Initialize placeholder results
683
- for (let i = 0; i < params.tasks.length; i++) {
684
- allResults[i] = {
685
- agent: params.tasks[i].agent,
686
- agentSource: "unknown",
687
- task: params.tasks[i].task,
688
- exitCode: -1, // -1 = still running
689
- messages: [],
690
- stderr: "",
691
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
692
- finalOutput: "",
740
+ const status = startSubagentStatus(ctx, toolCallId, parallelStatus(0, params.tasks.length, params.tasks.length));
741
+
742
+ try {
743
+ // Track all results for streaming updates
744
+ const allResults: SingleResult[] = new Array(params.tasks.length);
745
+
746
+ // Initialize placeholder results
747
+ for (let i = 0; i < params.tasks.length; i++) {
748
+ allResults[i] = {
749
+ agent: params.tasks[i].agent,
750
+ agentSource: "unknown",
751
+ task: params.tasks[i].task,
752
+ exitCode: -1, // -1 = still running
753
+ messages: [],
754
+ stderr: "",
755
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
756
+ finalOutput: "",
757
+ };
758
+ }
759
+
760
+ let doneCount = 0;
761
+ let runningCount = params.tasks.length;
762
+
763
+ const emitParallelUpdate = () => {
764
+ status.update(parallelStatus(doneCount, allResults.length, runningCount));
765
+ if (onUpdate) {
766
+ onUpdate({
767
+ content: [
768
+ {
769
+ type: "text",
770
+ text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
771
+ },
772
+ ],
773
+ details: makeDetails("parallel")([...allResults]),
774
+ });
775
+ }
693
776
  };
694
- }
695
777
 
696
- const emitParallelUpdate = () => {
697
- if (onUpdate) {
698
- const running = allResults.filter((r) => r.exitCode === -1).length;
699
- const done = allResults.filter((r) => r.exitCode !== -1).length;
700
- onUpdate({
701
- content: [
702
- { type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
703
- ],
704
- details: makeDetails("parallel")([...allResults]),
705
- });
778
+ const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
779
+ const result = await runSingleAgent(
780
+ ctx.cwd,
781
+ agents,
782
+ t.agent,
783
+ t.task,
784
+ t.cwd,
785
+ undefined,
786
+ signal,
787
+ t.timeoutMs ?? defaultTimeoutMs,
788
+ // Per-task update callback
789
+ (partial) => {
790
+ if (partial.details?.results[0]) {
791
+ allResults[index] = { ...partial.details.results[0], exitCode: -1 };
792
+ emitParallelUpdate();
793
+ }
794
+ },
795
+ makeDetails("parallel"),
796
+ );
797
+ allResults[index] = result;
798
+ doneCount += 1;
799
+ runningCount -= 1;
800
+ emitParallelUpdate();
801
+ return result;
802
+ });
803
+
804
+ let aggregatorResult: SingleResult | undefined;
805
+ if (params.aggregator) {
806
+ const aggregator = params.aggregator;
807
+ status.update(fanInStatus(aggregator.agent));
808
+ const fanInContext = buildFanInContext(results);
809
+ const aggregatorTask = aggregator.task.includes("{previous}")
810
+ ? aggregator.task.replace(/\{previous\}/g, fanInContext)
811
+ : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`;
812
+ aggregatorResult = await runSingleAgent(
813
+ ctx.cwd,
814
+ agents,
815
+ aggregator.agent,
816
+ aggregatorTask,
817
+ aggregator.cwd,
818
+ undefined,
819
+ signal,
820
+ aggregator.timeoutMs ?? defaultTimeoutMs,
821
+ (partial) => {
822
+ status.update(fanInStatus(aggregator.agent));
823
+ if (onUpdate && partial.details?.results[0]) {
824
+ onUpdate({
825
+ content: partial.content,
826
+ details: makeDetails("parallel")(results, partial.details.results[0]),
827
+ });
828
+ }
829
+ },
830
+ makeDetails("parallel"),
831
+ );
706
832
  }
707
- };
708
833
 
709
- const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
710
- const result = await runSingleAgent(
711
- ctx.cwd,
712
- agents,
713
- t.agent,
714
- t.task,
715
- t.cwd,
716
- undefined,
717
- signal,
718
- t.timeoutMs ?? defaultTimeoutMs,
719
- // Per-task update callback
720
- (partial) => {
721
- if (partial.details?.results[0]) {
722
- allResults[index] = partial.details.results[0];
723
- emitParallelUpdate();
724
- }
725
- },
726
- makeDetails("parallel"),
727
- );
728
- allResults[index] = result;
729
- emitParallelUpdate();
730
- return result;
731
- });
834
+ const successCount = results.filter((r) => r.exitCode === 0).length;
835
+ const summaries = results.map((r) => {
836
+ const output = getResultFinalOutput(r);
837
+ const error = r.errorMessage || r.stderr.trim();
838
+ const summaryText = output || error;
839
+ const preview = summaryText.slice(0, 160) + (summaryText.length > 160 ? "..." : "");
840
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
841
+ });
842
+ const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
843
+ const aggregatorError = aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
844
+ return {
845
+ content: [
846
+ {
847
+ type: "text",
848
+ text: aggregatorResult
849
+ ? aggregatorOutput || aggregatorError || `(aggregator ${aggregatorResult.agent} produced no output)`
850
+ : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
851
+ },
852
+ ],
853
+ details: makeDetails("parallel")(results, aggregatorResult),
854
+ isError: aggregatorResult
855
+ ? aggregatorResult.exitCode !== 0 ||
856
+ aggregatorResult.stopReason === "error" ||
857
+ aggregatorResult.stopReason === "aborted"
858
+ : undefined,
859
+ };
860
+ } finally {
861
+ status.clear();
862
+ }
863
+ }
732
864
 
733
- let aggregatorResult: SingleResult | undefined;
734
- if (params.aggregator) {
735
- const fanInContext = buildFanInContext(results);
736
- const aggregatorTask = params.aggregator.task.includes("{previous}")
737
- ? params.aggregator.task.replace(/\{previous\}/g, fanInContext)
738
- : `${params.aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`;
739
- aggregatorResult = await runSingleAgent(
865
+ if (params.agent && params.task) {
866
+ const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
867
+
868
+ try {
869
+ const result = await runSingleAgent(
740
870
  ctx.cwd,
741
871
  agents,
742
- params.aggregator.agent,
743
- aggregatorTask,
744
- params.aggregator.cwd,
872
+ params.agent,
873
+ params.task,
874
+ params.cwd,
745
875
  undefined,
746
876
  signal,
747
- params.aggregator.timeoutMs ?? defaultTimeoutMs,
748
- (partial) => {
749
- if (onUpdate && partial.details?.results[0]) {
750
- onUpdate({
751
- content: partial.content,
752
- details: makeDetails("parallel")(results, partial.details.results[0]),
753
- });
754
- }
755
- },
756
- makeDetails("parallel"),
877
+ params.timeoutMs ?? defaultTimeoutMs,
878
+ onUpdate,
879
+ makeDetails("single"),
757
880
  );
758
- }
759
-
760
- const successCount = results.filter((r) => r.exitCode === 0).length;
761
- const summaries = results.map((r) => {
762
- const output = getResultFinalOutput(r);
763
- const error = r.errorMessage || r.stderr.trim();
764
- const summaryText = output || error;
765
- const preview = summaryText.slice(0, 160) + (summaryText.length > 160 ? "..." : "");
766
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
767
- });
768
- const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
769
- const aggregatorError = aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
770
- return {
771
- content: [
772
- {
773
- type: "text",
774
- text: aggregatorResult
775
- ? aggregatorOutput || aggregatorError || `(aggregator ${aggregatorResult.agent} produced no output)`
776
- : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
777
- },
778
- ],
779
- details: makeDetails("parallel")(results, aggregatorResult),
780
- isError: aggregatorResult
781
- ? aggregatorResult.exitCode !== 0 ||
782
- aggregatorResult.stopReason === "error" ||
783
- aggregatorResult.stopReason === "aborted"
784
- : undefined,
785
- };
786
- }
787
-
788
- if (params.agent && params.task) {
789
- const result = await runSingleAgent(
790
- ctx.cwd,
791
- agents,
792
- params.agent,
793
- params.task,
794
- params.cwd,
795
- undefined,
796
- signal,
797
- params.timeoutMs ?? defaultTimeoutMs,
798
- onUpdate,
799
- makeDetails("single"),
800
- );
801
- const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
802
- if (isError) {
803
- const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
881
+ const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
882
+ if (isError) {
883
+ const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
884
+ return {
885
+ content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
886
+ details: makeDetails("single")([result]),
887
+ isError: true,
888
+ };
889
+ }
804
890
  return {
805
- content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
891
+ content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
806
892
  details: makeDetails("single")([result]),
807
- isError: true,
808
893
  };
894
+ } finally {
895
+ status.clear();
809
896
  }
810
- return {
811
- content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
812
- details: makeDetails("single")([result]),
813
- };
814
897
  }
815
898
 
816
899
  const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";