pi-crew 0.6.0 → 0.6.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 +225 -0
- package/README.md +70 -31
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/contracts.ts +2 -1
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ExtensionAPI,
|
|
6
6
|
ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { loadConfig } from "../config/config.ts";
|
|
8
|
+
import { asRecord, loadConfig } from "../config/config.ts";
|
|
9
9
|
import { applyCrewSettingsToConfig, loadCrewSettings } from "../runtime/settings-store.ts";
|
|
10
10
|
// 2.7: Lazy-load LiveRunSidebar — only constructed when the user actually opens
|
|
11
11
|
// a live run sidebar overlay. The class pulls in transcript-viewer and other
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { registerAutonomousPolicy } from "./autonomous-policy.ts";
|
|
20
20
|
import { registerCleanupHandler } from "./crew-cleanup.ts";
|
|
21
21
|
import type { ScheduledJob } from "../runtime/scheduler.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { clearHooksScoped } from "../hooks/registry.ts";
|
|
23
23
|
import { uninstallCrewGlobalRegistry } from "./team-tool.ts";
|
|
24
24
|
import { notifyActiveRuns } from "./session-summary.ts";
|
|
25
25
|
|
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
projectCrewRoot,
|
|
88
88
|
userCrewRoot,
|
|
89
89
|
} from "../utils/paths.ts";
|
|
90
|
+
import { resolveContainedPath } from "../utils/safe-paths.ts";
|
|
90
91
|
import { resetTimings, time } from "../utils/timings.ts";
|
|
91
92
|
import {
|
|
92
93
|
type PiCrewRpcHandle,
|
|
@@ -108,6 +109,7 @@ import {
|
|
|
108
109
|
import { registerSubagentTools } from "./registration/subagent-tools.ts";
|
|
109
110
|
import { registerTeamTool } from "./registration/team-tool.ts";
|
|
110
111
|
import { handleTeamTool } from "./team-tool.ts";
|
|
112
|
+
import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
|
|
111
113
|
|
|
112
114
|
let _cachedOTLPExporter: typeof OTLPExporterType | undefined;
|
|
113
115
|
async function importOTLPExporter(): Promise<typeof OTLPExporterType> {
|
|
@@ -132,6 +134,8 @@ import {
|
|
|
132
134
|
} from "../runtime/crash-recovery.ts";
|
|
133
135
|
import { appendDeadletter } from "../runtime/deadletter.ts";
|
|
134
136
|
import { HeartbeatWatcher } from "../runtime/heartbeat-watcher.ts";
|
|
137
|
+
import { cleanupOrphanTempDirs, cleanupLegacyOrphanTempDirs } from "../runtime/pi-args.ts";
|
|
138
|
+
import { cleanupOrphanWorkers } from "../runtime/orphan-worker-registry.ts";
|
|
135
139
|
import { reconcileOrphanedTempWorkspaces } from "../runtime/stale-reconciler.ts";
|
|
136
140
|
|
|
137
141
|
let _cachedCrashRecovery:
|
|
@@ -183,10 +187,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
183
187
|
const disposeI18n = initI18n(pi);
|
|
184
188
|
resetTimings();
|
|
185
189
|
time("register:start");
|
|
186
|
-
const globalStore = globalThis as Record<string, unknown>;
|
|
187
|
-
const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
|
|
190
|
+
const globalStore = globalThis as Record<string | symbol, unknown>;
|
|
191
|
+
const runtimeCleanupStoreKey = Symbol("__piCrewRuntimeCleanup");
|
|
188
192
|
const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
|
|
189
193
|
time("register:init");
|
|
194
|
+
// Best-effort cleanup of the previous runtime instance. Errors are logged but
|
|
195
|
+
// do not halt new registration — a failing cleanup from a prior instance is
|
|
196
|
+
// preferable to leaving pi-crew unregistered, and any stale state from the
|
|
197
|
+
// previous instance will be reconciled when the new instance initializes.
|
|
190
198
|
if (typeof previousRuntimeCleanup === "function") {
|
|
191
199
|
try {
|
|
192
200
|
previousRuntimeCleanup();
|
|
@@ -443,6 +451,34 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
443
451
|
cleanupOrphanedTempDirs:
|
|
444
452
|
config.reliability?.cleanupOrphanedTempDirs,
|
|
445
453
|
});
|
|
454
|
+
// Layer 4: also clean orphan temp dirs under
|
|
455
|
+
// ~/.pi/agent/pi-crew/tmp/ that the SIGKILL'd parent
|
|
456
|
+
// processes left behind. Catches anything Layers 1-3 missed.
|
|
457
|
+
const orphanResult = cleanupOrphanTempDirs();
|
|
458
|
+
if (orphanResult.cleaned > 0) {
|
|
459
|
+
notifyOperator({
|
|
460
|
+
id: `layer4_temp_cleanup_${Date.now()}`,
|
|
461
|
+
severity: "info",
|
|
462
|
+
source: "temp-cleanup",
|
|
463
|
+
title: `Layer 4: cleaned ${orphanResult.cleaned} orphan temp dir(s)`,
|
|
464
|
+
body: `~/.pi/agent/pi-crew/tmp/ orphans older than 24h removed (scanned ${orphanResult.scanned}, failed ${orphanResult.failed}).`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
// Layer 5: clean legacy /tmp/pi-crew-* prompt/task orphans
|
|
468
|
+
// from before commit 8ba270d moved temp dirs out of /tmp.
|
|
469
|
+
// The existing reconcileOrphanedTempWorkspaces only cleans
|
|
470
|
+
// dirs containing .crew/state/runs/ (run-state dirs), so
|
|
471
|
+
// prompt/task orphans are never touched by Layer 3.
|
|
472
|
+
const legacyResult = cleanupLegacyOrphanTempDirs();
|
|
473
|
+
if (legacyResult.cleaned > 0) {
|
|
474
|
+
notifyOperator({
|
|
475
|
+
id: `layer5_legacy_temp_cleanup_${Date.now()}`,
|
|
476
|
+
severity: "info",
|
|
477
|
+
source: "temp-cleanup",
|
|
478
|
+
title: `Layer 5: cleaned ${legacyResult.cleaned} legacy /tmp/pi-crew-* orphan(s)`,
|
|
479
|
+
body: `Pre-fix /tmp/pi-crew-* prompt/task orphans (no .crew/state/runs/, >24h) removed (scanned ${legacyResult.scanned}, failed ${legacyResult.failed}).`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
446
482
|
} catch (error) {
|
|
447
483
|
logInternalError("register.tempAutoRepair", error);
|
|
448
484
|
}
|
|
@@ -478,13 +514,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
478
514
|
);
|
|
479
515
|
}
|
|
480
516
|
};
|
|
481
|
-
const autoRecoveryLast = new Map<string, number>();
|
|
517
|
+
const autoRecoveryLast = new Map<string, { insertedAt: number; lastAccessAt: number }>();
|
|
482
518
|
// FIX (Round 22, defensive cap): Bound the cooldown-gate Map. Each run
|
|
483
519
|
// contributes up to 4 keys (one per maybeNotifyHealth kind). Without a cap,
|
|
484
520
|
// a long-running pi session that runs thousands of teams accumulates
|
|
485
|
-
// thousands of entries. Eviction: oldest
|
|
486
|
-
//
|
|
487
|
-
//
|
|
521
|
+
// thousands of entries. Eviction: oldest lastAccessAt first — uses LRU-like
|
|
522
|
+
// semantics so entries that are still being actively accessed (re-accessed
|
|
523
|
+
// before eviction) survive longer. This is fairer than insertion-order
|
|
524
|
+
// eviction under high churn where many entries expire naturally before
|
|
525
|
+
// being re-accessed.
|
|
488
526
|
const AUTO_RECOVERY_LAST_MAX_ENTRIES = 1000;
|
|
489
527
|
const configureDeliveryCoordinator = (): void => {
|
|
490
528
|
deliveryCoordinator?.dispose();
|
|
@@ -812,8 +850,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
812
850
|
.then(({ startForegroundWatchdog }) => {
|
|
813
851
|
startForegroundWatchdog({ pi, cwd: ctx.cwd, runId });
|
|
814
852
|
})
|
|
815
|
-
.catch(() => {
|
|
816
|
-
|
|
853
|
+
.catch((error) => {
|
|
854
|
+
logInternalError("register.foreground-watchdog-import", error);
|
|
817
855
|
});
|
|
818
856
|
}
|
|
819
857
|
setImmediate(() => {
|
|
@@ -823,7 +861,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
823
861
|
error instanceof Error ? error.message : String(error);
|
|
824
862
|
if (runId) {
|
|
825
863
|
try {
|
|
826
|
-
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
864
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency. Post-run status updates tolerate slight staleness.
|
|
827
865
|
if (
|
|
828
866
|
loaded &&
|
|
829
867
|
loaded.manifest.status !== "completed" &&
|
|
@@ -877,7 +915,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
877
915
|
}
|
|
878
916
|
}
|
|
879
917
|
if (ownerCurrent && runId) {
|
|
880
|
-
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
918
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency. Post-run status updates tolerate slight staleness.
|
|
881
919
|
const status = loaded?.manifest.status ?? "finished";
|
|
882
920
|
const level =
|
|
883
921
|
status === "failed" || status === "blocked"
|
|
@@ -971,9 +1009,25 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
971
1009
|
const registry = (globalThis as Record<symbol | string, unknown>)[
|
|
972
1010
|
CREW_REGISTRY_KEY
|
|
973
1011
|
] as Record<string, unknown>;
|
|
974
|
-
|
|
1012
|
+
// Phase 3b (defensive): Validate registry structure before patching methods.
|
|
1013
|
+
// If a previous occupant left a non-conforming object, replace it entirely.
|
|
1014
|
+
// This prevents runtime failures if getRecord/listRuns/etc. are called on a
|
|
1015
|
+
// malformed predecessor value.
|
|
1016
|
+
if (
|
|
1017
|
+
registry === null ||
|
|
1018
|
+
typeof registry !== "object" ||
|
|
1019
|
+
Array.isArray(registry)
|
|
1020
|
+
) {
|
|
1021
|
+
(globalThis as Record<symbol | string, unknown>)[
|
|
1022
|
+
CREW_REGISTRY_KEY
|
|
1023
|
+
] = {};
|
|
1024
|
+
}
|
|
1025
|
+
const validatedRegistry = (globalThis as Record<symbol | string, unknown>)[
|
|
1026
|
+
CREW_REGISTRY_KEY
|
|
1027
|
+
] as Record<string, unknown>;
|
|
1028
|
+
validatedRegistry.getRecord = (runId: string) =>
|
|
975
1029
|
manifestCacheForRegistry.get(runId);
|
|
976
|
-
|
|
1030
|
+
validatedRegistry.listRuns = () =>
|
|
977
1031
|
manifestCacheForRegistry
|
|
978
1032
|
.list(100)
|
|
979
1033
|
.map(
|
|
@@ -987,7 +1041,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
987
1041
|
goal: m.goal,
|
|
988
1042
|
}),
|
|
989
1043
|
);
|
|
990
|
-
|
|
1044
|
+
validatedRegistry.appendEvent = (
|
|
991
1045
|
runId: string,
|
|
992
1046
|
event: Record<string, unknown>,
|
|
993
1047
|
) => {
|
|
@@ -1003,7 +1057,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1003
1057
|
),
|
|
1004
1058
|
);
|
|
1005
1059
|
};
|
|
1006
|
-
|
|
1060
|
+
validatedRegistry.waitForAll = async (runId: string) => {
|
|
1007
1061
|
// LAZY: state-store only needed for post-completion polling (waitForAll) and sync hasRunning check; avoid at startup.
|
|
1008
1062
|
const { loadRunManifestById } = await import(
|
|
1009
1063
|
"../state/state-store.ts"
|
|
@@ -1022,7 +1076,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1022
1076
|
while (!check())
|
|
1023
1077
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1024
1078
|
};
|
|
1025
|
-
|
|
1079
|
+
validatedRegistry.hasRunning = async (runId: string) => {
|
|
1026
1080
|
const manifest = manifestCacheForRegistry.get(runId);
|
|
1027
1081
|
if (!manifest) return false;
|
|
1028
1082
|
// LAZY: state-store only needed in hasRunning; avoid at startup.
|
|
@@ -1112,7 +1166,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1112
1166
|
otlpExporter = undefined;
|
|
1113
1167
|
metricRegistry = undefined;
|
|
1114
1168
|
deliveryCoordinator?.dispose();
|
|
1115
|
-
|
|
1169
|
+
clearHooksScoped();
|
|
1116
1170
|
uninstallCrewGlobalRegistry();
|
|
1117
1171
|
overflowTracker?.dispose();
|
|
1118
1172
|
deliveryCoordinator = undefined;
|
|
@@ -1150,10 +1204,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1150
1204
|
notifyActiveRuns(ctx);
|
|
1151
1205
|
|
|
1152
1206
|
// Auto-cancel orphaned runs from dead sessions
|
|
1153
|
-
// Extract sessionId from context —
|
|
1207
|
+
// Extract sessionId from context — use Object.getOwnPropertyDescriptor
|
|
1208
|
+
// to safely access property without triggering Proxy traps, then validate.
|
|
1154
1209
|
const rawSessionId =
|
|
1155
|
-
typeof ctx === "object" && ctx !== null
|
|
1156
|
-
? (ctx
|
|
1210
|
+
typeof ctx === "object" && ctx !== null
|
|
1211
|
+
? Object.getOwnPropertyDescriptor(ctx, "sessionId")?.value
|
|
1157
1212
|
: undefined;
|
|
1158
1213
|
const currentSessionId =
|
|
1159
1214
|
typeof rawSessionId === "string" && rawSessionId.length > 0
|
|
@@ -1219,6 +1274,54 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1219
1274
|
}
|
|
1220
1275
|
}
|
|
1221
1276
|
|
|
1277
|
+
// Startup cleanup (Fix A): run orphan-temp-dir cleanup
|
|
1278
|
+
// immediately on session_start so we don't wait 5 minutes
|
|
1279
|
+
// for the first timer tick. Especially important after
|
|
1280
|
+
// a SIGKILL'd previous session that left thousands of
|
|
1281
|
+
// orphan temp dirs behind.
|
|
1282
|
+
try {
|
|
1283
|
+
const orphanTmp = cleanupOrphanTempDirs();
|
|
1284
|
+
const legacyTmp = cleanupLegacyOrphanTempDirs();
|
|
1285
|
+
if (orphanTmp.cleaned > 0 || legacyTmp.cleaned > 0) {
|
|
1286
|
+
notifyOperator({
|
|
1287
|
+
id: `startup_temp_cleanup_${Date.now()}`,
|
|
1288
|
+
severity: "info",
|
|
1289
|
+
source: "temp-cleanup",
|
|
1290
|
+
title: `Startup cleanup: removed ${orphanTmp.cleaned + legacyTmp.cleaned} orphan temp dir(s)`,
|
|
1291
|
+
body: `${orphanTmp.cleaned} from ~/.pi/agent/pi-crew/tmp/ + ${legacyTmp.cleaned} legacy /tmp/pi-crew-*`,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
logInternalError(
|
|
1296
|
+
"register.sessionStart.startupTempCleanup",
|
|
1297
|
+
error,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Orphan worker cleanup (Fix B): kill stale background-runner
|
|
1302
|
+
// processes from previous (SIGKILL'd) sessions. Workers
|
|
1303
|
+
// detached via setsid+unref outlive the spawning pi
|
|
1304
|
+
// session, and the per-worker parent-guard is intentionally
|
|
1305
|
+
// disabled for background-runner (BUG #17 design). So
|
|
1306
|
+
// orphans can only be cleaned from the next session_start.
|
|
1307
|
+
try {
|
|
1308
|
+
const orphanWorkers = cleanupOrphanWorkers(currentSessionId);
|
|
1309
|
+
if (orphanWorkers.killed > 0) {
|
|
1310
|
+
notifyOperator({
|
|
1311
|
+
id: `orphan_workers_cleanup`,
|
|
1312
|
+
severity: "info",
|
|
1313
|
+
source: "worker-cleanup",
|
|
1314
|
+
title: `Cleaned up ${orphanWorkers.killed} orphan worker(s)`,
|
|
1315
|
+
body: `Background workers from previous (SIGKILL'd) sessions were terminated (pruned ${orphanWorkers.pruned} dead, kept ${orphanWorkers.kept}).`,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
logInternalError(
|
|
1320
|
+
"register.sessionStart.orphanWorkers",
|
|
1321
|
+
error,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1222
1325
|
// Global purge of stale active-run-index entries
|
|
1223
1326
|
try {
|
|
1224
1327
|
const { purged } = purgeStaleActiveRunIndexFn();
|
|
@@ -1324,19 +1427,60 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1324
1427
|
runParams = { action: "run", team: "default", goal: job.prompt };
|
|
1325
1428
|
}
|
|
1326
1429
|
if (runParams.action !== "run") return `scheduled-${job.id}-${Date.now()}`;
|
|
1430
|
+
const agentId = `scheduled-${job.id}-${Date.now()}`;
|
|
1327
1431
|
setImmediate(async () => {
|
|
1328
1432
|
try {
|
|
1329
|
-
await handleTeamTool(
|
|
1433
|
+
const runResult = await handleTeamTool(
|
|
1330
1434
|
{ action: "run", team: runParams.team, goal: runParams.goal, async: true },
|
|
1331
1435
|
{ cwd: ctx.cwd, sessionId },
|
|
1332
1436
|
);
|
|
1437
|
+
// Track the runId so remove() can cancel spawned runs
|
|
1438
|
+
const runId = runResult?.details?.runId;
|
|
1439
|
+
if (runId && typeof runId === "string") {
|
|
1440
|
+
crewScheduler?.recordSpawnedRun(job.id, runId);
|
|
1441
|
+
// Update run manifest with scheduler provenance for traceability
|
|
1442
|
+
try {
|
|
1443
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1444
|
+
const loaded = loadRunManifestById(cwd, runId);
|
|
1445
|
+
if (loaded) {
|
|
1446
|
+
const { atomicWriteJson } = await import("../state/atomic-write.ts");
|
|
1447
|
+
atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
|
|
1448
|
+
...loaded.manifest,
|
|
1449
|
+
schedulerJobId: job.id,
|
|
1450
|
+
schedulerName: job.name,
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
} catch { /* best-effort provenance tracking */ }
|
|
1454
|
+
}
|
|
1455
|
+
// Persist updated job with spawnedRunIds to settings
|
|
1456
|
+
try {
|
|
1457
|
+
const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
|
|
1458
|
+
if (updatedJob) persistScheduledJobUpdate(ctx.cwd, updatedJob);
|
|
1459
|
+
} catch { /* best-effort */ }
|
|
1460
|
+
// Update run count
|
|
1461
|
+
crewScheduler?.update(job.id, {
|
|
1462
|
+
runCount: job.runCount + 1,
|
|
1463
|
+
lastRun: new Date().toISOString(),
|
|
1464
|
+
lastStatus: "success",
|
|
1465
|
+
});
|
|
1333
1466
|
} catch (err) {
|
|
1334
1467
|
logInternalError("scheduler.execute", err);
|
|
1468
|
+
crewScheduler?.update(job.id, { lastStatus: "error" });
|
|
1335
1469
|
}
|
|
1336
1470
|
});
|
|
1337
|
-
return
|
|
1471
|
+
return agentId;
|
|
1338
1472
|
},
|
|
1339
1473
|
finalizer: () => {},
|
|
1474
|
+
runCancelFn: (runId: string) => {
|
|
1475
|
+
try {
|
|
1476
|
+
handleTeamTool(
|
|
1477
|
+
{ action: "cancel", runId, confirm: true },
|
|
1478
|
+
{ cwd: ctx.cwd, sessionId },
|
|
1479
|
+
).catch((err) => logInternalError("scheduler.runCancelFn", err, `runId=${runId}`));
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
logInternalError("scheduler.runCancelFn.sync", err, `runId=${runId}`);
|
|
1482
|
+
}
|
|
1483
|
+
},
|
|
1340
1484
|
});
|
|
1341
1485
|
// Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
|
|
1342
1486
|
// Uses a global symbol so the module doesn't need a direct circular import.
|
|
@@ -1534,18 +1678,26 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1534
1678
|
const previous = autoRecoveryLast.get(key);
|
|
1535
1679
|
if (
|
|
1536
1680
|
previous !== undefined &&
|
|
1537
|
-
now - previous < 5 * 60_000
|
|
1681
|
+
now - previous.lastAccessAt < 5 * 60_000
|
|
1538
1682
|
)
|
|
1539
1683
|
return;
|
|
1540
|
-
// Defensive cap: evict oldest
|
|
1541
|
-
// when size exceeds the limit.
|
|
1542
|
-
//
|
|
1684
|
+
// Defensive cap: evict entry with oldest lastAccessAt before
|
|
1685
|
+
// inserting/updating when size exceeds the limit. Uses LRU
|
|
1686
|
+
// semantics so entries that are still being actively
|
|
1687
|
+
// accessed survive longer than insertion-order eviction.
|
|
1543
1688
|
while (autoRecoveryLast.size >= AUTO_RECOVERY_LAST_MAX_ENTRIES) {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
autoRecoveryLast
|
|
1689
|
+
let oldestKey: string | undefined;
|
|
1690
|
+
let oldestAccess = Infinity;
|
|
1691
|
+
for (const [k, v] of autoRecoveryLast) {
|
|
1692
|
+
if (v.lastAccessAt < oldestAccess) {
|
|
1693
|
+
oldestAccess = v.lastAccessAt;
|
|
1694
|
+
oldestKey = k;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (oldestKey === undefined) break;
|
|
1698
|
+
autoRecoveryLast.delete(oldestKey);
|
|
1547
1699
|
}
|
|
1548
|
-
autoRecoveryLast.set(key, now);
|
|
1700
|
+
autoRecoveryLast.set(key, { insertedAt: now, lastAccessAt: now });
|
|
1549
1701
|
notifyOperator({
|
|
1550
1702
|
id: key,
|
|
1551
1703
|
severity: "warning",
|
|
@@ -1752,8 +1904,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1752
1904
|
);
|
|
1753
1905
|
const paths: string[] = [];
|
|
1754
1906
|
if (fs.existsSync(extSkillDir)) paths.push(extSkillDir);
|
|
1755
|
-
if (skillDir !== extSkillDir && fs.existsSync(skillDir))
|
|
1756
|
-
|
|
1907
|
+
if (skillDir !== extSkillDir && fs.existsSync(skillDir)) {
|
|
1908
|
+
// Validate skillDir is within sessionCwd to prevent path traversal
|
|
1909
|
+
try {
|
|
1910
|
+
resolveContainedPath(sessionCwd, "skills");
|
|
1911
|
+
paths.push(skillDir);
|
|
1912
|
+
} catch {
|
|
1913
|
+
// skillDir outside sessionCwd boundary — skip
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1757
1916
|
return paths.length > 0 ? { skillPaths: paths } : {};
|
|
1758
1917
|
});
|
|
1759
1918
|
} catch {
|
|
@@ -1777,7 +1936,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1777
1936
|
if (event.toolName !== "team") return;
|
|
1778
1937
|
const rawInput = event.input;
|
|
1779
1938
|
if (!rawInput || typeof rawInput !== "object") return;
|
|
1780
|
-
const input = rawInput
|
|
1939
|
+
const input = asRecord(rawInput);
|
|
1940
|
+
if (!input) return;
|
|
1781
1941
|
const action = typeof input.action === "string" ? input.action : undefined;
|
|
1782
1942
|
const destructiveActions = new Set(["delete", "forget", "prune", "cleanup"]);
|
|
1783
1943
|
if (!action || !destructiveActions.has(action)) return;
|
|
@@ -154,7 +154,7 @@ function teamCommandContext(ctx: ExtensionCommandContext): ExtensionCommandConte
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
|
|
157
|
-
const loaded = loadRunManifestById(ctx.cwd, selection.runId);
|
|
157
|
+
const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
158
158
|
if (!loaded) {
|
|
159
159
|
depsNotify(ctx, `Run '${selection.runId}' not found.`, "error");
|
|
160
160
|
return;
|
|
@@ -379,7 +379,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
379
379
|
pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
380
380
|
const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
381
381
|
const selected = await selectAgentTask(ctx, runId, rawTaskId);
|
|
382
|
-
const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined;
|
|
382
|
+
const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
383
383
|
if (ctx.hasUI && loaded) {
|
|
384
384
|
const agent = readCrewAgents(loaded.manifest).find((item) => item.taskId === selected?.taskId || item.id === selected?.taskId) ?? readCrewAgents(loaded.manifest)[0];
|
|
385
385
|
const resultText = agent?.resultArtifactPath ? commandText(await handleTeamTool({ action: "api", runId: selected?.runId ?? "", config: { operation: "read-agent-output", agentId: agent.taskId, maxBytes: 64_000 } }, teamCommandContext(ctx))) : "(no result)";
|
|
@@ -428,7 +428,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
428
428
|
if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
|
|
429
429
|
if (selection.action === "agent-live" && await openLiveConversation(ctx, selection.runId)) continue;
|
|
430
430
|
if (selection.action === "agent-live") { await notifyCommandResult(ctx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
|
|
431
|
-
const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) :
|
|
431
|
+
const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
432
|
+
await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
|
|
432
433
|
await notifyCommandResult(ctx, commandText(result));
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
@@ -34,7 +34,7 @@ export function sendAgentWakeUp(pi: ExtensionAPI, content: string): boolean {
|
|
|
34
34
|
|
|
35
35
|
export function refreshPersistedSubagentRecord(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): SubagentRecord {
|
|
36
36
|
if (!record.runId) return record;
|
|
37
|
-
const loaded = loadRunManifestById(ctx.cwd, record.runId);
|
|
37
|
+
const loaded = loadRunManifestById(ctx.cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
38
38
|
if (!loaded) return record;
|
|
39
39
|
if (loaded.manifest.status === "completed" || loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled" || loaded.manifest.status === "blocked") {
|
|
40
40
|
const refreshed = {
|
|
@@ -65,7 +65,7 @@ export function formatSubagentRecord(record: SubagentRecord): string {
|
|
|
65
65
|
|
|
66
66
|
export function readSubagentRunResult(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): string | undefined {
|
|
67
67
|
if (!record.runId) return record.result;
|
|
68
|
-
const loaded = loadRunManifestById(ctx.cwd, record.runId);
|
|
68
|
+
const loaded = loadRunManifestById(ctx.cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
69
69
|
const task = loaded?.tasks.find((item) => item.resultArtifact) ?? loaded?.tasks[0];
|
|
70
70
|
const artifactPath = task?.resultArtifact?.path;
|
|
71
71
|
if (!artifactPath || !loaded) return undefined;
|
|
@@ -96,9 +96,11 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
|
|
|
96
96
|
}
|
|
97
97
|
return foregroundResult;
|
|
98
98
|
},
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
100
|
renderCall(args: any, theme: any, context: any): any {
|
|
100
101
|
return renderAgentToolCall(args, theme, context);
|
|
101
102
|
},
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
104
|
renderResult(result: any, options: any, theme: any, context: any): any {
|
|
103
105
|
return renderAgentToolResult(result, options, theme, context);
|
|
104
106
|
},
|
|
@@ -202,7 +204,7 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
|
|
|
202
204
|
let tasks;
|
|
203
205
|
let agents;
|
|
204
206
|
if (record.runId) {
|
|
205
|
-
const loaded = loadRunManifestById(cwd, record.runId);
|
|
207
|
+
const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
206
208
|
if (loaded) {
|
|
207
209
|
manifest = loaded.manifest;
|
|
208
210
|
tasks = loaded.tasks;
|
|
@@ -105,9 +105,11 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
|
|
|
105
105
|
stopProgress.stop();
|
|
106
106
|
}
|
|
107
107
|
},
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
109
|
renderCall(args: any, theme: any, context: any): any {
|
|
109
110
|
return renderTeamToolCall(args, theme, context);
|
|
110
111
|
},
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
111
113
|
renderResult(result: any, options: any, theme: any, context: any): any {
|
|
112
114
|
return renderTeamToolResult(result, options, theme, context);
|
|
113
115
|
},
|
|
@@ -134,7 +136,7 @@ function startTeamToolProgressBinder(onUpdate: OnUpdate | undefined): TeamToolPr
|
|
|
134
136
|
onUpdate({ content: [{ type: "text", text: `team status=starting elapsed=${elapsed}s` }] });
|
|
135
137
|
return;
|
|
136
138
|
}
|
|
137
|
-
const loaded = loadRunManifestById(cwd, runId);
|
|
139
|
+
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
138
140
|
if (!loaded) {
|
|
139
141
|
const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
140
142
|
onUpdate({ content: [{ type: "text", text: `team run=${runId} elapsed=${elapsed}s (manifest pending)` }] });
|
|
@@ -23,7 +23,7 @@ async function getViewer(): Promise<typeof DurableTranscriptViewerType> {
|
|
|
23
23
|
export async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
|
|
24
24
|
if (!runId) return undefined;
|
|
25
25
|
if (taskId) return { runId, taskId };
|
|
26
|
-
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
26
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
27
27
|
if (!loaded) return { runId };
|
|
28
28
|
const agents = readCrewAgents(loaded.manifest);
|
|
29
29
|
if (ctx.hasUI && agents.length > 1) {
|
|
@@ -39,7 +39,7 @@ export async function openTranscriptViewer(ctx: ExtensionCommandContext, initial
|
|
|
39
39
|
const runId = selected.runId;
|
|
40
40
|
const taskId = selected.taskId;
|
|
41
41
|
if (!runId || !ctx.hasUI) return false;
|
|
42
|
-
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
42
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
43
43
|
if (!loaded) return false;
|
|
44
44
|
const uiConfig = loadConfig(ctx.cwd).config.ui;
|
|
45
45
|
const DurableTranscriptViewer = await getViewer();
|
|
@@ -58,6 +58,7 @@ export async function openLiveConversation(ctx: ExtensionCommandContext, initial
|
|
|
58
58
|
const handle = liveAgents.find((h) => h.runId === selected.runId && (selected.taskId ? h.taskId === selected.taskId : true));
|
|
59
59
|
if (!handle) return false;
|
|
60
60
|
const theme = asCrewTheme({});
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
62
|
await ctx.ui.custom<undefined>((tui: any, _theme: any, _keybindings: any, done: (result: undefined) => void) => {
|
|
62
63
|
const columns = tui?.terminal?.columns ?? 80;
|
|
63
64
|
const rows = tui?.terminal?.rows ?? 24;
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
|
+
import * as crypto from "node:crypto";
|
|
4
5
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
5
6
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
6
7
|
import { readEvents, type TeamEvent } from "../state/event-log.ts";
|
|
7
8
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
8
9
|
|
|
9
10
|
/** Replace absolute paths containing home directory with ~/ */
|
|
11
|
+
/** Escape special regex characters in a string */
|
|
12
|
+
function escapeRegex(str: string): string {
|
|
13
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only redact home directory at path boundaries to avoid corrupting substrings */
|
|
17
|
+
function redactHomePathInString(str: string, home: string): string {
|
|
18
|
+
return str.replace(new RegExp(`(^|(?<=[:=/]))${escapeRegex(home)}`, "g"), "$1~");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Replace absolute paths containing home directory with ~/ at path boundaries only */
|
|
10
22
|
function redactHomePaths<T>(obj: T): T {
|
|
11
23
|
const home = os.homedir();
|
|
12
24
|
if (!home) return redactSecrets(obj) as T;
|
|
13
25
|
const json = JSON.stringify(obj);
|
|
14
|
-
const safe = json
|
|
26
|
+
const safe = redactHomePathInString(json, home);
|
|
15
27
|
return redactSecrets(JSON.parse(safe)) as T;
|
|
16
28
|
}
|
|
17
29
|
|
|
@@ -37,6 +49,9 @@ export function exportRunBundle(manifest: TeamRunManifest, tasks: TeamTaskState[
|
|
|
37
49
|
events: safeEvents as TeamEvent[],
|
|
38
50
|
artifactPaths: safeManifest.artifacts.map((artifact) => artifact.path),
|
|
39
51
|
};
|
|
52
|
+
// Compute SHA-256 integrity hash of the bundle and store in manifest
|
|
53
|
+
const sha256 = crypto.createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
|
|
54
|
+
(bundle.manifest as unknown as Record<string, unknown>).sha256 = sha256;
|
|
40
55
|
const json = writeArtifact(manifest.artifactsRoot, {
|
|
41
56
|
kind: "metadata",
|
|
42
57
|
relativePath: "export/run-export.json",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
3
4
|
import { assertRunBundle } from "./run-bundle-schema.ts";
|
|
4
5
|
import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
5
6
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
@@ -42,6 +43,21 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
|
|
|
42
43
|
if (!isContained) throw new Error(`Import path must be within project directory or crew root: ${resolvedPath}`);
|
|
43
44
|
const raw = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
|
|
44
45
|
assertRunBundle(raw);
|
|
46
|
+
|
|
47
|
+
// Integrity check: verify SHA-256 hash if present in manifest
|
|
48
|
+
const bundleJson = fs.readFileSync(resolvedPath, "utf-8");
|
|
49
|
+
const parsedForHash = JSON.parse(bundleJson) as { manifest?: { sha256?: string } };
|
|
50
|
+
if (parsedForHash.manifest?.sha256) {
|
|
51
|
+
const expectedHash = parsedForHash.manifest.sha256;
|
|
52
|
+
// Recompute hash by stringifying the bundle without the sha256 field
|
|
53
|
+
const { sha256: _sha256, ...manifestWithoutHash } = parsedForHash.manifest as Record<string, unknown> & { sha256?: string };
|
|
54
|
+
const bundleForHash = { ...parsedForHash, manifest: manifestWithoutHash };
|
|
55
|
+
const recomputedHash = crypto.createHash("sha256").update(JSON.stringify(bundleForHash)).digest("hex");
|
|
56
|
+
if (recomputedHash !== expectedHash) {
|
|
57
|
+
throw new Error(`Integrity check failed: SHA-256 mismatch. Expected ${expectedHash}, got ${recomputedHash}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
const runId = assertSafePathId("runId", raw.manifest.runId);
|
|
46
62
|
const importedAt = new Date().toISOString();
|
|
47
63
|
|
|
@@ -40,9 +40,13 @@ export function handleAnchorSet(
|
|
|
40
40
|
const cfg = params.config ?? {};
|
|
41
41
|
|
|
42
42
|
// Parse context from config
|
|
43
|
+
const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
43
44
|
const context: Record<string, unknown> = {};
|
|
44
45
|
if (cfg.context && typeof cfg.context === "object") {
|
|
45
|
-
|
|
46
|
+
const raw = cfg.context as Record<string, unknown>;
|
|
47
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
48
|
+
if (!POLLUTED_KEYS.has(k)) context[k] = v;
|
|
49
|
+
}
|
|
46
50
|
}
|
|
47
51
|
if (cfg.key) {
|
|
48
52
|
// Single key shorthand
|