pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -1,5 +1,3 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
1
  import type { AgentConfig } from "../../agents/agent-config.ts";
4
2
  import { resolveToolPolicy } from "../../agents/agent-config.ts";
5
3
  import type { CrewRuntimeConfig } from "../../config/config.ts";
@@ -11,6 +9,7 @@ import type { TeamRunManifest, TeamTaskState, UsageState } from "../../state/typ
11
9
  import { logInternalError } from "../../utils/internal-error.ts";
12
10
  import { redactSecrets } from "../../utils/redaction.ts";
13
11
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
12
+ import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
14
13
  import { createIrcTool } from "../custom-tools/irc-tool.ts";
15
14
  import { createSubmitResultTool } from "../custom-tools/submit-result-tool.ts";
16
15
  import { buildMcpProxyFromSession } from "../mcp-proxy.ts";
@@ -28,7 +27,13 @@ import { readEnabledModelsPatterns } from "../model/model-scope.ts";
28
27
  import { isLiveSessionRuntimeAvailable } from "../model/runtime-resolver.ts";
29
28
  import { awaitRuntimeWarmup } from "../model/runtime-warmup.ts";
30
29
  import { liveAgentContext, registerLiveAgentModel, unregisterLiveAgentModel } from "../model/session-model.ts";
31
- import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "../output/sidechain-output.ts";
30
+ import {
31
+ appendBatchedJsonlLine,
32
+ eventToSidechainType,
33
+ flushPendingSidechainWrites,
34
+ sidechainOutputPath,
35
+ writeSidechainEntry,
36
+ } from "../output/sidechain-output.ts";
32
37
  // NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
33
38
  // block is injected via renderTaskPrompt().full (the USER prompt), which is
34
39
  // shared by both the child-pi path (no system prompt) and the live-session
@@ -60,7 +65,7 @@ import {
60
65
  trackLiveAgentTurnEnd,
61
66
  updateLiveAgentStatus,
62
67
  } from "./live-agent-manager.ts";
63
- import { subscribeLiveControlRealtime } from "./live-control-realtime.ts";
68
+ import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
64
69
  import { buildExtensionBridge } from "./live-extension-bridge.ts";
65
70
  import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
66
71
 
