pi-crew 0.1.5 → 0.1.7

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 (35) hide show
  1. package/package.json +2 -1
  2. package/schema.json +24 -0
  3. package/src/agents/agent-config.ts +2 -0
  4. package/src/agents/discover-agents.ts +29 -5
  5. package/src/config/config.ts +148 -9
  6. package/src/extension/register.ts +10 -2
  7. package/src/extension/team-tool.ts +113 -10
  8. package/src/prompt/prompt-runtime.ts +12 -2
  9. package/src/runtime/agent-control.ts +64 -0
  10. package/src/runtime/agent-observability.ts +88 -0
  11. package/src/runtime/async-runner.ts +30 -1
  12. package/src/runtime/background-runner.ts +4 -2
  13. package/src/runtime/child-pi.ts +137 -7
  14. package/src/runtime/crew-agent-records.ts +137 -0
  15. package/src/runtime/crew-agent-runtime.ts +54 -0
  16. package/src/runtime/foreground-control.ts +82 -0
  17. package/src/runtime/group-join.ts +88 -0
  18. package/src/runtime/live-session-runtime.ts +33 -0
  19. package/src/runtime/pi-args.ts +29 -0
  20. package/src/runtime/policy-engine.ts +23 -0
  21. package/src/runtime/recovery-recipes.ts +74 -0
  22. package/src/runtime/role-permission.ts +28 -0
  23. package/src/runtime/runtime-resolver.ts +75 -0
  24. package/src/runtime/session-usage.ts +79 -0
  25. package/src/runtime/task-graph-scheduler.ts +107 -0
  26. package/src/runtime/task-output-context.ts +106 -0
  27. package/src/runtime/task-runner.ts +220 -4
  28. package/src/runtime/team-runner.ts +86 -14
  29. package/src/runtime/worker-startup.ts +57 -0
  30. package/src/state/contracts.ts +7 -0
  31. package/src/state/event-log.ts +103 -2
  32. package/src/state/state-store.ts +23 -2
  33. package/src/state/types.ts +3 -0
  34. package/src/ui/run-dashboard.ts +82 -10
  35. package/src/worktree/branch-freshness.ts +45 -0
@@ -1,13 +1,20 @@
1
1
  import type { AgentConfig } from "../agents/agent-config.ts";
2
- import type { CrewLimitsConfig } from "../config/config.ts";
2
+ import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
3
+ import type { CrewRuntimeCapabilities } from "./runtime-resolver.ts";
3
4
  import { writeArtifact } from "../state/artifact-store.ts";
4
5
  import { appendEvent } from "../state/event-log.ts";
5
6
  import type { TeamConfig } from "../teams/team-config.ts";
