pi-chassis 0.0.0-stage → 0.7.1

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.
Files changed (169) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/LICENSE +21 -0
  3. package/README.md +51 -2
  4. package/THIRD_PARTY_NOTICES +30 -0
  5. package/dist/chassis/alert.d.ts +50 -0
  6. package/dist/chassis/alert.js +107 -0
  7. package/dist/chassis/api.d.ts +411 -0
  8. package/dist/chassis/api.js +676 -0
  9. package/dist/chassis/bash-output.d.ts +89 -0
  10. package/dist/chassis/bash-output.js +157 -0
  11. package/dist/chassis/brand.d.ts +99 -0
  12. package/dist/chassis/brand.js +79 -0
  13. package/dist/chassis/client.d.ts +87 -0
  14. package/dist/chassis/client.js +271 -0
  15. package/dist/chassis/commands.d.ts +60 -0
  16. package/dist/chassis/commands.js +211 -0
  17. package/dist/chassis/editor.d.ts +70 -0
  18. package/dist/chassis/editor.js +213 -0
  19. package/dist/chassis/footer.d.ts +14 -0
  20. package/dist/chassis/footer.js +136 -0
  21. package/dist/chassis/header.d.ts +12 -0
  22. package/dist/chassis/header.js +38 -0
  23. package/dist/chassis/home.d.ts +48 -0
  24. package/dist/chassis/home.js +184 -0
  25. package/dist/chassis/icons.d.ts +203 -0
  26. package/dist/chassis/icons.js +190 -0
  27. package/dist/chassis/index.d.ts +21 -0
  28. package/dist/chassis/index.js +582 -0
  29. package/dist/chassis/layout.d.ts +40 -0
  30. package/dist/chassis/layout.js +264 -0
  31. package/dist/chassis/message-modes.d.ts +21 -0
  32. package/dist/chassis/message-modes.js +49 -0
  33. package/dist/chassis/messages.d.ts +75 -0
  34. package/dist/chassis/messages.js +392 -0
  35. package/dist/chassis/modes.d.ts +21 -0
  36. package/dist/chassis/modes.js +99 -0
  37. package/dist/chassis/palette.d.ts +84 -0
  38. package/dist/chassis/palette.js +235 -0
  39. package/dist/chassis/peek.d.ts +61 -0
  40. package/dist/chassis/peek.js +156 -0
  41. package/dist/chassis/prompt-row.d.ts +42 -0
  42. package/dist/chassis/prompt-row.js +68 -0
  43. package/dist/chassis/provider-view.d.ts +34 -0
  44. package/dist/chassis/provider-view.js +107 -0
  45. package/dist/chassis/provider.d.ts +69 -0
  46. package/dist/chassis/provider.js +73 -0
  47. package/dist/chassis/readonly-command.d.ts +19 -0
  48. package/dist/chassis/readonly-command.js +454 -0
  49. package/dist/chassis/sanitize.d.ts +42 -0
  50. package/dist/chassis/sanitize.js +103 -0
  51. package/dist/chassis/session-info.d.ts +39 -0
  52. package/dist/chassis/session-info.js +107 -0
  53. package/dist/chassis/sidebar.d.ts +48 -0
  54. package/dist/chassis/sidebar.js +273 -0
  55. package/dist/chassis/state.d.ts +38 -0
  56. package/dist/chassis/state.js +19 -0
  57. package/dist/chassis/telemetry.d.ts +60 -0
  58. package/dist/chassis/telemetry.js +191 -0
  59. package/dist/chassis/tools.d.ts +112 -0
  60. package/dist/chassis/tools.js +588 -0
  61. package/dist/chassis/version.d.ts +2 -0
  62. package/dist/chassis/version.js +20 -0
  63. package/dist/diff/git-diff.d.ts +71 -0
  64. package/dist/diff/git-diff.js +325 -0
  65. package/dist/diff/host.d.ts +34 -0
  66. package/dist/diff/host.js +50 -0
  67. package/dist/diff/index.d.ts +13 -0
  68. package/dist/diff/index.js +86 -0
  69. package/dist/diff/render.d.ts +67 -0
  70. package/dist/diff/render.js +196 -0
  71. package/dist/diff/types.d.ts +60 -0
  72. package/dist/diff/types.js +4 -0
  73. package/dist/diff/view.d.ts +118 -0
  74. package/dist/diff/view.js +528 -0
  75. package/dist/git/flows.d.ts +92 -0
  76. package/dist/git/flows.js +415 -0
  77. package/dist/git/forge.d.ts +26 -0
  78. package/dist/git/forge.js +41 -0
  79. package/dist/git/index.d.ts +20 -0
  80. package/dist/git/index.js +121 -0
  81. package/dist/git/repo.d.ts +64 -0
  82. package/dist/git/repo.js +186 -0
  83. package/dist/git/run.d.ts +38 -0
  84. package/dist/git/run.js +127 -0
  85. package/dist/git/settings.d.ts +8 -0
  86. package/dist/git/settings.js +38 -0
  87. package/dist/git/text.d.ts +51 -0
  88. package/dist/git/text.js +109 -0
  89. package/dist/widgets/container/detect.d.ts +23 -0
  90. package/dist/widgets/container/detect.js +67 -0
  91. package/dist/widgets/container/index.d.ts +9 -0
  92. package/dist/widgets/container/index.js +26 -0
  93. package/dist/widgets/mcp/index.d.ts +13 -0
  94. package/dist/widgets/mcp/index.js +50 -0
  95. package/dist/widgets/mcp/status.d.ts +50 -0
  96. package/dist/widgets/mcp/status.js +70 -0
  97. package/dist/widgets/session-todos/index.d.ts +12 -0
  98. package/dist/widgets/session-todos/index.js +52 -0
  99. package/dist/widgets/subagents/index.d.ts +16 -0
  100. package/dist/widgets/subagents/index.js +173 -0
  101. package/dist/widgets/subagents/status.d.ts +61 -0
  102. package/dist/widgets/subagents/status.js +198 -0
  103. package/dist/widgets/todo/index.d.ts +13 -0
  104. package/dist/widgets/todo/index.js +65 -0
  105. package/dist/widgets/todo/parse.d.ts +29 -0
  106. package/dist/widgets/todo/parse.js +122 -0
  107. package/docs/branding.md +25 -0
  108. package/docs/development.md +22 -0
  109. package/docs/diff-api.md +18 -0
  110. package/docs/git.md +23 -0
  111. package/docs/internals.md +13 -0
  112. package/docs/provider-status.md +23 -0
  113. package/docs/status-widgets.md +157 -0
  114. package/docs/tool-rows.md +50 -0
  115. package/docs/usage.md +62 -0
  116. package/docs/widgets.md +113 -0
  117. package/extensions/chassis/alert.ts +127 -0
  118. package/extensions/chassis/api.ts +967 -0
  119. package/extensions/chassis/bash-output.ts +186 -0
  120. package/extensions/chassis/brand.ts +151 -0
  121. package/extensions/chassis/client.ts +284 -0
  122. package/extensions/chassis/commands.ts +244 -0
  123. package/extensions/chassis/editor.ts +253 -0
  124. package/extensions/chassis/footer.ts +131 -0
  125. package/extensions/chassis/header.ts +43 -0
  126. package/extensions/chassis/home.ts +186 -0
  127. package/extensions/chassis/icons.ts +220 -0
  128. package/extensions/chassis/index.ts +605 -0
  129. package/extensions/chassis/layout.ts +298 -0
  130. package/extensions/chassis/message-modes.ts +48 -0
  131. package/extensions/chassis/messages.ts +403 -0
  132. package/extensions/chassis/modes.ts +108 -0
  133. package/extensions/chassis/palette.ts +258 -0
  134. package/extensions/chassis/peek.ts +167 -0
  135. package/extensions/chassis/prompt-row.ts +86 -0
  136. package/extensions/chassis/provider-view.ts +118 -0
  137. package/extensions/chassis/provider.ts +115 -0
  138. package/extensions/chassis/readonly-command.ts +440 -0
  139. package/extensions/chassis/sanitize.ts +108 -0
  140. package/extensions/chassis/session-info.ts +127 -0
  141. package/extensions/chassis/sidebar.ts +285 -0
  142. package/extensions/chassis/state.ts +50 -0
  143. package/extensions/chassis/telemetry.ts +222 -0
  144. package/extensions/chassis/tools.ts +618 -0
  145. package/extensions/chassis/version.ts +20 -0
  146. package/extensions/diff/git-diff.ts +365 -0
  147. package/extensions/diff/host.ts +58 -0
  148. package/extensions/diff/index.ts +101 -0
  149. package/extensions/diff/render.ts +217 -0
  150. package/extensions/diff/types.ts +67 -0
  151. package/extensions/diff/view.ts +523 -0
  152. package/extensions/git/flows.ts +485 -0
  153. package/extensions/git/forge.ts +54 -0
  154. package/extensions/git/index.ts +117 -0
  155. package/extensions/git/repo.ts +200 -0
  156. package/extensions/git/run.ts +143 -0
  157. package/extensions/git/settings.ts +38 -0
  158. package/extensions/git/text.ts +133 -0
  159. package/extensions/widgets/container/detect.ts +81 -0
  160. package/extensions/widgets/container/index.ts +29 -0
  161. package/extensions/widgets/mcp/index.ts +53 -0
  162. package/extensions/widgets/mcp/status.ts +94 -0
  163. package/extensions/widgets/session-todos/index.ts +55 -0
  164. package/extensions/widgets/subagents/index.ts +170 -0
  165. package/extensions/widgets/subagents/status.ts +237 -0
  166. package/extensions/widgets/todo/index.ts +66 -0
  167. package/extensions/widgets/todo/parse.ts +119 -0
  168. package/package.json +91 -4
  169. package/scripts/link-pi.mjs +76 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure logic for the MCP footer status (no pi imports, so the tests run standalone).
