pi-subagentura 2.0.1 → 2.0.2

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/artifact.ts +15 -9
  2. package/package.json +7 -2
  3. package/subagent.ts +151 -59
package/artifact.ts CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
+ import ndjson from "ndjson";
21
22
 
22
23
  // ── Types ───────────────────────────────────────────────────────────
23
24
 
@@ -87,6 +88,11 @@ export function writeOutput(art: SubagentArtifact, content: string): void {
87
88
  * ts >= since are returned. Malformed lines are silently skipped (the
88
89
  * sub-agent CLI is the only writer, but a partial write could in theory
89
90
  * leave a truncated line).
91
+ *
92
+ * Uses the `ndjson` library with `strict: false` so a single bad line does not abort the whole
93
+ * file — ndjson drops the bad row and continues with the rest. Any trailing partial line (file
94
+ * did not end with a newline) is buffered by the parser and dropped on `end()`; it is treated as a
95
+ * in-progress write that the next reader will pick up once completed.
90
96
  */
91
97
  export function readEvents(art: SubagentArtifact, since?: number): SubagentEvent[] {
92
98
  if (!existsSync(art.statusFile)) return [];
@@ -96,16 +102,16 @@ export function readEvents(art: SubagentArtifact, since?: number): SubagentEvent
96
102
  } catch {
97
103
  return [];
98
104
  }
105
+ const parser = ndjson.parse({ strict: false });
99
106
  const events: SubagentEvent[] = [];
100
- for (const line of content.split("\n")) {
101
- if (!line.trim()) continue;
102
- try {
103
- const ev = JSON.parse(line) as SubagentEvent;
104
- if (since === undefined || ev.ts >= since) events.push(ev);
105
- } catch {
106
- // Skip malformed lines (partial write, manual edit, etc.)
107
- }
108
- }
107
+ parser.on("data", (obj: unknown) => {
108
+ const ev = obj as SubagentEvent;
109
+ if (since === undefined || ev.ts >= since) events.push(ev);
110
+ });
111
+ // Non-strict mode never emits 'error' for bad JSON; attach a no-op so an unhandled error event
112
+ // can never crash the parent process.
113
+ parser.on("error", () => {});
114
+ parser.end(Buffer.from(content, "utf8"));
109
115
  return events;
110
116
  }
111
117
 
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "pi-subagentura",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Public Pi package that adds in-process sub-agents via the SDK",
5
+ "author": "lmn451",
6
+ "license": "MIT",
5
7
  "main": "subagent.ts",
6
8
  "type": "module",
7
9
  "keywords": [
@@ -13,7 +15,6 @@
13
15
  "swarm",
14
16
  "crew"
15
17
  ],
16
- "license": "MIT",
17
18
  "repository": {
18
19
  "type": "git",
19
20
  "url": "https://github.com/lmn451/pi-subagentura"
@@ -58,5 +59,9 @@
58
59
  "prettier": "^3.8.3",
59
60
  "typescript": "^6.0.3",
60
61
  "vitest": "^3.0.0"
62
+ },
63
+ "dependencies": {
64
+ "is-path-inside": "^4.0.0",
65
+ "ndjson": "^2.0.0"
61
66
  }
62
67
  }
package/subagent.ts CHANGED
@@ -59,12 +59,12 @@ import {
59
59
  } from "./interactive-tmux";
60
60
  import { appendEvent, artifactPath, lastEvent, readEvents, readOutput, type SubagentArtifact, type SubagentEvent } from "./artifact";
61
61
 
62
- import { openSync, readdirSync, readSync, statSync } from "node:fs";
62
+ import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
63
63
  import { homedir } from "node:os";
64
64
  import { basename, dirname, join } from "node:path";
65
65
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
66
66
  import { Type } from "typebox";
67
-
67
+ import ndjson from "ndjson";
68
68
  // ── Footer Status Key ─────────────────────────────────────────────────────────────────────
69
69
  const FOOTER_KEY = "subagentura-running";
70
70
  const WIDGET_KEY = "subagentura-activity";
@@ -544,13 +544,69 @@ export function pollArtifactChanges(pi: ExtensionAPI): void {
544
544
  }
545
545
  }
546
546
 
