Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,13 @@ ade actions list
ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
ade cursor cloud agents list --text
ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr
ade open ade://lane/<lane-uuid>
ade open --linear-issue ADE-123 --branch arul/ade-123-fix
ade link lane <lane-uuid>
ade link branch owner/repo my-branch --pr 42
ade link pr owner/repo 42 --ade
ade link linear-issue ADE-123 --branch arul/ade-123-fix
ade linear install
```

Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade help <command> <subcommand>` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run <domain.action>` only when there is no typed command for the workflow yet.
Expand Down
102 changes: 102 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,10 @@ function createRuntime() {
void data;
return true;
}),
write: vi.fn(),
resize: vi.fn(),
readTranscriptTail: vi.fn(async () => ""),
list: vi.fn(() => []),
enrichSessions: vi.fn((sessions: unknown[]) => sessions),
},
testService: {
Expand Down Expand Up @@ -1209,6 +1212,79 @@ function createFakePathExecutable(dir: string, name: string): string {
}

describe("adeRpcServer", () => {
it("exposes direct PTY RPC methods with enriched create/list responses", async () => {
const { runtime } = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
const session = {
id: "session-1",
laneId: "lane-1",
status: "running",
ownerPid: 12_345,
};
runtime.sessionService.get.mockReturnValue(session);
runtime.ptyService.list.mockReturnValue([session]);
await initialize(handler, { role: "external" });

const created = await handler({
jsonrpc: "2.0",
id: 2,
method: "pty.create",
params: { args: { laneId: "lane-1", title: "Claude", cols: 120, rows: 40 } },
});
expect(created).toEqual({
ptyId: "pty-1",
sessionId: "session-1",
session,
});
expect(runtime.ptyService.create).toHaveBeenCalledWith({
laneId: "lane-1",
title: "Claude",
cols: 120,
rows: 40,
});

await expect(handler({
jsonrpc: "2.0",
id: 3,
method: "pty.sendToSession",
params: { args: { sessionId: "session-1", text: "continue" } },
})).resolves.toMatchObject({ sessionId: "session-1", reusedExistingRuntime: true });
expect(runtime.ptyService.sendToSession).toHaveBeenCalledWith({ sessionId: "session-1", text: "continue" });

await expect(handler({
jsonrpc: "2.0",
id: 4,
method: "pty.write",
params: { args: { ptyId: "pty-1", data: "x" } },
})).resolves.toBeNull();
expect(runtime.ptyService.write).toHaveBeenCalledWith({ ptyId: "pty-1", data: "x" });

await expect(handler({
jsonrpc: "2.0",
id: 5,
method: "pty.resize",
params: { args: { ptyId: "pty-1", cols: 100, rows: 30 } },
})).resolves.toBeNull();
expect(runtime.ptyService.resize).toHaveBeenCalledWith({ ptyId: "pty-1", cols: 100, rows: 30 });

await expect(handler({
jsonrpc: "2.0",
id: 6,
method: "pty.dispose",
params: { args: { ptyId: "pty-1", sessionId: "session-1" } },
})).resolves.toBeNull();
expect(runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" });

const listed = await handler({
jsonrpc: "2.0",
id: 7,
method: "pty.list",
params: { args: { laneId: "lane-1", limit: 20 } },
});
expect(listed).toEqual({ sessions: [session] });
expect(runtime.ptyService.list).toHaveBeenCalledWith({ laneId: "lane-1", limit: 20 });
});

it("routes app/navigate through the runtime navigation service", async () => {
const { runtime } = createRuntime();
const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 }));
Expand Down Expand Up @@ -5779,6 +5855,32 @@ describe("adeRpcServer", () => {
expect(response.structuredContent.events.every((e: any) => e.category === "orchestrator")).toBe(true);
});

it("stream_events supports the PTY category", async () => {
const fixture = createRuntime();
fixture.runtime.eventBuffer.drain = vi.fn((cursor: number) => ({
events: [
{ id: cursor + 1, timestamp: new Date().toISOString(), category: "runtime", payload: { type: "terminal_session_changed" } },
{ id: cursor + 2, timestamp: new Date().toISOString(), category: "pty", payload: { type: "pty_data", event: { sessionId: "session-1", data: "hi" } } },
{ id: cursor + 3, timestamp: new Date().toISOString(), category: "pty", payload: { type: "pty_exit", event: { sessionId: "session-1", exitCode: 0 } } },
],
nextCursor: cursor + 3,
hasMore: false,
}));
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "external" });
const response = await callTool(handler, "stream_events", {
cursor: 0,
limit: 100,
category: "pty",
});

expect(response?.isError).toBeUndefined();
expect(response.structuredContent.events).toHaveLength(2);
expect(response.structuredContent.events.every((event: any) => event.category === "pty")).toBe(true);
expect(response.structuredContent.nextCursor).toBe(3);
});

it("stream_events returns runtime validation contract events when requested", async () => {
const fixture = createRuntime();
fixture.runtime.eventBuffer.drain = vi.fn((cursor: number) => ({
Expand Down
78 changes: 76 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,7 @@ const TOOL_SPECS: ToolSpec[] = [
properties: {
cursor: { type: "number", minimum: 0 },
limit: { type: "number", minimum: 1, maximum: 1000 },
category: { type: "string", enum: ["orchestrator", "dag_mutation", "runtime", "mission"] }
category: { type: "string", enum: ["orchestrator", "dag_mutation", "runtime", "mission", "pty"] }
}
}
},
Expand Down Expand Up @@ -7601,6 +7601,16 @@ async function readResource(runtime: AdeRuntime, uri: string): Promise<Record<st
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported resource URI: ${uri}`);
}