3
+ *
4
+ * pi's built-in MCP support has no public connection-state API, so the state is inferred:
5
+ * - configured: enabled servers from ~/.pi/agent/mcp.json, the trusted project's
6
+ * .pi/mcp.json (project entries replace global ones of the same name), plus servers
7
+ * extensions registered with pi.registerMcpServer() (mcp.json wins on name clashes).
8
+ * - connected: the server has at least one registered tool in namespace mcp__<server>
9
+ * whose exposure is not "hidden". Tools register when a server connects; a disabled
10
+ * server's tools are re-registered as hidden.
11
+ * Limits: a dropped connection keeps its tools registered until the next call reconnects it,
12
+ * and a server that offers zero tools never counts as connected.
13
+ */
14
+
15
+ /** Only `enabled` is read; other mcp.json fields (url, command, ...) pass through untyped. */
16
+ export interface ServerConfig {
17
+ enabled?: boolean;
18
+ url?: string;
19
+ command?: string;
20
+ }
21
+
22
+ export interface ToolLike {
23
+ exposure: string;
24
+ namespace?: { name: string };
25
+ }
26
+
27
+ /** Same rule as pi's mcpNamespace(): "-" and "_" are equivalent in server names. */
28
+ export function mcpNamespace(server: string): string {
29
+ return `mcp__${server.replace(/-/g, "_")}`;
30
+ }
31
+
32
+ /** The mcpServers object of an mcp.json text, or {} when missing or invalid. */
33
+ export function parseMcpServers(text: string | undefined): Record<string, ServerConfig> {
34
+ if (!text) return {};
35
+ try {
36
+ const servers = JSON.parse(text)?.mcpServers;
37
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return {};
38
+ const out: Record<string, ServerConfig> = {};
39
+ for (const [name, cfg] of Object.entries(servers)) {
40
+ if (typeof cfg === "object" && cfg !== null && !Array.isArray(cfg)) out[name] = cfg as ServerConfig;
41
+ }
42
+ return out;
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Names of enabled servers, in precedence order: global < project (replaces by namespace),
50
+ * then registered servers whose namespace no config file defines.
51
+ */
52
+ export function enabledServers(
53
+ global: Record<string, ServerConfig>,
54
+ project: Record<string, ServerConfig>,
55
+ registered: Array<{ name: string; config: ServerConfig }>,
56
+ ): string[] {
57
+ const byNs = new Map<string, { name: string; config: ServerConfig }>();
58
+ for (const [name, config] of Object.entries(global)) byNs.set(mcpNamespace(name), { name, config });
59
+ for (const [name, config] of Object.entries(project)) byNs.set(mcpNamespace(name), { name, config });
60
+ for (const r of registered) {
61
+ const ns = mcpNamespace(r.name);
62
+ if (!byNs.has(ns)) byNs.set(ns, r);
63
+ }
64
+ return [...byNs.values()].filter((s) => s.config.enabled !== false).map((s) => s.name);
65
+ }
66
+
67
+ /** Servers among `servers` that have at least one non-hidden registered tool. */
68
+ export function connectedServers(servers: string[], tools: ToolLike[]): string[] {
69
+ const live = new Set(tools.filter((t) => t.exposure !== "hidden" && t.namespace).map((t) => t.namespace!.name));
70
+ return servers.filter((s) => live.has(mcpNamespace(s)));
71
+ }
72
+
73
+ export type StatusLevel = "ok" | "partial" | "down";
74
+
75
+ export interface McpStatus {
76
+ connected: number;
77
+ enabled: number;
78
+ level: StatusLevel;
79
+ /** Enabled servers that are not connected yet. */
80
+ pending: string[];
81
+ }
82
+
83
+ /** Undefined when no server is enabled (the footer segment is hidden). */
84
+ export function summarize(enabled: string[], connected: string[]): McpStatus | undefined {
85
+ if (enabled.length === 0) return undefined;
86
+ const live = new Set(connected);
87
+ const pending = enabled.filter((s) => !live.has(s));
88
+ const level: StatusLevel = pending.length === 0 ? "ok" : connected.length === 0 ? "down" : "partial";
89
+ return { connected: connected.length, enabled: enabled.length, level, pending };
90
+ }
91
+
92
+ export function formatStatus(s: McpStatus): string {
93
+ return `${s.connected}/${s.enabled}`;
94
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Session todos widget: the latest todo list that a todo-style tool (pi's todo example, todowrite,
3
+ * ...) reported in this session, as its own sidebar section. Hidden when there is no list or
4
+ * everything is done. Separate from the TODO widget, which reads a TODO file in the repo.
5
+ *
6
+ * Todo
7
+ * [✓] Read the API
8
+ * [•] Write the tests
9
+ * [ ] Update the README
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { registerWidget } from "../../chassis/api.ts";
14
+ import { ICONS } from "../../chassis/icons.ts";
15
+ import { primary } from "../../chassis/palette.ts";
16
+ import { branchKey, latestTodos, type TodoItem } from "../../chassis/session-info.ts";
17
+
18
+ const SHOWN = 12;
19
+
20
+ /** Open todos only count as visible; a stale ctx after a session switch reads as "none". */
21
+ function todosOf(ctx: Parameters<typeof latestTodos>[0] | undefined): TodoItem[] {
22
+ if (!ctx) return [];
23
+ try {
24
+ return latestTodos(ctx);
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ export default function (pi: ExtensionAPI) {
31
+ registerWidget(pi, {
32
+ id: "session-todos",
33
+ title: "Todo",
34
+ icon: { nerd: ICONS.widgetSessionTodos.nerd, plain: ICONS.widgetSessionTodos.plain },
35
+ slots: ["sidebar"],
36
+ sidebar: "section",
37
+ order: 50,
38
+ // latestTodos scans the branch; redo it only when the branch changes.
39
+ cacheKey: ({ ctx }) => {
40
+ try {
41
+ return ctx ? branchKey(ctx) : "";
42
+ } catch {
43
+ return ""; // stale ctx after a session switch
44
+ }
45
+ },
46
+ render: ({ ctx }) => (todosOf(ctx).some((t) => !t.done) ? {} : undefined),
47
+ detail: ({ theme, ctx }) =>
48
+ todosOf(ctx)
49
+ .slice(0, SHOWN)
50
+ .map((t) => {
51
+ const box = t.done ? theme.fg("success", "[✓]") : t.active ? primary(theme, "[•]") : theme.fg("muted", "[ ]");
52
+ return `${box} ${t.done ? theme.fg("muted", t.text) : theme.fg("text", t.text)}`;
53
+ }),
54
+ });
55
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Subagents widget: a sidebar section with this session's pi-subagents children.
3
+ *
4
+ * Subagents 2 running · 1 done
5
+ * ● build-api bash 1m02s
6
+ * ● scout fg 18s
7
+ * ✓ reviewer 2m10s
8
+ * ↓ on an empty prompt to inspect
9
+ *
10
+ * Data comes from pi-subagents' public in-process RPC (`status`, see its docs/extension-api.md),
11
+ * never from its files on disk, so this keeps working as pi-subagents changes internally.
12
+ * Inspecting, steering and stopping are FleetView's job (pi-subagents' own UI under the
13
+ * editor); the hint line points there. Hidden when pi-subagents isn't installed or nothing ran.
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
19
+ import { registerWidget } from "../../chassis/api.ts";
20
+ import { ICONS } from "../../chassis/icons.ts";
21
+ import { readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { asyncActiveFrom, elapsed, fmtCount, GLYPH, type Limits, limitsLine, orderRows, parallelFrom, type Row, rowsFromStatus, shortModel, spawnedFrom, summary } from "./status.ts";
25
+
26
+ const REQUEST = "subagents:rpc:v1:request";
27
+ const REPLY = "subagents:rpc:v1:reply:";
28
+ const READY = "subagents:rpc:v1:ready";
29
+ const POLL_MS = 2_000;
30
+ const TIMEOUT_MS = 5_000;
31
+ const SHOWN = 8;
32
+ /** Running children that also get a model and token line, so the section stays short. */
33
+ const SHOWN_DETAIL = 5;
34
+ /** The spawn budget needs the heavier executor-backed status call: at most this often. */
35
+ const BUDGET_MIN_MS = 5_000;
36
+ /** Even lifecycle events (which can come in bursts) wait this long between budget calls. */
37
+ const BUDGET_FLOOR_MS = 1_000;
38
+
39
+ function readConfigText(): string | undefined {
40
+ try {
41
+ return readFileSync(join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "extensions", "subagent", "config.json"), "utf8");
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /** One `status` request; resolves to the reply's data, or undefined without pi-subagents. */
48
+ function requestStatus(pi: ExtensionAPI, params: Record<string, unknown> = {}): Promise<unknown> {
49
+ return new Promise((resolve) => {
50
+ const requestId = randomUUID();
51
+ let off: (() => void) | undefined;
52
+ const timer = setTimeout(() => {
53
+ off?.();
54
+ resolve(undefined);
55
+ }, TIMEOUT_MS);
56
+ timer.unref?.();
57
+ off = pi.events.on(`${REPLY}${requestId}`, (raw) => {
58
+ clearTimeout(timer);
59
+ off?.();
60
+ const r = raw as { success?: boolean; data?: unknown } | undefined;
61
+ resolve(r?.success ? r.data : undefined);
62
+ });
63
+ pi.events.emit(REQUEST, { version: 1, requestId, method: "status", params, source: { extension: "pi-chassis" } });
64
+ });
65
+ }
66
+
67
+ export default function (pi: ExtensionAPI) {
68
+ let rows: Row[] = [];
69
+ let inFlight = false;
70
+ let limits: Limits = {};
71
+ let budgetBusy = false;
72
+ let budgetAt = 0;
73
+
74
+ /** Spawn count and cap. Rarely needed, and costlier than the poll, so throttled. */
75
+ const refreshBudget = async (force = false): Promise<void> => {
76
+ if (budgetBusy || Date.now() - budgetAt < (force ? BUDGET_FLOOR_MS : BUDGET_MIN_MS)) return;
77
+ budgetBusy = true;
78
+ budgetAt = Date.now();
79
+ try {
80
+ const data = await requestStatus(pi, { view: "fleet" });
81
+ if (data !== undefined) limits = { ...limits, spawned: spawnedFrom(data) ?? limits.spawned };
82
+ } finally {
83
+ budgetBusy = false;
84
+ }
85
+ };
86
+
87
+ const update = async (_ctx?: ExtensionContext) => {
88
+ if (inFlight) return;
89
+ inFlight = true;
90
+ try {
91
+ const data = await requestStatus(pi);
92
+ if (data !== undefined) {
93
+ rows = orderRows(rowsFromStatus(data));
94
+ limits = { ...limits, asyncActive: asyncActiveFrom(data), parallel: parallelFrom(readConfigText()) };
95
+ // A new child changes the spawn count, so look again while something is active.
96
+ if (rows.some((r) => r.state === "running" || r.state === "queued")) void refreshBudget();
97
+ }
98
+ } finally {
99
+ inFlight = false;
100
+ }
101
+ };
102
+
103
+ const handle = registerWidget(pi, {
104
+ id: "subagents",
105
+ title: "Subagents",
106
+ icon: { nerd: ICONS.widgetSubagents.nerd, plain: ICONS.widgetSubagents.plain },
107
+ // pi-subagents' slash commands publish short-lived statuses ("0 tools | ctrl+o live detail")
108
+ // that this section already covers with per-child rows, so they're not listed a second time.
109
+ statusKeys: ["subagent-slash", "subagent-slash-text"],
110
+ slots: ["sidebar", "footer"],
111
+ sidebar: "section",
112
+ order: 35,
113
+ refreshMs: POLL_MS,
114
+ update,
115
+ render: () => {
116
+ if (!rows.length) return undefined;
117
+ const s = summary(rows);
118
+ return { label: "subagents", text: s.text, level: s.running ? "ok" : s.failed ? "warn" : "off", color: s.running ? "text" : undefined };
119
+ },
120
+ detail: ({ theme, width }) => {
121
+ if (!rows.length) return undefined;
122
+ const tone = (r: Row, x: string) =>
123
+ theme.fg(r.state === "running" ? "accent" : r.state === "done" ? "success" : r.state === "failed" ? "error" : r.state === "paused" ? "warning" : "dim", x);
124
+ const lines = rows.slice(0, SHOWN).map((r) => {
125
+ const active = r.state === "running" || r.state === "queued" || r.state === "paused";
126
+ const left = `${tone(r, GLYPH[r.state])} ${theme.fg(active ? "text" : "muted", r.label)}${r.foreground ? theme.fg("dim", " fg") : ""}`;
127
+ const right = theme.fg("dim", [r.currentTool, elapsed(r)].filter(Boolean).join(" "));
128
+ const gap = width - visibleWidth(left) - visibleWidth(right);
129
+ return gap >= 1 ? `${left}${" ".repeat(gap)}${right}` : truncateToWidth(left, width, "…");
130
+ });
131
+ // Model and tokens under running children: " opus-5-5 · 16k tokens".
132
+ const withFacts: string[] = [];
133
+ let factRows = 0;
134
+ rows.slice(0, SHOWN).forEach((r, i) => {
135
+ withFacts.push(lines[i]!);
136
+ const facts = [r.model ? shortModel(r.model) : "", r.tokens ? `${fmtCount(r.tokens)} tokens` : ""].filter(Boolean).join(" · ");
137
+ if (facts && r.state === "running" && factRows < SHOWN_DETAIL) {
138
+ factRows++;
139
+ withFacts.push(truncateToWidth(` ${theme.fg("dim", facts)}`, width, "…"));
140
+ }
141
+ });
142
+ lines.length = 0;
143
+ lines.push(...withFacts);
144
+ if (rows.length > SHOWN) lines.push(theme.fg("dim", `+${rows.length - SHOWN} more`));
145
+ const limit = limitsLine(limits);
146
+ if (limit) lines.push(theme.fg("dim", limit));
147
+ if (summary(rows).running) lines.push(theme.fg("dim", "↓ on an empty prompt to inspect"));
148
+ return lines;
149
+ },
150
+ });
151
+
152
+ // Refresh promptly on pi-subagents' lifecycle events, on top of the poll.
153
+ for (const ev of [READY, "subagent:async-started", "subagent:async-complete", "subagent:child-status"]) {
154
+ pi.events.on(ev, () => {
155
+ setTimeout(() => handle.refresh(), 150);
156
+ void refreshBudget(true);
157
+ });
158
+ }
159
+ pi.on("tool_execution_start", async (e) => {
160
+ if (e.toolName === "subagent") setTimeout(() => handle.refresh(), 300);
161
+ });
162
+ pi.on("tool_execution_end", async (e) => {
163
+ if (e.toolName === "subagent") setTimeout(() => handle.refresh(), 300);
164
+ });
165
+ pi.on("session_start", async () => {
166
+ rows = [];
167
+ limits = {};
168
+ budgetAt = 0;
169
+ });
170
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Projection of pi-subagents' public `status` RPC reply into sidebar rows.
3
+ *
4
+ * Reads only the documented, versioned shapes from pi-subagents' docs/extension-api.md:
5
+ * `data.asyncSnapshot` (kind "pi-subagents.async-status-snapshot", version 1) for background
6
+ * runs, and `data.fleet` (version 1) for active foreground children, which the snapshot doesn't
7
+ * cover. Unknown fields are ignored, as that doc asks. No pi imports, so tests stay light.
8
+ */
9
+
10
+ export type RowState = "running" | "queued" | "done" | "failed" | "paused" | "stopped";
11
+
12
+ export interface Row {
13
+ label: string;
14
+ state: RowState;
15
+ currentTool?: string;
16
+ startedAt?: number;
17
+ endedAt?: number;
18
+ /** A foreground child (from the fleet DTO). */
19
+ foreground?: boolean;
20
+ /** Model as the fleet DTO reports it, e.g. "anthropic/claude-opus-5-5". Active children only. */
21
+ model?: string;
22
+ /** Tokens so far (input + output). Active children only. */
23
+ tokens?: number;
24
+ }
25
+
26
+ /** What the sidebar shows about the limits, from the status reply and the config file. */
27
+ export interface Limits {
28
+ /** Children launched this session, and the cap (null: no cap). */
29
+ spawned?: { used: number; limit: number | null };
30
+ /** Background runs active in this session, and the cap (only when a cap is configured). */
31
+ asyncActive?: { used: number; limit: number };
32
+ /** `globalConcurrencyLimit`: children running at once inside one run. */
33
+ parallel?: number;
34
+ }
35
+
36
+ interface SnapshotNode {
37
+ kind?: string;
38
+ label?: string;
39
+ state?: string;
40
+ startedAt?: number;
41
+ endedAt?: number;
42
+ activity?: { currentTool?: string };
43
+ children?: SnapshotNode[];
44
+ }
45
+
46
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
47
+ const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : undefined);
48
+
49
+ function rowState(s: unknown): RowState {
50
+ switch (s) {
51
+ case "running":
52
+ return "running";
53
+ case "queued":
54
+ return "queued";
55
+ case "complete":
56
+ return "done";
57
+ case "paused":
58
+ return "paused";
59
+ case "stopped":
60
+ return "stopped";
61
+ default:
62
+ return "failed"; // failed, partial, rejected
63
+ }
64
+ }
65
+
66
+ /** A node's leaves: the children you'd open in FleetView. Workflow parents aren't rows. */
67
+ function leaves(node: SnapshotNode, out: Row[]): void {
68
+ const kids = Array.isArray(node.children) ? node.children : [];
69
+ if (kids.length) {
70
+ for (const k of kids) if (k && typeof k === "object") leaves(k, out);
71
+ return;
72
+ }
73
+ const state = rowState(node.state);
74
+ out.push({
75
+ label: str(node.label) ?? "subagent",
76
+ state,
77
+ currentTool: state === "running" ? str(node.activity?.currentTool) : undefined,
78
+ startedAt: num(node.startedAt),
79
+ endedAt: state === "running" || state === "queued" ? undefined : num(node.endedAt),
80
+ });
81
+ }
82
+
83
+ /** Rows from a `status` reply's `data`: background leaves, then active foreground children. */
84
+ export function rowsFromStatus(data: unknown): Row[] {
85
+ if (!data || typeof data !== "object") return [];
86
+ const d = data as { asyncSnapshot?: { kind?: string; version?: number; runs?: unknown }; fleet?: { version?: number; entries?: unknown } };
87
+ const rows: Row[] = [];
88
+ const snap = d.asyncSnapshot;
89
+ if (snap?.kind === "pi-subagents.async-status-snapshot" && snap.version === 1 && Array.isArray(snap.runs)) {
90
+ for (const run of snap.runs) if (run && typeof run === "object") leaves(run as SnapshotNode, rows);
91
+ }
92
+ // The fleet DTO lists every active child, background and foreground (plus a "workflow" entry
93
+ // per active workflow parent), but exposes no ids. Background children are already rows from
94
+ // the snapshot, so the entries beyond that count are foreground ones: take the newest.
95
+ const fleet = d.fleet;
96
+ if (fleet?.version === 1 && Array.isArray(fleet.entries)) {
97
+ const entries = (fleet.entries as FleetEntry[]).filter(
98
+ (e) => e && typeof e === "object" && e.agent !== "workflow",
99
+ );
100
+ const background = rows.filter((r) => r.state === "running" || r.state === "queued" || r.state === "paused").length;
101
+ const extra = entries.length - background;
102
+ if (extra > 0) {
103
+ const newest = [...entries].sort((a, b) => (num(b.startedAt) ?? 0) - (num(a.startedAt) ?? 0)).slice(0, extra);
104
+ for (const e of newest) {
105
+ rows.push({ label: str(e.role) ?? str(e.agent) ?? "subagent", state: "running", startedAt: num(e.startedAt), foreground: true, ...liveFacts(e) });
106
+ }
107
+ }
108
+ attachLiveFacts(rows, entries);
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ interface FleetEntry {
114
+ agent?: unknown;
115
+ role?: unknown;
116
+ model?: unknown;
117
+ startedAt?: unknown;
118
+ tokens?: { total?: unknown; input?: unknown; output?: unknown };
119
+ }
120
+
121
+ function liveFacts(e: FleetEntry): { model?: string; tokens?: number } {
122
+ const t = e.tokens;
123
+ const total = num(t?.total) ?? (num(t?.input) !== undefined || num(t?.output) !== undefined ? (num(t?.input) ?? 0) + (num(t?.output) ?? 0) : undefined);
124
+ return { ...(str(e.model) ? { model: str(e.model) } : {}), ...(total ? { tokens: total } : {}) };
125
+ }
126
+
127
+ /** Fleet start times differ from the snapshot's by a few milliseconds; this is the match window. */
128
+ const MATCH_MS = 1_000;
129
+
130
+ /**
131
+ * Background rows come from the async snapshot, which has no model or tokens, and the fleet DTO
132
+ * has no ids. Pair each active background row with the fleet entry that started nearest to it
133
+ * (within MATCH_MS, each entry used once) to borrow the model and token count.
134
+ */
135
+ function attachLiveFacts(rows: Row[], entries: FleetEntry[]): void {
136
+ const free = entries.filter((e) => num(e.startedAt) !== undefined && (str(e.model) || e.tokens));
137
+ const active = rows.filter((r) => !r.foreground && r.startedAt !== undefined && (r.state === "running" || r.state === "queued" || r.state === "paused"));
138
+ const pairs: Array<{ row: Row; entry: FleetEntry; gap: number }> = [];
139
+ for (const row of active) for (const entry of free) pairs.push({ row, entry, gap: Math.abs(row.startedAt! - num(entry.startedAt)!) });
140
+ pairs.sort((a, b) => a.gap - b.gap);
141
+ const usedRows = new Set<Row>();
142
+ const usedEntries = new Set<FleetEntry>();
143
+ for (const { row, entry, gap } of pairs) {
144
+ if (gap > MATCH_MS) break;
145
+ if (usedRows.has(row) || usedEntries.has(entry)) continue;
146
+ usedRows.add(row);
147
+ usedEntries.add(entry);
148
+ Object.assign(row, liveFacts(entry));
149
+ }
150
+ }
151
+
152
+ /** "anthropic/claude-opus-5-5" becomes "opus-5-5". */
153
+ export function shortModel(model: string): string {
154
+ return model.split("/").pop()!.replace(/^claude-/, "");
155
+ }
156
+
157
+ /** 16147 becomes "16k", 1_200_000 becomes "1.2M". */
158
+ export function fmtCount(n: number): string {
159
+ if (n < 1_000) return String(n);
160
+ if (n < 1_000_000) return `${Math.round(n / 1_000)}k`;
161
+ return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
162
+ }
163
+
164
+ /** Spawn budget from a `status` reply made with `view: "fleet"` (`data.details.spawnBudget`). */
165
+ export function spawnedFrom(data: unknown): Limits["spawned"] {
166
+ const b = (data as { details?: { spawnBudget?: { used?: unknown; limit?: unknown } } } | undefined)?.details?.spawnBudget;
167
+ const used = num(b?.used);
168
+ if (used === undefined) return undefined;
169
+ return { used, limit: num(b?.limit) ?? null };
170
+ }
171
+
172
+ /** Active async runs and their cap from the fleet DTO; undefined when no cap is configured. */
173
+ export function asyncActiveFrom(data: unknown): Limits["asyncActive"] {
174
+ const c = (data as { fleet?: { topLevelAsyncCapacity?: { used?: unknown; limit?: unknown } } } | undefined)?.fleet?.topLevelAsyncCapacity;
175
+ const used = num(c?.used);
176
+ const limit = num(c?.limit);
177
+ return used !== undefined && limit !== undefined && limit > 0 ? { used, limit } : undefined;
178
+ }
179
+
180
+ /** `globalConcurrencyLimit` from pi-subagents' config.json text (default 20 when absent). */
181
+ export function parallelFrom(configText: string | undefined): number {
182
+ try {
183
+ const n = num(JSON.parse(configText ?? "{}")?.globalConcurrencyLimit);
184
+ return n !== undefined && n > 0 ? Math.floor(n) : 20;
185
+ } catch {
186
+ return 20;
187
+ }
188
+ }
189
+
190
+ /** The limits line under the rows, or undefined when there is nothing to say. */
191
+ export function limitsLine(l: Limits): string | undefined {
192
+ const parts: string[] = [];
193
+ if (l.spawned) parts.push(l.spawned.limit === null ? `${l.spawned.used} spawned` : `${l.spawned.used}/${l.spawned.limit} spawned`);
194
+ if (l.asyncActive) parts.push(`${l.asyncActive.used}/${l.asyncActive.limit} async`);
195
+ else if (l.parallel) parts.push(`max ${l.parallel} parallel`);
196
+ return parts.length ? parts.join(" · ") : undefined;
197
+ }
198
+
199
+ const ORDER: Record<RowState, number> = { running: 0, paused: 1, queued: 2, failed: 3, stopped: 3, done: 3 };
200
+
201
+ /** Active first (oldest first), then finished (newest first). */
202
+ export function orderRows(rows: Row[]): Row[] {
203
+ return [...rows].sort((a, b) => {
204
+ const o = ORDER[a.state] - ORDER[b.state];
205
+ if (o) return o;
206
+ if (ORDER[a.state] < 3) return (a.startedAt ?? 0) - (b.startedAt ?? 0);
207
+ return (b.endedAt ?? b.startedAt ?? 0) - (a.endedAt ?? a.startedAt ?? 0);
208
+ });
209
+ }
210
+
211
+ export function summary(rows: Row[]): { text: string; running: number; failed: number } {
212
+ let running = 0;
213
+ let queued = 0;
214
+ let done = 0;
215
+ let failed = 0;
216
+ for (const r of rows) {
217
+ if (r.state === "running" || r.state === "paused") running++;
218
+ else if (r.state === "queued") queued++;
219
+ else if (r.state === "done") done++;
220
+ else failed++;
221
+ }
222
+ const text = [running ? `${running} running` : "", queued ? `${queued} queued` : "", done ? `${done} done` : "", failed ? `${failed} failed` : ""].filter(Boolean).join(" · ");
223
+ return { text, running, failed };
224
+ }
225
+
226
+ export function elapsed(r: Row, now = Date.now()): string {
227
+ if (!r.startedAt) return "";
228
+ const end = r.endedAt ?? (r.state === "running" || r.state === "paused" ? now : undefined);
229
+ if (end === undefined) return "";
230
+ const s = Math.max(0, Math.round((end - r.startedAt) / 1000));
231
+ if (s < 60) return `${s}s`;
232
+ const m = Math.floor(s / 60);
233
+ return m < 60 ? `${m}m${String(s % 60).padStart(2, "0")}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
234
+ }
235
+
236
+ /** Plain glyphs that exist in common monospace fonts. */
237
+ export const GLYPH: Record<RowState, string> = { running: "●", queued: "○", paused: "‖", done: "✓", failed: "×", stopped: "■" };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TODO widget: when the current repo has a TODO file at its root (TODO.md, TODO, TODO.txt, ...),
3
+ * shows it as its own sidebar section: the open item count beside the title and the first few
4
+ * items underneath. Hidden when there's no file or nothing is open. Re-reads the file when it
5
+ * changes. The file belongs to the project, so it is read only when the project is trusted.
6
+ *
7
+ * TODO 3 open
8
+ * • Test the provider section…
9
+ * • Add a cost breakdown
10
+ * +1 more
11
+ */
12
+
13
+ import { statSync } from "node:fs";
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { registerWidget } from "../../chassis/api.ts";
16
+ import { ICONS } from "../../chassis/icons.ts";
17
+ import { sanitize } from "../../chassis/palette.ts";
18
+ import { findTodoFile, readTodos } from "./parse.ts";
19
+
20
+ const POLL_MS = 5_000;
21
+ const SHOWN = 5;
22
+
23
+ export default function (pi: ExtensionAPI) {
24
+ let items: string[] = [];
25
+ let key = ""; // path + mtime, so unchanged files aren't re-parsed
26
+
27
+ registerWidget(pi, {
28
+ id: "todo",
29
+ title: "TODO",
30
+ icon: { nerd: ICONS.widgetTodo.nerd, plain: ICONS.widgetTodo.plain },
31
+ slots: ["sidebar"],
32
+ sidebar: "section",
33
+ order: 40,
34
+ refreshMs: POLL_MS,
35
+ update: (ctx) => {
36
+ if (!ctx.isProjectTrusted()) {
37
+ items = [];
38
+ key = "";
39
+ return;
40
+ }
41
+ const path = findTodoFile(ctx.cwd);
42
+ if (!path) {
43
+ items = [];
44
+ key = "";
45
+ return;
46
+ }
47
+ let mtime = 0;
48
+ try {
49
+ mtime = statSync(path).mtimeMs;
50
+ } catch {
51
+ // vanished between calls
52
+ }
53
+ const next = `${path}:${mtime}`;
54
+ if (next === key) return;
55
+ key = next;
56
+ items = readTodos(ctx.cwd)?.items ?? [];
57
+ },
58
+ render: () => (items.length ? { text: `${items.length} open` } : undefined),
59
+ detail: ({ theme }) => {
60
+ if (!items.length) return undefined;
61
+ const lines = items.slice(0, SHOWN).map((t) => `${theme.fg("muted", "•")} ${theme.fg("text", sanitize(t))}`);
62
+ if (items.length > SHOWN) lines.push(theme.fg("dim", `+${items.length - SHOWN} more`));
63
+ return lines;
64
+ },
65
+ });
66
+ }