@@ -178,20 +183,13 @@ type LiveSessionLike = {
178
183
 
179
184
  function appendTranscript(filePath: string | undefined, event: unknown): void {
180
185
  if (!filePath) return;
181
- // Fire-and-forget async write to avoid blocking the event loop.
182
- // Transcript writes are best-effort telemetry — callers do not need to await.
183
- void appendTranscriptAsync(filePath, event);
184
- }
185
-
186
- /** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
187
- async function appendTranscriptAsync(filePath: string, event: unknown): Promise<void> {
188
- try {
189
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
190
- const content = `${JSON.stringify(redactSecrets(event))}\n`;
191
- await fs.promises.appendFile(filePath, content, "utf-8");
192
- } catch (error) {
193
- logInternalError("live-session.transcript-write-failed", error as Error, `path=${filePath}`);
194
- }
186
+ // Task 26 (2026-08-24): serialize + redact at queue time, then route
187
+ // through the shared 50ms batched JSONL writer (one mkdir + appendFileSync
188
+ // per path per window instead of one async mkdir+appendFile PER streaming
189
+ // event). child-pi-transcript's batched writer is not generic over plain
190
+ // paths (ChildPiRunInput + artifactsRoot containment + re-redaction), so
191
+ // the identical map lives in sidechain-output.ts.
192
+ appendBatchedJsonlLine(filePath, `${JSON.stringify(redactSecrets(event))}\n`);
195
193
  }
196
194
 
197
195
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -630,6 +628,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
630
628
  message: event,
631
629
  cwd: input.task.cwd,
632
630
  });
631
+ // Task 26: the mock path returns before the try/finally below, so drain
632
+ // the batched writers here — tests read these files right after return.
633
+ flushPendingSidechainWrites();
633
634
  if (isCurrent()) input.onEvent?.(event);
634
635
  const stdout = `Mock live-session success for ${input.agent.name}${inherited}`;
635
636
  if (isCurrent()) input.onOutput?.(stdout);
@@ -678,7 +679,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
678
679
  let unsubscribe: (() => void) | undefined;
679
680
  let unsubscribeControlRealtime: (() => void) | undefined;
680
681
  let controlTimer: ReturnType<typeof setInterval> | undefined;
681
- let stdout = "";
682
+ // PERF (2026-08-24): unbounded string concat re-copies the whole transcript
683
+ // on every chunk; bounded tail keeps whatever the consumer actually reads.
684
+ // Consumer sizing: stdout becomes parsedOutput.finalText + the
685
+ // results/{taskId}.txt artifact — the same downstream consumers the
686
+ // child-pi path already caps at maxCaptureBytes (512 KiB) via BoundedTail,
687
+ // so we match that cap for parity (this is NOT a tail-only preview).
688
+ const stdoutTail = new BoundedTail();
682
689
  let jsonEvents = 0;
683
690
  const collectedJsonEvents: Record<string, unknown>[] | undefined = yieldEnabled ? [] : undefined;
684
691
  const maxCollectedJsonEvents = 1000;
@@ -898,9 +905,18 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
898
905
  });
899
906
  });
900
907
  await pollControl();
901
- controlTimer = setInterval(() => {
902
- if (isCurrent()) void pollControl();
903
- }, 500);
908
+ // PERF (2026-08-24): each poll does existsSync + readFileSync + JSON.parse
909
+ // of the whole control JSONL. With a realtime subscription active for this
910
+ // agent, in-process control requests (team-tool / cross-extension RPC
911
+ // publish) are delivered immediately and the poll only covers cross-process
912
+ // writers → 1000ms. Without realtime, the file poll is the sole delivery
913
+ // path → keep the faster 500ms cadence.
914
+ controlTimer = setInterval(
915
+ () => {
916
+ if (isCurrent()) void pollControl();
917
+ },
918
+ hasLiveControlRealtimeListeners() ? 1000 : 500,
919
+ );
904
920
  let turnCount = 0;
905
921
  let softLimitReached = false;
906
922
  const maxTurns = input.runtimeConfig?.maxTurns;
@@ -953,7 +969,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
953
969
  input.onEvent?.(event);
954
970
  const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
955
971
  if (text.trim()) {
956
- stdout += `${text}\n`;
972
+ stdoutTail.push(`${text}\n`);
957
973
  streamOut?.write(text + "\n");
958
974
  trackLiveAgentResponseText(agentId, text);
959
975
  input.onOutput?.(text);
@@ -1034,7 +1050,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1034
1050
  return {
1035
1051
  available: true,
1036
1052
  exitCode: 1,
1037
- stdout: stdout.trim(),
1053
+ stdout: stdoutTail.value().trim(),
1038
1054
  stderr: msg,
1039
1055
  jsonEvents,
1040
1056
  error: msg,
@@ -1050,7 +1066,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1050
1066
  data: {
1051
1067
  elapsedMs: Date.now() - promptStart,
1052
1068
  jsonEvents,
1053
- outputLength: stdout.length,
1069
+ outputLength: stdoutTail.value().length,
1054
1070
  },
1055
1071
  });
1056
1072
 
@@ -1171,7 +1187,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1171
1187
  return {
1172
1188
  available: true,
1173
1189
  exitCode: 0,
1174
- stdout: stdout.trim(),
1190
+ stdout: stdoutTail.value().trim(),
1175
1191
  stderr: created.modelFallbackMessage ?? "",
1176
1192
  jsonEvents,
1177
1193
  usage,
@@ -1205,7 +1221,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1205
1221
  return {
1206
1222
  available: true,
1207
1223
  exitCode: 1,
1208
- stdout: stdout.trim(),
1224
+ stdout: stdoutTail.value().trim(),
1209
1225
  stderr: message,
1210
1226
  jsonEvents,
1211
1227
  error: message,
@@ -1220,6 +1236,10 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1220
1236
  // H6: Unsubscribe listeners FIRST before clearing timer to prevent race
1221
1237
  unsubscribe?.();
1222
1238
  unsubscribeControlRealtime?.();
1239
+ // Task 26 (2026-08-24): drain the batched sidechain/transcript writers
1240
+ // now that no more session events can enqueue, so callers that read the
1241
+ // files right after this task settles see complete content.
1242
+ flushPendingSidechainWrites();
1223
1243
  // Round 27 (BUG 4): remove the named abort listener to avoid leaking it
1224
1244
  // on the shared AbortSignal across many live-session tasks.
1225
1245
  if (onSignalAbort) input.signal?.removeEventListener("abort", onSignalAbort);
@@ -29,6 +29,16 @@ interface CachedManifest {
29
29
  mtimeMs: number;
30
30
  size: number;
31
31
  loadedAtMs: number;
32
+ /**
33
+ * PERF (task-23a): timestamp of the last real statSync of this manifest.
34
+ * parseManifestIfChanged() skips the stat entirely while the entry is
35
+ * younger than the stat TTL (default 250ms), so back-to-back list() /
36
+ * listActive() scans do not re-stat every manifest on every 500ms list
37
+ * TTL expiry. Bounded staleness: a manifest change is picked up at most
38
+ * statTtlMs late on the scan path (watcher-driven refreshes bypass this
39
+ * with forceStat).
40
+ */
41
+ statCheckedAtMs: number;
32
42
  }
