pi-crew 0.1.38 → 0.1.39

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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.39
6
+
7
+ ### Fixed
8
+
9
+ - Made CI test execution deterministic across Node 22/macOS/Linux/Windows by running Node test files sequentially to avoid cross-file environment races.
10
+ - Fixed live-agent durable control symlink-file rejection to return an API error instead of throwing from the tool handler.
11
+ - Tightened symlink artifact security assertions so tests check leaked file contents rather than safe metadata paths.
12
+
5
13
  ## 0.1.38
6
14
 
7
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -47,8 +47,8 @@
47
47
  "ci": "npm run typecheck && npm test && npm pack --dry-run",
48
48
  "typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
49
49
  "test": "npm run test:unit && npm run test:integration",
50
- "test:unit": "node --experimental-strip-types --test --test-timeout=30000 test/unit/*.test.ts",
51
- "test:integration": "node --experimental-strip-types --test --test-timeout=120000 test/integration/*.test.ts",
50
+ "test:unit": "node --experimental-strip-types --test --test-concurrency=1 --test-timeout=30000 test/unit/*.test.ts",
51
+ "test:integration": "node --experimental-strip-types --test --test-concurrency=1 --test-timeout=120000 test/integration/*.test.ts",
52
52
  "smoke:pi": "pi install ."
53
53
  },
