pi-subagent-feather 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-subagent-feather
2
2
  基于 [`@eggmasonvalue/pi-subagent` 2.0.1](https://github.com/eggmasonvalue/pi-subagent) 的独立 MIT 派生版。
3
3
  npm 包名 `pi-subagent-feather`,本地目录 `D:\Work\pi-subagent-lite`。
4
- 版本 `0.1.0`。MIT 授权,见 LICENSE / NOTICE。
4
+ 版本 `0.2.0`。MIT 授权,见 LICENSE / NOTICE。
5
5
 
6
6
  目标:保留独立上下文、子会话存档、resume、并行、超时与实时进度;减少父会话重复数据和额外提示词。
7
7
 
@@ -99,7 +99,7 @@ subagent_models({})
99
99
  "resultLines": 200,
100
100
  "progressBytes": 2048,
101
101
  "stderrBytes": 4096,
102
- "eventBytes": 8388608
102
+ "eventBytes": 1048576
103
103
  }
104
104
  ```
105
105
 
@@ -110,6 +110,19 @@ subagent_models({})
110
110
  - 所有预算必须是正整数;最大 resultBytes 51200 / resultLines 2000 / eventBytes 64MiB。
111
111
  - stderr 是运行期最多4KiB的尾部,失败且没有正文时供模型看;不保存在 details。
112
112
 
113
+ 其他可选键(同一 config.json,未知键直接报错防拼写):
114
+
115
+ - `"offlineChildren": true`:子进程附加 `--offline`,跳过启动时的模型目录网络刷新(更快)。适合 API key 型 provider;OAuth 型子代理凭证过期时可能需要先在主会话刷新凭证。
116
+ - `"defaultTimeoutMs": 30000`:调用未传 `timeoutMs` 时的兜底超时,防失控子进程。
117
+ - `"resultKeep": "tail"`:完成的答案超预算时保留结尾(结论常在末尾);默认 `"head"`。流式进度始终保留头部。
118
+ - `eventBytes` 默认已降为 1MiB(真实子进程事件被 Pi 自身截断在 50KiB/2000 行内,1MiB 足够),单事件超限仍丢弃并在下一行恢复。
119
+ - 收集器解析前按事件类型做廉价预过滤,不关注的事件类型跳过 JSON.parse。
120
+
121
+ ## /subagents 命令
122
+
123
+ `/subagents` 列出最近的子会话(时间、大小、模型、路径),纯人看界面,不进入模型上下文、零 token。
124
+ 有 UI 时可选择一条并复制其 resume 路径;无 UI(或 RPC 降级)时以通知列出。
125
+
113
126
  ## 开发 / 验证
114
127
 
115
128
  ```sh
@@ -6,10 +6,10 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import type { AgentToolResult, ThinkingLevel } from "@earendil-works/pi-agent-core";
8
8
  import { getSupportedThinkingLevels, StringEnum, type Model } from "@earendil-works/pi-ai";
