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.
- package/CHANGELOG.md +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- 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/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -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/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 +13 -7
- 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 +261 -41
- 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 +353 -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 +4 -0
- 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 +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- 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/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 +47 -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 +101 -0
- 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/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- 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/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
|
@@ -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 {
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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,57 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
278
279
|
args.push("--thinking", effectiveThinking);
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ? "
|
|
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,
|