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.
@@ -1,9 +1,52 @@
1
1
  import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, join, resolve } from "node:path";
3
- import { buildPhasePrompt, buildSkillPrompt, buildThreadLogEntry, buildValidationSummary, canonicalPath, closeoutFileName, createEmptyStatus, dateOnly, DEFAULT_PIPELINE_PATH, ensureCloseoutStub, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, listSkillNames, loadOrchestratorState, loadYamlFile, normalizeForComparison, normalizeStatus, packagedWorkspaceDir, pathExists, PIPELINE_ALIASES, resolvePhase, resolvePipelineChoice, stringifySimpleYaml, uniqueStrings, updateStatusAtomically, validatePhaseOutput, writeOrchestratorState, } from "../../core/index.js";
3
+ import { runPhase } from "./agent-runner.js";
4
+ import { rewritePhasePrompt } from "./agent-rewriter.js";
5
+ import { clearPhase, finishPhase, getPhaseActivity, startPhase } from "./agent-state.js";
6
+ import { buildPhaseSummary } from "./agent-summary.js";
7
+ import { disposeAgentsWidget, getAgentsWidget } from "./agent-widget.js";
8
+ import { parseNextFlags } from "./next-flags.js";
9
+ import { appendUsageRun, buildPhasePrompt, buildSkillPrompt, buildThreadLogEntry, buildValidationSummary, canonicalPath, closeoutFileName, computePerPhaseTotals, computeTotals, createEmptyStatus, dateOnly, DEFAULT_PIPELINE_PATH, ensureCloseoutStub, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, normalizeStatus, packagedWorkspaceDir, pathExists, PIPELINE_ALIASES, resolvePhase, resolvePipelineChoice, stringifySimpleYaml, uniqueStrings, updateStatusAtomically, validatePhaseOutput, } from "../../core/index.js";
4
10
  const STATUS_WIDGET_ID = "codecarto-widget";
5
11
  const STATUS_LINE_ID = "codecarto-status";
6
12
  const SAFE_TOOL_NAMES = ["read", "grep", "find", "ls", "edit", "write"];
13
+ async function recordUsage(workspaceDir, phaseId, status, activity) {
14
+ try {
15
+ await appendUsageRun(workspaceDir, {
16
+ timestamp: new Date().toISOString(),
17
+ phase: phaseId,
18
+ status,
19
+ turn_count: activity.turnCount,
20
+ tool_uses: activity.toolUses,
21
+ duration_ms: (activity.completedAt ?? Date.now()) - activity.startedAt,
22
+ tokens: {
23
+ input: activity.lifetimeUsage.input,
24
+ output: activity.lifetimeUsage.output,
25
+ cache_write: activity.lifetimeUsage.cacheWrite,
26
+ },
27
+ });
28
+ }
29
+ catch {
30
+ // Local usage logging is best-effort. A full disk or permission
31
+ // failure shouldn't surface as a phase error to the user.
32
+ }
33
+ }
34
+ function formatUsageTokens(count) {
35
+ if (count >= 1_000_000)
36
+ return `${(count / 1_000_000).toFixed(2)}M`;
37
+ if (count >= 1_000)
38
+ return `${(count / 1_000).toFixed(1)}k`;
39
+ return `${count}`;
40
+ }
41
+ function formatUsageDuration(ms) {
42
+ if (ms < 1000)
43
+ return `${ms}ms`;
44
+ if (ms < 60_000)
45
+ return `${(ms / 1000).toFixed(1)}s`;
46
+ const minutes = Math.floor(ms / 60_000);
47
+ const seconds = Math.floor((ms % 60_000) / 1000);
48
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
49
+ }
7
50
  function buildStatusLines(state, extraLines = []) {
8
51
  const nextPhase = getNextEligiblePhase(state);
9
52
  const currentPhase = nextPhase?.id ?? state.status.current_phase ?? "complete";
@@ -79,6 +122,11 @@ export default function codeCartographerExtension(pi) {
79
122
  return;
80
123
  pi.setActiveTools(SAFE_TOOL_NAMES);
81
124
  });
125
+ pi.on("session_shutdown", async () => {
126
+ // Tear down the persistent agents widget so we don't leak the timer
127
+ // or render against a torn-down UI context after a session swap.
128
+ disposeAgentsWidget();
129
+ });
82
130
  pi.on("agent_end", async (_event, ctx) => {
83
131
  await refreshWorkspaceUi(ctx);
84
132
  });