547
+ /**
548
+ * Per-state ndjson parser instance used to tail-read the child's session JSONL.
549
+ *
550
+ * The parser buffers partial trailing lines internally (via split2 underneath), so we can
551
+ * safely write raw bytes from the file on every poll and let the parser emit complete JSON
552
+ * objects as 'data' events. This replaces a hand-rolled partial-line + cursor scheme that had
553
+ * three latent bugs:
554
+ * - A 1 MiB per-tick read cap combined with cursor-pinning on a missing newline caused a
555
+ * permanent re-read loop on any single JSONL line larger than 1 MiB (e.g. a multi-MB tool
556
+ * call result that the child pi runtime writes as a single line).
557
+ * - File truncation left the cursor pointing past EOF, silently dropping any post-truncation
558
+ * content.
559
+ * - A `require("node:fs").closeSync(fd)` call in the finally block leaked file descriptors on
560
+ * Node < 22.12 in some bundling paths.
561
+ *
562
+ * Keyed by sub-agent id; one parser per state lives for the lifetime of the process. The parser
563
+ * is destroyed and recreated on file truncation so the buffered partial state is cleared.
564
+ */
565
+ const sessionParsers = new Map<string, ReturnType<typeof ndjson.parse>>();
566
+
567
+ /** Defensive upper bound on the per-tick Buffer.alloc. With ndjson, a partial line is buffered
568
+ * internally across polls, so the cap is no longer required for correctness — it is kept purely
569
+ * to bound worst-case memory if the file explodes in a single tick. 1 MiB is plenty. */
570
+ const MAX_SESSION_READ_BYTES = 1 * 1024 * 1024;
571
+
572
+ /** Get-or-create the per-state session parser and wire its 'data' event to the entry handler. */
573
+ function getOrCreateSessionParser(state: InteractiveSubagentState): ReturnType<typeof ndjson.parse> {
574
+ const existing = sessionParsers.get(state.id);
575
+ if (existing) return existing;
576
+ // strict: false → malformed lines are silently dropped instead of triggering an 'error' event
577
+ // that would force us to recreate the parser mid-stream. Same best-effort delivery semantics as
578
+ // the old hand-rolled try/catch around JSON.parse.
579
+ const parser = ndjson.parse({ strict: false });
580
+ parser.on("data", (entry: unknown) => {
581
+ const art = artifactPath(dirname(state.artifactDir), basename(state.artifactDir));
582
+ processSessionLogEntry(state, art, entry as any);
583
+ });
584
+ // In non-strict mode the parser does not emit 'error' for bad JSON, but we still attach a no-op
585
+ // handler so an unhandled error event can never crash the process.
586
+ parser.on("error", () => {
587
+ // Drop the broken parser so the next tick creates a fresh one. The cursor is reset in the
588
+ // truncation handler, so this only fires for pathological non-truncation errors.
589
+ sessionParsers.delete(state.id);
590
+ });
591
+ sessionParsers.set(state.id, parser);
592
+ return parser;
593
+ }
594
+
595
+ /** Destroy a state's parser (used on truncation and on state removal). */
596
+ function destroySessionParser(state: InteractiveSubagentState): void {
597
+ const parser = sessionParsers.get(state.id);
598
+ if (!parser) return;
599
+ try {
600
+ parser.end();
601
+ } catch {
602
+ // ignore — we're tearing down
603
+ }
604
+ sessionParsers.delete(state.id);
605
+ }
606
+
547
607
  /** Tail-read the child's session JSONL and append `tool_activity` events to events.ndjson.
548
608
  * Updates `state.lastDeliveredSessionByte` so subsequent ticks re-read only new lines. */
549
- /** Hard cap on the per-tick read window. Session JSONL files can grow quickly
550
- * in a long-running sub-agent, so we never allocate more than this in a single
551
- * tailRead call. 1 MiB is plenty for many thousands of typical entries. */
552
- const MAX_SESSION_READ_BYTES = 1 * 1024 * 1024;
553
- function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifact): void {
609
+ function tailReadSessionLog(state: InteractiveSubagentState, _art: SubagentArtifact): void {
554
610
  const sessionFile = state.sessionFile;
555
611
  if (!sessionFile) return;
556
612
 
@@ -561,10 +617,21 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
561
617
  return; // file not yet created by the child
562
618
  }
563
619
 
620
+ const initialCursor = state.lastDeliveredSessionByte ?? 0;
621
+ if (size < initialCursor) {
622
+ // File shrunk under us (truncation, rotation, manual edit). Reset cursor and parser and fall
623
+ // through to the read below so any content already written after the truncation is processed in
624
+ // the same tick (e.g. test does truncateSync → writeFileSync → poll). The parser is recreated so the
625
+ // buffered partial state is cleared. Any duplicate tool_activity events are acceptable — the
626
+ // artifact log is best-effort and the LLM never sees these (TUI-widget only).
627
+ state.lastDeliveredSessionByte = 0;
628
+ destroySessionParser(state);
629
+ }
564
630
  const cursor = state.lastDeliveredSessionByte ?? 0;
565
631
  if (size <= cursor) return;
566
632
 
567
- // Cap the per-tick read so a runaway file can't trigger an unbounded Buffer.alloc.
633
+ // Defensive cap on per-tick allocation. ndjson handles partial lines correctly across writes,
634
+ // so a single multi-MB line split across ticks works fine — no cursor pin.
568
635
  const requested = size - cursor;
569
636
  const toRead = Math.min(requested, MAX_SESSION_READ_BYTES);
570
637
  if (toRead <= 0) return;
@@ -583,60 +650,46 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
583
650
  if (n <= 0) break;
584
651
  bytesRead += n;
585
652
  }
