pi-crew 0.5.14 → 0.5.17

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 (90) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +1 -1
  3. package/docs/pi-crew-v0.5.16-audit-fix-plan.md +35 -0
  4. package/docs/pi-crew-v0.5.17-audit-fix-plan.md +80 -0
  5. package/docs/skills/REFERENCE.md +11 -0
  6. package/package.json +1 -1
  7. package/skills/artifact-analysis-loop/SKILL.md +1 -0
  8. package/skills/async-worker-recovery/SKILL.md +1 -0
  9. package/skills/child-pi-spawning/SKILL.md +1 -0
  10. package/skills/context-artifact-hygiene/SKILL.md +1 -0
  11. package/skills/delegation-patterns/SKILL.md +1 -0
  12. package/skills/detection-pipeline-design/SKILL.md +2 -1
  13. package/skills/event-log-tracing/SKILL.md +1 -0
  14. package/skills/git-master/SKILL.md +1 -0
  15. package/skills/hunting-investigation-loop/SKILL.md +1 -0
  16. package/skills/incident-playbook-construction/SKILL.md +1 -0
  17. package/skills/iterative-audit/SKILL.md +331 -0
  18. package/skills/live-agent-lifecycle/SKILL.md +1 -0
  19. package/skills/mailbox-interactive/SKILL.md +1 -0
  20. package/skills/model-routing-context/SKILL.md +2 -1
  21. package/skills/multi-perspective-review/SKILL.md +1 -0
  22. package/skills/observability-reliability/SKILL.md +1 -0
  23. package/skills/orchestration/SKILL.md +2 -1
  24. package/skills/ownership-session-security/SKILL.md +1 -0
  25. package/skills/pi-extension-lifecycle/SKILL.md +3 -2
  26. package/skills/post-mortem/SKILL.md +1 -0
  27. package/skills/read-only-explorer/SKILL.md +1 -0
  28. package/skills/requirements-to-task-packet/SKILL.md +1 -0
  29. package/skills/resource-discovery-config/SKILL.md +2 -1
  30. package/skills/runtime-state-reader/SKILL.md +1 -0
  31. package/skills/safe-bash/SKILL.md +1 -0
  32. package/skills/scrutinize/SKILL.md +1 -0
  33. package/skills/secure-agent-orchestration-review/SKILL.md +1 -0
  34. package/skills/security-review/SKILL.md +1 -0
  35. package/skills/state-mutation-locking/SKILL.md +1 -0
  36. package/skills/systematic-debugging/SKILL.md +1 -0
  37. package/skills/threat-hypothesis-framework/SKILL.md +1 -0
  38. package/skills/ui-render-performance/SKILL.md +2 -1
  39. package/skills/verification-before-done/SKILL.md +1 -0
  40. package/skills/widget-rendering/SKILL.md +2 -1
  41. package/skills/workspace-isolation/SKILL.md +1 -0
  42. package/skills/worktree-isolation/SKILL.md +1 -0
  43. package/src/config/types.ts +1 -0
  44. package/src/extension/management.ts +1 -1
  45. package/src/extension/plan-orchestrate.ts +0 -1
  46. package/src/extension/register.ts +16 -7
  47. package/src/extension/registration/viewers.ts +1 -1
  48. package/src/extension/run-index.ts +1 -1
  49. package/src/extension/team-tool/explain.ts +0 -1
  50. package/src/extension/team-tool/handle-schedule.ts +0 -1
  51. package/src/extension/team-tool/health-monitor.ts +0 -1
  52. package/src/extension/team-tool/orchestrate.ts +12 -4
  53. package/src/extension/team-tool/run.ts +2 -2
  54. package/src/extension/team-tool/status.ts +1 -1
  55. package/src/extension/team-tool.ts +2 -30
  56. package/src/observability/exporters/otlp-exporter.ts +11 -1
  57. package/src/runtime/adaptive-plan.ts +18 -2
  58. package/src/runtime/child-pi.ts +18 -6
  59. package/src/runtime/crash-recovery.ts +1 -1
  60. package/src/runtime/crew-agent-records.ts +23 -3
  61. package/src/runtime/crew-hooks.ts +1 -1
  62. package/src/runtime/dynamic-script-runner.ts +14 -1
  63. package/src/runtime/handoff-manager.ts +0 -1
  64. package/src/runtime/heartbeat-watcher.ts +1 -1
  65. package/src/runtime/live-session-runtime.ts +0 -1
  66. package/src/runtime/loop-gates.ts +0 -1
  67. package/src/runtime/mcp-proxy.ts +2 -2
  68. package/src/runtime/pipeline-runner.ts +1 -2
  69. package/src/runtime/sandbox.ts +8 -0
  70. package/src/runtime/task-packet.ts +124 -0
  71. package/src/runtime/task-runner/live-executor.ts +1 -2
  72. package/src/runtime/task-runner/prompt-builder.ts +4 -1
  73. package/src/runtime/task-runner.ts +2 -2
  74. package/src/schema/config-schema.ts +1 -0
  75. package/src/state/event-log.ts +7 -0
  76. package/src/state/jsonl-writer.ts +24 -0
  77. package/src/state/locks.ts +66 -35
  78. package/src/state/run-metrics.ts +1 -2
  79. package/src/state/schedule.ts +13 -5
  80. package/src/state/state-store.ts +1 -1
  81. package/src/tools/safe-bash-extension.ts +1 -1
  82. package/src/tools/safe-bash.ts +10 -1
  83. package/src/ui/crew-widget.ts +2 -2
  84. package/src/ui/render-diff.ts +1 -1
  85. package/src/ui/run-dashboard.ts +1 -2
  86. package/src/ui/tool-render.ts +20 -3
  87. package/src/utils/conflict-detect.ts +0 -1
  88. package/src/utils/gh-protocol.ts +0 -2
  89. package/src/workflows/workflow-config.ts +3 -0
  90. package/src/worktree/worktree-manager.ts +75 -1
