pi-crew 0.1.41 → 0.1.43

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 (176) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +51 -0
  3. package/agents/analyst.md +11 -11
  4. package/agents/critic.md +11 -11
  5. package/agents/executor.md +11 -11
  6. package/agents/explorer.md +11 -11
  7. package/agents/planner.md +11 -11
  8. package/agents/reviewer.md +11 -11
  9. package/agents/security-reviewer.md +11 -11
  10. package/agents/test-engineer.md +11 -11
  11. package/agents/verifier.md +11 -11
  12. package/agents/writer.md +11 -11
  13. package/docs/refactor-tasks-phase3.md +394 -394
  14. package/docs/refactor-tasks-phase4.md +564 -564
  15. package/docs/refactor-tasks-phase5.md +402 -402
  16. package/docs/refactor-tasks-phase6.md +662 -662
  17. package/docs/research-extension-examples.md +297 -297
  18. package/docs/research-extension-system.md +324 -324
  19. package/docs/research-optimization-plan.md +548 -548
  20. package/docs/research-pi-coding-agent.md +357 -357
  21. package/docs/research-source-pi-crew-reference.md +174 -174
  22. package/docs/runtime-flow.md +148 -148
  23. package/docs/source-runtime-refactor-map.md +83 -83
  24. package/index.ts +6 -6
  25. package/package.json +1 -1
  26. package/src/agents/agent-serializer.ts +34 -34
  27. package/src/agents/discover-agents.ts +4 -4
  28. package/src/config/config.ts +9 -4
  29. package/src/extension/cross-extension-rpc.ts +82 -82
  30. package/src/extension/management.ts +37 -8
  31. package/src/extension/notification-router.ts +2 -2
  32. package/src/extension/register.ts +3 -0
  33. package/src/extension/registration/commands.ts +11 -9
  34. package/src/extension/registration/compaction-guard.ts +125 -125
  35. package/src/extension/registration/subagent-tools.ts +28 -19
  36. package/src/extension/result-watcher.ts +4 -4
  37. package/src/extension/run-bundle-schema.ts +8 -4
  38. package/src/extension/run-import.ts +4 -0
  39. package/src/extension/run-index.ts +4 -1
  40. package/src/extension/run-maintenance.ts +43 -24
  41. package/src/extension/team-tool/api.ts +1 -1
  42. package/src/extension/team-tool/cancel.ts +31 -31
  43. package/src/extension/team-tool/doctor.ts +8 -1
  44. package/src/extension/team-tool/handle-settings.ts +188 -0
  45. package/src/extension/team-tool/inspect.ts +41 -41
  46. package/src/extension/team-tool/lifecycle-actions.ts +79 -79
  47. package/src/extension/team-tool/plan.ts +19 -19
  48. package/src/extension/team-tool/run.ts +4 -2
  49. package/src/extension/team-tool/status.ts +93 -93
  50. package/src/extension/team-tool.ts +3 -1
  51. package/src/i18n.ts +184 -0
  52. package/src/observability/correlation.ts +2 -2
  53. package/src/observability/event-to-metric.ts +4 -3
  54. package/src/observability/exporters/adapter.ts +7 -1
  55. package/src/observability/exporters/otlp-exporter.ts +14 -2
  56. package/src/observability/exporters/prometheus-exporter.ts +9 -2
  57. package/src/observability/metric-registry.ts +18 -3
  58. package/src/observability/metric-retention.ts +11 -3
  59. package/src/observability/metric-sink.ts +9 -4
  60. package/src/observability/metrics-primitives.ts +4 -3
  61. package/src/prompt/prompt-runtime.ts +72 -68
  62. package/src/runtime/agent-control.ts +63 -63
  63. package/src/runtime/agent-memory.ts +72 -72
  64. package/src/runtime/agent-observability.ts +114 -114
  65. package/src/runtime/async-marker.ts +26 -26
  66. package/src/runtime/attention-events.ts +28 -23
  67. package/src/runtime/background-runner.ts +53 -53
  68. package/src/runtime/child-pi.ts +4 -4
  69. package/src/runtime/completion-guard.ts +103 -99
  70. package/src/runtime/concurrency.ts +1 -1
  71. package/src/runtime/crash-recovery.ts +2 -1
  72. package/src/runtime/crew-agent-runtime.ts +58 -58
  73. package/src/runtime/deadletter.ts +14 -4
  74. package/src/runtime/direct-run.ts +35 -35
  75. package/src/runtime/foreground-control.ts +82 -82
  76. package/src/runtime/green-contract.ts +46 -46
  77. package/src/runtime/group-join.ts +106 -106
  78. package/src/runtime/heartbeat-gradient.ts +28 -28
  79. package/src/runtime/heartbeat-watcher.ts +48 -4
  80. package/src/runtime/live-agent-control.ts +87 -87
  81. package/src/runtime/live-agent-manager.ts +85 -85
  82. package/src/runtime/live-control-realtime.ts +36 -36
  83. package/src/runtime/live-session-runtime.ts +305 -305
  84. package/src/runtime/manifest-cache.ts +2 -2
  85. package/src/runtime/model-fallback.ts +269 -261
  86. package/src/runtime/parallel-research.ts +44 -44
  87. package/src/runtime/parallel-utils.ts +1 -1
  88. package/src/runtime/pi-json-output.ts +111 -111
  89. package/src/runtime/policy-engine.ts +79 -78
  90. package/src/runtime/post-exit-stdio-guard.ts +2 -2
  91. package/src/runtime/process-status.ts +56 -56
  92. package/src/runtime/progress-event-coalescer.ts +43 -43
  93. package/src/runtime/recovery-recipes.ts +74 -74
  94. package/src/runtime/retry-executor.ts +5 -0
  95. package/src/runtime/role-permission.ts +39 -39
  96. package/src/runtime/runtime-resolver.ts +1 -1
  97. package/src/runtime/session-usage.ts +79 -79
  98. package/src/runtime/sidechain-output.ts +29 -29
  99. package/src/runtime/subagent-manager.ts +3 -3
  100. package/src/runtime/task-display.ts +38 -38
  101. package/src/runtime/task-output-context.ts +127 -127
  102. package/src/runtime/task-runner/live-executor.ts +101 -101
  103. package/src/runtime/task-runner/progress.ts +119 -111
  104. package/src/runtime/task-runner/result-utils.ts +14 -14
  105. package/src/runtime/task-runner/state-helpers.ts +22 -22
  106. package/src/runtime/team-runner.ts +3 -6
  107. package/src/runtime/worker-heartbeat.ts +21 -21
  108. package/src/runtime/worker-startup.ts +57 -57
  109. package/src/schema/config-schema.ts +1 -1
  110. package/src/schema/team-tool-schema.ts +110 -109
  111. package/src/state/artifact-store.ts +4 -2
  112. package/src/state/atomic-write.ts +12 -4
  113. package/src/state/contracts.ts +105 -105
  114. package/src/state/event-log.ts +3 -4
  115. package/src/state/jsonl-writer.ts +4 -1
  116. package/src/state/locks.ts +9 -1
  117. package/src/state/task-claims.ts +44 -42
  118. package/src/state/usage.ts +29 -29
  119. package/src/subagents/async-entry.ts +1 -1
  120. package/src/subagents/index.ts +3 -3
  121. package/src/subagents/live/control.ts +1 -1
  122. package/src/subagents/live/manager.ts +1 -1
  123. package/src/subagents/live/realtime.ts +1 -1
  124. package/src/subagents/live/session-runtime.ts +1 -1
  125. package/src/subagents/manager.ts +1 -1
  126. package/src/subagents/spawn.ts +1 -1
  127. package/src/teams/discover-teams.ts +2 -2
  128. package/src/teams/team-serializer.ts +38 -38
  129. package/src/types/diff.d.ts +18 -18
  130. package/src/ui/crew-footer.ts +101 -101
  131. package/src/ui/crew-select-list.ts +111 -111
  132. package/src/ui/dashboard-panes/metrics-pane.ts +34 -34
  133. package/src/ui/dynamic-border.ts +25 -25
  134. package/src/ui/layout-primitives.ts +106 -106
  135. package/src/ui/live-run-sidebar.ts +1 -1
  136. package/src/ui/loaders.ts +158 -158
  137. package/src/ui/mascot.ts +3 -2
  138. package/src/ui/powerbar-publisher.ts +4 -3
  139. package/src/ui/render-diff.ts +119 -119
  140. package/src/ui/render-scheduler.ts +54 -14
  141. package/src/ui/run-dashboard.ts +39 -11
  142. package/src/ui/run-snapshot-cache.ts +63 -37
  143. package/src/ui/spinner.ts +17 -17
  144. package/src/ui/status-colors.ts +54 -54
  145. package/src/ui/syntax-highlight.ts +116 -116
  146. package/src/ui/theme-adapter.ts +1 -1
  147. package/src/ui/transcript-viewer.ts +7 -2
  148. package/src/utils/completion-dedupe.ts +63 -63
  149. package/src/utils/file-coalescer.ts +5 -3
  150. package/src/utils/frontmatter.ts +68 -36
  151. package/src/utils/git.ts +262 -262
  152. package/src/utils/ids.ts +12 -12
  153. package/src/utils/internal-error.ts +1 -1
  154. package/src/utils/names.ts +27 -26
  155. package/src/utils/paths.ts +1 -1
  156. package/src/utils/redaction.ts +44 -41
  157. package/src/utils/safe-paths.ts +47 -34
  158. package/src/utils/sleep.ts +2 -2
  159. package/src/utils/timings.ts +2 -0
  160. package/src/utils/visual.ts +9 -1
  161. package/src/workflows/discover-workflows.ts +4 -1
  162. package/src/workflows/validate-workflow.ts +40 -40
  163. package/src/worktree/branch-freshness.ts +45 -45
  164. package/src/worktree/worktree-manager.ts +6 -1
  165. package/teams/default.team.md +12 -12
  166. package/teams/fast-fix.team.md +11 -11
  167. package/teams/implementation.team.md +18 -18
  168. package/teams/parallel-research.team.md +14 -14
  169. package/teams/research.team.md +11 -11
  170. package/teams/review.team.md +12 -12
  171. package/workflows/default.workflow.md +29 -29
  172. package/workflows/fast-fix.workflow.md +22 -22
  173. package/workflows/implementation.workflow.md +38 -38
  174. package/workflows/parallel-research.workflow.md +46 -46
  175. package/workflows/research.workflow.md +22 -22
  176. package/workflows/review.workflow.md +30 -30
