pi-crew 0.10.2 → 0.10.4

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 (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -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,62 @@ 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
+ // "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime
285
+ // behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it
286
+ // unconditionally), but pi's --tools allowlist filters the tool SURFACE — a
287
+ // control tool missing here is invisible to every frontmatter-pinned worker
288
+ // (battery 2026-09-10: message dead for all builtin roles since f843e14a).
289
+ const CONTROL_TOOLS = ["ask", "delegate", "message"] as const;
298
290
  if (input.agent.disableTools === true) {
299
- args.push("--no-tools");
291
+ args.push("--no-tools"); // capability-locked agents giữ hành vi (goal-judge)
300
292
  } 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));
293
+ // AgentConfig.tools is string[] (parseToolsField normalizes the
294
+ // frontmatter CSV), but inline/runtime-constructed agents may still
295
+ // carry the raw CSV string — normalize both.
296
+ const rawTools: unknown = input.agent.tools;
297
+ const declared =
298
+ typeof rawTools === "string"
299
+ ? rawTools
300
+ .split(",")
301
+ .map((t) => t.trim())
302
+ .filter(Boolean)
303
+ : Array.isArray(rawTools)
304
+ ? rawTools.map((t) => String(t).trim()).filter(Boolean)
305
+ : [];
306
+ if (declared.length > 0) {
307
+ const allow = new Set<string>([...declared, ...CONTROL_TOOLS]);
308
+ args.push("--tools", [...allow].join(","));
330
309
  }
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);
310
+ // declared rỗng → KHÔNG truyền --tools (full default toolset — pattern
311
+ // buildSubagentToolAllowlist của pi-interactive-subagents index.ts:809-811).
312
+ // B2 (fix round 1): `disallowedTools:` frontmatter is a
313
+ // declaration-driven denylist (opt-in like `tools:`) — NOT the
314
+ // role-based policy D5 removed.
315
+ const disallowed = (input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean);
316
+ if (disallowed.length > 0) args.push("--exclude-tools", [...new Set(disallowed)].join(","));
317
+ }
318
+ // prompt-runtime extension luôn nạp (hạ tầng phối hợp — không phải cắt xén).
319
+ args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
320
+ // SEC-1 (fix round 1): DECLARED extensions from untrusted sources
321
+ // (project / project-pi / dynamic) are stripped unless the operator opts
322
+ // in via PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS=1. Auto-discovery stays
323
+ // open (D5) — this only filters explicit `extensions:` declarations.
324
+ const extEnv = input.env ?? process.env;
325
+ const untrustedSource = input.agent.source === "project" || input.agent.source === "project-pi" || input.agent.source === "dynamic";
326
+ let declaredExtensions = input.agent.extensions ?? [];
327
+ if (untrustedSource && extEnv.PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS !== "1") {
328
+ declaredExtensions = [];
344
329
  }
345
- if (!input.agent.inheritSkills) args.push("--no-skills");
330
+ // F1 (v0.7.9): excludeExtensions denylist (case-insensitive basename
331
+ // match) applies to the declared list AFTER the SEC-1 strip. The
332
+ // prompt-runtime is a pi-crew internal and is never excludable.
333
+ const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
334
+ declaredExtensions = declaredExtensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
335
+ for (const ext of declaredExtensions) args.push("--extension", ext);
336
+ // KHÔNG còn --no-extensions (extension discovery hoạt động như main session).
337
+ if (input.agent.inheritSkills === false) args.push("--no-skills");
346
338
  for (const skillPath of input.skillPaths ?? []) args.push("--skill", skillPath);
347
339
 
348
340
  let tempDir: string | undefined;
@@ -380,12 +372,14 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
380
372
  // main session (the lesson from an accidental `kill` of a live main session).
381
373
  PI_CREW_KIND: "subagent",
382
374
  PI_CREW_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
