pi-crew 0.5.25 → 0.6.1

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 (81) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +13 -11
  3. package/docs/patterns/command-agent-skill.md +71 -0
  4. package/package.json +1 -1
  5. package/skills/council/SKILL.md +163 -0
  6. package/src/agents/agent-config.ts +4 -1
  7. package/src/agents/discover-agents.ts +1 -0
  8. package/src/benchmark/feedback-loop.ts +4 -2
  9. package/src/extension/cross-extension-rpc.ts +48 -0
  10. package/src/extension/registration/commands.ts +2 -1
  11. package/src/extension/registration/subagent-tools.ts +2 -0
  12. package/src/extension/registration/team-tool.ts +2 -0
  13. package/src/extension/registration/viewers.ts +1 -0
  14. package/src/extension/run-export.ts +16 -1
  15. package/src/extension/run-import.ts +16 -0
  16. package/src/extension/team-tool/anchor.ts +5 -1
  17. package/src/extension/team-tool/api.ts +9 -4
  18. package/src/extension/team-tool/config-patch.ts +15 -1
  19. package/src/extension/team-tool.ts +2 -1
  20. package/src/hooks/registry.ts +9 -1
  21. package/src/hooks/types.ts +14 -0
  22. package/src/i18n.ts +15 -2
  23. package/src/observability/exporters/otlp-exporter.ts +73 -0
  24. package/src/runtime/adaptive-plan.ts +24 -0
  25. package/src/runtime/agent-control.ts +6 -3
  26. package/src/runtime/async-runner.ts +58 -3
  27. package/src/runtime/background-runner.ts +1 -1
  28. package/src/runtime/chain-parser.ts +192 -0
  29. package/src/runtime/chain-runner.ts +58 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crew-agent-records.ts +4 -3
  32. package/src/runtime/cross-extension-rpc.ts +34 -8
  33. package/src/runtime/diagnostic-export.ts +3 -4
  34. package/src/runtime/dynamic-script-runner.ts +7 -7
  35. package/src/runtime/foreground-watchdog.ts +2 -2
  36. package/src/runtime/intercom-bridge.ts +178 -0
  37. package/src/runtime/live-agent-manager.ts +6 -3
  38. package/src/runtime/live-irc.ts +4 -2
  39. package/src/runtime/parallel-utils.ts +2 -1
  40. package/src/runtime/plan-templates.ts +200 -0
  41. package/src/runtime/post-checks.ts +10 -3
  42. package/src/runtime/run-drift.ts +220 -0
  43. package/src/runtime/sandbox.ts +26 -20
  44. package/src/runtime/semaphore.ts +2 -1
  45. package/src/runtime/settings-store.ts +14 -2
  46. package/src/runtime/skill-effectiveness.ts +4 -2
  47. package/src/runtime/skill-instructions.ts +4 -1
  48. package/src/runtime/subagent-manager.ts +20 -2
  49. package/src/runtime/subprocess-tool-registry.ts +2 -2
  50. package/src/runtime/task-graph.ts +79 -0
  51. package/src/runtime/task-id.ts +148 -0
  52. package/src/runtime/task-packet.ts +13 -1
  53. package/src/runtime/task-runner/context-retrieval.ts +172 -0
  54. package/src/runtime/task-runner.ts +39 -1
  55. package/src/runtime/team-runner.ts +7 -0
  56. package/src/runtime/usage-tracker.ts +4 -2
  57. package/src/runtime/verification-gates.ts +36 -9
  58. package/src/state/contracts.ts +2 -1
  59. package/src/state/event-log.ts +16 -5
  60. package/src/state/hook-instinct-bridge.ts +2 -1
  61. package/src/state/locks.ts +9 -2
  62. package/src/state/memory-store.ts +244 -0
  63. package/src/state/observation-store.ts +177 -0
  64. package/src/state/state-store.ts +4 -2
  65. package/src/state/task-claims.ts +9 -2
  66. package/src/tools/safe-bash.ts +69 -20
  67. package/src/types/new-api-types.ts +10 -5
  68. package/src/ui/keybinding-map.ts +2 -1
  69. package/src/ui/run-action-dispatcher.ts +2 -1
  70. package/src/ui/status-colors.ts +2 -1
  71. package/src/ui/syntax-highlight.ts +2 -1
  72. package/src/ui/tool-render.ts +13 -3
  73. package/src/utils/fingerprint.ts +183 -0
  74. package/src/utils/fs-watch.ts +4 -2
  75. package/src/utils/gh-protocol.ts +2 -1
  76. package/src/utils/safe-paths.ts +6 -0
  77. package/src/workflows/discover-workflows.ts +5 -1
  78. package/src/workflows/intermediate-store.ts +173 -0
  79. package/src/workflows/workflow-config.ts +8 -0
  80. package/src/worktree/cleanup.ts +8 -5
  81. package/src/worktree/worktree-manager.ts +1 -1