@@ -7,6 +7,7 @@ import { readPersistedSubagentRecord, savePersistedSubagentRecord, type Subagent
7
7
  import { loadConfig } from "../../config/config.ts";
8
8
  import { logInternalError } from "../../utils/internal-error.ts";
9
9
  import { __test__subagentSpawnParams, formatSubagentRecord, readSubagentRunResult, refreshPersistedSubagentRecord, subagentToolResult } from "./subagent-helpers.ts";
10
+ import { t } from "../../i18n.ts";
10
11
 
11
12
  export interface SubagentToolRegistrationOptions {
12
13
  ownerSessionGeneration?: () => number;
@@ -37,7 +38,7 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
37
38
  if (!permission.allowed) return subagentToolResult(permission.reason ?? "Current role cannot spawn subagents.", { role: currentRole, mode: permission.mode }, true);
38
39
  const spawnOptions = __test__subagentSpawnParams(params as Record<string, unknown>, ctx);
39
40
  spawnOptions.ownerSessionGeneration = options.ownerSessionGeneration?.();
40
- if (!spawnOptions.prompt.trim()) return subagentToolResult("Agent requires prompt.", {}, true);
41
+ if (!spawnOptions.prompt.trim()) return subagentToolResult(t("agent.requiresPrompt"), {}, true);
41
42
  const runner = async (currentOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => handleTeamTool({ action: "run", agent: currentOptions.type, goal: currentOptions.prompt, model: currentOptions.model, async: currentOptions.background, config: currentOptions.maxTurns ? { runtime: { maxTurns: currentOptions.maxTurns } } : undefined } as TeamToolParamsValue, currentOptions.background ? { ...ctx, signal: childSignal } : { ...ctx, signal: childSignal });
42
43
  const record = subagentManager.spawn(spawnOptions, runner, spawnOptions.background ? undefined : signal);
43
44
  if (spawnOptions.background || record.status === "queued") {
@@ -45,11 +46,11 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
45
46
  // Phase 1.6: Record was terminated for telemetry.
46
47
  record.terminated = true;
47
48
  savePersistedSubagentRecord(ctx.cwd, record);
48
- return { ...subagentToolResult([`Agent ${record.status === "queued" ? "queued" : "started"}.`, `Agent ID: ${record.id}`, `Type: ${record.type}`, `Description: ${record.description}`, "Use get_subagent_result to retrieve output. Do not duplicate this agent's work."].join("\n"), { agentId: record.id, status: record.status }), terminate: true };
49
+ return { ...subagentToolResult([t("agent.started", { state: record.status === "queued" ? "queued" : "started" }), t("agent.id", { id: record.id }), t("agent.type", { type: record.type }), t("agent.description", { description: record.description }), t("agent.retrieveHint")].join("\n"), { agentId: record.id, status: record.status }), terminate: true };
49
50
  }
50
51
  await record.promise;
51
- const output = readSubagentRunResult(ctx, record) ?? record.result ?? "No output.";
52
- const foregroundResult = subagentToolResult([`Agent ${record.id} ${record.status}.`, "", output].join("\n"), { agentId: record.id, runId: record.runId, status: record.status }, record.status === "failed" || record.status === "error");
52
+ const output = readSubagentRunResult(ctx, record) ?? record.result ?? t("agent.noOutput");
53
+ const foregroundResult = subagentToolResult([t("agent.foregroundStatus", { id: record.id, status: record.status }), "", output].join("\n"), { agentId: record.id, runId: record.runId, status: record.status }, record.status === "failed" || record.status === "error");
53
54
  if (loadConfig(ctx.cwd).config.tools?.terminateOnForeground === true) {
54
55
  record.terminated = true;
55
56
  savePersistedSubagentRecord(ctx.cwd, record);
@@ -66,14 +67,14 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
66
67
  parameters: Type.Object({ agent_id: Type.String({ description: "Agent ID returned by Agent." }), wait: Type.Optional(Type.Boolean({ description: "Wait for completion before returning." })), verbose: Type.Optional(Type.Boolean({ description: "Include status metadata before output." })) }) as never,
67
68
  async execute(_id, params, signal, _onUpdate, ctx) {
68
69
  const p = params as { agent_id?: string; wait?: boolean; verbose?: boolean };
69
- if (!p.agent_id) return subagentToolResult("get_subagent_result requires agent_id.", {}, true);
70
+ if (!p.agent_id) return subagentToolResult(t("result.requiresAgentId"), {}, true);
70
71
  const inMemory = subagentManager.getRecord(p.agent_id);
71
72
  const record = inMemory ?? readPersistedSubagentRecord(ctx.cwd, p.agent_id);
72
- if (!record) return subagentToolResult(`Agent not found: ${p.agent_id}`, {}, true);
73
+ if (!record) return subagentToolResult(t("result.notFound", { id: p.agent_id }), {}, true);
73
74
  let current = refreshPersistedSubagentRecord(ctx, record);
74
75
  if (inMemory && current !== inMemory) Object.assign(inMemory, current);
75
76
  if (!inMemory && !current.runId && (current.status === "running" || current.status === "queued")) {
76
- current = { ...current, status: "error", error: "Subagent was interrupted before its durable run id was recorded; it cannot be recovered after restart.", completedAt: current.completedAt ?? Date.now() };
77
+ current = { ...current, status: "error", error: t("result.unrecoverable"), completedAt: current.completedAt ?? Date.now() };
77
78
  savePersistedSubagentRecord(ctx.cwd, current);
78
79
  }
79
80
  if (p.wait && (current.status === "running" || current.status === "queued")) {
@@ -83,16 +84,24 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
83
84
  current.resultConsumed = false;
84
85
  if (inMemory) inMemory.resultConsumed = false;
85
86
  savePersistedSubagentRecord(ctx.cwd, current);
86
- }
87
- else while (current.status === "running" || current.status === "queued") {
88
- if (signal?.aborted) {
89
- current = { ...current, status: "error", error: "Waiting for subagent result was aborted.", completedAt: Date.now() };
90
- savePersistedSubagentRecord(ctx.cwd, current);
91
- break;
87
+ } else {
88
+ const waitStartMs = Date.now();
89
+ const maxWaitMs = 300_000; // 5 minutes
90
+ while (current.status === "running" || current.status === "queued") {
91
+ if (signal?.aborted) {
92
+ current = { ...current, status: "error", error: t("result.waitAborted"), completedAt: Date.now() };
93
+ savePersistedSubagentRecord(ctx.cwd, current);
94
+ break;
95
+ }
96
+ if (Date.now() - waitStartMs > maxWaitMs) {
97
+ current = { ...current, status: "error", error: t("result.waitTimeout"), completedAt: Date.now() };
98
+ savePersistedSubagentRecord(ctx.cwd, current);
99
+ break;
100
+ }
101
+ await new Promise((resolve) => setTimeout(resolve, 1000));
102
+ current = refreshPersistedSubagentRecord(ctx, current);
103
+ if (!current.runId) break;
92
104
  }
93
- await new Promise((resolve) => setTimeout(resolve, 1000));
94
- current = refreshPersistedSubagentRecord(ctx, current);
95
- if (!current.runId) break;
96
105
  }
97
106
  }
98
107
  const output = readSubagentRunResult(ctx, current);
@@ -101,7 +110,7 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
101
110
  if (inMemory) inMemory.resultConsumed = true;
102
111
  savePersistedSubagentRecord(ctx.cwd, current);
103
112
  }
104
- const text = [p.verbose ? formatSubagentRecord(current) : undefined, output ? `${p.verbose ? "\n" : ""}${output}` : current.status === "running" || current.status === "queued" ? "Agent is still running. Use wait=true or check again later." : current.error ?? "No output."].filter((line): line is string => Boolean(line)).join("\n");
113
+ const text = [p.verbose ? formatSubagentRecord(current) : undefined, output ? `${p.verbose ? "\n" : ""}${output}` : current.status === "running" || current.status === "queued" ? t("result.stillRunning") : current.error ?? t("agent.noOutput")].filter((line): line is string => Boolean(line)).join("\n");
105
114
  return subagentToolResult(text, { agentId: current.id, runId: current.runId, status: current.status }, current.status === "failed" || current.status === "error");
106
115
  },
107
116
  };
@@ -114,8 +123,8 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
114
123
  async execute(_id, params, _signal, _onUpdate, ctx) {
115
124
  const p = params as { agent_id?: string; message?: string };
116
125
  const record = p.agent_id ? subagentManager.getRecord(p.agent_id) ?? readPersistedSubagentRecord(ctx.cwd, p.agent_id) : undefined;
117
- if (!record) return subagentToolResult(`Agent not found: ${p.agent_id ?? ""}`, {}, true);
118
- return subagentToolResult([`Steering request noted for ${record.id}.`, "Current default pi-crew backend is child-process, so mid-turn session.steer is not available yet.", record.runId ? `Use team cancel runId=${record.runId} if the agent must be interrupted.` : undefined].filter((line): line is string => Boolean(line)).join("\n"), { agentId: record.id, runId: record.runId, status: record.status });
126
+ if (!record) return subagentToolResult(t("result.notFound", { id: p.agent_id ?? "" }), {}, true);
127
+ return subagentToolResult([t("steer.noted", { id: record.id }), t("steer.unavailable"), record.runId ? t("steer.cancelHint", { runId: record.runId }) : undefined].filter((line): line is string => Boolean(line)).join("\n"), { agentId: record.id, runId: record.runId, status: record.status });
119
128
  },
120
129
  };
121
130
 
@@ -61,7 +61,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
61
61
  coalescer.schedule(file, RESULT_WATCHER_POLL_MS);
62
62
  return;
63
63
  }
