@kata-sh/pi-symphony-extension 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.
@@ -0,0 +1,218 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { KeyId } from "@earendil-works/pi-tui";
3
+ import { assertLoopbackAttachUrl, resolveAttachUrl } from "./attach-url-policy.ts";
4
+ import { closeActiveConsole, handleActiveConsoleShortcut, openConsole, type ConsoleShortcutAction } from "./console.ts";
5
+ import { formatError, SymphonyExtensionError } from "./errors.ts";
6
+ import { parseAttachArgs, parseDoctorArgs, parseInitArgs, parseStartArgs, parseSteerArgs } from "./command-args.ts";
7
+ import { withSymphonyLoader, withSymphonyProgress } from "./progress.ts";
8
+ import type { SymphonyRuntime } from "./runtime.ts";
9
+ import { cleanupAbortedStart } from "./runtime-helpers.ts";
10
+ import { resolveStartWorkflow } from "./workflow-resolver.ts";
11
+
12
+ export function registerSymphonyCommands(pi: ExtensionAPI, runtime: SymphonyRuntime): void {
13
+ registerConsoleShortcuts(pi);
14
+
15
+ pi.registerCommand("symphony:help", {
16
+ description: "Show Symphony extension commands and current status",
17
+ handler: async (_args, ctx) => {
18
+ ctx.ui.notify(helpText(runtime), "info");
19
+ },
20
+ });
21
+
22
+ pi.registerCommand("symphony:init", {
23
+ description: "Run symphony init in the current Pi working directory",
24
+ handler: async (args, ctx) => runCommandHandler(ctx, async () => withSymphonyLoader(ctx, { message: "Initializing Symphony...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async (signal) => {
25
+ const parsed = parseInitArgs(args);
26
+ const binary = await runtime.resolveBinary(ctx);
27
+ const result = await pi.exec(binary, parsed.force ? ["init", "--force"] : ["init"], { cwd: ctx.cwd, signal });
28
+ if (result.code !== 0) throw new SymphonyExtensionError("command_failed", "symphony init failed", { cwd: ctx.cwd, code: result.code, stderr: result.stderr });
29
+ runtime.persist(pi);
30
+ ctx.ui.notify(result.stdout.trim() || "symphony init completed", "info");
31
+ })),
32
+ });
33
+
34
+ pi.registerCommand("symphony:doctor", {
35
+ description: "Run symphony doctor in the current Pi working directory",
36
+ handler: async (args, ctx) => runCommandHandler(ctx, async () => withSymphonyLoader(ctx, { message: "Running Symphony doctor...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async (signal) => {
37
+ const parsed = parseDoctorArgs(args);
38
+ const binary = await runtime.resolveBinary(ctx);
39
+ const commandArgs = parsed.workflow ? ["doctor", parsed.workflow] : ["doctor"];
40
+ const result = await pi.exec(binary, commandArgs, { cwd: ctx.cwd, signal });
41
+ if (result.code !== 0) throw new SymphonyExtensionError("command_failed", "symphony doctor failed", { cwd: ctx.cwd, code: result.code, stderr: result.stderr });
42
+ runtime.persist(pi);
43
+ ctx.ui.notify(result.stdout.trim() || "symphony doctor completed", "info");
44
+ })),
45
+ });
46
+
47
+ pi.registerCommand("symphony:start", {
48
+ description: "Start Symphony headlessly, attach to the HTTP API, and open the console",
49
+ handler: async (args, ctx) => runCommandHandler(ctx, async () => withSymphonyLoader(ctx, { message: "Starting Symphony...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async (signal) => {
50
+ let startedBaseUrl: string | undefined;
51
+ try {
52
+ const parsed = parseStartArgs(args);
53
+ const workflow = await resolveStartWorkflow(ctx.cwd, parsed.workflow);
54
+ const binary = await runtime.resolveBinary(ctx);
55
+ const started = await runtime.processManager.start({ binary, cwd: ctx.cwd, workflow, signal });
56
+ startedBaseUrl = started.baseUrl;
57
+ await runtime.attach(started.baseUrl, signal);
58
+ if (signal.aborted) {
59
+ await cleanupAbortedStart(runtime, startedBaseUrl);
60
+ runtime.persist(pi);
61
+ setSymphonyStatus(ctx, runtime);
62
+ return;
63
+ }
64
+ runtime.persist(pi);
65
+ setSymphonyStatus(ctx, runtime);
66
+ ctx.ui.notify(`Symphony started at ${started.baseUrl}`, "info");
67
+ await openConsole(ctx, runtime);
68
+ } catch (error) {
69
+ if (signal.aborted && startedBaseUrl) {
70
+ await cleanupAbortedStart(runtime, startedBaseUrl);
71
+ runtime.persist(pi);
72
+ setSymphonyStatus(ctx, runtime);
73
+ }
74
+ throw error;
75
+ }
76
+ })),
77
+ });
78
+
79
+ pi.registerCommand("symphony:attach", {
80
+ description: "Attach to an existing Symphony HTTP server, or the Pi-owned server when no URL is provided",
81
+ handler: async (args, ctx) => runCommandHandler(ctx, async () => withSymphonyProgress(ctx, { message: "Attaching to Symphony...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async () => {
82
+ const parsed = parseAttachArgs(args);
83
+ const url = resolveAttachUrl(parsed.url, runtime.state.ownedProcess);
84
+ assertLoopbackAttachUrl(url);
85
+ await runtime.attach(url);
86
+ runtime.persist(pi);
87
+ setSymphonyStatus(ctx, runtime);
88
+ ctx.ui.notify(`Attached to Symphony at ${runtime.state.attachedBaseUrl}`, "info");
89
+ })),
90
+ });
91
+
92
+ pi.registerCommand("symphony:console", {
93
+ description: "Open the Symphony console",
94
+ handler: async (_args, ctx) => runCommandHandler(ctx, async () => {
95
+ await openConsole(ctx, runtime);
96
+ }),
97
+ });
98
+
99
+ pi.registerCommand("symphony:detach", {
100
+ description: "Detach Pi from the current Symphony server without stopping it",
101
+ handler: async (_args, ctx) => runCommandHandler(ctx, async () => {
102
+ const attachedBaseUrl = runtime.state.attachedBaseUrl;
103
+ if (!attachedBaseUrl) {
104
+ ctx.ui.notify("No Symphony instance is attached.", "info");
105
+ return;
106
+ }
107
+
108
+ closeActiveConsole(ctx);
109
+ runtime.clearAttachment();
110
+ runtime.persist(pi);
111
+ setSymphonyStatus(ctx, runtime);
112
+ ctx.ui.notify(`Detached from Symphony at ${attachedBaseUrl}.`, "info");
113
+ }),
114
+ });
115
+
116
+ pi.registerCommand("symphony:status", {
117
+ description: "Show Symphony attachment and process status",
118
+ handler: async (_args, ctx) => runCommandHandler(ctx, async () => {
119
+ if (runtime.client) await runtime.refreshState();
120
+ runtime.persist(pi);
121
+ ctx.ui.notify(runtime.statusText(), "info");
122
+ }),
123
+ });
124
+
125
+ pi.registerCommand("symphony:refresh", {
126
+ description: "Request an immediate Symphony poll refresh",
127
+ handler: async (_args, ctx) => runCommandHandler(ctx, async () => withSymphonyProgress(ctx, { message: "Refreshing Symphony...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async () => {
128
+ await runtime.requestRefresh();
129
+ runtime.persist(pi);
130
+ ctx.ui.notify(`Symphony refresh requested; ${runtimeCountsText(runtime)}`, "info");
131
+ })),
132
+ });
133
+
134
+ pi.registerCommand("symphony:steer", {
135
+ description: "Send an operator instruction to a running Symphony worker",
136
+ handler: async (args, ctx) => runCommandHandler(ctx, async () => withSymphonyProgress(ctx, { message: "Sending steer instruction...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async (signal) => {
137
+ const parsed = parseSteerArgs(args);
138
+ const result = await runtime.steerWorker(parsed.issueIdentifier, parsed.instruction, signal);
139
+ runtime.persist(pi);
140
+ ctx.ui.notify(`Steer delivered to ${result.issueIdentifier}: ${result.instructionPreview}`, "info");
141
+ })),
142
+ });
143
+
144
+ pi.registerCommand("symphony:stop", {
145
+ description: "Stop a Symphony process started by this extension",
146
+ handler: async (_args, ctx) => runCommandHandler(ctx, async () => withSymphonyProgress(ctx, { message: "Stopping Symphony...", restoreStatus: (ctx) => setSymphonyStatus(ctx, runtime) }, async () => {
147
+ const ownedBaseUrl = runtime.state.ownedProcess?.baseUrl;
148
+ await runtime.processManager.stopOwned();
149
+ runtime.clearAttachmentIfBaseUrl(ownedBaseUrl);
150
+ runtime.persist(pi);
151
+ setSymphonyStatus(ctx, runtime);
152
+ ctx.ui.notify("Stopped owned Symphony process", "info");
153
+ })),
154
+ });
155
+ }
156
+
157
+ function registerConsoleShortcuts(pi: ExtensionAPI): void {
158
+ const shortcuts = [
159
+ { key: "ctrl+shift+up", action: "selectPrevious", description: "Select previous Symphony console item" },
160
+ { key: "ctrl+shift+down", action: "selectNext", description: "Select next Symphony console item" },
161
+ { key: "ctrl+shift+r", action: "refresh", description: "Refresh the Symphony console" },
162
+ { key: "ctrl+shift+t", action: "steer", description: "Steer the selected Symphony console worker" },
163
+ { key: "ctrl+shift+e", action: "respondEscalation", description: "Respond to the selected Symphony console escalation" },
164
+ { key: "ctrl+shift+i", action: "toggleDetails", description: "Toggle Symphony console item details" },
165
+ { key: "ctrl+shift+q", action: "close", description: "Close the Symphony console widget" },
166
+ ] as const satisfies ReadonlyArray<{ key: KeyId; action: ConsoleShortcutAction; description: string }>;
167
+
168
+ for (const shortcut of shortcuts) {
169
+ pi.registerShortcut(shortcut.key, {
170
+ description: shortcut.description,
171
+ handler: async (ctx) => {
172
+ await handleActiveConsoleShortcut(shortcut.action, ctx);
173
+ },
174
+ });
175
+ }
176
+ }
177
+
178
+ export function setSymphonyStatus(ctx: Pick<ExtensionContext, "ui">, runtime: SymphonyRuntime): void {
179
+ ctx.ui.setStatus("symphony", runtime.state.attachedBaseUrl ? `symphony ${runtime.state.attachedBaseUrl}` : "symphony detached");
180
+ }
181
+
182
+ async function runCommandHandler(ctx: ExtensionCommandContext, fn: () => Promise<void>): Promise<void> {
183
+ try {
184
+ await fn();
185
+ } catch (error) {
186
+ ctx.ui.notify(formatError(error), "error");
187
+ }
188
+ }
189
+
190
+ function runtimeCountsText(runtime: SymphonyRuntime): string {
191
+ const state = runtime.state.lastKnownState;
192
+ if (!state) return "state unavailable";
193
+ return `running ${state.runningCount}, retry ${state.retryCount}, blocked ${state.blockedCount}, completed ${state.completedCount}`;
194
+ }
195
+
196
+ function helpText(runtime: SymphonyRuntime): string {
197
+ return [
198
+ "Symphony Pi extension",
199
+ runtime.statusText(),
200
+ "",
201
+ "Commands:",
202
+ "/symphony:init [--force]",
203
+ "/symphony:doctor [workflow]",
204
+ "/symphony:start [workflow]",
205
+ "/symphony:attach [url]",
206
+ "/symphony:detach",
207
+ "/symphony:console",
208
+ "/symphony:status",
209
+ "/symphony:refresh",
210
+ "/symphony:steer <ISSUE> <instruction>",
211
+ "/symphony:stop",
212
+ "",
213
+ "Console keys:",
214
+ "↑/↓ select items",
215
+ "r refresh, s steer selected running worker, e respond to selected escalation",
216
+ "d details, q/Escape close",
217
+ ].join("\n");
218
+ }
@@ -0,0 +1,252 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildEscalationRows, buildIssueRows, buildWorkerRows, formatEventRows } from "./console-model.ts";
3
+ import type { SymphonyEventEnvelope, SymphonyStateResponse } from "./http-client.ts";
4
+
5
+ function stateFixture(): SymphonyStateResponse {
6
+ return {
7
+ tracker_project_url: "https://linear.app/kata-sh/project/symphony",
8
+ running: {
9
+ "issue-123": {
10
+ issue_id: "issue-123",
11
+ issue_identifier: "SIM-123",
12
+ issue_title: "Worker one",
13
+ attempt: 2,
14
+ workspace_path: "/tmp/symphony/issue-123",
15
+ started_at: "2026-05-14T12:00:00Z",
16
+ status: "running",
17
+ worker_host: "worker-a",
18
+ tracker_state: "In Progress",
19
+ },
20
+ "issue-777": {
21
+ issue_id: "issue-777",
22
+ issue_identifier: "SIM-777",
23
+ issue_title: "Worker two",
24
+ workspace_path: "/tmp/symphony/issue-777",
25
+ started_at: "2026-05-14T12:05:00Z",
26
+ status: "running",
27
+ error: "agent exited after a very long error message that needs to be shortened for the console",
28
+ },
29
+ },
30
+ running_sessions: {
31
+ "issue-123": { turn_count: 2, last_activity_at: "2026-05-14T12:03:00Z", last_event: "tool_call_completed", last_event_message: "running cargo test" },
32
+ },
33
+ running_session_info: {
34
+ "issue-123": { turn_count: 3, max_turns: 20, last_activity_ms: Date.parse("2026-05-14T12:04:00Z"), last_error: null },
35
+ "issue-777": { turn_count: 1, max_turns: 10, last_activity_ms: null, last_error: "usage limit" },
36
+ },
37
+ retry_queue: [],
38
+ blocked: [],
39
+ completed: [],
40
+ polling: { checking: false, next_poll_in_ms: 1000, poll_interval_ms: 30000 },
41
+ shared_context: { total_entries: 0, entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null },
42
+ supervisor: { active: true, steers_issued: 0, conflicts_detected: 0, patterns_detected: 0, escalations_created: 0 },
43
+ codex_totals: { input_tokens: 0, output_tokens: 0, total_tokens: 0, event_count: 0, seconds_running: 0 },
44
+ codex_rate_limits: null,
45
+ };
46
+ }
47
+
48
+ describe("console model", () => {
49
+ it("builds sorted worker rows with selected-worker detail fields", () => {
50
+ const rows = buildWorkerRows(stateFixture());
51
+
52
+ expect(rows.map((row) => row.issueIdentifier)).toEqual(["SIM-123", "SIM-777"]);
53
+ expect(rows[0]).toMatchObject({
54
+ issueId: "issue-123",
55
+ issueIdentifier: "SIM-123",
56
+ title: "Worker one",
57
+ trackerState: "In Progress",
58
+ attempt: "2",
59
+ turnCount: "3",
60
+ maxTurns: "20",
61
+ lastActivity: "2026-05-14T12:04:00.000Z",
62
+ workerHost: "worker-a",
63
+ workspacePath: "/tmp/symphony/issue-123",
64
+ errorPreview: "-",
65
+ });
66
+ expect(rows[1]?.attempt).toBe("1");
67
+ expect(rows[1]?.trackerState).toBe("-");
68
+ expect(rows[1]?.errorPreview).toBe("agent exited after a very long error message that needs to be shortened for the console");
69
+ });
70
+
71
+ it("builds issue rows across running, retry, blocked, and completed buckets", () => {
72
+ const state = stateFixture();
73
+ state.retry_queue = [
74
+ {
75
+ issue_id: "issue-retry",
76
+ identifier: "SIM-200",
77
+ attempt: 3,
78
+ due_in_ms: 90000,
79
+ error: "rate limit",
80
+ worker_host: "host-b",
81
+ workspace_path: "/tmp/retry",
82
+ },
83
+ ];
84
+ state.blocked = [
85
+ {
86
+ issue_id: "issue-blocked",
87
+ identifier: "SIM-300",
88
+ title: "Blocked work",
89
+ state: "Todo",
90
+ blocker_identifiers: ["SIM-100", "SIM-101"],
91
+ },
92
+ ];
93
+ state.completed = [
94
+ {
95
+ issue_id: "issue-done",
96
+ identifier: "SIM-400",
97
+ title: "Done work",
98
+ completed_at: "2026-05-14T13:00:00Z",
99
+ },
100
+ ];
101
+
102
+ const rows = buildIssueRows(state);
103
+
104
+ expect(rows.map((row) => `${row.kind}:${row.issueIdentifier}`)).toEqual([
105
+ "running:SIM-123",
106
+ "running:SIM-777",
107
+ "retry:SIM-200",
108
+ "blocked:SIM-300",
109
+ "completed:SIM-400",
110
+ ]);
111
+ expect(rows.find((row) => row.kind === "retry")).toMatchObject({
112
+ title: "rate limit",
113
+ status: "retry in 1m 30s",
114
+ attempt: "3",
115
+ workerHost: "host-b",
116
+ workspacePath: "/tmp/retry",
117
+ errorPreview: "rate limit",
118
+ });
119
+ expect(rows.find((row) => row.kind === "blocked")).toMatchObject({
120
+ status: "Todo",
121
+ blockers: "SIM-100, SIM-101",
122
+ });
123
+ expect(rows.find((row) => row.kind === "completed")).toMatchObject({
124
+ status: "completed",
125
+ completedAt: "2026-05-14T13:00:00Z",
126
+ });
127
+ });
128
+
129
+ it("rounds retry wait times up to the next second", () => {
130
+ const state = stateFixture();
131
+ state.retry_queue = [
132
+ {
133
+ issue_id: "issue-retry",
134
+ identifier: "SIM-200",
135
+ attempt: 1,
136
+ due_in_ms: 999,
137
+ },
138
+ ];
139
+
140
+ const retryRow = buildIssueRows(state).find((row) => row.kind === "retry");
141
+
142
+ expect(retryRow).toMatchObject({ status: "retry in 1s" });
143
+ });
144
+
145
+ it("preserves pending retry state when no retry error is present", () => {
146
+ const state = stateFixture();
147
+ state.retry_queue = [
148
+ {
149
+ issue_id: "issue-retry",
150
+ identifier: "SIM-200",
151
+ attempt: 1,
152
+ due_in_ms: 1000,
153
+ },
154
+ ];
155
+
156
+ const retryRow = buildIssueRows(state).find((row) => row.kind === "retry");
157
+
158
+ expect(retryRow).toMatchObject({
159
+ title: "pending retry",
160
+ trackerState: "retry",
161
+ });
162
+ });
163
+
164
+ it("builds escalation rows sorted by creation time and request id", () => {
165
+ const state = stateFixture();
166
+ state.pending_escalations = [
167
+ {
168
+ request_id: "esc-3",
169
+ issue_id: "issue-456",
170
+ issue_identifier: "SIM-456",
171
+ method: "approval",
172
+ preview: "Review matching timestamp command",
173
+ created_at: "2026-05-14T12:02:00Z",
174
+ timeout_ms: 30000,
175
+ },
176
+ {
177
+ request_id: "esc-2",
178
+ issue_id: "issue-777",
179
+ issue_identifier: "SIM-777",
180
+ method: "approval",
181
+ preview: "Review command",
182
+ created_at: "2026-05-14T12:02:00Z",
183
+ timeout_ms: 30000,
184
+ },
185
+ {
186
+ request_id: "esc-1",
187
+ issue_id: "issue-123",
188
+ issue_identifier: "SIM-123",
189
+ method: "approval",
190
+ preview: "Approve command",
191
+ created_at: "2026-05-14T12:01:00Z",
192
+ timeout_ms: 600000,
193
+ },
194
+ ];
195
+
196
+ const rows = buildEscalationRows(state);
197
+
198
+ expect(rows.map((row) => row.requestId)).toEqual(["esc-1", "esc-2", "esc-3"]);
199
+ expect(rows[0]).toMatchObject({
200
+ method: "approval",
201
+ preview: "Approve command",
202
+ timeout: "10m 0s",
203
+ });
204
+ });
205
+
206
+ it("formats escalation lifecycle events newest first", () => {
207
+ const events: SymphonyEventEnvelope[] = [
208
+ { version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "escalation_created", severity: "info", issue: "SIM-123", event: "escalation_created", payload: { summary: "Approve command" } },
209
+ { version: "v1", sequence: 2, timestamp: "2026-05-14T12:01:00Z", kind: "escalation_responded", severity: "info", issue: "SIM-123", event: "escalation_responded", payload: { summary: "Approved" } },
210
+ { version: "v1", sequence: 3, timestamp: "2026-05-14T12:02:00Z", kind: "escalation_timed_out", severity: "warn", issue: "SIM-777", event: "escalation_timed_out", payload: { summary: "Timed out" } },
211
+ ];
212
+
213
+ expect(formatEventRows(events)).toEqual([
214
+ "2026-05-14T12:02:00Z warn escalation_timed_out SIM-777 escalation_timed_out Timed out",
215
+ "2026-05-14T12:01:00Z info escalation_responded SIM-123 escalation_responded Approved",
216
+ "2026-05-14T12:00:00Z info escalation_created SIM-123 escalation_created Approve command",
217
+ ]);
218
+ });
219
+
220
+ it("formats only recent worker and runtime events", () => {
221
+ const events: SymphonyEventEnvelope[] = [
222
+ { version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "heartbeat", severity: "info", event: "heartbeat", payload: {} },
223
+ { version: "v1", sequence: 2, timestamp: "2026-05-14T12:01:00Z", kind: "runtime", severity: "info", event: "poll_completed", payload: { summary: "checked tracker" } },
224
+ { version: "v1", sequence: 3, timestamp: "2026-05-14T12:02:00Z", kind: "worker", severity: "error", issue: "SIM-123", event: "worker_failed", payload: { error_preview: "usage limit" } },
225
+ ];
226
+
227
+ expect(formatEventRows(events)).toEqual([
228
+ "2026-05-14T12:02:00Z error worker SIM-123 worker_failed usage limit",
229
+ "2026-05-14T12:01:00Z info runtime - poll_completed checked tracker",
230
+ ]);
231
+ });
232
+
233
+ it("includes worker failure errors and steer instructions in event summaries", () => {
234
+ const events: SymphonyEventEnvelope[] = [
235
+ { version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "worker", severity: "error", issue: "SIM-123", event: "worker_failed", payload: { error: "agent exited with code 1" } },
236
+ { version: "v1", sequence: 2, timestamp: "2026-05-14T12:01:00Z", kind: "runtime", severity: "info", issue: "SIM-123", event: "steer_received", payload: { instruction_preview: "Focus on failing tests" } },
237
+ ];
238
+
239
+ expect(formatEventRows(events)).toEqual([
240
+ "2026-05-14T12:01:00Z info runtime SIM-123 steer_received Focus on failing tests",
241
+ "2026-05-14T12:00:00Z error worker SIM-123 worker_failed agent exited with code 1",
242
+ ]);
243
+ });
244
+
245
+ it("normalizes multiline event summaries to one row", () => {
246
+ const events: SymphonyEventEnvelope[] = [
247
+ { version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "worker", severity: "error", issue: "SIM-123", event: "worker_failed", payload: { error: "first line\n\tsecond line" } },
248
+ ];
249
+
250
+ expect(formatEventRows(events)).toEqual(["2026-05-14T12:00:00Z error worker SIM-123 worker_failed first line second line"]);
251
+ });
252
+ });