Extract client SDK package (#1052)

* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
This commit is contained in:
Mohamed Boudra
2026-05-28 01:58:18 +08:00
committed by GitHub
parent 0ea41378a4
commit 53c14d9855
403 changed files with 3193 additions and 1417 deletions
+12
View File
@@ -0,0 +1,12 @@
# @getpaseo/protocol
Shared Paseo protocol schemas, codecs, and wire types.
## Stability
This package is public so Paseo's published packages can depend on it cleanly.
It is not a stable public API yet.
Schemas, exports, wire helpers, and types may change or disappear in any release
without advance notice. Use it outside Paseo at your own risk until the package
is explicitly documented as stable.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.83",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
"README.md"
],
"type": "module",
"exports": {
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build",
"typecheck": "tsgo --noEmit",
"test": "vitest run"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
buildAgentAttentionNotificationPayload,
findLatestAssistantMessageFromTimeline,
findLatestPermissionRequest,
} from "./agent-attention-notification.js";
describe("buildAgentAttentionNotificationPayload", () => {
it("builds finished notifications from markdown assistant text", () => {
const payload = buildAgentAttentionNotificationPayload({
reason: "finished",
serverId: "srv-1",
agentId: "agent-1",
assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).",
});
expect(payload).toEqual({
title: "Agent finished",
body: "Done. Updated README.md and link.",
data: {
serverId: "srv-1",
agentId: "agent-1",
reason: "finished",
},
});
});
it("builds permission notifications from request details", () => {
const payload = buildAgentAttentionNotificationPayload({
reason: "permission",
serverId: "srv-2",
agentId: "agent-2",
permissionRequest: {
id: "perm-1",
provider: "claude",
name: "exec",
kind: "tool",
title: "**Approve command**",
description: "Run `git push`",
},
});
expect(payload).toEqual({
title: "Agent needs permission",
body: "Approve command - Run git push",
data: {
serverId: "srv-2",
agentId: "agent-2",
reason: "permission",
},
});
});
it("uses error-specific defaults when reason is error", () => {
const payload = buildAgentAttentionNotificationPayload({
reason: "error",
serverId: "srv-3",
agentId: "agent-3",
});
expect(payload).toEqual({
title: "Agent needs attention",
body: "Encountered an error.",
data: {
serverId: "srv-3",
agentId: "agent-3",
reason: "error",
},
});
});
});
describe("findLatestAssistantMessageFromTimeline", () => {
it("joins the latest contiguous assistant chunks", () => {
expect(
findLatestAssistantMessageFromTimeline([
{ type: "user_message", text: "start" },
{ type: "assistant_message", text: "Part " },
{ type: "assistant_message", text: "one" },
{ type: "reasoning", text: "thinking..." },
{ type: "assistant_message", text: "Done " },
{ type: "assistant_message", text: "now" },
]),
).toBe("Done now");
});
});
describe("findLatestPermissionRequest", () => {
it("returns the most recently inserted request", () => {
const pending = new Map([
["first", { id: "first", provider: "claude", name: "a", kind: "tool" } as const],
["second", { id: "second", provider: "claude", name: "b", kind: "tool" } as const],
]);
expect(findLatestPermissionRequest(pending)?.id).toBe("second");
});
});
@@ -0,0 +1,210 @@
const NOTIFICATION_PREVIEW_LIMIT = 220;
export type AgentAttentionReason = "finished" | "error" | "permission";
export interface AgentAttentionNotificationData {
[key: string]: unknown;
serverId: string;
agentId: string;
reason: AgentAttentionReason;
}
export interface AgentAttentionNotificationPayload {
title: string;
body: string;
data: AgentAttentionNotificationData;
}
interface BuildAgentAttentionNotificationPayloadInput {
reason: AgentAttentionReason;
serverId: string;
agentId: string;
assistantMessage?: string | null;
permissionRequest?: NotificationPermissionRequest | null;
}
export interface NotificationPermissionRequest {
id: string;
provider: string;
name: string;
kind: "tool" | "plan" | "question" | "mode" | "other";
title?: string;
description?: string;
input?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export type AssistantTimelineItem =
| { type: "assistant_message"; text: string }
| { type: string; text?: string };
const normalizeNotificationText = (text: string): string => text.replace(/\s+/g, " ").trim();
const truncateNotificationText = (text: string, limit: number): string => {
if (text.length <= limit) {
return text;
}
const trimmed = text.slice(0, Math.max(0, limit - 3)).trimEnd();
return trimmed.length > 0 ? `${trimmed}...` : text.slice(0, limit);
};
const stripMarkdownToText = (markdown: string): string => {
let text = markdown.replace(/\r\n/g, "\n");
// Strip fenced code markers but keep the code content itself.
text = text.replace(/^\s*```[^\n]*$/gm, "");
text = text.replace(/^\s*~~~[^\n]*$/gm, "");
// Markdown links/images.
text = text.replace(/!\[([^\]]*)\]\((?:[^()\\]|\\.)*\)/g, "$1");
text = text.replace(/\[([^\]]+)\]\((?:[^()\\]|\\.)*\)/g, "$1");
// Structural prefixes.
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, "");
text = text.replace(/^\s{0,3}>+\s?/gm, "");
text = text.replace(/^\s{0,3}(?:[*+-]|\d+\.)\s+/gm, "");
text = text.replace(/^\s{0,3}(?:[-*_]\s*){3,}$/gm, "");
// Inline markdown markers.
text = text.replace(/`([^`]+)`/g, "$1");
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
text = text.replace(/__([^_]+)__/g, "$1");
text = text.replace(/\*([^*\n]+)\*/g, "$1");
text = text.replace(/_([^_\n]+)_/g, "$1");
text = text.replace(/~~([^~]+)~~/g, "$1");
// Angle-bracketed URL autolinks.
text = text.replace(/<([^>\n]+)>/g, "$1");
return text;
};
const buildNotificationPreview = (text: string | null | undefined): string | null => {
if (!text) {
return null;
}
const normalized = normalizeNotificationText(stripMarkdownToText(text));
if (!normalized) {
return null;
}
return truncateNotificationText(normalized, NOTIFICATION_PREVIEW_LIMIT);
};
const safeStringify = (value: unknown): string | null => {
try {
return JSON.stringify(value);
} catch {
return null;
}
};
const buildPermissionDetails = (
request: NotificationPermissionRequest | null | undefined,
): string | null => {
if (!request) {
return null;
}
const title = request.title?.trim();
const description = request.description?.trim();
const details: string[] = [];
if (title) {
details.push(title);
}
if (description && description !== title) {
details.push(description);
}
if (details.length > 0) {
return details.join(" - ");
}
const inputPreview = request.input ? safeStringify(request.input) : null;
if (inputPreview) {
return inputPreview;
}
const metadataPreview = request.metadata ? safeStringify(request.metadata) : null;
if (metadataPreview) {
return metadataPreview;
}
return request.name?.trim() || request.kind;
};
export function findLatestAssistantMessageFromTimeline(
timeline: readonly AssistantTimelineItem[],
): string | null {
// Providers may stream assistant content in consecutive chunks.
const chunks: string[] = [];
for (let i = timeline.length - 1; i >= 0; i -= 1) {
const item = timeline[i];
if (item.type !== "assistant_message" || typeof item.text !== "string") {
if (chunks.length > 0) {
break;
}
continue;
}
chunks.push(item.text);
}
if (chunks.length === 0) {
return null;
}
return chunks.toReversed().join("");
}
export function findLatestPermissionRequest(
pendingPermissions: ReadonlyMap<string, NotificationPermissionRequest>,
): NotificationPermissionRequest | null {
let latest: NotificationPermissionRequest | null = null;
for (const request of pendingPermissions.values()) {
latest = request;
}
return latest;
}
function resolveAgentAttentionTitle(reason: AgentAttentionReason): string {
if (reason === "permission") return "Agent needs permission";
if (reason === "error") return "Agent needs attention";
return "Agent finished";
}
function resolveAgentAttentionPreview(
input: BuildAgentAttentionNotificationPayloadInput,
): string | null {
if (input.reason === "finished") {
return buildNotificationPreview(input.assistantMessage);
}
if (input.reason === "permission") {
return buildNotificationPreview(buildPermissionDetails(input.permissionRequest));
}
return null;
}
function resolveAgentAttentionFallbackBody(reason: AgentAttentionReason): string {
if (reason === "permission") return "Permission requested.";
if (reason === "error") return "Encountered an error.";
return "Finished working.";
}
export function buildAgentAttentionNotificationPayload(
input: BuildAgentAttentionNotificationPayloadInput,
): AgentAttentionNotificationPayload {
const title = resolveAgentAttentionTitle(input.reason);
const preview = resolveAgentAttentionPreview(input);
const body = preview ?? resolveAgentAttentionFallbackBody(input.reason);
return {
title,
body,
data: {
serverId: input.serverId,
agentId: input.agentId,
reason: input.reason,
},
};
}
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import {
AgentFeatureSchema,
AgentSnapshotPayloadSchema,
SetAgentFeatureRequestMessageSchema,
SetAgentFeatureResponseMessageSchema,
} from "./messages.js";
describe("agent feature schemas", () => {
it("parses valid toggle features", () => {
const parsed = AgentFeatureSchema.parse({
type: "toggle",
id: "fast_mode",
label: "Fast mode",
description: "Uses lower latency service tier",
tooltip: "Toggle fast mode",
icon: "bolt",
value: true,
});
expect(parsed.type).toBe("toggle");
if (parsed.type !== "toggle") {
throw new Error("Expected toggle feature");
}
expect(parsed.value).toBe(true);
});
it("parses valid select features", () => {
const parsed = AgentFeatureSchema.parse({
type: "select",
id: "service_tier",
label: "Service tier",
description: "Choose a processing tier",
tooltip: "Select service tier",
icon: "gauge",
value: "flex",
options: [
{ id: "default", label: "Default", isDefault: true },
{ id: "flex", label: "Flex" },
],
});
expect(parsed.type).toBe("select");
if (parsed.type !== "select") {
throw new Error("Expected select feature");
}
expect(parsed.options).toHaveLength(2);
expect(parsed.value).toBe("flex");
});
it("rejects invalid features", () => {
const invalidDiscriminator = AgentFeatureSchema.safeParse({
type: "slider",
id: "fast_mode",
label: "Fast mode",
value: true,
});
const missingToggleValue = AgentFeatureSchema.safeParse({
type: "toggle",
id: "fast_mode",
label: "Fast mode",
});
const missingSelectOptions = AgentFeatureSchema.safeParse({
type: "select",
id: "service_tier",
label: "Service tier",
value: null,
});
expect(invalidDiscriminator.success).toBe(false);
expect(missingToggleValue.success).toBe(false);
expect(missingSelectOptions.success).toBe(false);
});
it("parses valid requests", () => {
const parsed = SetAgentFeatureRequestMessageSchema.parse({
type: "set_agent_feature_request",
agentId: "agent-123",
featureId: "fast_mode",
value: true,
requestId: "req-123",
});
expect(parsed.featureId).toBe("fast_mode");
expect(parsed.value).toBe(true);
});
it("parses valid responses", () => {
const parsed = SetAgentFeatureResponseMessageSchema.parse({
type: "set_agent_feature_response",
payload: {
requestId: "req-123",
agentId: "agent-123",
accepted: true,
error: null,
},
});
expect(parsed.payload.accepted).toBe(true);
expect(parsed.payload.error).toBeNull();
});
it("accepts features on agent snapshot payloads", () => {
const parsed = AgentSnapshotPayloadSchema.parse({
id: "agent-123",
provider: "codex",
cwd: "/tmp/project",
model: "gpt-5",
features: [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
tooltip: "Toggle fast mode",
value: false,
},
],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-03T12:00:00.000Z",
updatedAt: "2026-04-03T12:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
});
expect(parsed.features).toHaveLength(1);
expect(parsed.features?.[0]?.id).toBe("fast_mode");
});
it("defaults missing rewind capabilities to false", () => {
const parsed = AgentSnapshotPayloadSchema.parse({
id: "agent-123",
provider: "codex",
cwd: "/tmp/project",
model: "gpt-5",
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-03T12:00:00.000Z",
updatedAt: "2026-04-03T12:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
});
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
});
});
+1
View File
@@ -0,0 +1 @@
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
+9
View File
@@ -0,0 +1,9 @@
export const AGENT_LIFECYCLE_STATUSES = [
"initializing",
"idle",
"running",
"error",
"closed",
] as const;
export type AgentLifecycleStatus = (typeof AGENT_LIFECYCLE_STATUSES)[number];
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
deriveAgentStateBucket,
getAgentStatusPriority,
getWorkspaceStateBucketPriority,
} from "./agent-state-bucket.js";
describe("deriveAgentStateBucket", () => {
it("prioritizes pending permissions as needs_input", () => {
expect(
deriveAgentStateBucket({
status: "idle",
pendingPermissionCount: 1,
requiresAttention: false,
attentionReason: null,
}),
).toBe("needs_input");
});
it("keeps legacy permission attention in needs_input", () => {
expect(
deriveAgentStateBucket({
status: "idle",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "permission",
}),
).toBe("needs_input");
});
it("prioritizes error attention before running status", () => {
expect(
deriveAgentStateBucket({
status: "running",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "error",
}),
).toBe("failed");
});
it("treats unread finished agents as attention", () => {
expect(
deriveAgentStateBucket({
status: "idle",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe("attention");
});
it("treats initializing agents as done for workspace buckets", () => {
expect(
deriveAgentStateBucket({
status: "initializing",
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
}),
).toBe("done");
});
});
describe("getWorkspaceStateBucketPriority", () => {
it("orders active buckets before done", () => {
expect(
["done", "attention", "running", "failed", "needs_input"].sort(
(left, right) =>
getWorkspaceStateBucketPriority(left) - getWorkspaceStateBucketPriority(right),
),
).toEqual(["needs_input", "failed", "running", "attention", "done"]);
});
});
describe("getAgentStatusPriority", () => {
it("keeps initializing agents ahead of completed agents in agent lists", () => {
expect(getAgentStatusPriority({ status: "initializing" })).toBeLessThan(
getAgentStatusPriority({ status: "idle" }),
);
});
it("prioritizes pending permissions before errors and running agents", () => {
const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 });
expect(permission).toBeLessThan(
getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }),
);
expect(permission).toBeLessThan(
getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }),
);
});
});
@@ -0,0 +1,55 @@
import type { AgentLifecycleStatus } from "./agent-lifecycle.js";
import type { WorkspaceStateBucket } from "./messages.js";
export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined;
export interface AgentStateBucketInput {
status: AgentLifecycleStatus;
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: AgentAttentionReason;
}
const WORKSPACE_STATE_BUCKET_PRIORITY = {
needs_input: 0,
failed: 1,
running: 2,
attention: 3,
done: 4,
} as const satisfies Record<WorkspaceStateBucket, number>;
export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket {
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
return "needs_input";
}
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
if (input.status === "running") {
return "running";
}
if (input.requiresAttention) {
return "attention";
}
return "done";
}
export function getWorkspaceStateBucketPriority(bucket: WorkspaceStateBucket): number {
return WORKSPACE_STATE_BUCKET_PRIORITY[bucket];
}
export function getAgentStatusPriority(input: AgentStateBucketInput): number {
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
return 0;
}
if (input.status === "error" || input.attentionReason === "error") {
return 1;
}
if (input.status === "running") {
return 2;
}
if (input.status === "initializing") {
return 3;
}
return 4;
}
@@ -0,0 +1,2 @@
export const MAX_EXPLICIT_AGENT_TITLE_CHARS = 200;
export const MAX_AUTO_AGENT_TITLE_CHARS = 40;
+466
View File
@@ -0,0 +1,466 @@
import type { AgentAttachment } from "./messages.js";
export type AgentProvider = string;
export interface AgentMetadata {
[key: string]: unknown;
}
/**
* Stdio-based MCP server (spawns a subprocess).
*/
export interface McpStdioServerConfig {
type: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
}
/**
* HTTP-based MCP server.
*/
export interface McpHttpServerConfig {
type: "http";
url: string;
headers?: Record<string, string>;
}
/**
* SSE-based MCP server (Server-Sent Events over HTTP).
*/
export interface McpSseServerConfig {
type: "sse";
url: string;
headers?: Record<string, string>;
}
/**
* Canonical MCP server configuration.
* Discriminated union by `type` field.
* Each provider normalizes this to their expected format.
*/
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig | McpSseServerConfig;
export interface AgentMode {
id: string;
label: string;
description?: string;
icon?: string;
colorTier?: string;
}
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
export interface AgentModelDefinition {
provider: AgentProvider;
id: string;
label: string;
description?: string;
isDefault?: boolean;
metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;
}
export interface AgentSelectOption {
id: string;
label: string;
description?: string;
isDefault?: boolean;
metadata?: AgentMetadata;
}
export function normalizeAgentModelDefinition(model: AgentModelDefinition): AgentModelDefinition {
const defaultThinkingOptionId =
model.defaultThinkingOptionId ?? model.thinkingOptions?.find((option) => option.isDefault)?.id;
if (!defaultThinkingOptionId || defaultThinkingOptionId === model.defaultThinkingOptionId) {
return model;
}
return { ...model, defaultThinkingOptionId };
}
export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
enabled: boolean;
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
fetchedAt?: string;
label?: string;
description?: string;
defaultModeId?: string | null;
}
export interface AgentFeatureToggle {
type: "toggle";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: boolean;
}
export interface AgentFeatureSelect {
type: "select";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: string | null;
options: AgentSelectOption[];
}
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export interface AgentCapabilityFlags {
supportsStreaming: boolean;
supportsSessionPersistence: boolean;
supportsDynamicModes: boolean;
supportsMcpServers: boolean;
supportsReasoningStream: boolean;
supportsToolInvocations: boolean;
supportsRewindConversation?: boolean;
supportsRewindFiles?: boolean;
supportsRewindBoth?: boolean;
}
export interface AgentPersistenceHandle {
provider: AgentProvider;
sessionId: string;
/** Provider specific handle (Codex thread id, Claude resume token, etc). */
nativeHandle?: string;
metadata?: AgentMetadata;
}
export type AgentPromptContentBlock =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
| AgentAttachment;
export type AgentPromptInput = string | AgentPromptContentBlock[];
export interface AgentRunOptions {
outputSchema?: unknown;
resumeFrom?: AgentPersistenceHandle;
maxThinkingTokens?: number;
}
export interface AgentUsage {
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
totalCostUsd?: number;
contextWindowMaxTokens?: number;
contextWindowUsedTokens?: number;
}
export const TOOL_CALL_ICON_NAMES = [
"wrench",
"square_terminal",
"eye",
"pencil",
"search",
"bot",
"sparkles",
"brain",
"mic_vocal",
] as const;
export type ToolCallIconName = (typeof TOOL_CALL_ICON_NAMES)[number];
export type ToolCallDetail =
| {
type: "shell";
command: string;
cwd?: string;
output?: string;
exitCode?: number | null;
}
| {
type: "read";
filePath: string;
content?: string;
offset?: number;
limit?: number;
}
| {
type: "edit";
filePath: string;
oldString?: string;
newString?: string;
unifiedDiff?: string;
}
| {
type: "write";
filePath: string;
content?: string;
}
| {
type: "search";
query: string;
toolName?: "search" | "grep" | "glob" | "web_search";
content?: string;
filePaths?: string[];
webResults?: Array<{
title: string;
url: string;
}>;
annotations?: string[];
numFiles?: number;
numMatches?: number;
durationMs?: number;
durationSeconds?: number;
truncated?: boolean;
mode?: "content" | "files_with_matches" | "count";
}
| {
type: "fetch";
url: string;
prompt?: string;
result?: string;
code?: number;
codeText?: string;
bytes?: number;
durationMs?: number;
}
| {
type: "worktree_setup";
worktreePath: string;
branchName: string;
log: string;
commands: Array<{
index: number;
command: string;
cwd: string;
log: string;
status: "running" | "completed" | "failed";
exitCode: number | null;
durationMs?: number;
}>;
truncated?: boolean;
}
| {
type: "sub_agent";
subAgentType?: string;
description?: string;
childSessionId?: string;
log: string;
actions?: Array<{
index: number;
toolName: string;
summary?: string;
}>;
}
| {
type: "plain_text";
label?: string;
text?: string;
icon?: ToolCallIconName;
}
| {
type: "plan";
text: string;
}
| {
type: "unknown";
input: unknown;
output: unknown;
};
interface ToolCallBase {
[key: string]: unknown;
type: "tool_call";
callId: string;
name: string;
detail: ToolCallDetail;
metadata?: Record<string, unknown>;
}
type ToolCallRunningTimelineItem = ToolCallBase & {
status: "running";
error: null;
};
type ToolCallCompletedTimelineItem = ToolCallBase & {
status: "completed";
error: null;
};
type ToolCallFailedTimelineItem = ToolCallBase & {
status: "failed";
error: unknown;
};
type ToolCallCanceledTimelineItem = ToolCallBase & {
status: "canceled";
error: null;
};
export type ToolCallTimelineItem =
| ToolCallRunningTimelineItem
| ToolCallCompletedTimelineItem
| ToolCallFailedTimelineItem
| ToolCallCanceledTimelineItem;
export interface CompactionTimelineItem {
[key: string]: unknown;
type: "compaction";
status: "loading" | "completed";
trigger?: "auto" | "manual";
preTokens?: number;
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] }
| { type: "error"; message: string }
| CompactionTimelineItem;
export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
| {
type: "mode_changed";
provider: AgentProvider;
currentModeId: string | null;
availableModes: AgentMode[];
}
| { type: "model_changed"; provider: AgentProvider; runtimeInfo: AgentRuntimeInfo }
| {
type: "thinking_option_changed";
provider: AgentProvider;
thinkingOptionId: string | null;
}
| {
type: "turn_failed";
provider: AgentProvider;
error: string;
code?: string;
diagnostic?: string;
turnId?: string;
}
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
| {
type: "timeline";
item: AgentTimelineItem;
provider: AgentProvider;
turnId?: string;
timestamp?: string;
}
| {
type: "permission_requested";
provider: AgentProvider;
request: AgentPermissionRequest;
turnId?: string;
}
| {
type: "permission_resolved";
provider: AgentProvider;
requestId: string;
resolution: AgentPermissionResponse;
turnId?: string;
}
| {
type: "attention_required";
provider: AgentProvider;
reason: "finished" | "error" | "permission";
timestamp: string;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
return "turnId" in event ? event.turnId : undefined;
}
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata;
export interface AgentPermissionAction {
id: string;
label: string;
behavior: "allow" | "deny";
variant?: "primary" | "secondary" | "danger";
intent?: "implement" | "implement_resume" | "dismiss";
}
export interface AgentPermissionRequest {
id: string;
provider: AgentProvider;
name: string;
kind: AgentPermissionRequestKind;
title?: string;
description?: string;
input?: AgentMetadata;
detail?: ToolCallDetail;
suggestions?: AgentPermissionUpdate[];
actions?: AgentPermissionAction[];
metadata?: AgentMetadata;
}
export type AgentPermissionResponse =
| {
behavior: "allow";
selectedActionId?: string;
updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[];
}
| {
behavior: "deny";
selectedActionId?: string;
message?: string;
interrupt?: boolean;
};
export interface AgentRunResult {
sessionId: string;
finalText: string;
usage?: AgentUsage;
timeline: AgentTimelineItem[];
canceled?: boolean;
}
export interface AgentSessionConfig {
provider: AgentProvider;
cwd: string;
/**
* Provider-agnostic system/developer instruction string.
* Mapped by each provider to its native instruction field.
*/
systemPrompt?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
extra?: {
codex?: AgentMetadata;
claude?: AgentMetadata;
};
mcpServers?: Record<string, McpServerConfig>;
/**
* Internal agents are hidden from listings and don't trigger notifications.
* They are used for ephemeral system tasks like commit/PR generation.
*/
internal?: boolean;
}
export interface AgentRuntimeInfo {
provider: AgentProvider;
sessionId: string | null;
model?: string | null;
thinkingOptionId?: string | null;
modeId?: string | null;
extra?: AgentMetadata;
}
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import {
FileTransferOpcode,
TerminalStreamOpcode,
decodeFileTransferFrame,
decodeTerminalStreamFrame,
encodeFileTransferFrame,
} from "./index.js";
describe("file transfer binary frames", () => {
const encoder = new TextEncoder();
it("uses non-terminal opcodes", () => {
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileBegin);
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileChunk);
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileEnd);
});
it("encodes FileBegin as opcode plus request id prefix plus JSON metadata", () => {
const encoded = encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: 6,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
});
const requestId = encoder.encode("req-1");
const metadata = encoder.encode(
JSON.stringify({
mime: "image/png",
size: 6,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
}),
);
expect(decodeTerminalStreamFrame(encoded)).toBeNull();
expect(encoded[0]).toBe(FileTransferOpcode.FileBegin);
expect(encoded[1]).toBe(requestId.byteLength);
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
expect(
new DataView(encoded.buffer, encoded.byteOffset).getUint16(2 + requestId.byteLength),
).toBe(metadata.byteLength);
expect(encoded.subarray(4 + requestId.byteLength)).toEqual(metadata);
expect(decodeFileTransferFrame(encoded)).toEqual({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: 6,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
payload: new Uint8Array(),
});
});
it("encodes FileChunk as opcode plus request id prefix plus binary payload", () => {
const payload = new Uint8Array([0, 1, 2, 253, 254, 255]);
const encoded = encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-1",
payload,
});
const requestId = encoder.encode("req-1");
expect(encoded[0]).toBe(FileTransferOpcode.FileChunk);
expect(encoded[1]).toBe(requestId.byteLength);
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
expect(encoded.subarray(2 + requestId.byteLength)).toEqual(payload);
const decoded = decodeFileTransferFrame(encoded);
expect(decoded).toEqual({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-1",
payload,
});
expect(decoded?.payload).toBeInstanceOf(Uint8Array);
expect(decoded?.payload).toEqual(payload);
});
it("encodes FileEnd as opcode plus request id prefix only", () => {
const encoded = encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: "req-1",
});
const requestId = encoder.encode("req-1");
expect(encoded).toEqual(
new Uint8Array([FileTransferOpcode.FileEnd, requestId.byteLength, ...requestId]),
);
expect(decodeFileTransferFrame(encoded)).toEqual({
opcode: FileTransferOpcode.FileEnd,
requestId: "req-1",
payload: new Uint8Array(),
});
});
it("rejects malformed metadata and unknown metadata fields", () => {
expect(
decodeFileTransferFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: -1,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
),
).toBeNull();
const json = encoder.encode(
JSON.stringify({
mime: "image/png",
size: 1,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
extra: true,
}),
);
const requestId = encoder.encode("req-1");
const encoded = new Uint8Array(4 + requestId.byteLength + json.byteLength);
encoded[0] = FileTransferOpcode.FileBegin;
encoded[1] = requestId.byteLength;
encoded.set(requestId, 2);
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
encoded.set(json, 4 + requestId.byteLength);
expect(decodeFileTransferFrame(encoded)).toBeNull();
});
it("rejects malformed request id prefixes and frame tails", () => {
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 0]))).toBeNull();
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 6, 1]))).toBeNull();
const requestId = encoder.encode("req-1");
const encoded = new Uint8Array([
FileTransferOpcode.FileEnd,
requestId.byteLength,
...requestId,
1,
]);
expect(decodeFileTransferFrame(encoded)).toBeNull();
});
});
@@ -0,0 +1,163 @@
import { z } from "zod";
import { asUint8Array } from "./terminal.js";
export const FileTransferOpcode = {
FileBegin: 0x10,
FileChunk: 0x11,
FileEnd: 0x12,
} as const;
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
export const FileBeginMetadataSchema = z
.object({
mime: z.string().min(1),
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
})
.strict();
export interface FileBegin {
opcode: typeof FileTransferOpcode.FileBegin;
requestId: string;
metadata: z.infer<typeof FileBeginMetadataSchema>;
payload: Uint8Array;
}
export interface FileChunk {
opcode: typeof FileTransferOpcode.FileChunk;
requestId: string;
payload: Uint8Array;
}
export interface FileEnd {
opcode: typeof FileTransferOpcode.FileEnd;
requestId: string;
payload: Uint8Array;
}
export type FileTransferFrame = FileBegin | FileChunk | FileEnd;
type FileTransferFrameInput =
| {
opcode: typeof FileTransferOpcode.FileBegin;
requestId: string;
metadata: z.infer<typeof FileBeginMetadataSchema>;
}
| {
opcode: typeof FileTransferOpcode.FileChunk;
requestId: string;
payload?: Uint8Array | ArrayBuffer | string;
}
| {
opcode: typeof FileTransferOpcode.FileEnd;
requestId: string;
};
export function encodeFileTransferFrame(input: FileTransferFrameInput): Uint8Array {
const requestId = encodeRequestId(input.requestId);
if (input.opcode === FileTransferOpcode.FileBegin) {
const metadata = encodeJsonPayload(input.metadata);
if (metadata.byteLength > 0xffff) {
throw new RangeError("FileBegin metadata is too long");
}
const bytes = new Uint8Array(4 + requestId.byteLength + metadata.byteLength);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
bytes[0] = input.opcode;
bytes[1] = requestId.byteLength;
bytes.set(requestId, 2);
view.setUint16(2 + requestId.byteLength, metadata.byteLength);
bytes.set(metadata, 4 + requestId.byteLength);
return bytes;
}
const payload =
input.opcode === FileTransferOpcode.FileChunk
? (asUint8Array(input.payload ?? new Uint8Array()) ?? new Uint8Array())
: new Uint8Array();
const bytes = new Uint8Array(2 + requestId.byteLength + payload.byteLength);
bytes[0] = input.opcode;
bytes[1] = requestId.byteLength;
bytes.set(requestId, 2);
bytes.set(payload, 2 + requestId.byteLength);
return bytes;
}
export function decodeFileTransferFrame(bytes: Uint8Array): FileTransferFrame | null {
if (bytes.byteLength < 2) {
return null;
}
const opcode = bytes[0];
if (!isFileTransferOpcode(opcode)) {
return null;
}
const requestIdLength = bytes[1];
if (requestIdLength === 0 || requestIdLength > bytes.byteLength - 2) {
return null;
}
const requestId = decodeRequestId(bytes.subarray(2, 2 + requestIdLength));
const body = bytes.subarray(2 + requestIdLength);
if (opcode === FileTransferOpcode.FileBegin) {
if (body.byteLength < 2) {
return null;
}
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
const metadataLength = view.getUint16(0);
if (metadataLength !== body.byteLength - 2) {
return null;
}
const metadataBytes = body.subarray(2);
const result = FileBeginMetadataSchema.safeParse(decodeJsonPayload(metadataBytes));
return result.success
? { opcode, requestId, metadata: result.data, payload: new Uint8Array() }
: null;
}
if (opcode === FileTransferOpcode.FileChunk) {
return { opcode, requestId, payload: body };
}
if (body.byteLength !== 0) {
return null;
}
return { opcode, requestId, payload: new Uint8Array() };
}
function isFileTransferOpcode(value: number): value is FileTransferOpcode {
return (
value === FileTransferOpcode.FileBegin ||
value === FileTransferOpcode.FileChunk ||
value === FileTransferOpcode.FileEnd
);
}
function encodeJsonPayload(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value));
}
function encodeRequestId(requestId: string): Uint8Array {
const bytes = new TextEncoder().encode(requestId);
if (bytes.byteLength === 0) {
throw new RangeError("File transfer requestId is required");
}
if (bytes.byteLength > 0xff) {
throw new RangeError("File transfer requestId is too long");
}
return bytes;
}
function decodeRequestId(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
function decodeJsonPayload(bytes: Uint8Array): unknown {
try {
return JSON.parse(new TextDecoder().decode(bytes));
} catch {
return null;
}
}
@@ -0,0 +1,2 @@
export * from "./file-transfer.js";
export * from "./terminal.js";
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
} from "./index.js";
describe("terminal binary frames", () => {
it("encodes output frames as opcode plus slot plus payload", () => {
const payload = new TextEncoder().encode("hello");
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 7,
payload,
});
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
expect(encoded[1]).toBe(7);
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
const decoded = decodeTerminalStreamFrame(encoded);
expect(decoded).toEqual({
opcode: TerminalStreamOpcode.Output,
slot: 7,
payload,
});
});
it("round-trips resize payloads", () => {
const payload = encodeTerminalResizePayload({
rows: 24,
cols: 80,
});
expect(decodeTerminalResizePayload(payload)).toEqual({
rows: 24,
cols: 80,
});
});
it("round-trips snapshot payloads", () => {
const state = {
rows: 1,
cols: 2,
grid: [[{ char: "A" }, { char: "B" }]],
scrollback: [],
cursor: { row: 0, col: 2 },
};
const payload = encodeTerminalSnapshotPayload(state);
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
});
it("rejects unknown opcodes", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
});
it("rejects frames without a slot byte", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
});
it("rejects malformed JSON payloads", () => {
const malformed = new TextEncoder().encode("{");
expect(decodeTerminalResizePayload(malformed)).toBeNull();
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
});
it("rejects invalid resize and snapshot shapes", () => {
expect(
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
}),
),
),
).toBeNull();
});
it("rejects unknown fields in resize and snapshot payloads", () => {
expect(
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A", extra: true }]],
scrollback: [],
cursor: { row: 0, col: 1 },
}),
),
),
).toBeNull();
});
});
@@ -0,0 +1,133 @@
import { z } from "zod";
import { TerminalStateSchema } from "../messages.js";
export const TerminalStreamResizeSchema = z
.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
})
.strict();
export const TerminalStreamOpcode = {
Output: 0x01,
Input: 0x02,
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
export interface TerminalStreamFrame {
opcode: TerminalStreamOpcode;
slot: number;
payload: Uint8Array;
}
export function asUint8Array(data: unknown): Uint8Array | null {
if (typeof data === "string") {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(data);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(data, "utf8"));
}
const out = new Uint8Array(data.length);
for (let index = 0; index < data.length; index += 1) {
out[index] = data.charCodeAt(index) & 0xff;
}
return out;
}
if (data instanceof Uint8Array) {
return data;
}
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
return (
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.Restore
);
}
export function encodeTerminalStreamFrame(input: {
opcode: TerminalStreamOpcode;
slot: number;
payload?: Uint8Array | ArrayBuffer | string;
}): Uint8Array {
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
const bytes = new Uint8Array(2 + payload.byteLength);
bytes[0] = input.opcode;
bytes[1] = input.slot & 0xff;
bytes.set(payload, 2);
return bytes;
}
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
if (bytes.byteLength < 2) {
return null;
}
const opcode = bytes[0];
if (!isTerminalStreamOpcode(opcode)) {
return null;
}
return {
opcode,
slot: bytes[1],
payload: bytes.subarray(2),
};
}
export function encodeTerminalSnapshotPayload(
state: z.infer<typeof TerminalStateSchema>,
): Uint8Array {
return encodeJsonPayload(state);
}
export function decodeTerminalSnapshotPayload(
bytes: Uint8Array,
): z.infer<typeof TerminalStateSchema> | null {
const parsed = decodeJsonPayload(bytes);
const result = TerminalStateSchema.safeParse(parsed);
return result.success ? result.data : null;
}
export function encodeTerminalResizePayload(
input: z.infer<typeof TerminalStreamResizeSchema>,
): Uint8Array {
return encodeJsonPayload(input);
}
export function decodeTerminalResizePayload(
bytes: Uint8Array,
): z.infer<typeof TerminalStreamResizeSchema> | null {
const parsed = decodeJsonPayload(bytes);
const result = TerminalStreamResizeSchema.safeParse(parsed);
return result.success ? result.data : null;
}
function encodeJsonPayload(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value));
}
function decodeJsonPayload(bytes: Uint8Array): unknown {
try {
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
} catch {
return null;
}
}
+116
View File
@@ -0,0 +1,116 @@
import { z } from "zod";
import { ChatMessageSchema, ChatRoomDetailSchema } from "./types.js";
export const ChatCreateRequestSchema = z.object({
type: z.literal("chat/create"),
requestId: z.string(),
name: z.string(),
purpose: z.string().optional(),
});
export const ChatListRequestSchema = z.object({
type: z.literal("chat/list"),
requestId: z.string(),
});
export const ChatInspectRequestSchema = z.object({
type: z.literal("chat/inspect"),
requestId: z.string(),
room: z.string(),
});
export const ChatDeleteRequestSchema = z.object({
type: z.literal("chat/delete"),
requestId: z.string(),
room: z.string(),
});
export const ChatPostRequestSchema = z.object({
type: z.literal("chat/post"),
requestId: z.string(),
room: z.string(),
body: z.string(),
authorAgentId: z.string().optional(),
replyToMessageId: z.string().optional(),
});
export const ChatReadRequestSchema = z.object({
type: z.literal("chat/read"),
requestId: z.string(),
room: z.string(),
limit: z.number().int().nonnegative().optional(),
since: z.string().optional(),
authorAgentId: z.string().optional(),
});
export const ChatWaitRequestSchema = z.object({
type: z.literal("chat/wait"),
requestId: z.string(),
room: z.string(),
afterMessageId: z.string().optional(),
timeoutMs: z.number().int().nonnegative().optional(),
});
export const ChatCreateResponseSchema = z.object({
type: z.literal("chat/create/response"),
payload: z.object({
requestId: z.string(),
room: ChatRoomDetailSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatListResponseSchema = z.object({
type: z.literal("chat/list/response"),
payload: z.object({
requestId: z.string(),
rooms: z.array(ChatRoomDetailSchema),
error: z.string().nullable(),
}),
});
export const ChatInspectResponseSchema = z.object({
type: z.literal("chat/inspect/response"),
payload: z.object({
requestId: z.string(),
room: ChatRoomDetailSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatDeleteResponseSchema = z.object({
type: z.literal("chat/delete/response"),
payload: z.object({
requestId: z.string(),
room: ChatRoomDetailSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatPostResponseSchema = z.object({
type: z.literal("chat/post/response"),
payload: z.object({
requestId: z.string(),
message: ChatMessageSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ChatReadResponseSchema = z.object({
type: z.literal("chat/read/response"),
payload: z.object({
requestId: z.string(),
messages: z.array(ChatMessageSchema),
error: z.string().nullable(),
}),
});
export const ChatWaitResponseSchema = z.object({
type: z.literal("chat/wait/response"),
payload: z.object({
requestId: z.string(),
messages: z.array(ChatMessageSchema),
timedOut: z.boolean(),
error: z.string().nullable(),
}),
});
+30
View File
@@ -0,0 +1,30 @@
import { z } from "zod";
export const ChatRoomSchema = z.object({
id: z.string(),
name: z.string(),
purpose: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type ChatRoom = z.infer<typeof ChatRoomSchema>;
export const ChatMessageSchema = z.object({
id: z.string(),
roomId: z.string(),
authorAgentId: z.string(),
body: z.string(),
replyToMessageId: z.string().nullable(),
mentionAgentIds: z.array(z.string()),
createdAt: z.string(),
});
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export const ChatRoomDetailSchema = ChatRoomSchema.extend({
messageCount: z.number().int().nonnegative(),
lastMessageAt: z.string().nullable(),
});
export type ChatRoomDetail = z.infer<typeof ChatRoomDetailSchema>;
@@ -0,0 +1,10 @@
export const CLIENT_CAPS = {
reasoningMergeEnum: "reasoning_merge_enum",
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
// a closed enum and crash rendering unknown values; daemon downgrades icons
// outside the legacy set to "ShieldCheck" when this cap is absent. Drop the
// gate when floor >= v0.1.84.
customModeIcons: "custom_mode_icons",
} as const;
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
ConnectionOfferSchema,
decodeOfferFragmentPayload,
parseConnectionOfferFromUrl,
} from "./connection-offer.js";
function encodeBase64UrlNoPadUtf8(input: string): string {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
describe("connection offer", () => {
it("decodes base64url JSON payloads", () => {
const payload = {
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.paseo.sh:443" },
};
expect(decodeOfferFragmentPayload(encodeBase64UrlNoPadUtf8(JSON.stringify(payload)))).toEqual(
payload,
);
});
it("parses connection offers from QR-style URLs", () => {
const offer = ConnectionOfferSchema.parse({
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.paseo.sh:443" },
});
const encoded = encodeBase64UrlNoPadUtf8(JSON.stringify(offer));
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
});
it("leaves relay TLS unset when absent", () => {
expect(
ConnectionOfferSchema.parse({
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.example.com:80" },
}),
).toEqual({
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.example.com:80" },
});
});
it("round-trips relay TLS in offers without rejecting extra relay fields", () => {
const offer = ConnectionOfferSchema.parse({
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.example.com:443", useTls: true, extra: "future" },
});
const encoded = encodeBase64UrlNoPadUtf8(JSON.stringify(offer));
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual({
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.example.com:443", useTls: true },
});
});
it("returns null when the URL has no offer fragment", () => {
expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull();
});
});
+59
View File
@@ -0,0 +1,59 @@
import { z } from "zod";
/**
* Relay-only pairing offer.
*
* `serverId` is a stable daemon identifier scoped to `PASEO_HOME`, and is also
* used as the relay session identifier.
*/
export const ConnectionOfferV2Schema = z.object({
v: z.literal(2),
serverId: z.string().min(1),
daemonPublicKeyB64: z.string().min(1),
relay: z.object({
endpoint: z.string().min(1),
useTls: z.boolean().optional(),
}),
});
export type ConnectionOfferV2 = z.infer<typeof ConnectionOfferV2Schema>;
export const ConnectionOfferSchema = ConnectionOfferV2Schema;
export type ConnectionOffer = ConnectionOfferV2;
function decodeBase64UrlToUtf8(input: string): string {
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
const binary = globalThis.atob(padded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
export function decodeOfferFragmentPayload(encoded: string): unknown {
const json = decodeBase64UrlToUtf8(encoded);
return JSON.parse(json) as unknown;
}
const OFFER_FRAGMENT_PREFIX = "#offer=";
function extractOfferFragmentEncoded(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
const fragmentIndex = trimmed.indexOf(OFFER_FRAGMENT_PREFIX);
if (fragmentIndex === -1) return null;
const encoded = trimmed.slice(fragmentIndex + OFFER_FRAGMENT_PREFIX.length).trim();
return encoded.length > 0 ? encoded : null;
}
/**
* Parse a pairing-offer URL of the form `https://app.paseo.sh/#offer=<base64url>`.
*
* Returns `null` if the input has no `#offer=` fragment. Throws if the fragment
* exists but the payload is malformed or fails schema validation.
*/
export function parseConnectionOfferFromUrl(input: string): ConnectionOffer | null {
const encoded = extractOfferFragmentEncoded(input);
if (!encoded) return null;
const payload = decodeOfferFragmentPayload(encoded);
return ConnectionOfferSchema.parse(payload);
}
@@ -0,0 +1,213 @@
import { describe, expect, test } from "vitest";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
CURRENT_RELAY_PROTOCOL_VERSION,
extractHostPortFromWebSocketUrl,
normalizeRelayProtocolVersion,
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
shouldUseTlsForDefaultHostedRelay,
} from "./daemon-endpoints.js";
describe("connection URI parsing", () => {
test("round-trips a tcp host and port", () => {
const parsed = parseConnectionUri("tcp://localhost:6767");
expect(parsed).toEqual({
host: "localhost",
port: 6767,
isIpv6: false,
useTls: false,
});
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767");
});
test("round-trips an SSL-enabled tcp host and port", () => {
const parsed = parseConnectionUri("tcp://example.com:443?ssl=true");
expect(parsed).toEqual({
host: "example.com",
port: 443,
isIpv6: false,
useTls: true,
});
expect(serializeConnectionUri(parsed)).toBe("tcp://example.com:443?ssl=true");
});
test("round-trips an IPv6 host", () => {
const parsed = parseConnectionUri("tcp://[::1]:6767?ssl=true");
expect(parsed).toEqual({
host: "::1",
port: 6767,
isIpv6: true,
useTls: true,
});
expect(serializeConnectionUri(parsed)).toBe("tcp://[::1]:6767?ssl=true");
});
test("rejects a missing port", () => {
expect(() => parseConnectionUri("tcp://localhost")).toThrow("Connection URI port is required");
});
test("rejects an invalid scheme", () => {
expect(() => parseConnectionUri("http://localhost:6767")).toThrow(
"Connection URI protocol must be tcp:",
);
});
test("parses password without including it in the public serializer", () => {
const parsed = parseConnectionUri("tcp://localhost:6767?ssl=true&password=secret");
expect(parsed).toEqual({
host: "localhost",
port: 6767,
isIpv6: false,
useTls: true,
password: "secret",
});
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767?ssl=true");
expect(serializeConnectionUriForStorage(parsed)).toBe(
"tcp://localhost:6767?ssl=true&password=secret",
);
});
test("rejects userinfo passwords", () => {
expect(() => parseConnectionUri("tcp://:secret@localhost:6767?ssl=true")).toThrow(
"Connection URI userinfo is not supported",
);
});
});
describe("daemon websocket URLs", () => {
test("uses ws for port 443 when TLS is disabled", () => {
expect(buildDaemonWebSocketUrl("example.com:443", { useTls: false })).toBe(
"ws://example.com:443/ws",
);
});
test("uses wss for non-443 ports when TLS is enabled", () => {
expect(buildDaemonWebSocketUrl("example.com:6767", { useTls: true })).toBe(
"wss://example.com:6767/ws",
);
});
});
describe("relay websocket URL versioning", () => {
test("defaults relay URLs to v2", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: "srv_test",
role: "client",
}),
);
expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION);
expect(url.searchParams.has("connectionId")).toBe(false);
});
test("includes connectionId when provided (server data sockets)", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: "srv_test",
role: "server",
connectionId: "conn_abc123",
}),
);
expect(url.searchParams.get("connectionId")).toBe("conn_abc123");
});
test("allows explicitly requesting v1 relay URLs", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: "srv_test",
role: "server",
version: "1",
}),
);
expect(url.searchParams.get("v")).toBe("1");
});
test("normalizes numeric relay versions", () => {
expect(normalizeRelayProtocolVersion(2)).toBe("2");
expect(normalizeRelayProtocolVersion(1)).toBe("1");
});
test("rejects unsupported relay versions", () => {
expect(() => normalizeRelayProtocolVersion("3")).toThrow('Relay version must be "1" or "2"');
});
});
describe("relay websocket URLs", () => {
test("uses ws for port 443 when TLS is disabled", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: false,
serverId: "srv_test",
role: "client",
}),
);
expect(url.protocol).toBe("ws:");
});
test("uses wss for non-443 ports when TLS is enabled", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:6767",
useTls: true,
serverId: "srv_test",
role: "client",
}),
);
expect(url.protocol).toBe("wss:");
});
test("round-trips IPv6 relay endpoints with TLS enabled", () => {
const wsUrl = buildRelayWebSocketUrl({
endpoint: "[::1]:443",
useTls: true,
serverId: "srv_test",
role: "client",
});
const url = new URL(wsUrl);
expect(url.protocol).toBe("wss:");
expect(extractHostPortFromWebSocketUrl(wsUrl)).toBe("[::1]:443");
});
});
describe("shouldUseTlsForDefaultHostedRelay", () => {
test("returns true for the hosted Paseo relay on port 443", () => {
expect(shouldUseTlsForDefaultHostedRelay("relay.paseo.sh:443")).toBe(true);
});
test("returns true for any self-hosted relay on port 443", () => {
expect(shouldUseTlsForDefaultHostedRelay("relay.example.com:443")).toBe(true);
});
test("returns true for an IPv6 relay on port 443", () => {
expect(shouldUseTlsForDefaultHostedRelay("[::1]:443")).toBe(true);
});
test("returns false for a relay on a non-443 port", () => {
expect(shouldUseTlsForDefaultHostedRelay("relay.example.com:8080")).toBe(false);
});
test("returns false for malformed endpoints", () => {
expect(shouldUseTlsForDefaultHostedRelay("not-an-endpoint")).toBe(false);
});
});
+250
View File
@@ -0,0 +1,250 @@
export interface HostPortParts {
host: string;
port: number;
isIpv6: boolean;
}
export interface ConnectionUriParts extends HostPortParts {
useTls: boolean;
}
export interface ParsedConnectionUri extends ConnectionUriParts {
password?: string;
}
export type RelayRole = "server" | "client";
export type RelayProtocolVersion = "1" | "2";
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
export const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
export function normalizeRelayProtocolVersion(
value: unknown,
fallback: RelayProtocolVersion = CURRENT_RELAY_PROTOCOL_VERSION,
): RelayProtocolVersion {
if (value == null) {
return fallback;
}
let normalized = "";
if (typeof value === "string") {
normalized = value.trim();
} else if (typeof value === "number") {
normalized = String(value);
}
if (!normalized) {
return fallback;
}
if (normalized === "1" || normalized === "2") {
return normalized;
}
throw new Error('Relay version must be "1" or "2"');
}
function parsePort(portStr: string, context: string): number {
const port = Number(portStr);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${context}: port must be between 1 and 65535`);
}
return port;
}
export function parseHostPort(input: string): HostPortParts {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Host is required");
}
// IPv6: [::1]:6767
if (trimmed.startsWith("[")) {
const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
if (!match) {
throw new Error("Invalid host:port (expected [::1]:6767)");
}
const host = match[1].trim();
if (!host) throw new Error("Host is required");
const port = parsePort(match[2], "Invalid host:port");
return { host, port, isIpv6: true };
}
const match = trimmed.match(/^(.+):(\d{1,5})$/);
if (!match) {
throw new Error("Invalid host:port (expected localhost:6767)");
}
const host = match[1].trim();
if (!host) throw new Error("Host is required");
const port = parsePort(match[2], "Invalid host:port");
return { host, port, isIpv6: false };
}
export function normalizeHostPort(input: string): string {
const { host, port, isIpv6 } = parseHostPort(input);
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
}
export function parseConnectionUri(input: string): ParsedConnectionUri {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Connection URI is required");
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error("Invalid connection URI");
}
if (parsed.protocol !== "tcp:") {
throw new Error("Connection URI protocol must be tcp:");
}
if (!parsed.hostname) {
throw new Error("Connection URI host is required");
}
if (!parsed.port) {
throw new Error("Connection URI port is required");
}
if (parsed.username || parsed.password) {
throw new Error("Connection URI userinfo is not supported");
}
const isIpv6 = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]");
const host = isIpv6 ? parsed.hostname.slice(1, -1) : parsed.hostname;
const password = parsed.searchParams.get("password") || undefined;
return {
host,
port: parsePort(parsed.port, "Invalid connection URI"),
isIpv6,
useTls: parsed.searchParams.get("ssl") === "true",
...(password ? { password } : {}),
};
}
export function serializeConnectionUri(parts: ConnectionUriParts): string {
return createConnectionUri(parts).toString();
}
export function serializeConnectionUriForStorage(parts: ParsedConnectionUri): string {
const url = createConnectionUri(parts);
if (parts.password) {
url.searchParams.set("password", parts.password);
}
return url.toString();
}
function createConnectionUri(parts: ConnectionUriParts): URL {
const hostPart = parts.isIpv6 ? `[${parts.host}]` : parts.host;
const url = new URL(`tcp://${hostPart}:${parts.port}`);
if (parts.useTls) {
url.searchParams.set("ssl", "true");
}
return url;
}
export function normalizeLoopbackToLocalhost(endpoint: string): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
return `localhost:${port}`;
}
if (isIpv6 && (host === "::1" || host === "::")) {
return `localhost:${port}`;
}
return endpoint;
}
export function deriveLabelFromEndpoint(endpoint: string): string {
try {
const { host } = parseHostPort(endpoint);
return host || "Unnamed Host";
} catch {
return "Unnamed Host";
}
}
export interface WebSocketUrlOptions {
useTls: boolean;
}
export function buildDaemonWebSocketUrl(endpoint: string, opts: WebSocketUrlOptions): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = opts.useTls ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
}
export function buildRelayWebSocketUrl(params: {
endpoint: string;
useTls: boolean;
serverId: string;
role: RelayRole;
/**
* Per-connection routing identifier used by the daemon to open server data sockets.
* Clients should NOT provide this — the relay assigns a routing ID on connect.
*/
connectionId?: string;
version?: RelayProtocolVersion | 1 | 2;
}): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = params.useTls ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("serverId", params.serverId);
url.searchParams.set("role", params.role);
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
if (params.connectionId) {
url.searchParams.set("connectionId", params.connectionId);
}
return url.toString();
}
/**
* @deprecated Migration fallback for stored relay connections/offers that predate
* explicit relay useTls. Delete after two release cycles.
*/
export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
try {
const { port } = parseHostPort(endpoint);
return port === 443;
} catch {
return false;
}
}
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
const parsed = new URL(wsUrl);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
throw new Error("Invalid WebSocket URL protocol");
}
if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
throw new Error("Invalid WebSocket URL (expected /ws path)");
}
const host = parsed.hostname;
let port: number;
if (parsed.port) {
port = Number(parsed.port);
} else if (parsed.protocol === "wss:") {
port = 443;
} else {
port = 80;
}
if (!host) {
throw new Error("Invalid WebSocket URL (missing hostname)");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Invalid WebSocket URL (invalid port)");
}
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
}
export function isRelayClientWebSocketUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("serverId");
} catch {
return false;
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Extracts a human-readable error message from an unknown error value.
* Handles Error instances, string errors, and other types safely.
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return String(error);
}
/**
* Extracts an error message from an unknown error value, with a fallback
* for when no message can be extracted.
*/
export function getErrorMessageOr(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string" && error.length > 0) {
return error;
}
return fallback;
}
+86
View File
@@ -0,0 +1,86 @@
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
"https:": "https",
"http:": "http",
"ssh:": "ssh",
};
export interface GitRemoteLocation {
transport: "scp" | "ssh" | "http" | "https";
host: string;
path: string;
}
export interface GitHubRemoteIdentity {
owner: string;
name: string;
repo: string;
}
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !isGitHubHost(location.host)) return null;
return parseGitHubRemoteIdentity(location.path);
}
export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
if (scpLike) {
const host = normalizeHost(scpLike[1] ?? "");
const path = normalizeRemotePath(scpLike[2] ?? "");
if (!isValidRemoteHost(host) || !path) return null;
return { transport: "scp", host, path };
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
if (!transport) return null;
const host = normalizeHost(parsed.hostname);
let path: string;
try {
path = decodeURIComponent(parsed.pathname);
} catch {
return null;
}
const normalizedPath = normalizeRemotePath(path);
if (!isValidRemoteHost(host) || !normalizedPath) return null;
return { transport, host, path: normalizedPath };
}
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
const segments = path.split("/").filter(Boolean);
if (segments.length !== 2) return null;
const [owner, name] = segments;
if (!owner || !name) return null;
return { owner, name, repo: `${owner}/${name}` };
}
export function isGitHubHost(host: string): boolean {
return GITHUB_HOSTS.has(host);
}
export function normalizeHost(host: string): string {
return host.trim().replace(/\.+$/u, "").toLowerCase();
}
function normalizeRemotePath(path: string): string | null {
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
return normalized || null;
}
function isValidRemoteHost(host: string): boolean {
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
}
@@ -0,0 +1,12 @@
import { z } from "zod";
export const DirectTcpHostConnectionSchema = z.object({
id: z.string(),
type: z.literal("directTcp"),
endpoint: z.string(),
useTls: z.boolean().optional().default(false),
password: z.string().optional(),
});
export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>;
export type NormalizedDirectTcpHostConnection = z.output<typeof DirectTcpHostConnectionSchema>;
@@ -0,0 +1,6 @@
/**
* Providers eligible for "import recent session" discovery. ACP-based
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
+3
View File
@@ -0,0 +1,3 @@
// Adapted from type-fest's LiteralUnion:
// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts
export type LiteralUnion<T extends U, U extends string> = T | (U & Record<never, never>);
+195
View File
@@ -0,0 +1,195 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
export const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),
timestamp: z.string(),
iteration: z.number().int().positive().nullable(),
source: z.enum(["loop", "worker", "verifier", "verify-check"]),
level: z.enum(["info", "error"]),
text: z.string(),
});
export const LoopVerifyCheckResultSchema = z.object({
command: z.string(),
exitCode: z.number().int(),
passed: z.boolean(),
stdout: z.string(),
stderr: z.string(),
startedAt: z.string(),
completedAt: z.string(),
});
export const LoopVerifyPromptResultSchema = z.object({
passed: z.boolean(),
reason: z.string(),
verifierAgentId: z.string().nullable(),
startedAt: z.string(),
completedAt: z.string(),
});
export const LoopIterationRecordSchema = z.object({
index: z.number().int().positive(),
workerAgentId: z.string().nullable(),
workerStartedAt: z.string(),
workerCompletedAt: z.string().nullable(),
verifierAgentId: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(),
failureReason: z.string().nullable(),
verifyChecks: z.array(LoopVerifyCheckResultSchema),
verifyPrompt: LoopVerifyPromptResultSchema.nullable(),
});
export const LoopRecordSchema = z.object({
id: z.string(),
name: z.string().nullable(),
prompt: z.string(),
cwd: z.string(),
provider: AgentProviderSchema,
model: z.string().nullable(),
modeId: z.string().nullable().default(null),
workerProvider: AgentProviderSchema.nullable(),
workerModel: z.string().nullable(),
verifierProvider: AgentProviderSchema.nullable(),
verifierModel: z.string().nullable(),
verifierModeId: z.string().nullable().default(null),
verifyPrompt: z.string().nullable(),
verifyChecks: z.array(z.string()),
archive: z.boolean(),
sleepMs: z.number().int().nonnegative(),
maxIterations: z.number().int().positive().nullable(),
maxTimeMs: z.number().int().positive().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
createdAt: z.string(),
updatedAt: z.string(),
startedAt: z.string(),
completedAt: z.string().nullable(),
stopRequestedAt: z.string().nullable(),
iterations: z.array(LoopIterationRecordSchema),
logs: z.array(LoopLogEntrySchema),
nextLogSeq: z.number().int().positive(),
activeIteration: z.number().int().positive().nullable(),
activeWorkerAgentId: z.string().nullable(),
activeVerifierAgentId: z.string().nullable(),
});
export const LoopListItemSchema = z.object({
id: z.string(),
name: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed", "stopped"]),
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
activeIteration: z.number().int().positive().nullable(),
});
export const LoopRunRequestSchema = z.object({
type: z.literal("loop/run"),
requestId: z.string(),
prompt: z.string().trim().min(1),
cwd: z.string(),
provider: AgentProviderSchema.optional(),
model: z.string().trim().min(1).optional(),
modeId: z.string().trim().min(1).optional(),
workerProvider: AgentProviderSchema.optional(),
workerModel: z.string().trim().min(1).optional(),
verifierProvider: AgentProviderSchema.optional(),
verifierModel: z.string().trim().min(1).optional(),
verifierModeId: z.string().trim().min(1).optional(),
verifyPrompt: z.string().trim().min(1).optional(),
verifyChecks: z.array(z.string().trim().min(1)).optional(),
archive: z.boolean().optional(),
name: z.string().trim().min(1).optional(),
sleepMs: z.number().int().nonnegative().optional(),
maxIterations: z.number().int().positive().optional(),
maxTimeMs: z.number().int().positive().optional(),
});
export const LoopListRequestSchema = z.object({
type: z.literal("loop/list"),
requestId: z.string(),
});
export const LoopInspectRequestSchema = z.object({
type: z.literal("loop/inspect"),
requestId: z.string(),
id: z.string().trim().min(1),
});
export const LoopLogsRequestSchema = z.object({
type: z.literal("loop/logs"),
requestId: z.string(),
id: z.string().trim().min(1),
afterSeq: z.number().int().nonnegative().optional(),
});
export const LoopStopRequestSchema = z.object({
type: z.literal("loop/stop"),
requestId: z.string(),
id: z.string().trim().min(1),
});
export const LoopRunResponseSchema = z.object({
type: z.literal("loop/run/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
error: z.string().nullable(),
}),
});
export const LoopListResponseSchema = z.object({
type: z.literal("loop/list/response"),
payload: z.object({
requestId: z.string(),
loops: z.array(LoopListItemSchema),
error: z.string().nullable(),
}),
});
export const LoopInspectResponseSchema = z.object({
type: z.literal("loop/inspect/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
error: z.string().nullable(),
}),
});
export const LoopLogsResponseSchema = z.object({
type: z.literal("loop/logs/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
entries: z.array(LoopLogEntrySchema),
nextCursor: z.number().int().nonnegative(),
error: z.string().nullable(),
}),
});
export const LoopStopResponseSchema = z.object({
type: z.literal("loop/stop/response"),
payload: z.object({
requestId: z.string(),
loop: LoopRecordSchema.nullable(),
error: z.string().nullable(),
}),
});
export type LoopLogEntry = z.infer<typeof LoopLogEntrySchema>;
export type LoopVerifyCheckResult = z.infer<typeof LoopVerifyCheckResultSchema>;
export type LoopVerifyPromptResult = z.infer<typeof LoopVerifyPromptResultSchema>;
export type LoopIterationRecord = z.infer<typeof LoopIterationRecordSchema>;
export type LoopRecord = z.infer<typeof LoopRecordSchema>;
export type LoopListItem = z.infer<typeof LoopListItemSchema>;
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
export type LoopLogsResponse = z.infer<typeof LoopLogsResponseSchema>;
export type LoopStopResponse = z.infer<typeof LoopStopResponseSchema>;
@@ -0,0 +1,327 @@
import { describe, expect, it } from "vitest";
import {
CreateAgentRequestMessageSchema,
CreatePaseoWorktreeRequestSchema,
SendAgentMessageRequestSchema,
} from "./messages.js";
describe("shared messages attachments", () => {
it("keeps valid review attachments", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-review",
agentId: "agent-1",
text: "Please address these comments",
attachments: [
{
type: "review",
mimeType: "application/paseo-review",
cwd: "/tmp/repo",
mode: "base",
baseRef: "main",
comments: [
{
filePath: "src/index.ts",
side: "new",
lineNumber: 42,
body: "Please guard this nullable value.",
context: {
hunkHeader: "@@ -40,3 +40,4 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 42,
type: "add",
content: "const value = maybeNull.name;",
},
lines: [
{
oldLineNumber: 41,
newLineNumber: 41,
type: "context",
content: "const before = true;",
},
{
oldLineNumber: null,
newLineNumber: 42,
type: "add",
content: "const value = maybeNull.name;",
},
],
},
},
],
},
],
});
expect(parsed.attachments).toEqual([
{
type: "review",
mimeType: "application/paseo-review",
cwd: "/tmp/repo",
mode: "base",
baseRef: "main",
comments: [
{
filePath: "src/index.ts",
side: "new",
lineNumber: 42,
body: "Please guard this nullable value.",
context: {
hunkHeader: "@@ -40,3 +40,4 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 42,
type: "add",
content: "const value = maybeNull.name;",
},
lines: [
{
oldLineNumber: 41,
newLineNumber: 41,
type: "context",
content: "const before = true;",
},
{
oldLineNumber: null,
newLineNumber: 42,
type: "add",
content: "const value = maybeNull.name;",
},
],
},
},
],
},
]);
});
it("drops malformed review attachments while keeping valid attachments", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-bad-review",
agentId: "agent-1",
text: "Review",
attachments: [
{
type: "review",
mimeType: "application/paseo-review",
cwd: "/tmp/repo",
mode: "uncommitted",
comments: [
{
filePath: "src/index.ts",
side: "new",
lineNumber: "42",
body: "This line number is malformed.",
context: {
hunkHeader: "@@ -40,3 +40,4 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 42,
type: "add",
content: "const value = maybeNull.name;",
},
lines: [],
},
},
],
},
{
type: "github_issue",
mimeType: "application/github-issue",
number: 55,
title: "Improve startup error details",
url: "https://github.com/getpaseo/paseo/issues/55",
},
],
});
expect(parsed.attachments).toEqual([
{
type: "github_issue",
mimeType: "application/github-issue",
number: 55,
title: "Improve startup error details",
url: "https://github.com/getpaseo/paseo/issues/55",
},
]);
});
it("keeps known attachments and drops unknown create-agent attachments", () => {
const parsed = CreateAgentRequestMessageSchema.parse({
type: "create_agent_request",
requestId: "req-1",
config: {
provider: "codex",
cwd: "/tmp/repo",
},
initialPrompt: "Review this PR",
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
body: "Body",
baseRefName: "main",
headRefName: "fix/worktree-race",
},
{
type: "future_attachment",
mimeType: "application/future",
foo: "bar",
},
],
});
expect(parsed.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
body: "Body",
baseRefName: "main",
headRefName: "fix/worktree-race",
},
]);
});
it("keeps known attachments and drops unknown send-message attachments", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-2",
agentId: "agent-1",
text: "Review",
attachments: [
{
type: "github_issue",
mimeType: "application/github-issue",
number: 55,
title: "Improve startup error details",
url: "https://github.com/getpaseo/paseo/issues/55",
body: "Body",
},
{
type: "future_attachment",
mimeType: "application/future",
foo: "bar",
},
],
});
expect(parsed.attachments).toEqual([
{
type: "github_issue",
mimeType: "application/github-issue",
number: 55,
title: "Improve startup error details",
url: "https://github.com/getpaseo/paseo/issues/55",
body: "Body",
},
]);
});
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
requestId: "req-3",
cwd: "/tmp/repo",
firstAgentContext: {
prompt: "Investigate flaky test",
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 99,
title: "Fork-safe PR checkout",
url: "https://github.com/getpaseo/paseo/pull/99",
},
{
type: "future_attachment",
mimeType: "application/future",
foo: "bar",
},
],
},
});
expect(parsed.firstAgentContext?.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 99,
title: "Fork-safe PR checkout",
url: "https://github.com/getpaseo/paseo/pull/99",
},
]);
expect(parsed.firstAgentContext?.prompt).toBe("Investigate flaky test");
});
it("parses worktree-create payloads without a firstAgentContext", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
requestId: "req-4",
cwd: "/tmp/repo",
});
expect(parsed).toEqual({
type: "create_paseo_worktree_request",
requestId: "req-4",
cwd: "/tmp/repo",
});
});
it("accepts and strips create-worktree intent fields compatibly", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
requestId: "req-5",
cwd: "/tmp/repo",
action: "checkout",
refName: "feature/ref-picker",
githubPrNumber: 42,
futureField: "ignored",
});
expect(parsed).toEqual({
type: "create_paseo_worktree_request",
requestId: "req-5",
cwd: "/tmp/repo",
action: "checkout",
refName: "feature/ref-picker",
githubPrNumber: 42,
});
});
it("accepts optional create-agent git intent fields and strips unknown git fields", () => {
const parsed = CreateAgentRequestMessageSchema.parse({
type: "create_agent_request",
requestId: "req-6",
config: {
provider: "codex",
cwd: "/tmp/repo",
},
attachments: [],
git: {
createWorktree: true,
worktreeSlug: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
futureGitField: "ignored",
},
});
expect(parsed.git).toEqual({
createWorktree: true,
worktreeSlug: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
});
});
});
@@ -0,0 +1,189 @@
import { describe, expect, test } from "vitest";
import {
CheckoutGithubSetAutoMergeRequestSchema,
CheckoutGithubSetAutoMergeResponseSchema,
CheckoutPrMergeRequestSchema,
CheckoutPrStatusSchema,
ServerInfoStatusPayloadSchema,
} from "./messages.js";
describe("checkout PR schemas", () => {
test("parses PR status payloads without mergeability", () => {
expect(
CheckoutPrStatusSchema.parse({
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Ship it",
state: "open",
baseRefName: "main",
headRefName: "feature/ship-it",
isMerged: false,
}),
).toMatchObject({
number: 42,
mergeable: "UNKNOWN",
});
});
test("keeps missing provider-specific GitHub PR facts absent for old daemons", () => {
const parsed = CheckoutPrStatusSchema.parse({
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Ship it",
state: "open",
baseRefName: "main",
headRefName: "feature/ship-it",
isMerged: false,
mergeable: "MERGEABLE",
});
expect(parsed.github).toBeUndefined();
});
test("parses provider-specific GitHub PR status facts", () => {
expect(
CheckoutPrStatusSchema.parse({
number: 993,
url: "https://github.com/getpaseo/paseo/pull/993",
title: "Block direct merge while checks run",
state: "open",
baseRefName: "main",
headRefName: "phase-2",
isMerged: false,
mergeable: "MERGEABLE",
checks: [{ name: "server tests", status: "pending", url: null }],
checksStatus: "pending",
github: {
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
},
}),
).toMatchObject({
mergeable: "MERGEABLE",
checksStatus: "pending",
github: {
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
squashMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
},
});
});
test.each(["merge", "squash", "rebase"] as const)(
"accepts %s as a PR merge method",
(mergeMethod) => {
expect(
CheckoutPrMergeRequestSchema.parse({
type: "checkout_pr_merge_request",
cwd: "/tmp/repo",
mergeMethod,
requestId: "request-merge-pr",
}),
).toMatchObject({ mergeMethod });
},
);
test("rejects unknown PR merge methods", () => {
expect(() =>
CheckoutPrMergeRequestSchema.parse({
type: "checkout_pr_merge_request",
cwd: "/tmp/repo",
mergeMethod: "auto",
requestId: "request-merge-pr",
}),
).toThrow();
});
test.each(["merge", "squash", "rebase"] as const)(
"accepts %s as a GitHub set-auto-merge enable method",
(mergeMethod) => {
expect(
CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo",
enabled: true,
mergeMethod,
requestId: "request-enable-auto-merge",
}),
).toMatchObject({ enabled: true, mergeMethod });
},
);
test("rejects unknown GitHub set-auto-merge enable methods", () => {
expect(() =>
CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo",
enabled: true,
mergeMethod: "auto",
requestId: "request-enable-auto-merge",
}),
).toThrow();
});
test("accepts GitHub set-auto-merge disable requests", () => {
expect(
CheckoutGithubSetAutoMergeRequestSchema.parse({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/repo",
enabled: false,
requestId: "request-disable-auto-merge",
}),
).toMatchObject({
cwd: "/tmp/repo",
enabled: false,
requestId: "request-disable-auto-merge",
});
});
test("accepts GitHub set-auto-merge responses", () => {
const payload = {
cwd: "/tmp/repo",
enabled: true,
success: true,
error: null,
requestId: "request-auto-merge",
};
expect(
CheckoutGithubSetAutoMergeResponseSchema.parse({
type: "checkout.github.set_auto_merge.response",
payload,
}).payload,
).toEqual(payload);
});
test("accepts the GitHub auto-merge server_info feature flag", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "srv_test",
features: {
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
},
}).features,
).toEqual({
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
});
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { SessionInboundMessageSchema } from "./messages";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
describe("create_agent_request clientMessageId", () => {
it("accepts clientMessageId for stable initial prompt transfer", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req-1",
clientMessageId: "client-msg-1",
config: {
provider: "claude",
cwd: "/tmp/project",
},
initialPrompt: "hello",
});
expect(parsed.type).toBe("create_agent_request");
if (parsed.type !== "create_agent_request") {
throw new Error("Expected create_agent_request");
}
expect(parsed.clientMessageId).toBe("client-msg-1");
});
it("accepts explicit titles up to the create-agent limit", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req-title-ok",
config: {
provider: "claude",
cwd: "/tmp/project",
title: "x".repeat(MAX_EXPLICIT_AGENT_TITLE_CHARS),
},
});
expect(parsed.type).toBe("create_agent_request");
if (parsed.type !== "create_agent_request") {
throw new Error("Expected create_agent_request");
}
expect(parsed.config.title).toHaveLength(MAX_EXPLICIT_AGENT_TITLE_CHARS);
});
it("rejects explicit titles longer than the create-agent limit", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "create_agent_request",
requestId: "req-title-too-long",
config: {
provider: "claude",
cwd: "/tmp/project",
title: "x".repeat(MAX_EXPLICIT_AGENT_TITLE_CHARS + 1),
},
});
expect(parsed.success).toBe(false);
});
});
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema } from "./messages.js";
describe("create_agent_request worktree and autoArchive fields", () => {
test("accepts optional worktree branch-off target and autoArchive", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "create-agent-worktree",
config: {
provider: "codex",
cwd: "/repo/app",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "create-agent-worktree",
config: {
provider: "codex",
cwd: "/repo/app",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
labels: {},
});
});
test("keeps legacy create_agent_request defaults unchanged", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "legacy-create-agent",
config: {
provider: "codex",
cwd: "/repo/app",
},
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "legacy-create-agent",
config: {
provider: "codex",
cwd: "/repo/app",
},
labels: {},
});
});
});
@@ -0,0 +1,52 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema } from "./messages.js";
describe("list_commands_request schema", () => {
test("accepts legacy agent-only payload", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_commands_request",
agentId: "agent-123",
requestId: "req-123",
});
expect(parsed.type).toBe("list_commands_request");
if (parsed.type !== "list_commands_request") {
throw new Error("Expected list_commands_request message");
}
expect(parsed.agentId).toBe("agent-123");
expect(parsed.draftConfig).toBeUndefined();
});
test("accepts draft command context payload", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_commands_request",
agentId: "__new_agent__",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
},
requestId: "req-456",
});
expect(parsed.type).toBe("list_commands_request");
if (parsed.type !== "list_commands_request") {
throw new Error("Expected list_commands_request message");
}
expect(parsed.draftConfig).toEqual({
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
});
});
});
@@ -0,0 +1,117 @@
import { describe, expect, test } from "vitest";
import {
GetProvidersSnapshotResponseMessageSchema,
ProviderSnapshotEntrySchema,
ProvidersSnapshotUpdateMessageSchema,
} from "./messages.js";
describe("provider snapshot message schemas", () => {
test("defaults missing provider snapshot entry enabled state to true", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "codex",
status: "ready",
label: "Codex",
});
expect(parsed.enabled).toBe(true);
});
test("preserves disabled provider snapshot entries", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "claude",
status: "unavailable",
enabled: false,
label: "Claude",
});
expect(parsed.enabled).toBe(false);
});
test("preserves enabled provider snapshot entries", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "opencode",
status: "loading",
enabled: true,
label: "OpenCode",
});
expect(parsed.enabled).toBe(true);
});
test("normalizes thinking option defaults on provider snapshot models", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "claude",
status: "ready",
models: [
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
},
],
});
expect(parsed.models).toEqual([
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
defaultThinkingOptionId: "max",
},
]);
});
test("defaults missing enabled state in providers snapshot response entries", () => {
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
type: "get_providers_snapshot_response",
payload: {
entries: [
{
provider: "codex",
status: "ready",
label: "Codex",
},
{
provider: "claude",
status: "unavailable",
enabled: false,
label: "Claude",
},
],
generatedAt: "2026-04-24T00:00:00.000Z",
requestId: "req-providers",
},
});
expect(parsed.payload.entries.map((entry) => entry.enabled)).toEqual([true, false]);
});
test("defaults missing enabled state in providers snapshot update entries", () => {
const parsed = ProvidersSnapshotUpdateMessageSchema.parse({
type: "providers_snapshot_update",
payload: {
cwd: "/tmp/repo",
entries: [
{
provider: "codex",
status: "ready",
label: "Codex",
},
],
generatedAt: "2026-04-24T00:00:00.000Z",
},
});
expect(parsed.payload.entries[0]?.enabled).toBe(true);
});
});
@@ -0,0 +1,281 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import {
CheckoutPrStatusResponseSchema,
PullRequestTimelineRequestSchema,
PullRequestTimelineResponseSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "./messages.js";
describe("pull request timeline message schemas", () => {
test("requires request identity fields", () => {
expect(() =>
PullRequestTimelineRequestSchema.parse({
type: "pull_request_timeline_request",
}),
).toThrow();
});
test("parses request fields", () => {
const parsed = PullRequestTimelineRequestSchema.parse({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
expect(parsed).toEqual({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
});
test("parses request through the inbound message union", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
expect(parsed).toEqual({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
});
test("defaults optional response payload and malformed timeline item fields", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
items: [{}, { kind: "review" }],
},
});
expect(parsed).toEqual({
type: "pull_request_timeline_response",
payload: {
cwd: "",
prNumber: null,
items: [
{
id: "",
kind: "comment",
author: "unknown",
body: "",
createdAt: 0,
url: "",
},
{
id: "",
kind: "review",
author: "unknown",
body: "",
createdAt: 0,
url: "",
reviewState: "commented",
},
],
truncated: false,
error: null,
requestId: "",
githubFeaturesEnabled: true,
},
});
});
test("normalizes unknown timeline item kinds to comments", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
items: [
{
id: "future-1",
kind: "unknown_future_kind",
author: "octocat",
body: "Future daemon item",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#future-1",
},
],
},
});
expect(parsed.payload.items).toEqual([
{
id: "future-1",
kind: "comment",
author: "octocat",
body: "Future daemon item",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#future-1",
},
]);
});
test("parses response through the outbound message union", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "pull_request_timeline_response",
payload: {
cwd: "/tmp/repo",
prNumber: 42,
items: [
{
id: "review-1",
kind: "review",
author: "octocat",
body: "Looks good",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
reviewState: "approved",
},
{
id: "comment-1",
kind: "comment",
author: "hubot",
body: "Left a note",
createdAt: 1710000001000,
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
},
],
truncated: true,
error: { kind: "unknown", message: "rate limited" },
requestId: "request-1",
githubFeaturesEnabled: true,
},
});
expect(parsed.type).toBe("pull_request_timeline_response");
});
test("normalizes future error kinds to unknown", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
error: { kind: "future_kind", message: "x" },
},
});
expect(parsed.payload.error).toEqual({ kind: "unknown", message: "x" });
});
test.each(["rate_limited", "", undefined])(
"normalizes malformed error kind %j to unknown",
(kind) => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
error: { kind },
},
});
expect(parsed.payload.error).toEqual({ kind: "unknown", message: "" });
},
);
});
describe("checkout PR status compatibility", () => {
test("an old client schema parses a new daemon checkout PR status response", () => {
const oldClientCheckoutPrStatusResponseSchema = z.object({
type: z.literal("checkout_pr_status_response"),
payload: z.object({
cwd: z.string(),
status: z
.object({
url: z.string(),
title: z.string(),
state: z.string(),
baseRefName: z.string(),
headRefName: z.string(),
isMerged: z.boolean(),
checks: z
.array(
z.object({
name: z.string(),
status: z.string(),
url: z.string().nullable(),
}),
)
.optional()
.default([]),
checksStatus: z.string().optional(),
reviewDecision: z.string().nullable().optional(),
})
.nullable(),
githubFeaturesEnabled: z.boolean(),
error: z.unknown().nullable(),
requestId: z.string(),
}),
});
const newDaemonPayload = CheckoutPrStatusResponseSchema.parse({
type: "checkout_pr_status_response",
payload: {
cwd: "/tmp/repo",
status: {
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
state: "OPEN",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
checks: [
{
name: "server-tests",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/123",
workflow: "Server CI",
duration: "2m 14s",
},
],
checksStatus: "success",
reviewDecision: "APPROVED",
},
githubFeaturesEnabled: true,
error: null,
requestId: "request-compat",
},
});
expect(oldClientCheckoutPrStatusResponseSchema.parse(newDaemonPayload)).toEqual({
type: "checkout_pr_status_response",
payload: {
cwd: "/tmp/repo",
status: {
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
state: "OPEN",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
checks: [
{
name: "server-tests",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/123",
},
],
checksStatus: "success",
reviewDecision: "APPROVED",
},
githubFeaturesEnabled: true,
error: null,
requestId: "request-compat",
},
});
});
});
@@ -0,0 +1,140 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">;
function schemaWithoutMessageTypes(
schema: { options: SessionMessageOption[] },
excludedTypes: string[],
) {
const excluded = new Set(excludedTypes);
const options = schema.options.filter((option) => !excluded.has(option.shape.type.value));
return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]);
}
describe("rename entity message schemas", () => {
test("new client schema still parses old daemon checkout and terminal responses", () => {
const checkoutResponse = SessionOutboundMessageSchema.parse({
type: "checkout_switch_branch_response",
payload: {
cwd: "/tmp/repo",
success: true,
branch: "main",
source: "local",
error: null,
requestId: "request-switch",
},
});
const terminalResponse = SessionOutboundMessageSchema.parse({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
});
expect(checkoutResponse).toEqual({
type: "checkout_switch_branch_response",
payload: {
cwd: "/tmp/repo",
success: true,
branch: "main",
source: "local",
error: null,
requestId: "request-switch",
},
});
expect(terminalResponse).toEqual({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
});
});
test("old unions without rename variants reject rename messages and still parse existing messages", () => {
const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [
"terminal.rename.request",
"checkout.rename_branch.request",
]);
const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [
"terminal.rename.response",
"checkout.rename_branch.response",
]);
expect(
legacyInboundSchema.safeParse({
type: "terminal.rename.request",
terminalId: "terminal-1",
title: "Server logs",
requestId: "request-terminal-rename",
}).success,
).toBe(false);
expect(
legacyInboundSchema.safeParse({
type: "checkout.rename_branch.request",
cwd: "/tmp/repo",
branch: "feature/new-name",
requestId: "request-branch-rename",
}).success,
).toBe(false);
expect(
legacyOutboundSchema.safeParse({
type: "terminal.rename.response",
payload: {
requestId: "request-terminal-rename",
success: true,
error: null,
},
}).success,
).toBe(false);
expect(
legacyOutboundSchema.safeParse({
type: "checkout.rename_branch.response",
payload: {
requestId: "request-branch-rename",
success: true,
cwd: "/tmp/repo",
currentBranch: "feature/new-name",
error: null,
},
}).success,
).toBe(false);
expect(
legacyInboundSchema.parse({
type: "checkout_switch_branch_request",
cwd: "/tmp/repo",
branch: "main",
requestId: "request-switch",
}),
).toEqual({
type: "checkout_switch_branch_request",
cwd: "/tmp/repo",
branch: "main",
requestId: "request-switch",
});
expect(
legacyOutboundSchema.parse({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
}),
).toEqual({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
});
});
});
@@ -0,0 +1,405 @@
import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
FetchAgentTimelineResponseMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WSOutboundMessageSchema,
} from "./messages.js";
describe("shared messages stream parsing", () => {
it("parses representative fetch_agent_timeline_response payload", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_live",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 },
hasOlder: false,
hasNewer: false,
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
},
],
error: null,
},
});
expect(parsed.payload.entries).toHaveLength(1);
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("parses legacy worktree setup timeline entries without per-command log", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-legacy-setup",
agentId: "agent_legacy_setup",
agent: null,
direction: "tail",
projection: "canonical",
epoch: "epoch-setup",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-setup", seq: 1 },
endCursor: { epoch: "epoch-setup", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
provider: "codex",
item: {
type: "tool_call",
callId: "setup-1",
name: "paseo_worktree_setup",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature",
branchName: "feature",
log: "setup complete",
commands: [
{
index: 1,
command: "npm install",
cwd: "/repo/.paseo/worktrees/feature",
status: "completed",
exitCode: 0,
durationMs: 100,
},
],
},
error: null,
},
timestamp: "2026-04-22T00:00:00.000Z",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
},
],
error: null,
},
});
const item = parsed.payload.entries[0]?.item;
expect(item?.type).toBe("tool_call");
if (item?.type !== "tool_call" || item.detail.type !== "worktree_setup") {
throw new Error("Expected worktree setup tool call");
}
expect(item.detail.commands[0]?.log).toBe("");
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
const shutdownParsed = SessionInboundMessageSchema.safeParse({
type: "shutdown_server_request",
requestId: "req-shutdown-1",
});
expect(shutdownParsed.success).toBe(true);
const restartParsed = SessionInboundMessageSchema.safeParse({
type: "restart_server_request",
requestId: "req-restart-1",
reason: "settings_changed",
});
expect(restartParsed.success).toBe(true);
expect(shutdownParsed.success && shutdownParsed.data.type).toBe("shutdown_server_request");
expect(restartParsed.success && restartParsed.data.type).toBe("restart_server_request");
});
it("parses representative agent_stream tool_call event", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId: "call_live",
name: "shell",
status: "running",
detail: {
type: "shell",
command: "ls",
},
error: null,
},
},
},
});
expect(parsed.payload.event.type).toBe("timeline");
if (parsed.payload.event.type === "timeline") {
expect(parsed.payload.event.item.type).toBe("tool_call");
if (parsed.payload.event.item.type === "tool_call") {
expect(parsed.payload.event.item.status).toBe("running");
}
}
});
it("parses representative sub_agent tool_call event", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId: "call_sub_agent_live",
name: "Task",
status: "running",
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md",
},
error: null,
},
},
},
});
expect(parsed.payload.event.type).toBe("timeline");
if (parsed.payload.event.type === "timeline") {
expect(parsed.payload.event.item.type).toBe("tool_call");
if (parsed.payload.event.item.type === "tool_call") {
expect(parsed.payload.event.item.detail.type).toBe("sub_agent");
}
}
});
it("parses optional permission actions and selectedActionId compatibly", () => {
const requestParsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "permission_requested",
provider: "codex",
request: {
id: "perm-1",
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: { plan: "- step 1" },
actions: [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
],
},
},
},
});
expect(requestParsed.payload.event.type).toBe("permission_requested");
if (requestParsed.payload.event.type === "permission_requested") {
expect(requestParsed.payload.event.request.actions).toHaveLength(2);
expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement");
}
const resolutionParsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:01.000Z",
event: {
type: "permission_resolved",
provider: "claude",
requestId: "perm-1",
resolution: {
behavior: "allow",
selectedActionId: "implement_resume",
},
},
},
});
expect(resolutionParsed.payload.event.type).toBe("permission_resolved");
if (resolutionParsed.payload.event.type === "permission_resolved") {
expect(resolutionParsed.payload.event.resolution).toEqual({
behavior: "allow",
selectedActionId: "implement_resume",
});
}
});
it("parses permission request detail compatibly", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-shell-1",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
input: {
command: "ls /tmp/outside",
},
detail: {
type: "shell",
command: "ls /tmp/outside",
cwd: "/home/dev/project",
},
},
},
},
});
expect(parsed.payload.event.type).toBe("permission_requested");
if (parsed.payload.event.type === "permission_requested") {
expect(parsed.payload.event.request.detail).toEqual({
type: "shell",
command: "ls /tmp/outside",
cwd: "/home/dev/project",
});
}
});
it("rejects removed initialize_agent_request inbound payload", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request",
agentId: "agent-legacy",
requestId: "req-legacy-1",
});
expect(parsed.success).toBe(false);
});
it("rejects removed initialize_agent_request outbound payload", () => {
const parsed = SessionOutboundMessageSchema.safeParse({
type: "initialize_agent_request",
payload: {
requestId: "req-legacy-1",
agentId: "agent-legacy",
agentStatus: "running",
timelineSize: 12,
},
});
expect(parsed.success).toBe(false);
});
it("parses directory suggestions request and response payloads", () => {
const requestParsed = SessionInboundMessageSchema.safeParse({
type: "directory_suggestions_request",
query: "proj",
cwd: "/tmp/project",
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
limit: 20,
requestId: "req-dir-1",
});
expect(requestParsed.success).toBe(true);
const responseParsed = SessionOutboundMessageSchema.safeParse({
type: "directory_suggestions_response",
payload: {
directories: ["/Users/test/projects/paseo"],
entries: [{ path: "/Users/test/projects/paseo", kind: "directory" }],
error: null,
requestId: "req-dir-1",
},
});
expect(responseParsed.success).toBe(true);
});
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
const fixture = {
type: "agent_stream_snapshot",
payload: {
agentId: "agent-legacy",
events: [],
},
};
const wrapped = WSOutboundMessageSchema.safeParse({
type: "session",
message: fixture,
});
expect(wrapped.success).toBe(false);
});
it("rejects removed legacy git diff request messages", () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request",
agentId: "agent-1",
requestId: "req-1",
});
expect(gitDiffParsed.success).toBe(false);
const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request",
agentId: "agent-1",
requestId: "req-2",
});
expect(highlightedParsed.success).toBe(false);
});
it("rejects removed legacy git diff response messages", () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response",
payload: {
agentId: "agent-1",
diff: "",
error: null,
requestId: "req-1",
},
});
expect(gitDiffParsed.success).toBe(false);
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response",
payload: {
agentId: "agent-1",
files: [],
error: null,
requestId: "req-2",
},
});
expect(highlightedParsed.success).toBe(false);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { ServerInfoStatusPayloadSchema, SubscribeTerminalRequestSchema } from "./messages.js";
describe("terminal restore schemas", () => {
test("accepts legacy terminal subscribe requests without restore options", () => {
expect(
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
}),
).toEqual({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
});
});
test("accepts kebab-case terminal restore modes", () => {
for (const mode of ["live", "visible-snapshot", "full-snapshot"] as const) {
expect(
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: `req-${mode}`,
restore: {
mode,
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
},
}).restore?.mode,
).toBe(mode);
}
});
test("rejects camel-case terminal restore modes", () => {
expect(() =>
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visibleSnapshot",
},
}),
).toThrow();
});
test("accepts terminal restore mode feature metadata", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
features: {
"terminal-restore-modes": true,
},
}).features?.["terminal-restore-modes"],
).toBe(true);
});
});
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, test } from "vitest";
import { FileExplorerRequestSchema, SessionOutboundMessageSchema } from "./messages.js";
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
return {
id: "ws-1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "app",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
...overrides,
};
}
function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
return {
type: "fetch_workspaces_response",
payload: {
requestId: "req-1",
entries: [workspace],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
};
}
describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(workspaceDescriptor()),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]?.project).toBeUndefined();
});
test("new-shaped fetch_workspaces_response with project placement parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(
workspaceDescriptor({
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]?.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
test("adding project does not narrow existing descriptor fields", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(
workspaceDescriptor({
workspaceDirectory: undefined,
projectKind: "non_git",
workspaceKind: "directory",
gitRuntime: null,
githubRuntime: null,
project: {
projectKey: "/repo/local",
projectName: "local",
checkout: {
cwd: "/repo/local",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]).toMatchObject({
projectKind: "non_git",
workspaceKind: "directory",
workspaceDirectory: "/repo/app",
gitRuntime: null,
githubRuntime: null,
});
});
});
describe("file explorer request compatibility", () => {
test("acceptBinary is optional for old clients and accepted for new clients", () => {
expect(
FileExplorerRequestSchema.parse({
type: "file_explorer_request",
cwd: "/repo/app",
path: "image.png",
mode: "file",
requestId: "req-old",
}),
).toEqual({
type: "file_explorer_request",
cwd: "/repo/app",
path: "image.png",
mode: "file",
requestId: "req-old",
});
expect(
FileExplorerRequestSchema.parse({
type: "file_explorer_request",
cwd: "/repo/app",
path: "image.png",
mode: "file",
requestId: "req-new",
acceptBinary: true,
}),
).toMatchObject({
type: "file_explorer_request",
requestId: "req-new",
acceptBinary: true,
});
});
});
@@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import { AgentTimelineItemPayloadSchema } from "./messages.js";
function canonicalBase() {
return {
type: "tool_call" as const,
callId: "call_123",
name: "shell",
detail: {
type: "shell" as const,
command: "pwd",
},
};
}
describe("shared messages tool_call schema", () => {
it("parses each status-discriminated tool_call variant at runtime", () => {
const running = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "running",
error: null,
});
const completed = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "completed",
error: null,
});
const failed = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "failed",
error: { message: "command failed" },
});
const canceled = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "canceled",
error: null,
});
expect(running.type).toBe("tool_call");
expect(completed.type).toBe("tool_call");
expect(failed.type).toBe("tool_call");
expect(canceled.type).toBe("tool_call");
});
it("rejects non-recoverable invalid tool_call payloads", () => {
const missingCallId = AgentTimelineItemPayloadSchema.safeParse({
type: "tool_call",
name: "shell",
status: "running",
detail: {
type: "shell",
command: "pwd",
},
error: null,
});
const unknownStatus = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "mystery_status",
error: null,
});
const withTopLevelInputOutput = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "running",
error: null,
input: { command: "pwd" },
output: { exitCode: 0 },
});
expect(missingCallId.success).toBe(false);
expect(unknownStatus.success).toBe(false);
expect(withTopLevelInputOutput.success).toBe(false);
});
it("rejects legacy status/error combinations without normalization", () => {
const completedWithError = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "completed",
error: { message: "unexpected" },
});
const failedWithoutError = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "failed",
error: null,
});
const missingDetail = AgentTimelineItemPayloadSchema.safeParse({
type: "tool_call",
callId: "call_missing_detail",
name: "shell",
status: "running",
error: null,
});
const legacyStatus = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "inProgress",
error: null,
});
expect(completedWithError.success).toBe(false);
expect(failedWithoutError.success).toBe(false);
expect(missingDetail.success).toBe(false);
expect(legacyStatus.success).toBe(false);
});
it("parses canonical sub_agent detail payload", () => {
const parsed = AgentTimelineItemPayloadSchema.parse({
type: "tool_call",
callId: "call_sub_agent_1",
name: "Task",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md\n[Bash] ls",
},
});
expect(parsed.type).toBe("tool_call");
if (parsed.type === "tool_call") {
expect(parsed.detail.type).toBe("sub_agent");
if (parsed.detail.type === "sub_agent") {
expect(parsed.detail.subAgentType).toBe("Explore");
}
}
});
it("parses sub_agent detail without structured actions", () => {
const parsed = AgentTimelineItemPayloadSchema.parse({
type: "tool_call",
callId: "call_sub_agent_legacy",
name: "Task",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md",
},
});
expect(parsed.type).toBe("tool_call");
if (parsed.type === "tool_call") {
expect(parsed.detail.type).toBe("sub_agent");
if (parsed.detail.type === "sub_agent") {
expect(parsed.detail.log).toBe("[Read] README.md");
}
}
});
it("parses plain_text detail with icon and rejects unknown icon names", () => {
const parsed = AgentTimelineItemPayloadSchema.parse({
type: "tool_call",
callId: "call_plain_text_1",
name: "task_notification",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "Background task completed",
icon: "wrench",
},
});
expect(parsed.type).toBe("tool_call");
if (parsed.type === "tool_call") {
expect(parsed.detail.type).toBe("plain_text");
if (parsed.detail.type === "plain_text") {
expect(parsed.detail.icon).toBe("wrench");
}
}
const invalid = AgentTimelineItemPayloadSchema.safeParse({
type: "tool_call",
callId: "call_plain_text_invalid",
name: "task_notification",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "Background task completed",
icon: "laser",
},
});
expect(invalid.success).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,774 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import {
RecentProviderSessionDescriptorPayloadSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema,
} from "./messages.js";
describe("workspace message schemas", () => {
test("parses fetch_workspaces_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "fetch_workspaces_request",
requestId: "req-1",
filter: {
query: "repo",
projectId: "proj-12",
idPrefix: "/Users/me",
},
sort: [{ key: "activity_at", direction: "desc" }],
page: { limit: 50 },
subscribe: {},
});
expect(parsed.type).toBe("fetch_workspaces_request");
});
test("parses active-scoped fetch_agents_request as an optional extension", () => {
const legacy = SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId: "req-agents-legacy",
page: { limit: 50 },
});
const activeScoped = SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId: "req-agents-active",
scope: "active",
page: { limit: 50 },
subscribe: {},
});
expect(legacy.type).toBe("fetch_agents_request");
expect(activeScoped.type).toBe("fetch_agents_request");
if (activeScoped.type !== "fetch_agents_request") {
throw new Error("Expected fetch_agents_request");
}
expect(activeScoped.scope).toBe("active");
});
test("parses agent_update without project placement", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "agent_update",
payload: {
kind: "upsert",
agent: {
id: "agent-1",
provider: "codex",
cwd: "/tmp/repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-04T00:00:00.000Z",
updatedAt: "2026-04-04T00:00:00.000Z",
lastUserMessageAt: null,
status: "running",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
},
},
});
expect(result.success).toBe(true);
});
test("parses paginated fetch_agent_history_request and response", () => {
const request = SessionInboundMessageSchema.parse({
type: "fetch_agent_history_request",
requestId: "req-history",
page: { limit: 25, cursor: "cursor-1" },
sort: [{ key: "updated_at", direction: "desc" }],
});
const response = SessionOutboundMessageSchema.parse({
type: "fetch_agent_history_response",
payload: {
requestId: "req-history",
entries: [],
pageInfo: {
nextCursor: "cursor-2",
prevCursor: "cursor-1",
hasMore: true,
},
},
});
expect(request.type).toBe("fetch_agent_history_request");
expect(response.type).toBe("fetch_agent_history_response");
});
test("parses recent provider session descriptors without legacy handle fields", () => {
const parsed = RecentProviderSessionDescriptorPayloadSchema.parse({
providerId: "custom-codex",
providerLabel: "Custom Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
expect(parsed).toEqual({
providerId: "custom-codex",
providerLabel: "Custom Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
});
test("parses fetch_recent_provider_sessions request and response", () => {
const request = SessionInboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_request",
requestId: "req-recent-provider-sessions",
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
});
const response = SessionOutboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "my-claude",
providerLabel: "My Claude",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
},
});
expect(request.type).toBe("fetch_recent_provider_sessions_request");
expect(request.providers).toEqual(["my-claude"]);
expect(response.payload).toEqual({
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "my-claude",
providerLabel: "My Claude",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
});
});
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
const response = SessionOutboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [],
filteredAlreadyImportedCount: 3,
},
});
if (response.type !== "fetch_recent_provider_sessions_response") {
throw new Error("expected fetch_recent_provider_sessions_response");
}
expect(response.payload.filteredAlreadyImportedCount).toBe(3);
});
test("parses new and legacy import agent requests", () => {
const newRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId: "req-import-new",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
const legacyRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId: "req-import-legacy",
provider: "custom-codex",
sessionId: "thread-1",
cwd: "/tmp/repo",
});
expect(newRequest).toEqual({
type: "import_agent_request",
requestId: "req-import-new",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(legacyRequest).toEqual({
type: "import_agent_request",
requestId: "req-import-legacy",
provider: "custom-codex",
sessionId: "thread-1",
cwd: "/tmp/repo",
});
});
test("parses open_project_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "open_project_request",
cwd: "/tmp/repo",
requestId: "req-open",
});
expect(parsed.type).toBe("open_project_request");
});
test("parses list_available_editors_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",
requestId: "req-editors",
});
expect(parsed.type).toBe("list_available_editors_request");
});
test("parses open_in_editor_request with flexible editor ids", () => {
const knownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-webstorm",
editorId: "webstorm",
path: "/tmp/repo",
});
const unknownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-custom",
editorId: "unknown-editor",
path: "/tmp/repo",
});
expect(knownEditor.type).toBe("open_in_editor_request");
expect(unknownEditor.type).toBe("open_in_editor_request");
});
test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: null,
},
});
expect(parsed.type).toBe("open_in_editor_response");
});
test("parses list_available_editors_response with unknown editor ids", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "list_available_editors_response",
payload: {
requestId: "req-editors",
editors: [
{ id: "cursor", label: "Cursor" },
{ id: "unknown-editor", label: "Unknown Editor" },
],
error: null,
},
});
expect(parsed.type).toBe("list_available_editors_response");
});
test("rejects empty editor ids", () => {
const result = SessionInboundMessageSchema.safeParse({
type: "open_in_editor_request",
requestId: "req-open-empty",
editorId: "",
path: "/tmp/repo",
});
expect(result.success).toBe(false);
});
test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "ws-invalid",
projectId: "proj-invalid",
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "directory",
workspaceKind: "checkout",
name: "",
status: "not-a-bucket",
activityAt: null,
scripts: [],
},
},
});
expect(result.success).toBe(false);
});
test("parses workspace descriptors with scripts", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "ws-1",
projectId: "proj-1",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "directory",
workspaceKind: "checkout",
name: "repo",
status: "done",
activityAt: null,
scripts: [
{
scriptName: "web",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
},
],
},
},
});
expect(parsed.type).toBe("workspace_update");
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload");
}
expect(parsed.payload.workspace.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
]);
});
test("parses legacy workspace descriptors without workspaceDirectory", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "legacy-workspace",
projectId: "legacy-project",
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
scripts: [],
},
},
});
expect(parsed.type).toBe("workspace_update");
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload");
}
expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo");
});
test("defaults omitted workspace archiving state and preserves present timestamps", () => {
const baseWorkspace = {
id: "ws-archiving",
projectId: "proj-archiving",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
status: "done",
activityAt: null,
scripts: [],
} as const;
const archivingAt = "2026-04-30T20:45:00.000Z";
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).archivingAt).toBeNull();
expect(
WorkspaceDescriptorPayloadSchema.parse({
...baseWorkspace,
archivingAt,
}).archivingAt,
).toBe(archivingAt);
});
test("parses legacy workspace descriptor enum values", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "legacy-workspace",
projectId: "legacy-project",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "non_git",
workspaceKind: "directory",
name: "repo",
status: "done",
activityAt: null,
scripts: [],
},
},
});
expect(parsed.type).toBe("workspace_update");
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload");
}
expect(parsed.payload.workspace.projectKind).toBe("non_git");
expect(parsed.payload.workspace.workspaceKind).toBe("directory");
});
test("parses script_status_update payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "script_status_update",
payload: {
workspaceId: "ws-repo",
scripts: [
{
scriptName: "web",
hostname: "web.paseo.localhost",
port: null,
proxyUrl: null,
lifecycle: "stopped",
health: null,
},
],
},
});
expect(parsed.type).toBe("script_status_update");
expect(parsed.payload.workspaceId).toBe("ws-repo");
expect(parsed.payload.scripts[0]).toMatchObject({
type: "service",
exitCode: null,
});
});
test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress",
payload: {
workspaceId: "ws-feature-a",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [
{
index: 1,
command: "npm install",
cwd: "/repo/.paseo/worktrees/feature-a",
log: "done",
status: "completed",
exitCode: 0,
durationMs: 100,
},
],
},
error: null,
},
});
expect(parsed.type).toBe("workspace_setup_progress");
});
test("parses workspace_setup_status_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "workspace_setup_status_request",
workspaceId: "ws-feature-a",
requestId: "req-status",
});
expect(parsed.type).toBe("workspace_setup_status_request");
});
test("parses workspace_setup_status_response payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_status_response",
payload: {
requestId: "req-status",
workspaceId: "ws-feature-a",
snapshot: {
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [],
},
error: null,
},
},
});
expect(parsed.type).toBe("workspace_setup_status_response");
});
test("parses fetch_workspaces_response with optional runtime fields", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "fetch_workspaces_response",
payload: {
requestId: "req-workspaces",
entries: [
{
id: "ws-main",
projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: {
additions: 3,
deletions: 1,
},
gitRuntime: {
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: true,
aheadBehind: {
ahead: 2,
behind: 1,
},
aheadOfOrigin: 2,
behindOfOrigin: 1,
},
githubRuntime: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
title: "Runtime payloads",
state: "open",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: false,
},
error: null,
refreshedAt: "2026-04-12T00:00:00.000Z",
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
});
expect(parsed.type).toBe("fetch_workspaces_response");
expect(parsed.payload.entries[0]?.gitRuntime).toMatchObject({
currentBranch: "main",
isDirty: true,
aheadOfOrigin: 2,
});
expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads");
});
test("older workspace parsers ignore additive runtime fields", () => {
const message = {
type: "fetch_workspaces_response",
payload: {
requestId: "req-workspaces",
entries: [
{
id: "ws-main",
projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
gitRuntime: {
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: {
ahead: 0,
behind: 0,
},
aheadOfOrigin: 0,
behindOfOrigin: 0,
},
githubRuntime: {
featuresEnabled: true,
pullRequest: null,
error: null,
refreshedAt: "2026-04-12T00:00:00.000Z",
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
};
const legacyWorkspaceSchema = z.object({
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git"]),
workspaceKind: z.enum(["local_checkout", "worktree", "directory"]),
name: z.string(),
status: z.enum(["needs_input", "failed", "running", "attention", "done"]),
activityAt: z.string().nullable(),
diffStat: z
.object({
additions: z.number(),
deletions: z.number(),
})
.nullable()
.optional(),
});
const legacyMessageSchema = z.object({
type: z.literal("fetch_workspaces_response"),
payload: z.object({
requestId: z.string(),
entries: z.array(legacyWorkspaceSchema),
pageInfo: z.object({
nextCursor: z.string().nullable(),
prevCursor: z.string().nullable(),
hasMore: z.boolean(),
}),
}),
});
const parsed = legacyMessageSchema.parse(message);
expect(parsed.payload.entries[0]).toEqual({
id: "ws-main",
projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
});
});
test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "fetch_agents_response",
payload: {
requestId: "req-1",
entries: [
{
agent: {
id: "agent-1",
provider: "codex",
cwd: "C:\\repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-04T00:00:00.000Z",
updatedAt: "2026-04-04T00:00:00.000Z",
lastUserMessageAt: null,
status: "running",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent 1",
labels: {},
requiresAttention: false,
attentionReason: null,
},
project: {
projectKey: "remote:github.com/acme/repo",
projectName: "acme/repo",
checkout: {
cwd: "C:\\repo",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
const checkout = result.data.payload.entries[0]?.project.checkout;
expect(checkout?.worktreeRoot).toBe("C:\\repo");
});
});
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { PaseoConfigRawSchema, PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
describe("paseo config schema", () => {
it("parses an empty config without metadata generation", () => {
const parsed = PaseoConfigSchema.parse({});
expect(parsed).toEqual({});
expect(parsed.metadataGeneration).toBeUndefined();
});
it("parses old-style worktree and scripts config unchanged", () => {
const config = {
worktree: {
setup: "npm install",
teardown: ["npm run clean"],
},
scripts: {
dev: {
type: "service",
command: "npm run dev",
port: 5173,
},
},
};
expect(PaseoConfigSchema.parse(config)).toEqual({
worktree: {
setup: ["npm install"],
teardown: ["npm run clean"],
},
scripts: config.scripts,
});
});
it("parses all metadata generation instruction entries", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
},
});
});
it("parses partial metadata generation instructions with missing entries undefined", () => {
const parsed = PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Keep it short." },
},
});
expect(parsed.metadataGeneration).toEqual({
agentTitle: { instructions: "Keep it short." },
});
expect(parsed.metadataGeneration?.branchName).toBeUndefined();
expect(parsed.metadataGeneration?.commitMessage).toBeUndefined();
expect(parsed.metadataGeneration?.pullRequest).toBeUndefined();
});
it("passes through unknown metadata generation fields", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
futureField: 42,
},
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
futureField: 42,
},
});
});
it("passes through unknown metadata generator entry fields", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: {
instructions: "Use concise titles.",
model: "haiku",
},
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {
instructions: "Use concise titles.",
model: "haiku",
},
},
});
});
it("falls back to an empty metadata generator entry when instructions has an invalid type", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: 42 },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {},
},
});
});
it("raw schema preserves old-style config while accepting metadata generation", () => {
const config = {
worktree: {
setup: "npm install",
teardown: ["npm run clean"],
},
scripts: {
dev: {
type: "service",
command: "npm run dev",
},
},
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
},
};
expect(PaseoConfigRawSchema.parse(config)).toEqual(config);
});
it("raw schema falls back to an empty metadata generator entry when instructions has an invalid type", () => {
expect(
PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: 42 },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {},
},
});
});
});
@@ -0,0 +1,96 @@
import { z } from "zod";
export function normalizeLifecycleCommands(commands: unknown): string[] {
if (typeof commands === "string") {
return commands.trim().length > 0 ? [commands] : [];
}
if (!Array.isArray(commands)) {
return [];
}
return commands.filter((command): command is string => {
return typeof command === "string" && command.trim().length > 0;
});
}
export const PaseoLifecycleCommandRawSchema = z.union([z.string(), z.array(z.string())]);
export const PaseoScriptEntryRawSchema = z
.object({
type: z.unknown().optional(),
command: z.unknown().optional(),
port: z.unknown().optional(),
})
.passthrough();
export const PaseoWorktreeConfigRawSchema = z
.object({
setup: PaseoLifecycleCommandRawSchema.optional(),
teardown: PaseoLifecycleCommandRawSchema.optional(),
terminals: z.unknown().optional(),
})
.passthrough();
export const PaseoMetadataGenerationEntrySchema = z
.object({
instructions: z.string().optional(),
})
.passthrough()
.catch({});
export const PaseoMetadataGenerationSchema = z
.object({
agentTitle: PaseoMetadataGenerationEntrySchema.optional(),
branchName: PaseoMetadataGenerationEntrySchema.optional(),
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
})
.passthrough()
.catch({});
export const PaseoConfigRawSchema = z
.object({
worktree: PaseoWorktreeConfigRawSchema.optional(),
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
})
.passthrough();
export const WorktreeConfigSchema = PaseoWorktreeConfigRawSchema.extend({
setup: z.unknown().transform(normalizeLifecycleCommands),
teardown: z.unknown().transform(normalizeLifecycleCommands),
})
.passthrough()
.catch({ setup: [], teardown: [] });
export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
})
.passthrough()
.catch({});
export const PaseoConfigRevisionSchema = z.object({
mtimeMs: z.number(),
size: z.number(),
});
export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
z.object({ code: z.literal("project_not_found") }),
z.object({ code: z.literal("invalid_project_config") }),
z.object({
code: z.literal("stale_project_config"),
currentRevision: PaseoConfigRevisionSchema.nullable(),
}),
z.object({ code: z.literal("write_failed") }),
]);
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;
+17
View File
@@ -0,0 +1,17 @@
export function stripCwdPrefix(filePath: string, cwd?: string): string {
if (!cwd || !filePath) {
return filePath;
}
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = filePath.replace(/\\/g, "/");
const prefix = `${normalizedCwd}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
if (normalizedPath === normalizedCwd) {
return ".";
}
return filePath;
}
+148
View File
@@ -0,0 +1,148 @@
import { z } from "zod";
import type { AgentProvider } from "./agent-types.js";
import { AgentProviderSchema } from "./provider-manifest.js";
const ProviderCommandDefaultSchema = z
.object({
mode: z.literal("default"),
})
.strict();
const ProviderCommandAppendSchema = z
.object({
mode: z.literal("append"),
args: z.array(z.string()).optional(),
})
.strict();
const ProviderCommandReplaceSchema = z
.object({
mode: z.literal("replace"),
argv: z.array(z.string().min(1)).min(1),
})
.strict();
export const ProviderCommandSchema = z.discriminatedUnion("mode", [
ProviderCommandDefaultSchema,
ProviderCommandAppendSchema,
ProviderCommandReplaceSchema,
]);
export const ProviderRuntimeSettingsSchema = z
.object({
command: ProviderCommandSchema.optional(),
env: z.record(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
})
.strict();
const ProviderProfileThinkingOptionSchema = z
.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
})
.strict();
export const ProviderProfileModelSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
isDefault: z.boolean().optional(),
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
})
.strict();
export const ProviderOverrideSchema = z
.object({
extends: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
})
.strict();
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
export const ProviderOverridesSchema = z
.record(ProviderOverrideSchema)
.superRefine((providers, ctx) => {
const builtinProviderIdSet = new Set<string>(BUILTIN_PROVIDER_IDS);
const validExtendsValues = new Set<string>([...BUILTIN_PROVIDER_IDS, "acp"]);
for (const [providerId, provider] of Object.entries(providers)) {
if (!PROVIDER_ID_PATTERN.test(providerId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId],
message: `Provider ID "${providerId}" must match ${PROVIDER_ID_PATTERN}.`,
});
}
const isBuiltinProvider = builtinProviderIdSet.has(providerId);
if (!isBuiltinProvider && !provider.extends) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "extends"],
message: `Custom provider "${providerId}" must declare extends.`,
});
}
if (!isBuiltinProvider && !provider.label) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "label"],
message: `Custom provider "${providerId}" must declare label.`,
});
}
if (provider.extends && !validExtendsValues.has(provider.extends)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "extends"],
message: `Provider "${providerId}" extends unknown provider "${provider.extends}".`,
});
}
if (provider.extends === "acp" && !provider.command) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "command"],
message: `Provider "${providerId}" extending "acp" must declare command.`,
});
}
}
});
export const AgentProviderRuntimeSettingsMapSchema = z
.record(ProviderRuntimeSettingsSchema)
.superRefine((providers, ctx) => {
for (const providerId of Object.keys(providers)) {
const parsedProviderId = AgentProviderSchema.safeParse(providerId);
if (!parsedProviderId.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId],
message: `Invalid agent provider "${providerId}".`,
});
}
}
});
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
export type ProviderRuntimeSettings = z.infer<typeof ProviderRuntimeSettingsSchema>;
export type ProviderProfileModel = z.infer<typeof ProviderProfileModelSchema>;
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
export type ProviderOverrides = z.infer<typeof ProviderOverridesSchema>;
export type AgentProviderRuntimeSettingsMap = Partial<
Record<AgentProvider, ProviderRuntimeSettings>
>;
+280
View File
@@ -0,0 +1,280 @@
import { z } from "zod";
import type { AgentMode } from "./agent-types.js";
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`;
// Open string by design: the client looks icons up in a registry and falls back
// to a default for unknown values. Daemon downgrades unknown icons for clients
// that pre-date the open-string contract (see CLIENT_CAPS.customModeIcons).
export type AgentModeIcon = string;
export interface AgentModeVisuals {
icon: AgentModeIcon;
colorTier: AgentModeColorTier;
}
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & {
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
isUnattended?: boolean;
};
// TODO: `modes` should not be static. Providers (especially ACP) report their
// own modes at runtime via session/new. We should fetch modes from the provider
// as source of truth and enrich with UI metadata (icons, colorTier) on top.
export interface AgentProviderDefinition {
id: string;
label: string;
description: string;
defaultModeId: string | null;
modes: AgentProviderModeDefinition[];
voice?: {
enabled: boolean;
defaultModeId: string;
defaultModel?: string;
};
}
const CLAUDE_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "auto",
label: "Auto mode",
description: "Uses a model classifier to review permission prompts automatically",
icon: "ShieldQuestionMark",
colorTier: "moderate",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
icon: "ShieldCheck",
colorTier: "planning",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
icon: "ShieldAlert",
colorTier: "dangerous",
isUnattended: true,
},
];
const CODEX_MODES: AgentProviderModeDefinition[] = [
{
id: "auto",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "auto-review",
label: "Auto-review",
description:
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent.",
icon: "ShieldQuestionMark",
colorTier: "moderate",
},
{
id: "full-access",
label: "Full Access",
description: "Edit files, run commands, and access the network without additional prompts.",
icon: "ShieldAlert",
colorTier: "dangerous",
isUnattended: true,
},
];
const COPILOT_MODES: AgentProviderModeDefinition[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent",
description: "Default agent mode for conversational interactions",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
icon: "ShieldCheck",
colorTier: "planning",
},
{
id: "allow-all",
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
},
];
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
icon: "Bot",
colorTier: "moderate",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
icon: "Bot",
colorTier: "planning",
},
];
const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
{
id: "load-test",
label: "Load Test",
description: "Streams repeated markdown, reasoning, and tool calls for app stress testing",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Dev-only mode for the mock slow provider",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "claude",
label: "Claude",
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "haiku",
},
},
{
id: "codex",
label: "Codex",
description: "OpenAI's Codex workspace agent with sandbox controls and optional network access",
defaultModeId: "auto",
modes: CODEX_MODES,
voice: {
enabled: true,
defaultModeId: "auto",
defaultModel: "gpt-5.4-mini",
},
},
{
id: "copilot",
label: "Copilot",
description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support",
defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
modes: COPILOT_MODES,
},
{
id: "opencode",
label: "OpenCode",
description: "Open-source coding assistant with multi-provider model support",
defaultModeId: "build",
modes: OPENCODE_MODES,
voice: {
enabled: true,
defaultModeId: "build",
},
},
{
id: "pi",
label: "Pi",
description: "Minimal terminal-based coding agent with multi-provider LLM support",
defaultModeId: null,
modes: [],
},
];
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "mock",
label: "Mock Load Test",
description:
"Development-only provider that emits synthetic agent traffic for performance tests",
defaultModeId: "load-test",
modes: MOCK_LOAD_TEST_MODES,
},
{
id: "mock-slow",
label: "Mock Slow Provider",
description: "Dev-only: hangs during model discovery to test loading and timeout UI",
defaultModeId: "default",
modes: MOCK_SLOW_MODES,
},
];
export function getAgentProviderDefinition(
provider: string,
definitions: AgentProviderDefinition[] = [
...AGENT_PROVIDER_DEFINITIONS,
...DEV_AGENT_PROVIDER_DEFINITIONS,
],
): AgentProviderDefinition {
const definition = definitions.find((entry) => entry.id === provider);
if (!definition) {
throw new Error(`Unknown agent provider: ${provider}`);
}
return definition;
}
export const BUILTIN_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const AGENT_PROVIDER_IDS = BUILTIN_PROVIDER_IDS;
export const AgentProviderSchema = z.string();
export function isValidAgentProvider(
value: string,
validIds: Iterable<string> = BUILTIN_PROVIDER_IDS,
): boolean {
return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value);
}
export function getUnattendedModeId(
provider: string,
definitions: AgentProviderDefinition[] = [
...AGENT_PROVIDER_DEFINITIONS,
...DEV_AGENT_PROVIDER_DEFINITIONS,
],
): string | undefined {
const definition = definitions.find((entry) => entry.id === provider);
return definition?.modes.find((mode) => mode.isUnattended)?.id;
}
export function getModeVisuals(
provider: string,
modeId: string,
definitions: AgentProviderDefinition[],
): AgentModeVisuals | undefined {
const definition = definitions.find((entry) => entry.id === provider);
const mode = definition?.modes.find((m) => m.id === modeId);
if (!mode) return undefined;
return { icon: mode.icon, colorTier: mode.colorTier };
}
@@ -0,0 +1,176 @@
import { z } from "zod";
import {
ScheduleCadenceSchema,
ScheduleRunSchema,
ScheduleSummarySchema,
StoredScheduleSchema,
ScheduleTargetSchema,
} from "./types.js";
const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("self"),
agentId: z.string().uuid(),
}),
z.object({
type: z.literal("agent"),
agentId: z.string().uuid(),
}),
z.object({
type: z.literal("new-agent"),
config: ScheduleTargetSchema.options[1].shape.config,
}),
]);
export const ScheduleCreateRequestSchema = z.object({
type: z.literal("schedule/create"),
requestId: z.string(),
prompt: z.string().min(1),
name: z.string().optional(),
cadence: ScheduleCadenceSchema,
target: ScheduleCreateTargetSchema,
maxRuns: z.number().int().positive().optional(),
expiresAt: z.string().optional(),
runOnCreate: z.boolean().optional(),
});
export const ScheduleListRequestSchema = z.object({
type: z.literal("schedule/list"),
requestId: z.string(),
});
export const ScheduleInspectRequestSchema = z.object({
type: z.literal("schedule/inspect"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleLogsRequestSchema = z.object({
type: z.literal("schedule/logs"),
requestId: z.string(),
scheduleId: z.string(),
});
export const SchedulePauseRequestSchema = z.object({
type: z.literal("schedule/pause"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleResumeRequestSchema = z.object({
type: z.literal("schedule/resume"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleDeleteRequestSchema = z.object({
type: z.literal("schedule/delete"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleRunOnceRequestSchema = z.object({
type: z.literal("schedule/run-once"),
requestId: z.string(),
scheduleId: z.string(),
});
const ScheduleUpdateNewAgentConfigSchema = z.object({
provider: z.string().trim().min(1).optional(),
model: z.string().trim().min(1).nullable().optional(),
modeId: z.string().trim().min(1).nullable().optional(),
cwd: z.string().trim().min(1).optional(),
});
export const ScheduleUpdateRequestSchema = z.object({
type: z.literal("schedule/update"),
requestId: z.string(),
scheduleId: z.string(),
name: z.string().nullable().optional(),
prompt: z.string().min(1).optional(),
cadence: ScheduleCadenceSchema.optional(),
newAgentConfig: ScheduleUpdateNewAgentConfigSchema.optional(),
maxRuns: z.number().int().positive().nullable().optional(),
expiresAt: z.string().nullable().optional(),
});
export const ScheduleCreateResponseSchema = z.object({
type: z.literal("schedule/create/response"),
payload: z.object({
requestId: z.string(),
schedule: ScheduleSummarySchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleListResponseSchema = z.object({
type: z.literal("schedule/list/response"),
payload: z.object({
requestId: z.string(),
schedules: z.array(ScheduleSummarySchema),
error: z.string().nullable(),
}),
});
export const ScheduleInspectResponseSchema = z.object({
type: z.literal("schedule/inspect/response"),
payload: z.object({
requestId: z.string(),
schedule: StoredScheduleSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleLogsResponseSchema = z.object({
type: z.literal("schedule/logs/response"),
payload: z.object({
requestId: z.string(),
runs: z.array(ScheduleRunSchema),
error: z.string().nullable(),
}),
});
export const SchedulePauseResponseSchema = z.object({
type: z.literal("schedule/pause/response"),
payload: z.object({
requestId: z.string(),
schedule: ScheduleSummarySchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleResumeResponseSchema = z.object({
type: z.literal("schedule/resume/response"),
payload: z.object({
requestId: z.string(),
schedule: ScheduleSummarySchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleDeleteResponseSchema = z.object({
type: z.literal("schedule/delete/response"),
payload: z.object({
requestId: z.string(),
scheduleId: z.string(),
error: z.string().nullable(),
}),
});
export const ScheduleRunOnceResponseSchema = z.object({
type: z.literal("schedule/run-once/response"),
payload: z.object({
requestId: z.string(),
schedule: StoredScheduleSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ScheduleUpdateResponseSchema = z.object({
type: z.literal("schedule/update/response"),
payload: z.object({
requestId: z.string(),
schedule: StoredScheduleSchema.nullable(),
error: z.string().nullable(),
}),
});
+117
View File
@@ -0,0 +1,117 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;
export const ScheduleCadenceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("every"),
everyMs: z.number().int().positive(),
}),
z.object({
type: z.literal("cron"),
expression: z.string().trim().min(1),
}),
]);
export type ScheduleCadence = z.infer<typeof ScheduleCadenceSchema>;
export const ScheduleTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("agent"),
agentId: z.string().uuid(),
}),
z.object({
type: z.literal("new-agent"),
config: z.object({
provider: AgentProviderSchema,
cwd: z.string().trim().min(1),
modeId: z.string().trim().min(1).optional(),
model: z.string().trim().min(1).optional(),
thinkingOptionId: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).nullable().optional(),
approvalPolicy: z.string().trim().min(1).optional(),
sandboxMode: z.string().trim().min(1).optional(),
networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(),
featureValues: z.record(z.unknown()).optional(),
extra: z
.object({
codex: z.record(z.unknown()).optional(),
claude: z.record(z.unknown()).optional(),
})
.partial()
.optional(),
systemPrompt: z.string().optional(),
mcpServers: z.record(z.unknown()).optional(),
}),
}),
]);
export type ScheduleTarget = z.infer<typeof ScheduleTargetSchema>;
export const ScheduleRunSchema = z.object({
id: z.string(),
scheduledFor: z.string(),
startedAt: z.string(),
endedAt: z.string().nullable(),
status: z.enum(["running", "succeeded", "failed"]),
agentId: z.string().uuid().nullable(),
output: z.string().nullable(),
error: z.string().nullable(),
});
export type ScheduleRun = z.infer<typeof ScheduleRunSchema>;
export const StoredScheduleSchema = z.object({
id: z.string(),
name: z.string().nullable(),
prompt: z.string().min(1),
cadence: ScheduleCadenceSchema,
target: ScheduleTargetSchema,
status: ScheduleStatusSchema,
createdAt: z.string(),
updatedAt: z.string(),
nextRunAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
pausedAt: z.string().nullable(),
expiresAt: z.string().nullable(),
maxRuns: z.number().int().positive().nullable(),
runs: z.array(ScheduleRunSchema),
});
export type StoredSchedule = z.infer<typeof StoredScheduleSchema>;
export const ScheduleSummarySchema = StoredScheduleSchema.omit({
runs: true,
});
export type ScheduleSummary = z.infer<typeof ScheduleSummarySchema>;
export interface CreateScheduleInput {
name?: string | null;
prompt: string;
cadence: ScheduleCadence;
target: ScheduleTarget;
maxRuns?: number | null;
expiresAt?: string | null;
runOnCreate?: boolean | null;
}
export interface UpdateScheduleNewAgentConfig {
provider?: string;
model?: string | null;
modeId?: string | null;
cwd?: string;
}
export interface UpdateScheduleInput {
id: string;
name?: string | null;
prompt?: string;
cadence?: ScheduleCadence;
newAgentConfig?: UpdateScheduleNewAgentConfig;
maxRuns?: number | null;
expiresAt?: string | null;
}
export interface ScheduleExecutionResult {
agentId: string | null;
output: string | null;
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { TerminalInputModeTracker } from "./terminal-input-mode.js";
describe("TerminalInputModeTracker", () => {
it("activates from a pushed Kitty keyboard mode and builds a replay preamble", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.feed("\x1b[>7u").changed).toBe(true);
expect(tracker.supportsModifiedEnter()).toBe(true);
expect(tracker.getKittyKeyboardFlags()).toBe(7);
expect(tracker.getPreamble()).toBe("\x1b[=7;1u");
});
it("tracks split terminal output chunks", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[>");
const result = tracker.feed("1u");
expect(result.changed).toBe(true);
expect(tracker.getKittyKeyboardFlags()).toBe(1);
});
it("restores pushed Kitty keyboard modes when the foreground program pops them", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[>1u");
tracker.feed("\x1b[>7u");
expect(tracker.getKittyKeyboardFlags()).toBe(7);
expect(tracker.feed("\x1b[<u").changed).toBe(true);
expect(tracker.getKittyKeyboardFlags()).toBe(1);
expect(tracker.feed("\x1b[<u").changed).toBe(true);
expect(tracker.supportsModifiedEnter()).toBe(false);
});
it("answers Kitty keyboard mode queries with the current flags", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[=3;1u");
expect(tracker.feed("\x1b[?u").responses).toEqual(["\x1b[?3u"]);
});
it("tracks ConPTY Win32 input mode and replays it after snapshots", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.feed("\x1b[?9001h").changed).toBe(true);
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 0,
win32InputMode: true,
});
expect(tracker.supportsModifiedEnter()).toBe(true);
expect(tracker.getPreamble()).toBe("\x1b[?9001h");
expect(tracker.feed("\x1b[?9001l").changed).toBe(true);
expect(tracker.supportsModifiedEnter()).toBe(false);
});
it("keeps Kitty and Win32 input modes independent", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[>7u\x1b[?9001h");
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 7,
win32InputMode: true,
});
expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h");
});
it("ignores encoded key input sequences", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[13;2u");
expect(tracker.supportsModifiedEnter()).toBe(false);
});
});
@@ -0,0 +1,194 @@
export interface TerminalInputModeFeedResult {
changed: boolean;
responses: string[];
}
export interface TerminalInputModeState {
kittyKeyboardFlags: number;
win32InputMode: boolean;
}
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
const ESC = String.fromCharCode(0x1b);
const WIN32_INPUT_MODE = 9001;
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
"g",
);
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
function parseFirstParam(params: string): number | null {
const first = params.split(";")[0];
if (!first || !/^\d+$/.test(first)) {
return null;
}
return Number(first);
}
function parseSecondParam(params: string): number | null {
const second = params.split(";")[1];
if (!second || !/^\d+$/.test(second)) {
return null;
}
return Number(second);
}
function parsePrivateModeParams(params: string): Set<number> {
const modes = new Set<number>();
for (const param of params.split(";")) {
if (/^\d+$/.test(param)) {
modes.add(Number(param));
}
}
return modes;
}
export function terminalInputModeSupportsModifiedEnter(state: TerminalInputModeState): boolean {
return state.kittyKeyboardFlags > 0 || state.win32InputMode;
}
export function terminalInputModeStatesEqual(
left: TerminalInputModeState,
right: TerminalInputModeState,
): boolean {
return (
left.kittyKeyboardFlags === right.kittyKeyboardFlags &&
left.win32InputMode === right.win32InputMode
);
}
export class TerminalInputModeTracker {
private kittyKeyboardFlags = 0;
private win32InputMode = false;
private readonly kittyKeyboardStack: number[] = [];
private pending = "";
feed(data: string): TerminalInputModeFeedResult {
if (data.length === 0) {
return { changed: false, responses: [] };
}
const text = `${this.pending}${data}`;
this.pending = "";
let changed = false;
const responses: string[] = [];
let consumedUntil = 0;
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
for (;;) {
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
if (!match) {
break;
}
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
if (match[4]) {
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
continue;
}
const result = this.applyKittyKeyboardSequence(match[1] ?? "", match[2] ?? "");
changed = changed || result.changed;
responses.push(...result.responses);
}
const tail = text.slice(consumedUntil);
const pendingStart = tail.lastIndexOf(`${ESC}[`);
if (pendingStart >= 0) {
const pending = tail.slice(pendingStart);
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
this.pending = pending;
}
}
return { changed, responses };
}
reset(): void {
this.kittyKeyboardFlags = 0;
this.win32InputMode = false;
this.kittyKeyboardStack.length = 0;
this.pending = "";
}
getState(): TerminalInputModeState {
return {
kittyKeyboardFlags: this.kittyKeyboardFlags,
win32InputMode: this.win32InputMode,
};
}
getKittyKeyboardFlags(): number {
return this.kittyKeyboardFlags;
}
supportsModifiedEnter(): boolean {
return terminalInputModeSupportsModifiedEnter(this.getState());
}
getPreamble(): string {
const parts: string[] = [];
if (this.kittyKeyboardFlags > 0) {
parts.push(`\x1b[=${this.kittyKeyboardFlags};1u`);
}
if (this.win32InputMode) {
parts.push("\x1b[?9001h");
}
return parts.join("");
}
private applyKittyKeyboardSequence(
prefix: string,
params: string,
): { changed: boolean; responses: string[] } {
const previousFlags = this.kittyKeyboardFlags;
switch (prefix) {
case ">": {
this.kittyKeyboardStack.push(this.kittyKeyboardFlags);
this.kittyKeyboardFlags = parseFirstParam(params) ?? 1;
break;
}
case "=": {
const mode = parseSecondParam(params) ?? 1;
this.kittyKeyboardFlags = mode === 0 ? 0 : (parseFirstParam(params) ?? 0);
break;
}
case "<": {
const count = Math.max(1, parseFirstParam(params) ?? 1);
for (let index = 0; index < count; index += 1) {
this.kittyKeyboardFlags = this.kittyKeyboardStack.pop() ?? 0;
}
break;
}
case "?":
return {
changed: false,
responses: [`\x1b[?${this.kittyKeyboardFlags}u`],
};
default:
return { changed: false, responses: [] };
}
return {
changed: this.kittyKeyboardFlags !== previousFlags,
responses: [],
};
}
private applyPrivateModeSequence(params: string, final: string): boolean {
const modes = parsePrivateModeParams(params);
if (!modes.has(WIN32_INPUT_MODE)) {
return false;
}
const previous = this.win32InputMode;
this.win32InputMode = final === "h";
return this.win32InputMode !== previous;
}
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { encodeTerminalKeyInput } from "./terminal-key-input.js";
describe("encodeTerminalKeyInput", () => {
it("encodes ctrl+b for tmux prefix", () => {
expect(encodeTerminalKeyInput({ key: "b", ctrl: true })).toBe("\x02");
});
it("encodes shifted arrow key modifiers", () => {
expect(encodeTerminalKeyInput({ key: "ArrowLeft", shift: true })).toBe("\x1b[1;2D");
});
it("encodes alt-modified printable keys", () => {
expect(encodeTerminalKeyInput({ key: "x", alt: true })).toBe("\x1bx");
});
it("encodes enter and backspace", () => {
expect(encodeTerminalKeyInput({ key: "Enter" })).toBe("\r");
expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f");
});
it("keeps modified Enter as carriage return before enhanced input mode is active", () => {
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\r");
});
it("encodes Enter with modifiers using CSI u after Kitty keyboard mode is active", () => {
const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: false } };
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe("\x1b[13;2u");
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true }, options)).toBe("\x1b[13;5u");
expect(encodeTerminalKeyInput({ key: "Enter", alt: true }, options)).toBe("\x1b[13;3u");
expect(encodeTerminalKeyInput({ key: "Enter", meta: true }, options)).toBe("\x1b[13;9u");
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true }, options)).toBe(
"\x1b[13;6u",
);
});
it("encodes Shift+Enter using Win32 input mode when ConPTY requests it", () => {
const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: true } };
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe(
"\x1b[13;28;13;1;16;1_",
);
});
it("prefers Win32 input mode over CSI u when both modes are active", () => {
const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: true } };
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe(
"\x1b[13;28;13;1;16;1_",
);
});
it("returns empty string for unsupported keys", () => {
expect(encodeTerminalKeyInput({ key: "UnidentifiedKey" })).toBe("");
});
});
+233
View File
@@ -0,0 +1,233 @@
import type { TerminalInputModeState } from "./terminal-input-mode.js";
export interface TerminalKeyInput {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}
export interface TerminalKeyInputEncodingOptions {
inputMode?: TerminalInputModeState;
}
const WIN32_LEFT_ALT_PRESSED = 0x0002;
const WIN32_LEFT_CTRL_PRESSED = 0x0008;
const WIN32_SHIFT_PRESSED = 0x0010;
function modifierParam(input: TerminalKeyInput): number {
let value = 1;
if (input.shift) value += 1;
if (input.alt) value += 2;
if (input.ctrl) value += 4;
if (input.meta) value += 8;
return value;
}
function win32ControlKeyState(input: TerminalKeyInput): number {
let value = 0;
if (input.shift) value += WIN32_SHIFT_PRESSED;
if (input.ctrl) value += WIN32_LEFT_CTRL_PRESSED;
if (input.alt) value += WIN32_LEFT_ALT_PRESSED;
return value;
}
function shouldUseWin32InputMode(
input: TerminalKeyInput,
options: TerminalKeyInputEncodingOptions,
): boolean {
if (!options.inputMode?.win32InputMode) {
return false;
}
return Boolean(input.shift || input.ctrl || input.alt);
}
function shouldUseKittyKeyboardMode(
input: TerminalKeyInput,
options: TerminalKeyInputEncodingOptions,
): boolean {
if ((options.inputMode?.kittyKeyboardFlags ?? 0) <= 0) {
return false;
}
return Boolean(input.shift || input.ctrl || input.alt || input.meta);
}
function encodeWin32EnterKeyInput(input: TerminalKeyInput): string {
const controlKeyState = win32ControlKeyState(input);
// ConPTY Win32 input mode: CSI Vk;Sc;Uc;Kd;Cs;Rc _
return `\x1b[13;28;13;1;${controlKeyState};1_`;
}
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
return input.alt ? `\x1b${sequence}` : sequence;
}
function ctrlSymbolCode(char: string): string | null {
switch (char) {
case " ":
case "@":
case "2":
return "\x00";
case "[":
case "3":
return "\x1b";
case "\\":
case "4":
return "\x1c";
case "]":
case "5":
return "\x1d";
case "^":
case "6":
return "\x1e";
case "_":
case "/":
case "7":
return "\x1f";
case "8":
case "?":
return "\x7f";
default:
return null;
}
}
function encodeCtrlChar(char: string, input: TerminalKeyInput): string {
const upper = char.toUpperCase();
if (upper.length === 1 && upper >= "A" && upper <= "Z") {
return applyAltLikePrefix(String.fromCharCode(upper.charCodeAt(0) - 64), input);
}
const symbol = ctrlSymbolCode(char);
if (symbol !== null) {
return applyAltLikePrefix(symbol, input);
}
if (char.length === 1) {
const code = char.charCodeAt(0) & 0x1f;
return applyAltLikePrefix(String.fromCharCode(code), input);
}
return applyAltLikePrefix(char, input);
}
function encodePrintableKey(input: TerminalKeyInput): string {
const raw = input.key;
const char = input.shift ? raw.toUpperCase() : raw;
if (input.ctrl) {
return encodeCtrlChar(char, input);
}
return applyAltLikePrefix(char, input);
}
function csiWithModifier(finalByte: string, input: TerminalKeyInput): string {
const mod = modifierParam(input);
return mod === 1 ? `\x1b[${finalByte}` : `\x1b[1;${mod}${finalByte}`;
}
function csiTilde(base: number, input: TerminalKeyInput): string {
const mod = modifierParam(input);
return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`;
}
function encodeFunctionKey(key: string, input: TerminalKeyInput): string | null {
switch (key) {
case "F1":
return modifierParam(input) === 1 ? "\x1bOP" : csiWithModifier("P", input);
case "F2":
return modifierParam(input) === 1 ? "\x1bOQ" : csiWithModifier("Q", input);
case "F3":
return modifierParam(input) === 1 ? "\x1bOR" : csiWithModifier("R", input);
case "F4":
return modifierParam(input) === 1 ? "\x1bOS" : csiWithModifier("S", input);
case "F5":
return csiTilde(15, input);
case "F6":
return csiTilde(17, input);
case "F7":
return csiTilde(18, input);
case "F8":
return csiTilde(19, input);
case "F9":
return csiTilde(20, input);
case "F10":
return csiTilde(21, input);
case "F11":
return csiTilde(23, input);
case "F12":
return csiTilde(24, input);
default:
return null;
}
}
function encodeNavigationKey(key: string, input: TerminalKeyInput): string | null {
switch (key) {
case "ArrowUp":
return csiWithModifier("A", input);
case "ArrowDown":
return csiWithModifier("B", input);
case "ArrowRight":
return csiWithModifier("C", input);
case "ArrowLeft":
return csiWithModifier("D", input);
case "Home":
return csiWithModifier("H", input);
case "End":
return csiWithModifier("F", input);
case "Insert":
return csiTilde(2, input);
case "Delete":
return csiTilde(3, input);
case "PageUp":
return csiTilde(5, input);
case "PageDown":
return csiTilde(6, input);
default:
return null;
}
}
export function encodeTerminalKeyInput(
input: TerminalKeyInput,
options: TerminalKeyInputEncodingOptions = {},
): string {
const key = input.key;
if (!key) {
return "";
}
if (key.length === 1) {
return encodePrintableKey(input);
}
switch (key) {
case "Enter": {
const mod = modifierParam(input);
if (mod > 1 && shouldUseWin32InputMode(input, options)) {
return encodeWin32EnterKeyInput(input);
}
if (mod > 1 && shouldUseKittyKeyboardMode(input, options)) {
return `\x1b[13;${mod}u`;
}
return "\r";
}
case "Tab":
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
return "\x1b[Z";
}
return applyAltLikePrefix("\t", input);
case "Backspace":
return applyAltLikePrefix("\x7f", input);
case "Escape":
return "\x1b";
default:
break;
}
const nav = encodeNavigationKey(key, input);
if (nav !== null) return nav;
const fn = encodeFunctionKey(key, input);
if (fn !== null) return fn;
return "";
}
+205
View File
@@ -0,0 +1,205 @@
import type { TerminalCell, TerminalState } from "./messages.js";
interface TerminalStyle {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
}
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import * as terminalBinaryFrames from "./binary-frames/index.js";
import * as legacyTerminalStreamProtocol from "./terminal-stream-protocol.js";
describe("terminal stream protocol", () => {
it("keeps the old import path compatible with terminal binary frames", () => {
expect(legacyTerminalStreamProtocol.TerminalStreamOpcode).toBe(
terminalBinaryFrames.TerminalStreamOpcode,
);
expect(legacyTerminalStreamProtocol.encodeTerminalStreamFrame).toBe(
terminalBinaryFrames.encodeTerminalStreamFrame,
);
expect(legacyTerminalStreamProtocol.decodeTerminalStreamFrame).toBe(
terminalBinaryFrames.decodeTerminalStreamFrame,
);
const payload = new TextEncoder().encode("hello");
const encoded = legacyTerminalStreamProtocol.encodeTerminalStreamFrame({
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
slot: 7,
payload,
});
expect(encoded[0]).toBe(terminalBinaryFrames.TerminalStreamOpcode.Output);
expect(encoded[1]).toBe(7);
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
const decoded = terminalBinaryFrames.decodeTerminalStreamFrame(encoded);
expect(decoded).toEqual({
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
slot: 7,
payload,
});
});
});
@@ -0,0 +1 @@
export * from "./binary-frames/terminal.js";
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { buildToolCallDisplayModel } from "./tool-call-display.js";
describe("shared tool-call display mapping", () => {
it("builds summary from canonical detail", () => {
const display = buildToolCallDisplayModel({
name: "read_file",
status: "running",
error: null,
detail: {
type: "read",
filePath: "/tmp/repo/src/index.ts",
},
cwd: "/tmp/repo",
});
expect(display).toEqual({
displayName: "Read",
summary: "src/index.ts",
});
});
it("does not infer summaries from unknown raw detail", () => {
const display = buildToolCallDisplayModel({
name: "exec_command",
status: "running",
error: null,
detail: {
type: "unknown",
input: { command: "npm test" },
output: null,
},
});
expect(display).toEqual({
displayName: "Exec Command",
});
});
it("uses sub-agent detail for task label and description", () => {
const display = buildToolCallDisplayModel({
name: "task",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md",
},
});
expect(display).toEqual({
displayName: "Explore",
summary: "Inspect repository structure",
});
});
it("builds display model for worktree setup detail", () => {
const display = buildToolCallDisplayModel({
name: "paseo_worktree_setup",
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
branchName: "feature-branch",
log: "==> [1/1] Running: npm install\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
log: "==> [1/1] Running: npm install\n",
status: "running",
exitCode: null,
},
],
},
});
expect(display).toEqual({
displayName: "Worktree Setup",
summary: "feature-branch",
});
});
it("provides errorText for failed calls", () => {
const display = buildToolCallDisplayModel({
name: "shell",
status: "failed",
error: { message: "boom" },
detail: {
type: "unknown",
input: null,
output: null,
},
});
expect(display.errorText).toBe('{\n "message": "boom"\n}');
});
it("labels terminal interaction rows without a summary when no command is available", () => {
const display = buildToolCallDisplayModel({
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
icon: "square_terminal",
},
});
expect(display).toEqual({
displayName: "Terminal",
});
});
it("uses the command as terminal interaction summary when available", () => {
const display = buildToolCallDisplayModel({
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "npm run test",
icon: "square_terminal",
},
});
expect(display).toEqual({
displayName: "Terminal",
summary: "npm run test",
});
});
it("humanizes Paseo MCP tool names (Claude Code format)", () => {
const display = buildToolCallDisplayModel({
name: "mcp__paseo__create_agent",
status: "running",
error: null,
detail: { type: "unknown", input: null, output: null },
});
expect(display.displayName).toBe("Create Agent");
});
it("humanizes Paseo MCP tool names (Codex format)", () => {
const display = buildToolCallDisplayModel({
name: "paseo.create_agent",
status: "running",
error: null,
detail: { type: "unknown", input: null, output: null },
});
expect(display.displayName).toBe("Create Agent");
});
it("humanizes list_agents Paseo tool", () => {
const display = buildToolCallDisplayModel({
name: "mcp__paseo__list_agents",
status: "running",
error: null,
detail: { type: "unknown", input: null, output: null },
});
expect(display.displayName).toBe("List Agents");
});
it("does not override speak tool display name", () => {
const display = buildToolCallDisplayModel({
name: "speak",
status: "running",
error: null,
detail: { type: "unknown", input: null, output: null },
});
expect(display.displayName).toBe("Speak");
});
it("labels plan detail rows as Plan", () => {
const display = buildToolCallDisplayModel({
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout",
},
});
expect(display).toEqual({
displayName: "Plan",
});
});
});
+167
View File
@@ -0,0 +1,167 @@
import type { ToolCallTimelineItem } from "./agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<
ToolCallTimelineItem,
"name" | "status" | "error" | "metadata" | "detail"
> & {
cwd?: string;
};
export interface ToolCallDisplayModel {
displayName: string;
summary?: string;
errorText?: string;
}
interface DetailDisplay {
displayName?: string;
summary?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function humanizeToolName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
return name;
}
if (isPaseoToolName(trimmed)) {
const leaf = getPaseoToolLeafName(trimmed);
if (leaf) {
return humanizeToolName(leaf);
}
}
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
return trimmed;
}
return trimmed
.replace(/[._-]+/g, " ")
.split(" ")
.filter((segment) => segment.length > 0)
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
.join(" ");
}
function formatErrorText(error: unknown): string | undefined {
if (error === null || error === undefined) {
return undefined;
}
if (typeof error === "string") {
return error;
}
if (isRecord(error) && typeof error.content === "string") {
return error.content;
}
try {
return JSON.stringify(error, null, 2);
} catch {
return String(error);
}
}
function buildFilePathDisplay(
displayName: string,
filePath: string,
cwd: string | undefined,
): DetailDisplay {
return {
displayName,
summary: stripCwdPrefix(filePath, cwd),
};
}
function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay {
switch (input.detail.type) {
case "shell":
return {
displayName: "Shell",
summary: input.detail.command,
};
case "read":
return buildFilePathDisplay("Read", input.detail.filePath, input.cwd);
case "edit":
return buildFilePathDisplay("Edit", input.detail.filePath, input.cwd);
case "write":
return buildFilePathDisplay("Write", input.detail.filePath, input.cwd);
case "search":
return {
displayName: "Search",
summary: input.detail.query,
};
case "fetch":
return {
displayName: "Fetch",
summary: input.detail.url,
};
case "worktree_setup":
return {
displayName: "Worktree Setup",
summary: input.detail.branchName,
};
case "sub_agent":
return {
displayName: readString(input.detail.subAgentType) ?? "Task",
summary: readString(input.detail.description),
};
case "plain_text":
return {
summary: input.detail.label,
};
case "plan":
return {
displayName: "Plan",
};
case "unknown":
return {};
default:
throw new Error("unreachable");
}
}
function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay {
const lowerName = input.name.trim().toLowerCase();
if (input.detail.type === "unknown" && lowerName === "task") {
return {
displayName: "Task",
summary: isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined,
};
}
if (input.detail.type === "unknown" && lowerName === "thinking") {
return {
displayName: "Thinking",
};
}
if (lowerName === "terminal") {
return {
displayName: "Terminal",
summary: input.detail.type === "plain_text" ? readString(input.detail.label) : undefined,
};
}
return {};
}
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
const canonicalDisplay = buildCanonicalDetailDisplay(input);
const unknownDetailOverride = buildUnknownDetailOverride(input);
const displayName =
unknownDetailOverride.displayName ??
canonicalDisplay.displayName ??
humanizeToolName(input.name);
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
return {
displayName,
...(summary ? { summary } : {}),
...(errorText ? { errorText } : {}),
};
}
@@ -0,0 +1,94 @@
const TOOL_TOKEN_REGEX = /[a-z0-9]+/g;
const STANDARD_NAMESPACE_SEPARATOR_REGEX = /[.:/]/;
export function normalizeToolName(name: string): string {
return name.trim().toLowerCase();
}
export function tokenizeToolName(name: string): string[] {
const normalized = normalizeToolName(name);
return normalized.match(TOOL_TOKEN_REGEX) ?? [];
}
export function getToolLeafName(name: string): string | null {
const tokens = tokenizeToolName(name);
return tokens.length > 0 ? tokens[tokens.length - 1] : null;
}
export function isSpeakToolName(name: string): boolean {
return getToolLeafName(name) === "speak";
}
export function isLikelyNamespacedToolName(name: string): boolean {
const normalized = normalizeToolName(name);
if (STANDARD_NAMESPACE_SEPARATOR_REGEX.test(normalized)) {
return true;
}
if (!normalized.includes("__")) {
return false;
}
// Keep `__` handling strict to avoid false positives on arbitrary custom names.
const segments = normalized.split("__").filter((segment) => segment.length > 0);
if (segments.length >= 3) {
return true;
}
if (segments.length === 2 && segments[1].includes("_")) {
return true;
}
return false;
}
export function isPaseoToolName(name: string): boolean {
const normalized = normalizeToolName(name);
if (isSpeakToolName(normalized)) {
return false;
}
if (normalized.includes("__")) {
const segments = normalized.split("__").filter((s) => s.length > 0);
return (
segments.length >= 3 &&
segments[0] === "mcp" &&
(segments[1] === "paseo" || segments[1].startsWith("paseo_"))
);
}
if (normalized.includes(".")) {
const firstSegment = normalized.split(".")[0];
return firstSegment === "paseo" || firstSegment.startsWith("paseo_");
}
return false;
}
export function getPaseoToolLeafName(name: string): string | null {
const normalized = normalizeToolName(name);
if (normalized.includes("__")) {
const segments = normalized.split("__").filter((s) => s.length > 0);
if (
segments.length >= 3 &&
segments[0] === "mcp" &&
(segments[1] === "paseo" || segments[1].startsWith("paseo_"))
) {
return segments.slice(2).join("__");
}
return null;
}
if (normalized.includes(".")) {
const firstSegment = normalized.split(".")[0];
if (firstSegment === "paseo" || firstSegment.startsWith("paseo_")) {
return normalized.split(".").slice(1).join(".");
}
return null;
}
return null;
}
export function isLikelyExternalToolName(name: string): boolean {
const normalized = normalizeToolName(name);
if (!normalized) {
return false;
}
if (isSpeakToolName(normalized)) {
return true;
}
return isLikelyNamespacedToolName(normalized);
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023", "DOM"],
"types": ["node"],
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}