pi-crew 0.1.38 → 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.
@@ -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, loadRunManifestById } from "../../state/state-store.ts";
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
  }
@@ -133,8 +182,11 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
133
182
  const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
134
183
  if (!agent) return result("API read-agent-transcript requires config.agentId matching an agent id or task id, or at least one agent in the run.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
135
184
  const artifactTranscriptPath = safeContainedPath(loaded.manifest.artifactsRoot, agent.transcriptPath);
136
- const transcriptPath = artifactTranscriptPath ?? agentOutputPath(loaded.manifest, agent.taskId);
137
- const text = artifactTranscriptPath ? safeReadContainedFile(loaded.manifest.artifactsRoot, artifactTranscriptPath) ?? "" : safeReadContainedFile(loaded.manifest.stateRoot, transcriptPath) ?? "";
185
+ const fallbackPath = agentOutputPath(loaded.manifest, agent.taskId);
186
+ const artifactText = artifactTranscriptPath ? safeReadContainedFile(loaded.manifest.artifactsRoot, artifactTranscriptPath) ?? "" : "";
187
+ const fallbackText = artifactText ? "" : safeReadContainedFile(loaded.manifest.stateRoot, fallbackPath) ?? "";
188
+ const transcriptPath = artifactText ? artifactTranscriptPath : fallbackPath;
189
+ const text = artifactText || fallbackText;
138
190
  return result(text || `(no transcript at ${transcriptPath})`, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
139
191
  }
140
192
  if (operation === "read-agent-output") {
@@ -191,11 +243,16 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
191
243
  const task = loaded.tasks.find((item) => item.id === agent.taskId);
192
244
  if (!task) return result(`API ${operation} agent '${agentId}' does not match a run task.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
193
245
  if (operation === "resume-agent" && !prompt) return result("API resume-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
194
- const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: task.id, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" ? prompt : message });
195
- publishLiveControlRealtime(request);
196
- ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
197
- appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, realtime: true } });
198
- return result(JSON.stringify({ queued: true, request }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
246
+ try {
247
+ const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: task.id, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" ? prompt : message });
248
+ publishLiveControlRealtime(request);
249
+ ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
250
+ appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, realtime: true } });
251
+ return result(JSON.stringify({ queued: true, request }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
252
+ } catch (queueError) {
253
+ const message = queueError instanceof Error ? queueError.message : String(queueError);
254
+ return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
255
+ }
199
256
  }
200
257
  }
201
258
  if (operation === "read-mailbox") {
@@ -107,8 +107,8 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
107
107
  const userWritable = checkWritableDir(userCrewRoot());
108
108
  const projectWritable = checkWritableDir(projectCrewRoot(input.cwd));
109
109
  return [
110
- { label: "user state", ok: userWritable.ok, detail: userWritable.detail },
111
- { label: "project state", ok: projectWritable.ok, detail: projectWritable.detail },
110
+ { label: "user state", ok: userWritable.ok || userWritable.detail.endsWith(": missing"), detail: userWritable.detail },
111
+ { label: "project state", ok: projectWritable.ok || projectWritable.detail.endsWith(": missing"), detail: projectWritable.detail },
112
112
  { label: "project state root", ok: true, detail: path.join(projectCrewRoot(input.cwd), DEFAULT_PATHS.state.runsSubdir) },
113
113
  { label: "artifacts root", ok: true, detail: path.join(projectCrewRoot(input.cwd), DEFAULT_PATHS.state.artifactsSubdir) },
114
114
  ];
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { redactSecrets } from "../runtime/diagnostic-export.ts";
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";
@@ -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(() => { void pollControl(); }, 500);
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);
@@ -58,13 +58,22 @@ function parseManifest(filePath: string): TeamRunManifest | undefined {
58
58
  }
59
59
  }
60
60
 
61
+ function sameFilesystemPath(left: string, right: string): boolean {
62
+ if (path.resolve(left) === path.resolve(right)) return true;
63
+ try {
64
+ return fs.realpathSync.native(left) === fs.realpathSync.native(right);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
61
70
  function validateManifestForRoot(root: string, runId: string, manifest: TeamRunManifest): boolean {
62
71
  try {
63
72
  if (!isSafePathId(runId)) return false;
64
73
  const stateRoot = resolveContainedRelativePath(root, runId, "runId");
65
74
  const crewRoot = path.dirname(path.dirname(root));
66
75
  const artifactsRoot = resolveContainedRelativePath(path.join(crewRoot, DEFAULT_PATHS.state.artifactsSubdir), runId, "runId");
67
- if (manifest.runId !== runId || manifest.stateRoot !== stateRoot || manifest.tasksPath !== path.join(stateRoot, DEFAULT_PATHS.state.tasksFile) || manifest.eventsPath !== path.join(stateRoot, DEFAULT_PATHS.state.eventsFile) || manifest.artifactsRoot !== artifactsRoot) return false;
76
+ if (manifest.runId !== runId || !sameFilesystemPath(manifest.stateRoot, stateRoot) || !sameFilesystemPath(manifest.tasksPath, path.join(stateRoot, DEFAULT_PATHS.state.tasksFile)) || !sameFilesystemPath(manifest.eventsPath, path.join(stateRoot, DEFAULT_PATHS.state.eventsFile)) || !sameFilesystemPath(manifest.artifactsRoot, artifactsRoot)) return false;
68
77
  if (fs.existsSync(artifactsRoot)) {
69
78
  if (fs.lstatSync(artifactsRoot).isSymbolicLink()) return false;
70
79
  resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
@@ -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) queueIndex = buildTaskGraphIndex(tasks);
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 readyBatch = getReadyTasks(tasks, concurrency.selectedCount, queueIndex);
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
- atomicWriteFile(filePath, options.content);
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(options.content),
121
+ contentHash: hashContent(content),
120
122
  retention: options.retention ?? "run",
121
123
  };
122
124
  }
@@ -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 chunk = `${line}\n`;
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 {