@@ -25,9 +25,14 @@ import type { PiTeamsToolResult } from "../tool-result.ts";
25
25
  import { locateRunCwd } from "../team-tool.ts";
26
26
  import { configRecord, result, type TeamContext } from "./context.ts";
27
27
 
28
- function globMatch(value: string, pattern: string): boolean {
29
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\?/g, "\\?").replace(/\*/g, ".*");
30
- return new RegExp(`^${escaped}$`).test(value);
28
+ export function globMatch(value: string, pattern: string): boolean {
29
+ // Prevent ReDoS: reject excessively long patterns
30
+ if (pattern.length > 200) return false;
31
+ const regex = pattern
32
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars
33
+ .replace(/\*/g, "[^/]*") // * matches non-slash characters only
34
+ .replace(/\?/g, "[^/]"); // ? matches single non-slash
35
+ return new RegExp(`^${regex}$`).test(value);
31
36
  }
32
37
 
33
38
  function safeReadContainedFile(baseDir: string, filePath: string | undefined): string | undefined {
@@ -364,7 +369,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
364
369
  const updatedTask = claimTask(task, owner);
365
370
  const tasks = loaded.tasks.map((item) => item.id === task.id ? updatedTask : item);
366
371
  saveRunTasks(loaded.manifest, tasks);
367
- appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: updatedTask.claim?.token, leasedUntil: updatedTask.claim?.leasedUntil } });
372
+ appendEvent(loaded.manifest.eventsPath, { type: "task.claimed", runId: loaded.manifest.runId, taskId: task.id, data: { owner, token: "[REDACTED]", leasedUntil: updatedTask.claim?.leasedUntil } });
368
373
  return result(JSON.stringify(updatedTask.claim, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
369
374
  });
370
375
  } catch (error) {
@@ -1,5 +1,19 @@
1
1
  import { effectiveAutonomousConfig, parseConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig } from "../../config/config.ts";
2
2
 
3
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
4
+
5
+ /** Recursively strip dangerous prototype-pollution keys from all levels of an object. */
6
+ export function sanitizeObject<T>(obj: T): T {
7
+ if (obj === null || obj === undefined || typeof obj !== "object") return obj;
8
+ if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item)) as T;
9
+ const safe: Record<string, unknown> = {};
10
+ for (const key of Object.keys(obj as Record<string, unknown>)) {
11
+ if (DANGEROUS_KEYS.has(key)) continue;
12
+ safe[key] = sanitizeObject((obj as Record<string, unknown>)[key]);
13
+ }
14
+ return safe as T;
15
+ }
16
+
3
17
  export function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
4
18
  const rootPatch = parseConfig(config).autonomous;
5
19
  if (rootPatch) return rootPatch;
@@ -11,7 +25,7 @@ export function configPatchFromConfig(config: unknown): PiTeamsConfig {
11
25
  }
12
26
 
13
27
  export function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
14
- const patch = parseConfig(rawOverride);
28
+ const patch = sanitizeObject(parseConfig(rawOverride));
15
29
  return {
16
30
  ...base,
17
31
  ...patch,
@@ -1258,7 +1258,8 @@ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
1258
1258
  registry;
1259
1259
  }
1260
1260
 
1261
- export function getCrewGlobalRegistry(): CrewRegistry | undefined {
1261
+ /** @internal */
1262
+ function getCrewGlobalRegistry(): CrewRegistry | undefined {
1262
1263
  return (globalThis as Record<symbol | string, unknown>)[
1263
1264
  CREW_REGISTRY_KEY
1264
1265
  ] as CrewRegistry | undefined;
@@ -35,6 +35,14 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
35
35
  // environments, all hooks should set workspaceId to prevent cross-workspace access.
36
36
  const scopedHooks = hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId);
37
37
  if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
38
+ const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
39
+ function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
40
+ const clean: Record<string, unknown> = {};
41
+ for (const [k, v] of Object.entries(data)) {
42
+ if (!POLLUTED_KEYS.has(k)) clean[k] = v;
43
+ }
44
+ return clean;
45
+ }
38
46
  const start = Date.now();
