File size: 6,503 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | /**
* ACP client connection bridge — App-scope holder for the process-wide ACP
* client connection used by the ACP-backed `IHostFileSystem` to reverse-RPC
* file text reads/writes to the editor (ACP `fs.readTextFile` /
* `fs.writeTextFile`) and by the ACP-backed `ISessionProcessRunner` to
* reverse-RPC command execution (`terminal/create` … `terminal/release`).
*
* One ACP client connection exists per `acp-server` process (a single stdio
* connection, multiplexed by `sessionId`); it is established after
* `bootstrap()`, so it is bound here lazily via {@link IAcpConnection.bind}
* rather than seeded at composition time. The ACP-backed `IHostFileSystem`
* (Session scope) and the ACP-backed process runner (Agent scope) read it on
* first use through {@link IAcpConnection.get}.
*/
import {
createDecorator,
LifecycleScope,
registerScopedService,
ScopeActivation,
type ServiceIdentifier,
} from '@moonshot-ai/agent-core-v2';
/**
* Narrow ACP-client file surface the ACP-backed `IHostFileSystem` needs.
*
* Implemented by `../acp-client`'s `AcpClient` (the app-API outbound
* adapter), so the host can `bind(client)` directly without a further
* adapter.
*/
export interface IAcpFsClient {
readTextFile(params: {
readonly sessionId: string;
readonly path: string;
}): Promise<{ readonly content: string }>;
writeTextFile(params: {
readonly sessionId: string;
readonly path: string;
readonly content: string;
}): Promise<unknown>;
}
/**
* Narrow ACP-client terminal handle the ACP-backed process runner needs.
* Structurally compatible with `@agentclientprotocol/sdk`'s `TerminalHandle`.
*/
export interface IAcpTerminalHandle {
readonly id: string;
currentOutput(): Promise<{ readonly output: string; readonly truncated: boolean }>;
waitForExit(): Promise<{
readonly exitCode?: number | null;
readonly signal?: string | null;
}>;
kill(): Promise<unknown>;
release(): Promise<unknown>;
}
/**
* Narrow ACP-client terminal surface. Implemented by `../acp-client`'s
* `AcpClient.createTerminal`.
*/
export interface IAcpTerminalClient {
createTerminal(params: {
readonly sessionId: string;
readonly command: string;
readonly args?: string[];
readonly env?: Array<{ readonly name: string; readonly value: string }>;
readonly cwd?: string | null;
readonly outputByteLimit?: number | null;
}): Promise<IAcpTerminalHandle>;
}
/**
* Notification that a terminal was created for a session. The ACP adapter
* (`AcpSession`) uses it to correlate the terminal with the in-flight Bash
* tool call and attach a `{type: 'terminal'}` content entry to the tool card.
*/
export interface AcpTerminalCreatedEvent {
readonly sessionId: string;
/**
* The full shell invocation string (the `-c` payload of the exec call,
* `cd <cwd> && <command>`), used to match the tool call whose
* `args.command` it ends with.
*/
readonly shellCommand: string;
readonly terminalId: string;
}
export type AcpTerminalCreatedListener = (event: AcpTerminalCreatedEvent) => void;
export interface IAcpConnection {
readonly _serviceBrand: undefined;
/** Bind the process-wide ACP client connection. Later binds replace earlier ones. */
bind(client: IAcpFsClient & IAcpTerminalClient): void;
/** The bound ACP client connection. Throws if called before {@link bind}. */
get(): IAcpFsClient & IAcpTerminalClient;
/** Whether a client connection has been bound. */
readonly bound: boolean;
/** Bind the client's FS capabilities (from the `initialize` handshake). */
bindFsCapabilities(fs: { readTextFile?: boolean; writeTextFile?: boolean } | undefined): void;
/** Whether the client supports `fs.readTextFile`. */
readonly fsReadTextFile: boolean;
/** Whether the client supports `fs.writeTextFile`. */
readonly fsWriteTextFile: boolean;
/** Bind the client's terminal capability (from the `initialize` handshake). */
bindTerminalCapability(terminal: boolean | undefined): void;
/** Whether the client supports the `terminal/*` reverse-RPC surface. */
readonly terminalEnabled: boolean;
/** Notify listeners that a terminal was created (called by the ACP runner). */
notifyTerminalCreated(event: AcpTerminalCreatedEvent): void;
/** Subscribe to terminal-created notifications. Returns an unsubscribe. */
onTerminalCreated(listener: AcpTerminalCreatedListener): () => void;
}
export const IAcpConnection: ServiceIdentifier<IAcpConnection> =
createDecorator<IAcpConnection>('acpConnection');
export class AcpConnection implements IAcpConnection {
declare readonly _serviceBrand: undefined;
private client: (IAcpFsClient & IAcpTerminalClient) | undefined;
private _fsReadTextFile = false;
private _fsWriteTextFile = false;
private _terminalEnabled = false;
private readonly terminalCreatedListeners = new Set<AcpTerminalCreatedListener>();
bind(client: IAcpFsClient & IAcpTerminalClient): void {
this.client = client;
}
get(): IAcpFsClient & IAcpTerminalClient {
if (this.client === undefined) {
throw new Error(
'IAcpConnection.get() called before bind() — acp-server must bind the ACP client connection before any session performs file or terminal IO.',
);
}
return this.client;
}
get bound(): boolean {
return this.client !== undefined;
}
bindFsCapabilities(fs: { readTextFile?: boolean; writeTextFile?: boolean } | undefined): void {
this._fsReadTextFile = fs?.readTextFile === true;
this._fsWriteTextFile = fs?.writeTextFile === true;
}
get fsReadTextFile(): boolean {
return this._fsReadTextFile;
}
get fsWriteTextFile(): boolean {
return this._fsWriteTextFile;
}
bindTerminalCapability(terminal: boolean | undefined): void {
this._terminalEnabled = terminal === true;
}
get terminalEnabled(): boolean {
return this._terminalEnabled;
}
notifyTerminalCreated(event: AcpTerminalCreatedEvent): void {
for (const listener of this.terminalCreatedListeners) {
try {
listener(event);
} catch {
// A broken listener must not take down process execution.
}
}
}
onTerminalCreated(listener: AcpTerminalCreatedListener): () => void {
this.terminalCreatedListeners.add(listener);
return () => {
this.terminalCreatedListeners.delete(listener);
};
}
}
registerScopedService(
LifecycleScope.App,
IAcpConnection,
AcpConnection,
ScopeActivation.OnDemand,
'acp',
);
|