@@ -128,6 +128,130 @@ export function validateTaskPacket(packet: TaskPacket): TaskPacketValidationResu
128
128
  return { valid: errors.length === 0, errors };
129
129
  }
130
130
 
131
+ /**
132
+ * Structured handoff template for task completion reports.
133
+ * Distilled from ECC dmux-workflows pattern — workers use this format
134
+ * so verifiers and downstream consumers can parse output predictably.
135
+ */
136
+ export const HANDOFF_TEMPLATE = [
137
+ "## Handoff",
138
+ "",
139
+ "### Summary",
140
+ "<!-- 2-3 sentences describing what was done -->",
141
+ "",
142
+ "### Files Changed",
143
+ "<!-- List each file changed with brief description -->",
144
+ "<!-- - path/to/file.ts: description -->",
145
+ "",
146
+ "### Tests / Verification",
147
+ "<!-- What tests pass? What was manually verified? -->",
148
+ "",
149
+ "### Follow-ups",
150
+ "<!-- Any remaining issues or next steps -->",
151
+ ].join("\n");
152
+
153
+ export interface ParsedHandoff {
154
+ summary: string[];
155
+ filesChanged: string[];
156
+ tests: string[];
157
+ followups: string[];
158
+ }
159
+
160
+ /**
161
+ * Extract text between a ### heading and the next ### heading or end of text.
162
+ */
163
+ function extractSection(content: string, heading: string): string {
164
+ const lines = content.split("\n");
165
+ const headingMarker = `### ${heading}`;
166
+ const startIndex = lines.findIndex((line) => line.trim() === headingMarker);
167
+ if (startIndex === -1) return "";
168
+
169
+ const collected: string[] = [];
170
+ for (let i = startIndex + 1; i < lines.length; i++) {
171
+ const trimmed = lines[i].trim();
172
+ if (trimmed.startsWith("### ") || trimmed.startsWith("## ")) break;
173
+ // Stop at paragraph text (non-bullet, non-comment, non-empty) that follows
174
+ // a blank line — signals end of subsection content.
175
+ if (
176
+ trimmed.length > 0 &&
177
+ !trimmed.startsWith("- ") &&
178
+ !trimmed.startsWith("<!--") &&
179
+ i > startIndex + 1 &&
180
+ lines[i - 1].trim() === "" &&
181
+ collected.some((l) => l.trim().length > 0)
182
+ ) {
183
+ break;
184
+ }
185
+ collected.push(lines[i]);
186
+ }
187
+
188
+ return collected.join("\n").trim();
189
+ }
190
+
191
+ /**
192
+ * Parse bullet list items from a section, stripping leading "- " and backtick wrapping.
193
+ */
194
+ function parseBullets(section: string): string[] {
195
+ if (!section) return [];
196
+ return section
197
+ .split("\n")
198
+ .map((line) => line.trim())
199
+ .filter((line) => line.startsWith("- "))
200
+ .map((line) => {
201
+ let item = line.replace(/^- /, "").trim();
202
+ // Strip surrounding backticks
203
+ if (item.startsWith("`") && item.endsWith("`") && item.length >= 2) {
204
+ item = item.slice(1, -1);
205
+ }
206
+ return item;
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Parse a handoff section that may contain bullets AND free-text paragraphs.
212
+ * Returns all non-empty lines as individual items (bullets get their marker stripped).
213
+ */
214
+ function parseMixedContent(section: string): string[] {
215
+ if (!section) return [];
216
+ return section
217
+ .split("\n")
218
+ .map((line) => line.trim())
219
+ .filter((line) => line.length > 0 && !line.startsWith("<!--")) // skip HTML comments
220
+ .map((line) => {
221
+ if (line.startsWith("- ")) return line.slice(2).trim();
222
+ return line;
223
+ })
224
+ .map((item) => {
225
+ // Strip surrounding backticks
226
+ if (item.startsWith("`") && item.endsWith("`") && item.length >= 2) {
227
+ return item.slice(1, -1);
228
+ }
229
+ return item;
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Parse structured handoff data from agent output text.
235
+ * Looks for the "## Handoff" heading and extracts subsections.
236
+ * Returns empty arrays for sections not found.
237
+ */
238
+ export function parseHandoffFromOutput(output: string): ParsedHandoff {
239
+ if (!output || typeof output !== "string") {
240
+ return { summary: [], filesChanged: [], tests: [], followups: [] };
241
+ }
242
+
243
+ // Find the handoff section — look for ## Handoff
244
+ const handoffIndex = output.indexOf("## Handoff");
245
+ const content = handoffIndex >= 0 ? output.slice(handoffIndex) : output;
246
+
247
+ return {
248
+ summary: parseMixedContent(extractSection(content, "Summary")),
249
+ filesChanged: parseMixedContent(extractSection(content, "Files Changed")),
250
+ tests: parseMixedContent(extractSection(content, "Tests / Verification")),
251
+ followups: parseMixedContent(extractSection(content, "Follow-ups")),
252
+ };
253
+ }
254
+
131
255
  export function renderTaskPacket(packet: TaskPacket): string {
132
256
  return [
133
257
  "# Task Packet",
@@ -3,7 +3,6 @@ import type { AgentConfig } from "../../agents/agent-config.ts";
3
3
  import type { CrewRuntimeConfig } from "../../config/config.ts";
4
4
  import { writeArtifact } from "../../state/artifact-store.ts";
5
5
  import {
6
- appendEvent,
7
6
  appendEventFireAndForget,
8
7
  } from "../../state/event-log.ts";
9
8
  import type {
@@ -11,7 +10,7 @@ import type {
11
10
  TeamRunManifest,
12
11
  TeamTaskState,
13
12
  } from "../../state/types.ts";
14
- import { loadRunManifestById, saveRunTasks } from "../../state/state-store.ts";
13
+ import { loadRunManifestById } from "../../state/state-store.ts";
15
14
  import { persistSingleTaskUpdate } from "./state-helpers.ts";
16
15
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
17
16
  import { appendCrewAgentEvent, appendCrewAgentOutput, emptyCrewAgentProgress, recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
@@ -3,7 +3,7 @@ import type { TeamRunManifest, TeamTaskState, TaskOutputSchema } from "../../sta
3
3
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
4
4
  import { buildMemoryBlock } from "../agent-memory.ts";
5
5
  import { permissionForRole } from "../role-permission.ts";
6
- import { renderTaskPacket } from "../task-packet.ts";
6
+ import { renderTaskPacket, HANDOFF_TEMPLATE } from "../task-packet.ts";
7
7
  import { buildWorkspaceTree } from "../workspace-tree.ts";
8
8
 
9
9
  /**
@@ -132,6 +132,9 @@ export async function renderTaskPrompt(manifest: TeamRunManifest, step: Workflow
132
132
  task.taskPacket?.outputSchema ? renderOutputSchemaBlock(task.taskPacket.outputSchema) : "",
133
133
  "Task:",
134
134
  step.task.replaceAll("{goal}", manifest.goal),
135
+ "",
136
+ "When your task is complete, structure your final output using this handoff template:",
137
+ HANDOFF_TEMPLATE,
135
138
  ].join("\n");
136
139
 
137
140
  const full = [stablePrefix, "", dynamicSuffix].join("\n");
@@ -11,7 +11,7 @@ import type {
11
11
  } from "../state/types.ts";
12
12
  import { logInternalError } from "../utils/internal-error.ts";
13
13
  import { writeArtifact } from "../state/artifact-store.ts";
14
- import { appendEvent, appendEventAsync, appendEventFireAndForget } from "../state/event-log.ts";
14
+ import { appendEventAsync, appendEventFireAndForget } from "../state/event-log.ts";
15
15
  import { saveRunManifest } from "../state/state-store.ts";
16
16
  import { createTaskClaim } from "../state/task-claims.ts";
17
17
  import {
@@ -156,7 +156,7 @@ export async function runTeamTask(
156
156
  let streamBridge: ReturnType<typeof registerStreamBridge> | undefined;
157
157
  try {
158
158
  streamBridge = registerStreamBridge(manifest.runId);
159
- const workspace = prepareTaskWorkspace(manifest, input.task);
159
+ const workspace = prepareTaskWorkspace(manifest, input.task, input.step.seedPaths);
160
160
  const worktree =
161
161
  workspace.worktreePath && workspace.branch
162
162
  ? {
@@ -56,6 +56,7 @@ export const PiTeamsWorktreeConfigSchema = Type.Object({
56
56
  setupHook: Type.Optional(Type.String({ minLength: 1 })),
57
57
  setupHookTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
58
58
  linkNodeModules: Type.Optional(Type.Boolean()),
59
+ seedPaths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
59
60
  }, { additionalProperties: false });
60
61
 
61
62
  export const AgentOverrideSchema = Type.Object({
@@ -66,6 +66,13 @@ let appendCounter = 0;
66
66
  *
67
67
  * @deprecated Prefer `appendEventAsync()` for callers in async contexts. The sync lock
68
68
  * uses `sleepSync` which blocks the event loop and prevents AbortSignal handlers from firing.
69
+ *
70
+ * SECURITY WARNING: This function uses `sleepSync` in its lock-acquire retry loop, which
71
+ * blocks the Node.js event loop for up to 120s. During that time, AbortSignal handlers
72
+ * cannot fire, SIGTERM handlers are delayed, and the process appears unresponsive to
73
+ * orchestrator health checks. Known callers include `appendEvent` (sync path),
74
+ * `flushOneEventLogBuffer`, and `state/mailbox.ts`. Prefer the async alternative
75
+ * (`appendEventAsync`) for all new code.
69
76
  */
70
77
  export function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
71
78
  // Ensure parent directory exists before attempting lock
@@ -14,10 +14,17 @@ export interface JsonlWriteStream {
14
14
  }
15
15
 
16
16
  const DEFAULT_MAX_JSONL_BYTES = 50 * 1024 * 1024;
17
+ // FIX (Round 21, per-line cap): A single huge line could exhaust memory during
18
+ // redactJsonLine if an upstream caller constructs an enormous string. Cap each
19
+ // line at 1MB by default — large enough for any legitimate event payload, small
20
+ // enough to prevent memory blow-up. Mirrors the upstream oh-my-pi pattern of
21
+ // bounding chunk boundaries in Bun.file().writer().
22
+ const DEFAULT_MAX_LINE_BYTES = 1 * 1024 * 1024;
17
23
 
18
24
  export interface JsonlWriterDeps {
19
25
  createWriteStream?: (filePath: string) => JsonlWriteStream;
20
26
  maxBytes?: number;
27
+ maxLineBytes?: number;
21
28
  }
22
29
 
23
30
  export interface JsonlWriter {
@@ -47,7 +54,9 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
47
54
  let backpressured = false;
48
55
  let closed = false;
49
56
  let bytesWritten = 0;
57
+ let linesDroppedForSize = 0;
50
58
  const maxBytes = deps.maxBytes ?? DEFAULT_MAX_JSONL_BYTES;
59
+ const maxLineBytes = deps.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
51
60
 
52
61
  return {
53
62
  writeLine(line: string) {
@@ -55,6 +64,21 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
55
64
  const safeLine = redactJsonLine(line);
56
65
  const chunk = `${safeLine}\n`;
57
66
  const chunkBytes = Buffer.byteLength(chunk, "utf-8");
67
+ // FIX (Round 21, per-line cap): Drop oversize lines. Without this, a
68
+ // single huge payload (e.g. a 100MB base64-encoded transcript) would
69
+ // be buffered in memory by redactJsonLine AND queued in the write
70
+ // stream. We log the drop so silent loss is visible.
71
+ if (chunkBytes > maxLineBytes) {
72
+ linesDroppedForSize++;
73
+ if (linesDroppedForSize === 1 || linesDroppedForSize % 100 === 0) {
74
+ logInternalError(
75
+ "jsonl-writer.lineTooLarge",
76
+ new Error(`line size ${chunkBytes} exceeds maxLineBytes ${maxLineBytes}`),
77
+ `file=${filePath} dropped=${linesDroppedForSize}`,
78
+ );
79
+ }
80
+ return;
81
+ }
58
82
  if (bytesWritten + chunkBytes > maxBytes) return;
59
83
  try {
60
84
  const ok = stream.write(chunk);
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
  import type { TeamRunManifest } from "./types.ts";
4
5
  import { DEFAULT_LOCKS } from "../config/defaults.ts";
5
6
  import { sleepSync } from "../utils/sleep.ts";
@@ -59,22 +60,71 @@ function isLockHolderAlive(filePath: string): boolean {
59
60
  }
60
61
  }
61
62
 
62
- function writeLockFile(filePath: string): void {
63
+ /**
64
+ * Lock file kinds. Discriminator written to the lock file payload so that:
65
+ * - Debugging tools (e.g. a future `pi-crew locks` command) can identify
66
+ * what a lock is protecting.
67
+ * - Cross-kind ambiguity is prevented if two locks somehow resolve to the
68
+ * same path (defense in depth).
69
+ * - Forward compat: new lock types can be added without changing the
70
+ * on-disk format (the `kind` field is the only discriminator).
71
+ */
72
+ export type LockKind = "run" | "file";
73
+
74
+ function writeLockFile(filePath: string, token: string, kind: LockKind = "file"): void {
63
75
  const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
64
76
  try {
65
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
77
+ fs.writeSync(fd, JSON.stringify({ kind, pid: process.pid, createdAt: new Date().toISOString(), token }));
66
78
  } finally {
67
79
  fs.closeSync(fd);
68
80
  }
69
81
  }
70
82
 
71
- function acquireLockWithRetry(filePath: string, staleMs: number): void {
83
+ /**
84
+ * Read the token stored in a lock file. Returns undefined if the file
85
+ * cannot be read or parsed.
86
+ */
87
+ function readLockToken(filePath: string): string | undefined {
88
+ try {
89
+ const raw = fs.readFileSync(filePath, "utf-8");
90
+ const parsed = JSON.parse(raw) as { token?: unknown };
91
+ return typeof parsed.token === "string" ? parsed.token : undefined;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Release a lock file, but ONLY if the stored token matches. This prevents
99
+ * the "losing contender wipes winner's lock" race that occurs when:
100
+ * 1. Process A acquires lock with token T_A
101
+ * 2. Process B times out waiting, steals the lock (overwriting with T_B)
102
+ * 3. Process A finishes, tries to release — would otherwise rm Process B's lock
103
+ *
104
+ * With token matching, A's release is a no-op for B's lock.
105
+ */
106
+ function releaseLock(filePath: string, token: string): void {
107
+ const stored = readLockToken(filePath);
108
+ if (stored === undefined || stored === token) {
109
+ try {
110
+ fs.rmSync(filePath, { force: true });
111
+ } catch {
112
+ // Best-effort cleanup. Either someone else with the same token got
113
+ // there first, or the lock is already gone — both are fine.
114
+ }
115
+ }
116
+ // If the stored token does not match, our lock has been stolen
117
+ // (probably stale and overtaken). Do not touch it — the new holder owns it.
118
+ }
119
+
120
+ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind = "file"): string {
72
121
  let attempt = 0;
73
122
  const deadline = Date.now() + staleMs * 2;
74
123
  while (true) {
124
+ const token = randomUUID();
75
125
  try {
76
- writeLockFile(filePath);
77
- return;
126
+ writeLockFile(filePath, token, kind);
127
+ return token;
78
128
  } catch (error) {
79
129
  const code = (error as NodeJS.ErrnoException).code;
80
130
  if (code !== "EEXIST") throw error;
@@ -105,21 +155,14 @@ function sleep(ms: number): Promise<void> {
105
155
  return new Promise((resolve) => setTimeout(resolve, ms));
106
156
  }
107
157
 
108
- function readLockStateAsync(filePath: string, staleMs: number): void {
109
- try {
110
- if (isLockStale(filePath, staleMs)) fs.rmSync(filePath, { force: true });
111
- } catch {
112
- // Ignore stale-check races.
113
- }
114
- }
115
-
116
- async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Promise<void> {
158
+ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind: LockKind = "file"): Promise<string> {
117
159
  let attempt = 0;
118
160
  const deadline = Date.now() + staleMs * 2;
119
161
  while (true) {
162
+ const token = randomUUID();
120
163
  try {
121
- writeLockFile(filePath);
122
- return;
164
+ writeLockFile(filePath, token, kind);
165
+ return token;
123
166
  } catch (error) {
124
167
  const code = (error as NodeJS.ErrnoException).code;
125
168
  if (code !== "EEXIST") throw error;
@@ -139,7 +182,6 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Pro
139
182
  try {
140
183
  fs.rmSync(filePath, { force: true });
141
184
  } catch { /* race — let loop retry */ }
142
- await readLockStateAsync(filePath, staleMs);
143
185
  const delay = Math.min(250, 25 * 2 ** attempt);
144
186
  await sleep(delay);
145
187
  attempt++;
@@ -159,15 +201,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
159
201
  const lockFile = `${filePath}.lock`;
160
202
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
161
203
  fs.mkdirSync(path.dirname(lockFile), { recursive: true });
162
- acquireLockWithRetry(lockFile, staleMs);
204
+ const token = acquireLockWithRetry(lockFile, staleMs, "file");
163
205
  try {
164
206
  return fn();
165
207
  } finally {
166
- try {
167
- fs.rmSync(lockFile, { force: true });
168
- } catch {
169
- // Best-effort lock cleanup.
170
- }
208
+ // Token-guarded release: don't rm the lock if it has been stolen.
209
+ releaseLock(lockFile, token);
171
210
  }
172
211
  }
173
212
 
@@ -175,15 +214,11 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
175
214
  const filePath = lockPath(manifest);
176
215
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
177
216
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
178
- acquireLockWithRetry(filePath, staleMs);
217
+ const token = acquireLockWithRetry(filePath, staleMs, "run");
179
218
  try {
180
219
  return fn();
181
220
  } finally {
182
- try {
183
- fs.rmSync(filePath, { force: true });
184
- } catch {
185
- // Best-effort lock cleanup.
186
- }
221
+ releaseLock(filePath, token);
187
222
  }
188
223
  }
189
224
 
@@ -191,14 +226,10 @@ export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promis
191
226
  const filePath = lockPath(manifest);
192
227
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
193
228
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
194
- await acquireLockWithRetryAsync(filePath, staleMs);
229
+ const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
195
230
  try {
196
231
  return await fn();
197
232
  } finally {
198
- try {
199
- fs.rmSync(filePath, { force: true });
200
- } catch {
201
- // Best-effort lock cleanup.
202
- }
233
+ releaseLock(filePath, token);
203
234
  }
204
235
  }
@@ -1,9 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { loadRunManifestById } from "./state-store.ts";
4
- import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
4
+ import { projectCrewRoot } from "../utils/paths.ts";
5
5
  import { atomicWriteJson, readJsonFile } from "./atomic-write.ts";
6
- import { DEFAULT_PATHS } from "../config/defaults.ts";
7
6
 
8
7
  /**
9
8
  * Run metrics snapshot captured after a run completes (or on demand).
@@ -7,6 +7,9 @@
7
7
  * - parseRelativeTime(): "+10m" → ISO timestamp
8
8
  * - parseInterval(): "5m" → milliseconds
9
9
  */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { logInternalError } from "../utils/internal-error.ts";
10
13
 
11
14
  import type { ScheduleStoreData, ScheduledTask } from "./types.ts";
12
15
 
@@ -88,8 +91,8 @@ export class ScheduleStore {
88
91
  this.path = path;
89
92
  this.data = { version: 1, jobs: [] };
90
93
  try {
91
- if (require("node:fs").existsSync(path)) {
92
- const content = require("node:fs").readFileSync(path, "utf-8");
94
+ if (fs.existsSync(path)) {
95
+ const content = fs.readFileSync(path, "utf-8");
93
96
  const parsed = JSON.parse(content);
94
97
  if (parsed && typeof parsed === "object" && "version" in parsed && "jobs" in parsed) {
95
98
  this.data = parsed as ScheduleStoreData;
@@ -102,10 +105,15 @@ export class ScheduleStore {
102
105
 
103
106
  private save(): void {
104
107
  try {
105
- require("node:fs").mkdirSync(require("node:path").dirname(this.path), { recursive: true });
106
- require("node:fs").writeFileSync(this.path, JSON.stringify(this.data, null, 2), "utf-8");
108
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
109
+ fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2), "utf-8");
107
110
  } catch (error) {
108
- console.warn(`[pi-crew] Failed to save schedule store: ${error instanceof Error ? error.message : String(error)}`);
111
+ // FIX (Round 21, L1): Use logInternalError for consistency with
112
+ // the rest of the codebase. Previously console.warn may not be
113
+ // visible in all environments (e.g. JSON-RPC mode, redirected
114
+ // stderr). Also import the dependency properly at the top of
115
+ // the file (this method used the legacy require() pattern).
116
+ logInternalError("schedule.save", error, `path=${this.path}`);
109
117
  }
110
118
  }
111
119
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { TeamRunManifest, TeamTaskState } from "./types.ts";
4
- import { canTransitionRunStatus, isTerminalRunStatus } from "./contracts.ts";
4
+ import { canTransitionRunStatus } from "./contracts.ts";
5
5
  import { unregisterActiveRun } from "./active-run-registry.ts";
6
6
  import { atomicWriteJson, atomicWriteJsonAsync, atomicWriteJsonCoalesced, readJsonFile } from "./atomic-write.ts";
7
7
  import { appendEvent } from "./event-log.ts";
@@ -84,7 +84,7 @@ export default function safeBashExtension(pi: ExtensionAPI): void {
84
84
  content: [
85
85
  {
86
86
  type: "text" as const,
87
- text: `🚫 ${danger}\n\nIf you need to run this command, use the regular 'bash' tool instead, but be careful!`,
87
+ text: `🚫 ${danger}\n\nCommand blocked by safety policy. If this is a false positive, ask the user for confirmation or use force: true with explicit user approval.`,
88
88
  },
89
89
  ],
90
90
  };
@@ -4,7 +4,8 @@
4
4
  * Uses linear-time scanning to prevent ReDoS attacks
5
5
  */
6
6
 
7
- import { Type } from "@sinclair/typebox";
7
+ import { logInternalError } from "../utils/internal-error.ts";
8
+
8
9
 
9
10
  // Backward-compatible pattern array (kept for getPatterns API)
10
11
  // IMPORTANT: Line 8 (rm pattern with nested quantifiers) has been replaced
@@ -163,6 +164,14 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
163
164
 
164
165
  if (!enabled) return null;
165
166
 
167
+ // Reject overly permissive allowPatterns that would bypass all safety
168
+ for (const pattern of allowPatterns) {
169
+ if (pattern.source === ".*" || (pattern.test("") && pattern.test("rm -rf /"))) {
170
+ logInternalError("safe-bash.permissive-allow-pattern", new Error(`allowPattern rejects nothing: ${pattern}`));
171
+ throw new Error(`Overly permissive allowPattern rejected: ${pattern}. Use specific patterns only.`);
172
+ }
173
+ }
174
+
166
175
  // Normalize: remove line continuations, collapse whitespace
167
176
  const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
168
177
 
@@ -9,8 +9,8 @@ import { getTaskUsage } from "../runtime/usage-tracker.ts";
9
9
  import type { TeamRunManifest } from "../state/types.ts";
10
10
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
11
11
  import { reconcileAllStaleRuns } from "../runtime/crash-recovery.ts";
12
- import { colorForStatus, iconForStatus, type RunStatus } from "./status-colors.ts";
13
- import { pad, truncate } from "../utils/visual.ts";
12
+ import { iconForStatus } from "./status-colors.ts";
13
+ import { truncate } from "../utils/visual.ts";
14
14
  import type { CrewTheme } from "./theme-adapter.ts";
15
15
  import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
16
16
  import { Box, Text } from "./layout-primitives.ts";
@@ -18,7 +18,7 @@ function parseDiffLine(line: string): ParsedDiffLine | null {
18
18
  return { prefix: match[1], lineNum: match[2], content: match[3] };
19
19
  }
20
20
 
21
- function replaceTabs(text: string): string {
21
+ export function replaceTabs(text: string): string {
22
22
  return text.replace(/\t/g, " ");
23
23
  }
24
24
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
3
3
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
- import { getLiveAgentContextPercent, listLiveAgents } from "../runtime/live-agent-manager.ts";
4
+ import { getLiveAgentContextPercent } from "../runtime/live-agent-manager.ts";
5
5
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
6
  import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
7
7
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
@@ -11,7 +11,6 @@ import { applyStatusColor, iconForStatus, type RunStatus } from "./status-colors
11
11
  import { pad, truncate, sanitizeLine } from "../utils/visual.ts";
12
12
  import { Box, Text } from "./layout-primitives.ts";
13
13
  import { DynamicCrewBorder } from "./dynamic-border.ts";
14
- import { CrewFooter } from "./crew-footer.ts";
15
14
  import { aggregateUsage } from "../state/usage.ts";
16
15
  import { logInternalError } from "../utils/internal-error.ts";
17
16
  import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Container, Spacer, Text, visibleWidth } from "@earendil-works/pi-tui";
7
7
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
8
+ import { replaceTabs } from "./render-diff.ts";
8
9
 
9
10
  // ── Types ──────────────────────────────────────────────────────────────
10
11
  export interface Theme {
@@ -185,7 +186,15 @@ export function renderAgentProgress(
185
186
  }
186
187
 
187
188
  // Error
188
- if (record.error) addLine(theme.fg("error", `Error: ${record.error}`));
189
+ // FIX (Round 20, render-utils sanitization): Sanitize tool-error display so
190
+ // embedded tabs / control chars / newlines / very long strings cannot break
191
+ // the terminal layout. Mirrors the upstream oh-my-pi pattern at
192
+ // packages/coding-agent/src/tools/render-utils.ts:177-185:
193
+ // formatErrorMessage = replaceTabs(truncateToWidth(clean, LINE_CAP))
194
+ if (record.error) {
195
+ const clean = truncLine(replaceTabs(String(record.error)), innerW);
196
+ addLine(theme.fg("error", `Error: ${clean}`));
197
+ }
189
198
 
190
199
  // Usage line
191
200
  const usage = record.usage;
@@ -300,7 +309,12 @@ export function renderAgentToolResult(
300
309
  const label = item.agentId || "agent";
301
310
  c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
302
311
  if (item.error) {
303
- c.addChild(new Text(theme.fg("error", ` Error: ${item.error}`), 0, 0));
312
+ // FIX (Round 20, render-utils sanitization): Sanitize tool-error
313
+ // display so embedded tabs / newlines / very long strings cannot
314
+ // break the TUI border alignment. Mirrors upstream oh-my-pi
315
+ // render-utils.ts:177-185.
316
+ const clean = truncLine(replaceTabs(String(item.error)), w - 2);
317
+ c.addChild(new Text(theme.fg("error", ` Error: ${clean}`), 0, 0));
304
318
  } else if (item.output) {
305
319
  for (const line of item.output.split("\n").slice(0, 5))
306
320
  c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
@@ -318,7 +332,10 @@ export function renderAgentToolResult(
318
332
  const label = d.agentId;
319
333
  c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
320
334
  if (d.error) {
321
- c.addChild(new Text(theme.fg("error", ` Error: ${d.error}`), 0, 0));
335
+ // FIX (Round 20, render-utils sanitization): Same sanitization as
336
+ // above — see renderAgentToolResult header comment.
337
+ const clean = truncLine(replaceTabs(String(d.error)), w - 2);
338
+ c.addChild(new Text(theme.fg("error", ` Error: ${clean}`), 0, 0));
322
339
  } else if (d.output) {
323
340
  for (const line of d.output.split("\n").slice(0, 5))
324
341
  c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
@@ -20,7 +20,6 @@
20
20
  * merely start with `<` or `=` never match.
21
21
  */
22
22
  import * as fs from "node:fs";
23
- import * as path from "node:path";
24
23
 
25
24
  const OURS_PREFIX = "<<<<<<<";
26
25
  const BASE_PREFIX = "|||||||";
@@ -22,8 +22,6 @@
22
22
  * Repo resolution: git remote get-url origin from cwd.
23
23
  */
24
24
  import { execFileSync } from "node:child_process";
25
- import { readFileSync } from "node:fs";
26
- import * as path from "node:path";
27
25
 
28
26
  /** Resolve the default repo from `git remote get-url origin` in cwd. */
29
27
  export function resolveDefaultRepo(cwd: string): string {
@@ -14,6 +14,9 @@ export interface WorkflowStep {
14
14
  progress?: boolean;
15
15
  worktree?: boolean;
16
16
  verify?: boolean;
17
+ /** Per-step files to overlay into the worktree (in addition to global worktree.seedPaths).
18
+ * Useful when only certain steps need access to local drafts or scripts. */
19
+ seedPaths?: string[];
17
20
  }
18
21
 
19
22
  export interface WorkflowConfig {