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
|
@@ -3,6 +3,7 @@ import type { AgentConfig } from "../agents/agent-config.ts";
|
|
|
3
3
|
import type { CrewLimitsConfig, CrewRuntimeConfig, CrewReliabilityConfig } from "../config/config.ts";
|
|
4
4
|
import type { CrewRuntimeCapabilities } from "./runtime-resolver.ts";
|
|
5
5
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
6
|
+
import { executeHook, appendHookEvent } from "../hooks/registry.ts";
|
|
6
7
|
import { appendEvent } from "../state/event-log.ts";
|
|
7
8
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
8
9
|
import type { ArtifactDescriptor, PolicyDecision, TeamRunManifest, TaskAttemptState, TeamTaskState } from "../state/types.ts";
|
|
@@ -25,6 +26,8 @@ import { childCorrelation, withCorrelation } from "../observability/correlation.
|
|
|
25
26
|
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
26
27
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
27
28
|
import { permissionForRole } from "./role-permission.ts";
|
|
29
|
+
import { CrewCancellationError, buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "./cancellation.ts";
|
|
30
|
+
import { effectivenessPolicyDecision, evaluateRunEffectiveness, formatRunEffectivenessLines } from "./effectiveness.ts";
|
|
28
31
|
|
|
29
32
|
export interface ExecuteTeamRunInput {
|
|
30
33
|
manifest: TeamRunManifest;
|
|
@@ -43,6 +46,8 @@ export interface ExecuteTeamRunInput {
|
|
|
43
46
|
signal?: AbortSignal;
|
|
44
47
|
reliability?: CrewReliabilityConfig;
|
|
45
48
|
metricRegistry?: MetricRegistry;
|
|
49
|
+
/** Skill override from the team tool. false disables skill injection for this run. */
|
|
50
|
+
skillOverride?: string[] | false;
|
|
46
51
|
/** Optional callback for JSON events from child Pi. Used for overflow recovery tracking. */
|
|
47
52
|
onJsonEvent?: (taskId: string, runId: string, event: unknown) => void;
|
|
48
53
|
}
|
|
@@ -380,7 +385,11 @@ function formatTaskProgress(task: TeamTaskState): string {
|
|
|
380
385
|
return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
|
|
381
386
|
}
|
|
382
387
|
|
|
383
|
-
function
|
|
388
|
+
function runEffectivenessLines(manifest: TeamRunManifest, tasks: TeamTaskState[], executeWorkers: boolean, runtimeConfig?: CrewRuntimeConfig): string[] {
|
|
389
|
+
return formatRunEffectivenessLines(evaluateRunEffectiveness({ manifest, tasks, executeWorkers, runtimeConfig }));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], producer: string, executeWorkers = true, runtimeConfig?: CrewRuntimeConfig): TeamRunManifest {
|
|
384
393
|
const counts = new Map<string, number>();
|
|
385
394
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
386
395
|
const queue = taskGraphSnapshot(tasks);
|
|
@@ -401,6 +410,9 @@ function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], produc
|
|
|
401
410
|
"## Tasks",
|
|
402
411
|
...tasks.map(formatTaskProgress),
|
|
403
412
|
"",
|
|
413
|
+
"## Effectiveness",
|
|
414
|
+
...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
|
|
415
|
+
"",
|
|
404
416
|
].join("\n"),
|
|
405
417
|
});
|
|
406
418
|
return { ...manifest, updatedAt: new Date().toISOString(), artifacts: [...manifest.artifacts.filter((artifact) => !(artifact.kind === "progress" && artifact.path === progress.path)), progress] };
|
|
@@ -495,6 +507,39 @@ function hasPendingMutatingAdaptiveTask(tasks: TeamTaskState[]): boolean {
|
|
|
495
507
|
export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
496
508
|
let workflow = input.workflow;
|
|
497
509
|
let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
return await executeTeamRunCore(input, manifest, workflow);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
// P1: Catch unhandled errors — ensure manifest is set to "failed" so it doesn't stay "running" forever.
|
|
515
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
516
|
+
try {
|
|
517
|
+
manifest = updateRunStatus(manifest, "failed", `Unhandled error in team runner: ${message}`);
|
|
518
|
+
await saveRunManifestAsync(manifest);
|
|
519
|
+
} catch {
|
|
520
|
+
// Best-effort — state write may also fail
|
|
521
|
+
}
|
|
522
|
+
const tasks = refreshTaskGraphQueues(input.tasks).map((task) =>
|
|
523
|
+
task.status === "running" || task.status === "queued" || task.status === "waiting"
|
|
524
|
+
? { ...task, status: "failed" as const, finishedAt: new Date().toISOString(), error: message }
|
|
525
|
+
: task,
|
|
526
|
+
);
|
|
527
|
+
return { manifest, tasks };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function executeTeamRunCore(
|
|
532
|
+
input: ExecuteTeamRunInput,
|
|
533
|
+
manifest: TeamRunManifest,
|
|
534
|
+
workflow: WorkflowConfig,
|
|
535
|
+
): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
536
|
+
// Execute before_run_start hook (non-blocking by default)
|
|
537
|
+
const beforeRunReport = await executeHook("before_run_start", { runId: manifest.runId, cwd: manifest.cwd });
|
|
538
|
+
appendHookEvent(manifest, beforeRunReport);
|
|
539
|
+
if (beforeRunReport.outcome === "block") {
|
|
540
|
+
manifest = updateRunStatus(manifest, "blocked", beforeRunReport.reason ?? "before_run_start hook blocked the run.");
|
|
541
|
+
return { manifest, tasks: input.tasks };
|
|
542
|
+
}
|
|
498
543
|
let tasks = refreshTaskGraphQueues(input.tasks);
|
|
499
544
|
let queueIndex = buildTaskGraphIndex(tasks);
|
|
500
545
|
const canInjectAdaptivePlan = workflow.name === "implementation";
|
|
@@ -528,16 +573,28 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
528
573
|
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
529
574
|
return { manifest, tasks };
|
|
530
575
|
}
|
|
531
|
-
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
576
|
+
manifest = writeProgress(manifest, tasks, "team-runner", input.executeWorkers, input.runtimeConfig);
|
|
532
577
|
await saveRunManifestAsync(manifest);
|
|
533
578
|
const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
|
|
534
579
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
535
580
|
|
|
536
581
|
while (tasks.some((task) => task.status === "queued")) {
|
|
537
582
|
if (input.signal?.aborted) {
|
|
538
|
-
|
|
583
|
+
const cancelReason = cancellationReasonFromSignal(input.signal);
|
|
584
|
+
const message = `${cancelReason.message} (${cancelReason.code})`;
|
|
585
|
+
const cancelledTaskIds: string[] = [];
|
|
586
|
+
tasks = tasks.map((task) => {
|
|
587
|
+
if (task.status !== "queued" && task.status !== "running" && task.status !== "waiting") return task;
|
|
588
|
+
cancelledTaskIds.push(task.id);
|
|
589
|
+
const base = { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: message };
|
|
590
|
+
if (task.status === "running") {
|
|
591
|
+
return { ...base, terminalEvidence: [...(task.terminalEvidence ?? []), buildSyntheticTerminalEvidence("worker", cancelReason, task.startedAt)] };
|
|
592
|
+
}
|
|
593
|
+
return base;
|
|
594
|
+
});
|
|
539
595
|
await saveRunTasksAsync(manifest, tasks);
|
|
540
|
-
|
|
596
|
+
for (const taskId of cancelledTaskIds) appendEvent(manifest.eventsPath, { type: "task.cancelled", runId: manifest.runId, taskId, message, data: { reason: cancelReason.code } });
|
|
597
|
+
manifest = updateRunStatus(manifest, "cancelled", message, { data: { reason: cancelReason.code, cancelledTaskIds } });
|
|
541
598
|
return { manifest, tasks };
|
|
542
599
|
}
|
|
543
600
|
|
|
@@ -575,31 +632,44 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
575
632
|
}
|
|
576
633
|
|
|
577
634
|
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), readyCount: snapshot.ready.length, blockedCount: snapshot.blocked.length, runningCount: snapshot.running.length, doneCount: snapshot.done.length, selectedCount: readyBatch.length, maxConcurrent: concurrency.maxConcurrent, defaultConcurrency: concurrency.defaultConcurrency, concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason } });
|
|
635
|
+
// Execute before_task_start hooks for the batch
|
|
636
|
+
for (const task of readyBatch) {
|
|
637
|
+
const taskReport = await executeHook("before_task_start", { runId: manifest.runId, taskId: task.id, cwd: manifest.cwd });
|
|
638
|
+
appendHookEvent(manifest, taskReport);
|
|
639
|
+
if (taskReport.outcome === "block") {
|
|
640
|
+
tasks = tasks.map((t) => t.id === task.id ? { ...t, status: "skipped" as const, error: taskReport.reason ?? "before_task_start hook blocked execution." } : t);
|
|
641
|
+
manifest = updateRunStatus(manifest, manifest.status, `Task '${task.id}' blocked by hook.`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const batchTasks = readyBatch.filter((task) => tasks.find((t) => t.id === task.id && t.status !== "skipped"));
|
|
578
645
|
const results = await mapConcurrent(
|
|
579
|
-
|
|
646
|
+
batchTasks,
|
|
580
647
|
concurrency.selectedCount,
|
|
581
648
|
async (task) => {
|
|
582
649
|
const step = findStep(workflow, task);
|
|
583
650
|
const agent = findAgent(input.agents, task);
|
|
584
|
-
const
|
|
651
|
+
const teamRole = input.team.roles.find((role) => role.name === task.role);
|
|
652
|
+
const baseInput = { manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers, runtimeKind: input.runtime?.kind, runtimeConfig: input.runtimeConfig, parentContext: input.parentContext, parentModel: input.parentModel, modelRegistry: input.modelRegistry, modelOverride: input.modelOverride, teamRoleModel: teamRole?.model, teamRoleSkills: teamRole?.skills, skillOverride: input.skillOverride, limits: input.limits, onJsonEvent: input.onJsonEvent };
|
|
585
653
|
if (input.reliability?.autoRetry !== true) return withCorrelation(childCorrelation(manifest.runId, task.id), () => runTeamTask(baseInput));
|
|
586
654
|
let lastFailed: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
|
|
655
|
+
let lastAttemptId: string | undefined;
|
|
587
656
|
const attemptsSoFar: TaskAttemptState[] = [...(task.attempts ?? [])];
|
|
588
657
|
const policy = retryPolicyFromConfig(input.reliability);
|
|
589
658
|
try {
|
|
590
|
-
return await executeWithRetry(async (attempt) => {
|
|
659
|
+
return await executeWithRetry(async (attempt, info) => {
|
|
591
660
|
const startedAt = new Date().toISOString();
|
|
592
|
-
const inFlightAttempts: TaskAttemptState[] = [...attemptsSoFar, { startedAt }];
|
|
661
|
+
const inFlightAttempts: TaskAttemptState[] = [...attemptsSoFar, { attemptId: info.attemptId, startedAt }];
|
|
593
662
|
input.metricRegistry?.counter("crew.task.retry_attempt_total", "Retry attempts by run and task").inc({ runId: manifest.runId, taskId: task.id });
|
|
594
663
|
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
595
664
|
const freshManifest = fresh?.manifest ?? manifest;
|
|
596
665
|
const freshTasks = fresh?.tasks ?? tasks;
|
|
597
666
|
const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
|
|
667
|
+
if (freshTask.status !== "queued" && freshTask.status !== "running") return { manifest: freshManifest, tasks: freshTasks };
|
|
598
668
|
const taskWithAttempt: TeamTaskState = { ...freshTask, attempts: inFlightAttempts };
|
|
599
669
|
const result = await withCorrelation(childCorrelation(freshManifest.runId, task.id), () => runTeamTask({ ...baseInput, manifest: freshManifest, tasks: freshTasks, task: taskWithAttempt }));
|
|
600
670
|
const failed = failedTaskFrom(result, task.id);
|
|
601
671
|
const endedAt = new Date().toISOString();
|
|
602
|
-
const finishedAttempt: TaskAttemptState = { startedAt, endedAt, ...(failed?.error ? { error: failed.error } : {}) };
|
|
672
|
+
const finishedAttempt: TaskAttemptState = { attemptId: info.attemptId, startedAt, endedAt, ...(failed?.error ? { error: failed.error } : {}) };
|
|
603
673
|
attemptsSoFar.push(finishedAttempt);
|
|
604
674
|
const withAttempt = result.tasks.map((item) => item.id === task.id ? { ...item, attempts: [...attemptsSoFar] } : item);
|
|
605
675
|
const enriched = { manifest: result.manifest, tasks: withAttempt };
|
|
@@ -611,28 +681,54 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
611
681
|
return enriched;
|
|
612
682
|
}, policy, {
|
|
613
683
|
signal: input.signal,
|
|
614
|
-
|
|
615
|
-
|
|
684
|
+
attemptId: (attempt) => `${manifest.runId}:${task.id}:attempt-${attempt}`,
|
|
685
|
+
onAttemptFailed: (attempt, error, delayMs, info) => {
|
|
686
|
+
lastAttemptId = info.attemptId;
|
|
687
|
+
appendEvent(manifest.eventsPath, { type: "crew.task.retry_attempt", runId: manifest.runId, taskId: task.id, message: error.message, data: { attempt, attemptId: info.attemptId, delayMs }, metadata: { attemptId: info.attemptId } });
|
|
616
688
|
input.metricRegistry?.histogram("crew.task.retry_delay_ms", "Retry backoff delay, milliseconds").observe({ runId: manifest.runId, taskId: task.id }, delayMs);
|
|
617
689
|
},
|
|
618
|
-
onRetryGivenUp: (attempts, error) => {
|
|
619
|
-
|
|
690
|
+
onRetryGivenUp: (attempts, error, info) => {
|
|
691
|
+
lastAttemptId = info.attemptId;
|
|
692
|
+
appendDeadletter(manifest, { runId: manifest.runId, taskId: task.id, reason: "max-retries", attempts, attemptId: info.attemptId, lastError: error.message, timestamp: new Date().toISOString() });
|
|
620
693
|
input.metricRegistry?.counter("crew.task.deadletter_total", "Deadletter triggers by reason").inc({ reason: "max-retries" });
|
|
621
694
|
input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe({ runId: manifest.runId, team: input.team.name }, Math.max(0, attempts - 1));
|
|
622
695
|
},
|
|
623
696
|
});
|
|
624
|
-
} catch {
|
|
697
|
+
} catch (retryError) {
|
|
698
|
+
if (retryError instanceof CrewCancellationError || input.signal?.aborted) {
|
|
699
|
+
const reason = retryError instanceof CrewCancellationError ? retryError.reason : cancellationReasonFromSignal(input.signal);
|
|
700
|
+
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
701
|
+
const freshManifest = fresh?.manifest ?? manifest;
|
|
702
|
+
const freshTasks = fresh?.tasks ?? tasks;
|
|
703
|
+
const cancelledTasks = freshTasks.map((item) => item.id === task.id && (item.status === "queued" || item.status === "running") ? { ...item, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: `${reason.message} (${reason.code})` } : item);
|
|
704
|
+
appendEvent(freshManifest.eventsPath, { type: "task.cancelled", runId: freshManifest.runId, taskId: task.id, message: reason.message, data: { reason, phase: "retry" }, metadata: lastAttemptId ? { attemptId: lastAttemptId } : undefined });
|
|
705
|
+
return { manifest: updateRunStatus(freshManifest, "cancelled", reason.message), tasks: cancelledTasks };
|
|
706
|
+
}
|
|
625
707
|
if (lastFailed) return lastFailed;
|
|
626
708
|
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
627
709
|
const freshManifest = fresh?.manifest ?? manifest;
|
|
628
710
|
const freshTasks = fresh?.tasks ?? tasks;
|
|
629
711
|
const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
|
|
712
|
+
if (freshTask.status !== "queued" && freshTask.status !== "running") return { manifest: freshManifest, tasks: freshTasks };
|
|
630
713
|
return withCorrelation(childCorrelation(freshManifest.runId, task.id), () => runTeamTask({ ...baseInput, manifest: freshManifest, tasks: freshTasks, task: freshTask }));
|
|
631
714
|
}
|
|
632
715
|
},
|
|
633
716
|
);
|
|
717
|
+
if (results.length === 0) break;
|
|
634
718
|
manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
|
|
635
719
|
tasks = __test__mergeTaskUpdates(tasks, results);
|
|
720
|
+
const cancelledResult = results.find((item) => item.manifest.status === "cancelled");
|
|
721
|
+
if (cancelledResult || input.signal?.aborted) {
|
|
722
|
+
const reason = input.signal?.aborted ? cancellationReasonFromSignal(input.signal) : undefined;
|
|
723
|
+
const message = reason?.message ?? cancelledResult?.manifest.summary ?? "Run cancelled during task execution.";
|
|
724
|
+
manifest = { ...manifest, status: "running" };
|
|
725
|
+
manifest = updateRunStatus(manifest, "cancelled", message);
|
|
726
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
727
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
728
|
+
await saveRunManifestAsync(manifest);
|
|
729
|
+
appendEvent(manifest.eventsPath, { type: "run.cancelled", runId: manifest.runId, message, data: { reason, phase: "task-batch", cancelledResultRunId: cancelledResult?.manifest.runId } });
|
|
730
|
+
return { manifest, tasks };
|
|
731
|
+
}
|
|
636
732
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
637
733
|
const injectedAfterBatch = attemptAdaptivePlan();
|
|
638
734
|
if (injectedAfterBatch.missing) {
|
|
@@ -657,30 +753,46 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
657
753
|
}
|
|
658
754
|
await saveRunTasksAsync(manifest, tasks);
|
|
659
755
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
660
|
-
const completedBatch =
|
|
756
|
+
const completedBatch = batchTasks.map((task) => tasks.find((item) => item.id === task.id) ?? task);
|
|
661
757
|
const batchArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
662
758
|
kind: "summary",
|
|
663
|
-
relativePath: `batches/${
|
|
759
|
+
relativePath: `batches/${batchTasks.map((task) => task.id).join("+")}.md`,
|
|
664
760
|
producer: "team-runner",
|
|
665
761
|
content: aggregateTaskOutputs(completedBatch, manifest),
|
|
666
762
|
});
|
|
667
|
-
const groupDelivery = deliverGroupJoin({ manifest, mode: resolveGroupJoinMode(input.runtimeConfig), batch:
|
|
763
|
+
const groupDelivery = deliverGroupJoin({ manifest, mode: resolveGroupJoinMode(input.runtimeConfig), batch: batchTasks, allTasks: tasks });
|
|
668
764
|
manifest = { ...manifest, artifacts: mergeArtifacts([...manifest.artifacts, batchArtifact, ...(groupDelivery?.artifact ? [groupDelivery.artifact] : [])]) };
|
|
669
|
-
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
765
|
+
manifest = writeProgress(manifest, tasks, "team-runner", input.executeWorkers, input.runtimeConfig);
|
|
670
766
|
await saveRunManifestAsync(manifest);
|
|
671
767
|
}
|
|
672
768
|
|
|
673
769
|
const failed = tasks.find((task) => task.status === "failed");
|
|
770
|
+
const waiting = tasks.find((task) => task.status === "waiting");
|
|
771
|
+
const running = tasks.find((task) => task.status === "running");
|
|
674
772
|
manifest = applyPolicy(manifest, tasks, input.limits);
|
|
773
|
+
const effectiveness = evaluateRunEffectiveness({ manifest, tasks, executeWorkers: input.executeWorkers, runtimeConfig: input.runtimeConfig });
|
|
774
|
+
const effectivenessDecision = effectivenessPolicyDecision(effectiveness);
|
|
775
|
+
if (effectivenessDecision) {
|
|
776
|
+
manifest = { ...manifest, policyDecisions: [...(manifest.policyDecisions ?? []), effectivenessDecision], updatedAt: new Date().toISOString() };
|
|
777
|
+
appendEvent(manifest.eventsPath, { type: "run.effectiveness", runId: manifest.runId, message: effectivenessDecision.message, data: { effectiveness, policyDecision: effectivenessDecision } });
|
|
778
|
+
}
|
|
675
779
|
const blockingDecision = manifest.policyDecisions?.find((item) => item.action === "block" || item.action === "escalate");
|
|
676
780
|
if (failed) {
|
|
677
781
|
manifest = updateRunStatus(manifest, "failed", `Failed at task '${failed.id}'.`);
|
|
782
|
+
} else if (waiting) {
|
|
783
|
+
manifest = updateRunStatus(manifest, "blocked", `Waiting for response to task '${waiting.id}'.`);
|
|
784
|
+
} else if (running) {
|
|
785
|
+
manifest = updateRunStatus(manifest, "blocked", `Task '${running.id}' is still running.`);
|
|
786
|
+
} else if (effectiveness.severity === "failed") {
|
|
787
|
+
manifest = updateRunStatus(manifest, "failed", effectivenessDecision?.message ?? "Run effectiveness guard failed.");
|
|
788
|
+
} else if (effectiveness.severity === "blocked") {
|
|
789
|
+
manifest = updateRunStatus(manifest, "blocked", effectivenessDecision?.message ?? "Run effectiveness guard blocked completion.");
|
|
678
790
|
} else if (blockingDecision) {
|
|
679
791
|
manifest = updateRunStatus(manifest, "blocked", blockingDecision.message);
|
|
680
792
|
} else {
|
|
681
793
|
manifest = updateRunStatus(manifest, "completed", input.executeWorkers ? "Team workflow completed." : "Team workflow scaffold completed without launching child workers.");
|
|
682
794
|
}
|
|
683
|
-
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
795
|
+
manifest = writeProgress(manifest, tasks, "team-runner", input.executeWorkers, input.runtimeConfig);
|
|
684
796
|
await saveRunManifestAsync(manifest);
|
|
685
797
|
const usage = aggregateUsage(tasks);
|
|
686
798
|
const summaryArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
@@ -699,6 +811,9 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
699
811
|
"## Tasks",
|
|
700
812
|
...tasks.map(formatTaskProgress),
|
|
701
813
|
"",
|
|
814
|
+
"## Effectiveness",
|
|
815
|
+
...runEffectivenessLines(manifest, tasks, input.executeWorkers, input.runtimeConfig),
|
|
816
|
+
"",
|
|
702
817
|
"## Policy decisions",
|
|
703
818
|
...(manifest.policyDecisions?.length ? summarizePolicyDecisions(manifest.policyDecisions) : ["- (none)"]),
|
|
704
819
|
"",
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
// ── Public types ───────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface WorkspaceTree {
|
|
7
|
+
rootPath: string;
|
|
8
|
+
rendered: string;
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
totalLines: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WorkspaceTreeOptions {
|
|
14
|
+
/** Directory depth below root. Root is depth 0. Default: 3 */
|
|
15
|
+
maxDepth?: number;
|
|
16
|
+
/** Max entries per directory. Default: 12 */
|
|
17
|
+
dirLimit?: number;
|
|
18
|
+
/** Hard line limit for the rendered output. Default: 120 */
|
|
19
|
+
lineCap?: number;
|
|
20
|
+
/** Directory names to skip entirely. Default: node_modules, .git, .next, dist, build, target, .venv, .cache, .turbo */
|
|
21
|
+
excludedDirs?: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Defaults ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
27
|
+
const DEFAULT_DIR_LIMIT = 12;
|
|
28
|
+
const DEFAULT_LINE_CAP = 120;
|
|
29
|
+
const DEFAULT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([
|
|
30
|
+
"node_modules",
|
|
31
|
+
".git",
|
|
32
|
+
".next",
|
|
33
|
+
"dist",
|
|
34
|
+
"build",
|
|
35
|
+
"target",
|
|
36
|
+
".venv",
|
|
37
|
+
".cache",
|
|
38
|
+
".turbo",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// ── Internal types ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
interface TreeNode {
|
|
44
|
+
name: string;
|
|
45
|
+
relativePath: string;
|
|
46
|
+
depth: number;
|
|
47
|
+
isDirectory: boolean;
|
|
48
|
+
mtimeMs: number;
|
|
49
|
+
size: number;
|
|
50
|
+
children: TreeNode[];
|
|
51
|
+
droppedChildCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RenderLine {
|
|
55
|
+
text: string;
|
|
56
|
+
depth: number;
|
|
57
|
+
isRoot: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export function formatBytes(bytes: number): string {
|
|
63
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
64
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
65
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
66
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatAge(seconds: number): string {
|
|
70
|
+
if (seconds < 60) return "just now";
|
|
71
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
72
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
73
|
+
if (seconds < 86400 * 14) return `${Math.floor(seconds / 86400)}d`;
|
|
74
|
+
return `${Math.floor(seconds / (86400 * 7))}w`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Tree building ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function compareByRecency(a: TreeNode, b: TreeNode): number {
|
|
80
|
+
const diff = b.mtimeMs - a.mtimeMs;
|
|
81
|
+
if (diff !== 0) return diff;
|
|
82
|
+
return a.name.localeCompare(b.name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function applyDirLimit(
|
|
86
|
+
children: TreeNode[],
|
|
87
|
+
limit: number,
|
|
88
|
+
): { visible: TreeNode[]; dropped: number } {
|
|
89
|
+
if (children.length <= limit) {
|
|
90
|
+
return { visible: children, dropped: 0 };
|
|
91
|
+
}
|
|
92
|
+
if (limit <= 1) {
|
|
93
|
+
return { visible: children.slice(0, limit), dropped: children.length - limit };
|
|
94
|
+
}
|
|
95
|
+
// Keep first (limit-1) by recency + always keep the oldest (last after sort)
|
|
96
|
+
const recent = children.slice(0, limit - 1);
|
|
97
|
+
const oldest = children[children.length - 1];
|
|
98
|
+
const visible = oldest ? [...recent, oldest] : recent;
|
|
99
|
+
return { visible, dropped: children.length - limit };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readChildren(
|
|
103
|
+
rootPath: string,
|
|
104
|
+
parent: TreeNode,
|
|
105
|
+
excludedDirs: ReadonlySet<string>,
|
|
106
|
+
): Promise<TreeNode[]> {
|
|
107
|
+
const dirPath = parent.relativePath
|
|
108
|
+
? path.join(rootPath, parent.relativePath)
|
|
109
|
+
: rootPath;
|
|
110
|
+
|
|
111
|
+
let names: string[];
|
|
112
|
+
try {
|
|
113
|
+
names = await fs.readdir(dirPath);
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nodes = await Promise.all(
|
|
119
|
+
names.map(async (name): Promise<TreeNode | null> => {
|
|
120
|
+
// Skip hidden entries
|
|
121
|
+
if (name.startsWith(".")) return null;
|
|
122
|
+
const relativePath = parent.relativePath
|
|
123
|
+
? `${parent.relativePath}/${name}`
|
|
124
|
+
: name;
|
|
125
|
+
const absolutePath = path.join(rootPath, relativePath);
|
|
126
|
+
try {
|
|
127
|
+
const stat = await fs.stat(absolutePath);
|
|
128
|
+
if (stat.isDirectory() && excludedDirs.has(name)) return null;
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
relativePath,
|
|
132
|
+
depth: parent.depth + 1,
|
|
133
|
+
isDirectory: stat.isDirectory(),
|
|
134
|
+
mtimeMs: stat.mtimeMs,
|
|
135
|
+
size: stat.size,
|
|
136
|
+
children: [],
|
|
137
|
+
droppedChildCount: 0,
|
|
138
|
+
};
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return nodes.filter((n): n is TreeNode => n !== null).sort(compareByRecency);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function collectTree(
|
|
149
|
+
rootPath: string,
|
|
150
|
+
maxDepth: number,
|
|
151
|
+
dirLimit: number,
|
|
152
|
+
excludedDirs: ReadonlySet<string>,
|
|
153
|
+
): Promise<{ root: TreeNode; truncated: boolean }> {
|
|
154
|
+
const rootStat = await fs.stat(rootPath);
|
|
155
|
+
const root: TreeNode = {
|
|
156
|
+
name: ".",
|
|
157
|
+
relativePath: "",
|
|
158
|
+
depth: 0,
|
|
159
|
+
isDirectory: true,
|
|
160
|
+
mtimeMs: rootStat.mtimeMs,
|
|
161
|
+
size: rootStat.size,
|
|
162
|
+
children: [],
|
|
163
|
+
droppedChildCount: 0,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
let truncated = false;
|
|
167
|
+
const queue: TreeNode[] = [root];
|
|
168
|
+
let cursor = 0;
|
|
169
|
+
|
|
170
|
+
while (cursor < queue.length) {
|
|
171
|
+
const parent = queue[cursor];
|
|
172
|
+
cursor += 1;
|
|
173
|
+
if (!parent || parent.depth >= maxDepth) continue;
|
|
174
|
+
|
|
175
|
+
const children = await readChildren(rootPath, parent, excludedDirs);
|
|
176
|
+
const { visible, dropped } = applyDirLimit(children, dirLimit);
|
|
177
|
+
parent.children = visible;
|
|
178
|
+
parent.droppedChildCount = dropped;
|
|
179
|
+
if (dropped > 0) truncated = true;
|
|
180
|
+
|
|
181
|
+
for (const child of visible) {
|
|
182
|
+
if (child.isDirectory) queue.push(child);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { root, truncated };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Rendering ──────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function collectLines(node: TreeNode, nowMs: number, lines: RenderLine[]): void {
|
|
192
|
+
if (node.depth === 0) {
|
|
193
|
+
lines.push({ text: ".", depth: 0, isRoot: true });
|
|
194
|
+
} else {
|
|
195
|
+
const indent = " ".repeat(node.depth);
|
|
196
|
+
const suffix = node.isDirectory ? "/" : "";
|
|
197
|
+
const label = `${indent}- ${node.name}${suffix}`;
|
|
198
|
+
if (node.isDirectory) {
|
|
199
|
+
lines.push({ text: label, depth: node.depth, isRoot: false });
|
|
200
|
+
} else {
|
|
201
|
+
const ageSeconds = Math.max(0, Math.floor((nowMs - node.mtimeMs) / 1000));
|
|
202
|
+
const size = formatBytes(node.size);
|
|
203
|
+
const age = formatAge(ageSeconds);
|
|
204
|
+
lines.push({ text: `${label} ${size} ${age}`, depth: node.depth, isRoot: false });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (node.droppedChildCount > 0) {
|
|
209
|
+
// When we kept recent + oldest, render recent children, then truncation line, then oldest
|
|
210
|
+
const recentChildren = node.children.slice(0, -1);
|
|
211
|
+
const oldestChild = node.children[node.children.length - 1];
|
|
212
|
+
for (const child of recentChildren) collectLines(child, nowMs, lines);
|
|
213
|
+
|
|
214
|
+
const childDepth = node.depth + 1;
|
|
215
|
+
const indent = " ".repeat(childDepth);
|
|
216
|
+
lines.push({
|
|
217
|
+
text: `${indent}- … ${node.droppedChildCount} more`,
|
|
218
|
+
depth: childDepth,
|
|
219
|
+
isRoot: false,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (oldestChild) collectLines(oldestChild, nowMs, lines);
|
|
223
|
+
} else {
|
|
224
|
+
for (const child of node.children) collectLines(child, nowMs, lines);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function applyLineCap(
|
|
229
|
+
lines: RenderLine[],
|
|
230
|
+
cap: number,
|
|
231
|
+
): { lines: RenderLine[]; elided: number } {
|
|
232
|
+
if (lines.length <= cap) return { lines, elided: 0 };
|
|
233
|
+
|
|
234
|
+
const target = Math.max(1, cap - 1);
|
|
235
|
+
const removeCount = lines.length - target;
|
|
236
|
+
// Remove deepest non-root entries first
|
|
237
|
+
const removable = lines
|
|
238
|
+
.map((line, index) => ({ line, index }))
|
|
239
|
+
.filter((item) => !item.line.isRoot)
|
|
240
|
+
.sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
|
|
241
|
+
.slice(0, removeCount);
|
|
242
|
+
|
|
243
|
+
if (removable.length === 0) return { lines, elided: 0 };
|
|
244
|
+
|
|
245
|
+
const removedIndexes = new Set(removable.map((item) => item.index));
|
|
246
|
+
const kept = lines.filter((_, index) => !removedIndexes.has(index));
|
|
247
|
+
kept.push({
|
|
248
|
+
text: `… (${removable.length} lines elided)`,
|
|
249
|
+
depth: 0,
|
|
250
|
+
isRoot: false,
|
|
251
|
+
});
|
|
252
|
+
return { lines: kept, elided: removable.length };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
const emptyResult = (rootPath: string): WorkspaceTree => ({
|
|
258
|
+
rootPath,
|
|
259
|
+
rendered: "",
|
|
260
|
+
truncated: false,
|
|
261
|
+
totalLines: 0,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
export async function buildWorkspaceTree(
|
|
265
|
+
cwd: string,
|
|
266
|
+
options?: WorkspaceTreeOptions,
|
|
267
|
+
): Promise<WorkspaceTree> {
|
|
268
|
+
const rootPath = path.resolve(cwd);
|
|
269
|
+
try {
|
|
270
|
+
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
271
|
+
const dirLimit = options?.dirLimit ?? DEFAULT_DIR_LIMIT;
|
|
272
|
+
const lineCap = options?.lineCap ?? DEFAULT_LINE_CAP;
|
|
273
|
+
const excludedDirs = options?.excludedDirs ?? DEFAULT_EXCLUDED_DIRS;
|
|
274
|
+
|
|
275
|
+
const { root, truncated: dirTruncated } = await collectTree(
|
|
276
|
+
rootPath,
|
|
277
|
+
maxDepth,
|
|
278
|
+
dirLimit,
|
|
279
|
+
excludedDirs,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const nowMs = Date.now();
|
|
283
|
+
const lines: RenderLine[] = [];
|
|
284
|
+
collectLines(root, nowMs, lines);
|
|
285
|
+
|
|
286
|
+
const { lines: capped, elided } = applyLineCap(lines, lineCap);
|
|
287
|
+
const rendered = capped.map((l) => l.text).join("\n");
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
rootPath,
|
|
291
|
+
rendered,
|
|
292
|
+
truncated: dirTruncated || elided > 0,
|
|
293
|
+
totalLines: capped.length,
|
|
294
|
+
};
|
|
295
|
+
} catch {
|
|
296
|
+
return emptyResult(rootPath);
|
|
297
|
+
}
|
|
298
|
+
}
|