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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ? "
|
|
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
|
-
|
|
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
|
+
}
|