fix(daemon): normalize cmux workspace list responses

Dogfood against cmux 0.63.2 showed `cmux list-workspaces --json`
can return plain text even though the flag is accepted. Prefer the RPC
workspace.list surface when available, normalize ref/title rows to the
adapter's id/name shape, and keep a plaintext fallback for older command
surfaces.

Tests:
- npm --workspace packages/daemon test -- cmux-transport.test.ts cmux-transport-layout-methods.test.ts rig-cmux-launch-route.test.ts
- npx vitest run test/cmux-adapter-layout-methods.test.ts test/cmux-transport-layout-methods.test.ts test/cmux-adapter.test.ts test/cmux-transport.test.ts test/cmux-layout-service.test.ts test/rig-cmux-launch-route.test.ts
- npm --workspace packages/daemon run build
This commit is contained in:
mvschwarz committed 2026-05-11 22:55:26 -07:00
1 parent a2bc4ba337
commit 20820b235a
2 files changed
+115 -4

No files matched your search

+67 -2
View File
@@ -66,6 +66,9 @@ function buildCommand(
return { cmd: "cmux capabilities --json", json: true };
}
if (method === "workspace.list") {
if (ctx.supported.has("rpc")) {
return { cmd: "cmux rpc workspace.list", json: true };
}
return { cmd: "cmux list-workspaces --json", json: true };
}
if (method === "workspace.current") {
@@ -163,10 +166,21 @@ function buildCommand(
/**
* Normalize JSON payloads from version-adaptive commands so downstream
* consumers see one stable shape regardless of which cmux command actually
* answered. Currently aliases `panels` (cmux ≥0.63 `list-panels`) onto
* `surfaces` (legacy `list-surfaces`); leaves other shapes untouched.
* answered.
*/
function normalizePayload(method: string, raw: unknown): unknown {
if (method === "workspace.list") {
if (raw === null || typeof raw !== "object") return raw;
const obj = raw as Record<string, unknown>;
if (!Array.isArray(obj.workspaces)) return raw;
return {
...obj,
workspaces: obj.workspaces
.map(normalizeWorkspaceRow)
.filter((workspace): workspace is { id: string; name: string } => workspace !== null),
};
}
if (method !== "surface.list") return raw;
if (raw === null || typeof raw !== "object") return raw;
const obj = raw as Record<string, unknown>;
@@ -182,6 +196,29 @@ function normalizePayload(method: string, raw: unknown): unknown {
return raw;
}
function normalizeWorkspaceRow(row: unknown): { id: string; name: string } | null {
if (row === null || typeof row !== "object") return null;
const obj = row as Record<string, unknown>;
const id =
typeof obj.ref === "string"
? obj.ref
: typeof obj.workspace_ref === "string"
? obj.workspace_ref
: typeof obj.id === "string"
? obj.id
: typeof obj.workspace_id === "string"
? obj.workspace_id
: "";
if (!id) return null;
const name =
typeof obj.name === "string" && obj.name.trim()
? obj.name.trim()
: typeof obj.title === "string" && obj.title.trim()
? obj.title.trim()
: id;
return { id, name };
}
/**
* CLI-based CmuxTransportFactory.
*
@@ -262,9 +299,37 @@ function legacyJsonFallback(method: string, output: string): unknown | null {
}
}
if (method === "workspace.list") {
const workspaces = parsePlainTextWorkspaceRows(trimmed);
if (workspaces) {
return { workspaces };
}
}
return null;
}
function parsePlainTextWorkspaceRows(output: string): Array<{ id: string; name: string }> | null {
const workspaces: Array<{ id: string; name: string }> = [];
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const rowMatch = line.match(/^(?:\*\s+)?(workspace:[^\s]+)(?:\s+(.*?))?\s*$/);
if (!rowMatch || !rowMatch[1]) {
return null;
}
const rawName = (rowMatch[2] ?? "").replace(/\s+\[[^\]]+\]\s*$/g, "").trim();
workspaces.push({
id: rowMatch[1],
name: rawName || rowMatch[1],
});
}
return workspaces;
}
function parsePlainTextSurfaceRows(output: string): Array<{ id: string; title: string; type: string }> | null {
const surfaces: Array<{ id: string; title: string; type: string }> = [];
const knownSurfaceTypes = new Set(["terminal", "browser", "markdown"]);
+48 -2
View File
@@ -8,7 +8,7 @@ import type { ExecFn } from "../src/adapters/tmux.js";
* has removed the older `list-surfaces` and `agent-pids` commands.
* cmux <0.63 exposed `list-surfaces` and `agent-pids` instead.
*/
function helpText(opts: { modern?: boolean; legacy?: boolean }): string {
function helpText(opts: { modern?: boolean; legacy?: boolean; rpc?: boolean }): string {
const lines = [
"cmux - control cmux via Unix socket",
"",
@@ -31,6 +31,9 @@ function helpText(opts: { modern?: boolean; legacy?: boolean }): string {
if (opts.legacy) {
lines.push(" list-surfaces", " agent-pids");
}
if (opts.rpc) {
lines.push(" rpc <method> [json-params]");
}
return lines.join("\n") + "\n";
}
@@ -42,6 +45,7 @@ function helpText(opts: { modern?: boolean; legacy?: boolean }): string {
function mockExec(opts: {
modern?: boolean;
legacy?: boolean;
rpc?: boolean;
overrides?: Record<string, string | ((cmd: string) => string)>;
} = {}): ExecFn {
const impl = async (cmd: string): Promise<string> => {
@@ -86,7 +90,29 @@ describe("cmux CLI transport — stable commands (unchanged across versions)", (
expect(exec).toHaveBeenCalledWith("cmux capabilities --json");
});
it("request('workspace.list') -> exact: cmux list-workspaces --json", async () => {
it("request('workspace.list') prefers rpc when available and maps titles to names", async () => {
const exec = mockExec({
modern: true,
rpc: true,
overrides: {
"cmux rpc workspace.list":
'{"workspaces":[{"ref":"workspace:7","title":"qa6-cmux"},{"id":"workspace-uuid","name":"manual-name"}]}',
},
});
const transport = await createCmuxCliTransport(exec)();
const result = await transport.request("workspace.list");
expect(exec).toHaveBeenCalledWith("cmux rpc workspace.list");
expect(result).toEqual({
workspaces: [
{ id: "workspace:7", name: "qa6-cmux" },
{ id: "workspace-uuid", name: "manual-name" },
],
});
});
it("request('workspace.list') falls back to list-workspaces when rpc is unavailable", async () => {
const exec = mockExec({ modern: true, overrides: { "cmux list-workspaces --json": '{"workspaces":[]}' } });
const transport = await createCmuxCliTransport(exec)();
@@ -95,6 +121,26 @@ describe("cmux CLI transport — stable commands (unchanged across versions)", (
expect(exec).toHaveBeenCalledWith("cmux list-workspaces --json");
});
it("request('workspace.list') falls back to plain-text rows when list-workspaces --json prints text", async () => {
const exec = mockExec({
modern: true,
overrides: {
"cmux list-workspaces --json":
"* workspace:1 qa-rig [selected]\n workspace:2 tmux attach -t qa-rig@openrig\n",
},
});
const transport = await createCmuxCliTransport(exec)();
const result = await transport.request("workspace.list");
expect(result).toEqual({
workspaces: [
{ id: "workspace:1", name: "qa-rig" },
{ id: "workspace:2", name: "tmux attach -t qa-rig@openrig" },
],
});
});
it("request('workspace.current') -> exact: cmux current-workspace --json", async () => {
const exec = mockExec({
modern: true,