mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
feat(vscodex): add remote Codex collaboration module
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Wire types shared by the relay host and the Codex app-server adapter.
|
||||
*
|
||||
* The relay intentionally treats `payload` as JSON. Keeping this boundary
|
||||
* unopinionated lets the bridge continue working when app-server adds a new
|
||||
* notification or request before this extension is updated.
|
||||
*/
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
export type JsonRpcId = string | number;
|
||||
|
||||
/** Preserve the JSON-RPC id type when using it as a map key. */
|
||||
export function jsonRpcIdKey(id: JsonRpcId): string {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
export function isJsonRpcId(value: unknown): value is JsonRpcId {
|
||||
return typeof value === "string" || typeof value === "number";
|
||||
}
|
||||
|
||||
export type ApprovalDecisionKind = "allow" | "deny" | "cancel";
|
||||
|
||||
const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]);
|
||||
const V2_APPROVAL_METHODS = new Set([
|
||||
"item/commandExecution/requestApproval",
|
||||
"item/fileChange/requestApproval",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify both current and legacy app-server approval decisions without
|
||||
* rewriting the wire value. Unknown tagged objects intentionally return
|
||||
* `undefined` so callers can fail closed instead of accidentally approving a
|
||||
* newly introduced response shape.
|
||||
*/
|
||||
export function approvalDecisionKind(value: unknown): ApprovalDecisionKind | undefined {
|
||||
if (typeof value === "string") {
|
||||
if (new Set([
|
||||
"allow",
|
||||
"accept",
|
||||
"acceptForSession",
|
||||
"approved",
|
||||
"approved_for_session",
|
||||
"approved_mcp_policy_amendment",
|
||||
]).has(value)) return "allow";
|
||||
if (new Set(["deny", "decline", "denied", "timed_out"]).has(value)) return "deny";
|
||||
if (new Set(["cancel", "abort"]).has(value)) return "cancel";
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 1) return undefined;
|
||||
const key = keys[0];
|
||||
const nested = value[key];
|
||||
if (isExecpolicyAmendmentTag(key, nested) || isNetworkPolicyAmendmentTag(key, nested)) return "allow";
|
||||
if (key === "denied" && isRecord(nested) && typeof nested.rejection === "string") return "deny";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a decision against the response schema for one app-server method.
|
||||
* The generic classifier above is intentionally useful for relay envelopes;
|
||||
* this method-aware variant prevents a v2 tagged object from being sent to a
|
||||
* legacy callback (or vice versa), while retaining compatibility aliases that
|
||||
* the relay may use in its outer `decision` field.
|
||||
*/
|
||||
export function approvalDecisionKindForMethod(
|
||||
value: unknown,
|
||||
method?: string,
|
||||
): ApprovalDecisionKind | undefined {
|
||||
const generic = approvalDecisionKind(value);
|
||||
if (!generic || !method) return generic;
|
||||
|
||||
if (LEGACY_APPROVAL_METHODS.has(method)) {
|
||||
if (typeof value === "string") {
|
||||
return new Set([
|
||||
"approved",
|
||||
"approved_for_session",
|
||||
"approved_mcp_policy_amendment",
|
||||
"timed_out",
|
||||
"abort",
|
||||
]).has(value) ? generic : undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const key = Object.keys(value)[0];
|
||||
return key === "approved_execpolicy_amendment"
|
||||
|| key === "network_policy_amendment"
|
||||
|| key === "denied" ? generic : undefined;
|
||||
}
|
||||
|
||||
if (V2_APPROVAL_METHODS.has(method)) {
|
||||
if (typeof value === "string") {
|
||||
return new Set(["accept", "acceptForSession", "decline", "cancel"]).has(value)
|
||||
? generic
|
||||
: undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const key = Object.keys(value)[0];
|
||||
if (method === "item/fileChange/requestApproval") return undefined;
|
||||
return key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment"
|
||||
? generic
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (method === "mcpServer/elicitation/request") {
|
||||
return typeof value === "string" && new Set(["accept", "decline", "cancel"]).has(value)
|
||||
? generic
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return generic;
|
||||
}
|
||||
|
||||
function isExecpolicyAmendmentTag(key: string, nested: unknown): boolean {
|
||||
if (!isRecord(nested)) return false;
|
||||
if (key === "acceptWithExecpolicyAmendment") {
|
||||
return isStringArray(nested.execpolicy_amendment);
|
||||
}
|
||||
if (key === "approved_execpolicy_amendment") {
|
||||
return isStringArray(nested.proposed_execpolicy_amendment);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNetworkPolicyAmendmentTag(key: string, nested: unknown): boolean {
|
||||
if (!isRecord(nested)) return false;
|
||||
if (key === "applyNetworkPolicyAmendment") {
|
||||
return isNetworkPolicyAmendment(nested.network_policy_amendment);
|
||||
}
|
||||
if (key === "network_policy_amendment") {
|
||||
return isNetworkPolicyAmendment(nested.network_policy_amendment);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isNetworkPolicyAmendment(value: unknown): boolean {
|
||||
return isRecord(value)
|
||||
&& typeof value.host === "string"
|
||||
&& (value.action === "allow" || value.action === "deny");
|
||||
}
|
||||
|
||||
/** Whether a response explicitly carries a decision/action field. */
|
||||
export function hasApprovalDecisionField(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && (Object.prototype.hasOwnProperty.call(value, "decision")
|
||||
|| Object.prototype.hasOwnProperty.call(value, "action"));
|
||||
}
|
||||
|
||||
export interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
id: JsonRpcId;
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
id: JsonRpcId;
|
||||
result?: JsonValue;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: JsonValue;
|
||||
};
|
||||
}
|
||||
|
||||
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
||||
|
||||
export type RelayRole = "owner" | "operator" | "approver" | "viewer" | string;
|
||||
|
||||
export interface RelayActor {
|
||||
id?: string;
|
||||
role?: RelayRole;
|
||||
}
|
||||
|
||||
/** A versioned relay event frame. `seq` is normally assigned by the relay. */
|
||||
export interface RelayEventFrame {
|
||||
v: 1;
|
||||
kind: "event";
|
||||
type: string;
|
||||
id: string;
|
||||
sessionId: string;
|
||||
seq?: number;
|
||||
ts: string;
|
||||
actor?: RelayActor;
|
||||
payload: JsonObject;
|
||||
/** Optional typed execution projection attached by a VS Code host. */
|
||||
status?: AgentStatusSnapshot;
|
||||
}
|
||||
|
||||
export interface RelayCommandFrame {
|
||||
v?: 1;
|
||||
kind?: "command";
|
||||
type: string;
|
||||
/** Compact relay compatibility form: `{ type: "command", method, params }`. */
|
||||
method?: string;
|
||||
params?: JsonObject;
|
||||
commandId?: string;
|
||||
id?: string;
|
||||
sessionId?: string;
|
||||
actor?: RelayActor;
|
||||
payload?: JsonObject;
|
||||
/** Some clients put the command body under `command`. */
|
||||
command?: {
|
||||
type?: string;
|
||||
commandId?: string;
|
||||
payload?: JsonObject;
|
||||
[key: string]: JsonValue | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RelayHelloFrame {
|
||||
v: 1;
|
||||
kind: "hello";
|
||||
clientType: "host" | "web" | string;
|
||||
protocol?: number;
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
lastSeq?: number;
|
||||
sessionId?: string;
|
||||
payload?: JsonObject;
|
||||
}
|
||||
|
||||
export interface RelayAckFrame {
|
||||
v: 1;
|
||||
kind: "ack";
|
||||
sessionId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface RelayErrorFrame {
|
||||
v: 1;
|
||||
kind: "error";
|
||||
code: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
commandId?: string;
|
||||
}
|
||||
|
||||
export type RelayFrame =
|
||||
| RelayEventFrame
|
||||
| RelayCommandFrame
|
||||
| RelayHelloFrame
|
||||
| RelayAckFrame
|
||||
| RelayErrorFrame
|
||||
| (JsonObject & { kind?: string; v?: number });
|
||||
|
||||
/**
|
||||
* Live execution information projected from the official Codex conversation
|
||||
* state. The private IPC protocol can add new turn statuses/flags, so the
|
||||
* string fields intentionally remain open-ended for forward compatibility.
|
||||
*/
|
||||
export interface AgentStatusSnapshot {
|
||||
/** Coarse UI activity, for example `thinking`, `editing`, or `running`. */
|
||||
activity: string;
|
||||
/** Raw/normalized turn status (`inProgress`, `completed`, ...). */
|
||||
turnStatus: string;
|
||||
/** Runtime flags such as `waitingOnApproval` or `waitingOnUserInput`. */
|
||||
activeFlags: string[];
|
||||
startedAtMs?: number | null;
|
||||
durationMs?: number | null;
|
||||
/**
|
||||
* Time spent doing work in the official UI. This deliberately differs
|
||||
* from `durationMs`: Codex starts the worked-for clock at the first work
|
||||
* item and stops it when the final assistant response starts.
|
||||
*/
|
||||
workedDurationMs?: number | null;
|
||||
/** Elapsed wall-clock time for an active turn. */
|
||||
elapsedMs?: number | null;
|
||||
firstTurnWorkItemStartedAtMs?: number | null;
|
||||
finalAssistantStartedAtMs?: number | null;
|
||||
error?: JsonValue;
|
||||
}
|
||||
|
||||
/** Official background-agent lifecycle values emitted by Codex v2 items. */
|
||||
export type CollabAgentStatus =
|
||||
| "pendingInit"
|
||||
| "running"
|
||||
| "interrupted"
|
||||
| "completed"
|
||||
| "errored"
|
||||
| "shutdown"
|
||||
| "notFound"
|
||||
| string;
|
||||
|
||||
export type CollabAgentTool =
|
||||
| "spawnAgent"
|
||||
| "sendInput"
|
||||
| "resumeAgent"
|
||||
| "wait"
|
||||
| "closeAgent"
|
||||
| string;
|
||||
|
||||
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | string;
|
||||
export type SubAgentActivityKind = "started" | "interacted" | "interrupted" | "completed" | string;
|
||||
|
||||
/** Last known state for one receiver in a collabAgentToolCall item. */
|
||||
export interface CollabAgentStateSnapshot {
|
||||
status: CollabAgentStatus;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-safe projection of a background Codex subagent. The official
|
||||
* webview currently uses the four coarse statuses below; the string union is
|
||||
* deliberately open so a newer app-server status does not break the relay.
|
||||
*/
|
||||
export interface SubagentSnapshot {
|
||||
threadId: string;
|
||||
displayName: string | null;
|
||||
prompt: string | null;
|
||||
/** Alias used by the subagent side panel for the same prompt text. */
|
||||
objective?: string | null;
|
||||
status: "waiting" | "working" | "done" | "failed" | string;
|
||||
statusMessage: string | null;
|
||||
startedAtMs?: number | null;
|
||||
completedAtMs?: number | null;
|
||||
canInteract?: boolean;
|
||||
model?: string | null;
|
||||
agentPath?: string | null;
|
||||
parentThreadId?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentEvent {
|
||||
/** Normalized relay event name, for example `output.chunk`. */
|
||||
type: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
requestId?: JsonRpcId;
|
||||
payload: JsonObject;
|
||||
/** Original app-server notification/request, when available. */
|
||||
raw?: JsonValue;
|
||||
/** Optional typed projection of live Codex turn/runtime status. */
|
||||
status?: AgentStatusSnapshot;
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
requestId: JsonRpcId;
|
||||
method: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
itemId?: string;
|
||||
action: string;
|
||||
risk: "low" | "medium" | "high" | "unknown";
|
||||
summary: string;
|
||||
/** SHA-256 of canonicalized, unredacted app-server request params. */
|
||||
commandHash?: string;
|
||||
createdAt: number;
|
||||
expiresAt?: number;
|
||||
payload: JsonObject;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
state: string;
|
||||
pendingApprovals: PendingApproval[];
|
||||
pendingRequests?: Array<{
|
||||
requestId: JsonRpcId;
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
commandHash?: string;
|
||||
risk?: string;
|
||||
summary?: string;
|
||||
createdAt?: number;
|
||||
expiresAt?: number;
|
||||
}>;
|
||||
outputTail: string;
|
||||
/** Optional role-aware projection used by the browser renderer. */
|
||||
messages?: JsonValue[];
|
||||
/** Background/inline subagents reconstructed from official collab items. */
|
||||
subagents?: SubagentSnapshot[];
|
||||
/** Live execution projection; retained alongside the legacy `state` field. */
|
||||
status?: AgentStatusSnapshot;
|
||||
/** Convenience aliases for clients that do not consume `status` yet. */
|
||||
activity?: string;
|
||||
turnStatus?: string;
|
||||
activeFlags?: string[];
|
||||
startedAtMs?: number | null;
|
||||
durationMs?: number | null;
|
||||
workedDurationMs?: number | null;
|
||||
elapsedMs?: number | null;
|
||||
metadata?: JsonObject;
|
||||
}
|
||||
|
||||
/** A live VS Code Codex conversation that the attach bridge has verified. */
|
||||
export interface SessionListEntry {
|
||||
threadId: string;
|
||||
title: string;
|
||||
updatedAtMs: number | null;
|
||||
cwd?: string | null;
|
||||
active: boolean;
|
||||
/** True for attach-mode results; retained for wire compatibility. */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface SessionListResult {
|
||||
sessions: SessionListEntry[];
|
||||
activeThreadId: string | null;
|
||||
}
|
||||
|
||||
/** Which owner controls conversation navigation for the remote surface. */
|
||||
export type ControlMode = "sync" | "async";
|
||||
|
||||
export interface AgentAdapter {
|
||||
start(): Promise<void>;
|
||||
/** Switch between following VS Code and independently owned conversations. */
|
||||
setControlMode?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Return the currently committed control mode without taking a snapshot. */
|
||||
getControlMode?(): ControlMode;
|
||||
/** Start a new app-server thread. */
|
||||
startThread?(params?: JsonObject): Promise<JsonValue>;
|
||||
/** Ask the official VS Code Codex extension to open a fresh conversation. */
|
||||
newSession?(params?: JsonObject): Promise<JsonValue>;
|
||||
/** Start a turn; `threadId` may be supplied in params or use the active thread. */
|
||||
startTurn?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Steer the active turn. */
|
||||
steerTurn?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Persist model/effort and other owner-managed settings on the thread. */
|
||||
updateThreadSettings?(params: JsonObject): Promise<JsonValue>;
|
||||
/** List verified, attachable local conversations without starting another Codex process. */
|
||||
listSessions?(params?: JsonObject): Promise<JsonValue>;
|
||||
/** Attach the follower to another already-open conversation. */
|
||||
selectSession?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Interrupt a turn. */
|
||||
interruptTurn?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Convenience MVP aliases. */
|
||||
sendInput(text: string, params?: JsonObject): Promise<JsonValue>;
|
||||
cancel(taskId?: string, params?: JsonObject): Promise<JsonValue>;
|
||||
respondApproval(
|
||||
requestId: JsonRpcId,
|
||||
decision: "allow" | "deny" | "cancel",
|
||||
reason?: string,
|
||||
response?: JsonValue,
|
||||
): Promise<JsonValue>;
|
||||
/** Resolve all pending approvals/inputs with a deny response. */
|
||||
denyPending?(reason?: string): Promise<void>;
|
||||
snapshot(): Promise<SessionSnapshot>;
|
||||
onEvent(listener: (event: AgentEvent) => void): Disposable;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RelayTransport {
|
||||
connect(): Promise<void>;
|
||||
send(frame: RelayFrame): void;
|
||||
onMessage(listener: (frame: RelayFrame) => void): Disposable;
|
||||
onOpen?(listener: () => void): Disposable;
|
||||
onClose?(listener: (error?: Error) => void): Disposable;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug?(message: string, ...args: unknown[]): void;
|
||||
info?(message: string, ...args: unknown[]): void;
|
||||
warn?(message: string, ...args: unknown[]): void;
|
||||
error?(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export const noopDisposable = (): Disposable => ({ dispose: () => undefined });
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asJsonObject(value: unknown): JsonObject {
|
||||
return isRecord(value) ? (value as JsonObject) : {};
|
||||
}
|
||||
|
||||
export function asJsonValue(value: unknown): JsonValue {
|
||||
if (value === undefined) return null;
|
||||
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(asJsonValue);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const output: JsonObject = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (item !== undefined) output[key] = asJsonValue(item);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
Reference in New Issue
Block a user