pi-crew 0.1.45 → 0.1.49

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 (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -5
  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/next-upgrade-roadmap.md +808 -0
  14. package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
  15. package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
  16. package/docs/research/AUDIT_OH_MY_PI.md +261 -0
  17. package/docs/research/AUDIT_PI_CREW.md +457 -0
  18. package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
  19. package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
  20. package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
  21. package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
  22. package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
  23. package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
  24. package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
  25. package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
  26. package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
  27. package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
  28. package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
  29. package/docs/research-awesome-agent-skills-distillation.md +100 -0
  30. package/docs/research-oh-my-pi-distillation.md +369 -0
  31. package/docs/source-runtime-refactor-map.md +24 -0
  32. package/docs/usage.md +3 -3
  33. package/install.mjs +52 -8
  34. package/package.json +99 -98
  35. package/schema.json +10 -1
  36. package/skills/async-worker-recovery/SKILL.md +42 -0
  37. package/skills/context-artifact-hygiene/SKILL.md +52 -0
  38. package/skills/delegation-patterns/SKILL.md +54 -0
  39. package/skills/mailbox-interactive/SKILL.md +40 -0
  40. package/skills/model-routing-context/SKILL.md +39 -0
  41. package/skills/multi-perspective-review/SKILL.md +58 -0
  42. package/skills/observability-reliability/SKILL.md +41 -0
  43. package/skills/orchestration/SKILL.md +157 -0
  44. package/skills/ownership-session-security/SKILL.md +41 -0
  45. package/skills/pi-extension-lifecycle/SKILL.md +39 -0
  46. package/skills/requirements-to-task-packet/SKILL.md +63 -0
  47. package/skills/resource-discovery-config/SKILL.md +41 -0
  48. package/skills/runtime-state-reader/SKILL.md +44 -0
  49. package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
  50. package/skills/state-mutation-locking/SKILL.md +42 -0
  51. package/skills/systematic-debugging/SKILL.md +67 -0
  52. package/skills/ui-render-performance/SKILL.md +39 -0
  53. package/skills/verification-before-done/SKILL.md +57 -0
  54. package/skills/worktree-isolation/SKILL.md +39 -0
  55. package/src/agents/agent-config.ts +6 -0
  56. package/src/agents/agent-search.ts +98 -0
  57. package/src/agents/agent-serializer.ts +38 -34
  58. package/src/agents/discover-agents.ts +29 -15
  59. package/src/config/config.ts +72 -24
  60. package/src/config/defaults.ts +25 -0
  61. package/src/extension/autonomous-policy.ts +26 -33
  62. package/src/extension/help.ts +1 -0
  63. package/src/extension/management.ts +5 -0
  64. package/src/extension/project-init.ts +62 -2
  65. package/src/extension/register.ts +69 -22
  66. package/src/extension/registration/commands.ts +64 -25
  67. package/src/extension/registration/compaction-guard.ts +1 -1
  68. package/src/extension/registration/subagent-helpers.ts +8 -0
  69. package/src/extension/registration/subagent-tools.ts +149 -148
  70. package/src/extension/registration/team-tool.ts +14 -10
  71. package/src/extension/run-index.ts +35 -21
  72. package/src/extension/run-maintenance.ts +30 -5
  73. package/src/extension/team-tool/api.ts +47 -9
  74. package/src/extension/team-tool/cancel.ts +109 -5
  75. package/src/extension/team-tool/context.ts +8 -0
  76. package/src/extension/team-tool/intent-policy.ts +42 -0
  77. package/src/extension/team-tool/lifecycle-actions.ts +120 -79
  78. package/src/extension/team-tool/parallel-dispatch.ts +156 -0
  79. package/src/extension/team-tool/respond.ts +46 -18
  80. package/src/extension/team-tool/run.ts +55 -12
  81. package/src/extension/team-tool/status.ts +13 -2
  82. package/src/extension/team-tool-types.ts +3 -0
  83. package/src/extension/team-tool.ts +45 -14
  84. package/src/hooks/registry.ts +61 -0
  85. package/src/hooks/types.ts +41 -0
  86. package/src/observability/event-to-metric.ts +8 -1
  87. package/src/runtime/agent-control.ts +169 -63
  88. package/src/runtime/async-runner.ts +3 -1
  89. package/src/runtime/background-runner.ts +78 -53
  90. package/src/runtime/cancellation-token.ts +89 -0
  91. package/src/runtime/cancellation.ts +61 -0
  92. package/src/runtime/capability-inventory.ts +116 -0
  93. package/src/runtime/child-pi.ts +458 -444
  94. package/src/runtime/code-summary.ts +247 -0
  95. package/src/runtime/crash-recovery.ts +182 -0
  96. package/src/runtime/crew-agent-records.ts +70 -10
  97. package/src/runtime/crew-agent-runtime.ts +1 -0
  98. package/src/runtime/custom-tools/irc-tool.ts +201 -0
  99. package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
  100. package/src/runtime/deadletter.ts +1 -0
  101. package/src/runtime/delivery-coordinator.ts +48 -25
  102. package/src/runtime/effectiveness.ts +81 -0
  103. package/src/runtime/event-stream-bridge.ts +90 -0
  104. package/src/runtime/live-agent-control.ts +2 -1
  105. package/src/runtime/live-agent-manager.ts +179 -85
  106. package/src/runtime/live-control-realtime.ts +1 -1
  107. package/src/runtime/live-extension-bridge.ts +150 -0
  108. package/src/runtime/live-irc.ts +92 -0
  109. package/src/runtime/live-session-health.ts +100 -0
  110. package/src/runtime/live-session-runtime.ts +599 -305
  111. package/src/runtime/manifest-cache.ts +17 -2
  112. package/src/runtime/mcp-proxy.ts +113 -0
  113. package/src/runtime/model-fallback.ts +6 -4
  114. package/src/runtime/notebook-helpers.ts +90 -0
  115. package/src/runtime/orphan-sentinel.ts +7 -0
  116. package/src/runtime/output-validator.ts +187 -0
  117. package/src/runtime/parallel-utils.ts +57 -0
  118. package/src/runtime/parent-guard.ts +80 -0
  119. package/src/runtime/pi-args.ts +18 -3
  120. package/src/runtime/process-status.ts +5 -1
  121. package/src/runtime/prose-compressor.ts +164 -0
  122. package/src/runtime/result-extractor.ts +121 -0
  123. package/src/runtime/retry-executor.ts +81 -64
  124. package/src/runtime/runtime-resolver.ts +23 -10
  125. package/src/runtime/semaphore.ts +131 -0
  126. package/src/runtime/sensitive-paths.ts +92 -0
  127. package/src/runtime/skill-instructions.ts +222 -0
  128. package/src/runtime/stale-reconciler.ts +4 -14
  129. package/src/runtime/stream-preview.ts +177 -0
  130. package/src/runtime/subagent-manager.ts +6 -2
  131. package/src/runtime/subprocess-tool-registry.ts +67 -0
  132. package/src/runtime/task-output-context.ts +177 -127
  133. package/src/runtime/task-runner/capabilities.ts +78 -0
  134. package/src/runtime/task-runner/live-executor.ts +107 -101
  135. package/src/runtime/task-runner/prompt-builder.ts +72 -8
  136. package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
  137. package/src/runtime/task-runner/run-projection.ts +104 -0
  138. package/src/runtime/task-runner.ts +115 -5
  139. package/src/runtime/team-runner.ts +134 -19
  140. package/src/runtime/workspace-tree.ts +298 -0
  141. package/src/runtime/yield-handler.ts +189 -0
  142. package/src/schema/config-schema.ts +7 -0
  143. package/src/schema/team-tool-schema.ts +14 -4
  144. package/src/skills/discover-skills.ts +67 -0
  145. package/src/state/active-run-registry.ts +167 -0
  146. package/src/state/artifact-store.ts +4 -1
  147. package/src/state/atomic-write.ts +50 -1
  148. package/src/state/blob-store.ts +117 -0
  149. package/src/state/contracts.ts +2 -1
  150. package/src/state/event-log-rotation.ts +158 -0
  151. package/src/state/event-log.ts +52 -2
  152. package/src/state/mailbox.ts +129 -9
  153. package/src/state/state-store.ts +32 -5
  154. package/src/state/types.ts +64 -2
  155. package/src/teams/team-config.ts +1 -0
  156. package/src/ui/agent-management-overlay.ts +144 -0
  157. package/src/ui/crew-widget.ts +15 -5
  158. package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
  159. package/src/ui/dashboard-panes/capability-pane.ts +60 -0
  160. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
  161. package/src/ui/dashboard-panes/progress-pane.ts +2 -0
  162. package/src/ui/live-run-sidebar.ts +4 -0
  163. package/src/ui/powerbar-publisher.ts +77 -15
  164. package/src/ui/render-coalescer.ts +51 -0
  165. package/src/ui/run-dashboard.ts +4 -0
  166. package/src/ui/run-event-bus.ts +209 -0
  167. package/src/ui/run-snapshot-cache.ts +78 -18
  168. package/src/ui/snapshot-types.ts +10 -0
  169. package/src/ui/transcript-entries.ts +258 -0
  170. package/src/utils/ids.ts +5 -0
  171. package/src/utils/incremental-reader.ts +104 -0
  172. package/src/utils/paths.ts +4 -2
  173. package/src/utils/scan-cache.ts +137 -0
  174. package/src/utils/sse-parser.ts +134 -0
  175. package/src/utils/task-name-generator.ts +337 -0
  176. package/src/utils/visual.ts +33 -2
  177. package/src/workflows/workflow-config.ts +1 -0
  178. package/src/worktree/cleanup.ts +2 -1
@@ -0,0 +1,189 @@
1
+ import { subprocessToolRegistry, type SubprocessToolEvent } from "./subprocess-tool-registry.ts";
2
+
3
+ // G3: Full AJV-based schema validation for yield data.
4
+ // Falls back to lightweight validation if AJV is unavailable.
5
+
6
+ let _ajv: Awaited<ReturnType<typeof getAjvInternal>> | null | undefined;
7
+
8
+ async function getAjvInternal() {
9
+ const mod = await import("ajv");
10
+ const AjvCtor = ("default" in mod ? (mod as Record<string, unknown>).default : mod) as unknown as new (opts: Record<string, unknown>) => { compile: (schema: unknown) => { (data: unknown): boolean; errors?: unknown[] }; errorsText: (errors?: unknown[]) => string };
11
+ return new AjvCtor({ allErrors: true, strict: false, logger: false });
12
+ }
13
+
14
+ async function getAjv() {
15
+ if (_ajv === undefined) {
16
+ try {
17
+ _ajv = await getAjvInternal();
18
+ } catch {
19
+ _ajv = null;
20
+ }
21
+ }
22
+ return _ajv;
23
+ }
24
+
25
+ export interface SchemaValidationResult {
26
+ valid: boolean;
27
+ error?: string;
28
+ }
29
+
30
+ /**
31
+ * Validate yield data against a JSON Schema.
32
+ * Uses AJV when available (hoisted dep from pi-ai), falls back to
33
+ * lightweight type checking when not.
34
+ */
35
+ export async function validateYieldData(data: unknown, schema: unknown): Promise<SchemaValidationResult> {
36
+ if (!schema) return { valid: true };
37
+ if (data === undefined || data === null) return { valid: false, error: "Yield data is null or undefined." };
38
+
39
+ // Try AJV first
40
+ const ajv = await getAjv();
41
+ if (ajv) {
42
+ try {
43
+ const validate = ajv.compile(schema as Record<string, unknown>);
44
+ const valid = validate(data);
45
+ if (!valid) {
46
+ return { valid: false, error: ajv.errorsText(validate.errors ?? undefined) };
47
+ }
48
+ return { valid: true };
49
+ } catch {
50
+ // AJV compile failed — fall through to lightweight
51
+ }
52
+ }
53
+
54
+ // Lightweight fallback
55
+ return lightweightValidate(data, schema);
56
+ }
57
+
58
+ function lightweightValidate(data: unknown, schema: unknown): SchemaValidationResult {
59
+ if (typeof schema === "boolean") return { valid: schema };
60
+ if (typeof schema !== "object" || Array.isArray(schema)) return { valid: true };
61
+ const schemaObj = schema as Record<string, unknown>;
62
+
63
+ // Check type constraint
64
+ if (typeof schemaObj.type === "string") {
65
+ const expected = schemaObj.type;
66
+ const actual = Array.isArray(data) ? "array" : typeof data;
67
+ if (expected === "object" && (typeof data !== "object" || Array.isArray(data))) return { valid: false, error: `Expected type '${expected}' but got '${actual}'.` };
68
+ if (expected === "array" && !Array.isArray(data)) return { valid: false, error: `Expected type 'array' but got '${actual}'.` };
69
+ if (expected === "string" && typeof data !== "string") return { valid: false, error: `Expected type 'string' but got '${actual}'.` };
70
+ if (expected === "number" && typeof data !== "number") return { valid: false, error: `Expected type 'number' but got '${actual}'.` };
71
+ if (expected === "boolean" && typeof data !== "boolean") return { valid: false, error: `Expected type 'boolean' but got '${actual}'.` };
72
+ }
73
+
74
+ // Check required fields
75
+ if (Array.isArray(schemaObj.required) && typeof data === "object" && data !== null && !Array.isArray(data)) {
76
+ const dataObj = data as Record<string, unknown>;
77
+ for (const field of schemaObj.required) {
78
+ if (typeof field === "string" && !(field in dataObj)) {
79
+ return { valid: false, error: `Missing required field '${field}'.` };
80
+ }
81
+ }
82
+ }
83
+
84
+ return { valid: true };
85
+ }
86
+
87
+ export interface YieldResult {
88
+ summary: string;
89
+ artifacts?: Record<string, string>;
90
+ structuredData?: Record<string, unknown>;
91
+ toolCallId: string;
92
+ }
93
+
94
+ export interface YieldConfig {
95
+ enabled: boolean;
96
+ maxReminders: number;
97
+ reminderPrompt: string;
98
+ }
99
+
100
+ export const DEFAULT_YIELD_CONFIG: YieldConfig = {
101
+ enabled: true,
102
+ maxReminders: 3,
103
+ reminderPrompt: "You must call the submit_result tool to return your results.",
104
+ };
105
+
106
+ /** Tool name used by workers to yield their result. */
107
+ export const YIELD_TOOL_NAME = "submit_result";
108
+
109
+ /**
110
+ * Check if a value is a plain object record (non-null, non-array object).
111
+ */
112
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value);
114
+ }
115
+
116
+ /**
117
+ * Check if a value is a record with all string values.
118
+ */
119
+ function isStringRecord(value: unknown): value is Record<string, string> {
120
+ if (!isObjectRecord(value)) return false;
121
+ return Object.values(value).every((v) => typeof v === "string");
122
+ }
123
+
124
+ /**
125
+ * Shared helper to extract yield data from tool call arguments.
126
+ * Used by both `extractYieldResult` and `registerYieldTool.extractData`
127
+ * to avoid duplicating parsing logic.
128
+ */
129
+ export function extractYieldDataFromArgs(args: unknown, toolCallId: string): YieldResult | undefined {
130
+ if (!isObjectRecord(args)) return undefined;
131
+ const summary = typeof args.summary === "string" ? args.summary : "";
132
+ if (!summary) return undefined;
133
+ const result: YieldResult = { summary, toolCallId };
134
+ if (args.artifacts && isStringRecord(args.artifacts)) {
135
+ result.artifacts = args.artifacts;
136
+ }
137
+ if (args.structuredData && isObjectRecord(args.structuredData)) {
138
+ result.structuredData = args.structuredData;
139
+ }
140
+ return result;
141
+ }
142
+
143
+ /**
144
+ * Check if a JSON event represents a yield/submit_result tool call.
145
+ * Supports event types: tool_execution_start, toolCall, tool_call.
146
+ */
147
+ export function isYieldEvent(event: Record<string, unknown>): boolean {
148
+ const type = event.type;
149
+ if (type !== "tool_execution_start" && type !== "toolCall" && type !== "tool_call") return false;
150
+ const toolName = event.toolName ?? event.name ?? event.tool;
151
+ return toolName === YIELD_TOOL_NAME;
152
+ }
153
+
154
+ /**
155
+ * Extract structured result from a yield event.
156
+ */
157
+ export function extractYieldResult(event: Record<string, unknown>): YieldResult | undefined {
158
+ if (!isYieldEvent(event)) return undefined;
159
+ const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : "";
160
+ return extractYieldDataFromArgs(event.args, toolCallId);
161
+ }
162
+
163
+ /**
164
+ * Check if a worker output sequence contains a yield.
165
+ */
166
+ export function hasYieldInOutput(events: Record<string, unknown>[]): boolean {
167
+ return events.some((event) => isYieldEvent(event));
168
+ }
169
+
170
+ /**
171
+ * Build a reminder prompt for workers that haven't yielded.
172
+ */
173
+ export function buildYieldReminder(attempt: number, maxAttempts: number, reminderPrompt?: string): string {
174
+ return `[Yield Reminder ${attempt}/${maxAttempts}] ${reminderPrompt ?? DEFAULT_YIELD_CONFIG.reminderPrompt}`;
175
+ }
176
+
177
+ /**
178
+ * Register the submit_result tool handler in the subprocess tool registry.
179
+ */
180
+ export function registerYieldTool(): void {
181
+ subprocessToolRegistry.register<YieldResult>(YIELD_TOOL_NAME, {
182
+ extractData(event: SubprocessToolEvent): YieldResult | undefined {
183
+ return extractYieldDataFromArgs(event.args, event.toolCallId);
184
+ },
185
+ shouldTerminate(): boolean {
186
+ return true;
187
+ },
188
+ });
189
+ }
@@ -39,6 +39,7 @@ export const PiTeamsRuntimeConfigSchema = Type.Object({
39
39
  groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
40
40
  requirePlanApproval: Type.Optional(Type.Boolean()),
41
41
  completionMutationGuard: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")])),
42
+ effectivenessGuard: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("block"), Type.Literal("fail")])),
42
43
  }, { additionalProperties: false });