586
- const chunk = buf.subarray(0, bytesRead).toString("utf8");
587
- processSessionLogChunk(state, art, chunk);
588
- // Only advance the cursor to the end of the LAST complete line in the chunk.
589
- // If the chunk ends mid-line (partial trailing JSONL), the partial must be
590
- // re-read on the next tick after the child finishes writing it. Advancing the
591
- // cursor past the partial would silently drop bytes and corrupt the event log.
592
- const endOfComplete = chunk.lastIndexOf("\n");
593
- if (endOfComplete >= 0) {
594
- state.lastDeliveredSessionByte = cursor + endOfComplete + 1;
595
- }
596
- // If no newline in chunk and we hit the cap, leave the cursor where it was:
597
- // the child is still mid-line; we'll re-read from the same offset next tick.
653
+ if (bytesRead === 0) return;
654
+ const parser = getOrCreateSessionParser(state);
655
+ parser.write(buf.subarray(0, bytesRead));
656
+ // Always advance the cursor by the bytes we fed the parser. The parser buffers any partial
657
+ // trailing line internally and will emit the completed object on a later write. We do NOT
658
+ // rewind to the last newline the way the old code did — doing so would re-feed the same bytes
659
+ // to the parser and double-emit on the next tick.
660
+ state.lastDeliveredSessionByte = cursor + bytesRead;
598
661
  } finally {
599
- try { require("node:fs").closeSync(fd); } catch {}
600
- }
601
- }
602
-
603
- /** Parse a chunk of session JSONL, append a tool_activity event per tool call. */
604
- function processSessionLogChunk(state: InteractiveSubagentState, art: SubagentArtifact, chunk: string): void {
605
- const lines = chunk.split("\n");
606
- // Last entry may be a partial line (the child hasn't finished writing it yet).
607
- // We still process complete lines; the partial line will be re-read on the next tick.
608
- const completeLines = chunk.endsWith("\n") ? lines : lines.slice(0, -1);
609
- for (const line of completeLines) {
610
- if (!line.trim()) continue;
611
- let entry: any;
612
662
  try {
613
- entry = JSON.parse(line);
663
+ closeSync(fd);
614
664
  } catch {
615
- // Skip malformed/partial safer to drop than crash.
616
- continue;
665
+ /* fd already closed or never opened ignore */
617
666
  }
618
- if (entry.type !== "message") continue;
619
- const msg = entry.message;
620
- if (!msg) continue;
621
-
622
- // Assistant message: extract toolCall blocks.
623
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
624
- for (const block of msg.content) {
625
- if (block.type !== "toolCall") continue;
626
- const summary = summarizeToolCall(block.name, block.arguments);
627
- if (!summary) continue;
628
- const ev: SubagentEvent = {
629
- ts: msg.timestamp ?? Date.now(),
630
- type: "tool_activity",
631
- status: "running",
632
- tool: block.name,
633
- summary,
634
- };
635
- appendEvent(art, ev);
636
- state.lastToolName = block.name;
637
- state.lastToolSummary = summary;
638
- state.lastActivityAt = ev.ts;
639
- }
667
+ }
668
+ }
669
+
670
+ /** Process a single parsed JSONL entry from the session log; append tool_activity events. */
671
+ function processSessionLogEntry(state: InteractiveSubagentState, art: SubagentArtifact, entry: any): void {
672
+ if (entry.type !== "message") return;
673
+ const msg = entry.message;
674
+ if (!msg) return;
675
+
676
+ // Assistant message: extract toolCall blocks.
677
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
678
+ for (const block of msg.content) {
679
+ if (block.type !== "toolCall") continue;
680
+ const summary = summarizeToolCall(block.name, block.arguments);
681
+ if (!summary) continue;
682
+ const ev: SubagentEvent = {
683
+ ts: msg.timestamp ?? Date.now(),
684
+ type: "tool_activity",
685
+ status: "running",
686
+ tool: block.name,
687
+ summary,
688
+ };
689
+ appendEvent(art, ev);
690
+ state.lastToolName = block.name;
691
+ state.lastToolSummary = summary;
692
+ state.lastActivityAt = ev.ts;
640
693
  }
641
694
  }
