pi-crew 0.1.39 → 0.1.40
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 +20 -0
- package/README.md +42 -3
- package/package.json +1 -1
- package/schema.json +2 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config.ts +81 -2
- package/src/extension/async-notifier.ts +26 -4
- package/src/extension/notification-sink.ts +1 -1
- package/src/extension/register.ts +23 -7
- package/src/extension/registration/subagent-tools.ts +11 -6
- package/src/extension/result-watcher.ts +6 -1
- package/src/extension/team-tool/api.ts +50 -1
- package/src/observability/metric-sink.ts +1 -1
- package/src/runtime/child-pi.ts +2 -1
- package/src/runtime/crew-agent-records.ts +5 -4
- package/src/runtime/diagnostic-export.ts +2 -16
- package/src/runtime/live-session-runtime.ts +12 -6
- package/src/runtime/sidechain-output.ts +2 -1
- package/src/runtime/subagent-manager.ts +6 -1
- package/src/runtime/task-runner/live-executor.ts +3 -0
- package/src/runtime/team-runner.ts +74 -3
- package/src/schema/config-schema.ts +1 -0
- package/src/state/artifact-store.ts +4 -2
- package/src/state/event-log.ts +2 -1
- package/src/state/jsonl-writer.ts +3 -1
- package/src/state/mailbox.ts +4 -3
- package/src/state/types.ts +12 -0
- package/src/teams/discover-teams.ts +1 -1
- package/src/utils/redaction.ts +41 -0
- package/src/workflows/discover-workflows.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.40
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added owner-session generation guards for background subagents, async run notifications, result watchers, and live-session callbacks so stale sessions do not receive completions.
|
|
10
|
+
- Added `runtime.requirePlanApproval` with approve/cancel API support to gate mutating adaptive implementation tasks behind an explicit planner artifact approval.
|
|
11
|
+
- Added shared secret redaction for event logs, mailbox persistence, artifacts, JSONL streams, agent records, notifications, metrics, and diagnostics.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Project-local agents, teams, and workflows can no longer shadow builtin or user resources with the same name.
|
|
16
|
+
- Project-level sensitive config such as worker execution, runtime mode, autonomy, agent overrides, worktree setup hooks, and OTLP headers is ignored with warnings unless configured in trusted user scope.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fixed lost async completion notifications after auto-compaction/session restart by continuing to track active runs across notifier restarts.
|
|
21
|
+
- Fixed stale background subagent wakeups after session switch/shutdown while preserving terminal results for explicit joins.
|
|
22
|
+
- Fixed resume bypasses in plan approval by re-gating persisted mutating adaptive tasks when approval state is missing or pending.
|
|
23
|
+
- Restricted plan approval and cancellation to non-read-only roles and rejected cancel/approve after the approval state is no longer pending.
|
|
24
|
+
|
|
5
25
|
## 0.1.39
|
|
6
26
|
|
|
7
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -25,13 +25,14 @@ Current highlights:
|
|
|
25
25
|
- metadata-aware `recommend` action for routing, decomposition, fanout hints, async/worktree suggestions
|
|
26
26
|
- configurable autonomy profiles: `manual`, `suggested`, `assisted`, `aggressive`
|
|
27
27
|
- builtin agents, teams, and workflows
|
|
28
|
-
- user/project/builtin resource discovery
|
|
28
|
+
- user/project/builtin resource discovery where user resources override builtin resources, and project resources cannot shadow trusted user/builtin names
|
|
29
29
|
- resource format support for routing metadata: `triggers`, `useWhen`, `avoidWhen`, `cost`, `category`
|
|
30
30
|
- durable run state: manifest, tasks, events, artifacts, imports/exports
|
|
31
31
|
- foreground workflow scheduler
|
|
32
32
|
- detached async/background runner
|
|
33
33
|
- stale async PID detection
|
|
34
34
|
- active run summary and async completion notifications in Pi sessions
|
|
35
|
+
- owner-session delivery guards so stale sessions do not receive background subagent/result/live-session completions
|
|
35
36
|
- real child Pi worker execution by default, with explicit scaffold/dry-run opt-out
|
|
36
37
|
- child Pi JSON output parsing for final text, usage, and event counts
|
|
37
38
|
- retryable model fallback attempts per task
|
|
@@ -58,6 +59,8 @@ Current highlights:
|
|
|
58
59
|
- observability metrics: per-session Counter/Gauge/Histogram registry, JSONL sink, `/team-metrics`, dashboard metrics pane, Prometheus/OTLP exporters (OTLP opt-in)
|
|
59
60
|
- reliability hardening: heartbeat gradient watcher, opt-in retry executor with attempt trace, crash-recovery detection, deadletter queue
|
|
60
61
|
- background `Agent`/`crew_agent` completion wake-up so parent sessions can automatically join completed subagent results
|
|
62
|
+
- optional `runtime.requirePlanApproval` gate for planner-first approval before mutating adaptive implementation workers run
|
|
63
|
+
- shared redaction for common secrets before durable event/log/mailbox/artifact/metric/diagnostic persistence
|
|
61
64
|
- package polish: `schema.json`, TypeScript semantic check, strip-types import smoke, cross-platform CI workflow, dry-run package verification
|
|
62
65
|
|
|
63
66
|
## Install
|
|
@@ -146,9 +149,13 @@ The project root is auto-detected by walking up from the current directory and s
|
|
|
146
149
|
Config merge priority:
|
|
147
150
|
|
|
148
151
|
```text
|
|
149
|
-
user < project
|
|
152
|
+
user < project for ordinary presentation/UX settings
|
|
150
153
|
```
|
|
151
154
|
|
|
155
|
+
Trust-boundary exception: project config is intentionally not trusted for sensitive execution controls. Project-level values such as `executeWorkers`, `asyncByDefault`, runtime mode/live-session inheritance, autonomy mode, `agents.disableBuiltins`, `agents.overrides`, `worktree.setupHook`, and `otlp.headers` are ignored with warnings. Set those in user config when you want to trust them explicitly.
|
|
156
|
+
|
|
157
|
+
Resource discovery trust boundary: project-local agents, teams, and workflows may add new names, but cannot shadow builtin or user resources with the same name.
|
|
158
|
+
|
|
152
159
|
Supported config:
|
|
153
160
|
|
|
154
161
|
```json
|
|
@@ -167,6 +174,10 @@ Supported config:
|
|
|
167
174
|
"review": ["review", "audit", "inspect"]
|
|
168
175
|
}
|
|
169
176
|
},
|
|
177
|
+
"runtime": {
|
|
178
|
+
"mode": "auto",
|
|
179
|
+
"requirePlanApproval": false
|
|
180
|
+
},
|
|
170
181
|
"limits": {
|
|
171
182
|
"maxConcurrentWorkers": 3,
|
|
172
183
|
"maxTaskDepth": 2,
|
|
@@ -222,9 +233,11 @@ Supported config:
|
|
|
222
233
|
Safety notes:
|
|
223
234
|
|
|
224
235
|
- Foreground child-process runs continue in the Pi extension process and return control to chat immediately, so large workflows do not block the interactive session. They are interrupted on session shutdown. Use `async: true` only for intentionally detached runs that may survive the current session.
|
|
236
|
+
- Async completion notifications survive extension reload/auto-compaction: active runs are not marked consumed just because the notifier restarts, while stale owner-session callbacks are suppressed after session switches.
|
|
225
237
|
- Background `Agent`/`crew_agent` runs notify the parent session when they reach a terminal state; the parent can then call `get_subagent_result`/`crew_agent_result` and continue the original task.
|
|
226
238
|
- `tools.terminateOnForeground` is an opt-in power-user setting. When true, foreground `Agent`/`crew_agent` calls return with `terminate: true` after the child result is available, saving one follow-up LLM turn. Default is false so the assistant can still summarize raw worker output.
|
|
227
239
|
- Runtime state paths are treated as untrusted data: run ids, import bundles, artifact/transcript paths, mailbox files, and agent control/log files are validated with containment checks before reads or writes.
|
|
240
|
+
- Common secret patterns (`token=`, `apiKey=`, `Authorization: Bearer ...`, private keys, etc.) are redacted before durable logs/events/mailbox/artifacts/metrics/diagnostics are written.
|
|
228
241
|
- `observability.enabled` defaults to true for in-memory metrics and heartbeat watching. Metric JSONL snapshots are gated by `telemetry.enabled`; set `telemetry.enabled=false` to opt out of local telemetry files.
|
|
229
242
|
- `reliability.autoRetry` and `reliability.autoRecover` default to false. Enabling retry may execute an idempotent task more than once; each attempt is recorded in `task.attempts`, and exhausted retries append a deadletter entry.
|
|
230
243
|
- `otlp.enabled` defaults to false. Configure `otlp.endpoint` only when you want to push metrics to an OTLP HTTP collector.
|
|
@@ -320,7 +333,7 @@ Supported actions:
|
|
|
320
333
|
| `config` | Show/update config |
|
|
321
334
|
| `init` | Create project `.pi` layout and update `.gitignore` |
|
|
322
335
|
| `autonomy` | Show/update autonomous delegation settings |
|
|
323
|
-
| `api` | Safe interop for run/task/event/heartbeat/claim/mailbox state |
|
|
336
|
+
| `api` | Safe interop for run/task/event/heartbeat/claim/mailbox state, including plan approval/cancel operations |
|
|
324
337
|
| `help` | Show help text |
|
|
325
338
|
|
|
326
339
|
## Example tool calls
|
|
@@ -358,6 +371,30 @@ Run with worktrees:
|
|
|
358
371
|
}
|
|
359
372
|
```
|
|
360
373
|
|
|
374
|
+
Require explicit approval after the adaptive planner writes a plan artifact and before mutating workers run:
|
|
375
|
+
|
|
376
|
+
```json
|
|
377
|
+
{
|
|
378
|
+
"action": "run",
|
|
379
|
+
"team": "implementation",
|
|
380
|
+
"workflow": "implementation",
|
|
381
|
+
"goal": "Refactor auth and update tests",
|
|
382
|
+
"config": {
|
|
383
|
+
"runtime": { "requirePlanApproval": true }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Approve or cancel the pending plan:
|
|
389
|
+
|
|
390
|
+
```json
|
|
391
|
+
{
|
|
392
|
+
"action": "api",
|
|
393
|
+
"runId": "team_...",
|
|
394
|
+
"config": { "operation": "approve-plan" }
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
361
398
|
Inspect a run:
|
|
362
399
|
|
|
363
400
|
```json
|
|
@@ -438,6 +475,8 @@ Manual slash commands are ops/debug controls. Autonomous tool use via policy/rec
|
|
|
438
475
|
/team-api team_... ack-message messageId=msg_...
|
|
439
476
|
/team-api team_... read-delivery
|
|
440
477
|
/team-api team_... validate-mailbox repair=true
|
|
478
|
+
/team-api team_... approve-plan
|
|
479
|
+
/team-api team_... cancel-plan
|
|
441
480
|
```
|
|
442
481
|
|
|
443
482
|
Use `/team-metrics` for a current metrics snapshot. The optional argument is a glob-style metric filter:
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -68,7 +68,8 @@
|
|
|
68
68
|
"graceTurns": { "type": "integer", "minimum": 1 },
|
|
69
69
|
"inheritContext": { "type": "boolean" },
|
|
70
70
|
"promptMode": { "type": "string", "enum": ["replace", "append"] },
|
|
71
|
-
"groupJoin": { "type": "string", "enum": ["off", "group", "smart"] }
|
|
71
|
+
"groupJoin": { "type": "string", "enum": ["off", "group", "smart"] },
|
|
72
|
+
"requirePlanApproval": { "type": "boolean" }
|
|
72
73
|
}
|
|
73
74
|
},
|
|
74
75
|
"control": {
|
|
@@ -95,7 +95,7 @@ export function discoverAgents(cwd: string): AgentDiscoveryResult {
|
|
|
95
95
|
|
|
96
96
|
export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
|
|
97
97
|
const byName = new Map<string, AgentConfig>();
|
|
98
|
-
for (const agent of [...discovery.
|
|
98
|
+
for (const agent of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
|
|
99
99
|
byName.set(agent.name.toLowerCase(), agent);
|
|
100
100
|
}
|
|
101
101
|
return [...byName.values()].filter((agent) => !agent.disabled).sort((a, b) => a.name.localeCompare(b.name));
|
package/src/config/config.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface CrewRuntimeConfig {
|
|
|
39
39
|
inheritContext?: boolean;
|
|
40
40
|
promptMode?: "replace" | "append";
|
|
41
41
|
groupJoin?: "off" | "group" | "smart";
|
|
42
|
+
requirePlanApproval?: boolean;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export interface CrewControlConfig {
|
|
@@ -206,6 +207,82 @@ function validateConfigWithWarnings(raw: unknown): string[] {
|
|
|
206
207
|
return [];
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
function projectOverrideWarning(projectPath: string, dottedPath: string): string {
|
|
211
|
+
return `${projectPath}: project-level sensitive config '${dottedPath}' is ignored; set it in user config to trust it explicitly`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sanitizeProjectConfig(projectPath: string, userConfig: PiTeamsConfig, config: PiTeamsConfig): ConfigValidationResult {
|
|
215
|
+
const sanitized: PiTeamsConfig = { ...config };
|
|
216
|
+
const warnings: string[] = [];
|
|
217
|
+
const dropTopLevel = (key: keyof PiTeamsConfig): void => {
|
|
218
|
+
if (config[key] === undefined) return;
|
|
219
|
+
delete sanitized[key];
|
|
220
|
+
warnings.push(projectOverrideWarning(projectPath, String(key)));
|
|
221
|
+
};
|
|
222
|
+
dropTopLevel("executeWorkers");
|
|
223
|
+
dropTopLevel("asyncByDefault");
|
|
224
|
+
dropTopLevel("requireCleanWorktreeLeader");
|
|
225
|
+
if (config.runtime) {
|
|
226
|
+
const runtime = { ...config.runtime };
|
|
227
|
+
for (const key of ["mode", "preferLiveSession", "allowChildProcessFallback", "inheritContext"] as const) {
|
|
228
|
+
if (runtime[key] !== undefined) {
|
|
229
|
+
delete runtime[key];
|
|
230
|
+
warnings.push(projectOverrideWarning(projectPath, `runtime.${key}`));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (runtime.requirePlanApproval === false) {
|
|
234
|
+
delete runtime.requirePlanApproval;
|
|
235
|
+
warnings.push(projectOverrideWarning(projectPath, "runtime.requirePlanApproval"));
|
|
236
|
+
}
|
|
237
|
+
sanitized.runtime = Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
|
|
238
|
+
}
|
|
239
|
+
if (config.autonomous) {
|
|
240
|
+
const autonomous = { ...config.autonomous };
|
|
241
|
+
for (const key of ["profile", "enabled", "injectPolicy", "preferAsyncForLongTasks", "allowWorktreeSuggestion"] as const) {
|
|
242
|
+
if (autonomous[key] !== undefined) {
|
|
243
|
+
delete autonomous[key];
|
|
244
|
+
warnings.push(projectOverrideWarning(projectPath, `autonomous.${key}`));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
sanitized.autonomous = Object.values(autonomous).some((entry) => entry !== undefined) ? autonomous : undefined;
|
|
248
|
+
}
|
|
249
|
+
if (config.worktree?.setupHook !== undefined) {
|
|
250
|
+
sanitized.worktree = { ...config.worktree, setupHook: undefined };
|
|
251
|
+
if (!Object.values(sanitized.worktree).some((entry) => entry !== undefined)) sanitized.worktree = undefined;
|
|
252
|
+
warnings.push(projectOverrideWarning(projectPath, "worktree.setupHook"));
|
|
253
|
+
}
|
|
254
|
+
if (config.otlp?.headers !== undefined) {
|
|
255
|
+
sanitized.otlp = { ...config.otlp, headers: undefined };
|
|
256
|
+
if (!Object.values(sanitized.otlp).some((entry) => entry !== undefined)) sanitized.otlp = undefined;
|
|
257
|
+
warnings.push(projectOverrideWarning(projectPath, "otlp.headers"));
|
|
258
|
+
}
|
|
259
|
+
if (config.agents?.disableBuiltins !== undefined || config.agents?.overrides !== undefined) {
|
|
260
|
+
const agents = { ...config.agents };
|
|
261
|
+
if (agents.disableBuiltins !== undefined) {
|
|
262
|
+
delete agents.disableBuiltins;
|
|
263
|
+
warnings.push(projectOverrideWarning(projectPath, "agents.disableBuiltins"));
|
|
264
|
+
}
|
|
265
|
+
if (agents.overrides !== undefined) {
|
|
266
|
+
delete agents.overrides;
|
|
267
|
+
warnings.push(projectOverrideWarning(projectPath, "agents.overrides"));
|
|
268
|
+
}
|
|
269
|
+
sanitized.agents = Object.values(agents).some((entry) => entry !== undefined) ? agents : undefined;
|
|
270
|
+
}
|
|
271
|
+
if (config.tools?.enableSteer !== undefined || config.tools?.terminateOnForeground !== undefined) {
|
|
272
|
+
const tools = { ...config.tools };
|
|
273
|
+
if (tools.enableSteer !== undefined) {
|
|
274
|
+
delete tools.enableSteer;
|
|
275
|
+
warnings.push(projectOverrideWarning(projectPath, "tools.enableSteer"));
|
|
276
|
+
}
|
|
277
|
+
if (tools.terminateOnForeground !== undefined) {
|
|
278
|
+
delete tools.terminateOnForeground;
|
|
279
|
+
warnings.push(projectOverrideWarning(projectPath, "tools.terminateOnForeground"));
|
|
280
|
+
}
|
|
281
|
+
sanitized.tools = Object.values(tools).some((entry) => entry !== undefined) ? tools : undefined;
|
|
282
|
+
}
|
|
283
|
+
return { config: sanitized, warnings };
|
|
284
|
+
}
|
|
285
|
+
|
|
209
286
|
function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfig {
|
|
210
287
|
const merged: PiTeamsConfig = { ...base, ...withoutUndefined(override as Record<string, unknown>) };
|
|
211
288
|
if (base.autonomous || override.autonomous) {
|
|
@@ -416,6 +493,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
|
|
|
416
493
|
inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
|
|
417
494
|
promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
|
|
418
495
|
groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
|
|
496
|
+
requirePlanApproval: parseWithSchema(Type.Boolean(), obj.requirePlanApproval),
|
|
419
497
|
};
|
|
420
498
|
return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
|
|
421
499
|
}
|
|
@@ -639,8 +717,9 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
|
|
|
639
717
|
if (cwd) {
|
|
640
718
|
const projectPath = projectConfigPath(cwd);
|
|
641
719
|
const projectConfig = parseConfigWithWarnings(readConfigRecord(projectPath));
|
|
642
|
-
|
|
643
|
-
|
|
720
|
+
const projectSafeConfig = sanitizeProjectConfig(projectPath, config, projectConfig.config);
|
|
721
|
+
warnings.push(...projectConfig.warnings.map((warning) => `${projectPath}: ${warning}`), ...projectSafeConfig.warnings);
|
|
722
|
+
config = mergeConfig(config, projectSafeConfig.config);
|
|
644
723
|
}
|
|
645
724
|
return { path: filePath, paths, config, warnings: warnings.length > 0 ? warnings : undefined };
|
|
646
725
|
} catch (error) {
|
|
@@ -8,6 +8,13 @@ import { listRuns } from "./run-index.ts";
|
|
|
8
8
|
export interface AsyncNotifierState {
|
|
9
9
|
seenFinishedRunIds: Set<string>;
|
|
10
10
|
interval?: ReturnType<typeof setInterval>;
|
|
11
|
+
generation?: number;
|
|
12
|
+
lastStoppedAtMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AsyncNotifierOptions {
|
|
16
|
+
generation?: number;
|
|
17
|
+
isCurrent?: (generation: number) => boolean;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
function isFinished(status: string): boolean {
|
|
@@ -18,6 +25,12 @@ function isAsyncTerminalEvent(event: TeamEvent): boolean {
|
|
|
18
25
|
return event.type === "async.completed" || event.type === "async.failed" || event.type === "async.died";
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
function timeMs(value: string | undefined): number | undefined {
|
|
29
|
+
if (!value) return undefined;
|
|
30
|
+
const parsed = new Date(value).getTime();
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
function latestEventAgeMs(events: TeamEvent[], now = Date.now()): number {
|
|
22
35
|
const latest = events.at(-1);
|
|
23
36
|
if (!latest) return Number.POSITIVE_INFINITY;
|
|
@@ -38,14 +51,21 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
|
|
|
38
51
|
return failed;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifierState, intervalMs = 5000): void {
|
|
54
|
+
export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifierState, intervalMs = 5000, options: AsyncNotifierOptions = {}): void {
|
|
42
55
|
if (state.interval) clearInterval(state.interval);
|
|
56
|
+
const generation = options.generation ?? ((state.generation ?? 0) + 1);
|
|
57
|
+
state.generation = generation;
|
|
58
|
+
const startedAtMs = Date.now();
|
|
59
|
+
const staleBeforeMs = state.lastStoppedAtMs ?? startedAtMs;
|
|
43
60
|
for (const run of listRuns(ctx.cwd)) {
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
61
|
+
// Suppress only terminal runs that were already finished before this owner
|
|
62
|
+
// session (or before the previous session switch). Active runs must remain
|
|
63
|
+
// un-seen so completions during auto-compaction/session restart are delivered.
|
|
64
|
+
const updatedAtMs = timeMs(run.updatedAt) ?? 0;
|
|
65
|
+
if (isFinished(run.status) && updatedAtMs < staleBeforeMs) state.seenFinishedRunIds.add(run.runId);
|
|
47
66
|
}
|
|
48
67
|
state.interval = setInterval(() => {
|
|
68
|
+
if (options.isCurrent && !options.isCurrent(generation)) return;
|
|
49
69
|
try {
|
|
50
70
|
for (const run of listRuns(ctx.cwd).slice(0, 20)) {
|
|
51
71
|
const current = markDeadAsyncRunIfNeeded(run) ?? run;
|
|
@@ -64,4 +84,6 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
|
|
|
64
84
|
export function stopAsyncRunNotifier(state: AsyncNotifierState): void {
|
|
65
85
|
if (state.interval) clearInterval(state.interval);
|
|
66
86
|
state.interval = undefined;
|
|
87
|
+
state.generation = (state.generation ?? 0) + 1;
|
|
88
|
+
state.lastStoppedAtMs = Date.now();
|
|
67
89
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { NotificationDescriptor } from "./notification-router.ts";
|
|
4
|
-
import { redactSecrets } from "../
|
|
4
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
5
5
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
6
6
|
|
|
7
7
|
export interface NotificationSink {
|
|
@@ -53,6 +53,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
53
53
|
}
|
|
54
54
|
const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
|
|
55
55
|
let currentCtx: ExtensionContext | undefined;
|
|
56
|
+
let sessionGeneration = 0;
|
|
56
57
|
let rpcHandle: PiCrewRpcHandle | undefined;
|
|
57
58
|
let cleanedUp = false;
|
|
58
59
|
let manifestCache = createManifestCache(process.cwd());
|
|
@@ -153,6 +154,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
153
154
|
sendFollowUp(pi, [notification.title, notification.body].filter((line): line is string => Boolean(line)).join("\n"));
|
|
154
155
|
}
|
|
155
156
|
};
|
|
157
|
+
const captureSessionGeneration = (): number => sessionGeneration;
|
|
158
|
+
const isOwnerSessionCurrent = (ownerGeneration: number | undefined): boolean => !cleanedUp && (ownerGeneration === undefined || ownerGeneration === sessionGeneration);
|
|
159
|
+
const isContextCurrent = (ctx: ExtensionContext, ownerGeneration: number): boolean => !cleanedUp && currentCtx === ctx && sessionGeneration === ownerGeneration;
|
|
156
160
|
const subagentManager = new SubagentManager(
|
|
157
161
|
4,
|
|
158
162
|
(record) => {
|
|
@@ -170,6 +174,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
170
174
|
});
|
|
171
175
|
}
|
|
172
176
|
if (!record.background || record.resultConsumed) return;
|
|
177
|
+
if (!isOwnerSessionCurrent(record.ownerSessionGeneration)) return;
|
|
173
178
|
if (record.status === "completed" || record.status === "failed" || record.status === "cancelled" || record.status === "blocked" || record.status === "error") {
|
|
174
179
|
const metadata = JSON.stringify({ id: record.id, status: record.status, type: record.type, runId: record.runId, description: record.description }, null, 2);
|
|
175
180
|
const joinInstruction = [
|
|
@@ -186,6 +191,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
186
191
|
},
|
|
187
192
|
1000,
|
|
188
193
|
(event, payload) => {
|
|
194
|
+
const ownerGeneration = typeof payload.ownerSessionGeneration === "number" ? payload.ownerSessionGeneration : undefined;
|
|
195
|
+
if (ownerGeneration !== undefined && !isOwnerSessionCurrent(ownerGeneration)) return;
|
|
189
196
|
if (event === "subagent.stuck-blocked") {
|
|
190
197
|
const id = typeof payload.id === "string" ? payload.id : "unknown";
|
|
191
198
|
const runId = typeof payload.runId === "string" ? payload.runId : "unknown";
|
|
@@ -233,6 +240,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
233
240
|
});
|
|
234
241
|
};
|
|
235
242
|
const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
|
|
243
|
+
const ownerGeneration = captureSessionGeneration();
|
|
236
244
|
const controller = new AbortController();
|
|
237
245
|
foregroundControllers.add(controller);
|
|
238
246
|
if (ctx.hasUI) {
|
|
@@ -251,15 +259,16 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
251
259
|
logInternalError("register.foreground-run-failure", statusError, `runId=${runId}`);
|
|
252
260
|
}
|
|
253
261
|
}
|
|
254
|
-
ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
|
|
262
|
+
if (isContextCurrent(ctx, ownerGeneration)) ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
|
|
255
263
|
})
|
|
256
264
|
.finally(() => {
|
|
257
265
|
foregroundControllers.delete(controller);
|
|
258
|
-
|
|
266
|
+
const ownerCurrent = isContextCurrent(ctx, ownerGeneration);
|
|
267
|
+
if (ownerCurrent && ctx.hasUI) {
|
|
259
268
|
setWorkingIndicator(ctx);
|
|
260
269
|
ctx.ui.setWorkingMessage();
|
|
261
270
|
}
|
|
262
|
-
if (runId) {
|
|
271
|
+
if (ownerCurrent && runId) {
|
|
263
272
|
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
264
273
|
const status = loaded?.manifest.status ?? "finished";
|
|
265
274
|
const level = status === "failed" || status === "blocked" ? "error" : status === "cancelled" ? "warning" : "info";
|
|
@@ -287,7 +296,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
287
296
|
});
|
|
288
297
|
}
|
|
289
298
|
}
|
|
290
|
-
if (currentCtx) {
|
|
299
|
+
if (ownerCurrent && currentCtx) {
|
|
291
300
|
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
292
301
|
updateCrewWidget(currentCtx, widgetState, config, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd));
|
|
293
302
|
updatePiCrewPowerbar(pi.events, currentCtx.cwd, config, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd), currentCtx, widgetState.notificationCount ?? 0);
|
|
@@ -328,6 +337,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
328
337
|
notificationSink = undefined;
|
|
329
338
|
rpcHandle?.unsubscribe();
|
|
330
339
|
rpcHandle = undefined;
|
|
340
|
+
sessionGeneration += 1;
|
|
331
341
|
currentCtx = undefined;
|
|
332
342
|
if (globalStore[runtimeCleanupStoreKey] === cleanupRuntime) delete globalStore[runtimeCleanupStoreKey];
|
|
333
343
|
};
|
|
@@ -337,6 +347,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
337
347
|
runArtifactCleanup(ctx.cwd);
|
|
338
348
|
time("register.session-start");
|
|
339
349
|
cleanedUp = false;
|
|
350
|
+
sessionGeneration++;
|
|
351
|
+
const ownerGeneration = sessionGeneration;
|
|
340
352
|
currentCtx = ctx;
|
|
341
353
|
if (widgetState.interval) clearInterval(widgetState.interval);
|
|
342
354
|
widgetState.interval = undefined;
|
|
@@ -346,7 +358,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
346
358
|
configureNotifications(ctx);
|
|
347
359
|
configureObservability(ctx);
|
|
348
360
|
registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
|
|
349
|
-
startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs);
|
|
361
|
+
startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, { generation: ownerGeneration, isCurrent: (generation) => generation === sessionGeneration && currentCtx === ctx && !cleanedUp });
|
|
350
362
|
const cache = getManifestCache(ctx.cwd);
|
|
351
363
|
updateCrewWidget(ctx, widgetState, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd));
|
|
352
364
|
updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd), ctx, widgetState.notificationCount ?? 0);
|
|
@@ -395,7 +407,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
395
407
|
onInvalidate: () => getRunSnapshotCache(ctx.cwd).invalidate(),
|
|
396
408
|
});
|
|
397
409
|
});
|
|
398
|
-
pi.on("session_before_switch", () =>
|
|
410
|
+
pi.on("session_before_switch", () => {
|
|
411
|
+
sessionGeneration++;
|
|
412
|
+
stopAsyncRunNotifier(notifierState);
|
|
413
|
+
stopSessionBoundSubagents();
|
|
414
|
+
});
|
|
399
415
|
pi.on("session_shutdown", () => cleanupRuntime());
|
|
400
416
|
|
|
401
417
|
registerCompactionGuard(pi, { foregroundControllers });
|
|
@@ -417,7 +433,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
417
433
|
});
|
|
418
434
|
|
|
419
435
|
registerTeamTool(pi, { foregroundControllers, startForegroundRun, openLiveSidebar, getManifestCache, getRunSnapshotCache, getMetricRegistry: () => metricRegistry, widgetState });
|
|
420
|
-
registerSubagentTools(pi, subagentManager);
|
|
436
|
+
registerSubagentTools(pi, subagentManager, { ownerSessionGeneration: captureSessionGeneration });
|
|
421
437
|
time("register.tools");
|
|
422
438
|
|
|
423
439
|
registerTeamCommands(pi, { startForegroundRun, openLiveSidebar, getManifestCache, getRunSnapshotCache, getMetricRegistry: () => metricRegistry, dismissNotifications: () => {
|
|
@@ -8,7 +8,11 @@ import { loadConfig } from "../../config/config.ts";
|
|
|
8
8
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
9
9
|
import { __test__subagentSpawnParams, formatSubagentRecord, readSubagentRunResult, refreshPersistedSubagentRecord, subagentToolResult } from "./subagent-helpers.ts";
|
|
10
10
|
|
|
11
|
-
export
|
|
11
|
+
export interface SubagentToolRegistrationOptions {
|
|
12
|
+
ownerSessionGeneration?: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerSubagentTools(pi: ExtensionAPI, subagentManager: SubagentManager, options: SubagentToolRegistrationOptions = {}): void {
|
|
12
16
|
const agentTool: ToolDefinition = {
|
|
13
17
|
name: "Agent",
|
|
14
18
|
label: "Agent",
|
|
@@ -31,11 +35,12 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
|
|
|
31
35
|
const currentRole = currentCrewRole();
|
|
32
36
|
const permission = checkSubagentSpawnPermission(currentRole);
|
|
33
37
|
if (!permission.allowed) return subagentToolResult(permission.reason ?? "Current role cannot spawn subagents.", { role: currentRole, mode: permission.mode }, true);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
38
|
+
const spawnOptions = __test__subagentSpawnParams(params as Record<string, unknown>, ctx);
|
|
39
|
+
spawnOptions.ownerSessionGeneration = options.ownerSessionGeneration?.();
|
|
40
|
+
if (!spawnOptions.prompt.trim()) return subagentToolResult("Agent requires prompt.", {}, true);
|
|
41
|
+
const runner = async (currentOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => handleTeamTool({ action: "run", agent: currentOptions.type, goal: currentOptions.prompt, model: currentOptions.model, async: currentOptions.background, config: currentOptions.maxTurns ? { runtime: { maxTurns: currentOptions.maxTurns } } : undefined } as TeamToolParamsValue, currentOptions.background ? { ...ctx, signal: childSignal } : { ...ctx, signal: childSignal });
|
|
42
|
+
const record = subagentManager.spawn(spawnOptions, runner, spawnOptions.background ? undefined : signal);
|
|
43
|
+
if (spawnOptions.background || record.status === "queued") {
|
|
39
44
|
// Phase 1.1a: Terminate turn for background queued — no LLM follow-up needed.
|
|
40
45
|
// Phase 1.6: Record was terminated for telemetry.
|
|
41
46
|
record.terminated = true;
|
|
@@ -22,6 +22,7 @@ interface ResultWatcherDependencies {
|
|
|
22
22
|
export interface ResultWatcherOptions extends ResultWatcherDependencies {
|
|
23
23
|
eventName?: string;
|
|
24
24
|
completionTtlMs?: number;
|
|
25
|
+
isCurrent?: () => boolean;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const RESULT_WATCHER_RESTART_MS = 3000;
|
|
@@ -40,10 +41,12 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
40
41
|
const eventName = options.eventName ?? "pi-crew:run-result";
|
|
41
42
|
const completionTtlMs = options.completionTtlMs ?? 5 * 60_000;
|
|
42
43
|
const watch = options.watch ?? watchWithErrorHandler;
|
|
44
|
+
const isCurrent = options.isCurrent ?? (() => true);
|
|
43
45
|
const seen = getGlobalSeenMap("pi-crew.result-watcher");
|
|
44
46
|
let watcher: fs.FSWatcher | null | undefined;
|
|
45
47
|
let restartTimer: ReturnType<typeof setTimeout> | undefined;
|
|
46
48
|
const coalescer = createFileCoalescer((file) => {
|
|
49
|
+
if (!isCurrent()) return;
|
|
47
50
|
const filePath = path.join(resultsDir, file);
|
|
48
51
|
if (!file.endsWith(".json") || !fs.existsSync(filePath)) return;
|
|
49
52
|
const payload = readJson(filePath);
|
|
@@ -64,6 +67,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
64
67
|
restartTimer = setTimeout(() => {
|
|
65
68
|
restartTimer = undefined;
|
|
66
69
|
try {
|
|
70
|
+
if (!isCurrent()) return;
|
|
67
71
|
fs.mkdirSync(resultsDir, { recursive: true });
|
|
68
72
|
handle.start();
|
|
69
73
|
} catch (error) {
|
|
@@ -74,6 +78,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
74
78
|
};
|
|
75
79
|
const handle: ResultWatcherHandle = {
|
|
76
80
|
start() {
|
|
81
|
+
if (!isCurrent()) return;
|
|
77
82
|
fs.mkdirSync(resultsDir, { recursive: true });
|
|
78
83
|
if (watcher) closeWatcher(watcher);
|
|
79
84
|
watcher = watch(resultsDir, (event, fileName) => {
|
|
@@ -83,7 +88,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
83
88
|
watcher?.unref?.();
|
|
84
89
|
},
|
|
85
90
|
prime() {
|
|
86
|
-
if (!fs.existsSync(resultsDir)) return;
|
|
91
|
+
if (!isCurrent() || !fs.existsSync(resultsDir)) return;
|
|
87
92
|
for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
|
|
88
93
|
},
|
|
89
94
|
stop() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { loadConfig } from "../../config/config.ts";
|
|
3
3
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
4
|
-
import { saveRunTasks,
|
|
4
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/state-store.ts";
|
|
5
5
|
import { withRunLockSync } from "../../state/locks.ts";
|
|
6
6
|
import { canTransitionTaskStatus, isTeamTaskStatus } from "../../state/contracts.ts";
|
|
7
7
|
import { claimTask, releaseTaskClaim, transitionClaimedTaskStatus } from "../../state/task-claims.ts";
|
|
@@ -9,6 +9,7 @@ import { acknowledgeMailboxMessage, appendMailboxMessage, readDeliveryState, rea
|
|
|
9
9
|
import { appendEvent, readEvents, readEventsCursor } from "../../state/event-log.ts";
|
|
10
10
|
import { resolveCrewRuntime } from "../../runtime/runtime-resolver.ts";
|
|
11
11
|
import { probeLiveSessionRuntime } from "../../subagents/live/session-runtime.ts";
|
|
12
|
+
import { currentCrewRole, permissionForRole } from "../../runtime/role-permission.ts";
|
|
12
13
|
import { touchWorkerHeartbeat } from "../../runtime/worker-heartbeat.ts";
|
|
13
14
|
import { agentOutputPath, readCrewAgentEventsCursor, readCrewAgentStatus, readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
14
15
|
import { buildAgentDashboard, readAgentOutput } from "../../runtime/agent-observability.ts";
|
|
@@ -54,6 +55,13 @@ function snapshotHasRunId(snapshot: { values?: unknown }, runId: string): boolea
|
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function canApprovePlan(): { allowed: boolean; reason?: string } {
|
|
59
|
+
const role = currentCrewRole();
|
|
60
|
+
if (!role) return { allowed: true };
|
|
61
|
+
if (permissionForRole(role) === "read_only") return { allowed: false, reason: `Role '${role}' is read-only and cannot approve or cancel plan gates.` };
|
|
62
|
+
return { allowed: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
58
66
|
const cfg = configRecord(params.config);
|
|
59
67
|
const operation = typeof cfg.operation === "string" ? cfg.operation : "read-manifest";
|
|
@@ -74,6 +82,47 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
74
82
|
if (operation === "read-manifest") {
|
|
75
83
|
return result(JSON.stringify(loaded.manifest, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
|
|
76
84
|
}
|
|
85
|
+
if (operation === "approve-plan") {
|
|
86
|
+
const permission = canApprovePlan();
|
|
87
|
+
if (!permission.allowed) return result(permission.reason ?? "Plan approval is not allowed in this context.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
88
|
+
try {
|
|
89
|
+
return withRunLockSync(loaded.manifest, () => {
|
|
90
|
+
const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded;
|
|
91
|
+
const approval = current.manifest.planApproval;
|
|
92
|
+
if (!approval?.required || approval.status !== "pending") return result("Run has no pending plan approval request.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const manifest = { ...current.manifest, updatedAt: now, planApproval: { ...approval, status: "approved" as const, approvedAt: now, updatedAt: now } };
|
|
95
|
+
saveRunManifest(manifest);
|
|
96
|
+
appendEvent(manifest.eventsPath, { type: "plan.approved", runId: manifest.runId, taskId: approval.planTaskId, message: "Adaptive implementation plan approved; resume the run to execute mutating tasks.", metadata: { provenance: "api" } });
|
|
97
|
+
return result(JSON.stringify(manifest.planApproval, null, 2), { action: "api", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot });
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (operation === "cancel-plan") {
|
|
105
|
+
const permission = canApprovePlan();
|
|
106
|
+
if (!permission.allowed) return result(permission.reason ?? "Plan approval cancellation is not allowed in this context.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
107
|
+
try {
|
|
108
|
+
return withRunLockSync(loaded.manifest, () => {
|
|
109
|
+
const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded;
|
|
110
|
+
const approval = current.manifest.planApproval;
|
|
111
|
+
if (!approval?.required || approval.status !== "pending") return result("Run has no pending plan approval request.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
const tasks = current.tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled" as const, finishedAt: now, error: "Plan approval was cancelled." } : task);
|
|
114
|
+
let manifest: typeof current.manifest = { ...current.manifest, updatedAt: now, planApproval: { ...approval, status: "cancelled" as const, cancelledAt: now, updatedAt: now } };
|
|
115
|
+
saveRunManifest(manifest);
|
|
116
|
+
saveRunTasks(manifest, tasks);
|
|
117
|
+
appendEvent(manifest.eventsPath, { type: "plan.cancelled", runId: manifest.runId, taskId: approval.planTaskId, message: "Adaptive implementation plan was cancelled.", metadata: { provenance: "api" } });
|
|
118
|
+
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
119
|
+
return result(JSON.stringify({ planApproval: manifest.planApproval, cancelledTasks: tasks.filter((task) => task.status === "cancelled").map((task) => task.id) }, null, 2), { action: "api", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot });
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
77
126
|
if (operation === "list-tasks") {
|
|
78
127
|
return result(JSON.stringify(loaded.tasks, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
|
|
79
128
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { redactSecrets } from "../
|
|
3
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
4
4
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
5
|
import type { MetricRegistry } from "./metric-registry.ts";
|
|
6
6
|
import type { MetricSnapshot } from "./metrics-primitives.ts";
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
|
|
|
7
7
|
import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
8
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
9
|
import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
|
|
10
|
+
import { redactJsonLine } from "../utils/redaction.ts";
|
|
10
11
|
|
|
11
12
|
const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
|
|
12
13
|
const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
|
|
@@ -118,7 +119,7 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
|
|
|
118
119
|
function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
119
120
|
if (!input.transcriptPath) return;
|
|
120
121
|
fs.mkdirSync(path.dirname(input.transcriptPath), { recursive: true });
|
|
121
|
-
fs.appendFileSync(input.transcriptPath, `${line}\n`, "utf-8");
|
|
122
|
+
fs.appendFileSync(input.transcriptPath, `${redactJsonLine(line)}\n`, "utf-8");
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
function compactString(value: string, maxChars = MAX_COMPACT_CONTENT_CHARS): string {
|
|
@@ -7,6 +7,7 @@ import type { CrewAgentProgress, CrewAgentRecord, CrewRuntimeKind } from "./crew
|
|
|
7
7
|
import { taskStatusToAgentStatus } from "./crew-agent-runtime.ts";
|
|
8
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
9
|
import { assertSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
10
|
+
import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
|
|
10
11
|
|
|
11
12
|
export function agentsPath(manifest: TeamRunManifest): string {
|
|
12
13
|
return path.join(manifest.stateRoot, "agents.json");
|
|
@@ -71,7 +72,7 @@ export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
|
|
|
71
72
|
|
|
72
73
|
export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
73
74
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
74
|
-
atomicWriteJson(agentsPath(manifest), records);
|
|
75
|
+
atomicWriteJson(agentsPath(manifest), redactSecrets(records));
|
|
75
76
|
for (const record of records) writeCrewAgentStatus(manifest, record);
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -84,7 +85,7 @@ export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentReco
|
|
|
84
85
|
|
|
85
86
|
export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
86
87
|
ensureAgentStateDir(manifest, record.taskId);
|
|
87
|
-
atomicWriteJson(agentStatusPath(manifest, record.taskId), record);
|
|
88
|
+
atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record));
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: string): CrewAgentRecord | undefined {
|
|
@@ -121,7 +122,7 @@ export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string,
|
|
|
121
122
|
ensureAgentStateDir(manifest, taskId);
|
|
122
123
|
const filePath = agentStateFile(manifest, taskId, "events.jsonl");
|
|
123
124
|
const seq = nextAgentEventSeq(filePath);
|
|
124
|
-
fs.appendFileSync(filePath, `${JSON.stringify({ seq, time: new Date().toISOString(), event })}\n`, "utf-8");
|
|
125
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ seq, time: new Date().toISOString(), event }))}\n`, "utf-8");
|
|
125
126
|
try {
|
|
126
127
|
const stat = fs.statSync(filePath);
|
|
127
128
|
agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
|
|
@@ -172,7 +173,7 @@ export function readCrewAgentEventsCursor(manifest: TeamRunManifest, taskId: str
|
|
|
172
173
|
export function appendCrewAgentOutput(manifest: TeamRunManifest, taskId: string, text: string): void {
|
|
173
174
|
if (!text.trim()) return;
|
|
174
175
|
ensureAgentStateDir(manifest, taskId);
|
|
175
|
-
fs.appendFileSync(agentStateFile(manifest, taskId, "output.log"), `${text}\n`, "utf-8");
|
|
176
|
+
fs.appendFileSync(agentStateFile(manifest, taskId, "output.log"), `${redactSecretString(text)}\n`, "utf-8");
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
export function emptyCrewAgentProgress(): CrewAgentProgress {
|
|
@@ -9,6 +9,8 @@ import { loadRunManifestById } from "../state/state-store.ts";
|
|
|
9
9
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
10
10
|
import { summarizeHeartbeats, type HeartbeatSummary } from "../ui/heartbeat-aggregator.ts";
|
|
11
11
|
import type { RunUiSnapshot } from "../ui/snapshot-types.ts";
|
|
12
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
13
|
+
export { redactSecrets } from "../utils/redaction.ts";
|
|
12
14
|
|
|
13
15
|
export interface DiagnosticReport {
|
|
14
16
|
schemaVersion?: number;
|
|
@@ -25,22 +27,6 @@ export interface DiagnosticReport {
|
|
|
25
27
|
|
|
26
28
|
const SECRET_KEY_PATTERN = /(token|key|password|secret|credential|auth)/i;
|
|
27
29
|
|
|
28
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function redactSecrets(value: unknown, keyName = ""): unknown {
|
|
33
|
-
if (SECRET_KEY_PATTERN.test(keyName)) return "***";
|
|
34
|
-
if (typeof value === "string") return value.replace(/((?:token|key|password|secret|credential|auth)[\w.-]*\s*[=:]\s*)[^\s,;]+/gi, "$1***");
|
|
35
|
-
if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
|
|
36
|
-
if (isRecord(value)) {
|
|
37
|
-
const output: Record<string, unknown> = {};
|
|
38
|
-
for (const [key, entry] of Object.entries(value)) output[key] = redactSecrets(entry, key);
|
|
39
|
-
return output;
|
|
40
|
-
}
|
|
41
|
-
return value;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
30
|
function envRedacted(): Record<string, string> {
|
|
45
31
|
const output: Record<string, string> = {};
|
|
46
32
|
for (const [key, value] of Object.entries(process.env)) {
|
|
@@ -10,6 +10,7 @@ import { subscribeLiveControlRealtime } from "./live-control-realtime.ts";
|
|
|
10
10
|
import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "./sidechain-output.ts";
|
|
11
11
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
12
12
|
import { isLiveSessionRuntimeAvailable } from "./runtime-resolver.ts";
|
|
13
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
13
14
|
|
|
14
15
|
export interface LiveSessionSpawnInput {
|
|
15
16
|
manifest: TeamRunManifest;
|
|
@@ -25,6 +26,7 @@ export interface LiveSessionSpawnInput {
|
|
|
25
26
|
parentContext?: string;
|
|
26
27
|
parentModel?: unknown;
|
|
27
28
|
modelRegistry?: unknown;
|
|
29
|
+
isCurrent?: () => boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface LiveSessionRunResult {
|
|
@@ -70,7 +72,7 @@ type LiveSessionLike = {
|
|
|
70
72
|
function appendTranscript(filePath: string | undefined, event: unknown): void {
|
|
71
73
|
if (!filePath) return;
|
|
72
74
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
73
|
-
fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, "utf-8");
|
|
75
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(event))}\n`, "utf-8");
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -173,6 +175,7 @@ export async function probeLiveSessionRuntime(): Promise<LiveSessionUnavailableR
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<LiveSessionRunResult> {
|
|
178
|
+
const isCurrent = input.isCurrent ?? (() => true);
|
|
176
179
|
if (process.env.PI_CREW_MOCK_LIVE_SESSION === "success") {
|
|
177
180
|
const agentId = `${input.manifest.runId}:${input.task.id}`;
|
|
178
181
|
const inherited = input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
|
|
@@ -183,9 +186,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
183
186
|
const sidechainPath = sidechainOutputPath(input.manifest.stateRoot, input.task.id);
|
|
184
187
|
writeSidechainEntry(sidechainPath, { agentId, type: "user", message: { role: "user", content: input.prompt }, cwd: input.task.cwd });
|
|
185
188
|
writeSidechainEntry(sidechainPath, { agentId, type: "message", message: event, cwd: input.task.cwd });
|
|
186
|
-
input.onEvent?.(event);
|
|
189
|
+
if (isCurrent()) input.onEvent?.(event);
|
|
187
190
|
const stdout = `Mock live-session success for ${input.agent.name}${inherited}`;
|
|
188
|
-
input.onOutput?.(stdout);
|
|
191
|
+
if (isCurrent()) input.onOutput?.(stdout);
|
|
189
192
|
updateLiveAgentStatus(agentId, "completed");
|
|
190
193
|
return { available: true, exitCode: 0, stdout, stderr: "", jsonEvents: 1 };
|
|
191
194
|
}
|
|
@@ -234,7 +237,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
234
237
|
const seenControlRequestIds = new Set<string>();
|
|
235
238
|
let controlBusy = false;
|
|
236
239
|
const pollControl = async () => {
|
|
237
|
-
if (controlBusy || !session) return;
|
|
240
|
+
if (!isCurrent() || controlBusy || !session) return;
|
|
238
241
|
controlBusy = true;
|
|
239
242
|
try {
|
|
240
243
|
controlCursor = await applyLiveAgentControlRequests({ manifest: input.manifest, taskId: input.task.id, agentId, session, cursor: controlCursor, seenRequestIds: seenControlRequestIds });
|
|
@@ -243,11 +246,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
243
246
|
}
|
|
244
247
|
};
|
|
245
248
|
unsubscribeControlRealtime = subscribeLiveControlRealtime((request) => {
|
|
246
|
-
if (request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
|
|
249
|
+
if (!isCurrent() || request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
|
|
247
250
|
void applyLiveAgentControlRequest({ request, taskId: input.task.id, agentId, session, seenRequestIds: seenControlRequestIds });
|
|
248
251
|
});
|
|
249
252
|
await pollControl();
|
|
250
|
-
controlTimer = setInterval(() => {
|
|
253
|
+
controlTimer = setInterval(() => {
|
|
254
|
+
if (isCurrent()) void pollControl();
|
|
255
|
+
}, 500);
|
|
251
256
|
let turnCount = 0;
|
|
252
257
|
let softLimitReached = false;
|
|
253
258
|
const maxTurns = input.runtimeConfig?.maxTurns;
|
|
@@ -256,6 +261,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
256
261
|
writeSidechainEntry(sidechainPath, { agentId, type: "user", message: { role: "user", content: input.prompt }, cwd: input.task.cwd });
|
|
257
262
|
if (typeof session.subscribe === "function") {
|
|
258
263
|
unsubscribe = session.subscribe((event) => {
|
|
264
|
+
if (!isCurrent()) return;
|
|
259
265
|
jsonEvents += 1;
|
|
260
266
|
appendTranscript(input.transcriptPath, event);
|
|
261
267
|
const sidechainType = eventToSidechainType(event);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
3
4
|
|
|
4
5
|
export interface SidechainEntry {
|
|
5
6
|
isSidechain: true;
|
|
@@ -12,7 +13,7 @@ export interface SidechainEntry {
|
|
|
12
13
|
|
|
13
14
|
export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
|
|
14
15
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15
|
-
fs.appendFileSync(filePath, `${JSON.stringify({ isSidechain: true, timestamp: new Date().toISOString(), ...entry })}\n`, "utf-8");
|
|
16
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`, "utf-8");
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function sidechainOutputPath(stateRoot: string, taskId: string): string {
|
|
@@ -6,6 +6,7 @@ import { DEFAULT_SUBAGENT } from "../config/defaults.ts";
|
|
|
6
6
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
7
7
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
8
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
9
10
|
|
|
10
11
|
export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "blocked" | "stopped";
|
|
11
12
|
|
|
@@ -17,6 +18,7 @@ export interface SubagentSpawnOptions {
|
|
|
17
18
|
background: boolean;
|
|
18
19
|
model?: string;
|
|
19
20
|
maxTurns?: number;
|
|
21
|
+
ownerSessionGeneration?: number;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface SubagentRecord {
|
|
@@ -33,6 +35,7 @@ export interface SubagentRecord {
|
|
|
33
35
|
resultConsumed?: boolean;
|
|
34
36
|
model?: string;
|
|
35
37
|
background: boolean;
|
|
38
|
+
ownerSessionGeneration?: number;
|
|
36
39
|
stuckNotified?: boolean;
|
|
37
40
|
blockedAt?: number;
|
|
38
41
|
promise?: Promise<void>;
|
|
@@ -66,7 +69,7 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
|
|
|
66
69
|
try {
|
|
67
70
|
const filePath = persistedSubagentPath(cwd, record.id);
|
|
68
71
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
69
|
-
fs.writeFileSync(filePath, `${JSON.stringify(serializableRecord(record), null, 2)}\n`, "utf-8");
|
|
72
|
+
fs.writeFileSync(filePath, `${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`, "utf-8");
|
|
70
73
|
} catch (error) {
|
|
71
74
|
logInternalError("subagent-manager.save", error, `id=${record.id}`);
|
|
72
75
|
}
|
|
@@ -136,6 +139,7 @@ export class SubagentManager {
|
|
|
136
139
|
startedAt: Date.now(),
|
|
137
140
|
model: options.model,
|
|
138
141
|
background: options.background,
|
|
142
|
+
ownerSessionGeneration: options.ownerSessionGeneration,
|
|
139
143
|
};
|
|
140
144
|
this.records.set(record.id, record);
|
|
141
145
|
this.cwdByRecord.set(record.id, options.cwd);
|
|
@@ -373,6 +377,7 @@ export class SubagentManager {
|
|
|
373
377
|
id: current.id,
|
|
374
378
|
runId: current.runId,
|
|
375
379
|
durationMs: Math.max(0, Date.now() - current.blockedAt),
|
|
380
|
+
ownerSessionGeneration: current.ownerSessionGeneration,
|
|
376
381
|
});
|
|
377
382
|
savePersistedSubagentRecord(cwd, current);
|
|
378
383
|
};
|
|
@@ -24,6 +24,7 @@ export interface RunLiveTaskInput {
|
|
|
24
24
|
parentContext?: string;
|
|
25
25
|
parentModel?: unknown;
|
|
26
26
|
modelRegistry?: unknown;
|
|
27
|
+
isCurrent?: () => boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface RunLiveTaskOutput {
|
|
@@ -65,6 +66,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
|
|
|
65
66
|
}
|
|
66
67
|
};
|
|
67
68
|
const attemptStartedAt = new Date();
|
|
69
|
+
const isCurrent = input.isCurrent ?? (() => input.signal?.aborted !== true);
|
|
68
70
|
const liveResult = await runLiveSessionTask({
|
|
69
71
|
manifest,
|
|
70
72
|
task,
|
|
@@ -77,6 +79,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
|
|
|
77
79
|
parentContext: input.parentContext,
|
|
78
80
|
parentModel: input.parentModel,
|
|
79
81
|
modelRegistry: input.modelRegistry,
|
|
82
|
+
isCurrent,
|
|
80
83
|
onOutput: (text) => appendCrewAgentOutput(manifest, task.id, text),
|
|
81
84
|
onEvent: (event) => {
|
|
82
85
|
appendCrewAgentEvent(manifest, task.id, event);
|
|
@@ -24,6 +24,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
|
24
24
|
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
25
25
|
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
26
26
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
27
|
+
import { permissionForRole } from "./role-permission.ts";
|
|
27
28
|
|
|
28
29
|
export interface ExecuteTeamRunInput {
|
|
29
30
|
manifest: TeamRunManifest;
|
|
@@ -398,6 +399,47 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
|
|
|
398
399
|
return result.tasks.find((item) => item.id === taskId && item.status === "failed");
|
|
399
400
|
}
|
|
400
401
|
|
|
402
|
+
function requiresPlanApproval(workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
|
|
403
|
+
return workflow.name === "implementation" && runtimeConfig?.requirePlanApproval === true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
|
|
407
|
+
return manifest.planApproval?.required === true && manifest.planApproval.status === "pending";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isMutatingTask(task: TeamTaskState): boolean {
|
|
411
|
+
return permissionForRole(task.role) !== "read_only";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
|
|
415
|
+
if (manifest.planApproval) return manifest;
|
|
416
|
+
const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
|
|
417
|
+
const now = new Date().toISOString();
|
|
418
|
+
const updated: TeamRunManifest = {
|
|
419
|
+
...manifest,
|
|
420
|
+
updatedAt: now,
|
|
421
|
+
planApproval: {
|
|
422
|
+
required: true,
|
|
423
|
+
status: "pending",
|
|
424
|
+
requestedAt: now,
|
|
425
|
+
updatedAt: now,
|
|
426
|
+
planTaskId: assessTask?.id,
|
|
427
|
+
planArtifactPath: assessTask?.resultArtifact?.path,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
saveRunManifest(updated);
|
|
431
|
+
appendEvent(updated.eventsPath, { type: "plan.approval_required", runId: updated.runId, taskId: assessTask?.id, message: "Adaptive implementation plan requires explicit approval before mutating tasks run.", data: { planArtifactPath: assessTask?.resultArtifact?.path } });
|
|
432
|
+
return updated;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function cancelPlanTasks(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
436
|
+
return tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled", finishedAt: new Date().toISOString(), error: reason, graph: task.graph ? { ...task.graph, queue: "done" } : undefined } : task);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function hasPendingMutatingAdaptiveTask(tasks: TeamTaskState[]): boolean {
|
|
440
|
+
return tasks.some((task) => task.status === "queued" && task.adaptive && isMutatingTask(task));
|
|
441
|
+
}
|
|
442
|
+
|
|
401
443
|
export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
402
444
|
let workflow = input.workflow;
|
|
403
445
|
let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
|
|
@@ -422,7 +464,18 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
422
464
|
manifest = updateRunStatus(manifest, "blocked", "Adaptive planner did not produce a valid subagent plan.");
|
|
423
465
|
return { manifest, tasks };
|
|
424
466
|
}
|
|
425
|
-
if (initialAdaptive.injected)
|
|
467
|
+
if (initialAdaptive.injected) {
|
|
468
|
+
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
469
|
+
queueIndex = buildTaskGraphIndex(tasks);
|
|
470
|
+
} else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
|
|
471
|
+
manifest = ensurePlanApprovalRequested(manifest, tasks);
|
|
472
|
+
}
|
|
473
|
+
if (manifest.planApproval?.status === "cancelled") {
|
|
474
|
+
tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
|
|
475
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
476
|
+
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
477
|
+
return { manifest, tasks };
|
|
478
|
+
}
|
|
426
479
|
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
427
480
|
await saveRunManifestAsync(manifest);
|
|
428
481
|
const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
|
|
@@ -451,8 +504,16 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
451
504
|
if (concurrency.reason.includes(";unbounded:")) {
|
|
452
505
|
appendEvent(manifest.eventsPath, { type: "limits.unbounded", runId: manifest.runId, message: "Unbounded worker concurrency was explicitly enabled for this run.", data: { concurrencyReason: concurrency.reason, maxConcurrent: concurrency.maxConcurrent } });
|
|
453
506
|
}
|
|
454
|
-
const
|
|
507
|
+
const approvalPending = isPlanApprovalPending(manifest);
|
|
508
|
+
const candidateBatch = approvalPending ? getReadyTasks(tasks, tasks.length, queueIndex) : getReadyTasks(tasks, concurrency.selectedCount, queueIndex);
|
|
509
|
+
const readyBatch = approvalPending ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, concurrency.selectedCount) : candidateBatch;
|
|
455
510
|
if (readyBatch.length === 0) {
|
|
511
|
+
if (approvalPending && candidateBatch.some(isMutatingTask)) {
|
|
512
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
513
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
514
|
+
manifest = updateRunStatus(manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
|
|
515
|
+
return { manifest, tasks };
|
|
516
|
+
}
|
|
456
517
|
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
457
518
|
await saveRunTasksAsync(manifest, tasks);
|
|
458
519
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
@@ -460,7 +521,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
460
521
|
return { manifest, tasks };
|
|
461
522
|
}
|
|
462
523
|
|
|
463
|
-
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: concurrency.reason } });
|
|
524
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason } });
|
|
464
525
|
const results = await mapConcurrent(
|
|
465
526
|
readyBatch,
|
|
466
527
|
concurrency.selectedCount,
|
|
@@ -520,7 +581,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
520
581
|
return { manifest, tasks };
|
|
521
582
|
}
|
|
522
583
|
if (injectedAfterBatch.injected) {
|
|
584
|
+
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
523
585
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
586
|
+
} else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
|
|
587
|
+
manifest = ensurePlanApprovalRequested(manifest, tasks);
|
|
588
|
+
}
|
|
589
|
+
if (manifest.planApproval?.status === "cancelled") {
|
|
590
|
+
tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
|
|
591
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
592
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
593
|
+
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
594
|
+
return { manifest, tasks };
|
|
524
595
|
}
|
|
525
596
|
await saveRunTasksAsync(manifest, tasks);
|
|
526
597
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
@@ -36,6 +36,7 @@ export const PiTeamsRuntimeConfigSchema = Type.Object({
|
|
|
36
36
|
inheritContext: Type.Optional(Type.Boolean()),
|
|
37
37
|
promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
|
|
38
38
|
groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
|
|
39
|
+
requirePlanApproval: Type.Optional(Type.Boolean()),
|
|
39
40
|
}, { additionalProperties: false });
|
|
40
41
|
|
|
41
42
|
export const PiTeamsControlConfigSchema = Type.Object({
|
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import type { ArtifactDescriptor } from "./types.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
6
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
|
+
import { redactSecretString } from "../utils/redaction.ts";
|
|
7
8
|
|
|
8
9
|
function hashContent(content: string): string {
|
|
9
10
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -108,7 +109,8 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
108
109
|
resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
|
|
109
110
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
110
111
|
resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
|
|
111
|
-
|
|
112
|
+
const content = redactSecretString(options.content);
|
|
113
|
+
atomicWriteFile(filePath, content);
|
|
112
114
|
const stats = fs.statSync(filePath);
|
|
113
115
|
return {
|
|
114
116
|
kind: options.kind,
|
|
@@ -116,7 +118,7 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
116
118
|
createdAt: new Date().toISOString(),
|
|
117
119
|
producer: options.producer,
|
|
118
120
|
sizeBytes: stats.size,
|
|
119
|
-
contentHash: hashContent(
|
|
121
|
+
contentHash: hashContent(content),
|
|
120
122
|
retention: options.retention ?? "run",
|
|
121
123
|
};
|
|
122
124
|
}
|
package/src/state/event-log.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
7
8
|
|
|
8
9
|
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
|
|
9
10
|
export type TeamWatcherAction = "act" | "observe" | "ignore";
|
|
@@ -135,7 +136,7 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
|
|
|
135
136
|
} catch (error) {
|
|
136
137
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
137
138
|
}
|
|
138
|
-
fs.appendFileSync(eventsPath, `${JSON.stringify(fullEvent)}\n`, "utf-8");
|
|
139
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
|
|
139
140
|
const seq = fullEvent.metadata?.seq ?? 0;
|
|
140
141
|
try {
|
|
141
142
|
const stat = fs.statSync(eventsPath);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { redactJsonLine } from "../utils/redaction.ts";
|
|
2
3
|
|
|
3
4
|
export interface DrainableSource {
|
|
4
5
|
pause(): void;
|
|
@@ -50,7 +51,8 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
|
|
|
50
51
|
return {
|
|
51
52
|
writeLine(line: string) {
|
|
52
53
|
if (!stream || closed || !line.trim()) return;
|
|
53
|
-
const
|
|
54
|
+
const safeLine = redactJsonLine(line);
|
|
55
|
+
const chunk = `${safeLine}\n`;
|
|
54
56
|
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
|
|
55
57
|
if (bytesWritten + chunkBytes > maxBytes) return;
|
|
56
58
|
try {
|
package/src/state/mailbox.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { TeamRunManifest } from "./types.ts";
|
|
4
4
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
5
|
+
import { redactSecrets } from "../utils/redaction.ts";
|
|
5
6
|
|
|
6
7
|
export type MailboxDirection = "inbox" | "outbox";
|
|
7
8
|
export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
|
|
@@ -190,7 +191,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
190
191
|
|
|
191
192
|
function writeDeliveryState(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
|
|
192
193
|
ensureRunMailbox(manifest);
|
|
193
|
-
fs.writeFileSync(deliveryFile(manifest, true), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
194
|
+
fs.writeFileSync(deliveryFile(manifest, true), `${JSON.stringify(redactSecrets(state), null, 2)}\n`, "utf-8");
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & { id?: string; status?: MailboxMessageStatus }): MailboxMessage {
|
|
@@ -208,7 +209,7 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
|
|
|
208
209
|
status: message.status ?? "queued",
|
|
209
210
|
taskId: message.taskId,
|
|
210
211
|
};
|
|
211
|
-
fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(complete)}\n`, "utf-8");
|
|
212
|
+
fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
212
213
|
const delivery = readDeliveryState(manifest);
|
|
213
214
|
delivery.messages[complete.id] = complete.status;
|
|
214
215
|
delivery.updatedAt = createdAt;
|
|
@@ -249,7 +250,7 @@ export function validateMailbox(manifest: TeamRunManifest, options: { repair?: b
|
|
|
249
250
|
const parsed = JSON.parse(line) as unknown;
|
|
250
251
|
const message = parseMailboxMessage(parsed, direction);
|
|
251
252
|
if (!message) throw new Error("invalid message schema");
|
|
252
|
-
validLines.push(JSON.stringify(message));
|
|
253
|
+
validLines.push(JSON.stringify(redactSecrets(message)));
|
|
253
254
|
} catch (error) {
|
|
254
255
|
const message = error instanceof Error ? error.message : String(error);
|
|
255
256
|
issues.push({ level: "error", path: filePath, message });
|
package/src/state/types.ts
CHANGED
|
@@ -81,6 +81,17 @@ export interface AsyncRunState {
|
|
|
81
81
|
spawnedAt: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
export interface PlanApprovalState {
|
|
85
|
+
required: boolean;
|
|
86
|
+
status: "pending" | "approved" | "cancelled";
|
|
87
|
+
requestedAt: string;
|
|
88
|
+
updatedAt: string;
|
|
89
|
+
approvedAt?: string;
|
|
90
|
+
cancelledAt?: string;
|
|
91
|
+
planTaskId?: string;
|
|
92
|
+
planArtifactPath?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
export interface TeamRunManifest {
|
|
85
96
|
schemaVersion: 1;
|
|
86
97
|
runId: string;
|
|
@@ -98,6 +109,7 @@ export interface TeamRunManifest {
|
|
|
98
109
|
eventsPath: string;
|
|
99
110
|
artifacts: ArtifactDescriptor[];
|
|
100
111
|
async?: AsyncRunState;
|
|
112
|
+
planApproval?: PlanApprovalState;
|
|
101
113
|
summary?: string;
|
|
102
114
|
policyDecisions?: PolicyDecision[];
|
|
103
115
|
}
|
|
@@ -109,7 +109,7 @@ export function discoverTeams(cwd: string): TeamDiscoveryResult {
|
|
|
109
109
|
|
|
110
110
|
export function allTeams(discovery: TeamDiscoveryResult): TeamConfig[] {
|
|
111
111
|
const byName = new Map<string, TeamConfig>();
|
|
112
|
-
for (const team of [...discovery.
|
|
112
|
+
for (const team of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
|
|
113
113
|
byName.set(team.name, team);
|
|
114
114
|
}
|
|
115
115
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const SECRET_KEY_PATTERN = /(?:^|[_.-])(token|api[-_]?key|password|passwd|secret|credential|authorization|private[-_]?key)(?:$|[_.-])/i;
|
|
2
|
+
const INLINE_SECRET_PATTERN = /(^|[\s,{])(([A-Za-z0-9_.-]*(?:api[-_]?key|token|password|passwd|secret|credential|authorization|private[-_]?key)[A-Za-z0-9_.-]*)\s*[=:]\s*)([^\s,;"'}]+)/gi;
|
|
3
|
+
const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*(?:Bearer|Basic|Token)?\s*)([^\r\n]+)/gi;
|
|
4
|
+
const BEARER_PATTERN = /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})\b/g;
|
|
5
|
+
const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
6
|
+
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isSecretKey(keyName: string): boolean {
|
|
12
|
+
return SECRET_KEY_PATTERN.test(keyName) || /^(token|apiKey|api_key|password|secret|credential|authorization|privateKey|private_key)$/i.test(keyName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redactSecretString(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replace(PEM_PRIVATE_KEY_PATTERN, "***")
|
|
18
|
+
.replace(AUTH_HEADER_PATTERN, "$1***")
|
|
19
|
+
.replace(BEARER_PATTERN, "$1***")
|
|
20
|
+
.replace(INLINE_SECRET_PATTERN, "$1$2***");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function redactSecrets(value: unknown, keyName = ""): unknown {
|
|
24
|
+
if (keyName && isSecretKey(keyName)) return "***";
|
|
25
|
+
if (typeof value === "string") return redactSecretString(value);
|
|
26
|
+
if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
|
|
27
|
+
if (isRecord(value)) {
|
|
28
|
+
const output: Record<string, unknown> = {};
|
|
29
|
+
for (const [key, entry] of Object.entries(value)) output[key] = redactSecrets(entry, key);
|
|
30
|
+
return output;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function redactJsonLine(line: string): string {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(redactSecrets(JSON.parse(line) as unknown));
|
|
38
|
+
} catch {
|
|
39
|
+
return redactSecretString(line);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -129,7 +129,7 @@ export function discoverWorkflows(cwd: string): WorkflowDiscoveryResult {
|
|
|
129
129
|
|
|
130
130
|
export function allWorkflows(discovery: WorkflowDiscoveryResult): WorkflowConfig[] {
|
|
131
131
|
const byName = new Map<string, WorkflowConfig>();
|
|
132
|
-
for (const workflow of [...discovery.
|
|
132
|
+
for (const workflow of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
|
|
133
133
|
byName.set(workflow.name, workflow);
|
|
134
134
|
}
|
|
135
135
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|