codecartographer-pi 0.1.4 → 0.6.0
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/.codecarto/workflow/config.yaml +13 -0
- package/README.md +9 -6
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +2 -1
- package/dist/core/orchestrator-config.d.ts +20 -0
- package/dist/core/orchestrator-config.js +45 -0
- package/dist/core/usage.d.ts +31 -0
- package/dist/core/usage.js +94 -0
- package/dist/extensions/codecarto/agent-rewriter.d.ts +20 -0
- package/dist/extensions/codecarto/agent-rewriter.js +149 -0
- package/dist/extensions/codecarto/agent-runner.d.ts +44 -0
- package/dist/extensions/codecarto/agent-runner.js +169 -0
- package/dist/extensions/codecarto/agent-state.d.ts +35 -0
- package/dist/extensions/codecarto/agent-state.js +46 -0
- package/dist/extensions/codecarto/agent-summary.d.ts +17 -0
- package/dist/extensions/codecarto/agent-summary.js +79 -0
- package/dist/extensions/codecarto/agent-widget.d.ts +26 -0
- package/dist/extensions/codecarto/agent-widget.js +260 -0
- package/dist/extensions/codecarto/index.js +191 -78
- package/dist/extensions/codecarto/next-flags.d.ts +6 -0
- package/dist/extensions/codecarto/next-flags.js +19 -0
- package/dist/mcp-server/server.js +1 -1
- package/package.json +2 -2
- package/dist/core/orchestrator.d.ts +0 -6
- package/dist/core/orchestrator.js +0 -36
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# CodeCartographer workspace-level orchestrator configuration. Optional.
|
|
2
|
+
# Missing keys fall back to defaults defined in core/orchestrator-config.ts.
|
|
3
|
+
|
|
4
|
+
orchestrator:
|
|
5
|
+
# When true, /codecarto-next runs the orchestrator's LLM as a one-shot
|
|
6
|
+
# rewriter: it reads the previous phase's closeout + the next phase's
|
|
7
|
+
# template and produces a customized seed prompt for the sub-agent.
|
|
8
|
+
# Adds orchestrator-side tokens; off by default.
|
|
9
|
+
#
|
|
10
|
+
# Per-invocation overrides:
|
|
11
|
+
# /codecarto-next --llm-steer -> force on for this run
|
|
12
|
+
# /codecarto-next --no-llm-steer -> force off for this run
|
|
13
|
+
llm_steer_next_phase: false
|
package/README.md
CHANGED
|
@@ -84,16 +84,19 @@ What the Pi extension adds:
|
|
|
84
84
|
- tool interception that blocks `edit` and `write` outside `.codecarto/`
|
|
85
85
|
- direct phase prompts that tell Pi exactly which `.codecarto/findings/<phase>/SKILL.md` file to read, without registering those internal files as global Pi skills
|
|
86
86
|
|
|
87
|
-
###
|
|
87
|
+
### Phase sub-agents (0.2.0+)
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
`/codecarto-next` runs each phase as an isolated in-memory `AgentSession` while your TUI stays on the orchestrator session. The phase's tool calls, file reads, and reasoning live in the child's own context window — they never accumulate in the orchestrator. A persistent **Agents** widget appears above the editor while a phase is running, showing live tool count, token usage, elapsed time, and the current activity. The widget auto-clears once the phase finishes (and lingers a few seconds after for visibility).
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
```
|
|
92
|
+
● CodeCartographer
|
|
93
|
+
└─ ⠹ architecture phase ⟳3 · 5 tool uses · 12.3k tokens · 1m32s
|
|
94
|
+
⎿ reading…
|
|
95
|
+
```
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
Versions 0.1.3 – 0.1.4 used a different design — a session-switching pattern via `ctx.newSession()` that flipped the TUI to the child. That delivered context isolation but the switch was visually invisible during normal flow, so 0.2.0 replaced it with the parallel-widget approach. 0.1.x workspaces don't need migration; existing `.codecarto/` directories work with 0.2.0 unchanged.
|
|
95
98
|
|
|
96
|
-
The MCP-server path is unaffected — it has no session concept; the host (Claude Desktop / Claude Code / etc.) is the orchestrator.
|
|
99
|
+
The MCP-server path is unaffected — it has no session concept; the host (Claude Desktop / Claude Code / etc.) is always the orchestrator.
|
|
97
100
|
|
|
98
101
|
## MCP Server
|
|
99
102
|
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PathLike } from "node:fs";
|
|
2
|
+
export interface OrchestratorConfig {
|
|
3
|
+
/** When true, /codecarto-next runs an LLM rewriter to produce a seed prompt
|
|
4
|
+
* customized to the previous phase's closeout + the next phase's template
|
|
5
|
+
* before spawning the sub-agent. Off by default — extra orchestrator-side
|
|
6
|
+
* tokens, opt-in. */
|
|
7
|
+
llm_steer_next_phase: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface CodecartoConfig {
|
|
10
|
+
orchestrator: OrchestratorConfig;
|
|
11
|
+
}
|
|
12
|
+
export declare const CONFIG_RELATIVE_PATH = "workflow/config.yaml";
|
|
13
|
+
type RawConfig = {
|
|
14
|
+
orchestrator?: Partial<{
|
|
15
|
+
llm_steer_next_phase: unknown;
|
|
16
|
+
}>;
|
|
17
|
+
};
|
|
18
|
+
export declare function loadCodecartoConfig(workspaceDir: PathLike): Promise<CodecartoConfig>;
|
|
19
|
+
export declare function mergeConfig(raw: RawConfig | null | undefined): CodecartoConfig;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Workspace-level orchestrator configuration. Lives at
|
|
2
|
+
// `.codecarto/workflow/config.yaml`. Missing file or missing keys fall back
|
|
3
|
+
// to defaults, so existing workspaces created before this file existed
|
|
4
|
+
// keep working unchanged. Schema is intentionally narrow — one surface
|
|
5
|
+
// per feature, easy to grow.
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pathExists } from "./utils.js";
|
|
8
|
+
import { loadYamlFile } from "./yaml.js";
|
|
9
|
+
export const CONFIG_RELATIVE_PATH = "workflow/config.yaml";
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
orchestrator: {
|
|
12
|
+
llm_steer_next_phase: false,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export async function loadCodecartoConfig(workspaceDir) {
|
|
16
|
+
const configPath = join(workspaceDir, CONFIG_RELATIVE_PATH);
|
|
17
|
+
if (!(await pathExists(configPath)))
|
|
18
|
+
return cloneDefault();
|
|
19
|
+
try {
|
|
20
|
+
const raw = await loadYamlFile(configPath);
|
|
21
|
+
return mergeConfig(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Malformed YAML: fall back to defaults rather than failing the
|
|
25
|
+
// command. The user can fix it; a broken config shouldn't block work.
|
|
26
|
+
return cloneDefault();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function mergeConfig(raw) {
|
|
30
|
+
const merged = cloneDefault();
|
|
31
|
+
if (!raw || typeof raw !== "object")
|
|
32
|
+
return merged;
|
|
33
|
+
const o = raw.orchestrator;
|
|
34
|
+
if (o && typeof o === "object") {
|
|
35
|
+
if (typeof o.llm_steer_next_phase === "boolean") {
|
|
36
|
+
merged.orchestrator.llm_steer_next_phase = o.llm_steer_next_phase;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return merged;
|
|
40
|
+
}
|
|
41
|
+
function cloneDefault() {
|
|
42
|
+
return {
|
|
43
|
+
orchestrator: { ...DEFAULT_CONFIG.orchestrator },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
|
|
2
|
+
export type UsageRunStatus = "completed" | "aborted" | "error";
|
|
3
|
+
export interface UsageTokens {
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cache_write: number;
|
|
7
|
+
}
|
|
8
|
+
export interface UsageRun {
|
|
9
|
+
timestamp: string;
|
|
10
|
+
phase: string;
|
|
11
|
+
status: UsageRunStatus;
|
|
12
|
+
turn_count: number;
|
|
13
|
+
tool_uses: number;
|
|
14
|
+
duration_ms: number;
|
|
15
|
+
tokens: UsageTokens;
|
|
16
|
+
session_file?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface UsageFile {
|
|
19
|
+
version: number;
|
|
20
|
+
runs: UsageRun[];
|
|
21
|
+
}
|
|
22
|
+
export interface UsageTotals {
|
|
23
|
+
runs: number;
|
|
24
|
+
tokens: UsageTokens;
|
|
25
|
+
tool_uses: number;
|
|
26
|
+
duration_ms: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function loadUsage(workspaceDir: string): Promise<UsageFile>;
|
|
29
|
+
export declare function appendUsageRun(workspaceDir: string, run: UsageRun): Promise<void>;
|
|
30
|
+
export declare function computeTotals(file: UsageFile): UsageTotals;
|
|
31
|
+
export declare function computePerPhaseTotals(file: UsageFile): Map<string, UsageTotals>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Local-only phase usage log. Lives at
|
|
2
|
+
// `.codecarto/workflow/.usage.local.yaml` (gitignored). Append-only —
|
|
3
|
+
// each finished phase sub-agent contributes one entry. Totals are computed
|
|
4
|
+
// on read so the file never holds a number that contradicts the runs.
|
|
5
|
+
//
|
|
6
|
+
// Concurrency: /codecarto-next rejects re-entry on a phase that's already
|
|
7
|
+
// running, and phases run sequentially against this file, so a plain
|
|
8
|
+
// read-modify-write is safe enough. If parallel-phase dispatch ever ships,
|
|
9
|
+
// switch this to atomic-rename (see core/workspace.ts for the pattern).
|
|
10
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { pathExists } from "./utils.js";
|
|
13
|
+
import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
|
|
14
|
+
export const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
|
|
15
|
+
const SCHEMA_VERSION = 1;
|
|
16
|
+
export async function loadUsage(workspaceDir) {
|
|
17
|
+
const path = join(workspaceDir, USAGE_RELATIVE_PATH);
|
|
18
|
+
if (!(await pathExists(path)))
|
|
19
|
+
return emptyUsage();
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(path, "utf8");
|
|
22
|
+
const parsed = parseSimpleYaml(raw);
|
|
23
|
+
return normalize(parsed);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Malformed file: treat as empty rather than blocking the user. They
|
|
27
|
+
// can fix or delete the file; corrupt local state shouldn't stop work.
|
|
28
|
+
return emptyUsage();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function appendUsageRun(workspaceDir, run) {
|
|
32
|
+
const current = await loadUsage(workspaceDir);
|
|
33
|
+
current.runs.push(run);
|
|
34
|
+
const path = join(workspaceDir, USAGE_RELATIVE_PATH);
|
|
35
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
36
|
+
const serialized = `${stringifySimpleYaml(current)}\n`;
|
|
37
|
+
await writeFile(tempPath, serialized, "utf8");
|
|
38
|
+
await rename(tempPath, path);
|
|
39
|
+
}
|
|
40
|
+
export function computeTotals(file) {
|
|
41
|
+
const totals = {
|
|
42
|
+
runs: file.runs.length,
|
|
43
|
+
tokens: { input: 0, output: 0, cache_write: 0 },
|
|
44
|
+
tool_uses: 0,
|
|
45
|
+
duration_ms: 0,
|
|
46
|
+
};
|
|
47
|
+
for (const r of file.runs) {
|
|
48
|
+
totals.tokens.input += r.tokens?.input ?? 0;
|
|
49
|
+
totals.tokens.output += r.tokens?.output ?? 0;
|
|
50
|
+
totals.tokens.cache_write += r.tokens?.cache_write ?? 0;
|
|
51
|
+
totals.tool_uses += r.tool_uses ?? 0;
|
|
52
|
+
totals.duration_ms += r.duration_ms ?? 0;
|
|
53
|
+
}
|
|
54
|
+
return totals;
|
|
55
|
+
}
|
|
56
|
+
export function computePerPhaseTotals(file) {
|
|
57
|
+
const byPhase = new Map();
|
|
58
|
+
for (const r of file.runs) {
|
|
59
|
+
const t = byPhase.get(r.phase) ?? {
|
|
60
|
+
runs: 0,
|
|
61
|
+
tokens: { input: 0, output: 0, cache_write: 0 },
|
|
62
|
+
tool_uses: 0,
|
|
63
|
+
duration_ms: 0,
|
|
64
|
+
};
|
|
65
|
+
t.runs += 1;
|
|
66
|
+
t.tokens.input += r.tokens?.input ?? 0;
|
|
67
|
+
t.tokens.output += r.tokens?.output ?? 0;
|
|
68
|
+
t.tokens.cache_write += r.tokens?.cache_write ?? 0;
|
|
69
|
+
t.tool_uses += r.tool_uses ?? 0;
|
|
70
|
+
t.duration_ms += r.duration_ms ?? 0;
|
|
71
|
+
byPhase.set(r.phase, t);
|
|
72
|
+
}
|
|
73
|
+
return byPhase;
|
|
74
|
+
}
|
|
75
|
+
function emptyUsage() {
|
|
76
|
+
return { version: SCHEMA_VERSION, runs: [] };
|
|
77
|
+
}
|
|
78
|
+
function normalize(raw) {
|
|
79
|
+
if (!raw || typeof raw !== "object")
|
|
80
|
+
return emptyUsage();
|
|
81
|
+
const runs = Array.isArray(raw.runs) ? raw.runs.filter(isUsageRun) : [];
|
|
82
|
+
return {
|
|
83
|
+
version: typeof raw.version === "number" ? raw.version : SCHEMA_VERSION,
|
|
84
|
+
runs,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function isUsageRun(x) {
|
|
88
|
+
if (!x || typeof x !== "object")
|
|
89
|
+
return false;
|
|
90
|
+
const r = x;
|
|
91
|
+
return (typeof r.timestamp === "string" &&
|
|
92
|
+
typeof r.phase === "string" &&
|
|
93
|
+
typeof r.status === "string");
|
|
94
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type WorkspaceState } from "../../core/index.ts";
|
|
3
|
+
export interface RewritePhasePromptInput {
|
|
4
|
+
ctx: ExtensionContext;
|
|
5
|
+
state: WorkspaceState;
|
|
6
|
+
originalPrompt: string;
|
|
7
|
+
nextPhaseId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RewritePhasePromptResult {
|
|
10
|
+
prompt: string;
|
|
11
|
+
used: boolean;
|
|
12
|
+
skipReason?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run the rewriter and return the customized seed prompt. On any failure
|
|
16
|
+
* (no prior phase, missing closeout, rewriter session error, empty output)
|
|
17
|
+
* returns the original prompt with `used: false` and a skip reason — never
|
|
18
|
+
* throws. The caller decides what to surface to the user.
|
|
19
|
+
*/
|
|
20
|
+
export declare function rewritePhasePrompt(input: RewritePhasePromptInput): Promise<RewritePhasePromptResult>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Optional LLM-steered prompt rewriter for /codecarto-next.
|
|
2
|
+
//
|
|
3
|
+
// When enabled (workspace config `orchestrator.llm_steer_next_phase: true`,
|
|
4
|
+
// or per-invocation `--llm-steer`), this runs a one-shot in-memory
|
|
5
|
+
// AgentSession on the orchestrator's model with no tools. It receives the
|
|
6
|
+
// stock phase prompt + the previous phase's closeout (if any) and returns
|
|
7
|
+
// a customized seed prompt that acknowledges the prior findings.
|
|
8
|
+
//
|
|
9
|
+
// Off by default. The orchestrator's tokens stay yours unless you opt in.
|
|
10
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { closeoutFileName, pathExists } from "../../core/index.js";
|
|
14
|
+
/** Closeout content over this many bytes is truncated before being passed to
|
|
15
|
+
* the rewriter. Keeps the orchestrator-side cost predictable. */
|
|
16
|
+
const CLOSEOUT_BYTE_BUDGET = 8000;
|
|
17
|
+
/**
|
|
18
|
+
* Run the rewriter and return the customized seed prompt. On any failure
|
|
19
|
+
* (no prior phase, missing closeout, rewriter session error, empty output)
|
|
20
|
+
* returns the original prompt with `used: false` and a skip reason — never
|
|
21
|
+
* throws. The caller decides what to surface to the user.
|
|
22
|
+
*/
|
|
23
|
+
export async function rewritePhasePrompt(input) {
|
|
24
|
+
const { ctx, state, originalPrompt, nextPhaseId } = input;
|
|
25
|
+
const prevPhaseId = findPreviousPhaseId(state, nextPhaseId);
|
|
26
|
+
if (!prevPhaseId) {
|
|
27
|
+
return { prompt: originalPrompt, used: false, skipReason: "no previous phase to steer from" };
|
|
28
|
+
}
|
|
29
|
+
const closeout = await readLatestCloseout(state.workspaceDir, prevPhaseId);
|
|
30
|
+
if (!closeout) {
|
|
31
|
+
return { prompt: originalPrompt, used: false, skipReason: `no closeout found for ${prevPhaseId}` };
|
|
32
|
+
}
|
|
33
|
+
const rewriterPrompt = buildRewriterPrompt({
|
|
34
|
+
nextPhaseId,
|
|
35
|
+
prevPhaseId,
|
|
36
|
+
originalPrompt,
|
|
37
|
+
prevCloseout: closeout,
|
|
38
|
+
});
|
|
39
|
+
let customized;
|
|
40
|
+
try {
|
|
41
|
+
customized = await runRewriterOnce(ctx, rewriterPrompt);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
return { prompt: originalPrompt, used: false, skipReason: `rewriter session failed: ${message}` };
|
|
46
|
+
}
|
|
47
|
+
const trimmed = customized.trim();
|
|
48
|
+
if (!trimmed) {
|
|
49
|
+
return { prompt: originalPrompt, used: false, skipReason: "rewriter returned empty output" };
|
|
50
|
+
}
|
|
51
|
+
return { prompt: trimmed, used: true };
|
|
52
|
+
}
|
|
53
|
+
function findPreviousPhaseId(state, nextPhaseId) {
|
|
54
|
+
const order = state.pipeline.phase_order;
|
|
55
|
+
const idx = order.indexOf(nextPhaseId);
|
|
56
|
+
if (idx <= 0)
|
|
57
|
+
return undefined;
|
|
58
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
59
|
+
if (state.status.phases[order[i]]?.status === "complete")
|
|
60
|
+
return order[i];
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
async function readLatestCloseout(workspaceDir, phaseId) {
|
|
65
|
+
const closeoutsDir = join(workspaceDir, "closeouts");
|
|
66
|
+
if (!(await pathExists(closeoutsDir)))
|
|
67
|
+
return undefined;
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await readdir(closeoutsDir);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const suffix = closeoutFileName("", phaseId).replace(/^-/, ""); // "<phaseId>.md"
|
|
76
|
+
const matches = entries.filter((name) => name.endsWith(`-${suffix}`)).sort();
|
|
77
|
+
const latest = matches.at(-1);
|
|
78
|
+
if (!latest)
|
|
79
|
+
return undefined;
|
|
80
|
+
const raw = await readFile(join(closeoutsDir, latest), "utf8");
|
|
81
|
+
if (raw.length <= CLOSEOUT_BYTE_BUDGET)
|
|
82
|
+
return raw;
|
|
83
|
+
return `${raw.slice(0, CLOSEOUT_BYTE_BUDGET)}\n\n[…closeout truncated for rewriter input…]`;
|
|
84
|
+
}
|
|
85
|
+
function buildRewriterPrompt(input) {
|
|
86
|
+
return [
|
|
87
|
+
`You are seeding a sub-agent for the \`${input.nextPhaseId}\` phase of a CodeCartographer pipeline.`,
|
|
88
|
+
`The previous phase \`${input.prevPhaseId}\` just completed. Read its closeout below and produce a CUSTOMIZED seed prompt for the next phase's sub-agent.`,
|
|
89
|
+
"",
|
|
90
|
+
"Constraints for the customized prompt:",
|
|
91
|
+
`- Stay faithful to the original ${input.nextPhaseId} phase template; do not change its structure, required outputs, or completion criteria.`,
|
|
92
|
+
`- Add a short \"context from ${input.prevPhaseId}\" preamble that names the specific findings, open questions, or carry-forward items from the closeout that the ${input.nextPhaseId} phase should pay attention to.`,
|
|
93
|
+
"- Do not invent findings the closeout does not state.",
|
|
94
|
+
"- Do not add commentary directed at the user; the output is the sub-agent's seed prompt.",
|
|
95
|
+
"",
|
|
96
|
+
"Output ONLY the customized seed prompt as plain Markdown. No preface, no fenced code blocks, no commentary.",
|
|
97
|
+
"",
|
|
98
|
+
"=== ORIGINAL NEXT-PHASE PROMPT ===",
|
|
99
|
+
input.originalPrompt,
|
|
100
|
+
"=== END ORIGINAL NEXT-PHASE PROMPT ===",
|
|
101
|
+
"",
|
|
102
|
+
`=== PREVIOUS PHASE (${input.prevPhaseId}) CLOSEOUT ===`,
|
|
103
|
+
input.prevCloseout,
|
|
104
|
+
`=== END PREVIOUS PHASE (${input.prevPhaseId}) CLOSEOUT ===`,
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
async function runRewriterOnce(ctx, prompt) {
|
|
108
|
+
const cwd = ctx.cwd;
|
|
109
|
+
const agentDir = getAgentDir();
|
|
110
|
+
const loader = new DefaultResourceLoader({
|
|
111
|
+
cwd,
|
|
112
|
+
agentDir,
|
|
113
|
+
// Even more stripped than the phase runner: no extensions either,
|
|
114
|
+
// since the rewriter has no tools and shouldn't pick up codecarto's
|
|
115
|
+
// tool-interception (it has no tools to intercept).
|
|
116
|
+
noExtensions: true,
|
|
117
|
+
noSkills: true,
|
|
118
|
+
noPromptTemplates: true,
|
|
119
|
+
noThemes: true,
|
|
120
|
+
noContextFiles: true,
|
|
121
|
+
});
|
|
122
|
+
await loader.reload();
|
|
123
|
+
const { session } = await createAgentSession({
|
|
124
|
+
cwd,
|
|
125
|
+
agentDir,
|
|
126
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
127
|
+
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
128
|
+
modelRegistry: ctx.modelRegistry,
|
|
129
|
+
model: ctx.model,
|
|
130
|
+
tools: [],
|
|
131
|
+
resourceLoader: loader,
|
|
132
|
+
});
|
|
133
|
+
await session.prompt(prompt);
|
|
134
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
135
|
+
const msg = session.messages[i];
|
|
136
|
+
if (msg.role !== "assistant")
|
|
137
|
+
continue;
|
|
138
|
+
const blocks = msg.content;
|
|
139
|
+
const parts = [];
|
|
140
|
+
for (const c of blocks) {
|
|
141
|
+
if (c.type === "text" && c.text)
|
|
142
|
+
parts.push(c.text);
|
|
143
|
+
}
|
|
144
|
+
const joined = parts.join("\n").trim();
|
|
145
|
+
if (joined)
|
|
146
|
+
return joined;
|
|
147
|
+
}
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type AgentSession, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
export interface PhaseRunCallbacks {
|
|
3
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
4
|
+
onToolStart?: (toolCallId: string, toolName: string) => void;
|
|
5
|
+
onToolEnd?: (toolCallId: string, toolName: string) => void;
|
|
6
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
7
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
8
|
+
onMessageEnd?: (usage: {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheWrite: number;
|
|
12
|
+
}) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface PhaseRunOptions {
|
|
15
|
+
/** Display name written via appendSessionInfo so the session shows up in
|
|
16
|
+
* /resume's picker as e.g. "CodeCartographer phase: blueprint". Pi reads
|
|
17
|
+
* it via SessionManager.getSessionName(). */
|
|
18
|
+
sessionName?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PhaseRunResult {
|
|
21
|
+
session: AgentSession;
|
|
22
|
+
responseText: string;
|
|
23
|
+
toolUses: number;
|
|
24
|
+
turnCount: number;
|
|
25
|
+
aborted: boolean;
|
|
26
|
+
/** Path to the on-disk session file (under ~/.pi/agent/sessions/<encoded-cwd>/).
|
|
27
|
+
* Stable across the run; useful for /codecarto-usage and any future tooling
|
|
28
|
+
* that wants to point at the phase's transcript. */
|
|
29
|
+
sessionFile: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run one CodeCartographer phase as an isolated AgentSession. Awaiting this
|
|
33
|
+
* function blocks until the phase completes (or aborts via signal). The
|
|
34
|
+
* orchestrator's TUI stays active throughout — only the phase's own context
|
|
35
|
+
* window holds the tool calls and reasoning.
|
|
36
|
+
*
|
|
37
|
+
* The phase session is **persisted** to the default Pi session directory
|
|
38
|
+
* (`~/.pi/agent/sessions/<encoded-cwd>/`), the same directory the orchestrator
|
|
39
|
+
* uses, so Pi's `/resume`, `/tree`, and `/export` see phase transcripts as
|
|
40
|
+
* first-class sessions. They're tagged via `appendSessionInfo` (display name)
|
|
41
|
+
* and `parentSession` (the orchestrator's session file path) so the picker
|
|
42
|
+
* shows lineage.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runPhase(ctx: ExtensionContext, prompt: string, callbacks?: PhaseRunCallbacks, options?: PhaseRunOptions, signal?: AbortSignal): Promise<PhaseRunResult>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Sub-agent runner for codecarto phases. Spawns an in-memory AgentSession
|
|
2
|
+
// using the SDK's createAgentSession() (NOT ctx.newSession() — that replaces
|
|
3
|
+
// the active TUI session, which is not what we want). Subscribes to the
|
|
4
|
+
// session's event stream and forwards events to caller-provided callbacks
|
|
5
|
+
// so a parent UI (the agents widget; M2) can render live progress while the
|
|
6
|
+
// phase runs in parallel with the orchestrator.
|
|
7
|
+
//
|
|
8
|
+
// The runner is intentionally minimal: no memory tools, no append-mode
|
|
9
|
+
// system prompt, no parent-context inheritance, no turn-limit grace logic.
|
|
10
|
+
// Codecarto phases are bounded by their phase prompt and validation gate;
|
|
11
|
+
// they don't need the full subagent-framework machinery.
|
|
12
|
+
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
// Tools available to the phase sub-agent. Matches the codecarto interception
|
|
14
|
+
// allowlist (SAFE_TOOL_NAMES in extensions/codecarto/index.ts), minus bash.
|
|
15
|
+
// Phases analyze source code and write findings; they don't need a shell.
|
|
16
|
+
const PHASE_TOOL_NAMES = ["read", "edit", "write", "grep", "find", "ls"];
|
|
17
|
+
/**
|
|
18
|
+
* Run one CodeCartographer phase as an isolated AgentSession. Awaiting this
|
|
19
|
+
* function blocks until the phase completes (or aborts via signal). The
|
|
20
|
+
* orchestrator's TUI stays active throughout — only the phase's own context
|
|
21
|
+
* window holds the tool calls and reasoning.
|
|
22
|
+
*
|
|
23
|
+
* The phase session is **persisted** to the default Pi session directory
|
|
24
|
+
* (`~/.pi/agent/sessions/<encoded-cwd>/`), the same directory the orchestrator
|
|
25
|
+
* uses, so Pi's `/resume`, `/tree`, and `/export` see phase transcripts as
|
|
26
|
+
* first-class sessions. They're tagged via `appendSessionInfo` (display name)
|
|
27
|
+
* and `parentSession` (the orchestrator's session file path) so the picker
|
|
28
|
+
* shows lineage.
|
|
29
|
+
*/
|
|
30
|
+
export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal) {
|
|
31
|
+
const cwd = ctx.cwd;
|
|
32
|
+
const agentDir = getAgentDir();
|
|
33
|
+
// Resource loader: load Pi extensions and skills (so codecarto's own tool
|
|
34
|
+
// interception applies to the child) but skip prompt templates, themes,
|
|
35
|
+
// and project context files — they'd just bloat the system prompt.
|
|
36
|
+
const loader = new DefaultResourceLoader({
|
|
37
|
+
cwd,
|
|
38
|
+
agentDir,
|
|
39
|
+
noExtensions: false,
|
|
40
|
+
noSkills: false,
|
|
41
|
+
noPromptTemplates: true,
|
|
42
|
+
noThemes: true,
|
|
43
|
+
noContextFiles: true,
|
|
44
|
+
});
|
|
45
|
+
await loader.reload();
|
|
46
|
+
// File-backed session in the same directory the orchestrator's TUI uses.
|
|
47
|
+
// Pi's /resume, /tree, and /export read this directory, so phase
|
|
48
|
+
// transcripts become first-class browsable artifacts. Tag with
|
|
49
|
+
// parentSession (orchestrator's file) for lineage and a session_info
|
|
50
|
+
// display name so the picker can identify them at a glance.
|
|
51
|
+
const sessionManager = SessionManager.create(cwd);
|
|
52
|
+
const orchestratorSessionFile = ctx.sessionManager.getSessionFile();
|
|
53
|
+
if (orchestratorSessionFile) {
|
|
54
|
+
// SessionManager.create() calls newSession() with no options in its
|
|
55
|
+
// constructor; rewrite the header to attach the parent before the
|
|
56
|
+
// session ever flushes to disk.
|
|
57
|
+
sessionManager.newSession({ parentSession: orchestratorSessionFile });
|
|
58
|
+
}
|
|
59
|
+
if (options.sessionName) {
|
|
60
|
+
sessionManager.appendSessionInfo(options.sessionName);
|
|
61
|
+
}
|
|
62
|
+
const { session } = await createAgentSession({
|
|
63
|
+
cwd,
|
|
64
|
+
agentDir,
|
|
65
|
+
sessionManager,
|
|
66
|
+
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
67
|
+
modelRegistry: ctx.modelRegistry,
|
|
68
|
+
model: ctx.model,
|
|
69
|
+
tools: PHASE_TOOL_NAMES,
|
|
70
|
+
resourceLoader: loader,
|
|
71
|
+
});
|
|
72
|
+
await session.bindExtensions({});
|
|
73
|
+
callbacks.onSessionCreated?.(session);
|
|
74
|
+
let toolUses = 0;
|
|
75
|
+
let turnCount = 0;
|
|
76
|
+
let currentMessageText = "";
|
|
77
|
+
let aborted = false;
|
|
78
|
+
const unsubscribe = session.subscribe((event) => {
|
|
79
|
+
switch (event.type) {
|
|
80
|
+
case "tool_execution_start": {
|
|
81
|
+
toolUses++;
|
|
82
|
+
const id = event.toolCallId ?? `${event.toolName}-${toolUses}`;
|
|
83
|
+
callbacks.onToolStart?.(id, event.toolName);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "tool_execution_end": {
|
|
87
|
+
const id = event.toolCallId ?? `${event.toolName}-${toolUses}`;
|
|
88
|
+
callbacks.onToolEnd?.(id, event.toolName);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "turn_end": {
|
|
92
|
+
turnCount++;
|
|
93
|
+
callbacks.onTurnEnd?.(turnCount);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "message_start": {
|
|
97
|
+
currentMessageText = "";
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "message_update": {
|
|
101
|
+
if (event.assistantMessageEvent?.type === "text_delta") {
|
|
102
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
103
|
+
callbacks.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "message_end": {
|
|
108
|
+
if (event.message.role === "assistant") {
|
|
109
|
+
const u = event.message.usage;
|
|
110
|
+
if (u) {
|
|
111
|
+
callbacks.onMessageEnd?.({
|
|
112
|
+
input: u.input ?? 0,
|
|
113
|
+
output: u.output ?? 0,
|
|
114
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
let abortCleanup = () => { };
|
|
123
|
+
if (signal) {
|
|
124
|
+
const onAbort = () => {
|
|
125
|
+
aborted = true;
|
|
126
|
+
session.abort();
|
|
127
|
+
};
|
|
128
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
129
|
+
abortCleanup = () => signal.removeEventListener("abort", onAbort);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await session.prompt(prompt);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
unsubscribe();
|
|
136
|
+
abortCleanup();
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
session,
|
|
140
|
+
responseText: getLastAssistantText(session) || currentMessageText,
|
|
141
|
+
toolUses,
|
|
142
|
+
turnCount,
|
|
143
|
+
aborted,
|
|
144
|
+
sessionFile: sessionManager.getSessionFile(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Walk session.messages backward to find the last non-empty assistant text.
|
|
149
|
+
* Used as a fallback when text_delta streaming missed something or the final
|
|
150
|
+
* message arrived in a single chunk.
|
|
151
|
+
*/
|
|
152
|
+
function getLastAssistantText(session) {
|
|
153
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
154
|
+
const msg = session.messages[i];
|
|
155
|
+
if (msg.role !== "assistant")
|
|
156
|
+
continue;
|
|
157
|
+
// Assistant content is always a content-block array per SDK types.
|
|
158
|
+
const blocks = msg.content;
|
|
159
|
+
const parts = [];
|
|
160
|
+
for (const c of blocks) {
|
|
161
|
+
if (c.type === "text" && c.text)
|
|
162
|
+
parts.push(c.text);
|
|
163
|
+
}
|
|
164
|
+
const joined = parts.join("\n").trim();
|
|
165
|
+
if (joined)
|
|
166
|
+
return joined;
|
|
167
|
+
}
|
|
168
|
+
return "";
|
|
169
|
+
}
|