39
47
  const diagnostics: string[] = [];
40
48
  let capturedModifications: Record<string, unknown> | undefined;
@@ -45,7 +53,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
45
53
  return { hookName: name, outcome: "block", durationMs: Date.now() - start, reason: result.reason };
46
54
  }
47
55
  if (result.outcome === "modify" && result.data) {
48
- Object.assign(ctx, result.data);
56
+ Object.assign(ctx, sanitizeMergeData(result.data));
49
57
  capturedModifications = { ...result.data };
50
58
  }
51
59
  } catch (error) {
@@ -1,6 +1,8 @@
1
1
  export type HookName =
2
2
  | "before_run_start"
3
+ | "after_run_complete"
3
4
  | "before_task_start"
5
+ | "after_task_complete"
4
6
  | "task_result"
5
7
  | "before_cancel"
6
8
  | "before_retry"
@@ -8,8 +10,20 @@ export type HookName =
8
10
  | "before_cleanup"
9
11
  | "before_publish"
10
12
  | "session_before_switch"
13
+ | "session_after_connect"
14
+ | "session_after_disconnect"
11
15
  | "run_recovery";
12
16
 
17
+ /**
18
+ * Hook exit codes inspired by claude-mem's lifecycle architecture:
19
+ * - 0 = allow (success)
20
+ * - 1 = warn (non-blocking error, continue)
21
+ * - 2 = block (blocking error, stop)
22
+ */
23
+ /** @internal */ const HOOK_EXIT_SUCCESS = 0 as const;
24
+ /** @internal */ const HOOK_EXIT_WARN = 1 as const;
25
+ /** @internal */ const HOOK_EXIT_BLOCK = 2 as const;
26
+
13
27
  export type HookMode = "blocking" | "non_blocking";
14
28
  export type HookOutcome = "allow" | "block" | "modify" | "diagnostic";
15
29
 
package/src/i18n.ts CHANGED
@@ -129,13 +129,26 @@ export function t(key: Key, params?: Params): string {
129
129
  * @example
130
130
  * addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." })
131
131
  */
132
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
133
+
134
+ function stripDangerousKeys<T extends Record<string, unknown>>(obj: T): T {
135
+ const safe: Record<string, unknown> = {};
136
+ for (const key of Object.keys(obj)) {
137
+ if (!DANGEROUS_KEYS.has(key)) {
138
+ safe[key] = obj[key];
139
+ }
140
+ }
141
+ return safe as T;
142
+ }
143
+
132
144
  export function addTranslations(locale: string, bundle: Partial<Record<Key, string>>): void {
133
145
  if (!locale) return;
146
+ const safeBundle = stripDangerousKeys(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
134
147
  const existing = translations[locale];
135
148
  if (existing) {
136
- Object.assign(existing, bundle);
149
+ Object.assign(existing, safeBundle);
137
150
  } else {
138
- translations[locale] = { ...bundle };
151
+ translations[locale] = { ...safeBundle };
139
152
  }
140
153
  }
141
154
 
@@ -8,6 +8,78 @@ import type { MetricExporter } from "./adapter.ts";
8
8
 
9
9
  const gzipAsync = promisify(gzip);
10
10
 
11
+ /**
12
+ * SSRF protection: validate that an OTLP endpoint URL does not target
13
+ * private/reserved networks or dangerous protocols.
14
+ * Rejects: localhost, loopback, private IPs, link-local, cloud metadata,
15
+ * IPv6 private, file:// and javascript:// protocols.
16
+ * Only http:// and https:// to public hostnames are allowed.
17
+ */
18
+ export function validateEndpoint(endpoint: string): void {
19
+ let url: URL;
20
+ try {
21
+ url = new URL(endpoint);
22
+ } catch {
23
+ throw new Error(`Invalid OTLP endpoint URL: ${endpoint}`);
24
+ }
25
+
26
+ // Only allow http and https protocols
27
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
28
+ throw new Error(
29
+ `OTLP endpoint must use http:// or https:// protocol, got: ${url.protocol}`,
30
+ );
31
+ }
32
+
33
+ const hostname = url.hostname.toLowerCase();
34
+
35
+ // Reject known localhost names
36
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
37
+ throw new Error(`OTLP endpoint must not target localhost: ${endpoint}`);
38
+ }
39
+
40
+ // Reject IPv6 loopback and private
41
+ if (hostname.startsWith("[")) {
42
+ const bare = hostname.replace(/^\[|\]$/g, "");
43
+ if (bare === "::1") {
44
+ throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
45
+ }
46
+ if (bare.toLowerCase().startsWith("fd") || bare.toLowerCase().startsWith("fc")) {
47
+ throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
48
+ }
49
+ }
50
+
51
+ // Reject IPv4 private/reserved ranges
52
+ // Match plain IPv4 addresses (not hostnames that look like IPs)
53
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
54
+ if (ipv4Match) {
55
+ const octets = [Number(ipv4Match[1]), Number(ipv4Match[2]), Number(ipv4Match[3]), Number(ipv4Match[4])];
56
+ // 127.x.x.x - loopback
57
+ if (octets[0] === 127) {
58
+ throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
59
+ }
60
+ // 10.x.x.x - private class A
61
+ if (octets[0] === 10) {
62
+ throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${endpoint}`);
63
+ }
64
+ // 172.16.x.x - 172.31.x.x - private class B
65
+ if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
66
+ throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${endpoint}`);
67
+ }
68
+ // 192.168.x.x - private class C
69
+ if (octets[0] === 192 && octets[1] === 168) {
70
+ throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${endpoint}`);
71
+ }
72
+ // 169.254.x.x - link-local / cloud metadata
73
+ if (octets[0] === 169 && octets[1] === 254) {
74
+ throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${endpoint}`);
75
+ }
76
+ // 0.x.x.x - this network
77
+ if (octets[0] === 0) {
78
+ throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${endpoint}`);
79
+ }
80
+ }
81
+ }
82
+
11
83
  // FIX (Round 15): Cap the number of snapshots per push to prevent OOM when
12
84
  // the metric registry has grown large. The OTLP HTTP spec allows many metrics
13
85
  // in one payload, but a single push > 10_000 metrics would balloon the
@@ -71,6 +143,7 @@ export class OTLPExporter implements MetricExporter {
71
143
  private readonly registry: MetricRegistry;
72
144
 
73
145
  constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
146
+ validateEndpoint(opts.endpoint);
74
147
  this.opts = opts;
75
148
  this.registry = registry;
76
149
  }
@@ -22,6 +22,7 @@ import type { TeamConfig } from "../teams/team-config.ts";
22
22
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
23
23
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
24
24
  import { refreshTaskGraphQueues } from "./task-graph-scheduler.ts";
25
+ import { getPlanTemplate, renderPlanTemplate, listPlanTemplates } from "./plan-templates.ts";
25
26
 
26
27
  export interface AdaptivePlanTask {
27
28
  role: string;
@@ -70,6 +71,29 @@ export function extractAdaptivePlanJson(text: string): string | undefined {
70
71
  }
71
72
 
72
73
  export function parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
74
+ // Check if the text is a plan template reference: "template:<name>" with optional JSON variables
75
+ const templateRefMatch = text.match(/^template:([a-zA-Z0-9_-]+)/);
76
+ if (templateRefMatch?.[1]) {
77
+ const template = getPlanTemplate(templateRefMatch[1]);
78
+ if (template) {
79
+ // Try to extract variables from the remaining text
80
+ let variables: Record<string, string> = {};
81
+ try {
82
+ const varsJson = text.slice(templateRefMatch[0].length).trim();
83
+ if (varsJson) variables = JSON.parse(varsJson);
84
+ } catch { /* use empty variables */ }
85
+ const rendered = renderPlanTemplate(templateRefMatch[1], variables);
86
+ if (rendered) {
87
+ // Convert RenderedPlan → AdaptivePlan
88
+ const phases: AdaptivePlanPhase[] = rendered.phases.map(phase => ({
89
+ name: phase.name,
90
+ tasks: [{ role: phase.role, task: phase.task }],
91
+ }));
92
+ return phases.length ? { phases } : undefined;
93
+ }
94
+ }
95
+ }
96
+
73
97
  const raw = extractAdaptivePlanJson(text);
74
98
  if (!raw) return undefined;
75
99
  let parsed: unknown;
@@ -69,7 +69,8 @@ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentR
69
69
  return updated;
70
70
  }
71
71
 
72
- export function applyLongRunningCheck(
72
+ /** @internal */
73
+ function applyLongRunningCheck(
73
74
  manifest: TeamRunManifest,
74
75
  agent: CrewAgentRecord,
75
76
  config: CrewControlConfig,
@@ -105,7 +106,8 @@ export function applyLongRunningCheck(
105
106
  return updated;
106
107
  }
107
108
 
108
- export function trackConsecutiveToolFailure(
109
+ /** @internal */
110
+ function trackConsecutiveToolFailure(
109
111
  manifest: TeamRunManifest,
110
112
  agent: CrewAgentRecord,
111
113
  toolName: string,
@@ -140,7 +142,8 @@ export function trackConsecutiveToolFailure(
140
142
  return updated;
141
143
  }
142
144
 
143
- export function resetConsecutiveToolFailures(
145
+ /** @internal */
146
+ function resetConsecutiveToolFailures(
144
147
  manifest: TeamRunManifest,
145
148
  agent: CrewAgentRecord,
146
149
  ): void {
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { appendEvent } from "../state/event-log.ts";
8
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
8
9
  import type { TeamRunManifest } from "../state/types.ts";
9
10
 
10
11
 
@@ -131,8 +132,62 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
131
132
  const logPath = path.join(manifest.stateRoot, "background.log");
132
133
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
133
134
 
134
- // NOTE: Do NOT set PI_CREW_PARENT_PID for the background runner.
135
- const { PI_CREW_PARENT_PID: _, ...envWithoutParentPid } = process.env;
135
+ // SECURITY FIX: Use sanitizeEnvSecrets with same allow-list as child-pi.ts
136
+ // to prevent leaking all env vars (including secrets) to detached background runner.
137
+ // Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else.
138
+ const filteredEnv = sanitizeEnvSecrets(process.env, {
139
+ allowList: [
140
+ // Model provider API keys (same as child-pi.ts)
141
+ "MINIMAX_API_KEY",
142
+ "MINIMAX_GROUP_ID",
143
+ "OPENAI_API_KEY",
144
+ "OPENAI_ORG_ID",
145
+ "ANTHROPIC_API_KEY",
146
+ "GOOGLE_API_KEY",
147
+ "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
148
+ "AZURE_OPENAI_API_KEY",
149
+ "AZURE_OPENAI_ENDPOINT",
150
+ "AWS_ACCESS_KEY_ID",
151
+ "AWS_SECRET_ACCESS_KEY",
152
+ "AWS_REGION",
153
+ "ZEU_API_KEY",
154
+ "ZERODEV_API_KEY",
155
+ // Essential non-secret vars
156
+ "PATH",
157
+ "HOME",
158
+ "USER",
159
+ "SHELL",
160
+ "TERM",
161
+ "LANG",
162
+ "LC_ALL",
163
+ "LC_COLLATE",
164
+ "LC_CTYPE",
165
+ "LC_MESSAGES",
166
+ "LC_MONETARY",
167
+ "LC_NUMERIC",
168
+ "LC_TIME",
169
+ "XDG_CONFIG_HOME",
170
+ "XDG_DATA_HOME",
171
+ "XDG_CACHE_HOME",
172
+ "XDG_RUNTIME_DIR",
173
+ "NVM_BIN",
174
+ "NVM_DIR",
175
+ "NVM_INC",
176
+ "NODE_PATH",
177
+ "NODE_DISABLE_COLORS",
178
+ "NODE_EXTRA_CA_CERTS",
179
+ "NPM_CONFIG_REGISTRY",
180
+ "NPM_CONFIG_USERCONFIG",
181
+ "NPM_CONFIG_GLOBALCONFIG",
182
+ "PI_*",
183
+ "PI_CREW_*",
184
+ "PI_TEAMS_*",
185
+ ],
186
+ });
187
+ // Block execution control vars from leaking
188
+ delete filteredEnv.PI_CREW_PARENT_PID;
189
+ delete filteredEnv.PI_CREW_EXECUTE_WORKERS;
190
+ delete filteredEnv.PI_TEAMS_EXECUTE_WORKERS;
136
191
 
137
192
  const loader = resolveTypeScriptLoader();
138
193
  if (!loader) {
@@ -159,7 +214,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
159
214
  detached: true,
160
215
  setsid: true,
161
216
  stdio: ["ignore", "pipe", "pipe"],
162
- env: envWithoutParentPid,
217
+ env: filteredEnv,
163
218
  windowsHide: true,
164
219
  } as unknown as Parameters<typeof spawn>[2];
165
220
  const child = spawn(process.execPath, command.args, spawnOpts);
@@ -138,7 +138,7 @@ async function main(): Promise<void> {
138
138
  try {
139
139
  const logPath = path.join(_cwd, ".crew/state/runs", _runId, "background.log");
140
140
  logFd = fs.openSync(logPath, "a");
141
- const origWrite = (prefix: string) => (data: any, ...args: any[]) => {
141
+ const origWrite = (_prefix: string) => (data: unknown, ...args: unknown[]) => {
142
142
  const msg = [data, ...args].map(String).join(" ") + "\n";
143
143
  fs.writeSync(logFd!, msg);
144
144
  };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Chain/Parallel DSL Parser — parses workflow chain expressions.
3
+ *
4
+ * Syntax:
5
+ * step1 -> step2 -> parallel(step3, step4) -> step5
6
+ * step1:3 -> step2 --with-context -> step3
7
+ * parallel(a, b, parallel(c, d)) -> e
8
+ *
9
+ * Pattern origin: pi-prompt-template-model chain-parser.ts
10
+ */
11
+
12
+ export interface ChainStep {
13
+ /** Step name (maps to agent or workflow step ID) */
14
+ name: string;
15
+ /** Nested parallel group */
16
+ parallel?: ChainStep[];
17
+ /** Loop count (default: 1) */
18
+ loopCount?: number;
19
+ /** Pass predecessor output as context */
20
+ withContext?: boolean;
21
+ /** Positional arguments */
22
+ args?: string[];
23
+ }
24
+
25
+ /**
26
+ * Parse a chain DSL string into an AST.
27
+ *
28
+ * @throws {Error} on syntax errors (unclosed parens, empty names, etc.)
29
+ */
30
+ export function parseChainDSL(input: string): ChainStep[] {
31
+ const tokens = tokenize(input);
32
+ const parser = new ChainParser(tokens);
33
+ return parser.parse();
34
+ }
35
+
36
+ // ── Tokenizer ────────────────────────────────────────────────────────────
37
+
38
+ type TokenType = "NAME" | "ARROW" | "LPAREN" | "RPAREN" | "COMMA" | "COLON" | "NUMBER" | "FLAG" | "QUOTED";
39
+
40
+ interface Token {
41
+ type: TokenType;
42
+ value: string;
43
+ }
44
+
45
+ function tokenize(input: string): Token[] {
46
+ const tokens: Token[] = [];
47
+ let i = 0;
48
+
49
+ while (i < input.length) {
50
+ // Skip whitespace
51
+ if (/\s/.test(input[i]!)) { i++; continue; }
52
+
53
+ // Arrow ->
54
+ if (input[i] === "-" && input[i + 1] === ">") {
55
+ tokens.push({ type: "ARROW", value: "->" });
56
+ i += 2; continue;
57
+ }
58
+
59
+ // Punctuation
60
+ if (input[i] === "(") { tokens.push({ type: "LPAREN", value: "(" }); i++; continue; }
61
+ if (input[i] === ")") { tokens.push({ type: "RPAREN", value: ")" }); i++; continue; }
62
+ if (input[i] === ",") { tokens.push({ type: "COMMA", value: "," }); i++; continue; }
63
+ if (input[i] === ":") { tokens.push({ type: "COLON", value: ":" }); i++; continue; }
64
+
65
+ // Quoted argument
66
+ if (input[i] === '"' || input[i] === "'") {
67
+ const quote = input[i];
68
+ let str = "";
69
+ i++; // skip opening quote
70
+ while (i < input.length && input[i] !== quote) {
71
+ if (input[i] === "\\" && i + 1 < input.length) { i++; str += input[i]; }
72
+ else { str += input[i]!; }
73
+ i++;
74
+ }
75
+ if (i >= input.length) throw new Error("Unclosed quoted string in chain DSL");
76
+ i++; // skip closing quote
77
+ tokens.push({ type: "QUOTED", value: str });
78
+ continue;
79
+ }
80
+
81
+ // Flag --with-context
82
+ if (input[i] === "-" && input[i + 1] === "-") {
83
+ let flag = "";
84
+ i += 2;
85
+ while (i < input.length && /[a-zA-Z0-9_-]/.test(input[i]!)) { flag += input[i]; i++; }
86
+ tokens.push({ type: "FLAG", value: flag });
87
+ continue;
88
+ }
89
+
90
+ // Number
91
+ if (/[0-9]/.test(input[i]!)) {
92
+ let num = "";
93
+ while (i < input.length && /[0-9]/.test(input[i]!)) { num += input[i]; i++; }
94
+ tokens.push({ type: "NUMBER", value: num });
95
+ continue;
96
+ }
97
+
98
+ // Name
99
+ if (/[a-zA-Z_]/.test(input[i]!)) {
100
+ let name = "";
101
+ while (i < input.length && /[a-zA-Z0-9_.-]/.test(input[i]!)) { name += input[i]; i++; }
102
+ tokens.push({ type: "NAME", value: name });
103
+ continue;
104
+ }
105
+
106
+ throw new Error(`Unexpected character '${input[i]}' at position ${i} in chain DSL`);
107
+ }
108
+
109
+ return tokens;
110
+ }
111
+
112
+ // ── Recursive Descent Parser ─────────────────────────────────────────────
113
+
114
+ class ChainParser {
115
+ private pos = 0;
116
+
117
+ constructor(private tokens: Token[]) {}
118
+
119
+ parse(): ChainStep[] {
120
+ const steps: ChainStep[] = [];
121
+ steps.push(this.parseStep());
122
+ while (this.peek("ARROW")) {
123
+ this.consume("ARROW");
124
+ steps.push(this.parseStep());
125
+ }
126
+ if (this.pos < this.tokens.length) {
127
+ throw new Error(`Unexpected token '${this.tokens[this.pos]?.value}' at position ${this.pos}`);
128
+ }
129
+ return steps;
130
+ }
131
+
132
+ private parseStep(): ChainStep {
133
+ // Check for parallel(...) construct
134
+ if (this.peek("NAME", "parallel")) {
135
+ this.consume("NAME"); // eat "parallel"
136
+ this.consume("LPAREN");
137
+ const parallel: ChainStep[] = [];
138
+ parallel.push(this.parseStep());
139
+ while (this.peek("COMMA")) {
140
+ this.consume("COMMA");
141
+ parallel.push(this.parseStep());
142
+ }
143
+ this.consume("RPAREN");
144
+ const step: ChainStep = { name: "parallel", parallel };
145
+ this.parseModifiers(step);
146
+ return step;
147
+ }
148
+
149
+ // Normal step name
150
+ const name = this.consume("NAME").value;
151
+ const step: ChainStep = { name };
152
+
153
+ // Parse modifiers
154
+ this.parseModifiers(step);
155
+ return step;
156
+ }
157
+
158
+ private parseModifiers(step: ChainStep): void {
159
+ while (this.pos < this.tokens.length) {
160
+ if (this.peek("COLON")) {
161
+ this.consume("COLON");
162
+ const num = this.consume("NUMBER");
163
+ step.loopCount = Number.parseInt(num.value, 10);
164
+ } else if (this.peek("FLAG", "with-context")) {
165
+ this.consume("FLAG");
166
+ step.withContext = true;
167
+ } else if (this.peek("QUOTED")) {
168
+ const arg = this.consume("QUOTED");
169
+ step.args = step.args ?? [];
170
+ step.args.push(arg.value);
171
+ } else {
172
+ break;
173
+ }
174
+ }
175
+ }
176
+
177
+ private peek(type: TokenType, value?: string): boolean {
178
+ const tok = this.tokens[this.pos];
179
+ if (!tok) return false;
180
+ if (tok.type !== type) return false;
181
+ if (value !== undefined && tok.value !== value) return false;
182
+ return true;
183
+ }
184
+
185
+ private consume(type: TokenType): Token {
186
+ const tok = this.tokens[this.pos];
187
+ if (!tok) throw new Error(`Expected ${type} but reached end of chain DSL`);
188
+ if (tok.type !== type) throw new Error(`Expected ${type} but got ${tok.type}('${tok.value}')`);
189
+ this.pos++;
190
+ return tok;
191
+ }
192
+ }
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
14
+ import { parseChainDSL } from "./chain-parser.ts";
15
+ import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
14
16
 
15
17
  /**
16
18
  * Single step in a chain.
@@ -123,10 +125,25 @@ export class ChainRunner {
123
125
  * parseChain('"Research AI trends" -> "Analyze findings"')
124
126
  * parseChain("@step1 --model claude-opus-3 -> @step2")
125
127
  *
128
+ * Also supports DSL syntax from chain-parser for advanced constructs:
129
+ * parseChain("step1 -> parallel(step2, step3) -> step4")
130
+ * parseChain("step1:3 -> step2 --with-context -> step3")
131
+ *
126
132
  * @param chainString - The chain string to parse
127
133
  * @returns Parsed chain specification
128
134
  */
129
135
  parseChain(chainString: string): ChainSpec {
136
+ // Try DSL parser first for advanced syntax (parallel groups, loop counts, flags)
137
+ // Falls back to the simple split parser if DSL parsing fails
138
+ if (this.hasDSLConstructs(chainString)) {
139
+ try {
140
+ const dslSteps = parseChainDSL(chainString);
141
+ return this.dslToChainSpec(dslSteps, chainString);
142
+ } catch {
143
+ // DSL parse failed; fall through to simple parser
144
+ }
145
+ }
146
+
130
147
  const stepStrings = chainString.split("->").map(s => s.trim());
131
148
 
132
149
  const steps: ChainStep[] = stepStrings.map((step, index) => {
@@ -337,6 +354,47 @@ export class ChainRunner {
337
354
  return parsed;
338
355
  }
339
356
 
357
+ /**
358
+ * Detect if chainString uses DSL constructs that require chain-parser.
359
+ * DSL features: parallel(...), :loopCount, --with-context flag
360
+ */
361
+ private hasDSLConstructs(chainString: string): boolean {
362
+ return /\bparallel\s*\(/.test(chainString) ||
363
+ /\w+:\d+\b/.test(chainString) ||
364
+ /--with-context/.test(chainString);
365
+ }
366
+
367
+ /**
368
+ * Convert DSL AST steps (from chain-parser) to ChainSpec.
369
+ */
370
+ private dslToChainSpec(dslSteps: DSLChainStep[], chainString: string): ChainSpec {
371
+ const steps: ChainStep[] = dslSteps.map((dslStep, index) => {
372
+ // For parallel groups, use a synthetic step name
373
+ if (dslStep.parallel) {
374
+ return {
375
+ name: dslStep.name,
376
+ context: {
377
+ parallel: dslStep.parallel.map(p => ({ name: p.name, loopCount: p.loopCount, withContext: p.withContext, args: p.args })),
378
+ },
379
+ loopCount: dslStep.loopCount,
380
+ };
381
+ }
382
+ const step: ChainStep = { name: dslStep.name };
383
+ if (dslStep.loopCount) step.context = { ...step.context, loopCount: dslStep.loopCount };
384
+ if (dslStep.withContext) step.context = { ...step.context, withContext: true };
385
+ if (dslStep.args && dslStep.args.length > 0) step.context = { ...step.context, args: dslStep.args };
386
+ return step;
387
+ });
388
+
389
+ // Extract global overrides using existing logic
390
+ const globalModel = this.extractGlobalFlag(chainString, "global-model");
391
+ const globalSkill = this.extractGlobalFlag(chainString, "global-skill");
392
+ const globalThinking = this.extractGlobalFlag(chainString, "global-thinking") as "fast" | "standard" | "deep" | undefined;
393
+ const continueOnError = this.extractGlobalFlag(chainString, "continue-on-error") === "true";
394
+
395
+ return { steps, globalModel, globalSkill, globalThinking, continueOnError };
396
+ }
397
+
340
398
  /**
341
399
  * Sanitize identifier to prevent injection.
342
400
  */