pi-cockpit 0.1.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/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # pi-cockpit
2
+
3
+ A list-mode **agent cockpit** for [Pi](https://pi.dev): a status stack pinned **above the editor** showing live teammates and the current todo plan, plus a Starship-style footer. Everything is drawn through Pi's public extension APIs only (`setWidget` / `setFooter`) — no core patches.
4
+
5
+ ```
6
+ ┌─ AGENTS · 2 running ─────────────────────────────┐ ← setWidget(aboveEditor)
7
+ │ ⠋ explorer #a1f3c2 map auth read routes.ts │
8
+ │ ⠋ explorer #b9e014 trace jwt read jwt.ts │
9
+ ├─ TODO · 1/4 ─────────────────────────────────────┤
10
+ │ 01 ✓ map auth entrypoints │
11
+ │ 02 ⠋ trace jwt verify │
12
+ │ 03 · implement refresh │
13
+ │ 04 · add tests │
14
+ └──────────────────────────────────────────────────┘
15
+ > dispatch a goal… ← editor (Pi native)
16
+ pi/stream-70b · ctx [████░░░░] 42% · $0.52 · 01:23 ← setFooter
17
+ ```
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install <path-or-spec> # or, for one run:
23
+ pi -e ./extensions/pi-cockpit
24
+ ```
25
+
26
+ ## What it shows
27
+
28
+ - **AGENTS** — every running teammate as a table row (status spinner · role · id · label · live tail), sorted running-first. Collapses to a one-line `N agents running` summary in compact mode.
29
+ - **TODO** — the active plan as numbered rows with four states (done ✓ / in-progress spinner / blocked ! / pending ·). Collapses to a segmented progress bar + current step + percent.
30
+ - **Footer** — `provider/model · context gauge · ↑in ↓out · $cost · elapsed · git branch`.
31
+
32
+ Toggle each block between list and compact with `/cockpit`.
33
+
34
+ ## Data sources (and the dependency this implies)
35
+
36
+ | Block | Source | Available on bare Pi? |
37
+ |-------|--------|------------------------|
38
+ | AGENTS | `pi.events` channels `teammate:started` / `teammate:message` / `teammate:complete`, broadcast by **pi-maestro-teammate** | **No** — without that extension no events fire, the block stays hidden |
39
+ | TODO | the `todo-state` snapshot the **pi-maestro-flow** `todo` tool persists after every mutation (re-read on each `tool_execution_end` and on `session_start`) | **No** — without the `todo` tool the block stays hidden |
40
+ | Footer | `ctx.model`, `ctx.getContextUsage()`, session usage totals, `footerData.getGitBranch()` | **Yes** |
41
+
42
+ So on a stock Pi (no teammate / no todo tool) the extension loads without error, the status stack renders nothing, and only the footer appears. The roster is **self-accumulated from event deltas** — teammates already running before the extension loads are not back-filled (the teammate extension broadcasts deltas, never a full roster). The todo list **is** back-filled on `session_start` from the persisted snapshot.
43
+
44
+ ## Configuration
45
+
46
+ `~/.pi/agent/cockpit.json` (created on first run):
47
+
48
+ ```json
49
+ {
50
+ "enabled": true,
51
+ "agentsMode": "list",
52
+ "todoMode": "list",
53
+ "hideNativeAgents": false
54
+ }
55
+ ```
56
+
57
+ - `agentsMode` / `todoMode`: `"list"` or `"compact"`.
58
+ - `hideNativeAgents`: when `true`, clears the teammate extension's own `teammate-agents` widget (it draws a similar list *below* the editor) so the two don't duplicate. Off by default — the two widgets use different keys and placements and can coexist.
59
+
60
+ ## Command
61
+
62
+ `/cockpit` opens an overlay to toggle `enabled`, `agentsMode`, `todoMode`, and `hideNativeAgents` (`e` / `a` / `t` / `n`, `Esc` to close). Changes persist immediately.
63
+
64
+ ## Terminal feasibility — what this design deliberately does NOT do
65
+
66
+ The original mockup was a browser page; a terminal is an ANSI stream with no DOM, no CSS, no focus, no hover. Four mockup effects are **out of scope** here, with replacements:
67
+
68
+ | Mockup effect | Why it can't work in a TUI | Replacement |
69
+ |---------------|----------------------------|-------------|
70
+ | Input-focus "power-up" glow (`:has(:focus)`) | no focus pseudo-class; `render(width)` can't see focus | the stack's header dot turns accent-green while an agent is running |
71
+ | Hover-to-expand chips/rows | no hover in a terminal | list mode shows everything; compact mode is one line; `/cockpit` toggles |
72
+ | Scanlines / logo light-up animation | no overlay/animation layer | a braille spinner frame, advanced on each redraw |
73
+ | In-stream thinking-collapse / edit progress bar / colored bash stdout | built-in message & built-in tool rendering is **not** replaceable by extensions (`renderCall`/`renderResult` only apply to tools *you* register) | the conversation stream is left to Pi's native renderer — it is context, not a cockpit deliverable |
74
+
75
+ ## Local development
76
+
77
+ ```bash
78
+ cd extensions/pi-cockpit
79
+ node --test --experimental-transform-types tests/agents-store.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts
80
+ ../../node_modules/.bin/tsc -p tsconfig.json # type-check (uses the monorepo root's tsc + types)
81
+ ```
82
+
83
+ The package lives inside the `pi-maestro-flow` tree so it resolves `@earendil-works/*` types from the root `node_modules`, but it is **not** part of the `packages/*` workspace — it neither pollutes the lockfile nor gets swept by monorepo builds.
84
+
85
+ ## License
86
+
87
+ MIT
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.1.0",
4
+ "description": "Agent cockpit for Pi: a list-mode status stack (live teammates + todo plan) pinned above the editor, plus a Starship-style footer. Renders only via public extension APIs (setWidget/setFooter).",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test --experimental-transform-types tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension/index.ts"
27
+ ],
28
+ "themes": [
29
+ "./themes"
30
+ ]
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-agent-core": "*",
34
+ "@earendil-works/pi-ai": "*",
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*",
37
+ "pi-maestro-teammate": "^0.6.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-agent-core": {
41
+ "optional": true
42
+ },
43
+ "@earendil-works/pi-ai": {
44
+ "optional": true
45
+ },
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-tui": {
50
+ "optional": true
51
+ },
52
+ "pi-maestro-teammate": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@earendil-works/pi-agent-core": "0.82.1",
58
+ "@earendil-works/pi-ai": "0.82.1",
59
+ "@earendil-works/pi-coding-agent": "0.82.1",
60
+ "@earendil-works/pi-tui": "0.82.1",
61
+ "@types/node": "^22.0.0",
62
+ "pi-maestro-teammate": "0.6.0",
63
+ "typescript": "^5.5.0"
64
+ },
65
+ "main": "./src/index.ts",
66
+ "exports": {
67
+ ".": "./src/index.ts",
68
+ "./src/*": "./src/*"
69
+ }
70
+ }
@@ -0,0 +1,270 @@
1
+ import type { TeammateCompleteEvent } from "pi-maestro-teammate/v1/events";
2
+ import { sanitizeExtensionStatusText } from "./extension-status.ts";
3
+ import type { AgentRow, AgentStatus } from "./types.ts";
4
+
5
+ const TAIL_MAX = 48;
6
+
7
+ // Every string here originates in an LLM-authored teammate event. A raw newline
8
+ // would split one widget row into several physical terminal rows and a raw escape
9
+ // (e.g. ESC[2J) would clear or recolor the whole screen — and neither is caught by
10
+ // width checks, because both measure as zero columns. Sanitize on ingest so no
11
+ // renderer has to remember to.
12
+ function clean(raw: string | undefined): string {
13
+ return raw === undefined ? "" : sanitizeExtensionStatusText(raw);
14
+ }
15
+
16
+ function truncateTail(raw: string): string {
17
+ const flat = clean(raw);
18
+ if (flat.length === 0) return "";
19
+ return flat.length > TAIL_MAX ? flat.slice(0, TAIL_MAX - 1) + "…" : flat;
20
+ }
21
+
22
+ // Shapes mirror what pi-maestro-teammate emits on pi.events (teammate/.../index.ts:378/1636/3420).
23
+ export interface StartedPayload {
24
+ correlationId: string;
25
+ agent: string;
26
+ name?: string;
27
+ spawnedBy?: string;
28
+ startedAt?: number | string;
29
+ lastActivityAt?: number | string;
30
+ status?: string;
31
+ }
32
+ export interface ProgressPayload {
33
+ agent: string;
34
+ name?: string;
35
+ correlationId: string;
36
+ taskIndex: number;
37
+ dependencies?: number[];
38
+ status?: string;
39
+ startedAt?: number | string;
40
+ lastActivityAt?: number | string;
41
+ recentTools?: Array<string | { name?: string; status?: string }>;
42
+ toolCount?: number;
43
+ tokens?: number;
44
+ lastMessage?: string;
45
+ }
46
+ export interface MessagePayload {
47
+ correlationId: string;
48
+ taskCorrelationId?: string;
49
+ taskIndex?: number;
50
+ dependencies?: number[];
51
+ message?: string;
52
+ lastMessage?: string;
53
+ recentTools?: Array<string | { name?: string; status?: string }>;
54
+ toolCount?: number;
55
+ tokens?: number;
56
+ status?: string;
57
+ lastActivityAt?: number | string;
58
+ progress?: ProgressPayload[];
59
+ }
60
+ export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">;
61
+
62
+ /**
63
+ * How long a failed agent stays on screen after it completes.
64
+ *
65
+ * Long enough to read the role and the tail that explains it; short enough that
66
+ * the panel still empties itself without the user clearing anything.
67
+ */
68
+ export const FAILED_LINGER_MS = 30_000;
69
+
70
+ function deriveRole(agent: string | undefined, name: string | undefined): string {
71
+ if (agent && !agent.startsWith("graph(")) return clean(agent);
72
+ return clean(name) || "agent";
73
+ }
74
+
75
+ export function mapAgentStatus(status: unknown): AgentStatus {
76
+ switch (status) {
77
+ case "pending":
78
+ return "pending";
79
+ case "retrying":
80
+ return "retrying";
81
+ case "sleeping":
82
+ return "sleeping";
83
+ case "completed":
84
+ case "complete":
85
+ case "done":
86
+ return "done";
87
+ case "failed":
88
+ return "failed";
89
+ case "running":
90
+ default:
91
+ return "running";
92
+ }
93
+ }
94
+
95
+ function normalizeStartedAt(value: number | string | undefined, fallback: number): number {
96
+ if (typeof value === "number" && Number.isFinite(value)) return value;
97
+ if (typeof value === "string") {
98
+ const parsed = Date.parse(value);
99
+ if (Number.isFinite(parsed)) return parsed;
100
+ }
101
+ return fallback;
102
+ }
103
+
104
+ function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
105
+ if (!tools?.length) return undefined;
106
+ const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
107
+ ?? tools.at(-1);
108
+ if (!tool) return undefined;
109
+ if (typeof tool === "string") return truncateTail(tool);
110
+ const name = tool?.name?.trim();
111
+ if (!name) return undefined;
112
+ const status = tool.status?.trim();
113
+ return truncateTail(status && status !== "completed" ? `${name} (${status})` : name);
114
+ }
115
+
116
+ // Self-accumulating roster. The teammate extension only broadcasts deltas
117
+ // (started/message/complete), never a full snapshot, so we rebuild the list here.
118
+ // Cold start is empty by design — we only reflect activity observed after load.
119
+ export class AgentsStore {
120
+ private readonly roster = new Map<string, AgentRow>();
121
+
122
+ applyStarted(p: StartedPayload, now: number = Date.now()): void {
123
+ const id = p.correlationId;
124
+ const prev = this.roster.get(id);
125
+ this.roster.set(id, {
126
+ ...prev,
127
+ correlationId: id,
128
+ agent: clean(p.agent) || prev?.agent || "",
129
+ name: p.name === undefined ? prev?.name : clean(p.name),
130
+ role: deriveRole(p.agent, p.name),
131
+ task: prev?.task ?? clean(p.name),
132
+ status: p.status === undefined ? prev?.status ?? "running" : mapAgentStatus(p.status),
133
+ tail: prev?.tail ?? "",
134
+ startedAt: prev?.startedAt ?? normalizeStartedAt(p.startedAt, now),
135
+ lastActivityAt: normalizeStartedAt(p.lastActivityAt, now),
136
+ ...(p.spawnedBy && p.spawnedBy !== id
137
+ ? { parentCorrelationId: p.spawnedBy }
138
+ : prev?.parentCorrelationId
139
+ ? { parentCorrelationId: prev.parentCorrelationId }
140
+ : {}),
141
+ });
142
+ }
143
+
144
+ applyMessage(p: MessagePayload, now = Date.now()): void {
145
+ if (p.progress) {
146
+ for (const progress of p.progress) this.applyProgress(p.correlationId, progress, now);
147
+ }
148
+ const targetId = p.taskCorrelationId ?? p.correlationId;
149
+ const row = this.roster.get(targetId);
150
+ if (!row) return;
151
+ const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
152
+ row.lastActivityAt = normalizeStartedAt(p.lastActivityAt ?? progressActivity, now);
153
+ const tail = p.message ?? p.lastMessage;
154
+ if (typeof tail === "string" && tail.length > 0) row.tail = truncateTail(tail);
155
+ if (p.recentTools) {
156
+ const tool = latestTool(p.recentTools);
157
+ if (tool) row.activeTool = tool;
158
+ else delete row.activeTool;
159
+ }
160
+ if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
161
+ if (typeof p.tokens === "number") row.tokens = p.tokens;
162
+ if (typeof p.status === "string") {
163
+ row.taskStatus = p.status;
164
+ row.status = mapAgentStatus(p.status);
165
+ }
166
+ if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
167
+ if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
168
+ }
169
+
170
+ applyComplete(p: CompletePayload, now = Date.now()): void {
171
+ const pending = [p.correlationId];
172
+ const visited = new Set<string>();
173
+ while (pending.length > 0) {
174
+ const id = pending.pop()!;
175
+ if (visited.has(id)) continue;
176
+ visited.add(id);
177
+ for (const row of this.roster.values()) {
178
+ if (row.parentCorrelationId === id) pending.push(row.correlationId);
179
+ }
180
+ // A failed agent used to be deleted the moment its result arrived, so the
181
+ // only evidence of the failure disappeared in the same frame it appeared.
182
+ // Successes still vanish immediately — the work has simply moved on.
183
+ const row = this.roster.get(id);
184
+ const failedByExitCode = id === p.correlationId
185
+ && Number.isFinite(p.exitCode)
186
+ && p.exitCode !== 0;
187
+ if (row && (row.status === "failed" || failedByExitCode)) {
188
+ row.status = "failed";
189
+ row.taskStatus = "failed";
190
+ row.failedAt = now;
191
+ row.lastActivityAt = now;
192
+ delete row.activeTool;
193
+ } else {
194
+ this.roster.delete(id);
195
+ }
196
+ }
197
+ }
198
+
199
+ /** Drop failed rows that have had their time on screen. Returns true if any went. */
200
+ prune(now = Date.now()): boolean {
201
+ let changed = false;
202
+ for (const [id, row] of this.roster) {
203
+ if (row.failedAt !== undefined && now - row.failedAt >= FAILED_LINGER_MS) {
204
+ this.roster.delete(id);
205
+ changed = true;
206
+ }
207
+ }
208
+ return changed;
209
+ }
210
+
211
+ /** True while a failed row is still counting down, so the redraw loop must run. */
212
+ hasLingering(): boolean {
213
+ for (const row of this.roster.values()) {
214
+ if (row.failedAt !== undefined) return true;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
220
+ const row = this.roster.get(p.correlationId);
221
+ if (!row) return;
222
+ row.parentCorrelationId = parentCorrelationId === p.correlationId
223
+ ? row.parentCorrelationId
224
+ : parentCorrelationId;
225
+ row.agent = clean(p.agent) || row.agent;
226
+ row.name = p.name === undefined ? row.name : clean(p.name);
227
+ row.role = deriveRole(p.agent, p.name);
228
+ row.task = p.name === undefined ? row.task : clean(p.name);
229
+ if (typeof p.status === "string") {
230
+ row.taskStatus = p.status;
231
+ row.status = mapAgentStatus(p.status);
232
+ }
233
+ row.taskIndex = p.taskIndex;
234
+ row.dependencies = Array.isArray(p.dependencies) ? [...p.dependencies] : [];
235
+ if (p.startedAt !== undefined) row.startedAt = normalizeStartedAt(p.startedAt, row.startedAt);
236
+ row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
237
+ if (p.recentTools) {
238
+ const tool = latestTool(p.recentTools);
239
+ if (tool) row.activeTool = tool;
240
+ else delete row.activeTool;
241
+ }
242
+ if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
243
+ if (typeof p.tokens === "number") row.tokens = p.tokens;
244
+ if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
245
+ row.tail = truncateTail(p.lastMessage);
246
+ }
247
+ }
248
+
249
+ snapshot(now = Date.now()): AgentRow[] {
250
+ // Expiry is driven by reads rather than a dedicated timer: the panel is
251
+ // already redrawn while anything is lingering, and nothing else has to know.
252
+ this.prune(now);
253
+ return [...this.roster.values()].sort((a, b) => {
254
+ const activity = b.lastActivityAt - a.lastActivityAt;
255
+ return activity || a.correlationId.localeCompare(b.correlationId);
256
+ });
257
+ }
258
+
259
+ get size(): number {
260
+ return this.roster.size;
261
+ }
262
+
263
+ has(correlationId: string): boolean {
264
+ return this.roster.has(correlationId);
265
+ }
266
+
267
+ clear(): void {
268
+ this.roster.clear();
269
+ }
270
+ }
package/src/ambient.ts ADDED
@@ -0,0 +1,72 @@
1
+ // Ambient surfaces: the streaming working line, the terminal tab title, and the
2
+ // footer status slot.
3
+ //
4
+ // Cockpit already knows the in-progress task, the live agent roster and the
5
+ // background jobs, but it only ever painted them into its own widgets. Meanwhile
6
+ // the host's streaming loader said "Working…", the tab title said nothing, and a
7
+ // config error scrolled away as a one-shot toast. These are pure composers for
8
+ // those three surfaces — they add information without costing a single row.
9
+
10
+ import type { AgentRow, BashBgJob, TodoItem } from "./types.ts";
11
+
12
+ export interface AmbientState {
13
+ todos: readonly TodoItem[];
14
+ agents: readonly AgentRow[];
15
+ jobs: readonly BashBgJob[];
16
+ running: boolean;
17
+ cwd?: string;
18
+ activeTool?: string;
19
+ }
20
+
21
+ function liveAgents(agents: readonly AgentRow[]): AgentRow[] {
22
+ return agents.filter((a) => a.status === "running" || a.status === "retrying");
23
+ }
24
+
25
+ function failedAgents(agents: readonly AgentRow[]): AgentRow[] {
26
+ return agents.filter((a) => a.status === "failed");
27
+ }
28
+
29
+ function failedJobs(jobs: readonly BashBgJob[]): BashBgJob[] {
30
+ return jobs.filter((job) => job.status === "failed" || (job.exitCode !== null && job.exitCode !== 0));
31
+ }
32
+
33
+ /**
34
+ * The streaming loader line.
35
+ *
36
+ * Keep the host's default "Working" label, elapsed time and interrupt hint.
37
+ * Only replace the label while a foreground tool is actively executing.
38
+ */
39
+ export function workingMessage(state: AmbientState): string | undefined {
40
+ return state.activeTool;
41
+ }
42
+
43
+ /**
44
+ * The terminal tab title.
45
+ *
46
+ * A developer with several tabs open cannot otherwise tell which run finished and
47
+ * which one needs them. Failure outranks progress, because that is the state that
48
+ * actually requires a human.
49
+ */
50
+ export function titleFor(state: AmbientState, marks: { ok: string; fail: string }): string {
51
+ const base = state.cwd ? `pi · ${state.cwd}` : "pi";
52
+ const broken = failedAgents(state.agents).length + failedJobs(state.jobs).length;
53
+ if (broken > 0) return `${marks.fail} ${base} · ${broken} failed`;
54
+ if (state.running) {
55
+ const live = liveAgents(state.agents).length;
56
+ return live > 0 ? `${base} · ${live} agents` : `${base} · working`;
57
+ }
58
+ const jobs = state.jobs.filter((job) => job.status === "running").length;
59
+ if (jobs > 0) return `${base} · ${jobs} bg`;
60
+ return base;
61
+ }
62
+
63
+ /**
64
+ * The footer status slot.
65
+ *
66
+ * Reserved for conditions that must persist rather than scroll away — a config
67
+ * that failed to load is the motivating case, since the session then silently
68
+ * runs on defaults.
69
+ */
70
+ export function statusText(problem: string | undefined, mark: string): string | undefined {
71
+ return problem ? `${mark} cockpit: ${problem}` : undefined;
72
+ }