64
- const key = buildCompletionKey(payload as Record<string, unknown>, `file:${file}`);
64
+ const key = buildCompletionKey(payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : {}, `file:${file}`);
65
65
  if (!markSeenWithTtl(seen, key, Date.now(), completionTtlMs)) {
66
66
  events.emit(eventName, payload);
67
67
  }
@@ -78,7 +78,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
78
78
  const startPolling = () => {
79
79
  if (pollTimer) return;
80
80
  pollTimer = setInterval(poll, RESULT_WATCHER_POLL_MS);
81
- pollTimer.unref?.();
81
+ pollTimer.unref();
82
82
  poll();
83
83
  };
84
84
  const stopPolling = () => {
@@ -98,7 +98,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
98
98
  logInternalError("result-watcher.restart", error, `resultsDir=${resultsDir}`);
99
99
  }
100
100
  }, RESULT_WATCHER_RESTART_MS);
101
- restartTimer.unref?.();
101
+ restartTimer.unref();
102
102
  };
103
103
  const handle: ResultWatcherHandle = {
104
104
  start() {
@@ -110,7 +110,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
110
110
  coalescer.schedule(fileName.toString());
111
111
  }, scheduleRestart);
112
112
  if (watcher) stopPolling();
113
- watcher?.unref?.();
113
+ watcher?.unref();
114
114
  },
