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.
- package/CHANGELOG.md +97 -0
- package/README.md +5 -5
- package/agents/analyst.md +11 -11
- package/agents/critic.md +11 -11
- package/agents/executor.md +11 -11
- package/agents/explorer.md +11 -11
- package/agents/planner.md +11 -11
- package/agents/reviewer.md +11 -11
- package/agents/security-reviewer.md +11 -11
- package/agents/test-engineer.md +11 -11
- package/agents/verifier.md +11 -11
- package/agents/writer.md +11 -11
- package/docs/next-upgrade-roadmap.md +808 -0
- package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
- package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
- package/docs/research/AUDIT_OH_MY_PI.md +261 -0
- package/docs/research/AUDIT_PI_CREW.md +457 -0
- package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
- package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
- package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
- package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
- package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
- package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
- package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
- package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
- package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
- package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
- package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
- package/docs/research-awesome-agent-skills-distillation.md +100 -0
- package/docs/research-oh-my-pi-distillation.md +369 -0
- package/docs/source-runtime-refactor-map.md +24 -0
- package/docs/usage.md +3 -3
- package/install.mjs +52 -8
- package/package.json +99 -98
- package/schema.json +10 -1
- package/skills/async-worker-recovery/SKILL.md +42 -0
- package/skills/context-artifact-hygiene/SKILL.md +52 -0
- package/skills/delegation-patterns/SKILL.md +54 -0
- package/skills/mailbox-interactive/SKILL.md +40 -0
- package/skills/model-routing-context/SKILL.md +39 -0
- package/skills/multi-perspective-review/SKILL.md +58 -0
- package/skills/observability-reliability/SKILL.md +41 -0
- package/skills/orchestration/SKILL.md +157 -0
- package/skills/ownership-session-security/SKILL.md +41 -0
- package/skills/pi-extension-lifecycle/SKILL.md +39 -0
- package/skills/requirements-to-task-packet/SKILL.md +63 -0
- package/skills/resource-discovery-config/SKILL.md +41 -0
- package/skills/runtime-state-reader/SKILL.md +44 -0
- package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
- package/skills/state-mutation-locking/SKILL.md +42 -0
- package/skills/systematic-debugging/SKILL.md +67 -0
- package/skills/ui-render-performance/SKILL.md +39 -0
- package/skills/verification-before-done/SKILL.md +57 -0
- package/skills/worktree-isolation/SKILL.md +39 -0
- package/src/agents/agent-config.ts +6 -0
- package/src/agents/agent-search.ts +98 -0
- package/src/agents/agent-serializer.ts +38 -34
- package/src/agents/discover-agents.ts +29 -15
- package/src/config/config.ts +72 -24
- package/src/config/defaults.ts +25 -0
- package/src/extension/autonomous-policy.ts +26 -33
- package/src/extension/help.ts +1 -0
- package/src/extension/management.ts +5 -0
- package/src/extension/project-init.ts +62 -2
- package/src/extension/register.ts +69 -22
- package/src/extension/registration/commands.ts +64 -25
- package/src/extension/registration/compaction-guard.ts +1 -1
- package/src/extension/registration/subagent-helpers.ts +8 -0
- package/src/extension/registration/subagent-tools.ts +149 -148
- package/src/extension/registration/team-tool.ts +14 -10
- package/src/extension/run-index.ts +35 -21
- package/src/extension/run-maintenance.ts +30 -5
- package/src/extension/team-tool/api.ts +47 -9
- package/src/extension/team-tool/cancel.ts +109 -5
- package/src/extension/team-tool/context.ts +8 -0
- package/src/extension/team-tool/intent-policy.ts +42 -0
- package/src/extension/team-tool/lifecycle-actions.ts +120 -79
- package/src/extension/team-tool/parallel-dispatch.ts +156 -0
- package/src/extension/team-tool/respond.ts +46 -18
- package/src/extension/team-tool/run.ts +55 -12
- package/src/extension/team-tool/status.ts +13 -2
- package/src/extension/team-tool-types.ts +3 -0
- package/src/extension/team-tool.ts +45 -14
- package/src/hooks/registry.ts +61 -0
- package/src/hooks/types.ts +41 -0
- package/src/observability/event-to-metric.ts +8 -1
- package/src/runtime/agent-control.ts +169 -63
- package/src/runtime/async-runner.ts +3 -1
- package/src/runtime/background-runner.ts +78 -53
- package/src/runtime/cancellation-token.ts +89 -0
- package/src/runtime/cancellation.ts +61 -0
- package/src/runtime/capability-inventory.ts +116 -0
- package/src/runtime/child-pi.ts +458 -444
- package/src/runtime/code-summary.ts +247 -0
- package/src/runtime/crash-recovery.ts +182 -0
- package/src/runtime/crew-agent-records.ts +70 -10
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/custom-tools/irc-tool.ts +201 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
- package/src/runtime/deadletter.ts +1 -0
- package/src/runtime/delivery-coordinator.ts +48 -25
- package/src/runtime/effectiveness.ts +81 -0
- package/src/runtime/event-stream-bridge.ts +90 -0
- package/src/runtime/live-agent-control.ts +2 -1
- package/src/runtime/live-agent-manager.ts +179 -85
- package/src/runtime/live-control-realtime.ts +1 -1
- package/src/runtime/live-extension-bridge.ts +150 -0
- package/src/runtime/live-irc.ts +92 -0
- package/src/runtime/live-session-health.ts +100 -0
- package/src/runtime/live-session-runtime.ts +599 -305
- package/src/runtime/manifest-cache.ts +17 -2
- package/src/runtime/mcp-proxy.ts +113 -0
- package/src/runtime/model-fallback.ts +6 -4
- package/src/runtime/notebook-helpers.ts +90 -0
- package/src/runtime/orphan-sentinel.ts +7 -0
- package/src/runtime/output-validator.ts +187 -0
- package/src/runtime/parallel-utils.ts +57 -0
- package/src/runtime/parent-guard.ts +80 -0
- package/src/runtime/pi-args.ts +18 -3
- package/src/runtime/process-status.ts +5 -1
- package/src/runtime/prose-compressor.ts +164 -0
- package/src/runtime/result-extractor.ts +121 -0
- package/src/runtime/retry-executor.ts +81 -64
- package/src/runtime/runtime-resolver.ts +23 -10
- package/src/runtime/semaphore.ts +131 -0
- package/src/runtime/sensitive-paths.ts +92 -0
- package/src/runtime/skill-instructions.ts +222 -0
- package/src/runtime/stale-reconciler.ts +4 -14
- package/src/runtime/stream-preview.ts +177 -0
- package/src/runtime/subagent-manager.ts +6 -2
- package/src/runtime/subprocess-tool-registry.ts +67 -0
- package/src/runtime/task-output-context.ts +177 -127
- package/src/runtime/task-runner/capabilities.ts +78 -0
- package/src/runtime/task-runner/live-executor.ts +107 -101
- package/src/runtime/task-runner/prompt-builder.ts +72 -8
- package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
- package/src/runtime/task-runner/run-projection.ts +104 -0
- package/src/runtime/task-runner.ts +115 -5
- package/src/runtime/team-runner.ts +134 -19
- package/src/runtime/workspace-tree.ts +298 -0
- package/src/runtime/yield-handler.ts +189 -0
- package/src/schema/config-schema.ts +7 -0
- package/src/schema/team-tool-schema.ts +14 -4
- package/src/skills/discover-skills.ts +67 -0
- package/src/state/active-run-registry.ts +167 -0
- package/src/state/artifact-store.ts +4 -1
- package/src/state/atomic-write.ts +50 -1
- package/src/state/blob-store.ts +117 -0
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log-rotation.ts +158 -0
- package/src/state/event-log.ts +52 -2
- package/src/state/mailbox.ts +129 -9
- package/src/state/state-store.ts +32 -5
- package/src/state/types.ts +64 -2
- package/src/teams/team-config.ts +1 -0
- package/src/ui/agent-management-overlay.ts +144 -0
- package/src/ui/crew-widget.ts +15 -5
- package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
- package/src/ui/dashboard-panes/capability-pane.ts +60 -0
- package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
- package/src/ui/dashboard-panes/progress-pane.ts +2 -0
- package/src/ui/live-run-sidebar.ts +4 -0
- package/src/ui/powerbar-publisher.ts +77 -15
- package/src/ui/render-coalescer.ts +51 -0
- package/src/ui/run-dashboard.ts +4 -0
- package/src/ui/run-event-bus.ts +209 -0
- package/src/ui/run-snapshot-cache.ts +78 -18
- package/src/ui/snapshot-types.ts +10 -0
- package/src/ui/transcript-entries.ts +258 -0
- package/src/utils/ids.ts +5 -0
- package/src/utils/incremental-reader.ts +104 -0
- package/src/utils/paths.ts +4 -2
- package/src/utils/scan-cache.ts +137 -0
- package/src/utils/sse-parser.ts +134 -0
- package/src/utils/task-name-generator.ts +337 -0
- package/src/utils/visual.ts +33 -2
- package/src/workflows/workflow-config.ts +1 -0
- 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
|
|
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
|
-
|
|
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.
|
|
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 {
|