@kata-sh/pi-symphony-extension 0.1.2 → 2.3.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.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @kata-sh/pi-symphony-extension
2
2
 
3
- Pi extension for initializing, launching, attaching to, and monitoring Kata Symphony.
3
+ Pi extension for initializing, launching, attaching to, and monitoring Kata Symphony from Pi Coding Agent. It adds `/symphony:*` commands plus a live console for active workers, retry queue entries, blocked issues, completed issues, and pending escalations.
4
+
5
+ ![Symphony console in Pi Coding Agent](assets/symphony-console.png)
4
6
 
5
7
  ## Requirements
6
8
 
@@ -40,6 +42,49 @@ pnpm --dir apps/symphony/pi-extension typecheck
40
42
 
41
43
  Run it locally with `pi -e ./apps/symphony/pi-extension`.
42
44
 
45
+ ## Screenshot demo with mock data
46
+
47
+ Use the mock server to show an active Symphony session without tracker credentials or real workers.
48
+
49
+ 1. Start the seeded mock Symphony API:
50
+
51
+ ```sh
52
+ pnpm --dir apps/symphony/pi-extension run mock:wave3
53
+ ```
54
+
55
+ If port `8787` is already in use, choose another port:
56
+
57
+ ```sh
58
+ pnpm --dir apps/symphony/pi-extension run mock:wave3 -- --port 8788
59
+ ```
60
+
61
+ Expected output includes:
62
+
63
+ ```text
64
+ Mock Symphony server: http://127.0.0.1:8787
65
+ /symphony:attach http://127.0.0.1:8787
66
+ /symphony:console
67
+ ```
68
+
69
+ Use the printed port in the attach command when you override `--port`.
70
+
71
+ 2. Start Pi with the local extension:
72
+
73
+ ```sh
74
+ pi -e ./apps/symphony/pi-extension
75
+ ```
76
+
77
+ 3. In Pi, run:
78
+
79
+ ```text
80
+ /symphony:attach http://127.0.0.1:8787
81
+ /symphony:console
82
+ ```
83
+
84
+ 4. Use `↑` / `↓` to select the running worker, retry queue, blocked issue, completed issue, or pending escalation. Capture the screenshot when the console shows the seeded `SIM-*` items.
85
+
86
+ Suggested screenshot path for this README: `apps/symphony/pi-extension/assets/symphony-console.png`.
87
+
43
88
  ## Commands through Wave 3
44
89
 
45
90
  - `/symphony:help`
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@kata-sh/pi-symphony-extension",
3
- "version": "0.1.2",
3
+ "version": "2.3.1",
4
4
  "description": "Pi extension for launching, attaching to, and monitoring Kata Symphony",
5
5
  "license": "MIT",
6
6
  "private": false,
7
7
  "type": "module",
8
- "keywords": ["pi-package", "symphony", "kata"],
8
+ "keywords": [
9
+ "pi-package",
10
+ "symphony",
11
+ "kata"
12
+ ],
9
13
  "repository": {
10
14
  "type": "git",
11
15
  "url": "git+https://github.com/gannonh/kata.git",
@@ -15,7 +19,12 @@
15
19
  ".": "./src/index.ts",
16
20
  "./package.json": "./package.json"
17
21
  },
18
- "files": ["src", "scripts", "README.md", "package.json"],
22
+ "files": [
23
+ "src",
24
+ "scripts",
25
+ "README.md",
26
+ "package.json"
27
+ ],
19
28
  "scripts": {
20
29
  "build": "tsc --noEmit",
21
30
  "typecheck": "tsc --noEmit",
@@ -25,7 +34,9 @@
25
34
  "lint": "eslint src/ --max-warnings=0"
26
35
  },
27
36
  "pi": {
28
- "extensions": ["./src/index.ts"]
37
+ "extensions": [
38
+ "./src/index.ts"
39
+ ]
29
40
  },