115
115
  prime() {
116
116
  poll();
@@ -17,12 +17,13 @@ function validateArtifact(value: unknown, index: number, errors: string[]): valu
17
17
  errors.push(`manifest.artifacts[${index}] must be an object.`);
18
18
  return false;
19
19
  }
20
+ const before = errors.length;
20
21
  if (typeof value.kind !== "string") errors.push(`manifest.artifacts[${index}].kind must be a string.`);
21
22
  if (typeof value.path !== "string") errors.push(`manifest.artifacts[${index}].path must be a string.`);
22
23
  if (typeof value.createdAt !== "string") errors.push(`manifest.artifacts[${index}].createdAt must be a string.`);
23
24
  if (typeof value.producer !== "string") errors.push(`manifest.artifacts[${index}].producer must be a string.`);
24
25
  if (value.retention !== "run" && value.retention !== "project" && value.retention !== "temporary") errors.push(`manifest.artifacts[${index}].retention is invalid.`);
25
- return true;
26
+ return errors.length === before;
26
27
  }
27
28
 
28
29
  function validateManifest(value: unknown, errors: string[]): value is TeamRunManifest {
@@ -30,6 +31,7 @@ function validateManifest(value: unknown, errors: string[]): value is TeamRunMan
30
31
  errors.push("manifest must be an object.");
31
32
  return false;
32
33
  }
