pi-crew 0.1.39 → 0.1.41

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +50 -4
  3. package/docs/usage.md +11 -0
  4. package/package.json +1 -1
  5. package/schema.json +4 -1
  6. package/src/agents/discover-agents.ts +1 -1
  7. package/src/config/config.ts +87 -2
  8. package/src/extension/async-notifier.ts +26 -4
  9. package/src/extension/notification-sink.ts +1 -1
  10. package/src/extension/register.ts +23 -7
  11. package/src/extension/registration/subagent-tools.ts +11 -6
  12. package/src/extension/result-watcher.ts +38 -8
  13. package/src/extension/team-tool/api.ts +61 -2
  14. package/src/extension/team-tool/doctor.ts +31 -0
  15. package/src/extension/team-tool/status.ts +23 -3
  16. package/src/observability/metric-sink.ts +1 -1
  17. package/src/runtime/agent-control.ts +4 -5
  18. package/src/runtime/attention-events.ts +23 -0
  19. package/src/runtime/child-pi.ts +2 -1
  20. package/src/runtime/completion-guard.ts +99 -0
  21. package/src/runtime/crew-agent-records.ts +5 -4
  22. package/src/runtime/crew-agent-runtime.ts +2 -2
  23. package/src/runtime/diagnostic-export.ts +2 -16
  24. package/src/runtime/group-join.ts +22 -4
  25. package/src/runtime/live-session-runtime.ts +12 -6
  26. package/src/runtime/sidechain-output.ts +2 -1
  27. package/src/runtime/subagent-manager.ts +6 -1
  28. package/src/runtime/task-runner/live-executor.ts +3 -0
  29. package/src/runtime/task-runner.ts +25 -2
  30. package/src/runtime/team-runner.ts +131 -6
  31. package/src/schema/config-schema.ts +3 -0
  32. package/src/schema/team-tool-schema.ts +12 -3
  33. package/src/state/artifact-store.ts +4 -2
  34. package/src/state/event-log.ts +2 -1
  35. package/src/state/jsonl-writer.ts +3 -1
  36. package/src/state/mailbox.ts +15 -4
  37. package/src/state/types.ts +25 -0
  38. package/src/teams/discover-teams.ts +1 -1
  39. package/src/ui/dashboard-panes/progress-pane.ts +3 -0
  40. package/src/ui/run-snapshot-cache.ts +29 -1
  41. package/src/ui/snapshot-types.ts +8 -0
  42. package/src/utils/fs-watch.ts +3 -3
  43. package/src/utils/redaction.ts +41 -0
  44. package/src/workflows/discover-workflows.ts +1 -1
@@ -22,9 +22,16 @@ 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;
29
+ const RESULT_WATCHER_POLL_MS = 1000;
30
+
31
+ function shouldFallBackToPolling(error: unknown): boolean {
32
+ const code = error && typeof error === "object" ? (error as { code?: unknown }).code : undefined;
33
+ return code === "EMFILE" || code === "ENOSPC" || code === "EPERM";
34
+ }
28
35
 