const APP_NAVIGATE_SUPPORTED_KINDS = new Set([
"work",
"chat",
"lane",
"pr",
"route",
"branch",
"linear-issue",
]);

export function createAdeRpcRequestHandler(args: {
runtime: AdeRuntime;
serverVersion: string;
Expand Down Expand Up @@ -7823,6 +7833,38 @@ export function createAdeRpcRequestHandler(args: {
}
}

if (method.startsWith("pty.")) {
const ptyArgs = safeObject(params.args ?? params.arg ?? params);
if (method === "pty.create") {
const result = await runtime.ptyService.create(ptyArgs as Parameters<typeof runtime.ptyService.create>[0]);
return {
...result,
session: runtime.sessionService.get(result.sessionId),
};
}
if (method === "pty.sendToSession") {
return await runtime.ptyService.sendToSession(ptyArgs as Parameters<typeof runtime.ptyService.sendToSession>[0]);
}
if (method === "pty.write") {
runtime.ptyService.write(ptyArgs as Parameters<typeof runtime.ptyService.write>[0]);
return null;
}
if (method === "pty.resize") {
runtime.ptyService.resize(ptyArgs as Parameters<typeof runtime.ptyService.resize>[0]);
return null;
}
if (method === "pty.dispose") {
runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]);
return null;
}
if (method === "pty.list") {
return {
sessions: runtime.ptyService.list(ptyArgs as Parameters<typeof runtime.ptyService.list>[0]),
};
}
throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (method.startsWith("modelPicker.")) {
const store = getSharedModelPickerStore();
if (method === "modelPicker.getFavorites") {
Expand Down Expand Up @@ -7891,7 +7933,7 @@ export function createAdeRpcRequestHandler(args: {
if (!kind) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate requires target.kind.");
}
if (kind !== "work" && kind !== "chat" && kind !== "lane" && kind !== "pr" && kind !== "route") {
if (!APP_NAVIGATE_SUPPORTED_KINDS.has(kind)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported app navigation target kind: ${kind}.`);
}
if (kind === "lane" && !asOptionalTrimmedString(target.laneId)) {
Expand All @@ -7900,6 +7942,23 @@ export function createAdeRpcRequestHandler(args: {
if (kind === "route" && !asOptionalTrimmedString(target.route)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'route' requires route.");
}
if (
kind === "branch"
&& (!asOptionalTrimmedString(target.repoOwner)
|| !asOptionalTrimmedString(target.repoName)
|| !asOptionalTrimmedString(target.branch))
) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"app/navigate target 'branch' requires repoOwner, repoName, and branch.",
);
}
if (kind === "linear-issue" && !asOptionalTrimmedString(target.issueIdentifier)) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"app/navigate target 'linear-issue' requires issueIdentifier.",
);
}
const normalizedTarget: Record<string, unknown> = { kind };
const sessionId = asOptionalTrimmedString(target.sessionId);
const laneId = asOptionalTrimmedString(target.laneId);
Expand All @@ -7909,6 +7968,21 @@ export function createAdeRpcRequestHandler(args: {
const prId = asOptionalTrimmedString(target.prId);
if (prId) normalizedTarget.prId = prId;
if (typeof target.prNumber === "number") normalizedTarget.prNumber = target.prNumber;
const repoOwner = asOptionalTrimmedString(target.repoOwner);
const repoName = asOptionalTrimmedString(target.repoName);
if (repoOwner) normalizedTarget.repoOwner = repoOwner;
if (repoName) normalizedTarget.repoName = repoName;
}
if (kind === "branch") {
normalizedTarget.repoOwner = asOptionalTrimmedString(target.repoOwner);
normalizedTarget.repoName = asOptionalTrimmedString(target.repoName);
normalizedTarget.branch = asOptionalTrimmedString(target.branch);
if (typeof target.prNumber === "number") normalizedTarget.prNumber = target.prNumber;
}
if (kind === "linear-issue") {
normalizedTarget.issueIdentifier = asOptionalTrimmedString(target.issueIdentifier);
const branch = asOptionalTrimmedString(target.branch);
if (branch) normalizedTarget.branch = branch;
}
if (kind === "route") {
normalizedTarget.route = asOptionalTrimmedString(target.route);
Expand Down
4 changes: 2 additions & 2 deletions apps/ade-cli/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,14 @@ describe("createEventBuffer", () => {

it("preserves event category and payload through push and drain", () => {
const buffer = createEventBuffer();
const categories: BufferedEvent["category"][] = ["orchestrator", "dag_mutation", "runtime", "mission"];
const categories: BufferedEvent["category"][] = ["orchestrator", "dag_mutation", "runtime", "mission", "pty"];

for (const category of categories) {
buffer.push({ timestamp: "t", category, payload: { kind: category } });
}

const result = buffer.drain(0);
expect(result.events).toHaveLength(4);
expect(result.events).toHaveLength(5);
for (let i = 0; i < categories.length; i++) {
expect(result.events[i]!.category).toBe(categories[i]);
expect(result.events[i]!.payload).toEqual({ kind: categories[i] });
Expand Down
16 changes: 14 additions & 2 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { createUsageTrackingService } from "../../desktop/src/main/services/usag
import { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService";
import { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService";
import { createReviewService } from "../../desktop/src/main/services/review/reviewService";
import { createProcessRegistryService } from "../../desktop/src/main/services/runtime/processRegistryService";
import type { createAutoUpdateService } from "../../desktop/src/main/services/updates/autoUpdateService";
import {
createComputerUseArtifactBrokerService,
Expand Down Expand Up @@ -452,9 +453,17 @@ export async function createAdeRuntime(args: {
sessionService.onChanged((event) => {
pushEvent("runtime", { type: "terminal_session_changed", event });
});
const processRegistry = createProcessRegistryService({
db,
logger,
role: chatOnlyRuntime ? "tui-runtime" : "ade-serve-daemon",
projectRoot,
});
processRegistry.start();
sessionService.reconcileStaleRunningSessions({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
status: "disposed",
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"],
liveOwnerPids: processRegistry.listLivePids(),
});
const sessionDeltaService = createSessionDeltaService({
db,
Expand Down Expand Up @@ -613,9 +622,10 @@ export async function createAdeRuntime(args: {
transcriptsDir: paths.transcriptsDir,
laneService,
sessionService,
processRegistry,
logger,
broadcastData: (event) => pushEvent("runtime", { type: "pty_data", event }),
broadcastExit: (event) => pushEvent("runtime", { type: "pty_exit", event }),
broadcastData: (event) => pushEvent("pty", { type: "pty_data", event }),
broadcastExit: (event) => pushEvent("pty", { type: "pty_exit", event }),
onSessionEnded: () => {},
getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv,
loadPty: () => nodePty
Expand Down Expand Up @@ -942,6 +952,7 @@ export async function createAdeRuntime(args: {
computerUseArtifactBrokerService,
laneService,
sessionService,
processRegistry,
projectConfigService,
aiIntegrationService,
ctoStateService,
Expand Down Expand Up @@ -1234,6 +1245,7 @@ export async function createAdeRuntime(args: {
swallow(() => agentChatService?.forceDisposeAll?.());
swallow(() => testService.disposeAll());
swallow(() => ptyService.disposeAll());
swallow(() => processRegistry.stop());
swallow(() => db.flushNow());
swallow(() => db.close());
}
Expand Down
Loading