pi-crew 0.1.40 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.41
6
+
7
+ ### Added
8
+
9
+ - Added strict-provider-friendly team tool schema shapes and config schema coverage for result delivery controls.
10
+ - Added resilient result watcher fallback polling for resource-limit watch failures and partial JSON retry handling.
11
+ - Added `runtime.completionMutationGuard` (`off`/`warn`/`fail`) with structured `task.attention` events when implementation-style workers complete without observed mutations.
12
+ - Added group-join mailbox delivery metadata, request-id dedupe, ack observability, timeout events, and dashboard/status visibility.
13
+ - Expanded `team doctor` and `team status` with schema, async/result delivery, worktree/readiness, attention, transcript, and group-join diagnostics.
14
+
15
+ ### Fixed
16
+
17
+ - Recovered adaptive implementation planner output when compaction truncates the end marker but complete phase objects are still present.
18
+
5
19
  ## 0.1.40
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -60,6 +60,8 @@ Current highlights:
60
60
  - reliability hardening: heartbeat gradient watcher, opt-in retry executor with attempt trace, crash-recovery detection, deadletter queue
61
61
  - background `Agent`/`crew_agent` completion wake-up so parent sessions can automatically join completed subagent results
62
62
  - optional `runtime.requirePlanApproval` gate for planner-first approval before mutating adaptive implementation workers run
63
+ - optional `runtime.completionMutationGuard` to warn or fail implementation-style workers that complete without observed mutation tool calls
64
+ - grouped result delivery is correlated through mailbox metadata, deduped by request id, and acknowledged via existing `ack-message`
63
65
  - shared redaction for common secrets before durable event/log/mailbox/artifact/metric/diagnostic persistence
64
66
  - package polish: `schema.json`, TypeScript semantic check, strip-types import smoke, cross-platform CI workflow, dry-run package verification
65
67
 
@@ -176,7 +178,10 @@ Supported config:
176
178
  },
177
179
  "runtime": {
178
180
  "mode": "auto",
179
- "requirePlanApproval": false
181
+ "groupJoin": "smart",
182
+ "groupJoinAckTimeoutMs": 300000,
183
+ "requirePlanApproval": false,
184
+ "completionMutationGuard": "warn"
180
185
  },