@@ -153,21 +201,6 @@ export default function codeCartographerExtension(pi) {
153
201
  normalizedStatus.last_updated = new Date().toISOString();
154
202
  await writeFile(rawStatusPath, `${stringifySimpleYaml(normalizedStatus)}\n`, "utf8");
155
203
  lastFeedbackLines = [`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`];
156
- // Claim the current Pi session as the orchestrator for this workspace.
157
- // /codecarto-next will then spawn each phase as a child session, keeping
158
- // the orchestrator's context window clean. The pointer is gitignored
159
- // (workflow/.orchestrator.local.yaml) so it's machine-local. If we have
160
- // no session file (rare; Pi running headless), skip silently — handlers
161
- // fall back to in-place phase prompts.
162
- const orchestratorSessionFile = ctx.sessionManager.getSessionFile();
163
- const orchestratorSessionId = ctx.sessionManager.getSessionId();
164
- if (orchestratorSessionFile && orchestratorSessionId) {
165
- await writeOrchestratorState(ctx.cwd, {
166
- sessionFile: orchestratorSessionFile,
167
- sessionId: orchestratorSessionId,
168
- });
169
- lastFeedbackLines.push(`Claimed this session as the orchestrator (${orchestratorSessionId.slice(0, 8)}…).`);
170
- }
171
204
  ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)})`, "info");
172
205
  await ctx.reload();
173
206
  return;
@@ -186,8 +219,19 @@ export default function codeCartographerExtension(pi) {
186
219
  },
187
220
  });
188
221
  pi.registerCommand("codecarto-next", {
189
- description: "Queue the next eligible CodeCartographer phase prompt",
190
- handler: async (_args, ctx) => {
222
+ description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer",
223
+ getArgumentCompletions: (prefix) => {
224
+ const items = ["--llm-steer", "--no-llm-steer"]
225
+ .filter((value) => value.startsWith(prefix))
226
+ .map((value) => ({ value, label: value }));
227
+ return items.length > 0 ? items : null;
228
+ },
229
+ handler: async (args, ctx) => {
230
+ const flags = parseNextFlags(args);
231
+ if (flags.unknown.length > 0) {
232
+ ctx.ui.notify(`Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
233
+ return;
234
+ }
191
235
  const state = await ensureWorkspaceState(ctx);
192
236
  if (!state)
193
237
  return;
@@ -198,71 +242,110 @@ export default function codeCartographerExtension(pi) {
198
242
  ctx.ui.notify("All CodeCartographer phases are complete.", "info");
199
243
  return;
200
244
  }