54
54
  "exports": {
@@ -22,7 +22,7 @@ function readEntry(root: string, scope: "project" | "user", runId: string): Impo
22
22
  let summaryPath: string;
23
23
  try {
24
24
  const entryRoot = resolveRealContainedPath(root, runId);
25
- bundlePath = resolveRealContainedPath(root, path.join(entryRoot, "run-export.json"));
25
+ bundlePath = resolveRealContainedPath(root, path.join(runId, "run-export.json"));
26
26
  summaryPath = path.join(entryRoot, "README.md");
27
27
  } catch {
28
28
  return undefined;
@@ -133,8 +133,11 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
133
133
  const agent = agentId ? agents.find((item) => item.id === agentId || item.taskId === agentId) : agents[0];
134
134
  if (!agent) return result("API read-agent-transcript requires config.agentId matching an agent id or task id, or at least one agent in the run.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
135
135
  const artifactTranscriptPath = safeContainedPath(loaded.manifest.artifactsRoot, agent.transcriptPath);
136
- const transcriptPath = artifactTranscriptPath ?? agentOutputPath(loaded.manifest, agent.taskId);
137
- const text = artifactTranscriptPath ? safeReadContainedFile(loaded.manifest.artifactsRoot, artifactTranscriptPath) ?? "" : safeReadContainedFile(loaded.manifest.stateRoot, transcriptPath) ?? "";
136
+ const fallbackPath = agentOutputPath(loaded.manifest, agent.taskId);
137
+ const artifactText = artifactTranscriptPath ? safeReadContainedFile(loaded.manifest.artifactsRoot, artifactTranscriptPath) ?? "" : "";
138
+ const fallbackText = artifactText ? "" : safeReadContainedFile(loaded.manifest.stateRoot, fallbackPath) ?? "";
139
+ const transcriptPath = artifactText ? artifactTranscriptPath : fallbackPath;
140
+ const text = artifactText || fallbackText;
138
141
  return result(text || `(no transcript at ${transcriptPath})`, { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
139
142
  }
140
143
  if (operation === "read-agent-output") {
@@ -191,11 +194,16 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
191
194
  const task = loaded.tasks.find((item) => item.id === agent.taskId);
192
195
  if (!task) return result(`API ${operation} agent '${agentId}' does not match a run task.`, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
193
196
  if (operation === "resume-agent" && !prompt) return result("API resume-agent requires config.prompt or config.message.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
194
- const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: task.id, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" ? prompt : message });
195
- publishLiveControlRealtime(request);
196
- ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
197
- appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, realtime: true } });
198
- return result(JSON.stringify({ queued: true, request }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
197
+ try {
198
+ const request = appendLiveAgentControlRequest(loaded.manifest, { taskId: task.id, agentId: agent.id, operation: operation === "resume-agent" ? "resume" : operation === "steer-agent" ? "steer" : "stop", message: operation === "resume-agent" ? prompt : message });
199
+ publishLiveControlRealtime(request);
200
+ ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
201
+ appendEvent(loaded.manifest.eventsPath, { type: "agent.control.queued", runId: loaded.manifest.runId, taskId: agent.taskId, message: `Queued ${request.operation} control request for live agent.`, data: { request, realtime: true } });
202
+ return result(JSON.stringify({ queued: true, request }, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
203
+ } catch (queueError) {
204
+ const message = queueError instanceof Error ? queueError.message : String(queueError);
205
+ return result(message, { action: "api", status: "error", runId: loaded.manifest.runId }, true);
206
+ }
199
207
  }
200
208
  }
201
209
  if (operation === "read-mailbox") {
@@ -107,8 +107,8 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
107
107
  const userWritable = checkWritableDir(userCrewRoot());
108
108
  const projectWritable = checkWritableDir(projectCrewRoot(input.cwd));
109
109
  return [
110
- { label: "user state", ok: userWritable.ok, detail: userWritable.detail },
111
- { label: "project state", ok: projectWritable.ok, detail: projectWritable.detail },
110
+ { label: "user state", ok: userWritable.ok || userWritable.detail.endsWith(": missing"), detail: userWritable.detail },
111
+ { label: "project state", ok: projectWritable.ok || projectWritable.detail.endsWith(": missing"), detail: projectWritable.detail },
112
112
  { label: "project state root", ok: true, detail: path.join(projectCrewRoot(input.cwd), DEFAULT_PATHS.state.runsSubdir) },
113
113
  { label: "artifacts root", ok: true, detail: path.join(projectCrewRoot(input.cwd), DEFAULT_PATHS.state.artifactsSubdir) },
114
114
  ];
@@ -58,13 +58,22 @@ function parseManifest(filePath: string): TeamRunManifest | undefined {
58
58
  }
59
59
  }
60
60
 
61
+ function sameFilesystemPath(left: string, right: string): boolean {
62
+ if (path.resolve(left) === path.resolve(right)) return true;
63
+ try {
64
+ return fs.realpathSync.native(left) === fs.realpathSync.native(right);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
61
70
  function validateManifestForRoot(root: string, runId: string, manifest: TeamRunManifest): boolean {
62
71
  try {
63
72
  if (!isSafePathId(runId)) return false;
64
73
  const stateRoot = resolveContainedRelativePath(root, runId, "runId");
65
74
  const crewRoot = path.dirname(path.dirname(root));
66
75
  const artifactsRoot = resolveContainedRelativePath(path.join(crewRoot, DEFAULT_PATHS.state.artifactsSubdir), runId, "runId");
67
- if (manifest.runId !== runId || manifest.stateRoot !== stateRoot || manifest.tasksPath !== path.join(stateRoot, DEFAULT_PATHS.state.tasksFile) || manifest.eventsPath !== path.join(stateRoot, DEFAULT_PATHS.state.eventsFile) || manifest.artifactsRoot !== artifactsRoot) return false;
76
+ if (manifest.runId !== runId || !sameFilesystemPath(manifest.stateRoot, stateRoot) || !sameFilesystemPath(manifest.tasksPath, path.join(stateRoot, DEFAULT_PATHS.state.tasksFile)) || !sameFilesystemPath(manifest.eventsPath, path.join(stateRoot, DEFAULT_PATHS.state.eventsFile)) || !sameFilesystemPath(manifest.artifactsRoot, artifactsRoot)) return false;
68
77
  if (fs.existsSync(artifactsRoot)) {
69
78
  if (fs.lstatSync(artifactsRoot).isSymbolicLink()) return false;
70
79
  resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
@@ -29,14 +29,15 @@ export function findRepoRoot(cwd: string): string | undefined {
29
29
  let current = path.resolve(cwd);
30
30
  const root = path.parse(current).root;
31
31
  const home = path.resolve(os.homedir());
32
+ const tempRoot = path.resolve(os.tmpdir());
32
33
  while (current !== root) {
33
- if (current === home) return undefined;
34
+ if (current === home || current === tempRoot) return undefined;
34
35
  if (hasProjectMarker(current)) return current;
35
36
  const parent = path.dirname(current);
36
37
  if (parent === current) break;
37
38
  current = parent;
38
39
  }
39
- if (current === home) return undefined;
40
+ if (current === home || current === tempRoot) return undefined;
40
41
  if (hasProjectMarker(root)) return root;
41
42
  return undefined;
42
43
  }