181
186
  "limits": {
182
187
  "maxConcurrentWorkers": 3,
@@ -237,6 +242,8 @@ Safety notes:
237
242
  - 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.
238
243
  - `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.
239
244
  - 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.
245
+ - `runtime.completionMutationGuard` defaults to `warn`; set `off` to disable or `fail` to fail implementation-style tasks that report success without observed mutation tool calls.
246
+ - Group-join result messages use normal mailbox delivery and normal `ack-message`; missing acknowledgements never block run completion, and duplicate delivery attempts reuse the same request id/message instead of appending spam.
240
247
  - Common secret patterns (`token=`, `apiKey=`, `Authorization: Bearer ...`, private keys, etc.) are redacted before durable logs/events/mailbox/artifacts/metrics/diagnostics are written.
241
248
  - `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.
242
249
  - `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.
@@ -472,7 +479,7 @@ Manual slash commands are ops/debug controls. Autonomous tool use via policy/rec
472
479
  /team-api team_... send-message taskId=task_... direction=inbox to=worker body="task scoped message"
473
480
  /team-api team_... read-mailbox direction=outbox
474
481
  /team-api team_... read-mailbox taskId=task_... direction=inbox
475
- /team-api team_... ack-message messageId=msg_...
482
+ /team-api team_... ack-message messageId=msg_... # also acknowledges group-join result messages
476
483
  /team-api team_... read-delivery
477
484
  /team-api team_... validate-mailbox repair=true
478
485
  /team-api team_... approve-plan
package/docs/usage.md CHANGED
@@ -29,6 +29,13 @@ Supported fields:
29
29
  "preferAsyncForLongTasks": false,
30
30
  "allowWorktreeSuggestion": true
31
31
  },
32
+ "runtime": {
33
+ "mode": "auto",
34
+ "groupJoin": "smart",
35
+ "groupJoinAckTimeoutMs": 300000,
36
+ "completionMutationGuard": "warn",
37
+ "requirePlanApproval": false
38
+ },
32
39
  "ui": {
33
40
  "widgetPlacement": "aboveEditor",
34
41
  "widgetMaxLines": 8,
@@ -113,6 +120,10 @@ Background `Agent`/`crew_agent` subagents wake the parent Pi session when they c
113
120
 
114
121
  State paths are validated before read/write operations. Run ids, imported bundles, artifact and transcript references, mailbox files, and agent control/log files must stay inside their expected `.crew` roots and symlink escapes are rejected. Read-only mailbox APIs return default state without creating mailbox files when no messages exist.
115
122
 
123
+ Group-join result delivery uses the normal outbox mailbox and normal `/team-api ... ack-message`. `runtime.groupJoinAckTimeoutMs` only emits observability (`agent.group_join.ack_timeout`) and does not block run completion.
124
+
125
+ `runtime.completionMutationGuard` defaults to `warn`. Use `off` to disable or `fail` to fail implementation-style workers that complete without observed mutation tool calls.
126
+
116
127
  ## Worktree mode
117
128
 
118
129
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
package/schema.json CHANGED
@@ -69,7 +69,9 @@
69
69
  "inheritContext": { "type": "boolean" },
70
70
  "promptMode": { "type": "string", "enum": ["replace", "append"] },
71
71
  "groupJoin": { "type": "string", "enum": ["off", "group", "smart"] },
72
- "requirePlanApproval": { "type": "boolean" }
72
+ "groupJoinAckTimeoutMs": { "type": "integer", "minimum": 1 },
73
+ "requirePlanApproval": { "type": "boolean" },
74
+ "completionMutationGuard": { "type": "string", "enum": ["off", "warn", "fail"] }
73
75
  }
74
76
  },
75
77
  "control": {
@@ -30,6 +30,8 @@ export interface CrewLimitsConfig {
30
30
 
31
31
  export type CrewRuntimeMode = "auto" | "scaffold" | "child-process" | "live-session";
32
32
 
33
+ export type CompletionMutationGuardMode = "off" | "warn" | "fail";
34
+
33
35
  export interface CrewRuntimeConfig {
34
36
  mode?: CrewRuntimeMode;
35
37
  preferLiveSession?: boolean;
@@ -39,7 +41,9 @@ export interface CrewRuntimeConfig {
39
41
  inheritContext?: boolean;
40
42
  promptMode?: "replace" | "append";
41
43
  groupJoin?: "off" | "group" | "smart";
44
+ groupJoinAckTimeoutMs?: number;
42
45
  requirePlanApproval?: boolean;
46
+ completionMutationGuard?: CompletionMutationGuardMode;
43
47
  }
44
48
 
45
49
  export interface CrewControlConfig {
@@ -493,7 +497,9 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
493
497
  inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
494
498
  promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
495
499
  groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
500
+ groupJoinAckTimeoutMs: parsePositiveInteger(obj.groupJoinAckTimeoutMs, 86_400_000),
496
501
  requirePlanApproval: parseWithSchema(Type.Boolean(), obj.requirePlanApproval),
502
+ completionMutationGuard: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")]), obj.completionMutationGuard),
497
503
  };
498
504
  return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
499
505
  }
@@ -26,6 +26,12 @@ export interface ResultWatcherOptions extends ResultWatcherDependencies {
26
26
  }
27
27
 
28
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
+ }
29
35
 
30
36
  function readJson(filePath: string): unknown | undefined {
31
37
  try {
@@ -45,16 +51,19 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
45
51
  const seen = getGlobalSeenMap("pi-crew.result-watcher");
46
52
  let watcher: fs.FSWatcher | null | undefined;
47
53
  let restartTimer: ReturnType<typeof setTimeout> | undefined;
54
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
48
55
  const coalescer = createFileCoalescer((file) => {
49
56
  if (!isCurrent()) return;
50
57
  const filePath = path.join(resultsDir, file);
51
58
  if (!file.endsWith(".json") || !fs.existsSync(filePath)) return;
52
59
  const payload = readJson(filePath);
53
- if (payload !== undefined) {
54
- const key = buildCompletionKey(payload as Record<string, unknown>, `file:${file}`);
55
- if (!markSeenWithTtl(seen, key, Date.now(), completionTtlMs)) {
56
- events.emit(eventName, payload);
57
- }
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);
58
67
  }
59
68
  try {
60
69
  fs.unlinkSync(filePath);
@@ -62,7 +71,22 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
62
71
  logInternalError("result-watcher.unlink", error, `filePath=${filePath}`);
63
72
  }
64
73
  }, 50);
65
- 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();
66
90
  if (restartTimer) clearTimeout(restartTimer);
67
91
  restartTimer = setTimeout(() => {
68
92
  restartTimer = undefined;
@@ -85,17 +109,18 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
85
109
  if (event !== "rename" || !fileName) return;
86
110
  coalescer.schedule(fileName.toString());
87
111
  }, scheduleRestart);
112
+ if (watcher) stopPolling();
88
113
  watcher?.unref?.();
89
114
  },
90
115
  prime() {
91
- if (!isCurrent() || !fs.existsSync(resultsDir)) return;
92
- for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
116
+ poll();
93
117
  },
94
118
  stop() {
95
119
  if (restartTimer) clearTimeout(restartTimer);
96
120
  restartTimer = undefined;
97
121
  closeWatcher(watcher);
98
122
  watcher = undefined;
123
+ stopPolling();
99
124
  coalescer.clear();
100
125
  },
101
126
  };
@@ -5,7 +5,7 @@ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } f
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";
@@ -298,8 +298,18 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
298
298
  if (!messageId) return result("API ack-message requires config.messageId.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
299
299
  try {
300
300
  return withRunLockSync(loaded.manifest, () => {
301
+ const message = readMailboxMessage(loaded.manifest, messageId);
301
302
  const delivery = acknowledgeMailboxMessage(loaded.manifest, messageId);
302
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
+ }
303
313
  ctx.events?.emit?.("crew.mailbox.acknowledged", { runId: loaded.manifest.runId, messageId, delivery });
304
314
  return result(JSON.stringify(delivery, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
305
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 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
+ }
@@ -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
+ }
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  import type { CrewRuntimeConfig } from "../config/config.ts";
2
2
  import { writeArtifact } from "../state/artifact-store.ts";
3
3
  import { appendEvent } from "../state/event-log.ts";
4
- import { appendMailboxMessage } from "../state/mailbox.ts";
4
+ import { appendMailboxMessage, findMailboxMessageByRequestId, readDeliveryState } from "../state/mailbox.ts";
5
5
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
6
  import { aggregateTaskOutputs } from "./task-output-context.ts";
7
7
 
@@ -18,6 +18,9 @@ export interface CrewGroupJoinDelivery {
18
18
  remaining: string[];
19
19
  artifact?: ArtifactDescriptor;
20
20
  messageId?: string;
21
+ requestId?: string;
22
+ ackRequired?: boolean;
23
+ ackStatus?: "pending" | "acknowledged";
21
24
  }
22
25
 
23
26
  export function resolveGroupJoinMode(runtime?: CrewRuntimeConfig): CrewGroupJoinMode {
@@ -34,6 +37,10 @@ function batchIdFor(runId: string, taskIds: string[]): string {
34
37
  return `${runId}_${taskIds.join("+").replace(/[^a-zA-Z0-9_+-]/g, "_")}`;
35
38
  }
36
39
 
40
+ function requestIdFor(runId: string, batchId: string, partial: boolean): string {
41
+ return `${runId}:group-join:${partial ? "partial" : "completed"}:${batchId}`;
42
+ }
43
+
37
44
  function statusList(tasks: TeamTaskState[], status: TeamTaskState["status"]): string[] {
38
45
  return tasks.filter((task) => task.status === status).map((task) => task.id);
39
46
  }
@@ -55,7 +62,10 @@ export function deliverGroupJoin(input: {
55
62
  const partial = input.partial ?? remaining.length > 0;
56
63
  const batchId = batchIdFor(input.manifest.runId, taskIds);
57
64
  const summary = aggregateTaskOutputs(latest, input.manifest);
58
- const delivery: CrewGroupJoinDelivery = { batchId, mode: input.mode, partial, taskIds, completed, failed, skipped, remaining };
65
+ const requestId = requestIdFor(input.manifest.runId, batchId, partial);
66
+ const existingMailbox = findMailboxMessageByRequestId(input.manifest, requestId);
67
+ const existingStatus = existingMailbox ? readDeliveryState(input.manifest).messages[existingMailbox.id] ?? existingMailbox.status : undefined;
68
+ const delivery: CrewGroupJoinDelivery = { batchId, mode: input.mode, partial, taskIds, completed, failed, skipped, remaining, requestId, ackRequired: true, ackStatus: existingStatus === "acknowledged" ? "acknowledged" : "pending" };
59
69
  const content = `${JSON.stringify({ ...delivery, createdAt: new Date().toISOString() }, null, 2)}\n`;
60
70
  const artifact = writeArtifact(input.manifest.artifactsRoot, {
61
71
  kind: "metadata",
@@ -63,12 +73,13 @@ export function deliverGroupJoin(input: {
63
73
  producer: "group-join",
64
74
  content,
65
75
  });
66
- const mailbox = appendMailboxMessage(input.manifest, {
76
+ const mailbox = existingMailbox ?? appendMailboxMessage(input.manifest, {
67
77
  direction: "outbox",
68
78
  from: "group-join",
69
79
  to: "leader",
70
80
  body: [
71
81
  `Group join ${partial ? "partial" : "completed"}: ${taskIds.join(", ")}`,
82
+ `Request: ${requestId}`,
72
83
  `Completed: ${completed.join(", ") || "none"}`,
73
84
  `Failed: ${failed.join(", ") || "none"}`,
74
85
  `Skipped: ${skipped.join(", ") || "none"}`,
@@ -77,12 +88,19 @@ export function deliverGroupJoin(input: {
77
88
  summary,
78
89
  ].join("\n"),
79
90
  status: "delivered",
91
+ data: { kind: "group_join", requestId, batchId, partial, ackRequired: true, taskIds, completed, failed, skipped, remaining },
80
92
  });
81
93
  appendEvent(input.manifest.eventsPath, {
82
94
  type: partial ? "agent.group_join.partial" : "agent.group_join.completed",
83
95
  runId: input.manifest.runId,
84
96
  message: `Group join ${partial ? "partial" : "completed"} for ${taskIds.length} task(s).`,
85
- data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id },
97
+ data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id, fallback: "mailbox-delivered", reused: Boolean(existingMailbox) },
98
+ });
99
+ if (existingMailbox) appendEvent(input.manifest.eventsPath, {
100
+ type: "agent.group_join.delivery_reused",
101
+ runId: input.manifest.runId,
102
+ message: `Reused group join mailbox delivery for ${taskIds.length} task(s).`,
103
+ data: { requestId, messageId: mailbox.id, batchId, partial },
86
104
  });
87
105
  return { ...delivery, artifact, messageId: mailbox.id };
88
106
  }
@@ -25,6 +25,8 @@ import { coordinationBridgeInstructions, renderTaskPrompt } from "./task-runner/
25
25
  import { applyAgentProgressEvent, applyUsageToProgress, progressEventSummary, shouldFlushProgressEvent } from "./task-runner/progress.ts";
26
26
  import { checkpointTask, persistSingleTaskUpdate, updateTask } from "./task-runner/state-helpers.ts";
27
27
  import { cleanResultText, isFinalChildEvent } from "./task-runner/result-utils.ts";
28
+ import { evaluateCompletionMutationGuard } from "./completion-guard.ts";
29
+ import { appendTaskAttentionEvent } from "./attention-events.ts";
28
30
 
29
31
  export interface TaskRunnerInput {
30
32
  manifest: TeamRunManifest;
@@ -86,6 +88,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
86
88
  let error: string | undefined;
87
89
  let modelAttempts: ModelAttemptSummary[] | undefined;
88
90
  let parsedOutput: ParsedPiJsonOutput | undefined;
91
+ let finalStdout = "";
92
+ let transcriptPath: string | undefined;
89
93
 
90
94
  let startupEvidence = createStartupEvidence({ command: runtimeKind === "child-process" ? "pi" : runtimeKind === "live-session" ? "live-session" : "safe-scaffold", startedAt: new Date(task.startedAt ?? new Date().toISOString()), finishedAt: new Date(), promptSentAt: new Date(task.startedAt ?? new Date().toISOString()), promptAccepted: true, exitCode: 0 });
91
95
  const inputsArtifact = writeTaskInputsArtifact(manifest, task, dependencyContext);
@@ -100,10 +104,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
100
104
  const candidates = modelRoutingPlan.candidates;
101
105
  const attemptModels = candidates.length > 0 ? candidates : [undefined];
102
106
  const logs: string[] = [];
103
- let finalStdout = "";
104
107
  let finalStderr = "";
105
108
  modelAttempts = [];
106
- const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
109
+ transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
107
110
  let finalCheckpointWritten = false;
108
111
  let lastAgentRecordPersistedAt = 0;
109
112
  let lastHeartbeatPersistedAt = 0;
@@ -258,6 +261,26 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
258
261
  producer: task.id,
259
262
  }) : undefined;
260
263
 
264
+ const mutationGuardMode = input.runtimeConfig?.completionMutationGuard ?? "warn";
265
+ const mutationGuard = !error && mutationGuardMode !== "off" ? evaluateCompletionMutationGuard({ role: task.role, taskText: `${task.title}\n${input.step.task}`, transcriptPath: runtimeKind === "child-process" ? transcriptPath : transcriptArtifact?.path, stdout: finalStdout }) : undefined;
266
+ if (mutationGuard?.reason === "no_mutation_observed") {
267
+ appendTaskAttentionEvent({
268
+ manifest,
269
+ taskId: task.id,
270
+ message: "Implementation-style task completed without an observed mutation tool call.",
271
+ data: { activityState: "needs_attention", reason: "completion_guard", taskId: task.id, agentName: task.agent, observedTools: mutationGuard.observedTools, suggestedAction: mutationGuardMode === "fail" ? "Review the worker output and rerun with a concrete implementation task." : "Review the worker output; set runtime.completionMutationGuard='fail' to enforce this." },
272
+ });
273
+ task = { ...task, agentProgress: { ...(task.agentProgress ?? emptyCrewAgentProgress()), activityState: "needs_attention" } };
274
+ if (mutationGuardMode === "fail") {
275
+ error = "Completion mutation guard failed: implementation-style task completed without an observed mutation tool call.";
276
+ exitCode = exitCode === 0 ? 1 : exitCode;
277
+ if (modelAttempts?.length) {
278
+ modelAttempts = modelAttempts.map((attempt, index) => index === modelAttempts!.length - 1 ? { ...attempt, success: false, exitCode, error } : attempt);
279
+ }
280
+ }
281
+ tasks = updateTask(tasks, task);
282
+ }
283
+
261
284
  task = {
262
285
  ...task,
263
286
  status: error ? "failed" : "completed",
@@ -118,8 +118,11 @@ function slug(value: string): string {
118
118
 
119
119
  function extractAdaptivePlanJson(text: string): string | undefined {
120
120
  const markerMatch = text.match(/ADAPTIVE_PLAN_JSON_START\s*([\s\S]*?)\s*ADAPTIVE_PLAN_JSON_END/);
121
- const fencedMatch = markerMatch ? undefined : text.match(/```(?:json)?\s*([\s\S]*?)```/i);
122
- return markerMatch?.[1] ?? fencedMatch?.[1];
121
+ if (markerMatch?.[1]) return markerMatch[1];
122
+ const startIndex = text.indexOf("ADAPTIVE_PLAN_JSON_START");
123
+ if (startIndex >= 0) return text.slice(startIndex + "ADAPTIVE_PLAN_JSON_START".length).trim();
124
+ const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
125
+ return fencedMatch?.[1];
123
126
  }
124
127
 
125
128
  export function __test__parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
@@ -179,6 +182,52 @@ function closeUnbalancedJson(raw: string): string {
179
182
  return result;
180
183
  }
181
184
 
185
+ function salvageCompletePhaseObjects(raw: string): unknown | undefined {
186
+ const phasesIndex = raw.indexOf('"phases"');
187
+ if (phasesIndex < 0) return undefined;
188
+ const arrayStart = raw.indexOf("[", phasesIndex);
189
+ if (arrayStart < 0) return undefined;
190
+ const phases: unknown[] = [];
191
+ let objectStart = -1;
192
+ let depth = 0;
193
+ let inString = false;
194
+ let escaped = false;
195
+ for (let index = arrayStart + 1; index < raw.length; index++) {
196
+ const char = raw[index];
197
+ if (escaped) {
198
+ escaped = false;
199
+ continue;
200
+ }
201
+ if (char === "\\" && inString) {
202
+ escaped = true;
203
+ continue;
204
+ }
205
+ if (char === '"') {
206
+ inString = !inString;
207
+ continue;
208
+ }
209
+ if (inString) continue;
210
+ if (char === "{") {
211
+ if (depth === 0) objectStart = index;
212
+ depth++;
213
+ continue;
214
+ }
215
+ if (char === "}") {
216
+ if (depth <= 0) continue;
217
+ depth--;
218
+ if (depth === 0 && objectStart >= 0) {
219
+ try {
220
+ phases.push(JSON.parse(raw.slice(objectStart, index + 1)));
221
+ } catch {
222
+ // Ignore malformed trailing phase objects and keep earlier complete phases.
223
+ }
224
+ objectStart = -1;
225
+ }
226
+ }
227
+ }
228
+ return phases.length ? { phases } : undefined;
229
+ }
230
+
182
231
  function adaptiveRoleAlias(role: string, allowed: Set<string>): string | undefined {
183
232
  if (allowed.has(role)) return role;
184
233
  const normalized = slug(role);
@@ -199,6 +248,7 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
199
248
  if (!raw) return { repaired: false, reason: "missing-json" };
200
249
  const candidates = [raw, closeUnbalancedJson(raw)];
201
250
  let parsed: unknown;
251
+ let salvageUsed = false;
202
252
  for (const candidate of candidates) {
203
253
  try {
204
254
  parsed = JSON.parse(candidate);
@@ -207,13 +257,17 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
207
257
  // Try the next repair candidate.
208
258
  }
209
259
  }
260
+ if (!parsed) {
261
+ parsed = salvageCompletePhaseObjects(raw);
262
+ salvageUsed = parsed !== undefined;
263
+ }
210
264
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { repaired: false, reason: "invalid-json" };
211
265
  const phasesRaw = Array.isArray((parsed as { phases?: unknown }).phases) ? (parsed as { phases: unknown[] }).phases : Array.isArray((parsed as { tasks?: unknown }).tasks) ? [{ name: "adaptive", tasks: (parsed as { tasks: unknown[] }).tasks }] : undefined;
212
266
  if (!phasesRaw) return { repaired: false, reason: "missing-phases" };
213
267
  const allowed = new Set(allowedRoles);
214
268
  const phases: AdaptivePlanPhase[] = [];
215
269
  let total = 0;
216
- let repaired = raw !== closeUnbalancedJson(raw);
270
+ let repaired = salvageUsed || raw !== closeUnbalancedJson(raw);
217
271
  for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
218
272
  if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) continue;
219
273
  const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
@@ -36,7 +36,9 @@ 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
+ groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
39
40
  requirePlanApproval: Type.Optional(Type.Boolean()),
41
+ completionMutationGuard: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")])),
40
42
  }, { additionalProperties: false });
41
43
 
42
44
  export const PiTeamsControlConfigSchema = Type.Object({
@@ -1,9 +1,18 @@
1
1
  import { Type } from "typebox";
2
2
 
3
3
  const SkillOverride = Type.Unsafe({
4
- type: ["string", "array", "boolean"],
5
- items: { type: "string" },
6
4
  description: "Skill name(s) to inject, array of skill names, or false to disable role defaults.",
5
+ anyOf: [
6
+ { type: "string" },
7
+ { type: "array", items: { type: "string" } },
8
+ { type: "boolean" },
9
+ ],
10
+ });
11
+
12
+ const FreeformConfig = Type.Unsafe({
13
+ description: "Resource config for management actions.",
14
+ type: "object",
15
+ additionalProperties: true,
7
16
  });
8
17
 
9
18
  export const TeamToolParams = Type.Object({
@@ -66,7 +75,7 @@ export const TeamToolParams = Type.Object({
66
75
  Type.Literal("project"),
67
76
  Type.Literal("both"),
68
77
  ], { description: "Resource scope for discovery or management." })),
69
- config: Type.Optional(Type.Unsafe({ description: "Resource config for management actions." })),
78
+ config: Type.Optional(FreeformConfig),
70
79
  dryRun: Type.Optional(Type.Boolean({ description: "Preview a management mutation without writing files." })),
71
80
  confirm: Type.Optional(Type.Boolean({ description: "Required for destructive management actions." })),
72
81
  force: Type.Optional(Type.Boolean({ description: "Override reference checks for destructive management actions." })),
@@ -18,6 +18,7 @@ export interface MailboxMessage {
18
18
  status: MailboxMessageStatus;
19
19
  taskId?: string;
20
20
  acknowledgedAt?: string;
21
+ data?: Record<string, unknown>;
21
22
  }
22
23
 
23
24
  export interface MailboxDeliveryState {
@@ -134,7 +135,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
134
135
  const obj = raw as Record<string, unknown>;
135
136
  if (typeof obj.id !== "string" || typeof obj.runId !== "string" || !isDirection(obj.direction) || typeof obj.from !== "string" || typeof obj.to !== "string" || typeof obj.body !== "string" || typeof obj.createdAt !== "string" || !isStatus(obj.status)) return undefined;
136
137
  if (obj.direction !== expectedDirection) return undefined;
137
- return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined };
138
+ return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data: obj.data && typeof obj.data === "object" && !Array.isArray(obj.data) ? obj.data as Record<string, unknown> : undefined };
138
139
  }
139
140
 
140
141
  function readMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
@@ -208,6 +209,7 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
208
209
  createdAt,
209
210
  status: message.status ?? "queued",
210
211
  taskId: message.taskId,
212
+ data: message.data,
211
213
  };
212
214
  fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
213
215
  const delivery = readDeliveryState(manifest);
@@ -217,6 +219,14 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
217
219
  return complete;
218
220
  }
219
221
 
222
+ export function findMailboxMessageByRequestId(manifest: TeamRunManifest, requestId: string): MailboxMessage | undefined {
223
+ return readMailbox(manifest).find((message) => message.data?.requestId === requestId);
224
+ }
225
+
226
+ export function readMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxMessage | undefined {
227
+ return readMailbox(manifest).find((message) => message.id === messageId);
228
+ }
229
+
220
230
  export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
221
231
  const delivery = readDeliveryState(manifest);
222
232
  if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
@@ -92,6 +92,19 @@ export interface PlanApprovalState {
92
92
  planArtifactPath?: string;
93
93
  }
94
94
 
95
+ export type CrewActivityState = "active" | "active_long_running" | "needs_attention" | "stale";
96
+ export type CrewAttentionReason = "idle" | "tool_failures" | "completion_guard" | "heartbeat_stale" | "plan_approval_pending";
97
+
98
+ export interface CrewAttentionEventData {
99
+ activityState: CrewActivityState;
100
+ reason: CrewAttentionReason;
101
+ elapsedMs?: number;
102
+ taskId?: string;
103
+ agentName?: string;
104
+ suggestedAction?: string;
105
+ observedTools?: string[];
106
+ }
107
+
95
108
  export interface TeamRunManifest {
96
109
  schemaVersion: 1;
97
110
  runId: string;
@@ -3,8 +3,11 @@ import type { RunUiSnapshot } from "../snapshot-types.ts";
3
3
  export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[] {
4
4
  if (!snapshot) return ["Progress pane: snapshot unavailable"];
5
5
  const progress = snapshot.progress;
6
+ const groupJoins = snapshot.groupJoins ?? [];
7
+ const groupJoinLines = groupJoins.length ? groupJoins.map((item) => `group join ${item.partial ? "partial" : "completed"}: ${item.requestId} ack=${item.ack}`) : ["group joins: none"];
6
8
  return [
7
9
  `Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
10
+ ...groupJoinLines,
8
11
  ...snapshot.recentEvents.slice(-10).map((event) => {
9
12
  const seq = event.metadata?.seq !== undefined ? `#${event.metadata.seq}` : "#?";
10
13
  return `${seq} ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? ` · ${event.message}` : ""}`;
@@ -8,7 +8,7 @@ import { readEvents, type TeamEvent } from "../state/event-log.ts";
8
8
  import type { MailboxMessageStatus } from "../state/mailbox.ts";
9
9
  import { loadRunManifestById } from "../state/state-store.ts";
10
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
- import type { RunSnapshotCache, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
11
+ import type { RunSnapshotCache, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
12
12
 
13
13
  const DEFAULT_TTL_MS = 250;
14
14
  const DEFAULT_MAX_ENTRIES = 24;
@@ -195,6 +195,25 @@ function readDeliveryMessages(filePath: string): Record<string, MailboxMessageSt
195
195
  }
196
196
  }
197
197
 
198
+ function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
199
+ try {
200
+ return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).flatMap((line) => {
201
+ try {
202
+ const parsed = JSON.parse(line) as unknown;
203
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
204
+ const message = parsed as { id?: unknown; data?: unknown };
205
+ const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
206
+ if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return [];
207
+ return [{ requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const }];
208
+ } catch {
209
+ return [];
210
+ }
211
+ });
212
+ } catch {
213
+ return [];
214
+ }
215
+ }
216
+
198
217
  function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): number {
199
218
  try {
200
219
  return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).reduce((count, line) => {
@@ -213,6 +232,12 @@ function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMes
213
232
  }
214
233
  }
215
234
 
235
+ function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
236
+ const root = path.join(manifest.stateRoot, "mailbox");
237
+ const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
238
+ return readGroupJoinMailbox(path.join(root, "outbox.jsonl"), delivery).slice(-5);
239
+ }
240
+
216
241
  function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