201
- const prompt = await buildPhasePrompt(state, phase, false);
202
- const orchestrator = await loadOrchestratorState(ctx.cwd);
203
- const currentSessionFile = ctx.sessionManager.getSessionFile();
204
- // Legacy path: workspace has no orchestrator pointer (created by 0.1.2
205
- // or earlier, or Pi has no session file). Queue the prompt in-place
206
- // exactly as we did before sub-agent mode existed.
207
- if (!orchestrator || !currentSessionFile) {
208
- if (ctx.isIdle())
209
- pi.sendUserMessage(prompt);
210
- else
211
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
212
- lastFeedbackLines = [`Queued phase prompt for ${phase.id} (in-place; run /codecarto-init to enable sub-agent mode)`];
213
- setUiState(ctx, state, lastFeedbackLines);
214
- ctx.ui.notify(`Queued CodeCartographer phase: ${phase.id}`, "info");
245
+ // Reject re-entry: don't spawn a duplicate runner for a phase that's
246
+ // already in flight from a previous /codecarto-next invocation.
247
+ const existing = getPhaseActivity(phase.id);
248
+ if (existing && existing.status === "running") {
249
+ ctx.ui.notify(`Phase ${phase.id} is already running.`, "warning");
215
250
  return;
216
251
  }
217
- // Orchestrator path: we're the parent. Spawn a child session for the
218
- // phase so its tool calls and reasoning land in an isolated context.
219
- //
220
- // SDK contract: after `ctx.newSession()` resolves, `ctx` is invalidated
221
- // — touching `ctx.ui` or `ctx.sessionManager` from the outer scope
222
- // raises "extension ctx is stale after session replacement". So all
223
- // outer-session work (status line, widget) has to happen BEFORE the
224
- // call, and any work that needs a fresh ctx happens INSIDE the
225
- // `withSession` callback against the new session's ctx.
226
- if (currentSessionFile === orchestrator.sessionFile) {
227
- lastFeedbackLines = [`Spawned ${phase.id} phase in a sub-agent`];
228
- setUiState(ctx, state, lastFeedbackLines);
229
- ctx.ui.notify(`CodeCartographer phase: ${phase.id} (sub-agent)`, "info");
230
- await ctx.newSession({
231
- parentSession: orchestrator.sessionFile,
232
- withSession: async (childCtx) => {
233
- childCtx.sendUserMessage(prompt);
234
- },
235
- });
236
- return;
252
+ const config = await loadCodecartoConfig(state.workspaceDir);
253
+ const llmSteerEnabled = flags.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
254
+ let prompt = await buildPhasePrompt(state, phase, false);
255
+ if (llmSteerEnabled) {
256
+ ctx.ui.notify(`Customizing ${phase.id} prompt via LLM rewriter…`, "info");
257
+ const rewrite = await rewritePhasePrompt({ ctx, state, originalPrompt: prompt, nextPhaseId: phase.id });
258
+ if (rewrite.used) {
259
+ prompt = rewrite.prompt;
260
+ ctx.ui.notify(`LLM rewriter customized ${phase.id} seed prompt.`, "info");
261
+ }
262
+ else {
263
+ ctx.ui.notify(`LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
264
+ }
237
265
  }
238
- // Phase-child path: switch the TUI back to the orchestrator and chain
239
- // the next phase atomically. Same staleness rule: pre-switch work uses
240
- // `ctx`; post-switch work uses the fresh `orchestratorCtx` passed to
241
- // `withSession`. Inside that callback the inner `newSession()` again
242
- // invalidates `orchestratorCtx`, so the inner spawn must be the last
243
- // thing the callback does.
244
- lastFeedbackLines = ["Returning to orchestrator and queuing next phase as a sub-agent"];
266
+ const activity = startPhase(phase.id);
267
+ lastFeedbackLines = [`Running ${phase.id} phase as sub-agent`];
245
268
  setUiState(ctx, state, lastFeedbackLines);
246
- await ctx.switchSession(orchestrator.sessionFile, {
247
- withSession: async (orchestratorCtx) => {
248
- const freshState = await getWorkspaceState(orchestratorCtx.cwd);
249
- if (!freshState) {
250
- orchestratorCtx.ui.notify("Workspace state not found after switch.", "error");
251
- return;
252
- }
253
- const nextPhase = getNextEligiblePhase(freshState);
254
- if (!nextPhase) {
255
- orchestratorCtx.ui.notify("All CodeCartographer phases are complete.", "info");
256
- return;
257
- }
258
- const nextPrompt = await buildPhasePrompt(freshState, nextPhase, false);
259
- await orchestratorCtx.newSession({
260
- parentSession: orchestrator.sessionFile,
261
- withSession: async (nextChildCtx) => {
262
- nextChildCtx.sendUserMessage(nextPrompt);
263
- },
264
- });
269
+ ctx.ui.notify(`CodeCartographer phase: ${phase.id} (sub-agent running)`, "info");
270
+ // Attach the persistent "Agents" widget so the user can watch the
271
+ // phase's tool/turn/token counts live above the editor while the
272
+ // orchestrator's TUI stays responsive.
273
+ getAgentsWidget().attach(ctx.ui);
274
+ // Fire-and-forget: spawn the phase runner asynchronously so the
275
+ // orchestrator's TUI stays responsive. The runner mutates the shared
276
+ // agent-state map from event callbacks; M2 will read that map from a
277
+ // persistent widget. For M1 we just notify on completion.
278
+ void runPhase(ctx, prompt, {
279
+ onSessionCreated: (session) => { activity.session = session; },
280
+ onToolStart: (id, name) => { activity.activeTools.set(id, name); activity.toolUses++; },
281
+ onToolEnd: (id) => { activity.activeTools.delete(id); },
282
+ onTextDelta: (_delta, fullText) => { activity.responseText = fullText; },
283
+ onTurnEnd: (turnCount) => { activity.turnCount = turnCount; },
284
+ onMessageEnd: (usage) => {
285
+ activity.lifetimeUsage.input += usage.input;
286
+ activity.lifetimeUsage.output += usage.output;
287
+ activity.lifetimeUsage.cacheWrite += usage.cacheWrite;
265
288
  },
289
+ }, { sessionName: `CodeCartographer phase: ${phase.id}` })
290
+ .then((result) => {
291
+ const status = result.aborted ? "aborted" : "completed";
292
+ finishPhase(phase.id, { status });
293
+ if (ctx.hasUI) {
294
+ ctx.ui.notify(result.aborted
295
+ ? `Phase ${phase.id} aborted.`
296
+ : `Phase ${phase.id} sub-agent finished (${result.toolUses} tool uses, ${result.turnCount} turns).`, result.aborted ? "warning" : "info");
297
+ }
298
+ // Inject a CustomMessageEntry into the orchestrator's session so
299
+ // the user sees a closeout summary in the TUI scrollback and the
300
+ // orchestrator's LLM picks up the phase result as context on the
301
+ // next turn. display:true renders in the TUI; no triggerTurn so
302
+ // the LLM doesn't auto-respond — the user remains in control.
303
+ pi.sendMessage({
304
+ customType: "codecarto-phase-summary",
305
+ content: buildPhaseSummary({
306
+ phaseId: phase.id,
307
+ status: result.aborted ? "aborted" : "completed",
308
+ turnCount: activity.turnCount,
309
+ toolUses: activity.toolUses,
310
+ tokens: activity.lifetimeUsage,
311
+ durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
312
+ responseText: result.responseText,
313
+ }),
314
+ display: true,
315
+ });
316
+ void recordUsage(state.workspaceDir, phase.id, status, activity);
317
+ })
318
+ .catch((err) => {
319
+ const message = err instanceof Error ? err.message : String(err);
320
+ finishPhase(phase.id, { status: "error", error: message });
321
+ if (ctx.hasUI) {
322
+ ctx.ui.notify(`Phase ${phase.id} sub-agent failed: ${message}`, "error");
323
+ }
324
+ pi.sendMessage({
325
+ customType: "codecarto-phase-summary",
326
+ content: buildPhaseSummary({
327
+ phaseId: phase.id,
328
+ status: "error",
329
+ turnCount: activity.turnCount,
330
+ toolUses: activity.toolUses,
331
+ tokens: activity.lifetimeUsage,
332
+ durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
333
+ responseText: "",
334
+ error: message,
335
+ }),
336
+ display: true,
337
+ });
338
+ void recordUsage(state.workspaceDir, phase.id, "error", activity);
339
+ })
340
+ .finally(() => {
341
+ // Sub-agent may have written findings, owner_notes, or carry-forward
342
+ // items into status.yaml. Refresh the main status widget so the
343
+ // "Open questions / Carry-forward / Next" lines reflect the new
344
+ // state without waiting for the user to run /codecarto-status.
345
+ void refreshWorkspaceUi(ctx);
346
+ // Linger 30s in M1 so /codecarto-status can show that the phase ran;
347
+ // M2's widget owns the proper "linger N turns" lifecycle.
348
+ setTimeout(() => clearPhase(phase.id), 30_000);
266
349
  });
267
350
  },
268
351
  });
@@ -440,4 +523,34 @@ export default function codeCartographerExtension(pi) {
440
523
  ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
441
524
  },
442
525
  });
526
+ pi.registerCommand("codecarto-usage", {
527
+ description: "Show cumulative + per-phase token usage from local phase runs",
528
+ handler: async (_args, ctx) => {
529
+ const state = await ensureWorkspaceState(ctx);
530
+ if (!state)
531
+ return;
532
+ const usage = await loadUsage(state.workspaceDir);
533
+ if (usage.runs.length === 0) {
534
+ lastFeedbackLines = ["No phase runs recorded yet."];
535
+ setUiState(ctx, state, lastFeedbackLines);
536
+ ctx.ui.notify("No phase runs recorded yet.", "info");
537
+ return;
538
+ }
539
+ const totals = computeTotals(usage);
540
+ const perPhase = computePerPhaseTotals(usage);
541
+ const lines = [];
542
+ lines.push(`Total runs: ${totals.runs}`);
543
+ lines.push(`Total tokens: ${formatUsageTokens(totals.tokens.input)} in · ${formatUsageTokens(totals.tokens.output)} out · ${formatUsageTokens(totals.tokens.cache_write)} cache-write`);
544
+ lines.push(`Total duration: ${formatUsageDuration(totals.duration_ms)} · ${totals.tool_uses} tool uses`);
545
+ lines.push("");
546
+ lines.push("Per-phase totals:");
547
+ for (const [phaseId, t] of perPhase) {
548
+ const tokensTotal = t.tokens.input + t.tokens.output;
549
+ lines.push(` ${phaseId}: ${t.runs} run${t.runs === 1 ? "" : "s"} · ${formatUsageTokens(tokensTotal)} tokens · ${t.tool_uses} tool uses · ${formatUsageDuration(t.duration_ms)}`);
550
+ }
551
+ lastFeedbackLines = lines;
552
+ setUiState(ctx, state, lastFeedbackLines);
553
+ ctx.ui.notify(`CodeCartographer usage: ${totals.runs} run${totals.runs === 1 ? "" : "s"}, ${formatUsageTokens(totals.tokens.input + totals.tokens.output)} tokens total`, "info");
554
+ },
555
+ });
443
556
  }
@@ -0,0 +1,6 @@
1
+ export interface NextFlags {
2
+ llmSteerOverride?: boolean;
3
+ unknown: string[];
4
+ }
5
+ export declare function parseNextFlags(args: string): NextFlags;
6
+ export declare const KNOWN_NEXT_FLAGS: readonly string[];
@@ -0,0 +1,19 @@
1
+ // Flag parser for /codecarto-next. The user invokes the slash command with
2
+ // an optional "--llm-steer" or "--no-llm-steer" override. We parse and
3
+ // validate; index.ts decides how to merge the override with workspace
4
+ // config to compute the effective "should we run the LLM rewriter?" bit.
5
+ const KNOWN = new Set(["--llm-steer", "--no-llm-steer"]);
6
+ export function parseNextFlags(args) {
7
+ const tokens = args.trim().split(/\s+/).filter((t) => t.length > 0);
8
+ const result = { unknown: [] };
9
+ for (const t of tokens) {
10
+ if (t === "--llm-steer")
11
+ result.llmSteerOverride = true;
12
+ else if (t === "--no-llm-steer")
13
+ result.llmSteerOverride = false;
14
+ else
15
+ result.unknown.push(t);
16
+ }
17
+ return result;
18
+ }
19
+ export const KNOWN_NEXT_FLAGS = [...KNOWN];
@@ -367,7 +367,7 @@ const HANDLERS = {
367
367
  };
368
368
  // ---------- server bootstrap ----------
369
369
  export function buildServer() {
370
- const server = new Server({ name: "codecartographer", version: "0.1.4" }, { capabilities: { tools: {} } });
370
+ const server = new Server({ name: "codecartographer", version: "0.2.0" }, { capabilities: { tools: {} } });
371
371
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
372
372
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
373
373
  const handler = HANDLERS[request.params.name];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.1.4",
3
+ "version": "0.6.0",
4
4
  "description": "CodeCartographer packaged for Pi as an extension-driven workflow wrapper.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -42,7 +42,7 @@
42
42
  "@modelcontextprotocol/sdk": "^1.29.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "^0.74.0",
45
+ "@earendil-works/pi-coding-agent": "~0.74.0",
46
46
  "@sinclair/typebox": "*"
47
47
  },
48
48
  "pi": {
@@ -1,6 +0,0 @@
1
- export interface OrchestratorState {
2
- sessionFile: string;
3
- sessionId: string;
4
- }
5
- export declare function loadOrchestratorState(cwd: string): Promise<OrchestratorState | null>;
6
- export declare function writeOrchestratorState(cwd: string, state: OrchestratorState): Promise<void>;
@@ -1,36 +0,0 @@
1
- // Per-machine pointer to the Pi session that runs as the CodeCartographer
2
- // orchestrator. Stored in `.codecarto/workflow/.orchestrator.local.yaml`
3
- // (gitignored) so committed `status.yaml` doesn't leak machine-specific
4
- // session file paths to collaborators.
5
- //
6
- // Only the Pi extension writes/reads this — the MCP path has no session
7
- // concept. When the file is missing or malformed, `/codecarto-next` falls
8
- // back to in-place phase prompts (legacy 0.1.0–0.1.2 behavior).
9
- import { mkdir, writeFile } from "node:fs/promises";
10
- import { dirname, join } from "node:path";
11
- import { pathExists } from "./utils.js";
12
- import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
13
- const ORCHESTRATOR_STATE_RELATIVE = "workflow/.orchestrator.local.yaml";
14
- function orchestratorStatePath(cwd) {
15
- return join(cwd, ".codecarto", ORCHESTRATOR_STATE_RELATIVE);
16
- }
17
- export async function loadOrchestratorState(cwd) {
18
- const path = orchestratorStatePath(cwd);
19
- if (!(await pathExists(path)))
20
- return null;
21
- let data;
22
- try {
23
- data = await loadYamlFile(path);
24
- }
25
- catch {
26
- return null;
27
- }
28
- if (!data?.sessionFile || !data?.sessionId)
29
- return null;
30
- return { sessionFile: data.sessionFile, sessionId: data.sessionId };
31
- }
32
- export async function writeOrchestratorState(cwd, state) {
33
- const path = orchestratorStatePath(cwd);
34
- await mkdir(dirname(path), { recursive: true });
35
- await writeFile(path, `${stringifySimpleYaml(state)}\n`, "utf8");
36
- }