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
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
|
|
19
19
|
import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
20
20
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
21
|
-
import { appendEvent } from "../state/event-log.ts";
|
|
21
|
+
import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
|
|
22
22
|
import { withRunLock } from "../state/locks.ts";
|
|
23
23
|
import { replayPendingMailboxMessages } from "../state/mailbox.ts";
|
|
24
24
|
import {
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
TeamTaskState,
|
|
34
34
|
} from "../state/types.ts";
|
|
35
35
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
36
|
+
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
36
37
|
import {
|
|
37
38
|
allWorkflows,
|
|
38
39
|
discoverWorkflows,
|
|
@@ -99,10 +100,31 @@ async function handleRun(
|
|
|
99
100
|
return _cachedHandleRun(...args);
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
import { FileCheckpointStore } from "../runtime/checkpoint.ts";
|
|
102
104
|
import { waitForRun } from "../runtime/run-tracker.ts";
|
|
103
105
|
import { normalizeSkillOverride } from "../runtime/skill-instructions.ts";
|
|
106
|
+
import {
|
|
107
|
+
computeRunCacheKey,
|
|
108
|
+
getCachedRun,
|
|
109
|
+
getCacheStats,
|
|
110
|
+
} from "../state/run-cache.ts";
|
|
111
|
+
import { listRunGraphs, loadRunGraph } from "../state/run-graph.ts";
|
|
104
112
|
import { searchAgents, searchTeams } from "../utils/bm25-search.ts";
|
|
105
113
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
114
|
+
import { buildTeamOnboarding } from "./team-onboard.ts";
|
|
115
|
+
import {
|
|
116
|
+
handleAnchorAccumulate,
|
|
117
|
+
handleAnchorClear,
|
|
118
|
+
handleAnchorSet,
|
|
119
|
+
handleAnchorStatus,
|
|
120
|
+
} from "./team-tool/anchor.ts";
|
|
121
|
+
import {
|
|
122
|
+
createAutoSummarizeService,
|
|
123
|
+
handleAutoSummarizeConfig,
|
|
124
|
+
handleAutoSummarizeOff,
|
|
125
|
+
handleAutoSummarizeOn,
|
|
126
|
+
handleAutoSummarizeStatus,
|
|
127
|
+
} from "./team-tool/auto-summarize.ts";
|
|
106
128
|
import {
|
|
107
129
|
type CacheControlDeps,
|
|
108
130
|
invalidateSnapshot,
|
|
@@ -110,6 +132,10 @@ import {
|
|
|
110
132
|
import { handleCancel, handleRetry } from "./team-tool/cancel.ts";
|
|
111
133
|
import { handleDoctor } from "./team-tool/doctor.ts";
|
|
112
134
|
import { handleExplain } from "./team-tool/explain.ts";
|
|
135
|
+
import {
|
|
136
|
+
handleListScheduled,
|
|
137
|
+
handleSchedule,
|
|
138
|
+
} from "./team-tool/handle-schedule.ts";
|
|
113
139
|
import { handleHealthMonitor } from "./team-tool/health-monitor.ts";
|
|
114
140
|
import {
|
|
115
141
|
handleArtifacts,
|
|
@@ -125,30 +151,17 @@ import {
|
|
|
125
151
|
handlePrune,
|
|
126
152
|
handleWorktrees,
|
|
127
153
|
} from "./team-tool/lifecycle-actions.ts";
|
|
128
|
-
import {
|
|
129
|
-
getCachedRun,
|
|
130
|
-
computeRunCacheKey,
|
|
131
|
-
getCacheStats,
|
|
132
|
-
} from "../state/run-cache.ts";
|
|
133
|
-
import {
|
|
134
|
-
loadRunGraph,
|
|
135
|
-
listRunGraphs,
|
|
136
|
-
} from "../state/run-graph.ts";
|
|
137
|
-
import { FileCheckpointStore } from "../runtime/checkpoint.ts";
|
|
138
|
-
import { buildTeamOnboarding } from "./team-onboard.ts";
|
|
154
|
+
import { handleOrchestrate } from "./team-tool/orchestrate.ts";
|
|
139
155
|
import { handleParallel } from "./team-tool/parallel-dispatch.ts";
|
|
140
|
-
import { handleSchedule, handleListScheduled } from "./team-tool/handle-schedule.ts";
|
|
141
156
|
import { handlePlan } from "./team-tool/plan.ts";
|
|
142
|
-
import { handleOrchestrate } from "./team-tool/orchestrate.ts";
|
|
143
157
|
import { handleRespond } from "./team-tool/respond.ts";
|
|
144
158
|
import { handleStatus } from "./team-tool/status.ts";
|
|
145
|
-
import { handleAnchorSet, handleAnchorClear, handleAnchorStatus, handleAnchorAccumulate } from "./team-tool/anchor.ts";
|
|
146
|
-
import { handleAutoSummarizeOn, handleAutoSummarizeOff, handleAutoSummarizeStatus, handleAutoSummarizeConfig, createAutoSummarizeService } from "./team-tool/auto-summarize.ts";
|
|
147
159
|
|
|
148
160
|
export { handleApi } from "./team-tool/api.ts";
|
|
149
161
|
export { handleRetry } from "./team-tool/cancel.ts";
|
|
150
162
|
export type { TeamContext } from "./team-tool/context.ts";
|
|
151
163
|
export { handleDoctor } from "./team-tool/doctor.ts";
|
|
164
|
+
export { handleSchedule } from "./team-tool/handle-schedule.ts";
|
|
152
165
|
export {
|
|
153
166
|
handleArtifacts,
|
|
154
167
|
handleEvents,
|
|
@@ -163,12 +176,11 @@ export {
|
|
|
163
176
|
handlePrune,
|
|
164
177
|
handleWorktrees,
|
|
165
178
|
} from "./team-tool/lifecycle-actions.ts";
|
|
166
|
-
export {
|
|
179
|
+
export { handleOrchestrate } from "./team-tool/orchestrate.ts";
|
|
167
180
|
export { handlePlan } from "./team-tool/plan.ts";
|
|
168
181
|
export { handleStatus } from "./team-tool/status.ts";
|
|
169
182
|
export type { TeamToolDetails } from "./team-tool-types.ts";
|
|
170
183
|
export { handleRun };
|
|
171
|
-
export { handleOrchestrate } from "./team-tool/orchestrate.ts";
|
|
172
184
|
|
|
173
185
|
export function handleList(
|
|
174
186
|
params: TeamToolParamsValue,
|
|
@@ -451,7 +463,7 @@ export async function handleResume(
|
|
|
451
463
|
{ action: "resume", status: "error" },
|
|
452
464
|
true,
|
|
453
465
|
);
|
|
454
|
-
const loaded = loadRunManifestById(runCwd, params.runId);
|
|
466
|
+
const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
455
467
|
if (!loaded)
|
|
456
468
|
return result(
|
|
457
469
|
`Run '${params.runId}' not found.`,
|
|
@@ -674,7 +686,7 @@ export function handleSteer(
|
|
|
674
686
|
{ action: "steer", status: "error" },
|
|
675
687
|
true,
|
|
676
688
|
);
|
|
677
|
-
const loaded = loadRunManifestById(runCwd, runId);
|
|
689
|
+
const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
678
690
|
if (!loaded)
|
|
679
691
|
return result(
|
|
680
692
|
`Run '${runId}' not found`,
|
|
@@ -689,12 +701,23 @@ export function handleSteer(
|
|
|
689
701
|
true,
|
|
690
702
|
);
|
|
691
703
|
if (!task.pendingSteers) task.pendingSteers = [];
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
704
|
+
// HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
|
|
705
|
+
const MAX_PENDING_STEERS = 100;
|
|
706
|
+
if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
|
|
707
|
+
// Log warning before dropping the oldest message
|
|
708
|
+
appendEventFireAndForget(loaded.manifest.eventsPath, {
|
|
709
|
+
type: "task.steer_dropped",
|
|
710
|
+
runId,
|
|
711
|
+
taskId,
|
|
712
|
+
data: {
|
|
713
|
+
droppedMessage: task.pendingSteers[0],
|
|
714
|
+
reason: "pendingSteers cap exceeded",
|
|
715
|
+
queueDepth: task.pendingSteers.length,
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
task.pendingSteers = task.pendingSteers.slice(-(MAX_PENDING_STEERS - 1));
|
|
719
|
+
}
|
|
720
|
+
task.pendingSteers.push(message);
|
|
698
721
|
saveRunTasks(loaded.manifest, loaded.tasks);
|
|
699
722
|
appendEvent(loaded.manifest.eventsPath, {
|
|
700
723
|
type: "task.steer_queued",
|
|
@@ -751,17 +774,57 @@ function handleInvalidate(
|
|
|
751
774
|
/**
|
|
752
775
|
* Locate the CWD where a run's state is stored.
|
|
753
776
|
* Tries ctx.cwd first, then scans immediate child directories for .crew/state/runs/<runId>.
|
|
777
|
+
*
|
|
778
|
+
* Defensive bounds (prevent hang on large dirs like /tmp in CI):
|
|
779
|
+
* - Skips entries that are well-known system/ephemeral dirs (e.g. .npm, node_modules, .git)
|
|
780
|
+
* - Caps the scan at MAX_SCAN_ENTRIES to avoid pathological scans
|
|
781
|
+
* - Skips hidden entries (starting with `.`) unless they look like run directories
|
|
782
|
+
* (e.g. .crew, .pi, .tmp-crew-runs)
|
|
754
783
|
*/
|
|
755
|
-
|
|
784
|
+
const MAX_SCAN_ENTRIES = 1000;
|
|
785
|
+
const SKIP_SCAN_DIRS = new Set([
|
|
786
|
+
"node_modules",
|
|
787
|
+
".git",
|
|
788
|
+
".npm",
|
|
789
|
+
".cache",
|
|
790
|
+
".local",
|
|
791
|
+
"proc",
|
|
792
|
+
"sys",
|
|
793
|
+
"dev",
|
|
794
|
+
"Library",
|
|
795
|
+
"Applications",
|
|
796
|
+
]);
|
|
797
|
+
|
|
798
|
+
export function locateRunCwd(
|
|
799
|
+
runId: string,
|
|
800
|
+
baseCwd: string,
|
|
801
|
+
): string | undefined {
|
|
756
802
|
// Fast path: run is in the current CWD
|
|
757
|
-
if (loadRunManifestById(baseCwd, runId))
|
|
803
|
+
if (loadRunManifestById(baseCwd, runId)) {
|
|
804
|
+
return baseCwd;
|
|
805
|
+
}
|
|
758
806
|
|
|
759
|
-
// Scan immediate child directories
|
|
807
|
+
// Scan immediate child directories, but with defensive bounds.
|
|
760
808
|
try {
|
|
761
|
-
|
|
809
|
+
const entries = fs.readdirSync(baseCwd, { withFileTypes: true });
|
|
810
|
+
const boundedEntries = entries.length > MAX_SCAN_ENTRIES
|
|
811
|
+
? entries.slice(0, MAX_SCAN_ENTRIES)
|
|
812
|
+
: entries;
|
|
813
|
+
for (const entry of boundedEntries) {
|
|
762
814
|
if (!entry.isDirectory()) continue;
|
|
815
|
+
if (SKIP_SCAN_DIRS.has(entry.name)) continue;
|
|
816
|
+
// Skip hidden entries except well-known run-storage prefixes
|
|
817
|
+
if (entry.name.startsWith(".")) {
|
|
818
|
+
if (
|
|
819
|
+
!entry.name.startsWith(".crew") &&
|
|
820
|
+
!entry.name.startsWith(".pi") &&
|
|
821
|
+
!entry.name.startsWith(".tmp-crew")
|
|
822
|
+
) continue;
|
|
823
|
+
}
|
|
763
824
|
const candidate = path.join(baseCwd, entry.name);
|
|
764
|
-
if (loadRunManifestById(candidate, runId))
|
|
825
|
+
if (loadRunManifestById(candidate, runId)) {
|
|
826
|
+
return candidate;
|
|
827
|
+
}
|
|
765
828
|
}
|
|
766
829
|
} catch {
|
|
767
830
|
/* ignore unreadable dirs */
|
|
@@ -1090,23 +1153,32 @@ export async function handleTeamTool(
|
|
|
1090
1153
|
return handleWait(params, ctx);
|
|
1091
1154
|
case "graph": {
|
|
1092
1155
|
if (params.runId) {
|
|
1156
|
+
assertSafePathId("runId", params.runId);
|
|
1093
1157
|
const graph = loadRunGraph(ctx.cwd, params.runId);
|
|
1094
1158
|
return result(
|
|
1095
|
-
graph
|
|
1159
|
+
graph
|
|
1160
|
+
? JSON.stringify(graph, null, 2)
|
|
1161
|
+
: "No graph found for this run.",
|
|
1096
1162
|
{ action: "graph", status: graph ? "ok" : "error" },
|
|
1097
1163
|
!graph,
|
|
1098
1164
|
);
|
|
1099
1165
|
}
|
|
1100
1166
|
const graphs = listRunGraphs(ctx.cwd);
|
|
1101
1167
|
return result(
|
|
1102
|
-
graphs.length
|
|
1168
|
+
graphs.length
|
|
1169
|
+
? `Available graphs:\n${graphs.join("\n")}`
|
|
1170
|
+
: "No graphs available.",
|
|
1103
1171
|
{ action: "graph", status: "ok" },
|
|
1104
1172
|
);
|
|
1105
1173
|
}
|
|
1106
1174
|
case "search": {
|
|
1107
1175
|
const query = params.goal ?? params.task ?? "";
|
|
1108
1176
|
if (!query) {
|
|
1109
|
-
return result(
|
|
1177
|
+
return result(
|
|
1178
|
+
"Search requires goal or task query.",
|
|
1179
|
+
{ action: "search", status: "error" },
|
|
1180
|
+
true,
|
|
1181
|
+
);
|
|
1110
1182
|
}
|
|
1111
1183
|
try {
|
|
1112
1184
|
const [agentResults, teamResults] = await Promise.all([
|
|
@@ -1117,19 +1189,30 @@ export async function handleTeamTool(
|
|
|
1117
1189
|
if (teamResults.length) {
|
|
1118
1190
|
lines.push("## Teams");
|
|
1119
1191
|
for (const r of teamResults) {
|
|
1120
|
-
lines.push(
|
|
1192
|
+
lines.push(
|
|
1193
|
+
`- [${r.team.name}] score=${r.score.toFixed(2)}: ${r.team.description ?? "(no description)"}`,
|
|
1194
|
+
);
|
|
1121
1195
|
}
|
|
1122
1196
|
}
|
|
1123
1197
|
if (agentResults.length) {
|
|
1124
1198
|
lines.push("## Agents");
|
|
1125
1199
|
for (const r of agentResults) {
|
|
1126
|
-
lines.push(
|
|
1200
|
+
lines.push(
|
|
1201
|
+
`- [${r.agent.name}] score=${r.score.toFixed(2)}: ${r.agent.description ?? "(no description)"}`,
|
|
1202
|
+
);
|
|
1127
1203
|
}
|
|
1128
1204
|
}
|
|
1129
|
-
return result(
|
|
1205
|
+
return result(
|
|
1206
|
+
lines.length ? lines.join("\n") : "No results found.",
|
|
1207
|
+
{ action: "search", status: "ok" },
|
|
1208
|
+
);
|
|
1130
1209
|
} catch (err) {
|
|
1131
1210
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1132
|
-
return result(
|
|
1211
|
+
return result(
|
|
1212
|
+
`Search failed: ${msg}`,
|
|
1213
|
+
{ action: "search", status: "error" },
|
|
1214
|
+
true,
|
|
1215
|
+
);
|
|
1133
1216
|
}
|
|
1134
1217
|
}
|
|
1135
1218
|
case "schedule":
|
|
@@ -1137,7 +1220,10 @@ export async function handleTeamTool(
|
|
|
1137
1220
|
case "scheduled":
|
|
1138
1221
|
return handleListScheduled(params, ctx);
|
|
1139
1222
|
case "anchor": {
|
|
1140
|
-
const subAction =
|
|
1223
|
+
const subAction =
|
|
1224
|
+
typeof params.config?.subAction === "string"
|
|
1225
|
+
? params.config.subAction
|
|
1226
|
+
: "status";
|
|
1141
1227
|
switch (subAction) {
|
|
1142
1228
|
case "set":
|
|
1143
1229
|
return handleAnchorSet(params, ctx);
|
|
@@ -1151,7 +1237,12 @@ export async function handleTeamTool(
|
|
|
1151
1237
|
}
|
|
1152
1238
|
case "auto-summarize":
|
|
1153
1239
|
case "auto_boomerang": {
|
|
1154
|
-
const subAction =
|
|
1240
|
+
const subAction =
|
|
1241
|
+
typeof params.config?.subAction === "string"
|
|
1242
|
+
? params.config.subAction
|
|
1243
|
+
: (params.action as string) === "auto_boomerang"
|
|
1244
|
+
? "toggle"
|
|
1245
|
+
: "status";
|
|
1155
1246
|
switch (subAction) {
|
|
1156
1247
|
case "on":
|
|
1157
1248
|
return handleAutoSummarizeOn(params, ctx);
|
|
@@ -1178,7 +1269,14 @@ export async function handleTeamTool(
|
|
|
1178
1269
|
}
|
|
1179
1270
|
case "explain": {
|
|
1180
1271
|
const explainResult = handleExplain(params, ctx.cwd);
|
|
1181
|
-
return result(
|
|
1272
|
+
return result(
|
|
1273
|
+
explainResult.text,
|
|
1274
|
+
{
|
|
1275
|
+
action: "explain",
|
|
1276
|
+
status: explainResult.isError ? "error" : "ok",
|
|
1277
|
+
},
|
|
1278
|
+
explainResult.isError,
|
|
1279
|
+
);
|
|
1182
1280
|
}
|
|
1183
1281
|
case "cache": {
|
|
1184
1282
|
if (params.goal) {
|
|
@@ -1192,10 +1290,24 @@ export async function handleTeamTool(
|
|
|
1192
1290
|
if (cached) {
|
|
1193
1291
|
return result(
|
|
1194
1292
|
`Cached run found (${new Date(cached.cachedAt).toISOString()}): runId=${cached.runId}, status=${cached.status}, ${cached.tasks.length} tasks`,
|
|
1195
|
-
{
|
|
1293
|
+
{
|
|
1294
|
+
action: "cache",
|
|
1295
|
+
status: "ok",
|
|
1296
|
+
data: {
|
|
1297
|
+
cacheKey: key,
|
|
1298
|
+
cacheHit: true,
|
|
1299
|
+
runId: cached.runId,
|
|
1300
|
+
status: cached.status,
|
|
1301
|
+
taskCount: cached.tasks.length,
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1196
1304
|
);
|
|
1197
1305
|
}
|
|
1198
|
-
return result(`No cached result for key: ${key}`, {
|
|
1306
|
+
return result(`No cached result for key: ${key}`, {
|
|
1307
|
+
action: "cache",
|
|
1308
|
+
status: "ok",
|
|
1309
|
+
data: { cacheKey: key, cacheHit: false },
|
|
1310
|
+
});
|
|
1199
1311
|
}
|
|
1200
1312
|
const stats = getCacheStats(ctx.cwd);
|
|
1201
1313
|
return result(
|
|
@@ -1205,13 +1317,28 @@ export async function handleTeamTool(
|
|
|
1205
1317
|
}
|
|
1206
1318
|
case "checkpoint": {
|
|
1207
1319
|
if (!params.runId || !params.taskId) {
|
|
1208
|
-
return result(
|
|
1320
|
+
return result(
|
|
1321
|
+
"Checkpoint requires runId and taskId.",
|
|
1322
|
+
{ action: "checkpoint", status: "error" },
|
|
1323
|
+
true,
|
|
1324
|
+
);
|
|
1209
1325
|
}
|
|
1210
|
-
|
|
1326
|
+
assertSafePathId("runId", params.runId);
|
|
1327
|
+
assertSafePathId("taskId", params.taskId);
|
|
1328
|
+
const stateRoot = path.join(
|
|
1329
|
+
projectCrewRoot(ctx.cwd),
|
|
1330
|
+
"state",
|
|
1331
|
+
"runs",
|
|
1332
|
+
params.runId,
|
|
1333
|
+
);
|
|
1211
1334
|
const store = new FileCheckpointStore(stateRoot);
|
|
1212
1335
|
const checkpoint = store.load(params.runId, params.taskId);
|
|
1213
1336
|
if (!checkpoint) {
|
|
1214
|
-
return result(
|
|
1337
|
+
return result(
|
|
1338
|
+
"No checkpoint found.",
|
|
1339
|
+
{ action: "checkpoint", status: "error" },
|
|
1340
|
+
true,
|
|
1341
|
+
);
|
|
1215
1342
|
}
|
|
1216
1343
|
return result(
|
|
1217
1344
|
`Checkpoint: step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
|
|
@@ -1258,7 +1385,8 @@ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
|
|
|
1258
1385
|
registry;
|
|
1259
1386
|
}
|
|
1260
1387
|
|
|
1261
|
-
|
|
1388
|
+
/** @internal */
|
|
1389
|
+
function getCrewGlobalRegistry(): CrewRegistry | undefined {
|
|
1262
1390
|
return (globalThis as Record<symbol | string, unknown>)[
|
|
1263
1391
|
CREW_REGISTRY_KEY
|
|
1264
1392
|
] as CrewRegistry | undefined;
|
package/src/hooks/registry.ts
CHANGED
|
@@ -5,19 +5,42 @@ import { runEventBus } from "../ui/run-event-bus.ts";
|
|
|
5
5
|
|
|
6
6
|
const registry = new Map<HookName, HookDefinition[]>();
|
|
7
7
|
|
|
8
|
+
// Track hook IDs registered by pi-crew for scope-aware cleanup
|
|
9
|
+
const _piCrewHookIds = new Set<number>();
|
|
10
|
+
let _nextHookId = 1;
|
|
11
|
+
|
|
8
12
|
// SECURITY: Hooks are currently global (registered once, applied to all workspaces).
|
|
9
13
|
// For multi-workspace environments, consider filtering hooks by workspace scope:
|
|
10
14
|
// const workspaceHooks = getHooks(name).filter(h => !h.workspaceId || h.workspaceId === ctx.workspaceId);
|
|
11
15
|
// This prevents globally-registered hooks from operating on runs they weren't designed for.
|
|
12
16
|
|
|
13
|
-
export function registerHook(definition: HookDefinition):
|
|
17
|
+
export function registerHook(definition: HookDefinition): number {
|
|
18
|
+
const hookId = _nextHookId++;
|
|
19
|
+
_piCrewHookIds.add(hookId);
|
|
14
20
|
const hooks = registry.get(definition.name) ?? [];
|
|
15
|
-
hooks.push(definition);
|
|
21
|
+
hooks.push({ ...definition, _hookId: hookId });
|
|
16
22
|
registry.set(definition.name, hooks);
|
|
23
|
+
return hookId;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export function clearHooks(): void {
|
|
20
27
|
registry.clear();
|
|
28
|
+
_piCrewHookIds.clear();
|
|
29
|
+
_nextHookId = 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Scope-aware hook clearing: only removes hooks registered by pi-crew
|
|
33
|
+
export function clearHooksScoped(): void {
|
|
34
|
+
for (const [name, hooks] of registry) {
|
|
35
|
+
const remaining = hooks.filter((h) => !("_hookId" in h && _piCrewHookIds.has((h as { _hookId?: number })._hookId ?? -1)));
|
|
36
|
+
if (remaining.length === 0) {
|
|
37
|
+
registry.delete(name);
|
|
38
|
+
} else {
|
|
39
|
+
registry.set(name, remaining);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
_piCrewHookIds.clear();
|
|
43
|
+
_nextHookId = 1;
|
|
21
44
|
}
|
|
22
45
|
|
|
23
46
|
export function getHooks(name: HookName): HookDefinition[] {
|
|
@@ -27,29 +50,78 @@ export function getHooks(name: HookName): HookDefinition[] {
|
|
|
27
50
|
export async function executeHook(name: HookName, ctx: HookContext): Promise<HookExecutionReport> {
|
|
28
51
|
const hooks = getHooks(name);
|
|
29
52
|
if (hooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
|
|
30
|
-
// SECURITY:
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
53
|
+
// SECURITY: Filter hooks by workspace scope.
|
|
54
|
+
// - Global hooks (no workspaceId) match all contexts UNLESS ctx explicitly
|
|
55
|
+
// opts out via `includeGlobalHooks: false`. This is the documented
|
|
56
|
+
// behavior: "Hooks without workspaceId match ALL workspaces".
|
|
57
|
+
// - Scoped hooks (workspaceId set) require an exact match with ctx.workspaceId.
|
|
58
|
+
// - If ctx has no workspaceId, scoped hooks are excluded (they can't match).
|
|
59
|
+
// - `includeGlobalHooks: true` on ctx forces inclusion of global hooks even
|
|
60
|
+
// when ctx has a workspaceId.
|
|
61
|
+
const scopedHooks = hooks.filter((h) => {
|
|
62
|
+
// Scoped hook: must match ctx.workspaceId exactly
|
|
63
|
+
if (h.workspaceId !== undefined) {
|
|
64
|
+
return h.workspaceId === ctx.workspaceId;
|
|
65
|
+
}
|
|
66
|
+
// Global hook (no workspaceId): include unless ctx explicitly excludes
|
|
67
|
+
return ctx.includeGlobalHooks !== false;
|
|
68
|
+
});
|
|
37
69
|
if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
|
|
70
|
+
const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype", "hasOwnProperty", "toString", "valueOf", "isPrototypeOf", "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__"].map((k) => k.toLowerCase().normalize("NFKC")));
|
|
71
|
+
function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
|
|
72
|
+
const clean: Record<string, unknown> = {};
|
|
73
|
+
for (const [k, v] of Object.entries(data)) {
|
|
74
|
+
if (!POLLUTED_KEYS.has(k.toLowerCase())) {
|
|
75
|
+
if (v !== null && typeof v === "object") {
|
|
76
|
+
if (Array.isArray(v)) {
|
|
77
|
+
// Sanitize array elements that are objects
|
|
78
|
+
clean[k] = v.map((item) => (item !== null && typeof item === "object" && !Array.isArray(item) ? sanitizeMergeData(item as Record<string, unknown>) : item));
|
|
79
|
+
} else {
|
|
80
|
+
clean[k] = sanitizeMergeData(v as Record<string, unknown>);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
clean[k] = v;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return clean;
|
|
88
|
+
}
|
|
89
|
+
// Sanitize ctx by stripping dangerous property names before passing to handlers.
|
|
90
|
+
// Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS]
|
|
91
|
+
// This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
|
|
92
|
+
function sanitizeContext(ctx: HookContext): HookContext {
|
|
93
|
+
for (const key of Object.keys(ctx)) {
|
|
94
|
+
if (POLLUTED_KEYS.has(key.toLowerCase())) {
|
|
95
|
+
delete ctx[key];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return ctx;
|
|
99
|
+
}
|
|
100
|
+
function sanitizeErrorMessage(message: string): string {
|
|
101
|
+
// Remove file paths, environment variable references, and other potentially sensitive data
|
|
102
|
+
return message
|
|
103
|
+
.replace(/\/[^:\s]+/g, "[path]")
|
|
104
|
+
.replace(/\b[A-Z_0-9]+\s*=/g, "[env]")
|
|
105
|
+
.replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]");
|
|
106
|
+
}
|
|
38
107
|
const start = Date.now();
|
|
39
108
|
const diagnostics: string[] = [];
|
|
40
109
|
let capturedModifications: Record<string, unknown> | undefined;
|
|
41
110
|
for (const hook of scopedHooks) {
|
|
42
111
|
try {
|
|
43
|
-
const result: HookResult = await hook.handler(ctx);
|
|
112
|
+
const result: HookResult = await hook.handler(sanitizeContext(ctx));
|
|
113
|
+
// SECURITY: Sanitize any direct mutations the handler may have made to ctx.
|
|
114
|
+
// This prevents hooks from injecting dangerous properties via direct ctx assignment.
|
|
115
|
+
sanitizeContext(ctx);
|
|
44
116
|
if (hook.mode === "blocking" && result.outcome === "block") {
|
|
45
117
|
return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
|
|
46
118
|
}
|
|
47
119
|
if (result.outcome === "modify" && result.data) {
|
|
48
|
-
Object.assign(ctx, result.data);
|
|
120
|
+
Object.assign(ctx, sanitizeMergeData(result.data));
|
|
49
121
|
capturedModifications = { ...result.data };
|
|
50
122
|
}
|
|
51
123
|
} catch (error) {
|
|
52
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
|
53
125
|
if (hook.mode === "blocking") {
|
|
54
126
|
return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: `Hook error: ${message}` };
|
|
55
127
|
}
|
package/src/hooks/types.ts
CHANGED
|
@@ -20,9 +20,9 @@ export type HookName =
|
|
|
20
20
|
* - 1 = warn (non-blocking error, continue)
|
|
21
21
|
* - 2 = block (blocking error, stop)
|
|
22
22
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/** @internal */ const HOOK_EXIT_SUCCESS = 0 as const;
|
|
24
|
+
/** @internal */ const HOOK_EXIT_WARN = 1 as const;
|
|
25
|
+
/** @internal */ const HOOK_EXIT_BLOCK = 2 as const;
|
|
26
26
|
|
|
27
27
|
export type HookMode = "blocking" | "non_blocking";
|
|
28
28
|
export type HookOutcome = "allow" | "block" | "modify" | "diagnostic";
|
|
@@ -31,6 +31,13 @@ export interface HookContext {
|
|
|
31
31
|
runId: string;
|
|
32
32
|
taskId?: string;
|
|
33
33
|
cwd: string;
|
|
34
|
+
// NOTE: Hooks receive a shared mutable context object. A hook may directly set
|
|
35
|
+
// properties on ctx, but MUST NOT set dangerous names like "__proto__",
|
|
36
|
+
// "constructor", "prototype", etc. (see registry.ts POLLUTED_KEYS).
|
|
37
|
+
// Hook authors are trusted but must avoid prototype pollution attacks.
|
|
38
|
+
// The sanitizeMergeData function prevents dangerous properties from propagating
|
|
39
|
+
// via result.data merge, and a runtime guard sanitizes ctx before passing to
|
|
40
|
+
// handlers AND after handler execution to catch direct mutations.
|
|
34
41
|
[key: string]: unknown;
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -47,6 +54,8 @@ export interface HookDefinition {
|
|
|
47
54
|
// SECURITY: Optional workspace scoping. When set, the hook only executes for
|
|
48
55
|
// runs in the specified workspace. When absent, the hook applies to all runs.
|
|
49
56
|
workspaceId?: string;
|
|
57
|
+
/** @internal Internal hook ID for scope-aware cleanup. */
|
|
58
|
+
_hookId?: number;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export interface HookExecutionReport {
|
package/src/i18n.ts
CHANGED
|
@@ -129,13 +129,26 @@ export function t(key: Key, params?: Params): string {
|
|
|
129
129
|
* @example
|
|
130
130
|
* addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." })
|
|
131
131
|
*/
|
|
132
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
133
|
+
|
|
134
|
+
function stripDangerousKeys<T extends Record<string, unknown>>(obj: T): T {
|
|
135
|
+
const safe: Record<string, unknown> = {};
|
|
136
|
+
for (const key of Object.keys(obj)) {
|
|
137
|
+
if (!DANGEROUS_KEYS.has(key)) {
|
|
138
|
+
safe[key] = obj[key];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return safe as T;
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
export function addTranslations(locale: string, bundle: Partial<Record<Key, string>>): void {
|
|
133
145
|
if (!locale) return;
|
|
146
|
+
const safeBundle = stripDangerousKeys(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
|
|
134
147
|
const existing = translations[locale];
|
|
135
148
|
if (existing) {
|
|
136
|
-
Object.assign(existing,
|
|
149
|
+
Object.assign(existing, safeBundle);
|
|
137
150
|
} else {
|
|
138
|
-
translations[locale] = { ...
|
|
151
|
+
translations[locale] = { ...safeBundle };
|
|
139
152
|
}
|
|
140
153
|
}
|
|
141
154
|
|
|
@@ -8,6 +8,78 @@ import type { MetricExporter } from "./adapter.ts";
|
|
|
8
8
|
|
|
9
9
|
const gzipAsync = promisify(gzip);
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* SSRF protection: validate that an OTLP endpoint URL does not target
|
|
13
|
+
* private/reserved networks or dangerous protocols.
|
|
14
|
+
* Rejects: localhost, loopback, private IPs, link-local, cloud metadata,
|
|
15
|
+
* IPv6 private, file:// and javascript:// protocols.
|
|
16
|
+
* Only http:// and https:// to public hostnames are allowed.
|
|
17
|
+
*/
|
|
18
|
+
export function validateEndpoint(endpoint: string): void {
|
|
19
|
+
let url: URL;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(endpoint);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`Invalid OTLP endpoint URL: ${endpoint}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Only allow http and https protocols
|
|
27
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`OTLP endpoint must use http:// or https:// protocol, got: ${url.protocol}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hostname = url.hostname.toLowerCase();
|
|
34
|
+
|
|
35
|
+
// Reject known localhost names
|
|
36
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
37
|
+
throw new Error(`OTLP endpoint must not target localhost: ${endpoint}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Reject IPv6 loopback and private
|
|
41
|
+
if (hostname.startsWith("[")) {
|
|
42
|
+
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
43
|
+
if (bare === "::1") {
|
|
44
|
+
throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
|
|
45
|
+
}
|
|
46
|
+
if (bare.toLowerCase().startsWith("fd") || bare.toLowerCase().startsWith("fc")) {
|
|
47
|
+
throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Reject IPv4 private/reserved ranges
|
|
52
|
+
// Match plain IPv4 addresses (not hostnames that look like IPs)
|
|
53
|
+
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
54
|
+
if (ipv4Match) {
|
|
55
|
+
const octets = [Number(ipv4Match[1]), Number(ipv4Match[2]), Number(ipv4Match[3]), Number(ipv4Match[4])];
|
|
56
|
+
// 127.x.x.x - loopback
|
|
57
|
+
if (octets[0] === 127) {
|
|
58
|
+
throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
|
|
59
|
+
}
|
|
60
|
+
// 10.x.x.x - private class A
|
|
61
|
+
if (octets[0] === 10) {
|
|
62
|
+
throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${endpoint}`);
|
|
63
|
+
}
|
|
64
|
+
// 172.16.x.x - 172.31.x.x - private class B
|
|
65
|
+
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
|
|
66
|
+
throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${endpoint}`);
|
|
67
|
+
}
|
|
68
|
+
// 192.168.x.x - private class C
|
|
69
|
+
if (octets[0] === 192 && octets[1] === 168) {
|
|
70
|
+
throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${endpoint}`);
|
|
71
|
+
}
|
|
72
|
+
// 169.254.x.x - link-local / cloud metadata
|
|
73
|
+
if (octets[0] === 169 && octets[1] === 254) {
|
|
74
|
+
throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${endpoint}`);
|
|
75
|
+
}
|
|
76
|
+
// 0.x.x.x - this network
|
|
77
|
+
if (octets[0] === 0) {
|
|
78
|
+
throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${endpoint}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
11
83
|
// FIX (Round 15): Cap the number of snapshots per push to prevent OOM when
|
|
12
84
|
// the metric registry has grown large. The OTLP HTTP spec allows many metrics
|
|
13
85
|
// in one payload, but a single push > 10_000 metrics would balloon the
|
|
@@ -71,6 +143,7 @@ export class OTLPExporter implements MetricExporter {
|
|
|
71
143
|
private readonly registry: MetricRegistry;
|
|
72
144
|
|
|
73
145
|
constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
|
|
146
|
+
validateEndpoint(opts.endpoint);
|
|
74
147
|
this.opts = opts;
|
|
75
148
|
this.registry = registry;
|
|
76
149
|
}
|