9
- import { type ExtensionAPI, getAgentDir, getPackageDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
9
+ import { type ExtensionAPI, copyToClipboard, getAgentDir, getPackageDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
10
  import { Container, Markdown, Text } from "@earendil-works/pi-tui";
11
11
  import { Type } from "typebox";
12
- import { ChildCollector, DEFAULT_LIMITS, type CollectorLimits, type CollectorUsage } from "../../src/collector.ts";
12
+ import { ChildCollector, DEFAULT_LIMITS, type CollectorLimits, type CollectorUsage, type KeepMode } from "../../src/collector.ts";
13
13
 
14
14
  const CHILD_METADATA_SUFFIX = ".subagent.json";
15
15
  const INTERNAL_TOOL_NAMES = new Set(["subagent", "subagent_models"]);
@@ -318,19 +318,39 @@ function bounded(text: string, bytes: number): string {
318
318
  return Buffer.from(text.slice(0, end), "utf8").toString("utf8");
319
319
  }
320
320
 
321
- function loadLimits(agentDir = getAgentDir()): CollectorLimits {
321
+ interface FeatherConfig {
322
+ limits: CollectorLimits;
323
+ offlineChildren: boolean;
324
+ defaultTimeoutMs?: number;
325
+ resultKeep: KeepMode;
326
+ }
327
+
328
+ function loadConfig(agentDir = getAgentDir()): FeatherConfig {
322
329
  const file = path.join(agentDir, "pi-subagent-feather", "config.json");
323
- if (!fs.existsSync(file)) return { ...DEFAULT_LIMITS };
330
+ if (!fs.existsSync(file)) return { limits: { ...DEFAULT_LIMITS }, offlineChildren: false, resultKeep: "head" };
324
331
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
325
332
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid config: ${file}`);
326
333
  const limits = { ...DEFAULT_LIMITS };
334
+ const config: FeatherConfig = { limits, offlineChildren: false, resultKeep: "head" };
327
335
  const maximum: CollectorLimits = { resultBytes: 51200, resultLines: 2000, progressBytes: 8192, stderrBytes: 16384, eventBytes: 64 * 1024 * 1024 };
328
- for (const key of Object.keys(limits) as (keyof CollectorLimits)[]) {
329
- if (value[key] === undefined) continue;
330
- if (!Number.isInteger(value[key]) || value[key] < 1 || value[key] > maximum[key]) throw new Error(`Invalid ${key} in ${file}`);
331
- limits[key] = value[key];
336
+ for (const key of Object.keys(value)) {
337
+ if (key in limits) {
338
+ if (!Number.isInteger(value[key]) || value[key] < 1 || value[key] > maximum[key as keyof CollectorLimits]) throw new Error(`Invalid ${key} in ${file}`);
339
+ limits[key as keyof CollectorLimits] = value[key];
340
+ } else if (key === "offlineChildren") {
341
+ if (typeof value[key] !== "boolean") throw new Error(`Invalid offlineChildren in ${file}`);
342
+ config.offlineChildren = value[key];
343
+ } else if (key === "defaultTimeoutMs") {
344
+ if (!Number.isInteger(value[key]) || value[key] < 1 || value[key] > 2147483647) throw new Error(`Invalid defaultTimeoutMs in ${file}`);
345
+ config.defaultTimeoutMs = value[key];
346
+ } else if (key === "resultKeep") {
347
+ if (value[key] !== "head" && value[key] !== "tail") throw new Error(`Invalid resultKeep in ${file}`);
348
+ config.resultKeep = value[key];
349
+ } else {
350
+ throw new Error(`Unknown config key "${key}" in ${file}`);
351
+ }
332
352
  }
333
- return limits;
353
+ return config;
334
354
  }
335
355
 
336
356
  function resolveSessionFile(sessionDir: string): string | undefined {
@@ -392,6 +412,8 @@ interface RunChildDependencies {
392
412
  agentDir?: string;
393
413
  invoke?: (args: string[]) => { command: string; args: string[] };
394
414
  limits?: Partial<CollectorLimits>;
415
+ resultKeep?: KeepMode;
416
+ offlineChildren?: boolean;
395
417
  }
396
418
 
397
419
  async function runChild(
@@ -399,7 +421,8 @@ async function runChild(
399
421
  resumePath: string | undefined, signal: AbortSignal | undefined, onUpdate: Update | undefined,
400
422
  dependencies: RunChildDependencies = {},
401
423
  ): Promise<RunResult> {
402
- const collector = new ChildCollector(dependencies.limits ?? loadLimits(dependencies.agentDir));
424
+ const feather = loadConfig(dependencies.agentDir);
425
+ const collector = new ChildCollector({ ...feather.limits, ...dependencies.limits }, { resultKeep: dependencies.resultKeep ?? feather.resultKeep });
403
426
  let sessionFile = resumePath, exitCode = -1, status: Status = "running";
404
427
  const snapshot = (): Receipt => ({ version: 1, status, label, resumed: Boolean(resumePath), timeoutMs, exitCode,
405
428
  sessionFile, sessionId: collector.sessionId, model: config.model ?? collector.model, thinking: config.thinking,
@@ -411,6 +434,7 @@ async function runChild(
411
434
  if (!resumePath) await fs.promises.mkdir(sessionDir, { recursive: true });
412
435
  const args = ["--mode", "json", "-p"];
413
436
  if (resumePath) args.push("--session", resumePath); else args.push("--session-dir", sessionDir);
437
+ if (dependencies.offlineChildren ?? feather.offlineChildren) args.push("--offline");
414
438
  if (config.model) args.push("--model", config.model);
415
439
  if (config.thinking) args.push("--thinking", config.thinking);
416
440
  if (!config.tools.length) args.push("--no-tools"); else args.push("--tools", config.tools.join(","));
@@ -489,7 +513,78 @@ const SubagentParams = Type.Object({
489
513
 
490
514
  export const __testing = { allowedThinkingLevels, compactModelCatalog, loadModelPolicy, metadataPath,
491
515
  modelFacingResult, readChildMetadata, resolveFreshModel, resolveResumePath, runChild,
492
- validateModelPolicy, writeChildMetadata, receiptOf, loadLimits, getPiInvocation, SubagentParams };
516
+ validateModelPolicy, writeChildMetadata, receiptOf, loadConfig, recentChildSessions, formatBytes,
517
+ getPiInvocation, SubagentParams };
518
+
519
+ interface ChildSessionInfo {
520
+ path: string;
521
+ mtimeMs: number;
522
+ bytes: number;
523
+ sessionId?: string;
524
+ model?: string;
525
+ cwd?: string;
526
+ }
527
+
528
+ function formatBytes(n: number): string {
529
+ if (n < 1024) return `${n}B`;
530
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)}KB`;
531
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
532
+ }
533
+
534
+ /** Recent child sessions (newest first). Read-only: stats, sidecar metadata, first-line id. */
535
+ function recentChildSessions(agentDir = getAgentDir(), max = 24): ChildSessionInfo[] {
536
+ const root = path.join(agentDir, "sessions", "subagent-lite");
537
+ let runDirs: string[];
538
+ try {
539
+ runDirs = fs.readdirSync(root);
540
+ } catch {
541
+ return [];
542
+ }
543
+ const sessions: ChildSessionInfo[] = [];
544
+ for (const run of runDirs) {
545
+ const dir = path.join(root, run);
546
+ let names: string[];
547
+ try {
548
+ names = fs.readdirSync(dir);
549
+ } catch {
550
+ continue;
551
+ }
552
+ for (const name of names) {
553
+ if (!name.endsWith(".jsonl")) continue;
554
+ const file = path.join(dir, name);
555
+ let stat: fs.Stats;
556
+ try {
557
+ stat = fs.statSync(file);
558
+ } catch {
559
+ continue;
560
+ }
561
+ if (!stat.isFile()) continue;
562
+ const info: ChildSessionInfo = { path: file, mtimeMs: stat.mtimeMs, bytes: stat.size };
563
+ try {
564
+ const meta = readChildMetadata(file);
565
+ info.model = meta.model;
566
+ info.cwd = meta.cwd;
567
+ } catch {
568
+ // Sidecar metadata is optional for listing.
569
+ }
570
+ try {
571
+ const fd = fs.openSync(file, "r");
572
+ try {
573
+ const buf = Buffer.alloc(512);
574
+ const read = fs.readSync(fd, buf, 0, 512, 0);
575
+ const first = JSON.parse(buf.toString("utf8", 0, read).split("\n", 1)[0]);
576
+ if (first?.type === "session" && first.id) info.sessionId = String(first.id).slice(0, 64);
577
+ } finally {
578
+ fs.closeSync(fd);
579
+ }
580
+ } catch {
581
+ // First-line probe is optional.
582
+ }
583
+ sessions.push(info);
584
+ }
585
+ }
586
+ return sessions.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, max);
587
+ }
493
588
 
494
589
  export default function (pi: ExtensionAPI) {
495
590
  const running = new Map<AbortController, Promise<RunResult>>();
@@ -498,6 +593,36 @@ export default function (pi: ExtensionAPI) {
498
593
  await Promise.allSettled(running.values());
499
594
  running.clear();
500
595
  });
596
+ pi.registerCommand("subagents", {
597
+ description: "List recent subagent child sessions (pick one to copy its resume path)",
598
+ handler: async (_args, ctx) => {
599
+ const sessions = recentChildSessions();
600
+ if (sessions.length === 0) {
601
+ ctx.ui.notify("No subagent child sessions found.", "info");
602
+ return;
603
+ }
604
+ const lines = sessions.map(s => `${new Date(s.mtimeMs).toLocaleString()} ${formatBytes(s.bytes)} ${s.model ?? "?"} ${s.path}`);
605
+ if (!ctx.hasUI) {
606
+ ctx.ui.notify(lines.join("\n"), "info");
607
+ return;
608
+ }
609
+ let picked: string | undefined;
610
+ try {
611
+ picked = await ctx.ui.select(`Subagent sessions (${sessions.length})`, lines);
612
+ } catch {
613
+ picked = undefined;
614
+ }
615
+ if (!picked) return;
616
+ const chosen = sessions[lines.indexOf(picked)];
617
+ if (!chosen) return;
618
+ try {
619
+ await copyToClipboard(chosen.path);
620
+ ctx.ui.notify(`Copied resume path: ${chosen.path}`, "info");
621
+ } catch {
622
+ ctx.ui.notify(`Resume path: ${chosen.path}`, "info");
623
+ }
624
+ },
625
+ });
501
626
  pi.registerTool({
502
627
  name: "subagent_models", label: "Subagent Models",
503
628
  description: "List child model policy, defaults and supported thinking levels. No child starts.",
@@ -537,8 +662,10 @@ export default function (pi: ExtensionAPI) {
537
662
  tools: params.tools ? [...new Set(params.tools)] : pi.getActiveTools().filter(name => !INTERNAL_TOOL_NAMES.has(name)) };
538
663
  }
539
664
  const controller = new AbortController();
540
- const child = runChild(config, params.task, params.label, params.timeoutMs, resumePath,
541
- signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, onUpdate);
665
+ const feather = loadConfig();
666
+ const child = runChild(config, params.task, params.label, params.timeoutMs ?? feather.defaultTimeoutMs, resumePath,
667
+ signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, onUpdate,
668
+ { resultKeep: feather.resultKeep });
542
669
  running.set(controller, child);
543
670
  try {
544
671
  const result = await child;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagent-feather",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi subagent with bounded parent receipts, persistent child sessions and minimal prompt injection.",
6
6
  "license": "MIT",
package/src/collector.ts CHANGED
@@ -22,12 +22,15 @@ export interface CollectorLimits {
22
22
  }
23
23
 
24
24
  /** Default budgets, identical in spirit to upstream but chosen for a safe fork. */
25
+ /** Where an oversized completed answer keeps content: head (default) or tail. */
26
+ export type KeepMode = "head" | "tail";
27
+
25
28
  export const DEFAULT_LIMITS: CollectorLimits = {
26
29
  resultBytes: 8192,
27
30
  resultLines: 200,
28
31
  progressBytes: 2048,
29
32
  stderrBytes: 4096,
30
- eventBytes: 8 * 1024 * 1024,
33
+ eventBytes: 1024 * 1024,
31
34
  };
32
35
 
33
36
  /** Accumulated token/cost accounting exposed by {@link ChildCollector.usage}. */
@@ -266,6 +269,75 @@ class BoundedText {
266
269
  }
267
270
  }
268
271
 
272
+ /** Anything BoundedText/TailText can append into. */
273
+ interface TextSink {
274
+ append(input: string): void;
275
+ }
276
+
277
+ /**
278
+ * Raw-substring markers for the event types this collector reads. Pi writes compact
279
+ * JSON (`"type":"x"`, no spaces), so a line without any needle cannot be one of them;
280
+ * such lines skip JSON.parse entirely. Key order does not matter (scan, not prefix match).
281
+ */
282
+ const EVENT_TYPE_NEEDLES = [
283
+ "session",
284
+ "message_start",
285
+ "message_update",
286
+ "message_end",
287
+ "tool_execution_start",
288
+ "tool_execution_end",
289
+ ].map(type => `"type":"${type}"`);
290
+
291
+ /**
292
+ * Bounded accumulator that keeps the most recent content (tail) instead of the head.
293
+ * Compaction re-slices only when the budget is exceeded; at the default few-KiB budgets
294
+ * the amortized cost per appended byte is O(1).
295
+ */
296
+ class TailText {
297
+ private readonly maxBytes: number;
298
+ private readonly maxLines: number;
299
+ private readonly separator: string;
300
+ private buf = "";
301
+ private segments = 0;
302
+ truncated = false;
303
+
304
+ constructor(maxBytes: number, maxLines: number, separator: string) {
305
+ this.maxBytes = maxBytes;
306
+ this.maxLines = maxLines;
307
+ this.separator = separator;
308
+ }
309
+
310
+ append(input: string): void {
311
+ if (input.length === 0) return;
312
+ if (this.maxBytes <= 0 || this.maxLines <= 0) {
313
+ this.truncated = true;
314
+ return;
315
+ }
316
+ if (this.segments > 0 && this.separator.length > 0) this.buf += this.separator;
317
+ this.segments += 1;
318
+ this.buf += input;
319
+ this.compact();
320
+ }
321
+
322
+ private compact(): void {
323
+ if (utf8Length(this.buf) > this.maxBytes) {
324
+ this.buf = detach(utf8Tail(this.buf, this.maxBytes));
325
+ this.truncated = true;
326
+ }
327
+ let newlines = 0;
328
+ for (let i = this.buf.indexOf("\n"); i !== -1; i = this.buf.indexOf("\n", i + 1)) newlines += 1;
329
+ if (newlines >= this.maxLines) {
330
+ const lines = this.buf.split("\n");
331
+ this.buf = detach(lines.slice(lines.length - this.maxLines).join("\n"));
332
+ this.truncated = true;
333
+ }
334
+ }
335
+
336
+ value(): string {
337
+ return this.buf;
338
+ }
339
+ }
340
+
269
341
  function normalizeLimits(overrides?: Partial<CollectorLimits>): CollectorLimits {
270
342
  const out: CollectorLimits = { ...DEFAULT_LIMITS };
271
343
  if (overrides) {
@@ -288,7 +360,7 @@ function shortToolName(name: unknown): string {
288
360
  return dunder >= 0 ? base.slice(dunder + 2) : base;
289
361
  }
290
362
 
291
- function appendTextContent(acc: BoundedText, content: unknown): void {
363
+ function appendTextContent(acc: TextSink, content: unknown): void {
292
364
  if (typeof content === "string") {
293
365
  acc.append(content);
294
366
  return;
@@ -325,6 +397,8 @@ function addUsage(target: CollectorUsage, usage: TurnUsage): void {
325
397
  */
326
398
  export class ChildCollector {
327
399
  readonly limits: CollectorLimits;
400
+ /** Whether oversized completed answers keep their head or tail. */
401
+ readonly resultKeep: KeepMode;
328
402
 
329
403
  /** Most recent completed non-empty assistant answer (bounded). */
330
404
  finalText = "";
@@ -355,8 +429,9 @@ export class ChildCollector {
355
429
  private bufferBytes = 0;
356
430
  private dropping = false;
357
431
 
358
- constructor(limits?: Partial<CollectorLimits>) {
432
+ constructor(limits?: Partial<CollectorLimits>, options?: { resultKeep?: KeepMode }) {
359
433
  this.limits = normalizeLimits(limits);
434
+ this.resultKeep = options?.resultKeep === "tail" ? "tail" : "head";
360
435
  this.partialBuffer = new BoundedText(this.limits.resultBytes, this.limits.resultLines, "");
361
436
  this.stdoutDecoder = new StringDecoder("utf8");
362
437
  this.stderrDecoder = new StringDecoder("utf8");
@@ -459,7 +534,9 @@ export class ChildCollector {
459
534
  const m = message as Record<string, any>;
460
535
  if (m.role !== "assistant") return;
461
536
 
462
- const acc = new BoundedText(this.limits.resultBytes, this.limits.resultLines, "\n");
537
+ const acc = this.resultKeep === "tail"
538
+ ? new TailText(this.limits.resultBytes, this.limits.resultLines, "\n")
539
+ : new BoundedText(this.limits.resultBytes, this.limits.resultLines, "\n");
463
540
  appendTextContent(acc, m.content);
464
541
  const text = acc.value();
465
542
  if (text.trim().length > 0) {
@@ -577,6 +654,15 @@ export class ChildCollector {
577
654
  }
578
655
 
579
656
  private processLine(line: string): void {
657
+ // Cheap pre-filter before JSON.parse: skip lines whose event type we never read.
658
+ let interesting = false;
659
+ for (const needle of EVENT_TYPE_NEEDLES) {
660
+ if (line.includes(needle)) {
661
+ interesting = true;
662
+ break;
663
+ }
664
+ }
665
+ if (!interesting) return;
580
666
  const trimmed = line.trim();
581
667
  if (trimmed.length === 0) return;
582
668
  let event: unknown;