6
- import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
7
+ import type { ArtifactDescriptor, PolicyDecision, TeamRunManifest, TeamTaskState } from "../state/types.ts";
7
8
  import { saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
8
9
  import { aggregateUsage, formatUsage } from "../state/usage.ts";
9
10
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
10
11
  import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
12
+ import { buildRecoveryLedger } from "./recovery-recipes.ts";
13
+ import { getReadyTasks, refreshTaskGraphQueues, taskGraphSnapshot } from "./task-graph-scheduler.ts";
14
+ import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
15
+ import { aggregateTaskOutputs } from "./task-output-context.ts";
16
+ import { recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
17
+ import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
11
18
  import { runTeamTask } from "./task-runner.ts";
12
19
 
13
20
  export interface ExecuteTeamRunInput {
@@ -18,12 +25,13 @@ export interface ExecuteTeamRunInput {
18
25
  agents: AgentConfig[];
19
26
  executeWorkers: boolean;
20
27
  limits?: CrewLimitsConfig;
28
+ runtime?: CrewRuntimeCapabilities;
29
+ runtimeConfig?: CrewRuntimeConfig;
21
30
  signal?: AbortSignal;
22
31
  }
23
32
 
24
33
  function findReadyTask(tasks: TeamTaskState[]): TeamTaskState | undefined {
25
- const completedStepIds = new Set(tasks.filter((task) => task.status === "completed").map((task) => task.stepId).filter((id): id is string => id !== undefined));
26
- return tasks.find((task) => task.status === "queued" && task.dependsOn.every((dep) => completedStepIds.has(dep)));
34
+ return getReadyTasks(tasks, 1)[0];
27
35
  }
28
36
 
29
37
  function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
@@ -42,6 +50,26 @@ function markBlocked(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
42
50
  return tasks.map((task) => task.status === "queued" ? { ...task, status: "skipped", error: reason, finishedAt: new Date().toISOString(), graph: task.graph ? { ...task.graph, queue: "blocked" } : undefined } : task);
43
51
  }
44
52
 
53
+ function mergeArtifacts(items: ArtifactDescriptor[]): ArtifactDescriptor[] {
54
+ const byPath = new Map<string, ArtifactDescriptor>();
55
+ for (const item of items) byPath.set(item.path, item);
56
+ return [...byPath.values()];
57
+ }
58
+
59
+ function mergeTaskUpdates(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
60
+ let merged = base;
61
+ for (const result of results) {
62
+ for (const updated of result.tasks) {
63
+ const current = merged.find((task) => task.id === updated.id);
64
+ if (!current) continue;
65
+ if (updated.status !== current.status || updated.finishedAt || updated.startedAt || updated.resultArtifact || updated.error) {
66
+ merged = merged.map((task) => task.id === updated.id ? updated : task);
67
+ }
68
+ }
69
+ }
70
+ return refreshTaskGraphQueues(merged);
71
+ }
72
+
45
73
  function formatTaskProgress(task: TeamTaskState): string {
46
74
  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}` : ""}`;
47
75
  }
@@ -49,6 +77,7 @@ function formatTaskProgress(task: TeamTaskState): string {
49
77
  function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], producer: string): TeamRunManifest {
50
78
  const counts = new Map<string, number>();
51
79
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
80
+ const queue = taskGraphSnapshot(tasks);
52
81
  const progress = writeArtifact(manifest.artifactsRoot, {
53
82
  kind: "progress",
54
83
  relativePath: "progress.md",
@@ -61,6 +90,7 @@ function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], produc
61
90
  `Workflow: ${manifest.workflow ?? "(none)"}`,
62
91
  `Updated: ${new Date().toISOString()}`,
63
92
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
93
+ `Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
64
94
  "",
65
95
  "## Tasks",
66
96
  ...tasks.map(formatTaskProgress),
@@ -71,22 +101,49 @@ function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], produc
71
101
  }
72
102
 
73
103
  function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?: CrewLimitsConfig): TeamRunManifest {
74
- const decisions = evaluateCrewPolicy({ manifest, tasks, limits });
104
+ const branchFreshness = checkBranchFreshness(manifest.cwd);
105
+ const branchArtifact = writeArtifact(manifest.artifactsRoot, {
106
+ kind: "metadata",
107
+ relativePath: "metadata/branch-freshness.json",
108
+ producer: "branch-freshness",
109
+ content: `${JSON.stringify(branchFreshness, null, 2)}\n`,
110
+ });
111
+ let decisions: PolicyDecision[] = evaluateCrewPolicy({ manifest, tasks, limits });
112
+ if (branchFreshness.status === "stale" || branchFreshness.status === "diverged") {
113
+ const branchDecision: PolicyDecision = {
114
+ action: "notify",
115
+ reason: "branch_stale",
116
+ message: branchFreshness.message,
117
+ createdAt: new Date().toISOString(),
118
+ };
119
+ decisions = [...decisions, branchDecision];
120
+ appendEvent(manifest.eventsPath, { type: "branch.stale", runId: manifest.runId, message: branchFreshness.message, data: { branchFreshness } });
121
+ }
75
122
  const policyArtifact = writeArtifact(manifest.artifactsRoot, {
76
123
  kind: "metadata",
77
124
  relativePath: "policy-decisions.json",
78
125
  producer: "policy-engine",
79
126
  content: `${JSON.stringify(decisions, null, 2)}\n`,
80
127
  });
128
+ const recoveryLedger = buildRecoveryLedger(decisions);
129
+ const recoveryArtifact = writeArtifact(manifest.artifactsRoot, {
130
+ kind: "metadata",
131
+ relativePath: "recovery-ledger.json",
132
+ producer: "recovery-engine",
133
+ content: `${JSON.stringify(recoveryLedger, null, 2)}\n`,
134
+ });
81
135
  for (const item of decisions) appendEvent(manifest.eventsPath, { type: item.action === "escalate" ? "policy.escalated" : "policy.action", runId: manifest.runId, taskId: item.taskId, message: item.message, data: { action: item.action, reason: item.reason } });
82
- return { ...manifest, updatedAt: new Date().toISOString(), policyDecisions: decisions, artifacts: [...manifest.artifacts.filter((artifact) => !(artifact.kind === "metadata" && artifact.path.endsWith("policy-decisions.json"))), policyArtifact] };
136
+ for (const item of recoveryLedger.entries) appendEvent(manifest.eventsPath, { type: item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted", runId: manifest.runId, taskId: item.taskId, message: item.message, data: { scenario: item.scenario, steps: item.steps, attempt: item.attempt, state: item.state } });
137
+ return { ...manifest, updatedAt: new Date().toISOString(), policyDecisions: decisions, artifacts: [...manifest.artifacts.filter((artifact) => !(artifact.kind === "metadata" && (artifact.path.endsWith("policy-decisions.json") || artifact.path.endsWith("recovery-ledger.json") || artifact.path.endsWith("branch-freshness.json")))), branchArtifact, policyArtifact, recoveryArtifact] };
83
138
  }
84
139
 
85
140
  export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
86
141
  let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
87
- let tasks = input.tasks;
142
+ let tasks = refreshTaskGraphQueues(input.tasks);
88
143
  manifest = writeProgress(manifest, tasks, "team-runner");
89
144
  saveRunManifest(manifest);
145
+ const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
146
+ saveCrewAgents(manifest, tasks.map((task) => recordFromTask(manifest, task, runtimeKind)));
90
147
 
91
148
  while (tasks.some((task) => task.status === "queued")) {
92
149
  if (input.signal?.aborted) {
@@ -104,19 +161,34 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
104
161
  return { manifest, tasks };
105
162
  }
106
163
 
107
- const task = findReadyTask(tasks);
108
- if (!task) {
164
+ const maxConcurrent = Math.max(1, input.limits?.maxConcurrentWorkers ?? 1);
165
+ const readyBatch = getReadyTasks(tasks, maxConcurrent);
166
+ if (readyBatch.length === 0) {
109
167
  tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
110
168
  saveRunTasks(manifest, tasks);
111
169
  manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
112
170
  return { manifest, tasks };
113
171
  }
114
172
 
115
- const step = findStep(input.workflow, task);
116
- const agent = findAgent(input.agents, task);
117
- const result = await runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers });
118
- manifest = result.manifest;
119
- tasks = result.tasks;
173
+ 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), maxConcurrent } });
174
+ const results = await Promise.all(readyBatch.map((task) => {
175
+ const step = findStep(input.workflow, task);
176
+ const agent = findAgent(input.agents, task);
177
+ return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers, limits: input.limits });
178
+ }));
179
+ manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
180
+ tasks = mergeTaskUpdates(tasks, results);
181
+ saveRunTasks(manifest, tasks);
182
+ saveCrewAgents(manifest, tasks.map((task) => recordFromTask(manifest, task, runtimeKind)));
183
+ const completedBatch = readyBatch.map((task) => tasks.find((item) => item.id === task.id) ?? task);
184
+ const batchArtifact = writeArtifact(manifest.artifactsRoot, {
185
+ kind: "summary",
186
+ relativePath: `batches/${readyBatch.map((task) => task.id).join("+")}.md`,
187
+ producer: "team-runner",
188
+ content: aggregateTaskOutputs(completedBatch),
189
+ });
190
+ const groupDelivery = deliverGroupJoin({ manifest, mode: resolveGroupJoinMode(input.runtimeConfig), batch: readyBatch, allTasks: tasks });
191
+ manifest = { ...manifest, artifacts: mergeArtifacts([...manifest.artifacts, batchArtifact, ...(groupDelivery?.artifact ? [groupDelivery.artifact] : [])]) };
120
192
  manifest = writeProgress(manifest, tasks, "team-runner");
121
193
  saveRunManifest(manifest);
122
194
  }
@@ -0,0 +1,57 @@
1
+ export type WorkerLifecycleState = "spawning" | "trust_required" | "ready_for_prompt" | "running" | "finished" | "failed";
2
+ export type StartupFailureClassification = "trust_required" | "prompt_misdelivery" | "prompt_acceptance_timeout" | "transport_dead" | "worker_crashed" | "unknown";
3
+
4
+ export interface WorkerStartupEvidence {
5
+ lastLifecycleState: WorkerLifecycleState;
6
+ command: string;
7
+ promptSentAt?: string;
8
+ promptAccepted: boolean;
9
+ trustPromptDetected: boolean;
10
+ transportHealthy: boolean;
11
+ childProcessAlive: boolean;
12
+ elapsedMs: number;
13
+ classification: StartupFailureClassification;
14
+ stderrPreview?: string;
15
+ }
16
+
17
+ export function detectTrustPrompt(text: string): boolean {
18
+ const lowered = text.toLowerCase();
19
+ return lowered.includes("do you trust") || lowered.includes("trust this") || lowered.includes("untrusted") || lowered.includes("workspace trust") || lowered.includes("allow this folder");
20
+ }
21
+
22
+ export function classifyStartupFailure(evidence: Omit<WorkerStartupEvidence, "classification">): StartupFailureClassification {
23
+ if (!evidence.transportHealthy) return "transport_dead";
24
+ if (evidence.trustPromptDetected || evidence.lastLifecycleState === "trust_required") return "trust_required";
25
+ if (evidence.promptSentAt && !evidence.promptAccepted && evidence.childProcessAlive) return "prompt_acceptance_timeout";
26
+ if (evidence.promptSentAt && !evidence.promptAccepted && !evidence.childProcessAlive) return "worker_crashed";
27
+ if (evidence.stderrPreview?.toLowerCase().includes("command not found") || evidence.stderrPreview?.toLowerCase().includes("not recognized")) return "prompt_misdelivery";
28
+ if (!evidence.childProcessAlive && evidence.lastLifecycleState !== "finished") return "worker_crashed";
29
+ return "unknown";
30
+ }
31
+
32
+ export function createStartupEvidence(input: {
33
+ command: string;
34
+ startedAt: Date;
35
+ finishedAt?: Date;
36
+ promptSentAt?: Date;
37
+ promptAccepted?: boolean;
38
+ stderr?: string;
39
+ error?: string;
40
+ exitCode?: number | null;
41
+ }): WorkerStartupEvidence {
42
+ const stderrPreview = (input.error || input.stderr || "").slice(0, 500) || undefined;
43
+ const trustPromptDetected = detectTrustPrompt(stderrPreview ?? "");
44
+ const childProcessAlive = input.exitCode === undefined || input.exitCode === null ? !input.finishedAt : false;
45
+ const base: Omit<WorkerStartupEvidence, "classification"> = {
46
+ lastLifecycleState: input.error || (input.exitCode !== undefined && input.exitCode !== null && input.exitCode !== 0) ? "failed" : input.finishedAt ? "finished" : "running",
47
+ command: input.command,
48
+ promptSentAt: input.promptSentAt?.toISOString(),
49
+ promptAccepted: input.promptAccepted ?? !input.error,
50
+ trustPromptDetected,
51
+ transportHealthy: !input.error || !/enoent|spawn|transport/i.test(input.error),
52
+ childProcessAlive,
53
+ elapsedMs: Math.max(0, (input.finishedAt ?? new Date()).getTime() - input.startedAt.getTime()),
54
+ stderrPreview,
55
+ };
56
+ return { ...base, classification: classifyStartupFailure(base) };
57
+ }
@@ -42,10 +42,15 @@ export const TEAM_EVENT_TYPES = [
42
42
  "task.red",
43
43
  "task.completed",
44
44
  "task.failed",
45
+ "task.cancelled",
46
+ "task.skipped",
45
47
  "review.approved",
46
48
  "review.rejected",
47
49
  "policy.action",
48
50
  "policy.escalated",
51
+ "recovery.attempted",
52
+ "recovery.escalated",
53
+ "branch.stale",
49
54
  "mailbox.timeout",
50
55
  "worktree.cleanup",
51
56
  "worktree.dirty",
@@ -64,6 +69,8 @@ export const TEAM_WAKEABLE_EVENT_TYPES: ReadonlySet<TeamEventType> = new Set([
64
69
  "run.cancelled",
65
70
  "task.completed",
66
71
  "task.failed",
72
+ "task.cancelled",
73
+ "task.skipped",
67
74
  "async.completed",
68
75
  "async.failed",
69
76
  "async.stale",
@@ -1,6 +1,33 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
 
5
+ export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
6
+ export type TeamWatcherAction = "act" | "observe" | "ignore";
7
+
8
+ export interface TeamEventSessionIdentity {
9
+ title: string;
10
+ workspace: string;
11
+ purpose: string;
12
+ placeholderReason?: string;
13
+ }
14
+
15
+ export interface TeamEventOwnership {
16
+ owner: string;
17
+ workflowScope: string;
18
+ watcherAction: TeamWatcherAction;
19
+ }
20
+
21
+ export interface TeamEventMetadata {
22
+ seq: number;
23
+ provenance: TeamEventProvenance;
24
+ sessionIdentity?: TeamEventSessionIdentity;
25
+ ownership?: TeamEventOwnership;
26
+ nudgeId?: string;
27
+ fingerprint?: string;
28
+ confidence?: "low" | "medium" | "high";
29
+ }
30
+
4
31
  export interface TeamEvent {
5
32
  time: string;
6
33
  type: string;
@@ -8,11 +35,52 @@ export interface TeamEvent {
8
35
  taskId?: string;
9
36
  message?: string;
10
37
  data?: Record<string, unknown>;
38
+ metadata?: TeamEventMetadata;
39
+ }
40
+
41
+ export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & { metadata?: Partial<TeamEventMetadata> };
42
+
43
+ const TERMINAL_EVENT_TYPES = new Set(["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.cancelled", "task.skipped"]);
44
+
45
+ function nextSequence(eventsPath: string): number {
46
+ if (!fs.existsSync(eventsPath)) return 1;
47
+ let max = 0;
48
+ for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
49
+ if (!line.trim()) continue;
50
+ try {
51
+ const event = JSON.parse(line) as TeamEvent;
52
+ max = Math.max(max, event.metadata?.seq ?? 0);
53
+ } catch {
54
+ max += 1;
55
+ }
56
+ }
57
+ return max + 1;
11
58
  }
12
59
 
13
- export function appendEvent(eventsPath: string, event: Omit<TeamEvent, "time">): TeamEvent {
60
+ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
61
+ return createHash("sha256").update(JSON.stringify({ type: event.type, runId: event.runId, taskId: event.taskId, data: event.data ?? null })).digest("hex").slice(0, 16);
62
+ }
63
+
64
+ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
14
65
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
15
- const fullEvent: TeamEvent = { time: new Date().toISOString(), ...event };
66
+ const baseMetadata = event.metadata;
67
+ let metadata: TeamEventMetadata = {
68
+ seq: baseMetadata?.seq ?? nextSequence(eventsPath),
69
+ provenance: baseMetadata?.provenance ?? "team_runner",
70
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
71
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
72
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
73
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
74
+ };
75
+ const fullEvent: TeamEvent = {
76
+ time: new Date().toISOString(),
77
+ ...event,
78
+ metadata,
79
+ };
80
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
81
+ metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
82
+ fullEvent.metadata = metadata;
83
+ }
16
84
  fs.appendFileSync(eventsPath, `${JSON.stringify(fullEvent)}\n`, "utf-8");
17
85
  return fullEvent;
18
86
  }
@@ -25,3 +93,36 @@ export function readEvents(eventsPath: string): TeamEvent[] {
25
93
  .filter(Boolean)
26
94
  .map((line) => JSON.parse(line) as TeamEvent);
27
95
  }
96
+
97
+ export interface EventCursorOptions {
98
+ sinceSeq?: number;
99
+ limit?: number;
100
+ }
101
+
102
+ function positiveInteger(value: number | undefined): number | undefined {
103
+ return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
104
+ }
105
+
106
+ export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): { events: TeamEvent[]; nextSeq: number; total: number } {
107
+ const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
108
+ const limit = positiveInteger(options.limit);
109
+ const all = readEvents(eventsPath);
110
+ const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
111
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
112
+ const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
113
+ return { events, nextSeq: returnedMaxSeq, total: filtered.length };
114
+ }
115
+
116
+ export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
117
+ const seen = new Set<string>();
118
+ const output: TeamEvent[] = [];
119
+ for (const event of events) {
120
+ const fingerprint = event.metadata?.fingerprint;
121
+ if (fingerprint && TERMINAL_EVENT_TYPES.has(event.type)) {
122
+ if (seen.has(fingerprint)) continue;
123
+ seen.add(fingerprint);
124
+ }
125
+ output.push(event);
126
+ }
127
+ return output;
128
+ }
@@ -97,7 +97,18 @@ export function createRunManifest(params: {
97
97
  fs.mkdirSync(paths.artifactsRoot, { recursive: true });
98
98
  atomicWriteJson(paths.manifestPath, manifest);
99
99
  atomicWriteJson(paths.tasksPath, tasks);
100
- appendEvent(paths.eventsPath, { type: "run.created", runId: paths.runId, data: { team: params.team.name, workflow: params.workflow?.name } });
100
+ appendEvent(paths.eventsPath, {
101
+ type: "run.created",
102
+ runId: paths.runId,
103
+ data: { team: params.team.name, workflow: params.workflow?.name },
104
+ metadata: {
105
+ seq: 1,
106
+ provenance: "team_runner",
107
+ sessionIdentity: { title: params.team.name, workspace: params.cwd, purpose: params.goal },
108
+ ownership: { owner: params.team.name, workflowScope: params.workflow?.name ?? "manual", watcherAction: "act" },
109
+ confidence: "high",
110
+ },
111
+ });
101
112
  return { manifest, tasks, paths };
102
113
  }
103
114
 
@@ -115,7 +126,17 @@ export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManife
115
126
  }
116
127
  const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
117
128
  saveRunManifest(updated);
118
- appendEvent(updated.eventsPath, { type: `run.${status}`, runId: updated.runId, message: summary });
129
+ appendEvent(updated.eventsPath, {
130
+ type: `run.${status}`,
131
+ runId: updated.runId,
132
+ message: summary,
133
+ metadata: {
134
+ provenance: "team_runner",
135
+ sessionIdentity: { title: updated.team, workspace: updated.cwd, purpose: updated.goal },
136
+ ownership: { owner: updated.team, workflowScope: updated.workflow ?? "manual", watcherAction: "act" },
137
+ confidence: "high",
138
+ },
139
+ });
119
140
  return updated;
120
141
  }
121
142
 
@@ -1,6 +1,7 @@
1
1
  import type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
2
2
  import type { TaskClaimState } from "./task-claims.ts";
3
3
  import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
4
+ import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
4
5
  export type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
5
6
 
6
7
  export interface ArtifactDescriptor {
@@ -137,12 +138,14 @@ export interface TeamTaskState {
137
138
  promptArtifact?: ArtifactDescriptor;
138
139
  resultArtifact?: ArtifactDescriptor;
139
140
  logArtifact?: ArtifactDescriptor;
141
+ transcriptArtifact?: ArtifactDescriptor;
140
142
  startedAt?: string;
141
143
  finishedAt?: string;
142
144
  exitCode?: number | null;
143
145
  modelAttempts?: ModelAttemptState[];
144
146
  usage?: UsageState;
145
147
  jsonEvents?: number;
148
+ agentProgress?: CrewAgentProgress;
146
149
  error?: string;
147
150
  claim?: TaskClaimState;
148
151
  heartbeat?: WorkerHeartbeatState;
@@ -1,5 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { TeamRunManifest } from "../state/types.ts";
3
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
+ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
3
5
 
4
6
  interface DashboardComponent {
5
7
  invalidate(): void;
@@ -7,17 +9,43 @@ interface DashboardComponent {
7
9
  handleInput(data: string): void;
8
10
  }
9
11
 
10
- export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "reload";
12
+ export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
11
13
  export interface RunDashboardSelection {
12
14
  runId: string;
13
15
  action: RunDashboardAction;
14
16
  }
15
17
 
18
+ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
19
+
20
+ function visibleLength(value: string): number {
21
+ return value.replace(ANSI_PATTERN, "").length;
22
+ }
23
+
16
24
  function truncate(value: string, width: number): string {
17
25
  if (width <= 0) return "";
18
- if (value.length <= width) return value;
26
+ if (visibleLength(value) <= width) return value;
19
27
  if (width <= 1) return "…";
20
- return `${value.slice(0, width - 1)}…`;
28
+ let output = "";
29
+ let visible = 0;
30
+ for (let index = 0; index < value.length;) {
31
+ const slice = value.slice(index);
32
+ const ansi = slice.match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
33
+ if (ansi?.[0]) {
34
+ output += ansi[0];
35
+ index += ansi[0].length;
36
+ continue;
37
+ }
38
+ const char = value[index]!;
39
+ if (visible >= width - 1) break;
40
+ output += char;
41
+ visible += 1;
42
+ index += char.length;
43
+ }
44
+ return `${output}\u001b[0m…`;
45
+ }
46
+
47
+ function padVisible(value: string, width: number): string {
48
+ return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
21
49
  }
22
50
 
23
51
  function statusIcon(status: string): string {
@@ -40,6 +68,30 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
40
68
  }
41
69
  }
42
70
 
71
+ function agentPreviewLine(agent: CrewAgentRecord): string {
72
+ const stats = [
73
+ agent.progress?.activityState,
74
+ agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
75
+ agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
76
+ agent.progress?.tokens !== undefined ? `${agent.progress.tokens} tok` : undefined,
77
+ agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
78
+ agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
79
+ ].filter((part): part is string => Boolean(part));
80
+ const recent = agent.progress?.recentOutput?.at(-1);
81
+ return `Agent: ${statusIcon(agent.status)} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
82
+ }
83
+
84
+ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
85
+ try {
86
+ const agents = readCrewAgents(run);
87
+ if (!agents.length) return ["Agents: (none)"];
88
+ return ["Agents:", ...agents.slice(0, maxLines).map(agentPreviewLine), ...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : [])];
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ return [`Agents: failed to read (${message})`];
92
+ }
93
+ }
94
+
43
95
  function countByStatus(runs: TeamRunManifest[]): string {
44
96
  const counts = new Map<string, number>();
45
97
  for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
@@ -64,19 +116,19 @@ export class RunDashboard implements DashboardComponent {
64
116
  const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
65
117
  const lines = [
66
118
  `╭${"─".repeat(borderWidth)}╮`,
67
- `│ ${truncate("pi-crew dashboard", innerWidth - 1).padEnd(innerWidth - 1)}│`,
68
- `│ ${truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • q close", innerWidth - 1).padEnd(innerWidth - 1)}│`,
69
- `│ ${truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1).padEnd(innerWidth - 1)}│`,
119
+ `│ ${padVisible(truncate("pi-crew dashboard", innerWidth - 1), innerWidth - 1)}│`,
120
+ `│ ${padVisible(truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close", innerWidth - 1), innerWidth - 1)}│`,
121
+ `│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
70
122
  `├${"─".repeat(borderWidth)}┤`,
71
123
  ];
72
124
  if (this.runs.length === 0) {
73
- lines.push(`│ ${truncate("No runs found.", innerWidth - 1).padEnd(innerWidth - 1)}│`);
125
+ lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
74
126
  } else {
75
127
  for (let i = 0; i < Math.min(this.runs.length, 10); i++) {
76
128
  const run = this.runs[i]!;
77
129
  const marker = i === this.selected ? "›" : " ";
78
130
  const text = `${marker} ${statusIcon(run.status)} ${run.runId} ${run.status} ${run.team}/${run.workflow ?? "none"} ${run.goal}`;
79
- lines.push(`│ ${truncate(text, innerWidth - 1).padEnd(innerWidth - 1)}│`);
131
+ lines.push(`│ ${padVisible(truncate(text, innerWidth - 1), innerWidth - 1)}│`);
80
132
  }
81
133
  const selectedRun = this.runs[this.selected];
82
134
  if (selectedRun) {
@@ -90,8 +142,8 @@ export class RunDashboard implements DashboardComponent {
90
142
  selectedRun.async ? `Async: pid=${selectedRun.async.pid ?? "unknown"} log=${selectedRun.async.logPath}` : "Async: no",
91
143
  `Goal: ${selectedRun.goal}`,
92
144
  ];
93
- for (const detail of [...details, ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
94
- lines.push(`│ ${truncate(detail, innerWidth - 1).padEnd(innerWidth - 1)}│`);
145
+ for (const detail of [...details, ...readAgentPreview(selectedRun), ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
146
+ lines.push(`│ ${padVisible(truncate(detail, innerWidth - 1), innerWidth - 1)}│`);
95
147
  }
96
148
  }
97
149
  }
@@ -124,6 +176,26 @@ export class RunDashboard implements DashboardComponent {
124
176
  this.done(runId ? { runId, action: "api" } : undefined);
125
177
  return;
126
178
  }
179
+ if (data === "d") {
180
+ const runId = this.runs[this.selected]?.runId;
181
+ this.done(runId ? { runId, action: "agents" } : undefined);
182
+ return;
183
+ }
184
+ if (data === "e") {
185
+ const runId = this.runs[this.selected]?.runId;
186
+ this.done(runId ? { runId, action: "agent-events" } : undefined);
187
+ return;
188
+ }
189
+ if (data === "o") {
190
+ const runId = this.runs[this.selected]?.runId;
191
+ this.done(runId ? { runId, action: "agent-output" } : undefined);
192
+ return;
193
+ }
194
+ if (data === "v") {
195
+ const runId = this.runs[this.selected]?.runId;
196
+ this.done(runId ? { runId, action: "agent-transcript" } : undefined);
197
+ return;
198
+ }
127
199
  if (data === "r") {
128
200
  this.done({ runId: this.runs[this.selected]?.runId ?? "", action: "reload" });
129
201
  return;
@@ -0,0 +1,45 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export type BranchFreshnessStatus = "fresh" | "stale" | "diverged" | "unknown";
4
+ export type StaleBranchPolicy = "warn" | "block" | "auto_rebase" | "auto_merge_forward";
5
+
6
+ export interface BranchFreshness {
7
+ status: BranchFreshnessStatus;
8
+ branch?: string;
9
+ mainRef: string;
10
+ ahead: number;
11
+ behind: number;
12
+ missingFixes: string[];
13
+ message: string;
14
+ error?: string;
15
+ }
16
+
17
+ function git(cwd: string, args: string[]): string {
18
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
19
+ }
20
+
21
+ function count(cwd: string, range: string): number {
22
+ const raw = git(cwd, ["rev-list", "--count", range]);
23
+ const parsed = Number.parseInt(raw, 10);
24
+ return Number.isFinite(parsed) ? parsed : 0;
25
+ }
26
+
27
+ export function checkBranchFreshness(cwd: string, mainRef = "main"): BranchFreshness {
28
+ try {
29
+ git(cwd, ["rev-parse", "--is-inside-work-tree"]);
30
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
31
+ const behind = count(cwd, `${branch}..${mainRef}`);
32
+ const ahead = count(cwd, `${mainRef}..${branch}`);
33
+ const missingFixes = behind > 0 ? git(cwd, ["log", "--format=%s", `${branch}..${mainRef}`]).split("\n").map((line) => line.trim()).filter(Boolean) : [];
34
+ if (behind === 0) return { status: "fresh", branch, mainRef, ahead, behind, missingFixes, message: `Branch '${branch}' is fresh against ${mainRef}.` };
35
+ if (ahead > 0) return { status: "diverged", branch, mainRef, ahead, behind, missingFixes, message: `Branch '${branch}' diverged from ${mainRef}: ahead=${ahead}, behind=${behind}.` };
36
+ return { status: "stale", branch, mainRef, ahead, behind, missingFixes, message: `Branch '${branch}' is ${behind} commit(s) behind ${mainRef}.` };
37
+ } catch (error) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ return { status: "unknown", mainRef, ahead: 0, behind: 0, missingFixes: [], message: "Branch freshness could not be determined.", error: message };
40
+ }
41
+ }
42
+
43
+ export function shouldBlockForBranchFreshness(freshness: BranchFreshness, policy: StaleBranchPolicy = "warn"): boolean {
44
+ return policy === "block" && (freshness.status === "stale" || freshness.status === "diverged");
45
+ }