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.
- package/CHANGELOG.md +171 -0
- package/README.md +1 -1
- package/docs/pi-crew-v0.5.16-audit-fix-plan.md +35 -0
- package/docs/pi-crew-v0.5.17-audit-fix-plan.md +80 -0
- package/docs/skills/REFERENCE.md +11 -0
- package/package.json +1 -1
- package/skills/artifact-analysis-loop/SKILL.md +1 -0
- package/skills/async-worker-recovery/SKILL.md +1 -0
- package/skills/child-pi-spawning/SKILL.md +1 -0
- package/skills/context-artifact-hygiene/SKILL.md +1 -0
- package/skills/delegation-patterns/SKILL.md +1 -0
- package/skills/detection-pipeline-design/SKILL.md +2 -1
- package/skills/event-log-tracing/SKILL.md +1 -0
- package/skills/git-master/SKILL.md +1 -0
- package/skills/hunting-investigation-loop/SKILL.md +1 -0
- package/skills/incident-playbook-construction/SKILL.md +1 -0
- package/skills/iterative-audit/SKILL.md +331 -0
- package/skills/live-agent-lifecycle/SKILL.md +1 -0
- package/skills/mailbox-interactive/SKILL.md +1 -0
- package/skills/model-routing-context/SKILL.md +2 -1
- package/skills/multi-perspective-review/SKILL.md +1 -0
- package/skills/observability-reliability/SKILL.md +1 -0
- package/skills/orchestration/SKILL.md +2 -1
- package/skills/ownership-session-security/SKILL.md +1 -0
- package/skills/pi-extension-lifecycle/SKILL.md +3 -2
- package/skills/post-mortem/SKILL.md +1 -0
- package/skills/read-only-explorer/SKILL.md +1 -0
- package/skills/requirements-to-task-packet/SKILL.md +1 -0
- package/skills/resource-discovery-config/SKILL.md +2 -1
- package/skills/runtime-state-reader/SKILL.md +1 -0
- package/skills/safe-bash/SKILL.md +1 -0
- package/skills/scrutinize/SKILL.md +1 -0
- package/skills/secure-agent-orchestration-review/SKILL.md +1 -0
- package/skills/security-review/SKILL.md +1 -0
- package/skills/state-mutation-locking/SKILL.md +1 -0
- package/skills/systematic-debugging/SKILL.md +1 -0
- package/skills/threat-hypothesis-framework/SKILL.md +1 -0
- package/skills/ui-render-performance/SKILL.md +2 -1
- package/skills/verification-before-done/SKILL.md +1 -0
- package/skills/widget-rendering/SKILL.md +2 -1
- package/skills/workspace-isolation/SKILL.md +1 -0
- package/skills/worktree-isolation/SKILL.md +1 -0
- package/src/config/types.ts +1 -0
- package/src/extension/management.ts +1 -1
- package/src/extension/plan-orchestrate.ts +0 -1
- package/src/extension/register.ts +16 -7
- package/src/extension/registration/viewers.ts +1 -1
- package/src/extension/run-index.ts +1 -1
- package/src/extension/team-tool/explain.ts +0 -1
- package/src/extension/team-tool/handle-schedule.ts +0 -1
- package/src/extension/team-tool/health-monitor.ts +0 -1
- package/src/extension/team-tool/orchestrate.ts +12 -4
- package/src/extension/team-tool/run.ts +2 -2
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +2 -30
- package/src/observability/exporters/otlp-exporter.ts +11 -1
- package/src/runtime/adaptive-plan.ts +18 -2
- package/src/runtime/child-pi.ts +18 -6
- package/src/runtime/crash-recovery.ts +1 -1
- package/src/runtime/crew-agent-records.ts +23 -3
- package/src/runtime/crew-hooks.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +14 -1
- package/src/runtime/handoff-manager.ts +0 -1
- package/src/runtime/heartbeat-watcher.ts +1 -1
- package/src/runtime/live-session-runtime.ts +0 -1
- package/src/runtime/loop-gates.ts +0 -1
- package/src/runtime/mcp-proxy.ts +2 -2
- package/src/runtime/pipeline-runner.ts +1 -2
- package/src/runtime/sandbox.ts +8 -0
- package/src/runtime/task-packet.ts +124 -0
- package/src/runtime/task-runner/live-executor.ts +1 -2
- package/src/runtime/task-runner/prompt-builder.ts +4 -1
- package/src/runtime/task-runner.ts +2 -2
- package/src/schema/config-schema.ts +1 -0
- package/src/state/event-log.ts +7 -0
- package/src/state/jsonl-writer.ts +24 -0
- package/src/state/locks.ts +66 -35
- package/src/state/run-metrics.ts +1 -2
- package/src/state/schedule.ts +13 -5
- package/src/state/state-store.ts +1 -1
- package/src/tools/safe-bash-extension.ts +1 -1
- package/src/tools/safe-bash.ts +10 -1
- package/src/ui/crew-widget.ts +2 -2
- package/src/ui/render-diff.ts +1 -1
- package/src/ui/run-dashboard.ts +1 -2
- package/src/ui/tool-render.ts +20 -3
- package/src/utils/conflict-detect.ts +0 -1
- package/src/utils/gh-protocol.ts +0 -2
- package/src/workflows/workflow-config.ts +3 -0
- 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
|
|
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 {
|
|
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({
|
package/src/state/event-log.ts
CHANGED
|
@@ -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);
|
package/src/state/locks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
fs.rmSync(filePath, { force: true });
|
|
200
|
-
} catch {
|
|
201
|
-
// Best-effort lock cleanup.
|
|
202
|
-
}
|
|
233
|
+
releaseLock(filePath, token);
|
|
203
234
|
}
|
|
204
235
|
}
|
package/src/state/run-metrics.ts
CHANGED
|
@@ -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
|
|
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).
|
package/src/state/schedule.ts
CHANGED
|
@@ -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 (
|
|
92
|
-
const content =
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
package/src/state/state-store.ts
CHANGED
|
@@ -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
|
|
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\
|
|
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
|
};
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Uses linear-time scanning to prevent ReDoS attacks
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
|
package/src/ui/crew-widget.ts
CHANGED
|
@@ -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 {
|
|
13
|
-
import {
|
|
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";
|
package/src/ui/render-diff.ts
CHANGED
|
@@ -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
|
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -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
|
|
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";
|
package/src/ui/tool-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -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 {
|