pi-crew 0.6.4 → 0.7.2

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 (41) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +68 -0
  3. package/package.json +2 -1
  4. package/src/errors.ts +20 -2
  5. package/src/extension/knowledge-injection.ts +71 -0
  6. package/src/extension/pi-api.ts +47 -0
  7. package/src/extension/register.ts +19 -6
  8. package/src/extension/registration/commands.ts +40 -1
  9. package/src/extension/registration/compaction-guard.ts +154 -14
  10. package/src/extension/team-tool/handle-settings.ts +57 -0
  11. package/src/extension/team-tool/inspect.ts +4 -1
  12. package/src/extension/team-tool/plan.ts +8 -1
  13. package/src/runtime/intercom-bridge.ts +5 -1
  14. package/src/runtime/replace.ts +555 -0
  15. package/src/runtime/resilient-edit.ts +166 -0
  16. package/src/runtime/single-agent-compose.ts +87 -0
  17. package/src/runtime/task-runner/prompt-builder.ts +6 -0
  18. package/src/runtime/team-runner.ts +24 -7
  19. package/src/schema/team-tool-schema.ts +6 -0
  20. package/src/state/usage.ts +73 -0
  21. package/src/ui/card-colors.ts +126 -0
  22. package/src/ui/deploy-bundled-themes.ts +71 -0
  23. package/src/ui/render-diff.ts +37 -3
  24. package/src/ui/settings-overlay.ts +70 -7
  25. package/src/ui/syntax-highlight.ts +42 -23
  26. package/src/ui/theme-discovery.ts +188 -0
  27. package/src/ui/tool-renderers/index.ts +27 -14
  28. package/themes/crew-catppuccin-latte.json +96 -0
  29. package/themes/crew-catppuccin-mocha.json +93 -0
  30. package/themes/crew-dark.json +90 -0
  31. package/themes/crew-dracula.json +83 -0
  32. package/themes/crew-gruvbox-dark.json +83 -0
  33. package/themes/crew-gruvbox-light.json +90 -0
  34. package/themes/crew-nord.json +85 -0
  35. package/themes/crew-one-dark.json +89 -0
  36. package/themes/crew-solarized-dark.json +90 -0
  37. package/themes/crew-solarized-light.json +92 -0
  38. package/themes/crew-tokyo-night.json +85 -0
  39. package/src/extension/registration/brief-tool-overrides.ts +0 -400
  40. package/src/runtime/budget-tracker.ts +0 -354
  41. package/src/state/memory-store.ts +0 -244
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Resilient edit wrapper — uses the cascading replace() engine as a fallback
3
+ * when the native edit tool fails with "old_string not found".
4
+ *
5
+ * Multi-agent runs (crew workers) often provide slightly-wrong oldText:
6
+ * - indentation drift (tabs vs spaces)
7
+ * - trailing whitespace differences
8
+ * - line-ending normalization (\r\n vs \n)
9
+ *
10
+ * The native edit tool is exact-match only and fails hard on these. This
11
+ * wrapper catches that failure and retries with the cascading engine, which
12
+ * tries (in order): exact → escape-normalized → line-trimmed → block-anchor →
13
+ * whitespace-normalized → trimmed-boundary.
14
+ *
15
+ * CONFLICT AWARENESS: pi-diff (`@heyhuynhgiabuu/pi-diff`) also overrides the
16
+ * edit tool with its own replace() integration. To avoid double-wrapping,
17
+ * this module is OPT-IN via the `CREW_RESILIENT_EDIT=1` env var, and
18
+ * auto-disables if pi-diff is detected in the loaded extensions.
19
+ *
20
+ * Usage (in register.ts, guarded):
21
+ * if (process.env.CREW_RESILIENT_EDIT === "1") {
22
+ * wrapEditWithResilientReplace(pi);
23
+ * }
24
+ */
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { replace, type ReplaceResult } from "../runtime/replace.ts";
27
+
28
+ interface ToolLike {
29
+ name: string;
30
+ description: string;
31
+ parameters: unknown;
32
+ execute: (toolCallId: string, params: any, signal: any, onUpdate: any) => Promise<unknown>;
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ [key: string]: any;
35
+ }
36
+
37
+ interface EditParams {
38
+ path?: string;
39
+ filePath?: string;
40
+ oldString?: string;
41
+ old_string?: string;
42
+ newString?: string;
43
+ new_string?: string;
44
+ replaceAll?: boolean;
45
+ replace_all?: boolean;
46
+ }
47
+
48
+ interface EditResult {
49
+ content?: unknown[];
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ const NOT_FOUND_PATTERNS = [
54
+ /old_string not found/i,
55
+ /oldstring not found/i,
56
+ /no match/i,
57
+ /could not find/i,
58
+ /string not found/i,
59
+ ];
60
+
61
+ function isNotFoundResult(result: unknown): boolean {
62
+ if (!result || typeof result !== "object") return false;
63
+ const r = result as EditResult;
64
+ const text = JSON.stringify(r.content ?? r);
65
+ return NOT_FOUND_PATTERNS.some((re) => re.test(text));
66
+ }
67
+
68
+ /** Detect whether pi-diff is loaded (to avoid double-wrapping edit). */
69
+ function isPiDiffLoaded(pi: ExtensionAPI): boolean {
70
+ try {
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ const piAny = pi as any;
73
+ const extensions = piAny?.extensions ?? piAny?._extensions ?? [];
74
+ const names = Array.isArray(extensions)
75
+ ? extensions.map((e: unknown) => (typeof e === "string" ? e : (e as { name?: string })?.name ?? ""))
76
+ : Object.keys(extensions);
77
+ return names.some((n: string) => typeof n === "string" && n.includes("pi-diff"));
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Wrap the native `edit` tool so that on "old_string not found" failures, it
85
+ * retries using the cascading replace() engine (lenient matching).
86
+ *
87
+ * @param pi the Pi extension API
88
+ * @param tools optional injected tool registry (for testing)
89
+ * @returns true if the wrapper was applied, false if skipped
90
+ */
91
+ export function wrapEditWithResilientReplace(pi: ExtensionAPI, tools?: { edit: ToolLike }): boolean {
92
+ // Auto-disable if pi-diff is present (it has its own replace integration).
93
+ if (isPiDiffLoaded(pi)) {
94
+ return false;
95
+ }
96
+
97
+ const t = tools ?? ((pi as unknown as { tools?: { edit?: ToolLike } }).tools);
98
+ if (!t?.edit?.execute) return false;
99
+
100
+ const nativeExecute = t.edit.execute.bind(t.edit);
101
+
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ t.edit.execute = async function resilientExecute(toolCallId: string, params: any, signal: any, onUpdate: any): Promise<unknown> {
104
+ try {
105
+ const result = await nativeExecute(toolCallId, params, signal, onUpdate);
106
+ if (!isNotFoundResult(result)) return result;
107
+ // Fall through to resilient retry.
108
+ return await retryWithReplace(params, toolCallId, signal, onUpdate);
109
+ } catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ if (NOT_FOUND_PATTERNS.some((re) => re.test(msg))) {
112
+ return await retryWithReplace(params, toolCallId, signal, onUpdate);
113
+ }
114
+ throw err;
115
+ }
116
+ };
117
+
118
+ return true;
119
+
120
+ async function retryWithReplace(
121
+ params: EditParams,
122
+ toolCallId: string,
123
+ signal: any,
124
+ onUpdate: any,
125
+ ): Promise<unknown> {
126
+ const filePath = params.path ?? params.filePath;
127
+ const oldStr = params.oldString ?? params.old_string;
128
+ const newStr = params.newString ?? params.new_string;
129
+ const replaceAll = params.replaceAll ?? params.replace_all ?? false;
130
+
131
+ if (!filePath || typeof oldStr !== "string" || typeof newStr !== "string") {
132
+ // Can't retry — rethrow a not-found style error.
133
+ throw new Error("old_string not found (and resilient retry skipped: missing path/old/new)");
134
+ }
135
+
136
+ const fs = await import("node:fs/promises");
137
+ let content: string;
138
+ try {
139
+ content = await fs.readFile(filePath, "utf8");
140
+ } catch (readErr) {
141
+ throw new Error(
142
+ `resilient edit: could not read ${filePath}: ${
143
+ readErr instanceof Error ? readErr.message : String(readErr)
144
+ }`,
145
+ );
146
+ }
147
+
148
+ const result: ReplaceResult = replace(content, oldStr, newStr, { replaceAll });
149
+ if (!result.changed) {
150
+ throw new Error(
151
+ `old_string not found (resilient cascade exhausted, strategy=${result.strategy})`,
152
+ );
153
+ }
154
+
155
+ await fs.writeFile(filePath, result.content, "utf8");
156
+ return {
157
+ content: [
158
+ {
159
+ type: "text",
160
+ text: `Edited ${filePath} via resilient cascade (strategy: ${result.strategy}).`,
161
+ },
162
+ ],
163
+ _replaceStrategy: result.strategy,
164
+ };
165
+ }
166
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * single-agent-compose.ts — Cliff hedge v0 (roadmap Phase 0 / T0.5).
3
+ *
4
+ * PURPOSE (Round 6 §5, Round 7 Pillar 3 "Cliff-Resilient Value"):
5
+ * If by ~2027 single models with 1M+ tokens outperform multi-agent teams for
6
+ * most coding tasks, pi-crew must still deliver value. This module composes a
7
+ * workflow into ONE sequential prompt that a single agent can execute — proving
8
+ * pi-crew's mission (reliable orchestration) survives even if the multi-agent
9
+ * MECHANISM is obsoleted.
10
+ *
11
+ * This is the CHEAP v0 (prompt composition only, ~100 LOC). The full
12
+ * single-agent runtime mode is Phase 2 (T2.2).
13
+ *
14
+ * What survives the cliff (cliff-resilient value):
15
+ * - Workflow-as-data (the Markdown workflow definitions)
16
+ * - Sequential phase structure + dependencies
17
+ * - Artifact naming + output contracts
18
+ * - The agent just executes phases in order instead of N agents in parallel.
19
+ */
20
+ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
21
+
22
+ export interface SingleAgentPrompt {
23
+ prompt: string;
24
+ stepCount: number;
25
+ }
26
+
27
+ /** Topologically order steps by dependsOn (stable for already-ordered input). */
28
+ function orderSteps(steps: WorkflowStep[]): WorkflowStep[] {
29
+ const byId = new Map(steps.map((s) => [s.id, s]));
30
+ const ordered: WorkflowStep[] = [];
31
+ const seen = new Set<string>();
32
+ const visit = (step: WorkflowStep): void => {
33
+ if (seen.has(step.id)) return;
34
+ if (step.dependsOn) {
35
+ for (const dep of step.dependsOn) {
36
+ const d = byId.get(dep);
37
+ if (d) visit(d);
38
+ }
39
+ }
40
+ seen.add(step.id);
41
+ ordered.push(step);
42
+ };
43
+ for (const s of steps) visit(s);
44
+ return ordered;
45
+ }
46
+
47
+ /**
48
+ * Compose a workflow into a single sequential execution prompt.
49
+ * The agent executes each phase in dependency order, writing outputs to the
50
+ * named artifacts, mimicking what the multi-agent team would produce.
51
+ */
52
+ export function composeSingleAgentPrompt(
53
+ workflow: WorkflowConfig,
54
+ goal: string,
55
+ ): SingleAgentPrompt {
56
+ const ordered = orderSteps(workflow.steps);
57
+ const lines: string[] = [];
58
+ lines.push(`# Single-agent workflow execution: ${workflow.name}`);
59
+ lines.push(`Workflow: ${workflow.name} — ${workflow.description}`);
60
+ lines.push("");
61
+ lines.push(`## Goal`);
62
+ lines.push(goal);
63
+ lines.push("");
64
+ lines.push("## Execution plan");
65
+ lines.push(
66
+ "Execute the following phases in order. Each phase has a role, a task, and an optional output artifact.",
67
+ "Complete each phase fully before moving to the next. After all phases, summarize results.",
68
+ );
69
+ lines.push("");
70
+ ordered.forEach((step, i) => {
71
+ const deps = step.dependsOn?.length ? ` (after: ${step.dependsOn.join(", ")})` : "";
72
+ lines.push(`### Phase ${i + 1}: ${step.id}${deps}`);
73
+ lines.push(`Role: ${step.role}`);
74
+ lines.push(`Task: ${step.task}`);
75
+ if (step.output) {
76
+ lines.push(`Output: write your result to \`${step.output}\``);
77
+ }
78
+ if (step.reads && step.reads.length) {
79
+ lines.push(`Read first: ${step.reads.join(", ")}`);
80
+ }
81
+ lines.push("");
82
+ });
83
+ lines.push("## After all phases");
84
+ lines.push("Write a brief summary of what you accomplished, referencing each output artifact.");
85
+ const prompt = lines.join("\n");
86
+ return { prompt, stepCount: ordered.length };
87
+ }
@@ -5,6 +5,7 @@ import { buildMemoryBlock } from "../agent-memory.ts";
5
5
  import { permissionForRole } from "../role-permission.ts";
6
6
  import { renderTaskPacket, HANDOFF_TEMPLATE } from "../task-packet.ts";
7
7
  import { buildWorkspaceTree } from "../workspace-tree.ts";
8
+ import { buildKnowledgeFragment } from "../../extension/knowledge-injection.ts";
8
9
 
9
10
  /**
10
11
  * When loadMode is "lean", emit a tool guidance block that tells the worker
@@ -114,6 +115,11 @@ export async function renderTaskPrompt(manifest: TeamRunManifest, step: Workflow
114
115
  treeBlock,
115
116
  "",
116
117
  toolGuidanceBlock(agent),
118
+ "",
119
+ // O4: project knowledge (.crew/knowledge.md) — workers don't load the
120
+ // pi-crew extension (spawned with --no-extensions), so before_agent_start
121
+ // never fires for them. Inject here so every worker sees project knowledge.
122
+ buildKnowledgeFragment(task.cwd),
117
123
  ].filter(Boolean).join("\n");
118
124
 
119
125
  // Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
@@ -342,8 +342,11 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
342
342
  return result.tasks.find((item) => item.id === taskId && item.status === "failed");
343
343
  }
344
344
 
345
- function requiresPlanApproval(workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
346
- return workflow.name === "implementation" && runtimeConfig?.requirePlanApproval === true;
345
+ function requiresPlanApproval(_workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
346
+ // ROADMAP T1.2: plan-level HITL applies to ANY workflow when
347
+ // config.runtime.requirePlanApproval === true (not just 'implementation').
348
+ // The gate fires at the read-only → mutating (plan → execute) boundary.
349
+ return runtimeConfig?.requirePlanApproval === true;
347
350
  }
348
351
 
349
352
  function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
@@ -357,6 +360,9 @@ function isMutatingTask(task: TeamTaskState): boolean {
357
360
  function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
358
361
  if (manifest.planApproval) return manifest;
359
362
  const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
363
+ // ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
364
+ // completed read-only (planning) task as the plan reference.
365
+ const planTask = assessTask ?? [...tasks].reverse().find((t) => t.status === "completed" && !isMutatingTask(t));
360
366
  const now = new Date().toISOString();
361
367
  const updated: TeamRunManifest = {
362
368
  ...manifest,
@@ -366,12 +372,12 @@ function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskS
366
372
  status: "pending",
367
373
  requestedAt: now,
368
374
  updatedAt: now,
369
- planTaskId: assessTask?.id,
370
- planArtifactPath: assessTask?.resultArtifact?.path,
375
+ planTaskId: planTask?.id,
376
+ planArtifactPath: planTask?.resultArtifact?.path,
371
377
  },
372
378
  };
373
379
  saveRunManifest(updated);
374
- appendEvent(updated.eventsPath, { type: "plan.approval_required", runId: updated.runId, taskId: assessTask?.id, message: "Adaptive implementation plan requires explicit approval before mutating tasks run.", data: { planArtifactPath: assessTask?.resultArtifact?.path } });
380
+ appendEvent(updated.eventsPath, { type: "plan.approval_required", runId: updated.runId, taskId: planTask?.id, message: "Plan requires explicit approval before mutating tasks run. Use: team api op=approve-plan runId=...", data: { planArtifactPath: planTask?.resultArtifact?.path } });
375
381
  return updated;
376
382
  }
377
383
 
@@ -383,6 +389,17 @@ function hasPendingMutatingAdaptiveTask(tasks: TeamTaskState[]): boolean {
383
389
  return tasks.some((task) => task.status === "queued" && task.adaptive && isMutatingTask(task));
384
390
  }
385
391
 
392
+ /**
393
+ * ROADMAP T1.2: gate detection for ANY workflow (not just adaptive).
394
+ * Fires when there are pending mutating tasks whose prerequisites (read-only
395
+ * tasks) have completed — i.e. the plan→execute boundary.
396
+ */
397
+ export function hasPendingMutatingTaskAtBoundary(tasks: TeamTaskState[]): boolean {
398
+ const hasCompletedReadOnly = tasks.some((t) => t.status === "completed" && !isMutatingTask(t));
399
+ const hasPendingMutating = tasks.some((t) => t.status === "queued" && isMutatingTask(t));
400
+ return hasCompletedReadOnly && hasPendingMutating;
401
+ }
402
+
386
403
  /**
387
404
  * Check whether any task uses explicit `dependsOn` that would benefit from DAG-based
388
405
  * execution planning. If so, build an execution plan and use `getDagReadyTasks`
@@ -516,7 +533,7 @@ async function executeTeamRunCore(
516
533
  if (initialAdaptive.injected) {
517
534
  manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
518
535
  queueIndex = buildTaskGraphIndex(tasks);
519
- } else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
536
+ } else if (requiresPlanApproval(workflow, input.runtimeConfig) && (hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))) {
520
537
  manifest = ensurePlanApprovalRequested(manifest, tasks);
521
538
  }
522
539
  if (manifest.planApproval?.status === "cancelled") {
@@ -815,7 +832,7 @@ tasks = mergeResult.resultTasks;
815
832
  if (injectedAfterBatch.injected) {
816
833
  manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
817
834
  queueIndex = buildTaskGraphIndex(tasks);
818
- } else if (requiresPlanApproval(workflow, input.runtimeConfig) && hasPendingMutatingAdaptiveTask(tasks)) {
835
+ } else if (requiresPlanApproval(workflow, input.runtimeConfig) && (hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))) {
819
836
  manifest = ensurePlanApprovalRequested(manifest, tasks);
820
837
  }
821
838
  if (manifest.planApproval?.status === "cancelled") {
@@ -113,6 +113,11 @@ export const TeamToolParams = Type.Object({
113
113
  description: "Concrete task text for direct role/agent execution.",
114
114
  }),
115
115
  ),
116
+ singleAgent: Type.Optional(
117
+ Type.Boolean({
118
+ description: "When true (with action=plan), compose a single-agent sequential prompt for the workflow instead of a multi-agent plan. Cliff-resilient mode.",
119
+ }),
120
+ ),
116
121
  runId: Type.Optional(
117
122
  Type.String({
118
123
  description: "Run ID for status, cancel, or resume.",
@@ -308,6 +313,7 @@ export interface TeamToolParamsValue {
308
313
  agent?: string;
309
314
  goal?: string;
310
315
  task?: string;
316
+ singleAgent?: boolean;
311
317
  runId?: string;
312
318
  taskId?: string;
313
319
  message?: string;
@@ -47,3 +47,76 @@ export function formatUsage(usage: UsageState | undefined): string {
47
47
  if (usage.turns !== undefined) parts.push(`turns=${usage.turns}`);
48
48
  return parts.join(", ") || "(none)";
49
49
  }
50
+
51
+ /** Human-readable compact token count (12345 -> "12.3k"). */
52
+ export function formatTokens(n: number): string {
53
+ if (n < 1000) return `${n}`;
54
+ if (n < 1_000_000) return n < 10_000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
55
+ return `${(n / 1_000_000).toFixed(2)}M`;
56
+ }
57
+
58
+ /** Human-readable cost ($0.001234 -> "$0.001234", $1.5 -> "$1.50"). */
59
+ export function formatCost(cost: number | undefined): string {
60
+ if (cost === undefined || !Number.isFinite(cost)) return "$0.00";
61
+ if (cost === 0) return "$0.00";
62
+ if (cost < 0.01) return `$${cost.toFixed(6)}`;
63
+ return `$${cost.toFixed(cost < 1 ? 4 : 2)}`;
64
+ }
65
+
66
+ export interface RoleUsage {
67
+ role: string;
68
+ input: number;
69
+ output: number;
70
+ cacheWrite: number;
71
+ cost: number;
72
+ turns: number;
73
+ taskCount: number;
74
+ }
75
+
76
+ /** Aggregate usage per-role (and per-task) for cost attribution. */
77
+ export function aggregateUsageByRole(tasks: TeamTaskState[]): RoleUsage[] {
78
+ const byRole = new Map<string, RoleUsage>();
79
+ for (const task of tasks) {
80
+ const role = task.role || "unknown";
81
+ let bucket = byRole.get(role);
82
+ if (!bucket) {
83
+ bucket = { role, input: 0, output: 0, cacheWrite: 0, cost: 0, turns: 0, taskCount: 0 };
84
+ byRole.set(role, bucket);
85
+ }
86
+ bucket.taskCount++;
87
+ if (!task.usage) continue;
88
+ bucket.input += task.usage.input ?? 0;
89
+ bucket.output += task.usage.output ?? 0;
90
+ bucket.cacheWrite += task.usage.cacheWrite ?? 0;
91
+ bucket.cost += task.usage.cost ?? 0;
92
+ bucket.turns += task.usage.turns ?? 0;
93
+ }
94
+ return [...byRole.values()].sort((a, b) => b.cost - a.cost);
95
+ }
96
+
97
+ /**
98
+ * Build a multi-line cost report with per-role attribution.
99
+ * Used by the `summary` action for cost visibility (roadmap T1.1).
100
+ */
101
+ export function formatCostReport(tasks: TeamTaskState[]): string {
102
+ const usage = aggregateUsage(tasks);
103
+ if (!usage) return "Cost: (no usage data recorded)";
104
+ const byRole = aggregateUsageByRole(tasks);
105
+ const totalTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
106
+ const lines: string[] = [];
107
+ lines.push("═══ Cost Report ═══");
108
+ lines.push(`Tokens: ${formatTokens(totalTokens)} (in ${formatTokens(usage.input ?? 0)}, out ${formatTokens(usage.output ?? 0)}, cache-write ${formatTokens(usage.cacheWrite ?? 0)})`);
109
+ lines.push(`Cost: ${formatCost(usage.cost)}${usage.turns ? ` across ${usage.turns} turn(s)` : ""}`);
110
+ if (byRole.length > 1) {
111
+ lines.push("");
112
+ lines.push("By role:");
113
+ for (const r of byRole) {
114
+ const pct = usage.cost && usage.cost > 0 ? Math.round((r.cost / usage.cost) * 100) : 0;
115
+ const tok = r.input + r.output + r.cacheWrite;
116
+ lines.push(` ${r.role} (${r.taskCount} task${r.taskCount === 1 ? "" : "s"}): ${formatCost(r.cost)}${pct ? ` — ${pct}%` : ""}, ${formatTokens(tok)} tok, ${r.turns} turns`);
117
+ }
118
+ }
119
+ lines.push("");
120
+ lines.push("Track budget via budgetTotal/budgetWarning/budgetAbort on team run.");
121
+ return lines.join("\n");
122
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Color utilities for pi-crew UI — background tinting and ANSI parsing.
3
+ *
4
+ * Ported from pi-diff's mixBg() / autoDeriveBgFromTheme() technique:
5
+ * derive subtle status-tinted backgrounds from the theme's foreground colors
6
+ * so crew cards stay readable across light/dark themes without hardcoding RGB.
7
+ *
8
+ * mixBg(base, accent, intensity) does a LINEAR interpolation:
9
+ * result = base + (accent - base) * intensity
10
+ *
11
+ * Intensity tiers (per pi-diff research):
12
+ * 10-12% — subtle gutter/hint
13
+ * 15-18% — line background (visible but not loud)
14
+ * 30-35% — word-level emphasis (prominent)
15
+ */
16
+ import type { CrewTheme } from "./theme-adapter.ts";
17
+
18
+ // ── ANSI parsing ────────────────────────────────────────────────────────
19
+
20
+ interface Rgb { r: number; g: number; b: number; }
21
+
22
+ const ANSI_RGB_FG_RE = /^\x1b?\[?38;2;(\d+);(\d+);(\d+)m?$/;
23
+ const ANSI_RGB_BG_RE = /^\x1b?\[?48;2;(\d+);(\d+);(\d+)m?$/;
24
+
25
+ /** Parse a truecolor ANSI SGR sequence (e.g. "\x1b[38;2;255;100;50m") to RGB. */
26
+ export function parseAnsiRgb(ansi: string | undefined | null): Rgb | null {
27
+ if (!ansi) return null;
28
+ const stripped = ansi.replace(/^\x1b?\[?/, "").replace(/m?$/, "");
29
+ const fgMatch = stripped.match(/^38;2;(\d+);(\d+);(\d+)$/);
30
+ const bgMatch = stripped.match(/^48;2;(\d+);(\d+);(\d+)$/);
31
+ const m = fgMatch ?? bgMatch;
32
+ if (!m) return null;
33
+ const r = Number(m[1]);
34
+ const g = Number(m[2]);
35
+ const b = Number(m[3]);
36
+ if (![r, g, b].every(Number.isFinite)) return null;
37
+ return { r, g, b };
38
+ }
39
+
40
+ // ── Color mixing ────────────────────────────────────────────────────────
41
+
42
+ /** Linear-interpolate two RGB colors and emit a 48;2 (truecolor bg) SGR code. */
43
+ export function mixBg(base: Rgb, accent: Rgb, intensity: number): string {
44
+ const r = Math.round(base.r + (accent.r - base.r) * intensity);
45
+ const g = Math.round(base.g + (accent.g - base.g) * intensity);
46
+ const b = Math.round(base.b + (accent.b - base.b) * intensity);
47
+ return `\x1b[48;2;${r};${g};${b}m`;
48
+ }
49
+
50
+ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
51
+
52
+ // ── Theme → background derivation ───────────────────────────────────────
53
+
54
+ /**
55
+ * Resolve a subtle status-tinted background for a crew card interior.
56
+ *
57
+ * Reads the theme's fg color for the given status slot (success/error/border),
58
+ * mixes it into a dark base at low intensity (default 8%), and returns a bg SGR
59
+ * sequence. Returns "" if the theme exposes no fg ANSI (graceful — caller
60
+ * renders without background).
61
+ *
62
+ * @param theme pi-crew theme adapter
63
+ * @param statusSlot which status color to tint with ("success" | "error" | "borderAccent" | "border")
64
+ * @param intensity 0-1, how strong the tint is (default 0.08 = very subtle)
65
+ */
66
+ export function deriveCardBackground(
67
+ theme: CrewTheme,
68
+ statusSlot: "success" | "error" | "borderAccent" | "border",
69
+ intensity = 0.08,
70
+ ): string {
71
+ // Prefer the theme's bg color as base (matches the card's surroundings).
72
+ let base = BLACK;
73
+ const themeAny = theme as unknown as {
74
+ getBgAnsi?: (slot: string) => string | undefined;
75
+ getFgAnsi?: (slot: string) => string | undefined;
76
+ };
77
+ if (themeAny.getBgAnsi) {
78
+ try {
79
+ const bg = themeAny.getBgAnsi("background");
80
+ const parsed = parseAnsiRgb(bg);
81
+ if (parsed) base = parsed;
82
+ } catch { /* fall back to black */ }
83
+ }
84
+
85
+ // Get the accent from the theme's fg for the requested slot.
86
+ let accentRgb: Rgb | null = null;
87
+ if (themeAny.getFgAnsi) {
88
+ try {
89
+ accentRgb = parseAnsiRgb(themeAny.getFgAnsi(statusSlot));
90
+ } catch { /* accent stays null */ }
91
+ }
92
+ if (!accentRgb) return ""; // can't derive — render without bg tint
93
+
94
+ return mixBg(base, accentRgb, intensity);
95
+ }
96
+
97
+ // ── Helpers for padding lines with a background ─────────────────────────
98
+
99
+ const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g;
100
+ const RESET = "\x1b[0m";
101
+
102
+ /** Strip ANSI SGR codes to get visible character count. */
103
+ export function visibleWidth(text: string): number {
104
+ return text.replace(ANSI_SGR_RE, "").length;
105
+ }
106
+
107
+ /**
108
+ * Pad a line to `targetWidth` and apply `bgAnsi` as the background for the
109
+ * full visible width (including any trailing padding spaces).
110
+ *
111
+ * `bgAnsi` should be a 48;2 (truecolor bg) SGR sequence from mixBg().
112
+ * The function inserts the bg code after any existing leading SGR, fills the
113
+ * content, pads to width, then resets. This ensures the entire row's background
114
+ * is tinted — not just the characters.
115
+ */
116
+ export function padWithBackground(line: string, targetWidth: number, bgAnsi: string): string {
117
+ if (!bgAnsi) {
118
+ // No background — just space-pad (preserving any existing styling).
119
+ const width = visibleWidth(line);
120
+ return width >= targetWidth ? line : line + " ".repeat(targetWidth - width);
121
+ }
122
+ const width = visibleWidth(line);
123
+ const pad = width >= targetWidth ? "" : " ".repeat(targetWidth - width);
124
+ // Re-apply bg after a leading reset if present, otherwise prepend.
125
+ return `${bgAnsi}${line}${pad}${RESET}`;
126
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Deploy pi-crew's bundled themes to ~/.pi/agent/themes/ so Pi's theme
3
+ * loader discovers them (Pi scans that directory for custom themes).
4
+ *
5
+ * Best-effort and idempotent: compares content to avoid unnecessary writes,
6
+ * logs errors but never crashes registration.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { packageRoot, userPiRoot } from "../utils/paths.ts";
11
+
12
+ /** Directory containing pi-crew's bundled theme JSON files. */
13
+ function bundledThemesDir(): string {
14
+ return path.join(packageRoot(), "themes");
15
+ }
16
+
17
+ /** Target directory: ~/.pi/agent/themes/ (where Pi loads custom themes from). */
18
+ function customThemesDir(): string {
19
+ return path.join(userPiRoot(), "themes");
20
+ }
21
+
22
+ function readIfExists(filePath: string): string | undefined {
23
+ try {
24
+ return fs.readFileSync(filePath, "utf8");
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Copy bundled themes to ~/.pi/agent/themes/, skipping files whose content
32
+ * already matches (idempotent). Returns the number of themes deployed/updated.
33
+ *
34
+ * Safe to call on every registration. Never throws.
35
+ */
36
+ export function deployBundledThemes(): number {
37
+ try {
38
+ const srcDir = bundledThemesDir();
39
+ const dstDir = customThemesDir();
40
+
41
+ if (!fs.existsSync(srcDir)) return 0;
42
+
43
+ const files = (fs.readdirSync(srcDir) as string[]).filter((f) => f.endsWith(".json"));
44
+ if (files.length === 0) return 0;
45
+
46
+ // Ensure target directory exists
47
+ fs.mkdirSync(dstDir, { recursive: true });
48
+
49
+ let deployed = 0;
50
+ for (const file of files) {
51
+ const srcPath = path.join(srcDir, file);
52
+ const dstPath = path.join(dstDir, file);
53
+ const srcContent = fs.readFileSync(srcPath, "utf8");
54
+ const dstContent = readIfExists(dstPath);
55
+
56
+ // Skip if content matches exactly (idempotent — no churn)
57
+ if (dstContent !== undefined && dstContent === srcContent) continue;
58
+
59
+ try {
60
+ fs.writeFileSync(dstPath, srcContent, "utf8");
61
+ deployed++;
62
+ } catch {
63
+ // Individual file write failure — skip but continue
64
+ }
65
+ }
66
+
67
+ return deployed;
68
+ } catch {
69
+ return 0;
70
+ }
71
+ }