Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.
- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
`readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
emit binary frames when set; legacy JSON path remains for old clients.
Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
`expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
removed.
132 lines
3.8 KiB
TypeScript
132 lines
3.8 KiB
TypeScript
import type { Command } from "commander";
|
|
import { homedir } from "node:os";
|
|
import { basename, join, sep } from "node:path";
|
|
import type { DaemonClient } from "@getpaseo/server";
|
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
|
|
|
/** Worktree list item for display */
|
|
export interface WorktreeListItem {
|
|
name: string;
|
|
branch: string;
|
|
cwd: string;
|
|
agent: string;
|
|
}
|
|
|
|
/** Shorten home directory in path */
|
|
function shortenPath(path: string): string {
|
|
const home = process.env.HOME;
|
|
if (home && path.startsWith(home)) {
|
|
return "~" + path.slice(home.length);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/** Extract worktree name from path */
|
|
function extractWorktreeName(path: string): string {
|
|
return basename(path);
|
|
}
|
|
|
|
export function resolvePaseoHomePath(): string {
|
|
return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
}
|
|
|
|
export function resolvePaseoWorktreesDir(): string {
|
|
return join(resolvePaseoHomePath(), "worktrees");
|
|
}
|
|
|
|
function isAgentInManagedWorktree(agentCwd: string): boolean {
|
|
const worktreesDir = resolvePaseoWorktreesDir();
|
|
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep);
|
|
}
|
|
|
|
/** Schema for worktree ls output */
|
|
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
|
|
idField: "name",
|
|
columns: [
|
|
{ header: "NAME", field: "name", width: 20 },
|
|
{ header: "BRANCH", field: "branch", width: 25 },
|
|
{ header: "CWD", field: "cwd", width: 45 },
|
|
{ header: "AGENT", field: "agent", width: 10 },
|
|
],
|
|
};
|
|
|
|
export type WorktreeLsResult = ListResult<WorktreeListItem>;
|
|
|
|
export interface WorktreeLsOptions extends CommandOptions {
|
|
host?: string;
|
|
}
|
|
|
|
export async function runLsCommand(
|
|
options: WorktreeLsOptions,
|
|
_command: Command,
|
|
): Promise<WorktreeLsResult> {
|
|
const host = getDaemonHost({ host: options.host });
|
|
|
|
let client: DaemonClient;
|
|
try {
|
|
client = await connectToDaemon({ host: options.host });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "DAEMON_NOT_RUNNING",
|
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
|
details: "Start the daemon with: paseo daemon start",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
|
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
|
|
|
// Get worktree list from daemon
|
|
const response = await client.getPaseoWorktreeList({});
|
|
|
|
await client.close();
|
|
|
|
if (response.error) {
|
|
const error: CommandError = {
|
|
code: "WORKTREE_LIST_FAILED",
|
|
message: `Failed to list worktrees: ${response.error.message}`,
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
// Build a map of worktree paths to agent IDs
|
|
const worktreeAgentMap = new Map<string, string>();
|
|
for (const agent of agents) {
|
|
if (isAgentInManagedWorktree(agent.cwd)) {
|
|
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7));
|
|
}
|
|
}
|
|
|
|
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
|
|
name: extractWorktreeName(wt.worktreePath),
|
|
branch: wt.branchName ?? "-",
|
|
cwd: shortenPath(wt.worktreePath),
|
|
agent: worktreeAgentMap.get(wt.worktreePath) ?? "-",
|
|
}));
|
|
|
|
return {
|
|
type: "list",
|
|
data: items,
|
|
schema: worktreeLsSchema,
|
|
};
|
|
} catch (err) {
|
|
await client.close().catch(() => {});
|
|
|
|
// Re-throw CommandError as-is
|
|
if (err && typeof err === "object" && "code" in err) {
|
|
throw err;
|
|
}
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "WORKTREE_LIST_FAILED",
|
|
message: `Failed to list worktrees: ${message}`,
|
|
};
|
|
throw error;
|
|
}
|
|
}
|