383
- PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
375
+ // B1 (fix round 1): match the argv `=== false` semantics — undefined
376
+ // inheritSkills means INHERIT (D5 default), not "0".
377
+ PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills === false ? "0" : "1",
384
378
  PI_CREW_DEPTH: String(parentDepth + 1),
385
379
  PI_CREW_MAX_DEPTH: String(maxDepth),
386
380
  PI_CREW_ROLE: input.agent.name,
387
381
  PI_TEAMS_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
388
- PI_TEAMS_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
382
+ PI_TEAMS_INHERIT_SKILLS: input.agent.inheritSkills === false ? "0" : "1",
389
383
  PI_TEAMS_DEPTH: String(parentDepth + 1),
390
384
  PI_TEAMS_MAX_DEPTH: String(maxDepth),
391
385
  PI_TEAMS_ROLE: input.agent.name,
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { logInternalError } from "../../utils/internal-error.ts";
3
4
  import { redactSecrets } from "../../utils/redaction.ts";
4
5
  import { isSafePathId } from "../../utils/safe-paths.ts";
5
6
 
@@ -12,15 +13,69 @@ export interface SidechainEntry {
12
13
  cwd: string;
13
14
  }
14
15
 
16
+ // ── Batched JSONL writer (Task 26, 2026-08-24) ──────────────────────────
17
+ // PERF (2026-08-24): the old writeSidechainEntry did one mkdirSync +
18
+ // appendFileSync + full redaction clone PER STREAMING EVENT (message_update
19
+ // fires per chunk). Batch per path on a 50ms unref'd timer like
20
+ // child-pi-transcript; redaction + serialization still run per event at
21
+ // queue time, and the flush does ONE mkdir + append per path per window.
22
+ interface PendingJsonlBatch {
23
+ lines: string[];
24
+ timer: NodeJS.Timeout;
25
+ }
26
+ const pendingJsonlBatches = new Map<string, PendingJsonlBatch>();
27
+ const JSONL_FLUSH_MS = 50;
28
+
29
+ function queueJsonlLine(filePath: string, line: string): void {
30
+ const pending = pendingJsonlBatches.get(filePath);
31
+ if (pending) {
32
+ pending.lines.push(line);
33
+ return;
34
+ }
35
+ const timer = setTimeout(() => flushJsonlBatch(filePath), JSONL_FLUSH_MS);
36
+ timer.unref();
37
+ pendingJsonlBatches.set(filePath, { lines: [line], timer });
38
+ }
39
+
40
+ function flushJsonlBatch(filePath: string): void {
41
+ const pending = pendingJsonlBatches.get(filePath);
42
+ if (!pending) return;
43
+ pendingJsonlBatches.delete(filePath);
44
+ clearTimeout(pending.timer);
45
+ try {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ fs.appendFileSync(filePath, pending.lines.join(""), "utf-8");
48
+ } catch (error) {
49
+ logInternalError("sidechain-output.flush", error as Error, `path=${filePath}`);
50
+ }
51
+ }
52
+
15
53
  export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
