pi-crew 0.1.7 → 0.1.9

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.
@@ -0,0 +1,204 @@
1
+ import * as fs from "node:fs";
2
+ type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
3
+ type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
4
+ import type { TeamRunManifest } from "../state/types.ts";
5
+ import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
6
+
7
+ function visibleWidth(text: string): number {
8
+ return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
9
+ }
10
+
11
+ function truncate(text: string, width: number): string {
12
+ if (width <= 0) return "";
13
+ if (visibleWidth(text) <= width) return text;
14
+ return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
15
+ }
16
+
17
+ function wrap(text: string, width: number): string[] {
18
+ const source = text.split(/\r?\n/);
19
+ const lines: string[] = [];
20
+ for (const raw of source) {
21
+ const line = raw || " ";
22
+ if (line.length <= width) {
23
+ lines.push(line);
24
+ continue;
25
+ }
26
+ for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
27
+ }
28
+ return lines;
29
+ }
30
+
31
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
32
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
33
+ }
34
+
35
+ function textFromContent(content: unknown): string {
36
+ if (typeof content === "string") return content;
37
+ if (!Array.isArray(content)) return "";
38
+ return content.map((part) => {
39
+ const obj = asRecord(part);
40
+ if (!obj) return "";
41
+ if (typeof obj.text === "string") return obj.text;
42
+ if (typeof obj.content === "string") return obj.content;
43
+ if (typeof obj.name === "string") return `[tool:${obj.name}]`;
44
+ return "";
45
+ }).filter(Boolean).join("\n");
46
+ }
47
+
48
+ export function formatTranscriptEvent(event: unknown): string[] {
49
+ const obj = asRecord(event);
50
+ if (!obj) return [String(event)];
51
+ const type = typeof obj.type === "string" ? obj.type : undefined;
52
+ const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
53
+ if (type && /tool/i.test(type)) {
54
+ const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
55
+ return [`[tool${toolName ? `:${toolName}` : ""} ${type}]: ${text.trim() || "(no output)"}`];
56
+ }
57
+ const message = asRecord(obj.message);
58
+ if (message) {
59
+ const role = typeof message.role === "string" ? message.role : "message";
60
+ const text = textFromContent(message.content);
61
+ if (text.trim()) return [`[${role}]: ${text.trim()}`];
62
+ }
63
+ if (type) {
64
+ const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
65
+ return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
66
+ }
67
+ return [JSON.stringify(event)];
68
+ }
69
+
70
+ export function formatTranscriptText(text: string): string[] {
71
+ const lines: string[] = [];
72
+ for (const raw of text.split(/\r?\n/).filter(Boolean)) {
73
+ try {
74
+ lines.push(...formatTranscriptEvent(JSON.parse(raw)));
75
+ } catch {
76
+ lines.push(raw);
77
+ }
78
+ }
79
+ return lines.length ? lines : ["(no transcript content)"];
80
+ }
81
+
82
+ export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
83
+ const agents = readCrewAgents(manifest);
84
+ const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
85
+ const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
86
+ const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
87
+ const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
88
+ return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
89
+ }
90
+
91
+ export class DurableTextViewer implements Component {
92
+ private scroll = 0;
93
+ private lastHeight = 10;
94
+ private title: string;
95
+ private subtitle: string;
96
+ private lines: string[];
97
+ private theme: unknown;
98
+ private done: (result: undefined) => void;
99
+
100
+ constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
101
+ this.title = title;
102
+ this.subtitle = subtitle;
103
+ this.lines = lines.length ? lines : ["(empty)"];
104
+ this.theme = theme;
105
+ this.done = done;
106
+ }
107
+
108
+ invalidate(): void {}
109
+
110
+ handleInput(data: string): void {
111
+ if (data === "q" || data === "\u001b") {
112
+ this.done(undefined);
113
+ return;
114
+ }
115
+ const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
116
+ if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
117
+ else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
118
+ else if (data === "g") this.scroll = 0;
119
+ else if (data === "G") this.scroll = maxScroll;
120
+ }
121
+
122
+ render(width: number): string[] {
123
+ const th = this.theme as TranscriptTheme;
124
+ const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
125
+ const bold = th.bold?.bind(th) ?? ((text: string) => text);
126
+ const inner = Math.max(20, width - 4);
127
+ this.lastHeight = 16;
128
+ const body = this.lines.flatMap((line) => wrap(line, inner));
129
+ const maxScroll = Math.max(0, body.length - this.lastHeight);
130
+ this.scroll = Math.min(this.scroll, maxScroll);
131
+ const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
132
+ const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
133
+ const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
134
+ return [
135
+ fg("border", `╭${"─".repeat(inner + 2)}╮`),
136
+ row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
137
+ row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
138
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
139
+ ...visible.map(row),
140
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
141
+ row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
142
+ fg("border", `╰${"─".repeat(inner + 2)}╯`),
143
+ ];
144
+ }
145
+ }
146
+
147
+ export class DurableTranscriptViewer implements Component {
148
+ private scroll = 0;
149
+ private lastHeight = 10;
150
+ private manifest: TeamRunManifest;
151
+ private theme: unknown;
152
+ private done: (result: undefined) => void;
153
+ private taskId?: string;
154
+
155
+ constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
156
+ this.manifest = manifest;
157
+ this.theme = theme;
158
+ this.done = done;
159
+ this.taskId = taskId;
160
+ }
161
+
162
+ invalidate(): void {}
163
+
164
+ handleInput(data: string): void {
165
+ if (data === "q" || data === "\u001b") {
166
+ this.done(undefined);
167
+ return;
168
+ }
169
+ const content = readRunTranscript(this.manifest, this.taskId).lines;
170
+ const maxScroll = Math.max(0, content.length - this.lastHeight);
171
+ if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
172
+ else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
173
+ else if (data === "g") this.scroll = 0;
174
+ else if (data === "G") this.scroll = maxScroll;
175
+ }
176
+
177
+ render(width: number): string[] {
178
+ const th = this.theme as TranscriptTheme;
179
+ const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
180
+ const bold = th.bold?.bind(th) ?? ((text: string) => text);
181
+ const inner = Math.max(20, width - 4);
182
+ const data = readRunTranscript(this.manifest, this.taskId);
183
+ const body = data.lines.flatMap((line) => wrap(line, inner));
184
+ this.lastHeight = 16;
185
+ const maxScroll = Math.max(0, body.length - this.lastHeight);
186
+ this.scroll = Math.min(this.scroll, maxScroll);
187
+ const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
188
+ const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
189
+ const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
190
+ const lines = [
191
+ fg("border", `╭${"─".repeat(inner + 2)}╮`),
192
+ row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
193
+ row(fg("dim", data.path)),
194
+ row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
195
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
196
+ ...visible.map(row),
197
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
198
+ row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
199
+ fg("border", `╰${"─".repeat(inner + 2)}╯`),
200
+ ];
201
+ return lines;
202
+ }
203
+ }
204
+
@@ -0,0 +1,33 @@
1
+ interface TimerApi {
2
+ setTimeout(handler: () => void, delayMs: number): unknown;
3
+ clearTimeout(handle: unknown): void;
4
+ }
5
+
6
+ const defaultTimerApi: TimerApi = {
7
+ setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
8
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
9
+ };
10
+
11
+ export interface FileCoalescer {
12
+ schedule(file: string, delayMs?: number): boolean;
13
+ clear(): void;
14
+ }
15
+
16
+ export function createFileCoalescer(handler: (file: string) => void, defaultDelayMs: number, timerApi: TimerApi = defaultTimerApi): FileCoalescer {
17
+ const pending = new Map<string, unknown>();
18
+ return {
19
+ schedule(file, delayMs = defaultDelayMs) {
20
+ if (pending.has(file)) return false;
21
+ const timer = timerApi.setTimeout(() => {
22
+ pending.delete(file);
23
+ handler(file);
24
+ }, delayMs);
25
+ pending.set(file, timer);
26
+ return true;
27
+ },
28
+ clear() {
29
+ for (const timer of pending.values()) timerApi.clearTimeout(timer);
30
+ pending.clear();
31
+ },
32
+ };
33
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { loadConfig } from "../config/config.ts";
@@ -9,6 +9,15 @@ export interface PreparedTaskWorkspace {
9
9
  worktreePath?: string;
10
10
  branch?: string;
11
11
  reused?: boolean;
12
+ nodeModulesLinked?: boolean;
13
+ syntheticPaths?: string[];
14
+ }
15
+
16
+ export interface WorktreeDiffStat {
17
+ filesChanged: number;
18
+ insertions: number;
19
+ deletions: number;
20
+ diffStat: string;
12
21
  }
13
22
 
14
23
  function git(cwd: string, args: string[]): string {
@@ -30,6 +39,47 @@ export function assertCleanLeader(repoRoot: string): void {
30
39
  }
31
40
  }
32
41
 
42
+ function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boolean {
43
+ const source = path.join(repoRoot, "node_modules");
44
+ const target = path.join(worktreePath, "node_modules");
45
+ if (!fs.existsSync(source) || fs.existsSync(target)) return false;
46
+ try {
47
+ fs.symlinkSync(source, target, process.platform === "win32" ? "junction" : "dir");
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
55
+ const resolved = path.resolve(worktreePath, rawPath);
56
+ const relative = path.relative(worktreePath, resolved);
57
+ if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
58
+ return path.normalize(relative);
59
+ }
60
+
61
+ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot: string, worktreePath: string, branch: string): string[] {
62
+ const cfg = loadConfig(manifest.cwd).config.worktree;
63
+ if (!cfg?.setupHook) return [];
64
+ const hookPath = path.isAbsolute(cfg.setupHook) ? cfg.setupHook : path.resolve(repoRoot, cfg.setupHook);
65
+ if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) throw new Error(`worktree setup hook not found or not a file: ${hookPath}`);
66
+ const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
67
+ const result = spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
68
+ cwd: worktreePath,
69
+ encoding: "utf-8",
70
+ input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
71
+ timeout: cfg.setupHookTimeoutMs ?? 30_000,
72
+ shell: false,
73
+ });
74
+ if (result.error) throw new Error(`worktree setup hook failed: ${result.error.message}`);
75
+ if (result.status !== 0) throw new Error(`worktree setup hook failed with exit code ${result.status}: ${result.stderr || result.stdout || "no output"}`);
76
+ const trimmed = result.stdout.trim();
77
+ if (!trimmed) return [];
78
+ const parsed = JSON.parse(trimmed) as { syntheticPaths?: unknown };
79
+ if (!Array.isArray(parsed.syntheticPaths)) return [];
80
+ return [...new Set(parsed.syntheticPaths.filter((entry): entry is string => typeof entry === "string").map((entry) => normalizeSyntheticPath(worktreePath, entry)))];
81
+ }
82
+
33
83
  export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskState): PreparedTaskWorkspace {
34
84
  if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
35
85
  const repoRoot = findGitRoot(manifest.cwd);
@@ -47,7 +97,28 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
47
97
  return { cwd: worktreePath, worktreePath, branch, reused: true };
48
98
  }
49
99
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
50
- return { cwd: worktreePath, worktreePath, branch, reused: false };
100
+ const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
101
+ const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
102
+ return { cwd: worktreePath, worktreePath, branch, reused: false, nodeModulesLinked, syntheticPaths };
103
+ }
104
+
105
+ export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat {
106
+ try {
107
+ const diffStat = git(worktreePath, ["diff", "--stat"]);
108
+ const numstat = git(worktreePath, ["diff", "--numstat"]);
109
+ let filesChanged = 0;
110
+ let insertions = 0;
111
+ let deletions = 0;
112
+ for (const line of numstat.split(/\r?\n/).filter(Boolean)) {
113
+ const [add, del] = line.split(/\s+/);
114
+ filesChanged += 1;
115
+ insertions += Number(add) || 0;
116
+ deletions += Number(del) || 0;
117
+ }
118
+ return { filesChanged, insertions, deletions, diffStat };
119
+ } catch {
120
+ return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" };
121
+ }
51
122
  }
52
123
 
53
124
  export function captureWorktreeDiff(worktreePath: string): string {