33
43
 
34
44
  interface CachedList {
@@ -41,9 +51,16 @@ export interface ManifestCacheOptions {
41
51
  debounceMs?: number;
42
52
  watch?: boolean;
43
53
  maxEntries?: number;
54
+ /**
55
+ * PERF (task-23a): how long a cached manifest skips re-statting on scan
56
+ * (default 250ms). Exposed mainly so tests can pin the window
57
+ * deterministically.
58
+ */
59
+ statTtlMs?: number;
44
60
  }
45
61
 
46
62
  const DEFAULT_TTL_MS = 500;
63
+ const DEFAULT_STAT_TTL_MS = 250;
47
64
 
48
65
  interface ParsedEntry {
49
66
  runId: string;
@@ -101,13 +118,30 @@ function validateManifestForRoot(root: string, runId: string, manifest: TeamRunM
101
118
  }
102
119
  }
103
120
 
104
- function parseManifestIfChanged(root: string, runId: string, filePath: string, previous?: CachedManifest): CachedManifest | undefined {
121
+ function parseManifestIfChanged(
122
+ root: string,
123
+ runId: string,
124
+ filePath: string,
125
+ previous?: CachedManifest,
126
+ forceStat = false,
127
+ statTtlMs = DEFAULT_STAT_TTL_MS,
128
+ ): CachedManifest | undefined {
129
+ if (!forceStat && previous && Date.now() - previous.statCheckedAtMs < statTtlMs) {
130
+ // PERF (task-23a): stat'ed very recently and not forcibly invalidated —
131
+ // the stat (and any parse behind it) cannot have changed the verdict.
132
+ // This bounds per-scan stat cost to ~1/statTtlMs per manifest instead
133
+ // of one per list() TTL expiry.
134
+ return previous;
135
+ }
105
136
  let stat: fs.Stats;
106
137
  try {
107
138
  stat = fs.statSync(filePath);
108
139
  } catch {
109
140
  return undefined;
110
141
  }
142
+ if (previous) {
143
+ previous.statCheckedAtMs = Date.now();
144
+ }
111
145
  if (previous && previous.mtimeMs === stat.mtimeMs && previous.size === stat.size) {
112
146
  // P1-9: the manifest file is unchanged, so its recorded paths and their
113
147
  // containment verdict are unchanged too — skip the ~10-syscall
@@ -123,6 +157,7 @@ function parseManifestIfChanged(root: string, runId: string, filePath: string, p
123
157
  mtimeMs: stat.mtimeMs,
124
158
  size: stat.size,
125
159
  loadedAtMs: Date.now(),
160
+ statCheckedAtMs: Date.now(),
126
161
  };
127
162
  }
128
163
 
@@ -135,25 +170,54 @@ function listRunRoots(cwd: string): string[] {
135
170
  return [...roots];
136
171
  }
137
172
 
173
+ // PERF (2026-08-24, task-23b): list()/listActive() re-readdir'd every run
174
+ // root on each 500ms TTL expiry. A runs root's own mtimeMs only changes when
175
+ // direct entries (run dirs) are added/removed/renamed — exactly and only when
176
+ // the listing must refresh — so the fully-mapped ParsedEntry[] is cached
177
+ // against it. Manifest rewrites happen one level down (inside a run dir) and
178
+ // never touch the root's mtime; they are picked up by the per-run stat in
179
+ // parseManifestIfChanged. Caching the MAPPED entries (not just the raw names)
180
+ // also skips the manifestPathForRun resolution (realpath + O_NOFOLLOW
181
+ // ancestor walk, ~10 syscalls per run) for unchanged listings: an unchanged
182
+ // root now costs a single statSync instead of existsSync + readdir + N walks.
183
+ const DIR_LIST_CACHE_MAX_ROOTS = 64;
184
+ const dirListCache = new Map<string, { mtimeMs: number; entries: ParsedEntry[] }>();
185
+
138
186
  function collectRoots(root: string): ParsedEntry[] {
139
- if (!fs.existsSync(root)) return [];
187
+ let mtimeMs: number;
188
+ try {
189
+ mtimeMs = fs.statSync(root).mtimeMs;
190
+ } catch {
191
+ return [];
192
+ }
193
+ const cached = dirListCache.get(root);
194
+ if (cached && cached.mtimeMs === mtimeMs) {
195
+ return cached.entries;
196
+ }
140
197
  let entries: string[];
141
198
  try {
142
199
  entries = fs.readdirSync(root);
143
200
  } catch {
144
201
  return [];
145
202
  }
146
- return entries
203
+ const mapped = entries
147
204
  .filter((entry) => entry.length > 0 && isSafePathId(entry))
148
205
  .map((entry) => ({
149
206
  runId: entry,
150
207
  path: manifestPathForRun(root, entry),
151
208
  }))
152
209
  .filter((entry): entry is ParsedEntry => entry.path !== undefined);
210
+ // Bounded growth: the map is module-scoped and shared across cache
211
+ // instances (tests, multi-project sessions); roots are few, but stale
212
+ // tempdir roots from finished tests would otherwise linger forever.
213
+ if (dirListCache.size >= DIR_LIST_CACHE_MAX_ROOTS) dirListCache.clear();
214
+ dirListCache.set(root, { mtimeMs, entries: mapped });
215
+ return mapped;
153
216
  }
154
217
 
155
218
  export function createManifestCache(cwd: string, options: ManifestCacheOptions = {}): ManifestCache {
156
219
  const ttlMs = options.debounceMs ?? DEFAULT_TTL_MS;
220
+ const statTtlMs = options.statTtlMs ?? DEFAULT_STAT_TTL_MS;
157
221
  const maxEntries = options.maxEntries ?? DEFAULT_CACHE.manifestMaxEntries;
158
222
  const roots = listRunRoots(cwd);
159
223
  const manifestIndex = new Map<string, CachedManifest>();
@@ -198,7 +262,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
198
262
  const activeEntry = activeRunEntries().find((entry) => entry.runId === runId);
199
263
  if (activeEntry) {
200
264
  const activeRoot = path.dirname(activeEntry.stateRoot);
201
- const parsed = parseManifestIfChanged(activeRoot, runId, activeEntry.manifestPath, cached);
265
+ const parsed = parseManifestIfChanged(activeRoot, runId, activeEntry.manifestPath, cached, false, statTtlMs);
202
266
  if (parsed) {
203
267
  manifestIndex.set(runId, parsed);
204
268
  return parsed;
@@ -207,7 +271,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
207
271
  for (const root of rootsToCheck) {
208
272
  const manifestPath = manifestPathForRun(root, runId);
209
273
  if (!manifestPath) continue;
210
- const parsed = parseManifestIfChanged(root, runId, manifestPath, cached);
274
+ const parsed = parseManifestIfChanged(root, runId, manifestPath, cached, false, statTtlMs);
211
275
  if (parsed) {
212
276
  if (!cached || parsed.mtimeMs !== cached.mtimeMs || parsed.size !== cached.size) {
213
277
  manifestIndex.set(runId, parsed);
@@ -223,13 +287,21 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
223
287
  }
224
288
 
225
289
  /**
226
- * NOTE (RT-F10): This function performs a full FS scan + JSON.parse of all
227
- * manifests on every TTL expiry (default 500ms). This is a known performance
228
- * tradeoff: fs.watch provides real-time invalidation (see constructor above)
229
- * so the TTL cache is only consulted when the watcher fires, but the list()
230
- * call itself still re-reads all entries to pick up changes. Incremental
231
- * updates via fs.watch-driven delta tracking are planned for a future
232
- * iteration to avoid the full scan on each expiry.
290
+ * NOTE (RT-F10, updated 2026-08-24): the original "full FS scan +
291
+ * JSON.parse on every TTL expiry" cost is now layered:
292
+ * 1. collectRoots() reuses a cached dir listing keyed by the runs root's
293
+ * own mtimeMs — one statSync per root replaces existsSync + readdir +
294
+ * the per-run manifestPathForRun resolution when no run dir was
295
+ * added/removed/renamed.
296
+ * 2. parseManifestIfChanged() skips the per-manifest stat for statTtlMs
297
+ * (default 250ms) after the last check, so scans re-stat each run at
298
+ * most ~4x/sec regardless of how often the list TTL lapses.
299
+ * 3. JSON.parse only happens when mtime+size actually changed.
300
+ * What remains per 500ms expiry: one stat per runs root, one stat per run
301
+ * whose stat TTL lapsed, and the in-memory merge + sort. fs.watch events
302
+ * that carry a filename refresh just that run (forceStat) and expire the
303
+ * list caches immediately instead of triggering a wholesale refresh (see
304
+ * handleWatchEvent below).
233
305
  */
234
306
  function list(limit = DEFAULT_CACHE.manifestMaxEntries): TeamRunManifest[] {
235
307
  const now = Date.now();
@@ -249,7 +321,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
249
321
  if (entry.runId.length === 0) continue;
250
322
  let cached = manifestIndex.get(entry.runId);
251
323
  const root = path.dirname(path.dirname(entry.path));
252
- const parsed = parseManifestIfChanged(root, entry.runId, entry.path, cached);
324
+ const parsed = parseManifestIfChanged(root, entry.runId, entry.path, cached, false, statTtlMs);
253
325
  if (parsed) {
254
326
  cached = parsed;
255
327
  manifestIndex.set(entry.runId, cached);
@@ -258,7 +330,11 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
258
330
  }
259
331
 
260
332
  const runs = [...unique.values()].filter((value): value is CachedManifest => value !== undefined).map((value) => value.manifest);
261
- const sorted = runs.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
333
+ // PERF (task-23d): ISO-8601 createdAt strings are fixed-width and sort
334
+ // correctly with plain comparison — no locale machinery per compare.
335
+ const sorted = runs.sort((a, b) =>
336
+ (b.createdAt ?? "") < (a.createdAt ?? "") ? -1 : (b.createdAt ?? "") > (a.createdAt ?? "") ? 1 : 0,
337
+ );
262
338
  const limited = sorted.slice(0, Math.max(0, limit));
263
339
  if (manifestIndex.size > maxEntries) {
264
340
  const removeCount = manifestIndex.size - maxEntries;
@@ -324,7 +400,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
324
400
  if (entry.runId.length === 0) continue;
325
401
  let cached = manifestIndex.get(entry.runId);
326
402
  const root = path.dirname(path.dirname(entry.path));
327
- const parsed = parseManifestIfChanged(root, entry.runId, entry.path, cached);
403
+ const parsed = parseManifestIfChanged(root, entry.runId, entry.path, cached, false, statTtlMs);
328
404
  if (parsed) {
329
405
  cached = parsed;
330
406
  manifestIndex.set(entry.runId, cached);
@@ -339,12 +415,47 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
339
415
  return running.slice(0, cap);
340
416
  }
341
417
 
418
+ // PERF (task-23c): the runs-root watcher reports the direct child that
419
+ // changed in `filename` (a run dir added/removed/renamed — the watcher is
420
+ // non-recursive, so manifest rewrites one level down never reach it).
421
+ // Refresh ONLY that run — forceStat bypasses the statTtlMs skip so a
422
+ // just-checked run still re-stats — and expire the list caches so the
423
+ // next list()/listActive() re-collects roots and re-sorts. Events without
424
+ // a usable filename (null, Buffer, non-run-id names like ".DS_Store")
425
+ // fall back to the wholesale debounced refresh.
426
+ function handleWatchEvent(root: string, filename: string | Buffer | null): void {
427
+ if (typeof filename !== "string" || !isSafePathId(filename)) {
428
+ scheduleListRefresh();
429
+ return;
430
+ }
431
+ const manifestPath = manifestPathForRun(root, filename);
432
+ if (manifestPath) {
433
+ const parsed = parseManifestIfChanged(
434
+ root,
435
+ filename,
436
+ manifestPath,
437
+ manifestIndex.get(filename),
438
+ /* forceStat */ true,
439
+ statTtlMs,
440
+ );
441
+ if (parsed) {
442
+ manifestIndex.set(filename, parsed);
443
+ } else {
444
+ // Run dir (or its manifest) is gone — drop the cached entry so
445
+ // the next scan does not serve a stale manifest.
446
+ manifestIndex.delete(filename);
447
+ }
448
+ }
449
+ listCache.clear();
450
+ invalidateListActive();
451
+ }
452
+
342
453
  if (options.watch ?? true) {
343
454
  for (const root of roots) {
344
455
  const watcher = watchWithErrorHandler(
345
456
  root,
346
- () => {
347
- scheduleListRefresh();
457
+ (_eventType, filename) => {
458
+ handleWatchEvent(root, filename);
348
459
  },
349
460
  () => {
350
461
  scheduleListRefresh();
@@ -2,7 +2,6 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../../agents/agent-config.ts";
5
- import { resolveToolPolicy } from "../../agents/agent-config.ts";
6
5
  import { atomicWriteFile } from "../../state/atomic-write.ts";
7
6
  import { packageRoot, userPiRoot } from "../../utils/paths.ts";
8
7
 
@@ -17,7 +16,9 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
17
16
  // dist/ entry points.
18
17
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(packageRoot(), "src", "prompt", "prompt-runtime.ts");
19
18
  const TASK_ARG_LIMIT = 8000;
20
- const DEFAULT_MAX_CREW_DEPTH = 2;
19
+ // D8 spec v0.7 — nested mở (default 4 depth levels: child→child→child→child);
20
+ // cap giữ để chống runaway recursion từ config lỗi hoặc intentional abuse.
21
+ const DEFAULT_MAX_CREW_DEPTH = 4;
21
22
 
22
23
  // Track every temp dir created in this process so we can clean them up
23
24
  // even if the parent is killed before child-pi.ts cleanup runs.
@@ -278,71 +279,57 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
278
279
  args.push("--thinking", effectiveThinking);
279
280
  }
280
281
 
281
- // Apply role-based tool restrictions (from role-tools.ts)
282
- // F1 unify (v0.8.0): the tool policy is resolved by the shared
283
- // `resolveToolPolicy` helper (same code as the live-session path), so the
284
- // two spawn paths agree. Before this, child-pi used role-config
285
- // authoritative and ignored `agent.disallowedTools`; live-session used
286
- // frontmatter authoritative and ignored role-config. Now:
287
- // - allowlist precedence is source-aware (builtin → role authoritative;
288
- // user/project → frontmatter authoritative)
289
- // - denylist is additive (role excludeTools + agent disallowedTools merged)
290
- const policy = resolveToolPolicy(input.agent, input.role);
291
- const explicitTools = policy.tools;
292
- const excludeTools = policy.excludeTools;
293
-
294
- // §0c C6: agent.disableTools (Pi `--no-tools`) fully disables all tools. Used by
295
- // capability-locked agents (e.g. the goal-judge) that must have NO agency.
296
- // MUST come before any --tools/--exclude-tools so it wins (Pi applies last-wins).
297
- // An empty `tools:[]` is INSUFFICIENT because the length-check below skips it.
282
+ // D5 (spec v0.7 §6): default loadout = FULL session (như main session).
283
+ // --no-extensions/--no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit.
284
+ const CONTROL_TOOLS = ["ask", "delegate"] as const;
298
285
  if (input.agent.disableTools === true) {
299
- args.push("--no-tools");
286
+ args.push("--no-tools"); // capability-locked agents giữ hành vi (goal-judge)
300
287
  } else {
301
- if (explicitTools?.length) args.push("--tools", explicitTools.join(","));
302
- if (excludeTools?.length) args.push("--exclude-tools", excludeTools.join(","));
303
- }
304
- // Always add --no-extensions before --extension to prevent user extensions from being auto-loaded.
305
- // User extensions in ~/.pi/agent/extensions/ may fail due to missing dependencies.
306
- args.push("--no-extensions");
307
- if (input.agent.extensions !== undefined) {
308
- // F1 (v0.7.9): apply `excludeExtensions` denylist (case-insensitive
309
- // basename match) BEFORE the trusted PROMPT_RUNTIME_EXTENSION_PATH is
310
- // prepended. The prompt-runtime is a pi-crew internal and is never
311
- // excludable. Unknown names in the denylist are tolerated (logged
312
- // would be nice but this path is sync and minimal — keeping parity
313
- // with the rest of the agent loader's best-effort semantics).
314
- const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
315
- let allowed = input.agent.extensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
316
- // SEC-1: Defense-in-depth — deny untrusted project-sourced extensions
317
- // unless explicitly trusted via PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS=1.
318
- // Even if parseAgentFile missed the strip (e.g., agent was constructed
319
- // at runtime), buildPiWorkerArgs must never emit --extension
320
- // <attacker-path> for a project / project-pi agent. Both `project`
321
- // (.crew/agents/) and `project-pi` (.pi/agents/) are repo-adjacent /
322
- // untrusted sources. Only the trusted PROMPT_RUNTIME_EXTENSION_PATH is
323
- // allowed through.
324
- const extEnv = input.env ?? process.env;
325
- if (
326
- (input.agent.source === "project" || input.agent.source === "project-pi") &&
327
- extEnv.PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS !== "1"
328
- ) {
329
- allowed = allowed.filter((ext) => path.resolve(ext) === path.resolve(PROMPT_RUNTIME_EXTENSION_PATH));
288
+ // AgentConfig.tools is string[] (parseToolsField normalizes the
289
+ // frontmatter CSV), but inline/runtime-constructed agents may still
290
+ // carry the raw CSV string — normalize both.
291
+ const rawTools: unknown = input.agent.tools;
292
+ const declared =
293
+ typeof rawTools === "string"
294
+ ? rawTools
295
+ .split(",")
296
+ .map((t) => t.trim())
297
+ .filter(Boolean)
298
+ : Array.isArray(rawTools)
299
+ ? rawTools.map((t) => String(t).trim()).filter(Boolean)
300
+ : [];
301
+ if (declared.length > 0) {
302
+ const allow = new Set<string>([...declared, ...CONTROL_TOOLS]);
303
+ args.push("--tools", [...allow].join(","));
330
304
  }
331
- // ADR-5 §8 (governed nesting, T3/WP-5 step 8): every spawn this builder
332
- // produces runs at depth > 0 (workers are depth 1, delegate grandchildren
333
- // are 2+; currentCrewDepth(base)+1 is ALWAYS >= 1) — the extension list
334
- // is an unconditional ALLOWLIST: only the trusted
335
- // PROMPT_RUNTIME_EXTENSION_PATH passes, REGARDLESS OF SOURCE. User-sourced
336
- // agent declarations can no longer inject extensions into sub-agents
337
- // (SEC-1's project-only strip left user/builtin declarations unfiltered —
338
- // the audit's untested hole). The denylist + SEC-1 strip above remain as
339
- // defense-in-depth; the allowlist is authoritative.
340
- allowed = [];
341
- for (const extension of [PROMPT_RUNTIME_EXTENSION_PATH, ...allowed]) args.push("--extension", extension);
342
- } else {
343
- args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
305
+ // declared rỗng → KHÔNG truyền --tools (full default toolset — pattern
306
+ // buildSubagentToolAllowlist của pi-interactive-subagents index.ts:809-811).
307
+ // B2 (fix round 1): `disallowedTools:` frontmatter is a
308
+ // declaration-driven denylist (opt-in like `tools:`) — NOT the
309
+ // role-based policy D5 removed.
310
+ const disallowed = (input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean);
311
+ if (disallowed.length > 0) args.push("--exclude-tools", [...new Set(disallowed)].join(","));
312
+ }
313
+ // prompt-runtime extension luôn nạp (hạ tầng phối hợp — không phải cắt xén).
314
+ args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
315
+ // SEC-1 (fix round 1): DECLARED extensions from untrusted sources
316
+ // (project / project-pi / dynamic) are stripped unless the operator opts
317
+ // in via PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS=1. Auto-discovery stays
318
+ // open (D5) — this only filters explicit `extensions:` declarations.
319
+ const extEnv = input.env ?? process.env;
320
+ const untrustedSource = input.agent.source === "project" || input.agent.source === "project-pi" || input.agent.source === "dynamic";
321
+ let declaredExtensions = input.agent.extensions ?? [];
322
+ if (untrustedSource && extEnv.PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS !== "1") {
323
+ declaredExtensions = [];
344
324
  }
345
- if (!input.agent.inheritSkills) args.push("--no-skills");
325
+ // F1 (v0.7.9): excludeExtensions denylist (case-insensitive basename
326
+ // match) applies to the declared list AFTER the SEC-1 strip. The
327
+ // prompt-runtime is a pi-crew internal and is never excludable.
328
+ const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
329
+ declaredExtensions = declaredExtensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
330
+ for (const ext of declaredExtensions) args.push("--extension", ext);
331
+ // KHÔNG còn --no-extensions (extension discovery hoạt động như main session).
332
+ if (input.agent.inheritSkills === false) args.push("--no-skills");
346
333
  for (const skillPath of input.skillPaths ?? []) args.push("--skill", skillPath);
347
334
 
348
335
  let tempDir: string | undefined;
@@ -380,12 +367,14 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
380
367
  // main session (the lesson from an accidental `kill` of a live main session).
381
368
  PI_CREW_KIND: "subagent",
382
369
  PI_CREW_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
383
- PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
370
+ // B1 (fix round 1): match the argv `=== false` semantics — undefined
371
+ // inheritSkills means INHERIT (D5 default), not "0".
372
+ PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills === false ? "0" : "1",
384
373
  PI_CREW_DEPTH: String(parentDepth + 1),
385
374
  PI_CREW_MAX_DEPTH: String(maxDepth),
386
375
  PI_CREW_ROLE: input.agent.name,
387
376
  PI_TEAMS_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
388
- PI_TEAMS_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
377
+ PI_TEAMS_INHERIT_SKILLS: input.agent.inheritSkills === false ? "0" : "1",
389
378
  PI_TEAMS_DEPTH: String(parentDepth + 1),
390
379
  PI_TEAMS_MAX_DEPTH: String(maxDepth),
391
380
  PI_TEAMS_ROLE: input.agent.name,