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,35 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ export type PhaseStatus = "running" | "completed" | "error" | "aborted";
3
+ export interface PhaseActivity {
4
+ phaseId: string;
5
+ status: PhaseStatus;
6
+ startedAt: number;
7
+ completedAt?: number;
8
+ turnCount: number;
9
+ toolUses: number;
10
+ /** Currently-executing tool calls keyed by toolCallId, value is the tool name. */
11
+ activeTools: Map<string, string>;
12
+ /** Latest streaming response text from the assistant (for "thinking…" UI). */
13
+ responseText: string;
14
+ /** Cumulative token usage across all assistant turns; survives in-session compaction. */
15
+ lifetimeUsage: {
16
+ input: number;
17
+ output: number;
18
+ cacheWrite: number;
19
+ };
20
+ session?: AgentSession;
21
+ error?: string;
22
+ }
23
+ export declare function getPhaseActivity(phaseId: string): PhaseActivity | undefined;
24
+ export declare function listPhaseActivity(): PhaseActivity[];
25
+ export declare function startPhase(phaseId: string): PhaseActivity;
26
+ export declare function finishPhase(phaseId: string, outcome: {
27
+ status: Exclude<PhaseStatus, "running">;
28
+ error?: string;
29
+ }): void;
30
+ /**
31
+ * Clear a phase from the activity map. The widget (M2) will linger finished
32
+ * phases for a turn or two before calling this; for now (M1) we clear after
33
+ * a fixed timeout so the orchestrator notification stays meaningful.
34
+ */
35
+ export declare function clearPhase(phaseId: string): void;
@@ -0,0 +1,46 @@
1
+ // Shared, per-extension-instance phase activity state. The runner mutates
2
+ // it from session-event callbacks; the agents widget (M2) reads it on each
3
+ // render. Module-scoped Map so different command handlers can hand work to
4
+ // the runner and the widget sees the same state without explicit plumbing.
5
+ const phaseActivity = new Map();
6
+ export function getPhaseActivity(phaseId) {
7
+ return phaseActivity.get(phaseId);
8
+ }
9
+ export function listPhaseActivity() {
10
+ return [...phaseActivity.values()];
11
+ }
12
+ export function startPhase(phaseId) {
13
+ const existing = phaseActivity.get(phaseId);
14
+ if (existing && existing.status === "running")
15
+ return existing;
16
+ const activity = {
17
+ phaseId,
18
+ status: "running",
19
+ startedAt: Date.now(),
20
+ turnCount: 0,
21
+ toolUses: 0,
22
+ activeTools: new Map(),
23
+ responseText: "",
24
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
25
+ };
26
+ phaseActivity.set(phaseId, activity);
27
+ return activity;
28
+ }
29
+ export function finishPhase(phaseId, outcome) {
30
+ const activity = phaseActivity.get(phaseId);
31
+ if (!activity)
32
+ return;
33
+ activity.status = outcome.status;
34
+ activity.completedAt = Date.now();
35
+ if (outcome.error)
36
+ activity.error = outcome.error;
37
+ activity.activeTools.clear();
38
+ }
39
+ /**
40
+ * Clear a phase from the activity map. The widget (M2) will linger finished
41
+ * phases for a turn or two before calling this; for now (M1) we clear after
42
+ * a fixed timeout so the orchestrator notification stays meaningful.
43
+ */
44
+ export function clearPhase(phaseId) {
45
+ phaseActivity.delete(phaseId);
46
+ }
@@ -0,0 +1,17 @@
1
+ import type { PhaseActivity } from "./agent-state.ts";
2
+ export interface PhaseSummaryInput {
3
+ phaseId: string;
4
+ status: PhaseActivity["status"];
5
+ turnCount: number;
6
+ toolUses: number;
7
+ tokens: {
8
+ input: number;
9
+ output: number;
10
+ cacheWrite: number;
11
+ };
12
+ durationMs: number;
13
+ responseText: string;
14
+ sessionFile?: string;
15
+ error?: string;
16
+ }
17
+ export declare function buildPhaseSummary(input: PhaseSummaryInput): string;
@@ -0,0 +1,79 @@
1
+ // Build the markdown summary that's injected into the orchestrator's session
2
+ // when a phase sub-agent finishes. The summary is a CustomMessageEntry
3
+ // (display: true, no triggerTurn) so the user sees it in the TUI and the
4
+ // orchestrator's LLM picks it up as context on the user's next turn.
5
+ const RESPONSE_TEXT_CHAR_BUDGET = 2000;
6
+ export function buildPhaseSummary(input) {
7
+ const stats = formatStats(input);
8
+ const headerLine = formatHeader(input.phaseId, input.status);
9
+ if (input.status === "error") {
10
+ const errorLine = input.error ? `\n\n\`${input.error}\`` : "";
11
+ return `${headerLine}\n\n${stats}${errorLine}`;
12
+ }
13
+ const trailer = formatTrailer(input);
14
+ const excerpt = formatExcerpt(input.responseText);
15
+ const sections = [headerLine, stats];
16
+ if (excerpt)
17
+ sections.push(excerpt);
18
+ sections.push(trailer);
19
+ return sections.join("\n\n");
20
+ }
21
+ function formatHeader(phaseId, status) {
22
+ switch (status) {
23
+ case "completed":
24
+ return `**Phase \`${phaseId}\` finished.**`;
25
+ case "aborted":
26
+ return `**Phase \`${phaseId}\` aborted.**`;
27
+ case "error":
28
+ return `**Phase \`${phaseId}\` failed.**`;
29
+ default:
30
+ return `**Phase \`${phaseId}\`.**`;
31
+ }
32
+ }
33
+ function formatStats(input) {
34
+ const parts = [];
35
+ if (input.turnCount > 0)
36
+ parts.push(`⟳ ${input.turnCount}`);
37
+ if (input.toolUses > 0)
38
+ parts.push(`${input.toolUses} tool use${input.toolUses === 1 ? "" : "s"}`);
39
+ const totalTokens = input.tokens.input + input.tokens.output;
40
+ if (totalTokens > 0)
41
+ parts.push(formatTokens(totalTokens));
42
+ if (input.durationMs > 0)
43
+ parts.push(formatDuration(input.durationMs));
44
+ return parts.length > 0 ? `_${parts.join(" · ")}_` : "_(no activity recorded)_";
45
+ }
46
+ function formatExcerpt(responseText) {
47
+ const trimmed = responseText.trim();
48
+ if (!trimmed)
49
+ return "";
50
+ if (trimmed.length <= RESPONSE_TEXT_CHAR_BUDGET)
51
+ return trimmed;
52
+ return `${trimmed.slice(0, RESPONSE_TEXT_CHAR_BUDGET).trimEnd()}…\n\n_(transcript truncated; resume the phase session for the full output)_`;
53
+ }
54
+ function formatTrailer(input) {
55
+ const lines = [];
56
+ if (input.sessionFile) {
57
+ lines.push(`Phase transcript: \`${input.sessionFile}\` (open via \`/resume\`).`);
58
+ }
59
+ if (input.status === "completed") {
60
+ lines.push("Run `/codecarto-validate` to check the output, then `/codecarto-complete` to advance.");
61
+ }
62
+ return lines.join("\n");
63
+ }
64
+ function formatTokens(count) {
65
+ if (count >= 1_000_000)
66
+ return `${(count / 1_000_000).toFixed(1)}M tokens`;
67
+ if (count >= 1_000)
68
+ return `${(count / 1_000).toFixed(1)}k tokens`;
69
+ return `${count} tokens`;
70
+ }
71
+ function formatDuration(ms) {
72
+ if (ms < 1000)
73
+ return `${ms}ms`;
74
+ if (ms < 60_000)
75
+ return `${(ms / 1000).toFixed(1)}s`;
76
+ const minutes = Math.floor(ms / 60_000);
77
+ const seconds = Math.floor((ms % 60_000) / 1000);
78
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
79
+ }
@@ -0,0 +1,26 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ declare class CodecartoAgentsWidget {
3
+ private uiCtx;
4
+ private timer;
5
+ private spinnerFrame;
6
+ private widgetRegistered;
7
+ private tui;
8
+ private lastStatus;
9
+ /** Tick counts for finished phases — used to age them out. */
10
+ private finishedAge;
11
+ /**
12
+ * Wire this widget to a UI context. Called from /codecarto-next when a
13
+ * phase starts. Idempotent — if already running on the same context the
14
+ * call is a no-op.
15
+ */
16
+ attach(uiCtx: ExtensionUIContext): void;
17
+ /** Tear down — called from extension dispose. */
18
+ dispose(): void;
19
+ private ensureTimer;
20
+ private update;
21
+ private unregister;
22
+ private renderLines;
23
+ }
24
+ export declare function getAgentsWidget(): CodecartoAgentsWidget;
25
+ export declare function disposeAgentsWidget(): void;
26
+ export {};
@@ -0,0 +1,260 @@
1
+ // Persistent "Agents" widget rendered above the editor while CodeCartographer
2
+ // phase sub-agents are running. Reads the shared agent-state map every ~80ms
3
+ // to update the spinner and per-phase stats; unregisters itself when no phase
4
+ // is active and any finished phases have lingered out.
5
+ //
6
+ // Pattern adapted from @tintinweb/pi-subagents (src/ui/agent-widget.ts).
7
+ // Slimmed down: codecarto runs at most a small number of phases sequentially
8
+ // per workflow, so the overflow/queue logic in tintinweb's version is dropped.
9
+ import { truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { listPhaseActivity } from "./agent-state.js";
11
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ const WIDGET_KEY = "codecarto-agents";
13
+ const STATUS_KEY = "codecarto-agents-status";
14
+ const TICK_MS = 80;
15
+ /** How many ticks a finished phase lingers in the widget before removal. */
16
+ const FINISHED_LINGER_TICKS = 80; // ~6.4s at 80ms
17
+ const TOOL_DISPLAY = {
18
+ read: "reading",
19
+ edit: "editing",
20
+ write: "writing",
21
+ grep: "searching",
22
+ find: "finding files",
23
+ ls: "listing",
24
+ };
25
+ class CodecartoAgentsWidget {
26
+ uiCtx;
27
+ timer;
28
+ spinnerFrame = 0;
29
+ widgetRegistered = false;
30
+ tui;
31
+ lastStatus;
32
+ /** Tick counts for finished phases — used to age them out. */
33
+ finishedAge = new Map();
34
+ /**
35
+ * Wire this widget to a UI context. Called from /codecarto-next when a
36
+ * phase starts. Idempotent — if already running on the same context the
37
+ * call is a no-op.
38
+ */
39
+ attach(uiCtx) {
40
+ if (this.uiCtx === uiCtx && this.timer !== undefined)
41
+ return;
42
+ this.uiCtx = uiCtx;
43
+ this.widgetRegistered = false;
44
+ this.tui = undefined;
45
+ this.lastStatus = undefined;
46
+ this.ensureTimer();
47
+ this.update();
48
+ }
49
+ /** Tear down — called from extension dispose. */
50
+ dispose() {
51
+ if (this.timer) {
52
+ clearInterval(this.timer);
53
+ this.timer = undefined;
54
+ }
55
+ if (this.uiCtx) {
56
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
57
+ this.uiCtx.setStatus(STATUS_KEY, undefined);
58
+ }
59
+ this.widgetRegistered = false;
60
+ this.tui = undefined;
61
+ this.lastStatus = undefined;
62
+ this.finishedAge.clear();
63
+ }
64
+ ensureTimer() {
65
+ if (this.timer)
66
+ return;
67
+ this.timer = setInterval(() => this.update(), TICK_MS);
68
+ }
69
+ update() {
70
+ if (!this.uiCtx)
71
+ return;
72
+ const all = listPhaseActivity();
73
+ const running = all.filter((a) => a.status === "running");
74
+ const finished = all.filter((a) => a.status !== "running");
75
+ // Age finished phases. Drop those past the linger threshold.
76
+ for (const a of finished) {
77
+ const age = (this.finishedAge.get(a.phaseId) ?? 0) + 1;
78
+ this.finishedAge.set(a.phaseId, age);
79
+ }
80
+ // Forget aged-out phases (visual only — agent-state owns the actual map).
81
+ const visibleFinished = finished.filter((a) => (this.finishedAge.get(a.phaseId) ?? 0) <= FINISHED_LINGER_TICKS);
82
+ // Drop entries for phases that are no longer in the activity map.
83
+ for (const id of [...this.finishedAge.keys()]) {
84
+ if (!all.some((a) => a.phaseId === id))
85
+ this.finishedAge.delete(id);
86
+ }
87
+ const hasActive = running.length > 0;
88
+ const hasContent = hasActive || visibleFinished.length > 0;
89
+ if (!hasContent) {
90
+ this.unregister();
91
+ return;
92
+ }
93
+ const newStatus = hasActive
94
+ ? `${running.length} CodeCartographer phase${running.length === 1 ? "" : "s"} running`
95
+ : undefined;
96
+ if (newStatus !== this.lastStatus) {
97
+ this.uiCtx.setStatus(STATUS_KEY, newStatus);
98
+ this.lastStatus = newStatus;
99
+ }
100
+ this.spinnerFrame++;
101
+ if (!this.widgetRegistered) {
102
+ this.uiCtx.setWidget(WIDGET_KEY, (tui, theme) => {
103
+ this.tui = tui;
104
+ return {
105
+ render: () => this.renderLines(tui, theme),
106
+ invalidate: () => {
107
+ this.widgetRegistered = false;
108
+ this.tui = undefined;
109
+ },
110
+ };
111
+ }, { placement: "aboveEditor" });
112
+ this.widgetRegistered = true;
113
+ }
114
+ else {
115
+ this.tui?.requestRender();
116
+ }
117
+ }
118
+ unregister() {
119
+ if (!this.uiCtx)
120
+ return;
121
+ if (this.widgetRegistered) {
122
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
123
+ this.widgetRegistered = false;
124
+ this.tui = undefined;
125
+ }
126
+ if (this.lastStatus !== undefined) {
127
+ this.uiCtx.setStatus(STATUS_KEY, undefined);
128
+ this.lastStatus = undefined;
129
+ }
130
+ if (this.timer) {
131
+ clearInterval(this.timer);
132
+ this.timer = undefined;
133
+ }
134
+ this.finishedAge.clear();
135
+ }
136
+ renderLines(tui, theme) {
137
+ const all = listPhaseActivity();
138
+ if (all.length === 0)
139
+ return [];
140
+ const cols = tui.terminal?.columns ?? 80;
141
+ const truncate = (line) => truncateToWidth(line, cols);
142
+ const running = all.filter((a) => a.status === "running");
143
+ const finished = all
144
+ .filter((a) => a.status !== "running")
145
+ .filter((a) => (this.finishedAge.get(a.phaseId) ?? 0) <= FINISHED_LINGER_TICKS);
146
+ const headingColor = running.length > 0 ? "accent" : "dim";
147
+ const headingIcon = running.length > 0 ? "●" : "○";
148
+ const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "CodeCartographer")}`;
149
+ const lines = [truncate(heading)];
150
+ const allEntries = [];
151
+ for (const a of running)
152
+ allEntries.push({ activity: a, isLast: false });
153
+ for (const a of finished)
154
+ allEntries.push({ activity: a, isLast: false });
155
+ for (let i = 0; i < allEntries.length; i++) {
156
+ allEntries[i].isLast = i === allEntries.length - 1;
157
+ }
158
+ for (const { activity, isLast } of allEntries) {
159
+ const connector = isLast ? "└─" : "├─";
160
+ const continuation = isLast ? " " : "│ ";
161
+ if (activity.status === "running") {
162
+ const frame = SPINNER[this.spinnerFrame % SPINNER.length];
163
+ const stats = formatRunningStats(activity);
164
+ const header = `${theme.fg("dim", connector)} ${theme.fg("accent", frame)} ${theme.bold(activity.phaseId)} phase ${theme.fg("dim", stats)}`;
165
+ const activityText = describeActivity(activity);
166
+ lines.push(truncate(header));
167
+ lines.push(truncate(`${theme.fg("dim", continuation)}${theme.fg("dim", ` ⎿ ${activityText}`)}`));
168
+ }
169
+ else {
170
+ const stats = formatFinishedStats(activity);
171
+ const icon = formatStatusIcon(activity.status, theme);
172
+ const dim = activity.status === "completed" ? "dim" : "warning";
173
+ const errSuffix = activity.error ? ` ${theme.fg("error", `– ${activity.error.slice(0, 60)}`)}` : "";
174
+ lines.push(truncate(`${theme.fg("dim", connector)} ${icon} ${theme.fg(dim, `${activity.phaseId} phase`)} ${theme.fg("dim", stats)}${errSuffix}`));
175
+ }
176
+ }
177
+ return lines;
178
+ }
179
+ }
180
+ function formatRunningStats(a) {
181
+ const parts = [];
182
+ if (a.turnCount > 0)
183
+ parts.push(`⟳ ${a.turnCount}`);
184
+ if (a.toolUses > 0)
185
+ parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
186
+ const tokens = a.lifetimeUsage.input + a.lifetimeUsage.output;
187
+ if (tokens > 0)
188
+ parts.push(formatTokens(tokens));
189
+ parts.push(formatDuration(Date.now() - a.startedAt));
190
+ return parts.join(" · ");
191
+ }
192
+ function formatFinishedStats(a) {
193
+ const parts = [];
194
+ if (a.turnCount > 0)
195
+ parts.push(`⟳ ${a.turnCount}`);
196
+ if (a.toolUses > 0)
197
+ parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
198
+ const tokens = a.lifetimeUsage.input + a.lifetimeUsage.output;
199
+ if (tokens > 0)
200
+ parts.push(formatTokens(tokens));
201
+ const dur = a.completedAt ? a.completedAt - a.startedAt : 0;
202
+ if (dur > 0)
203
+ parts.push(formatDuration(dur));
204
+ return parts.join(" · ");
205
+ }
206
+ function formatTokens(count) {
207
+ if (count >= 1_000_000)
208
+ return `${(count / 1_000_000).toFixed(1)}M tokens`;
209
+ if (count >= 1_000)
210
+ return `${(count / 1_000).toFixed(1)}k tokens`;
211
+ return `${count} tokens`;
212
+ }
213
+ function formatDuration(ms) {
214
+ if (ms < 1000)
215
+ return `${ms}ms`;
216
+ if (ms < 60_000)
217
+ return `${(ms / 1000).toFixed(1)}s`;
218
+ const minutes = Math.floor(ms / 60_000);
219
+ const seconds = Math.floor((ms % 60_000) / 1000);
220
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
221
+ }
222
+ function formatStatusIcon(status, theme) {
223
+ switch (status) {
224
+ case "completed": return theme.fg("success", "✓");
225
+ case "aborted": return theme.fg("warning", "■");
226
+ case "error": return theme.fg("error", "✗");
227
+ default: return theme.fg("dim", "·");
228
+ }
229
+ }
230
+ function describeActivity(a) {
231
+ if (a.activeTools.size > 0) {
232
+ const counts = new Map();
233
+ for (const name of a.activeTools.values()) {
234
+ const action = TOOL_DISPLAY[name] ?? name;
235
+ counts.set(action, (counts.get(action) ?? 0) + 1);
236
+ }
237
+ const parts = [];
238
+ for (const [action, count] of counts) {
239
+ parts.push(count > 1 ? `${action} ×${count}` : action);
240
+ }
241
+ return `${parts.join(", ")}…`;
242
+ }
243
+ const text = a.responseText.split("\n").find((l) => l.trim()) ?? "";
244
+ if (text.trim().length > 0) {
245
+ return text.length > 60 ? `${text.slice(0, 60)}…` : text;
246
+ }
247
+ return "thinking…";
248
+ }
249
+ let widgetSingleton;
250
+ export function getAgentsWidget() {
251
+ if (!widgetSingleton)
252
+ widgetSingleton = new CodecartoAgentsWidget();
253
+ return widgetSingleton;
254
+ }
255
+ export function disposeAgentsWidget() {
256
+ if (widgetSingleton) {
257
+ widgetSingleton.dispose();
258
+ widgetSingleton = undefined;
259
+ }
260
+ }