Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
],
"dependencies": {
"@agentclientprotocol/sdk": "0.26.0",
"@anthropic-ai/claude-agent-sdk": "^0.3.186",
"@ff-labs/fff-node": "^0.6.4",
"@inquirer/prompts": "^8.4.2",
"@opencode-ai/sdk": "1.14.41",
Expand Down
33 changes: 33 additions & 0 deletions cli/src/agents/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type {
AgentDescriptor,
AgentInfo,
LoadSessionResult,
NativeSessionHistory,
NativeSessionHistoryRequest,
PromptCallbacks,
PromptResult,
StoredSession,
Expand Down Expand Up @@ -621,6 +623,37 @@ export class ACP {
return this.sessions.get(sessionId)?.session ?? null;
}

/** Whether this adapter can read current history without ACP replay. */
hasNativeSessionHistory(): boolean {
return false;
}

/**
* Read the current transcript from the agent's own storage/API.
* Implementations must not return a Shellular-maintained cache.
*/
async readNativeSessionHistory(
_params: NativeSessionHistoryRequest,
): Promise<NativeSessionHistory> {
throw new UnsupportedCapabilityError(this.id, "native session history");
}

seedSessionHistory(
sessionId: string,
cwd: string,
history: NativeSessionHistory,
) {
const transcript = this.getTranscript(sessionId);
transcript.replaceMessages(history.messages);
const existing = this.sessions.get(sessionId);
this.sessions.set(sessionId, {
session:
existing?.session ??
newAiSessionFromResponse({ sessionId }, path.resolve(cwd), sessionId),
messages: transcript.getMessages(),
});
}

snapshotSession(
params: acp.LoadSessionRequest,
clientId?: string,
Expand Down
27 changes: 27 additions & 0 deletions cli/src/agents/claude-code.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import { getSessionMessages } from "@anthropic-ai/claude-agent-sdk";

import { BUILTIN_AGENT_DESCRIPTORS } from "./agents";
import { ACP } from "./base";
import { normalizeClaudeCodeHistory } from "./native-history";
import type { NativeSessionHistoryRequest } from "./types";

const NATIVE_HISTORY_PAGE_SIZE = 30;

export class ClaudeCode extends ACP {
static create() {
return new ClaudeCode(BUILTIN_AGENT_DESCRIPTORS["claude-code"]);
}

override hasNativeSessionHistory(): boolean {
return true;
}

override async readNativeSessionHistory(params: NativeSessionHistoryRequest) {
const all = await getSessionMessages(params.sessionId, {
...(params.cwd ? { dir: params.cwd } : {}),
});
const history = normalizeClaudeCodeHistory(all);
const limit = params.limit ?? NATIVE_HISTORY_PAGE_SIZE;
let page = history;
if (params.cursor) {
const cursorIndex = history.findIndex(
(message) => message.id === params.cursor,
);
if (cursorIndex < 0) return { messages: [] };
page = history.slice(0, cursorIndex);
}
return { messages: page.slice(-limit) };
}
}
158 changes: 158 additions & 0 deletions cli/src/agents/codex-app-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";

type JsonRpcId = number;

interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}

interface JsonRpcResponse {
id?: JsonRpcId;
result?: unknown;
error?: { code?: number; message?: string };
method?: string;
}

/** Minimal persistent client for Codex's supported app-server JSONL transport. */
export class CodexAppServer {
private process: ChildProcessWithoutNullStreams | null = null;
private pending = new Map<JsonRpcId, PendingRequest>();
private nextId = 1;
private stdoutBuffer = "";
private initializeTask: Promise<void> | null = null;

constructor(private readonly command: string) {}

async readThread(threadId: string): Promise<unknown> {
await this.initialize();
return this.request("thread/read", { threadId, includeTurns: true });
}

async warmup(): Promise<void> {
await this.initialize();
}

destroy() {
this.failPending(new Error("Codex app-server stopped"));
this.process?.kill();
this.process = null;
this.initializeTask = null;
this.stdoutBuffer = "";
}

private initialize() {
if (!this.initializeTask) {
this.initializeTask = this.start().catch((error) => {
this.destroy();
throw error;
});
}
return this.initializeTask;
}

private async start() {
const child = spawn(this.command, ["app-server", "--stdio"], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.process = child;
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.consume(chunk));
// Drain stderr so a verbose child can never block on a full pipe.
child.stderr.resume();
child.on("error", (error) => this.failPending(error));
child.on("exit", (code, signal) => {
this.process = null;
this.initializeTask = null;
this.failPending(
new Error(
`Codex app-server exited (${code ?? "no code"}, ${signal ?? "no signal"})`,
),
);
});

await this.request("initialize", {
clientInfo: {
name: "shellular",
title: "Shellular",
version: "1",
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
},
});
this.notify("initialized", {});
}

private request(method: string, params: unknown): Promise<unknown> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out: ${method}`));
}, 30_000);
this.pending.set(id, { resolve, reject, timer });
try {
this.write({ id, method, params });
} catch (error) {
clearTimeout(timer);
this.pending.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}

private notify(method: string, params: unknown) {
this.write({ method, params });
}

private write(message: unknown) {
if (!this.process?.stdin.writable) {
throw new Error("Codex app-server is not running");
}
this.process.stdin.write(`${JSON.stringify(message)}\n`);
}

private consume(chunk: string) {
this.stdoutBuffer += chunk;
let newline = this.stdoutBuffer.indexOf("\n");
while (newline >= 0) {
const line = this.stdoutBuffer.slice(0, newline).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
if (line) this.handleLine(line);
newline = this.stdoutBuffer.indexOf("\n");
}
}

private handleLine(line: string) {
let message: JsonRpcResponse;
try {
message = JSON.parse(line) as JsonRpcResponse;
} catch {
return;
}
if (typeof message.id !== "number" || message.method) return;
const pending = this.pending.get(message.id);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(message.id);
if (message.error) {
pending.reject(
new Error(message.error.message ?? "Codex app-server request failed"),
);
return;
}
pending.resolve(message.result);
}

private failPending(error: Error) {
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(error);
}
this.pending.clear();
}
}
37 changes: 37 additions & 0 deletions cli/src/agents/codex.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { BUILTIN_AGENT_DESCRIPTORS } from "./agents";
import { ACP } from "./base";
import { CodexAppServer } from "./codex-app-server";
import type { AcpTranscriptOptions } from "./events";
import { normalizeCodexHistoryPage } from "./native-history";
import { normalizeCodexUserReplayMessage } from "./replay-normalization";
import type { NativeSessionHistoryRequest } from "./types";

const NATIVE_HISTORY_PAGE_SIZE = 30;

export class Codex extends ACP {
private static readonly appServer = new CodexAppServer("codex");
private readonly nativeHistory = new Map<string, unknown>();

static destroyNativeHistoryRuntime() {
Codex.appServer.destroy();
}

static warmNativeHistoryRuntime() {
return Codex.appServer.warmup();
}

static create() {
return new Codex(BUILTIN_AGENT_DESCRIPTORS.codex);
}
Expand All @@ -13,4 +29,25 @@ export class Codex extends ACP {
normalizeUserReplayMessage: normalizeCodexUserReplayMessage,
};
}

override hasNativeSessionHistory(): boolean {
return true;
}

override async readNativeSessionHistory(params: NativeSessionHistoryRequest) {
let history = this.nativeHistory.get(params.sessionId);
if (!params.cursor || !history) {
history = await Codex.appServer.readThread(params.sessionId);
this.nativeHistory.set(params.sessionId, history);
}
const limit = params.limit ?? NATIVE_HISTORY_PAGE_SIZE;
return {
messages: normalizeCodexHistoryPage(history, params.cursor, limit),
};
}

override destroy() {
this.nativeHistory.clear();
super.destroy();
}
}
10 changes: 10 additions & 0 deletions cli/src/agents/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,16 @@ export class AcpTranscript {
});
}

replaceMessages(messages: AcpMessage[]) {
this.messages = messages.map((message) => ({
...message,
parts: [...message.parts],
}));
this.currentUser = null;
this.currentAssistant = null;
this.toolParts.clear();
}

beginTurn(prompt?: acp.PromptRequest["prompt"]) {
this.currentUser = null;
this.currentAssistant = null;
Expand Down
Loading