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',
);