43
44
 
44
45
  export const PiTeamsControlConfigSchema = Type.Object({
@@ -76,6 +77,11 @@ export const PiTeamsTelemetryConfigSchema = Type.Object({
76
77
  enabled: Type.Optional(Type.Boolean()),
77
78
  }, { additionalProperties: false });
78
79
 
80
+ export const PiTeamsPolicyConfigSchema = Type.Object({
81
+ requireIntentForDestructiveActions: Type.Optional(Type.Boolean()),
82
+ disabledCapabilities: Type.Optional(Type.Array(Type.String())),
83
+ }, { additionalProperties: false });
84
+
79
85
  export const PiTeamsNotificationsConfigSchema = Type.Object({
80
86
  enabled: Type.Optional(Type.Boolean()),
81
87
  severityFilter: Type.Optional(Type.Array(Type.Union([Type.Literal("info"), Type.Literal("warning"), Type.Literal("error"), Type.Literal("critical")]))),
@@ -141,6 +147,7 @@ export const PiTeamsConfigSchema = Type.Object({
141
147
  agents: Type.Optional(PiTeamsAgentsConfigSchema),
142
148
  tools: Type.Optional(PiTeamsToolsConfigSchema),
143
149
  telemetry: Type.Optional(PiTeamsTelemetryConfigSchema),
150
+ policy: Type.Optional(PiTeamsPolicyConfigSchema),
144
151
  notifications: Type.Optional(PiTeamsNotificationsConfigSchema),
145
152
  observability: Type.Optional(PiTeamsObservabilityConfigSchema),
146
153
  reliability: Type.Optional(PiTeamsReliabilityConfigSchema),
@@ -1,10 +1,10 @@
1
1
  import { Type } from "typebox";
2
2
 
3
3
  const SkillOverride = Type.Unsafe({
4
- description: "Skill name(s) to inject, array of skill names, or false to disable role defaults.",
4
+ description: "Skill name(s) to add to role/default skills, an array of skill names, or false to disable all injected skills for this run.",
5
5
  anyOf: [
6
- { type: "string" },
7
- { type: "array", items: { type: "string" } },
6
+ { type: "string", maxLength: 2048 },
7
+ { type: "array", maxItems: 32, items: { type: "string", maxLength: 80 } },
8
8
  { type: "boolean" },
9
9
  ],
10
10
  });
@@ -18,6 +18,7 @@ const FreeformConfig = Type.Unsafe({
18
18
  export const TeamToolParams = Type.Object({
19
19
  action: Type.Optional(Type.Union([
20
20
  Type.Literal("run"),
21
+ Type.Literal("parallel"),
21
22
  Type.Literal("plan"),
22
23
  Type.Literal("status"),
23
24
  Type.Literal("list"),
@@ -85,10 +86,13 @@ export const TeamToolParams = Type.Object({
85
86
  force: Type.Optional(Type.Boolean({ description: "Override reference checks for destructive management actions." })),
86
87
  keep: Type.Optional(Type.Integer({ minimum: 0, description: "Number of finished runs to keep for prune." })),
87
88
  updateReferences: Type.Optional(Type.Boolean({ description: "When renaming agents or workflows, update team references in the same project/user scope." })),
89
+ replyTo: Type.Optional(Type.String({ description: "ID of the original mailbox message this is a reply to." })),
90
+ replyFrom: Type.Optional(Type.String({ description: "Task ID sending the reply." })),
91
+ replyDeadline: Type.Optional(Type.Integer({ description: "Ms epoch deadline for a reply." })),
88
92
  });
89
93
 
90
94
  export interface TeamToolParamsValue {
91
- action?: "run" | "plan" | "status" | "list" | "get" | "cancel" | "resume" | "respond" | "create" | "update" | "delete" | "doctor" | "cleanup" | "events" | "artifacts" | "worktrees" | "forget" | "summary" | "prune" | "export" | "import" | "imports" | "help" | "validate" | "config" | "init" | "recommend" | "autonomy" | "api" | "settings";
95
+ action?: "run" | "parallel" | "plan" | "status" | "list" | "get" | "cancel" | "retry" | "resume" | "respond" | "create" | "update" | "delete" | "doctor" | "cleanup" | "events" | "artifacts" | "worktrees" | "forget" | "summary" | "prune" | "export" | "import" | "imports" | "help" | "validate" | "config" | "init" | "recommend" | "autonomy" | "api" | "settings";
92
96
  resource?: "agent" | "team" | "workflow";
93
97
  team?: string;
94
98
  workflow?: string;
@@ -112,4 +116,10 @@ export interface TeamToolParamsValue {
112
116
  force?: boolean;
113
117
  keep?: number;
114
118
  updateReferences?: boolean;
119
+ /** ID of the original mailbox message this is a reply to. */
120
+ replyTo?: string;
121
+ /** Task ID sending the reply. */
122
+ replyFrom?: string;
123
+ /** Ms epoch deadline for a reply. */
124
+ replyDeadline?: number;
115
125
  }
@@ -0,0 +1,67 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { logInternalError } from "../utils/internal-error.ts";
5
+ import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
6
+
7
+ const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
8
+
9
+ export interface SkillDescriptor {
10
+ name: string;
11
+ description: string;
12
+ source: "project" | "package";
13
+ path: string;
14
+ }
15
+
16
+ function listSkillDirs(cwd: string): Array<{ root: string; source: "project" | "package" }> {
17
+ return [
18
+ { root: path.resolve(cwd, "skills"), source: "project" },
19
+ { root: PACKAGE_SKILLS_DIR, source: "package" },
20
+ ];
21
+ }
22
+
23
+ function frontmatterDescription(content: string): string | undefined {
24
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
25
+ if (!match) return undefined;
26
+ const line = match[1].split(/\r?\n/).find((entry) => entry.startsWith("description:"));
27
+ return line?.slice("description:".length).trim();
28
+ }
29
+
30
+ export function discoverSkills(cwd: string): SkillDescriptor[] {
31
+ const results: SkillDescriptor[] = [];
32
+ for (const dir of listSkillDirs(cwd)) {
33
+ if (!fs.existsSync(dir.root)) continue;
34
+ try {
35
+ for (const entry of fs.readdirSync(dir.root, { withFileTypes: true })) {
36
+ if (!entry.isDirectory()) continue;
37
+ if (!isSafePathId(entry.name)) continue;
38
+ const skillDirPath = path.join(dir.root, entry.name);
39
+ try {
40
+ if (fs.lstatSync(skillDirPath).isSymbolicLink()) continue;
41
+ } catch { continue; }
42
+ const skillMdRelative = path.join(entry.name, "SKILL.md");
43
+ let skillMdPath: string;
44
+ try {
45
+ skillMdPath = resolveContainedPath(dir.root, skillMdRelative);
46
+ } catch { continue; }
47
+ if (!fs.existsSync(skillMdPath)) continue;
48
+ try {
49
+ if (fs.lstatSync(skillMdPath).isSymbolicLink()) continue;
50
+ } catch { continue; }
51
+ let description = "";
52
+ try {
53
+ const realPath = resolveRealContainedPath(dir.root, skillMdRelative);
54
+ const content = fs.readFileSync(realPath, "utf-8");
55
+ description = frontmatterDescription(content) ?? "";
56
+ skillMdPath = realPath;
57
+ } catch (error) {
58
+ logInternalError("discoverSkills.readSkill", error, `skill=${entry.name}`);
59
+ }
60
+ results.push({ name: entry.name, description, source: dir.source, path: skillMdPath });
61
+ }
62
+ } catch (error) {
63
+ logInternalError("discoverSkills.readdir", error, `root=${dir.root}`);
64
+ }
65
+ }
66
+ return results;
67
+ }
@@ -0,0 +1,167 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
4
+ import type { TeamRunManifest } from "./types.ts";
5
+ import { atomicWriteJson } from "./atomic-write.ts";
6
+ import { userCrewRoot } from "../utils/paths.ts";
7
+ import { isSafePathId } from "../utils/safe-paths.ts";
8
+ import { sharedScanCache } from "../utils/scan-cache.ts";
9
+
10
+ export interface ActiveRunRegistryEntry {
11
+ runId: string;
12
+ cwd: string;
13
+ stateRoot: string;
14
+ manifestPath: string;
15
+ updatedAt: string;
16
+ }
17
+
18
+ function registryPath(): string {
19
+ return path.join(userCrewRoot(), DEFAULT_PATHS.state.runsSubdir, "active-run-index.json");
20
+ }
21
+
22
+ function registryLockPath(): string {
23
+ return `${registryPath()}.lock`;
24
+ }
25
+
26
+ function sleepSync(ms: number): void {
27
+ try {
28
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
29
+ } catch {
30
+ const deadline = Date.now() + ms;
31
+ while (Date.now() < deadline) {
32
+ // Best-effort fallback for rare runtimes without Atomics.wait.
33
+ }
34
+ }
35
+ }
36
+
37
+ function lockCreatedAt(raw: string): number | undefined {
38
+ try {
39
+ const parsed = JSON.parse(raw) as { createdAt?: unknown };
40
+ if (typeof parsed.createdAt !== "string") return undefined;
41
+ const time = Date.parse(parsed.createdAt);
42
+ return Number.isNaN(time) ? undefined : time;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ function removeStaleRegistryLock(lockPath: string, staleMs: number): boolean {
49
+ try {
50
+ const stat = fs.statSync(lockPath);
51
+ const createdAt = lockCreatedAt(fs.readFileSync(lockPath, "utf-8")) ?? stat.mtimeMs;
52
+ if (Date.now() - createdAt <= staleMs) return false;
53
+ fs.rmSync(lockPath, { force: true });
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function withRegistryLock<T>(fn: () => T): T {
61
+ const filePath = registryLockPath();
62
+ const staleMs = 30_000;
63
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
+ let attempt = 0;
65
+ const deadline = Date.now() + staleMs * 2;
66
+ while (true) {
67
+ try {
68
+ const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
69
+ try {
70
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
71
+ } finally {
72
+ fs.closeSync(fd);
73
+ }
74
+ break;
75
+ } catch (error) {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ if (code !== "EEXIST") throw error;
78
+ if (!removeStaleRegistryLock(filePath, staleMs) && Date.now() > deadline) throw new Error("Active-run registry is locked by another operation.");
79
+ sleepSync(Math.min(250, 25 * 2 ** attempt));
80
+ attempt += 1;
81
+ }
82
+ }
83
+ try {
84
+ return fn();
85
+ } finally {
86
+ try {
87
+ fs.rmSync(filePath, { force: true });
88
+ } catch {
89
+ // Best-effort cleanup.
90
+ }
91
+ }
92
+ }
93
+
94
+ function normalizeEntry(value: unknown): ActiveRunRegistryEntry | undefined {
95
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
96
+ const record = value as Record<string, unknown>;
97
+ const runId = typeof record.runId === "string" ? record.runId : undefined;
98
+ const cwd = typeof record.cwd === "string" ? record.cwd : undefined;
99
+ const stateRoot = typeof record.stateRoot === "string" ? record.stateRoot : undefined;
100
+ const manifestPath = typeof record.manifestPath === "string" ? record.manifestPath : undefined;
101
+ const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : undefined;
102
+ if (!runId || !isSafePathId(runId) || !cwd || !stateRoot || !manifestPath || !updatedAt) return undefined;
103
+ if (path.basename(stateRoot) !== runId) return undefined;
104
+ if (path.resolve(manifestPath) !== path.resolve(path.join(stateRoot, DEFAULT_PATHS.state.manifestFile))) return undefined;
105
+ return { runId, cwd, stateRoot, manifestPath, updatedAt };
106
+ }
107
+
108
+ export function readActiveRunRegistry(maxEntries = DEFAULT_CACHE.manifestMaxEntries): ActiveRunRegistryEntry[] {
109
+ let parsed: unknown;
110
+ try {
111
+ parsed = JSON.parse(fs.readFileSync(registryPath(), "utf-8"));
112
+ } catch {
113
+ return [];
114
+ }
115
+ const entries = Array.isArray(parsed) ? parsed.map(normalizeEntry).filter((entry): entry is ActiveRunRegistryEntry => entry !== undefined) : [];
116
+ const byId = new Map<string, ActiveRunRegistryEntry>();
117
+ for (const entry of entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))) {
118
+ if (!byId.has(entry.runId)) byId.set(entry.runId, entry);
119
+ }
120
+ return [...byId.values()].slice(0, Math.max(0, maxEntries));
121
+ }
122
+
123
+ function writeEntries(entries: ActiveRunRegistryEntry[]): void {
124
+ fs.mkdirSync(path.dirname(registryPath()), { recursive: true });
125
+ atomicWriteJson(registryPath(), entries.slice(0, DEFAULT_CACHE.manifestMaxEntries));
126
+ }
127
+
128
+ export function registerActiveRun(manifest: TeamRunManifest): void {
129
+ const entry: ActiveRunRegistryEntry = {
130
+ runId: manifest.runId,
131
+ cwd: manifest.cwd,
132
+ stateRoot: manifest.stateRoot,
133
+ manifestPath: path.join(manifest.stateRoot, DEFAULT_PATHS.state.manifestFile),
134
+ updatedAt: manifest.updatedAt,
135
+ };
136
+ withRegistryLock(() => {
137
+ writeEntries([entry, ...readActiveRunRegistry().filter((item) => item.runId !== manifest.runId)]);
138
+ });
139
+ }
140
+
141
+ export function unregisterActiveRun(runId: string): void {
142
+ if (!isSafePathId(runId)) return;
143
+ withRegistryLock(() => {
144
+ writeEntries(readActiveRunRegistry().filter((entry) => entry.runId !== runId));
145
+ });
146
+ }
147
+
148
+ export function activeRunEntries(): ActiveRunRegistryEntry[] {
149
+ const entries: ActiveRunRegistryEntry[] = [];
150
+ for (const entry of readActiveRunRegistry()) {
151
+ try {
152
+ if (!fs.existsSync(entry.stateRoot) || !fs.existsSync(entry.manifestPath)) continue;
153
+ if (fs.lstatSync(entry.stateRoot).isSymbolicLink()) continue;
154
+ const cached = sharedScanCache.readAndCache("active-manifests", entry.runId, entry.manifestPath);
155
+ const manifest = (cached?.raw ?? JSON.parse(fs.readFileSync(entry.manifestPath, "utf-8"))) as { status?: unknown };
156
+ if (manifest.status !== "queued" && manifest.status !== "planning" && manifest.status !== "running" && manifest.status !== "blocked") continue;
157
+ entries.push(entry);
158
+ } catch {
159
+ // Ignore stale entries; callers filter active status from manifests.
160
+ }
161
+ }
162
+ return entries;
163
+ }
164
+
165
+ export function activeRunRoots(): string[] {
166
+ return [...new Set(activeRunEntries().map((entry) => path.dirname(entry.stateRoot)))];
167
+ }
@@ -99,7 +99,10 @@ function resolveInside(baseDir: string, relativePath: string): string {
99
99
  const resolved = path.resolve(base, normalizedRelativePath);
100
100
  const relative = path.relative(base, resolved);
101
101
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Invalid artifact path: ${relativePath}`);
102
- return resolved;
102
+ // C1: Extra normalization guard for case-insensitive / symlinked filesystems
103
+ const normalized = path.normalize(resolved);
104
+ if (!normalized.startsWith(base + path.sep) && normalized !== base) throw new Error(`Invalid artifact path (traversal): ${relativePath}`);
105
+ return normalized;
103
106
  }
104
107
 
105
108
  export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptions): ArtifactDescriptor {
@@ -4,6 +4,49 @@ import { logInternalError } from "../utils/internal-error.ts";
4
4
 
5
5
  const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
6
6
 
7
+ /**
8
+ * Symlink-safe file write guard (caveman-inspired).
9
+ * Returns true if the path is safe to write, false if it's a symlink or
10
+ * inside a symlinked directory owned by another user.
11
+ */
12
+ function isSymlinkSafePath(filePath: string): boolean {
13
+ try {
14
+ const dir = path.dirname(filePath);
15
+ // Check if parent directory is a symlink
16
+ try {
17
+ const dirStat = fs.lstatSync(dir);
18
+ if (dirStat.isSymbolicLink()) {
19
+ // Resolve and verify ownership on Unix
20
+ const realDir = fs.realpathSync(dir);
21
+ const realStat = fs.statSync(realDir);
22
+ if (!realStat.isDirectory()) return false;
23
+ if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) return false;
24
+ }
25
+ } catch {
26
+ // Directory doesn't exist yet — that's OK, mkdirSync will create it
27
+ }
28
+
29
+ // Check if target file itself is a symlink
30
+ try {
31
+ const fileStat = fs.lstatSync(filePath);
32
+ if (fileStat.isSymbolicLink()) return false;
33
+ } catch {
34
+ // File doesn't exist yet — that's OK
35
+ }
36
+
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Synchronous sleep using Atomics.wait (non-busy) with busy-wait fallback.
45
+ *
46
+ * WARNING: This blocks the Node.js main thread. Only used in atomic-write
47
+ * rename retry path where sync I/O is required by the caller.
48
+ * NOT safe to call from Pi extension async code paths.
49
+ */
7
50
  function sleepSync(ms: number): void {
8
51
  try {
9
52
  const buffer = new SharedArrayBuffer(4);
@@ -56,10 +99,15 @@ export async function __test__renameWithRetryAsync(tempPath: string, filePath: s
56
99
  }
57
100
 
58
101
  export function atomicWriteFile(filePath: string, content: string): void {
102
+ if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
59
103
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
60
104
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
105
+ // Write temp with restrictive permissions
106
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
61
107
  try {
62
- fs.writeFileSync(tempPath, content, "utf-8");
108
+ const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o644);
109
+ fs.writeSync(fd, content, undefined, "utf-8");
110
+ fs.closeSync(fd);
63
111
  __test__renameWithRetry(tempPath, filePath);
64
112
  } catch (error) {
65
113
  try {
@@ -73,6 +121,7 @@ export function atomicWriteFile(filePath: string, content: string): void {
73
121
 
74
122
 
75
123
  export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
124
+ if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
76
125
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
77
126
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
78
127
  try {