217
242
  const root = path.join(manifest.stateRoot, "mailbox");
218
243
  const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
@@ -241,6 +266,7 @@ function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, sta
241
266
  progress: input.progress,
242
267
  usage: input.usage,
243
268
  mailbox: input.mailbox,
269
+ groupJoins: input.groupJoins,
244
270
  events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message]),
245
271
  output: input.recentOutputLines,
246
272
  stamps,
@@ -306,6 +332,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
306
332
  throw new Error(`Run '${runId}' could not be parsed.`);
307
333
  }
308
334
  const mailbox = mailboxFrom(loaded.manifest, agents);
335
+ const groupJoins = groupJoinsFrom(loaded.manifest);
309
336
  const base = {
310
337
  runId: loaded.manifest.runId,
311
338
  cwd: loaded.manifest.cwd,
@@ -315,6 +342,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
315
342
  progress: progressFromTasks(tasks),
316
343
  usage: usageFrom(tasks, agents),
317
344
  mailbox,
345
+ groupJoins,
318
346
  recentEvents: safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit),
319
347
  recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
320
348
  };
@@ -22,6 +22,13 @@ export interface RunUiMailbox {
22
22
  needsAttention: number;
23
23
  }
24
24
 
25
+ export interface RunUiGroupJoin {
26
+ requestId: string;
27
+ messageId: string;
28
+ partial: boolean;
29
+ ack: "pending" | "acknowledged";
30
+ }
31
+
25
32
  export interface RunUiSnapshot {
26
33
  runId: string;
27
34
  cwd: string;
@@ -33,6 +40,7 @@ export interface RunUiSnapshot {
33
40
  progress: RunUiProgress;
34
41
  usage: RunUiUsage;
35
42
  mailbox: RunUiMailbox;
43
+ groupJoins?: RunUiGroupJoin[];
36
44
  recentEvents: TeamEvent[];
37
45
  recentOutputLines: string[];
38
46
  }
@@ -18,14 +18,14 @@ export function closeWatcher(watcher: FSWatcher | null | undefined): void {
18
18
  export function watchWithErrorHandler(
19
19
  path: string,
20
20
  listener: WatchListener<string>,
21
- onError: () => void,
21
+ onError: (error?: unknown) => void,
22
22
  ): FSWatcher | null {
23
23
  try {
24
24
  const watcher = fs.watch(path, listener);
25
25
  watcher.on("error", onError);
26
26
  return watcher;
27
- } catch {
28
- onError();
27
+ } catch (error) {
28
+ onError(error);
29
29
  return null;
30
30
  }
31
31
  }