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.
@@ -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
- ### Orchestrator / phase sub-agent mode
87
+ ### Phase sub-agents (0.2.0+)
88
88
 
89
- When `/codecarto-init` runs from the Pi extension (0.1.3+), the current Pi session is recorded as the **orchestrator** for that workspace. Each subsequent `/codecarto-next`:
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
- - **Run from the orchestrator** — spawns a child Pi session pre-seeded with the phase prompt. The TUI switches to the child; phase tool calls and reasoning land in the child's context window, leaving the orchestrator clean.
92
- - **Run from inside a phase child** — switches the TUI back to the orchestrator and chains the next phase as another child, atomically.
91
+ ```
92
+ CodeCartographer
93
+ └─ ⠹ architecture phase ⟳3 · 5 tool uses · 12.3k tokens · 1m32s
94
+ ⎿ reading…
95
+ ```
93
96
 
94
- The orchestrator pointer is stored in `.codecarto/workflow/.orchestrator.local.yaml` (gitignored the file holds an absolute path into the user's Pi session storage, which is machine-local). Workspaces created by 0.1.0 0.1.2 don't have this file; the extension falls back to in-place phase prompts (the legacy behavior). Re-run `/codecarto-init` to opt in.
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
 
@@ -5,4 +5,5 @@ export * from "./status.ts";
5
5
  export * from "./pipeline.ts";
6
6
  export * from "./prompts.ts";
7
7
  export * from "./workspace.ts";
8
- export * from "./orchestrator.ts";
8
+ export * from "./orchestrator-config.ts";
9
+ export * from "./usage.ts";
@@ -8,4 +8,5 @@ export * from "./status.js";
8
8
  export * from "./pipeline.js";
9
9
  export * from "./prompts.js";
10
10
  export * from "./workspace.js";
11
- export * from "./orchestrator.js";
11
+ export * from "./orchestrator-config.js";
12
+ export * from "./usage.js";
@@ -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
+ }