30
41
  "peerDependencies": {
31
42
  "@earendil-works/pi-ai": "*",
@@ -20,6 +20,16 @@ const server = createServer((req, res) => {
20
20
  });
21
21
  });
22
22
 
23
+ server.on("error", (error) => {
24
+ if (error?.code === "EADDRINUSE") {
25
+ console.error(`Port ${options.port} is already in use on ${options.host}.`);
26
+ console.error("Try another port, for example:");
27
+ console.error("pnpm --dir apps/symphony/pi-extension run mock:wave3 -- --port 8788");
28
+ process.exit(1);
29
+ }
30
+ throw error;
31
+ });
32
+
23
33
  server.listen(options.port, options.host, () => {
24
34
  const address = server.address();
25
35
  if (!address || typeof address === "string") {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildEscalationRows, buildIssueRows, buildWorkerRows, formatEventRows } from "./console-model.ts";
2
+ import { buildEscalationRows, buildIssueRows, buildTriageRows, buildWorkerRows, formatEventRows } from "./console-model.ts";
3
3
  import type { SymphonyEventEnvelope, SymphonyStateResponse } from "./http-client.ts";
4
4
 
5
5
  function stateFixture(): SymphonyStateResponse {
@@ -126,6 +126,50 @@ describe("console model", () => {
126
126
  });
127
127
  });
128
128
 
129
+ it("includes triage sessions between running and retry rows", () => {
130
+ const state = stateFixture();
131
+ state.triage_sessions = [
132
+ {
133
+ issue_identifier: "#1",
134
+ run_id: "run-1",
135
+ stage_run_id: "stage-1",
136
+ attempt: 2,
137
+ harness: "pi",
138
+ model: "openai-codex/gpt-5.6-luna",
139
+ started_at: "2026-05-14T12:00:00Z",
140
+ last_activity_at: "2026-05-14T12:00:05Z",
141
+ last_event: "tool_execution_start",
142
+ last_event_message: "running read",
143
+ current_tool_name: "read",
144
+ current_tool_args_preview: "README.md",
145
+ total_tokens: 12,
146
+ },
147
+ ];
148
+
149
+ const issueRows = buildIssueRows(state);
150
+ const triageRows = buildTriageRows(state);
151
+
152
+ expect(issueRows.map((row) => `${row.kind}:${row.issueIdentifier}`)).toEqual([
153
+ "running:SIM-123",
154
+ "running:SIM-777",
155
+ "triage:#1",
156
+ ]);
157
+ expect(triageRows).toEqual([
158
+ {
159
+ issueIdentifier: "#1",
160
+ stageRunId: "stage-1",
161
+ attempt: "2",
162
+ harness: "pi",
163
+ model: "openai-codex/gpt-5.6-luna",
164
+ status: "triage#2",
165
+ lastEvent: "tool_execution_start",
166
+ message: "read: README.md",
167
+ lastActivity: "2026-05-14T12:00:05Z",
168
+ tokens: "12",
169
+ },
170
+ ]);
171
+ });
172
+
129
173
  it("rounds retry wait times up to the next second", () => {
130
174
  const state = stateFixture();
131
175
  state.retry_queue = [
@@ -6,6 +6,7 @@ import type {
6
6
  RunAttemptResponse,
7
7
  SymphonyEventEnvelope,
8
8
  SymphonyStateResponse,
9
+ TriageSessionResponse,
9
10
  } from "./http-client.ts";
10
11
 
11
12
  export interface WorkerRow {
@@ -23,7 +24,7 @@ export interface WorkerRow {
23
24
  errorPreview: string;
24
25
  }
25
26
 
26
- export type IssueRowKind = "running" | "retry" | "blocked" | "completed";
27
+ export type IssueRowKind = "running" | "triage" | "retry" | "blocked" | "completed";
27
28
 
28
29
  export interface IssueRow {
29
30
  kind: IssueRowKind;
@@ -43,6 +44,19 @@ export interface IssueRow {
43
44
  completedAt: string;
44
45
  }
45
46
 
47
+ export interface TriageRow {
48
+ issueIdentifier: string;
49
+ stageRunId: string;
50
+ attempt: string;
51
+ harness: string;
52
+ model: string;
53
+ status: string;
54
+ lastEvent: string;
55
+ message: string;
56
+ lastActivity: string;
57
+ tokens: string;
58
+ }
59
+
46
60
  export interface EscalationRow {
47
61
  requestId: string;
48
62
  issueId: string;
@@ -66,12 +80,31 @@ export function buildIssueRows(state: SymphonyStateResponse | undefined): IssueR
66
80
  ...Object.entries(state.running ?? {})
67
81
  .map(([issueId, attempt]) => workerToIssueRow(issueId, attempt, state))
68
82
  .sort(compareIssueRows),
83
+ ...(state.triage_sessions ?? []).map(triageToIssueRow).sort(compareIssueRows),
69
84
  ...(state.retry_queue ?? []).map(retryToIssueRow).sort(compareIssueRows),
70
85
  ...(state.blocked ?? []).map(blockedToIssueRow).sort(compareIssueRows),
71
86
  ...(state.completed ?? []).map(completedToIssueRow).sort(compareIssueRows),
72
87
  ];
73
88
  }
74
89
 
90
+ export function buildTriageRows(state: SymphonyStateResponse | undefined): TriageRow[] {
91
+ return (state?.triage_sessions ?? [])
92
+ .slice()
93
+ .sort((left, right) => left.issue_identifier.localeCompare(right.issue_identifier))
94
+ .map((session) => ({
95
+ issueIdentifier: session.issue_identifier,
96
+ stageRunId: session.stage_run_id,
97
+ attempt: String(session.attempt),
98
+ harness: session.harness,
99
+ model: session.model?.trim() || "-",
100
+ status: `triage#${session.attempt}`,
101
+ lastEvent: session.last_event?.trim() || "-",
102
+ message: triageMessage(session),
103
+ lastActivity: session.last_activity_at ?? session.started_at,
104
+ tokens: session.total_tokens === undefined ? "-" : String(session.total_tokens),
105
+ }));
106
+ }
107
+
75
108
  export function buildEscalationRows(state: SymphonyStateResponse | undefined): EscalationRow[] {
76
109
  return (state?.pending_escalations ?? [])
77
110
  .slice()
@@ -89,12 +122,47 @@ export function buildEscalationRows(state: SymphonyStateResponse | undefined): E
89
122
 
90
123
  export function formatEventRows(events: SymphonyEventEnvelope[], limit = 8): string[] {
91
124
  return events
92
- .filter((event) => event.kind === "worker" || event.kind === "runtime" || event.kind.startsWith("escalation_"))
125
+ .filter(
126
+ (event) =>
127
+ event.kind === "worker" ||
128
+ event.kind === "runtime" ||
129
+ event.kind === "triage" ||
130
+ event.kind.startsWith("escalation_") ||
131
+ event.event.startsWith("triage_"),
132
+ )
93
133
  .slice(-limit)
94
134
  .reverse()
95
135
  .map((event) => [event.timestamp, event.severity, event.kind, event.issue ?? "-", event.event, eventSummary(event)].filter(Boolean).join(" "));
96
136
  }
97
137
 
138
+ function triageToIssueRow(session: TriageSessionResponse): IssueRow {
139
+ return {
140
+ kind: "triage",
141
+ issueId: session.stage_run_id,
142
+ issueIdentifier: session.issue_identifier,
143
+ title: triageMessage(session),
144
+ status: `triage#${session.attempt}`,
145
+ trackerState: "triage",
146
+ attempt: String(session.attempt),
147
+ turnCount: "1",
148
+ maxTurns: "1",
149
+ lastActivity: session.last_activity_at ?? session.started_at,
150
+ workerHost: session.harness,
151
+ workspacePath: session.session_id ?? "-",
152
+ errorPreview: "-",
153
+ blockers: "-",
154
+ completedAt: "-",
155
+ };
156
+ }
157
+
158
+ function triageMessage(session: TriageSessionResponse): string {
159
+ if (session.current_tool_name) {
160
+ const args = session.current_tool_args_preview?.trim();
161
+ return args ? `${session.current_tool_name}: ${truncateText(args, 80)}` : session.current_tool_name;
162
+ }
163
+ return session.last_event_message?.trim() || session.last_event?.trim() || "running";
164
+ }
165
+
98
166
  function workerToIssueRow(issueId: string, attempt: RunAttemptResponse, state: SymphonyStateResponse | undefined): IssueRow {
99
167
  return {
100
168
  kind: "running",
package/src/console.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { buildEscalationRows, buildIssueRows, buildWorkerRows, formatEventRows, type EscalationRow, type IssueRow, type WorkerRow } from "./console-model.ts";
3
+ import { buildEscalationRows, buildIssueRows, buildTriageRows, buildWorkerRows, formatEventRows, type EscalationRow, type IssueRow, type TriageRow, type WorkerRow } from "./console-model.ts";
4
4
  import { startSymphonyEventStream, type EventStreamHandle } from "./event-stream.ts";
5
5
  import type { SymphonyEventEnvelope, SymphonyStateResponse } from "./http-client.ts";
6
6
  import type { SymphonyRuntime } from "./runtime.ts";
@@ -124,6 +124,8 @@ export class SymphonyConsoleComponent {
124
124
  const escalationRows: EscalationRow[] = buildEscalationRows(symphonyState);
125
125
  this.clampSelection(issueRows.length + escalationRows.length);
126
126
  const runningRows = issueRows.filter((row) => row.kind === "running");
127
+ const triageRows = issueRows.filter((row) => row.kind === "triage");
128
+ const triageDetailRows: TriageRow[] = buildTriageRows(symphonyState);
127
129
  const retryRows = issueRows.filter((row) => row.kind === "retry");
128
130
  const blockedRows = issueRows.filter((row) => row.kind === "blocked");
129
131
  const completedRows = issueRows.filter((row) => row.kind === "completed");
@@ -132,6 +134,10 @@ export class SymphonyConsoleComponent {
132
134
  const connection = state.attachedBaseUrl ? color(theme, "success", "attached") : color(theme, "error", "detached");
133
135
  const polling = health?.pollingChecking ? color(theme, "warning", "checking") : color(theme, "success", "idle");
134
136
  const consoleWidth = Math.max(44, width);
137
+ const triageOffset = runningRows.length;
138
+ const retryOffset = triageOffset + triageRows.length;
139
+ const blockedOffset = retryOffset + retryRows.length;
140
+ const completedOffset = blockedOffset + blockedRows.length;
135
141
  const lines = [
136
142
  color(theme, "accent", bold(theme, "Symphony Console")),
137
143
  "",
@@ -145,13 +151,14 @@ export class SymphonyConsoleComponent {
145
151
  ], consoleWidth, theme),
146
152
  "",
147
153
  ...boxLines("Worker Summary", [
148
- `workers: ${color(theme, "success", `running: ${health?.runningCount ?? workers.length}`)} | ${color(theme, "warning", `retry: ${health?.retryCount ?? retryRows.length}`)} | ${color(theme, "error", `blocked: ${health?.blockedCount ?? blockedRows.length}`)} | ${color(theme, "accent", `completed: ${health?.completedCount ?? completedRows.length}`)}`,
154
+ `workers: ${color(theme, "success", `running: ${health?.runningCount ?? workers.length}`)} | ${color(theme, "accent", `triage: ${triageRows.length}`)} | ${color(theme, "warning", `retry: ${health?.retryCount ?? retryRows.length}`)} | ${color(theme, "error", `blocked: ${health?.blockedCount ?? blockedRows.length}`)} | ${color(theme, "accent", `completed: ${health?.completedCount ?? completedRows.length}`)}`,
149
155
  ], consoleWidth, theme),
150
156
  "",
151
157
  ...boxLines("Running Workers", renderIssueTable(runningRows, this.selectedIndex, 0, theme), consoleWidth, theme),
152
- ...boxLines("Retry Queue", renderIssueTable(retryRows, this.selectedIndex, runningRows.length, theme), consoleWidth, theme),
153
- ...boxLines("Blocked Issues", renderIssueTable(blockedRows, this.selectedIndex, runningRows.length + retryRows.length, theme), consoleWidth, theme),
154
- ...boxLines("Completed Issues", renderIssueTable(completedRows, this.selectedIndex, runningRows.length + retryRows.length + blockedRows.length, theme), consoleWidth, theme),
158
+ ...boxLines("Triage", renderTriageTable(triageDetailRows, this.selectedIndex, triageOffset, theme), consoleWidth, theme),
159
+ ...boxLines("Retry Queue", renderIssueTable(retryRows, this.selectedIndex, retryOffset, theme), consoleWidth, theme),
160
+ ...boxLines("Blocked Issues", renderIssueTable(blockedRows, this.selectedIndex, blockedOffset, theme), consoleWidth, theme),
161
+ ...boxLines("Completed Issues", renderIssueTable(completedRows, this.selectedIndex, completedOffset, theme), consoleWidth, theme),
155
162
  ...boxLines("Selected Issue", renderSelectedIssueDetails(selectedIssue, state.console.showDetails, theme), consoleWidth, theme),
156
163
  ...boxLines("Pending Escalations", renderEscalationTable(escalationRows, this.selectedIndex - issueRows.length, theme), consoleWidth, theme),
157
164
  ...boxLines("Selected Escalation", renderSelectedEscalationDetails(escalationRows[this.selectedIndex - issueRows.length], state.console.showDetails, theme), consoleWidth, theme),
@@ -295,6 +302,27 @@ function renderIssueTable(rows: IssueRow[], selectedIndex: number, selectedOffse
295
302
  return lines;
296
303
  }
297
304
 
305
+ function renderTriageTable(rows: TriageRow[], selectedIndex: number, selectedOffset: number, theme?: ConsoleTheme): string[] {
306
+ const lines = [color(theme, "dim", "sel issue attempt harness model event message")];
307
+ if (rows.length === 0) return [...lines, color(theme, "dim", "- none")];
308
+
309
+ for (const [index, row] of rows.entries()) {
310
+ const rowIndex = selectedOffset + index;
311
+ const selected = rowIndex === selectedIndex ? ">" : " ";
312
+ const line = [
313
+ selected,
314
+ pad(row.issueIdentifier, 8),
315
+ pad(row.attempt, 7),
316
+ pad(row.harness, 8),
317
+ pad(row.model, 21),
318
+ pad(row.lastEvent, 16),
319
+ row.message,
320
+ ].join(" ");
321
+ lines.push(rowIndex === selectedIndex ? selectedLine(theme, line) : line);
322
+ }
323
+ return lines;
324
+ }
325
+
298
326
  function renderSelectedIssueDetails(issue: IssueRow | undefined, showDetails: boolean, theme?: ConsoleTheme): string[] {
299
327
  if (!showDetails) return [];
300
328
  if (!issue) return [color(theme, "dim", "none")];
@@ -315,6 +343,15 @@ function renderSelectedIssueDetails(issue: IssueRow | undefined, showDetails: bo
315
343
  `workspace: ${color(theme, "dim", issue.workspacePath)}`,
316
344
  `error: ${issue.errorPreview === "-" ? color(theme, "dim", issue.errorPreview) : color(theme, "error", issue.errorPreview)}`,
317
345
  );
346
+ } else if (issue.kind === "triage") {
347
+ lines.push(
348
+ `stage: triage`,
349
+ `attempt: ${issue.attempt}`,
350
+ `harness: ${issue.workerHost}`,
351
+ `last activity: ${color(theme, "dim", issue.lastActivity)}`,
352
+ `session: ${color(theme, "dim", issue.workspacePath)}`,
353
+ `detail: ${issue.title}`,
354
+ );
318
355
  } else if (issue.kind === "retry") {
319
356
  lines.push(
320
357
  `attempt: ${issue.attempt}`,
@@ -145,6 +145,23 @@ export interface CodexTotalsResponse {
145
145
  seconds_running: number;
146
146
  }
147
147
 
148
+ export interface TriageSessionResponse {
149
+ issue_identifier: string;
150
+ run_id: string;
151
+ stage_run_id: string;
152
+ attempt: number;
153
+ harness: string;
154
+ model?: string | null;
155
+ started_at: string;
156
+ last_activity_at?: string | null;
157
+ last_event?: string | null;
158
+ last_event_message?: string | null;
159
+ session_id?: string | null;
160
+ current_tool_name?: string | null;
161
+ current_tool_args_preview?: string | null;
162
+ total_tokens?: number;
163
+ }
164
+
148
165
  export interface SymphonyStateResponse {
149
166
  tracker_project_url?: string | null;
150
167
  running?: Record<string, RunAttemptResponse>;
@@ -154,6 +171,8 @@ export interface SymphonyStateResponse {
154
171
  blocked: BlockedIssueResponse[];
155
172
  pending_escalations?: PendingEscalationResponse[];
156
173
  completed: CompletedIssueResponse[];
174
+ /** In-flight A1 triage attempts (optional for older Symphony builds). */
175
+ triage_sessions?: TriageSessionResponse[];
157
176
  polling?: {
158
177
  checking?: boolean;
159
178
  next_poll_in_ms?: number;
@@ -406,6 +425,7 @@ function validateSymphonyStateResponse(value: unknown, details: Record<string, u
406
425
  validateOptionalRecord(value, "running_sessions", details);
407
426
  validateOptionalRecord(value, "running_session_info", details);
408
427
  validateOptionalArray(value, "claimed", details);
428
+ validateOptionalTriageSessions(value, details);
409
429
  validatePendingEscalations(value, "pending_escalations", details);
410
430
  validateSharedContextSummary(value.shared_context, details, "shared_context", throwNonSymphonyState);
411
431
  validateSupervisorSnapshot(value.supervisor, details, "supervisor", throwNonSymphonyState);
@@ -748,6 +768,31 @@ function validateOptionalArray(value: Record<string, unknown>, field: string, de
748
768
  }
749
769
  }
750
770
 
771
+ function validateOptionalTriageSessions(value: Record<string, unknown>, details: Record<string, unknown>): void {
772
+ const fieldValue = value.triage_sessions;
773
+ if (fieldValue === undefined) return;
774
+ if (!Array.isArray(fieldValue)) {
775
+ throwNonSymphonyState(details, "state response field had an invalid shape", {
776
+ field: "triage_sessions",
777
+ expected: "array",
778
+ });
779
+ }
780
+ fieldValue.forEach((entry, index) => {
781
+ if (!isRecord(entry)) {
782
+ throwNonSymphonyState(details, "state response field had an invalid shape", {
783
+ field: `triage_sessions.${index}`,
784
+ expected: "object",
785
+ });
786
+ }
787
+ validateRequiredString(entry, "issue_identifier", details, `triage_sessions.${index}.issue_identifier`);
788
+ validateRequiredString(entry, "run_id", details, `triage_sessions.${index}.run_id`);
789
+ validateRequiredString(entry, "stage_run_id", details, `triage_sessions.${index}.stage_run_id`);
790
+ validateRequiredNumber(entry, "attempt", details, `triage_sessions.${index}.attempt`);
791
+ validateRequiredString(entry, "harness", details, `triage_sessions.${index}.harness`);
792
+ validateRequiredString(entry, "started_at", details, `triage_sessions.${index}.started_at`);
793
+ });
794
+ }
795
+
751
796
  function validateOptionalRecord(value: Record<string, unknown>, field: string, details: Record<string, unknown>, detailField = field): void {
752
797
  const fieldValue = value[field];
753
798
  if (fieldValue === undefined) return;