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.
- package/CHANGELOG.md +62 -0
- package/README.md +68 -0
- package/package.json +2 -1
- package/src/errors.ts +20 -2
- package/src/extension/knowledge-injection.ts +71 -0
- package/src/extension/pi-api.ts +47 -0
- package/src/extension/register.ts +19 -6
- package/src/extension/registration/commands.ts +40 -1
- package/src/extension/registration/compaction-guard.ts +154 -14
- package/src/extension/team-tool/handle-settings.ts +57 -0
- package/src/extension/team-tool/inspect.ts +4 -1
- package/src/extension/team-tool/plan.ts +8 -1
- package/src/runtime/intercom-bridge.ts +5 -1
- package/src/runtime/replace.ts +555 -0
- package/src/runtime/resilient-edit.ts +166 -0
- package/src/runtime/single-agent-compose.ts +87 -0
- package/src/runtime/task-runner/prompt-builder.ts +6 -0
- package/src/runtime/team-runner.ts +24 -7
- package/src/schema/team-tool-schema.ts +6 -0
- package/src/state/usage.ts +73 -0
- package/src/ui/card-colors.ts +126 -0
- package/src/ui/deploy-bundled-themes.ts +71 -0
- package/src/ui/render-diff.ts +37 -3
- package/src/ui/settings-overlay.ts +70 -7
- package/src/ui/syntax-highlight.ts +42 -23
- package/src/ui/theme-discovery.ts +188 -0
- package/src/ui/tool-renderers/index.ts +27 -14
- package/themes/crew-catppuccin-latte.json +96 -0
- package/themes/crew-catppuccin-mocha.json +93 -0
- package/themes/crew-dark.json +90 -0
- package/themes/crew-dracula.json +83 -0
- package/themes/crew-gruvbox-dark.json +83 -0
- package/themes/crew-gruvbox-light.json +90 -0
- package/themes/crew-nord.json +85 -0
- package/themes/crew-one-dark.json +89 -0
- package/themes/crew-solarized-dark.json +90 -0
- package/themes/crew-solarized-light.json +92 -0
- package/themes/crew-tokyo-night.json +85 -0
- package/src/extension/registration/brief-tool-overrides.ts +0 -400
- package/src/runtime/budget-tracker.ts +0 -354
- 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(
|
|
346
|
-
|
|
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:
|
|
370
|
-
planArtifactPath:
|
|
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:
|
|
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;
|
package/src/state/usage.ts
CHANGED
|
@@ -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
|
+
}
|