34
+ const before = errors.length;
33
35
  if (value.schemaVersion !== 1) errors.push("manifest.schemaVersion must be 1.");
34
36
  for (const field of ["runId", "team", "goal", "createdAt", "updatedAt", "cwd", "stateRoot", "artifactsRoot", "tasksPath", "eventsPath"] as const) {
35
37
  if (typeof value[field] !== "string") errors.push(`manifest.${field} must be a string.`);
@@ -38,7 +40,7 @@ function validateManifest(value: unknown, errors: string[]): value is TeamRunMan
38
40
  if (value.workspaceMode !== "single" && value.workspaceMode !== "worktree") errors.push("manifest.workspaceMode must be single or worktree.");
39
41
  if (!Array.isArray(value.artifacts)) errors.push("manifest.artifacts must be an array.");
40
42
  else value.artifacts.forEach((artifact, index) => validateArtifact(artifact, index, errors));
41
- return true;
43
+ return errors.length === before;
42
44
  }
43
45
 
44
46
  function validateTask(value: unknown, index: number, errors: string[]): value is TeamTaskState {
@@ -46,12 +48,13 @@ function validateTask(value: unknown, index: number, errors: string[]): value is
46
48
  errors.push(`tasks[${index}] must be an object.`);
47
49
  return false;
48
50
  }
51
+ const before = errors.length;
49
52
  for (const field of ["id", "runId", "role", "agent", "title", "cwd"] as const) {
50
53
  if (typeof value[field] !== "string") errors.push(`tasks[${index}].${field} must be a string.`);
51
54
  }
52
55
  if (!isTeamTaskStatus(value.status)) errors.push(`tasks[${index}].status is invalid.`);
53
56
  if (!Array.isArray(value.dependsOn)) errors.push(`tasks[${index}].dependsOn must be an array.`);
54
- return true;
57
+ return errors.length === before;
55
58
  }
56
59
 
57
60
  function validateEvent(value: unknown, index: number, errors: string[]): value is TeamEvent {
@@ -59,10 +62,11 @@ function validateEvent(value: unknown, index: number, errors: string[]): value i
59
62
  errors.push(`events[${index}] must be an object.`);
60
63
  return false;
61
64
  }
65
+ const before = errors.length;
62
66
  for (const field of ["time", "type", "runId"] as const) {
63
67
  if (typeof value[field] !== "string") errors.push(`events[${index}].${field} must be a string.`);
64
68
  }
65
- return true;
69
+ return errors.length === before;
66
70
  }
67
71
 
