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.
- package/artifact.ts +15 -9
- package/package.json +7 -2
- 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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
663
|
+
closeSync(fd);
|
|
614
664
|
} catch {
|
|
615
|
-
|
|
616
|
-
continue;
|
|
665
|
+
/* fd already closed or never opened — ignore */
|
|
617
666
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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") {
|