16
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
- fs.appendFileSync(
18
- filePath,
19
- `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`,
20
- "utf-8",
21
- );
54
+ queueJsonlLine(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`);
22
55
  }
23
56
 
57
+ /**
58
+ * Append one pre-serialized JSONL line through the shared batched writer.
59
+ * Task 26: the live-session transcript append routes through the same
60
+ * per-path 50ms batching as sidechain output. child-pi-transcript's batched
61
+ * writer was NOT reused — it takes a ChildPiRunInput and enforces
62
+ * artifactsRoot containment + a second redaction pass (redactJsonLine over
63
+ * the already-redacted line), so it is not generic over plain paths.
64
+ */
65
+ export function appendBatchedJsonlLine(filePath: string, line: string): void {
66
+ queueJsonlLine(filePath, line);
67
+ }
68
+
69
+ /** @internal — flush on session teardown / process exit so files are complete. */
70
+ export function flushPendingSidechainWrites(): void {
71
+ for (const filePath of [...pendingJsonlBatches.keys()]) flushJsonlBatch(filePath);
72
+ }
73
+
74
+ // The batch timers are unref'd, so a naturally-draining event loop can exit
75
+ // with unflushed telemetry. Same exit-hook pattern as atomic-write.ts and
76
+ // crew-agent-records.ts (sync-only work, safe inside "exit").
77
+ process.on("exit", () => flushPendingSidechainWrites());
78
+
24
79
  export function sidechainOutputPath(stateRoot: string, taskId: string): string {
25
80
  if (!isSafePathId(taskId)) throw new Error(`Invalid taskId: ${taskId}`);
26
81
  return path.join(stateRoot, "agents", taskId, "sidechain.output.jsonl");
@@ -111,6 +111,8 @@ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialT
111
111
  : t,
112
112
  );
113
113
  cancelledTaskIds.push(task.id);
114
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
115
+ // sweep tests read plan.item.dropped events synchronously.
114
116
  appendEvent(fresh.manifest.eventsPath, {
115
117
  type: "plan.item.dropped",
116
118
  runId,
@@ -127,6 +129,7 @@ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialT
127
129
  if (!appendSteeringAdvisory(fresh.manifest, task.id)) continue;
128
130
  tasks = tasks.map((t) => (t.id === task.id ? { ...t, replanDroppedAt: new Date().toISOString() } : t));
129
131
  advisedTaskIds.push(task.id);
132
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
130
133
  appendEvent(fresh.manifest.eventsPath, {
131
134
  type: "plan.item.dropped",
132
135
  runId,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * proc-stat.ts — THE single `/proc/<pid>/stat` field parser.
3
+ *
4
+ * Three consumers previously hand-rolled the same paren-aware split (the
5
+ * surface spawn's parent starttime capture, the worker-side parent-guard, and
6
+ * the zombie scanner's ppid/starttime read). A divergence between them is not
7
+ * cosmetic: the spawn writes `PI_CREW_PARENT_START_TIME` from field 22, and
8
+ * the worker kills itself when that value stops matching — so both sides MUST
9
+ * index identically. comm (field 2) may contain spaces AND ')' characters,
10
+ * hence everything up to the LAST ")" is skipped, never a naive whitespace
11
+ * split.
12
+ *
13
+ * Layout after the comm field: rest[0] = state, rest[1] = ppid, …
14
+ * rest[STARTTIME_INDEX] = starttime (field 22, clock ticks since boot).
15
+ */
16
+
17
+ /** Field 22 (starttime) minus the two consumed by the comm cut = index 19. */
18
+ export const PROC_STAT_STARTTIME_INDEX = 19;
19
+ /** Field 3 (state) → first entry after the comm cut. */
20
+ export const PROC_STAT_STATE_INDEX = 0;
21
+ /** Field 4 (ppid) → second entry after the comm cut. */
22
+ export const PROC_STAT_PPID_INDEX = 1;
23
+
24
+ /**
25
+ * Fields of a `/proc/<pid>/stat` line AFTER the comm column (paren-aware).
26
+ * Returns undefined when the buffer has no closing paren at all.
27
+ */
28
+ export function fieldsAfterComm(stat: string): string[] | undefined {
29
+ const lastParen = stat.lastIndexOf(")");
30
+ if (lastParen === -1) return undefined;
31
+ return stat
32
+ .slice(lastParen + 1)
33
+ .trim()
34
+ .split(/\s+/);
35
+ }
36
+
37
+ /**
38
+ * Raw starttime ticks (field 22) as a string, for byte-exact comparison with
39
+ * the value the spawner recorded. undefined when absent/unparseable.
40
+ */
41
+ export function procStartTimeTicks(stat: string): string | undefined {
42
+ const fields = fieldsAfterComm(stat);
43
+ if (!fields) return undefined;
44
+ const raw = fields[PROC_STAT_STARTTIME_INDEX];
45
+ return raw && Number.isFinite(Number(raw)) ? raw : undefined;
46
+ }