68
72
  export function validateRunBundle(value: unknown): BundleValidationResult {
@@ -29,6 +29,10 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
29
29
  resolveRealContainedPath(path.dirname(importsRoot), path.basename(importsRoot));
30
30
  const root = resolveContainedRelativePath(importsRoot, runId, "runId");
31
31
  fs.mkdirSync(root, { recursive: true });
32
+ // TOCTOU note: mkdirSync would throw EEXIST if a symlink already existed.
33
+ // The lstatSync check catches a symlink swapped in between mkdirSync and the check
34
+ // (theoretically possible but requires local attacker with exact timing).
35
+ // resolveRealContainedPath provides an additional real-path containment barrier.
32
36
  if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Invalid import directory: ${root}`);
33
37
  resolveRealContainedPath(importsRoot, runId);
34
38
  const targetJson = path.join(root, "run-export.json");
@@ -40,7 +40,10 @@ function mergeRuns(runSets: TeamRunManifest[][], max?: number): TeamRunManifest[
40
40
  }
41
41
 
42
42
  function scopedRunRoots(cwd: string): string[] {
43
- return [findRepoRoot(cwd) ? projectCrewRoot(cwd) : userCrewRoot()];
43
+ const roots: string[] = [userCrewRoot()];
44
+ const projectRoot = findRepoRoot(cwd);
45
+ if (projectRoot) roots.unshift(projectCrewRoot(cwd));
46
+ return roots;
44
47
  }
45
48
 
46
49
  export function listRuns(cwd: string): TeamRunManifest[] {
@@ -1,24 +1,43 @@
1
- import * as fs from "node:fs";
2
- import type { TeamRunManifest } from "../state/types.ts";
3
- import { listRuns } from "./run-index.ts";
4
-
5
- export interface PruneRunsResult {
6
- kept: string[];
7
- removed: string[];
8
- }
9
-
10
- function isFinished(run: TeamRunManifest): boolean {
11
- return run.status === "completed" || run.status === "failed" || run.status === "cancelled" || run.status === "blocked";
12
- }
13
-
14
- export function pruneFinishedRuns(cwd: string, keep: number): PruneRunsResult {
15
- const finished = listRuns(cwd).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
16
- const kept = finished.slice(0, keep).map((run) => run.runId);
17
- const removed: string[] = [];
18
- for (const run of finished.slice(keep)) {
19
- fs.rmSync(run.stateRoot, { recursive: true, force: true });
20
- fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
21
- removed.push(run.runId);
22
- }
23
- return { kept, removed };
24
- }
1
+ import * as fs from "node:fs";
2
+ import type { TeamRunManifest } from "../state/types.ts";
3
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
4
+ import { projectCrewRoot } from "../utils/paths.ts";
5
+ import { listRuns } from "./run-index.ts";
6
+ import { logInternalError } from "../utils/internal-error.ts";
7
+
8
+ export interface PruneRunsResult {
9
+ kept: string[];
10
+ removed: string[];
11
+ }
12
+
13
+ function isFinished(run: TeamRunManifest): boolean {
14
+ return run.status === "completed" || run.status === "failed" || run.status === "cancelled" || run.status === "blocked";
15
+ }
16
+
17
+ function isSafeToPrune(cwd: string, run: TeamRunManifest): boolean {
18
+ try {
19
+ const crewRoot = projectCrewRoot(cwd);
20
+ resolveRealContainedPath(crewRoot, run.stateRoot);
21
+ resolveRealContainedPath(crewRoot, run.artifactsRoot);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export function pruneFinishedRuns(cwd: string, keep: number): PruneRunsResult {
29
+ const crewRoot = projectCrewRoot(cwd);
30
+ const finished = listRuns(cwd).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
31
+ const kept = finished.slice(0, keep).map((run) => run.runId);
32
+ const removed: string[] = [];
33
+ for (const run of finished.slice(keep)) {
34
+ if (!isSafeToPrune(cwd, run)) {
35
+ logInternalError("prune.path-unsafe", new Error(`Skipping unsafe prune: stateRoot=${run.stateRoot}, artifactsRoot=${run.artifactsRoot}`), `runId=${run.runId}`);
36
+ continue;
37
+ }
38
+ fs.rmSync(run.stateRoot, { recursive: true, force: true });
39
+ fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
40
+ removed.push(run.runId);
41
+ }
42
+ return { kept, removed };
43
+ }
@@ -22,7 +22,7 @@ import type { PiTeamsToolResult } from "../tool-result.ts";
22
22
  import { configRecord, result, type TeamContext } from "./context.ts";
23
23
 
24
24
  function globMatch(value: string, pattern: string): boolean {
25
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
25
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\?/g, "\\?").replace(/\*/g, ".*");
26
26
  return new RegExp(`^${escaped}$`).test(value);
27
27
  }
28
28
 
@@ -1,31 +1,31 @@
1
- import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
2
- import { withRunLockSync } from "../../state/locks.ts";
3
- import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/state-store.ts";
4
- import { saveCrewAgents, recordFromTask } from "../../runtime/crew-agent-records.ts";
5
- import { writeForegroundInterruptRequest } from "../../runtime/foreground-control.ts";
6
- import { logInternalError } from "../../utils/internal-error.ts";
7
- import type { PiTeamsToolResult } from "../tool-result.ts";
8
- import { result, type TeamContext } from "./context.ts";
9
-
10
- export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
11
- if (!params.runId) return result("Cancel requires runId.", { action: "cancel", status: "error" }, true);
12
- const loaded = loadRunManifestById(ctx.cwd, params.runId);
13
- if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
14
- return withRunLockSync(loaded.manifest, () => {
15
- if (loaded.manifest.status === "completed" && !params.force) return result(`Run ${loaded.manifest.runId} is already completed; nothing to cancel. Use force: true to mark it cancelled anyway.`, { action: "cancel", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
16
- const tasks = loaded.tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: "Run cancelled by user request." } : task);
17
- saveRunTasks(loaded.manifest, tasks);
18
- try {
19
- saveCrewAgents(loaded.manifest, tasks.map((task) => recordFromTask(loaded.manifest, task, "child-process")));
20
- } catch (error) {
21
- logInternalError("team-tool.handleCancel.crewAgents", error, `runId=${loaded.manifest.runId}`);
22
- }
23
- try {
24
- writeForegroundInterruptRequest(loaded.manifest, "Run cancelled by user request.");
25
- } catch (error) {
26
- logInternalError("team-tool.handleCancel.interruptRequest", error, `runId=${loaded.manifest.runId}`);
27
- }
28
- const updated = updateRunStatus(loaded.manifest, "cancelled", "Run cancelled by user request. Already-finished worker processes are not retroactively changed.");
29
- return result(`Cancelled run ${updated.runId}.`, { action: "cancel", status: "ok", runId: updated.runId, artifactsRoot: updated.artifactsRoot });
30
- });
31
- }
1
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
2
+ import { withRunLockSync } from "../../state/locks.ts";
3
+ import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/state-store.ts";
4
+ import { saveCrewAgents, recordFromTask } from "../../runtime/crew-agent-records.ts";
5
+ import { writeForegroundInterruptRequest } from "../../runtime/foreground-control.ts";
6
+ import { logInternalError } from "../../utils/internal-error.ts";
7
+ import type { PiTeamsToolResult } from "../tool-result.ts";
8
+ import { result, type TeamContext } from "./context.ts";
9
+
10
+ export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
11
+ if (!params.runId) return result("Cancel requires runId.", { action: "cancel", status: "error" }, true);
12
+ const loaded = loadRunManifestById(ctx.cwd, params.runId);
13
+ if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
14
+ return withRunLockSync(loaded.manifest, () => {
15
+ if (loaded.manifest.status === "completed" && !params.force) return result(`Run ${loaded.manifest.runId} is already completed; nothing to cancel. Use force: true to mark it cancelled anyway.`, { action: "cancel", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
16
+ const tasks = loaded.tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: "Run cancelled by user request." } : task);
17
+ saveRunTasks(loaded.manifest, tasks);
18
+ try {
19
+ saveCrewAgents(loaded.manifest, tasks.map((task) => recordFromTask(loaded.manifest, task, "child-process")));
20
+ } catch (error) {
21
+ logInternalError("team-tool.handleCancel.crewAgents", error, `runId=${loaded.manifest.runId}`);
22
+ }
23
+ try {
24
+ writeForegroundInterruptRequest(loaded.manifest, "Run cancelled by user request.");
25
+ } catch (error) {
26
+ logInternalError("team-tool.handleCancel.interruptRequest", error, `runId=${loaded.manifest.runId}`);
27
+ }
28
+ const updated = updateRunStatus(loaded.manifest, "cancelled", "Run cancelled by user request. Already-finished worker processes are not retroactively changed.");
29
+ return result(`Cancelled run ${updated.runId}.`, { action: "cancel", status: "ok", runId: updated.runId, artifactsRoot: updated.artifactsRoot });
30
+ });
31
+ }
@@ -52,7 +52,14 @@ function checkWritableDir(dir: string): { ok: boolean; detail: string } {
52
52
  try {
53
53
  if (!fs.existsSync(dir)) return { ok: false, detail: `${dir}: missing` };
54
54
  if (!fs.statSync(dir).isDirectory()) return { ok: false, detail: `${dir}: not a directory` };
55
- fs.accessSync(dir, fs.constants.W_OK);
55
+ // fs.accessSync(W_OK) is unreliable on Windows; verify by writing a temp file.
56
+ const probePath = `${dir}/.pi-crew-write-test`;
57
+ try {
58
+ fs.writeFileSync(probePath, "ok", "utf-8");
59
+ fs.rmSync(probePath, { force: true });
60
+ } catch {
61
+ return { ok: false, detail: `${dir}: not writable (write test failed)` };
62
+ }
56
63
  return { ok: true, detail: dir };
57
64
  } catch (error) {
58
65
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,188 @@
1
+ import type { TeamContext } from "../team-tool/context.ts";
2
+ import { loadConfig, updateConfig } from "../../config/config.ts";
3
+ import { configPatchFromConfig } from "../team-tool/config-patch.ts";
4
+ import { result } from "../team-tool/context.ts";
5
+ import type { PiTeamsToolResult } from "../tool-result.ts";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function setNested(obj: Record<string, unknown>, path: string, value: unknown): void {
12
+ const keys = path.split(".");
13
+ let target: Record<string, unknown> = obj;
14
+ for (let i = 0; i < keys.length - 1; i++) {
15
+ if (!target[keys[i]] || typeof target[keys[i]] !== "object") {
16
+ target[keys[i]] = {};
17
+ }
18
+ target = target[keys[i]] as Record<string, unknown>;
19
+ }
20
+ target[keys[keys.length - 1]] = value;
21
+ }
22
+
23
+ function getNested(obj: Record<string, unknown>, path: string): unknown {
24
+ const keys = path.split(".");
25
+ let current: unknown = obj;
26
+ for (const key of keys) {
27
+ if (!current || typeof current !== "object") return undefined;
28
+ current = (current as Record<string, unknown>)[key];
29
+ }
30
+ return current;
31
+ }
32
+
33
+ function formatValue(value: unknown): string {
34
+ if (value === undefined) return "<not set>";
35
+ if (typeof value === "object") return JSON.stringify(value);
36
+ return String(value);
37
+ }
38
+
39
+ function parseValue(raw: string): unknown {
40
+ // JSON handles strings (quoted), numbers, booleans, null, arrays, objects.
41
+ try { return JSON.parse(raw); } catch { /* keep as string */ }
42
+ return raw;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Known config keys — mirrors config-schema.ts + config.ts.
47
+ // When adding new config fields, add the dotted path here so team-settings
48
+ // can discover and display them.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const KNOWN_KEYS = new Set([
52
+ // top-level
53
+ "asyncByDefault",
54
+ "executeWorkers",
55
+ "notifierIntervalMs",
56
+ "requireCleanWorktreeLeader",
57
+ // runtime
58
+ "runtime.mode",
59
+ "runtime.preferLiveSession",
60
+ "runtime.allowChildProcessFallback",
61
+ "runtime.maxTurns",
62
+ "runtime.graceTurns",
63
+ "runtime.inheritContext",
64
+ "runtime.promptMode",
65
+ "runtime.groupJoin",
66
+ "runtime.groupJoinAckTimeoutMs",
67
+ "runtime.requirePlanApproval",
68
+ "runtime.completionMutationGuard",
69
+ // limits
70
+ "limits.maxConcurrentWorkers",
71
+ "limits.allowUnboundedConcurrency",
72
+ "limits.maxTaskDepth",
73
+ "limits.maxChildrenPerTask",
74
+ "limits.maxRunMinutes",
75
+ "limits.maxRetriesPerTask",
76
+ "limits.maxTasksPerRun",
77
+ "limits.heartbeatStaleMs",
78
+ // control
79
+ "control.enabled",
80
+ "control.needsAttentionAfterMs",
81
+ // autonomous
82
+ "autonomous.profile",
83
+ "autonomous.enabled",
84
+ "autonomous.injectPolicy",
85
+ "autonomous.preferAsyncForLongTasks",
86
+ "autonomous.allowWorktreeSuggestion",
87
+ // tools
88
+ "tools.enableClaudeStyleAliases",
89
+ "tools.enableSteer",
90
+ "tools.terminateOnForeground",
91
+ // agents
92
+ "agents.disableBuiltins",
93
+ // observability
94
+ "observability.prometheus.enabled",
95
+ "observability.otlp.enabled",
96
+ // worktree
97
+ "worktree.enabled",
98
+ ]);
99
+
100
+ const KNOWN_SORTED = [...KNOWN_KEYS].sort();
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Detail objects – all require { action, status } from TeamToolDetails.
104
+ // Extras (count, key, value, path) are passed as never to bypass the narrow
105
+ // TeamToolDetails interface (consistent with the rest of the codebase).
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const OK = { action: "settings", status: "ok" as const };
109
+ const ERR = { action: "settings", status: "error" as const };
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Main handler
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export function handleSettings(params: { config?: Record<string, unknown> }, ctx: TeamContext): PiTeamsToolResult {
116
+ const cfg = (params.config ?? {}) as Record<string, unknown>;
117
+ const args = typeof cfg.args === "string" ? cfg.args.trim() : "";
118
+ const scope = cfg.scope === "project" ? "project" : "user";
119
+ const loaded = loadConfig(ctx.cwd);
120
+ const effective = loaded.config as Record<string, unknown>;
121
+
122
+ // team-settings list
123
+ if (!args || args === "list") {
124
+ const lines = ["pi-crew settings:", `Path: ${loaded.path}`, ""];
125
+ for (const key of KNOWN_SORTED) {
126
+ const value = getNested(effective, key);
127
+ lines.push(` ${key} = ${formatValue(value)}`);
128
+ }
129
+ lines.push("", "Usage: team-settings [list|get <key>|set <key> <value>|unset <key>|path|scope]");
130
+ return result(lines.join("\n"), { ...OK, count: KNOWN_KEYS.size } as never);
131
+ }
132
+
133
+ // team-settings path
134
+ if (args === "path") {
135
+ return result(`pi-crew config path: ${loaded.path}`, { ...OK, path: loaded.path } as never);
136
+ }
137
+
138
+ // team-settings scope
139
+ if (args === "scope") {
140
+ return result(`Current scope: ${scope}\nConfig at: ${loaded.path}`, { ...OK, scope } as never);
141
+ }
142
+
143
+ // team-settings get <key>
144
+ if (args.startsWith("get ")) {
145
+ const key = args.slice(4).trim();
146
+ if (!key) return result("Usage: team-settings get <key>", { ...ERR }, true);
147
+ const value = getNested(effective, key);
148
+ const note = KNOWN_KEYS.has(key) ? "" : " (unknown key — may not take effect)";
149
+ return result(`${key} = ${formatValue(value)}${note}`, { ...OK, key, value } as never);
150
+ }
151
+
152
+ // team-settings unset <key>
153
+ if (args.startsWith("unset ")) {
154
+ const key = args.slice(6).trim();
155
+ if (!key) return result("Usage: team-settings unset <key>", { ...ERR }, true);
156
+ try {
157
+ const saved = updateConfig({}, { cwd: ctx.cwd, scope, unsetPaths: [key] });
158
+ return result(`Unset ${key}\nPath: ${saved.path}`, { ...OK, key } as never);
159
+ } catch (error) {
160
+ return result(error instanceof Error ? error.message : String(error), { ...ERR }, true);
161
+ }
162
+ }
163
+
164
+ // team-settings set <key> <value>
165
+ if (args.startsWith("set ")) {
166
+ const rest = args.slice(4).trim();
167
+ const spaceIdx = rest.indexOf(" ");
168
+ if (spaceIdx === -1) return result("Usage: team-settings set <key> <value>", { ...ERR }, true);
169
+ const key = rest.slice(0, spaceIdx);
170
+ const rawValue = rest.slice(spaceIdx + 1).trim();
171
+ if (!key) return result("Usage: team-settings set <key> <value>", { ...ERR }, true);
172
+
173
+ const value = parseValue(rawValue);
174
+ const patch = {};
175
+ setNested(patch as Record<string, unknown>, key, value);
176
+
177
+ try {
178
+ const converted = configPatchFromConfig({ config: patch as Record<string, unknown> });
179
+ const saved = updateConfig(converted, { cwd: ctx.cwd, scope });
180
+ const warning = KNOWN_KEYS.has(key) ? "" : "\nWarning: unknown key — verify it exists in config schema.";
181
+ return result(`Set ${key} = ${formatValue(value)}\nPath: ${saved.path}${warning}`, { ...OK, key, value } as never);
182
+ } catch (error) {
183
+ return result(error instanceof Error ? error.message : String(error), { ...ERR }, true);
184
+ }
185
+ }
186
+
187
+ return result("Unknown subcommand. Usage: team-settings [list|get <key>|set <key> <value>|unset <key>|path|scope]", { ...ERR }, true);
188
+ }