642
695
  }
@@ -751,6 +804,8 @@ function labelFor(event: SubagentEvent): string {
751
804
  * default artifacts root (PI_CODING_AGENT_SESSION_DIR or ~/.pi/agent/sessions/subagentura).
752
805
  * For v1 this is a best-effort lookup; a future iteration can track all artifact roots.
753
806
  */
807
+ import isPathInside from "is-path-inside";
808
+
754
809
  export function findArtifactById(id: string): SubagentArtifact | null {
755
810
  // Sub-agent ids are randomBytes(4).toString("hex") at spawn time, i.e. 8 hex
756
811
  // chars. Validate the id before joining it into a path so that an
@@ -761,6 +816,15 @@ export function findArtifactById(id: string): SubagentArtifact | null {
761
816
  if (!/^[a-f0-9]{8}$/.test(id)) return null;
762
817
 
763
818
  const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi", "agent", "sessions");
819
+ // Resolve the root once, with symlinks followed, so the containment check below
820
+ // is anchored on the real on-disk location. realpathSync throws if root doesn't
821
+ // exist; in that case there's nothing for us to find.
822
+ let realRoot: string;
823
+ try {
824
+ realRoot = realpathSync(root);
825
+ } catch {
826
+ return null;
827
+ }
764
828
  let topLevel: string[];
765
829
  try {
766
830
  topLevel = readdirSync(root);
@@ -771,6 +835,19 @@ export function findArtifactById(id: string): SubagentArtifact | null {
771
835
  const candidate = join(root, entry, "artifacts", id);
772
836
  try {
773
837
  if (statSync(candidate).isDirectory()) {
838
+ // statSync follows symlinks, so a symlink at
839
+ // <root>/<cwd>/artifacts/<id> pointing outside the artifact root
840
+ // would otherwise be returned as a valid artifact. Resolve the
841
+ // candidate with realpath and verify it is still inside the
842
+ // resolved root. realpathSync is safe here because statSync
843
+ // above already confirmed candidate exists as a directory.
844
+ let realCandidate: string;
845
+ try {
846
+ realCandidate = realpathSync(candidate);
847
+ } catch {
848
+ continue;
849
+ }
850
+ if (!isPathInside(realCandidate, realRoot)) continue;
774
851
  return artifactPath(join(root, entry, "artifacts"), id);
775
852
  }
776
853
  } catch {
@@ -779,7 +856,6 @@ export function findArtifactById(id: string): SubagentArtifact | null {
779
856
  }
780
857
  return null;
781
858
  }
782
-
783
859
  /** Sanitize a string by redacting common sensitive patterns (API keys, tokens, JWTs). */
784
860
  function sanitizeOutput(text: string): string {
785
861
  return text.replace(
@@ -1803,6 +1879,15 @@ export default function (pi: ExtensionAPI) {
1803
1879
  }),
1804
1880
 
1805
1881
  async execute(_toolCallId, params): Promise<any> {
1882
+ // Validate the id shape FIRST so a malformed id gets a precise error
1883
+ // instead of being collapsed into the generic "not found" message.
1884
+ if (!/^[a-f0-9]{8}$/.test(params.id)) {
1885
+ return {
1886
+ content: [{ type: "text", text: `Invalid sub-agent id ${JSON.stringify(params.id)}; expected 8 lowercase hex chars.` }],
1887
+ details: { id: params.id, status: "invalid_id" },
1888
+ isError: true,
1889
+ };
1890
+ }
1806
1891
  const state = interactiveSubagentRegistry.get(params.id);
1807
1892
  const art = state
1808
1893
  ? artifactPath(dirname(state.artifactDir), basename(state.artifactDir))
@@ -2040,6 +2125,13 @@ export default function (pi: ExtensionAPI) {
2040
2125
  }
2041
2126
  } catch { /* best effort */ }
2042
2127
 
2128
+ // Drop in-memory state for cancelled/exited interactive sub-agents. Without
2129
+ // this, the Map grows unbounded across session_start/session_shutdown cycles
2130
+ // and list_subagent_artifacts returns stale entries from previous sessions.
2131
+ try {
2132
+ interactiveSubagentRegistry.clear();
2133
+ } catch { /* best effort */ }
2134
+
2043
2135
  // Abort all running subagent sessions before clearing
2044
2136
  for (const job of jobRegistry.values()) {
2045
2137
  if (job.status === "running") {