pi-better-subagents 0.9.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -552,50 +552,18 @@ function contains(root: string, target: string): boolean {
552
552
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
553
553
  }
554
554
 
555
- /** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
556
- const caseFoldingByDevice = new Map<number, boolean>();
557
-
558
- /**
559
- * Whether the volume holding `path` (its nearest existing ancestor) ignores
560
- * case: an existing component whose case-swapped spelling names the same
561
- * inode proves it. macOS only; false whenever it cannot be shown.
562
- */
563
- function caseInsensitiveVolume(path: string): boolean {
564
- if (process.platform !== "darwin") return false;
565
- let current = path;
566
- while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
567
- let device: number;
568
- try { device = statSync(current).dev; } catch { return false; }
569
- const known = caseFoldingByDevice.get(device);
570
- if (known !== undefined) return known;
571
- for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
572
- const name = basename(probe);
573
- const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
574
- if (swapped === name) continue;
575
- try {
576
- const original = statSync(probe);
577
- if (original.dev !== device) break;
578
- let folded = false;
579
- try {
580
- const other = statSync(join(dirname(probe), swapped));
581
- folded = other.dev === original.dev && other.ino === original.ino;
582
- } catch { /* The swapped name is absent: case-sensitive. */ }
583
- caseFoldingByDevice.set(device, folded);
584
- return folded;
585
- } catch { return false; }
586
- }
587
- return false;
588
- }
589
-
590
555
  /**
591
- * Spell `path` the way the policy spells it when they differ only in case on a
592
- * case-insensitive volume. `canonicalizePath` fixes the case of every existing
593
- * component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
594
- * the caller's case, and the kernel still treats it as the protected name. The
595
- * longest policy path that matches case-insensitively wins, so the pre-check
596
- * decides as the kernel does (#354).
556
+ * Spell `path` the way the policy spells it when they differ only in case, on
557
+ * macOS. `canonicalizePath` fixes the case of every existing component on a
558
+ * case-insensitive volume, but a missing tail (`~/.NPMRC` while `~/.npmrc` is
559
+ * absent) keeps the caller's case, and the kernel still treats it as the
560
+ * protected name. Seatbelt matches paths case-insensitively on every volume,
561
+ * case-sensitive APFS included, so this folds on darwin without probing the
562
+ * volume: the pre-check decides as the kernel does (#354, #358). The longest
563
+ * policy path that matches case-insensitively wins. Other platforms never fold.
597
564
  */
598
- function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
565
+ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
566
+ if (currentPlatform(seams) !== "darwin") return path;
599
567
  const broad = policy.broad;
600
568
  const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
601
569
  ...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
@@ -608,8 +576,7 @@ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): st
608
576
  if (folded.length !== candidate.length || !contains(folded, lower)) continue;
609
577
  if (!best || candidate.length > best.length) best = candidate;
610
578
  }
611
- // Already spelled as the policy spells it: nothing to probe.
612
- if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
579
+ if (!best || path.startsWith(best)) return path;
613
580
  return `${best}${path.slice(best.length)}`;
614
581
  }
615
582
 
@@ -627,7 +594,7 @@ export function evaluateReadAccess(
627
594
  policy: CompiledSandboxWritePolicy,
628
595
  seams: SandboxSeams = {},
629
596
  ): ReadAccessDecision {
630
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
597
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
631
598
  const permissions = policy.permissions;
632
599
  if (!permissions) return { allowed: true, path };
633
600
  if (policy.broad) {
@@ -654,7 +621,7 @@ export function evaluateWriteAccess(
654
621
  policy: CompiledSandboxWritePolicy,
655
622
  seams: SandboxSeams = {},
656
623
  ): WriteAccessDecision {
657
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
624
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
658
625
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
659
626
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
660
627
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -1582,7 +1549,7 @@ function buildLinuxBroadCommand(
1582
1549
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1583
1550
  "--", args.execPath, ...args.execArgs],
1584
1551
  ...(created.length && { notices: [
1585
- `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
1552
+ `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place (remove it before creating the real target): ${created.join(", ")}`,
1586
1553
  ] }),
1587
1554
  };
1588
1555
  }
package/tools.ts CHANGED
@@ -420,7 +420,7 @@ export function subagentResultTool(Type: TypeModule, baseSession: SubagentToolSe
420
420
  name: "subagent_result",
421
421
  label: "Subagent Result",
422
422
  description:
423
- "Read a subagent's final output if it has finished. NEVER waits. Default current session. Ordinary answers are preserved verbatim in a 2 KiB UTF-8 page (max_bytes up to 8 KiB, optional lines cap); pass cursor to reconstruct the rest. Pass all:true for a foreign-session id. mode=raw pages retained log bytes (16 KiB default, 64 KiB cap). Failures and exceptional lifecycle facts come before progress. No tool-name histories or default cost lines; pass include:[\"cost\",\"tools\"] to opt in.",
423
+ "Read a subagent's final output if it has finished. NEVER waits. Default current session. Ordinary answers are preserved verbatim in a 2048-byte UTF-8 page (max_bytes up to 8192 bytes, optional lines cap); pass cursor to reconstruct the rest. Pass all:true for a foreign-session id. mode=raw pages retained log bytes (16 KiB default, 64 KiB cap). Failures and exceptional lifecycle facts come before progress. No tool-name histories or default cost lines; pass include:[\"cost\",\"tools\"] to opt in.",
424
424
  promptSnippet: "Read a finished subagent's final result (never waits)",
425
425
  promptGuidelines: [
426
426
  "Use subagent_result to collect a finished run's output. If it reports the run is still going, stop — do not poll; you'll be notified when it finishes.",
@@ -429,7 +429,7 @@ export function subagentResultTool(Type: TypeModule, baseSession: SubagentToolSe
429
429
  parameters: Type.Object({
430
430
  id: Type.String({ description: "Run id from subagent_spawn." }),
431
431
  cursor: Type.Optional(Type.String({ description: "Caller-owned answer page or status cursor from a previous response." })),
432
- max_bytes: Type.Optional(Type.Number({ description: "UTF-8 byte budget for this page (default 2 KiB, max 8 KiB)." })),
432
+ max_bytes: Type.Optional(Type.Number({ description: "UTF-8 byte budget for this page (default 2048 bytes, max 8192 bytes)." })),
433
433
  maxBytes: Type.Optional(Type.Number({ description: MAX_BYTES_ALIAS_DESCRIPTION })),
434
434
  lines: Type.Optional(Type.Number({ description: "Optional line cap for each answer page (default: bytes only). nextCursor continues after the last line shown." })),
435
435
  tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines. lines wins when both are given." })),