29
36
  function readJson(filePath: string): unknown | undefined {
30
37
  try {
@@ -40,18 +47,23 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
40
47
  const eventName = options.eventName ?? "pi-crew:run-result";
41
48
  const completionTtlMs = options.completionTtlMs ?? 5 * 60_000;
42
49
  const watch = options.watch ?? watchWithErrorHandler;
50
+ const isCurrent = options.isCurrent ?? (() => true);
43
51
  const seen = getGlobalSeenMap("pi-crew.result-watcher");
44
52
  let watcher: fs.FSWatcher | null | undefined;
45
53
  let restartTimer: ReturnType<typeof setTimeout> | undefined;
54
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
46
55
  const coalescer = createFileCoalescer((file) => {
56
+ if (!isCurrent()) return;
47
57
  const filePath = path.join(resultsDir, file);
48
58
  if (!file.endsWith(".json") || !fs.existsSync(filePath)) return;
49
59
  const payload = readJson(filePath);
50
- if (payload !== undefined) {
51
- const key = buildCompletionKey(payload as Record<string, unknown>, `file:${file}`);
52
- if (!markSeenWithTtl(seen, key, Date.now(), completionTtlMs)) {
53
- events.emit(eventName, payload);
54
- }
60
+ if (payload === undefined) {
61
+ coalescer.schedule(file, RESULT_WATCHER_POLL_MS);
62
+ return;
63
+ }
64
+ const key = buildCompletionKey(payload as Record<string, unknown>, `file:${file}`);
65
+ if (!markSeenWithTtl(seen, key, Date.now(), completionTtlMs)) {
66
+ events.emit(eventName, payload);
55
67
  }
56
68
  try {
57
69
  fs.unlinkSync(filePath);
@@ -59,11 +71,27 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
59
71
  logInternalError("result-watcher.unlink", error, `filePath=${filePath}`);
60
72
  }
61
73
  }, 50);
62
- const scheduleRestart = () => {
74
+ const poll = () => {
75
+ if (!isCurrent() || !fs.existsSync(resultsDir)) return;
76
+ for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
77
+ };
78
+ const startPolling = () => {
79
+ if (pollTimer) return;
80
+ pollTimer = setInterval(poll, RESULT_WATCHER_POLL_MS);
81
+ pollTimer.unref?.();
82
+ poll();
83
+ };
84
+ const stopPolling = () => {
85
+ if (pollTimer) clearInterval(pollTimer);
86
+ pollTimer = undefined;
87
+ };
88
+ const scheduleRestart = (error?: unknown) => {
89
+ if (shouldFallBackToPolling(error)) startPolling();
63
90
  if (restartTimer) clearTimeout(restartTimer);
64
91
  restartTimer = setTimeout(() => {
65
92
  restartTimer = undefined;
66
93
  try {
94
+ if (!isCurrent()) return;
67
95
  fs.mkdirSync(resultsDir, { recursive: true });
68
96
  handle.start();
69
97
  } catch (error) {
@@ -74,23 +102,25 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
74
102
  };
75
103
  const handle: ResultWatcherHandle = {
76
104
  start() {
105
+ if (!isCurrent()) return;
77
106
  fs.mkdirSync(resultsDir, { recursive: true });
78
107
  if (watcher) closeWatcher(watcher);
79
108
  watcher = watch(resultsDir, (event, fileName) => {
80
109
  if (event !== "rename" || !fileName) return;
81
110
  coalescer.schedule(fileName.toString());
82
111
  }, scheduleRestart);
112
+ if (watcher) stopPolling();
83
113
  watcher?.unref?.();
84
114
  },
85
115
  prime() {
86
- if (!fs.existsSync(resultsDir)) return;
87
- for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
116
+ poll();
88
117
  },
89
118
  stop() {
90
119
  if (restartTimer) clearTimeout(restartTimer);
91
120
  restartTimer = undefined;
92
121
  closeWatcher(watcher);
93
122
  watcher = undefined;
123
+ stopPolling();
94
124
  coalescer.clear();
95
125
  },
96
126
  };
@@ -1,14 +1,15 @@
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";
8
- import { acknowledgeMailboxMessage, appendMailboxMessage, readDeliveryState, readMailbox, validateMailbox, type MailboxDirection } from "../../state/mailbox.ts";
8
+ import { acknowledgeMailboxMessage, appendMailboxMessage, readDeliveryState, readMailbox, readMailboxMessage, validateMailbox, type MailboxDirection } from "../../state/mailbox.ts";
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
  }
@@ -249,8 +298,18 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
249
298
  if (!messageId) return result("API ack-message requires config.messageId.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
250
299
  try {
251
300
  return withRunLockSync(loaded.manifest, () => {
301
+ const message = readMailboxMessage(loaded.manifest, messageId);
252
302
  const delivery = acknowledgeMailboxMessage(loaded.manifest, messageId);
253
303
  appendEvent(loaded.manifest.eventsPath, { type: "mailbox.acknowledged", runId: loaded.manifest.runId, data: { messageId } });
304
+ if (message?.data?.kind === "group_join" && typeof message.data.requestId === "string") {
305
+ appendEvent(loaded.manifest.eventsPath, {
306
+ type: "agent.group_join.acknowledged",
307
+ runId: loaded.manifest.runId,
308
+ message: "Group join delivery acknowledged via mailbox ack.",
309
+ data: { requestId: message.data.requestId, messageId, batchId: message.data.batchId, partial: message.data.partial, acknowledgedAt: delivery.updatedAt, acknowledgedBy: "leader" },
310
+ metadata: { provenance: "api" },
311
+ });
312
+ }
254
313
  ctx.events?.emit?.("crew.mailbox.acknowledged", { runId: loaded.manifest.runId, messageId, delivery });
255
314
  return result(JSON.stringify(delivery, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
256
315
  });
@@ -10,6 +10,7 @@ import { DEFAULT_PATHS } from "../../config/defaults.ts";
10
10
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
11
11
  import { getPiSpawnCommand } from "../../runtime/pi-spawn.ts";
12
12
  import { validateResources } from "../validate-resources.ts";
13
+ import { TeamToolParams } from "../../schema/team-tool-schema.ts";
13
14
  import type { PiTeamsToolResult } from "../tool-result.ts";
14
15
  import { configRecord, result, type TeamContext } from "./context.ts";
15
16
 
@@ -59,6 +60,24 @@ function checkWritableDir(dir: string): { ok: boolean; detail: string } {
59
60
  }
60
61
  }
61
62
 
63
+ function auditJsonSchema(schema: unknown): string[] {
64
+ const issues: string[] = [];
65
+ const walk = (node: unknown): void => {
66
+ if (!node || typeof node !== "object" || Array.isArray(node)) return;
67
+ const record = node as Record<string, unknown>;
68
+ if (Array.isArray(record.type)) issues.push("schema node uses array-valued type");
69
+ if (record.description && !record.type && !record.anyOf && !record.oneOf && !record.allOf && !record.properties) issues.push(`description-only schema node: ${record.description}`);
70
+ if (record.type === "array" && !record.items) issues.push("array schema missing items");
71
+ if (record.type && (record.anyOf || record.oneOf)) issues.push("schema node combines type with union keyword");
72
+ for (const value of Object.values(record)) {
73
+ if (Array.isArray(value)) for (const item of value) walk(item);
74
+ else walk(value);
75
+ }
76
+ };
77
+ walk(schema);
78
+ return issues;
79
+ }
80
+
62
81
  function makeLine(check: DoctorCheck): string {
63
82
  return `- ${check.ok ? "OK" : "FAIL"} ${check.label}: ${check.detail}`;
64
83
  }
@@ -130,6 +149,18 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
130
149
  ok: input.validationErrors === 0,
131
150
  detail: `${input.validationErrors} errors, ${input.validationWarnings} warnings`,
132
151
  }]),
152
+ section("Schema", () => {
153
+ const schemaIssues = auditJsonSchema(TeamToolParams);
154
+ return [{ label: "strict-provider schema", ok: schemaIssues.length === 0, detail: schemaIssues.length ? schemaIssues.slice(0, 3).join("; ") : "team tool schema compatible" }];
155
+ }),
156
+ section("Async/result delivery", () => [
157
+ { label: "result watcher", ok: true, detail: "fs.watch with polling fallback for EMFILE/ENOSPC/EPERM" },
158
+ { label: "async notifier", ok: true, detail: "session-stale guarded completion notifications enabled" },
159
+ ]),
160
+ section("Worktrees", () => [
161
+ { label: "leader repository", ok: true, detail: input.cwd },
162
+ { label: "cleanup policy", ok: true, detail: "dirty worktrees preserved unless force is set" },
163
+ ]),
133
164
  ];
134
165
  if (input.smokeChildPi) {
135
166
  sections.push([`Child check`, `- ${input.smokeChildPi.ok ? "OK" : "FAIL"} child Pi smoke: ${input.smokeChildPi.detail}`]);
@@ -1,6 +1,7 @@
1
1
  import { loadConfig } from "../../config/config.ts";
2
2
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
3
  import { appendEvent, readEvents } from "../../state/event-log.ts";
4
+ import { readDeliveryState, readMailbox } from "../../state/mailbox.ts";
4
5
  import { loadRunManifestById, updateRunStatus } from "../../state/state-store.ts";
5
6
  import { aggregateUsage, formatUsage } from "../../state/usage.ts";
6
7
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
@@ -27,15 +28,32 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
27
28
  }
28
29
  const counts = new Map<string, number>();
29
30
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
30
- const events = readEvents(manifest.eventsPath).slice(-8);
31
+ const allEvents = readEvents(manifest.eventsPath);
32
+ const events = allEvents.slice(-8);
33
+ const attentionByTask = new Map(allEvents.filter((event) => event.type === "task.attention" && event.taskId).map((event) => [event.taskId!, event]));
31
34
  const controlConfig = resolveCrewControlConfig(loadConfig(ctx.cwd).config);
32
35
  const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
33
36
  const artifactLines = manifest.artifacts.slice(-10).map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`);
37
+ const deliveryState = readDeliveryState(manifest);
38
+ const ackTimeoutMs = loadConfig(ctx.cwd).config.runtime?.groupJoinAckTimeoutMs;
39
+ const groupJoinLines = readMailbox(manifest, "outbox")
40
+ .filter((message) => message.data?.kind === "group_join")
41
+ .slice(-5)
42
+ .map((message) => {
43
+ const ack = deliveryState.messages[message.id] === "acknowledged" ? "acknowledged" : "pending";
44
+ const ageMs = Date.now() - new Date(message.createdAt).getTime();
45
+ const requestId = String(message.data?.requestId ?? "unknown");
46
+ const timedOut = ack === "pending" && ackTimeoutMs !== undefined && Number.isFinite(ageMs) && ageMs > ackTimeoutMs;
47
+ if (timedOut && !allEvents.some((event) => event.type === "agent.group_join.ack_timeout" && event.data?.requestId === requestId)) {
48
+ appendEvent(manifest.eventsPath, { type: "agent.group_join.ack_timeout", runId: manifest.runId, message: "Group join delivery ack timed out; mailbox delivery remains the fallback.", data: { requestId, messageId: message.id, batchId: message.data?.batchId, partial: message.data?.partial, ageMs, ackTimeoutMs } });
49
+ }
50
+ return `- ${String(message.data?.partial) === "true" ? "partial" : "completed"} request=${requestId} message=${message.id} ack=${timedOut ? "timeout" : ack}`;
51
+ });
34
52
  const totalUsage = aggregateUsage(tasks);
35
53
  const activeAgents = crewAgents.filter((agent) => agent.status === "running");
36
54
  const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
37
55
  const waitingTasks = tasks.filter((task) => task.status === "queued");
38
- const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState === "needs_attention" ? " needs_attention" : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
56
+ const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState ? ` activityState=${agent.progress.activityState}` : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
39
57
  const lines = [
40
58
  `Run: ${manifest.runId}`,
41
59
  `Team: ${manifest.team}`,
@@ -51,7 +69,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
51
69
  "Task graph:",
52
70
  ...formatTaskGraphLines(tasks),
53
71
  "Tasks:",
54
- ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
72
+ ...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.agentProgress?.activityState ? ` activityState=${task.agentProgress.activityState}` : ""}${attentionByTask.get(task.id)?.data?.reason ? ` attention=${String(attentionByTask.get(task.id)?.data?.reason)}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.resultArtifact ? ` result=${task.resultArtifact.path}` : ""}${task.transcriptArtifact ? ` transcript=${task.transcriptArtifact.path}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
55
73
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
56
74
  "Active agents:",
57
75
  ...(activeAgents.length ? activeAgents.map(agentLine) : ["- (none)"]),
@@ -62,6 +80,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
62
80
  "Policy decisions:",
63
81
  ...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
64
82
  `Total usage: ${formatUsage(totalUsage)}`,
83
+ "Group joins:",
84
+ ...(groupJoinLines.length ? groupJoinLines : ["- (none)"]),
65
85
  "",
66
86
  "Recent artifacts:",
67
87
  ...(artifactLines.length ? artifactLines : ["- (none)"]),
@@ -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";
@@ -1,6 +1,6 @@
1
1
  import type { PiTeamsConfig } from "../config/config.ts";
2
2
  import type { TeamRunManifest } from "../state/types.ts";
3
- import { appendEvent } from "../state/event-log.ts";
3
+ import { appendTaskAttentionEvent } from "./attention-events.ts";
4
4
  import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
5
5
  import { upsertCrewAgent } from "./crew-agent-records.ts";
6
6
 
@@ -53,12 +53,11 @@ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentR
53
53
  },
54
54
  };
55
55
  upsertCrewAgent(manifest, updated);
56
- appendEvent(manifest.eventsPath, {
57
- type: "agent.needs_attention",
58
- runId: manifest.runId,
56
+ appendTaskAttentionEvent({
57
+ manifest,
59
58
  taskId: agent.taskId,
60
59
  message: `${agent.agent} needs attention (no observed activity for ${Math.floor(age / 1000)}s).`,
61
- data: { agentId: agent.id, ageMs: age, needsAttentionAfterMs: config.needsAttentionAfterMs },
60
+ data: { activityState: "needs_attention", reason: "idle", elapsedMs: age, taskId: agent.taskId, agentName: agent.agent, suggestedAction: "Check worker status, wait, steer, or cancel if needed." },
62
61
  });
63
62
  return updated;
64
63
  }
@@ -0,0 +1,23 @@
1
+ import { appendEvent, readEvents } from "../state/event-log.ts";
2
+ import type { CrewAttentionEventData, TeamRunManifest } from "../state/types.ts";
3
+
4
+ export interface AppendTaskAttentionInput {
5
+ manifest: TeamRunManifest;
6
+ taskId?: string;
7
+ message: string;
8
+ data: CrewAttentionEventData;
9
+ }
10
+
11
+ export function appendTaskAttentionEvent(input: AppendTaskAttentionInput): boolean {
12
+ const recent = readEvents(input.manifest.eventsPath).slice(-100);
13
+ const duplicate = recent.some((event) => event.type === "task.attention" && event.taskId === input.taskId && event.data?.reason === input.data.reason && event.data?.activityState === input.data.activityState);
14
+ if (duplicate) return false;
15
+ appendEvent(input.manifest.eventsPath, {
16
+ type: "task.attention",
17
+ runId: input.manifest.runId,
18
+ taskId: input.taskId,
19
+ message: input.message,
20
+ data: { ...input.data },
21
+ });
22
+ return true;
23
+ }
@@ -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 {
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+
3
+ export interface CompletionMutationGuardInput {
4
+ role: string;
5
+ taskText?: string;
6
+ transcriptPath?: string;
7
+ stdout?: string;
8
+ }
9
+
10
+ export interface CompletionMutationGuardResult {
11
+ expectedMutation: boolean;
12
+ observedMutation: boolean;
13
+ reason?: "no_mutation_observed";
14
+ observedTools: string[];
15
+ }
16
+
17
+ const MUTATING_ROLES = new Set(["executor", "test-engineer"]);
18
+ const MUTATING_TOOLS = new Set(["edit", "write", "multi_edit", "apply_patch"]);
19
+ const READ_ONLY_COMMANDS = /^(pwd|ls|dir|cat|type|sed|grep|rg|find|git\s+(status|diff|log|show|branch|remote|rev-parse|ls-files)|npm\s+(test|run\s+(typecheck|check|lint|test|ci))|node\s+--test)\b/i;
20
+ const MUTATING_COMMANDS = /\b(rm\s+-|del\s+|erase\s+|mv\s+|move\s+|cp\s+|copy\s+|mkdir\b|touch\b|git\s+(add|commit|push|reset|clean|checkout|switch|merge|rebase|stash)|npm\s+(install|i|uninstall|publish|version)|pnpm\s+(add|install|remove)|yarn\s+(add|install|remove)|python\b.*>|node\b.*>|echo\b.*>|Set-Content|Out-File)\b/i;
21
+ const READ_ONLY_HINTS = /\b(read-only|no edits?|do not edit|không sửa|khong sua|chỉ đọc|chi doc|plan only|chỉ lập plan|review only|audit only)\b/i;
22
+
23
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
24
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
25
+ }
26
+
27
+ function commandText(value: unknown): string {
28
+ const record = asRecord(value);
29
+ if (!record) return typeof value === "string" ? value : "";
30
+ for (const key of ["command", "cmd", "script", "input"]) {
31
+ const raw = record[key];
32
+ if (typeof raw === "string") return raw;
33
+ }
34
+ return JSON.stringify(record);
35
+ }
36
+
37
+ function isMutatingTool(tool: string, args: unknown): boolean {
38
+ const normalized = tool.toLowerCase();
39
+ if (MUTATING_TOOLS.has(normalized)) return true;
40
+ if (normalized === "bash" || normalized === "shell" || normalized === "powershell") {
41
+ const command = commandText(args).trim();
42
+ if (!command || READ_ONLY_COMMANDS.test(command)) return false;
43
+ return MUTATING_COMMANDS.test(command);
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function collectToolCallsFromEvent(event: unknown): Array<{ tool: string; args?: unknown }> {
49
+ const record = asRecord(event);
50
+ if (!record) return [];
51
+ const calls: Array<{ tool: string; args?: unknown }> = [];
52
+ const directTool = record.toolName ?? record.name ?? record.tool;
53
+ if (typeof directTool === "string" && (record.type === "tool_execution_start" || record.type === "toolCall" || record.type === "tool_call")) {
54
+ calls.push({ tool: directTool, args: record.args ?? record.input });
55
+ }
56
+ const content = Array.isArray(record.content) ? record.content : asRecord(record.message)?.content;
57
+ if (Array.isArray(content)) {
58
+ for (const part of content) {
59
+ const item = asRecord(part);
60
+ if (!item) continue;
61
+ const tool = item.name ?? item.toolName ?? item.tool;
62
+ if (typeof tool === "string" && (item.type === "toolCall" || item.type === "tool_call" || item.type === "tool_execution_start")) calls.push({ tool, args: item.input ?? item.args });
63
+ }
64
+ }
65
+ return calls;
66
+ }
67
+
68
+ function transcriptText(input: CompletionMutationGuardInput): string {
69
+ if (input.transcriptPath && fs.existsSync(input.transcriptPath)) return fs.readFileSync(input.transcriptPath, "utf-8");
70
+ return input.stdout ?? "";
71
+ }
72
+
73
+ export function expectsImplementationMutation(input: Pick<CompletionMutationGuardInput, "role" | "taskText">): boolean {
74
+ if (!MUTATING_ROLES.has(input.role)) return false;
75
+ return !READ_ONLY_HINTS.test(input.taskText ?? "");
76
+ }
77
+
78
+ export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
79
+ const expectedMutation = expectsImplementationMutation(input);
80
+ const observedTools: string[] = [];
81
+ let observedMutation = false;
82
+ const text = transcriptText(input);
83
+ for (const line of text.split("\n")) {
84
+ const trimmed = line.trim();
85
+ if (!trimmed) continue;
86
+ let event: unknown;
87
+ try { event = JSON.parse(trimmed); } catch { continue; }
88
+ for (const call of collectToolCallsFromEvent(event)) {
89
+ observedTools.push(call.tool);
90
+ if (isMutatingTool(call.tool, call.args)) observedMutation = true;
91
+ }
92
+ }
93
+ return {
94
+ expectedMutation,
95
+ observedMutation,
96
+ observedTools,
97
+ ...(expectedMutation && !observedMutation ? { reason: "no_mutation_observed" as const } : {}),
98
+ };
99
+ }
@@ -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 {
@@ -1,5 +1,5 @@
1
1
  import type { TeamTaskStatus } from "../state/contracts.ts";
2
- import type { ModelRoutingState, UsageState } from "../state/types.ts";
2
+ import type { CrewActivityState, ModelRoutingState, UsageState } from "../state/types.ts";
3
3
 
4
4
  export type CrewRuntimeKind = "scaffold" | "child-process" | "live-session";
5
5
  export type CrewAgentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped";
@@ -21,7 +21,7 @@ export interface CrewAgentProgress {
21
21
  turns?: number;
22
22
  durationMs?: number;
23
23
  lastActivityAt?: string;
24
- activityState?: "active" | "needs_attention" | "stale";
24
+ activityState?: CrewActivityState;
25
25
  failedTool?: string;
26
26
  }
27
27
 
@@ -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)) {