@mearl/agent-runtime 3.0.0-beta.5 → 3.0.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mearl/agent-runtime
2
2
 
3
- 本机 Codex app-server 与 Qoder ACP 的共享 Provider,以及受管理的一次性模型执行适配。
3
+ 本机 Codex app-server、通用 Agent Provider 合同与受管理的一次性模型执行适配。Qoder ACP 由可选的 [`@mearl/plugin-qoder`](../plugin-qoder/README.md) 提供。
4
4
 
5
5
  此包提供可独立使用的本地 Agent 适配、模型调用和会话能力,依赖 daemon-core、browser-core 的纯协议合同和公开 Provider SDK,不依赖浏览器扩展、安装器或浏览器执行宿主。
6
6
 
@@ -0,0 +1,2 @@
1
+ export * from './executable-resolver.js';
2
+ export * from './process-env.js';
@@ -0,0 +1,2 @@
1
+ export * from './executable-resolver.js';
2
+ export * from './process-env.js';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from './provider.js';
2
2
  export * from './codex-provider.js';
3
- export * from './qoder-provider.js';
4
3
  export * from './model.js';
5
4
  export type * from '@mearl/browser-core/pluginModels';
6
5
  export type * from '@mearl/browser-core/agentChat';
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export * from './provider.js';
2
2
  export * from './codex-provider.js';
3
- export * from './qoder-provider.js';
4
3
  export * from './model.js';
5
4
  export * from './model-catalog.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/agent-runtime",
3
- "version": "3.0.0-beta.5",
3
+ "version": "3.0.0-beta.8",
4
4
  "description": "Shared local agent provider runtimes for Mearl",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,9 +18,9 @@
18
18
  "types": "./dist/codex.d.ts",
19
19
  "import": "./dist/codex.js"
20
20
  },
21
- "./qoder": {
22
- "types": "./dist/qoder-provider.d.ts",
23
- "import": "./dist/qoder-provider.js"
21
+ "./execution": {
22
+ "types": "./dist/execution.d.ts",
23
+ "import": "./dist/execution.js"
24
24
  }
25
25
  },
26
26
  "files": [
@@ -29,15 +29,14 @@
29
29
  ],
30
30
  "license": "Apache-2.0",
31
31
  "engines": {
32
- "node": ">=22.12.0"
32
+ "node": ">=20.18.0"
33
33
  },
34
34
  "publishConfig": {
35
35
  "registry": "https://registry.npmjs.org"
36
36
  },
37
37
  "dependencies": {
38
- "@agentclientprotocol/sdk": "^1.2.1",
39
- "@mearl/daemon-core": "3.0.0-beta.5",
40
- "@mearl/browser-core": "3.0.0-beta.5"
38
+ "@mearl/daemon-core": "3.0.0-beta.8",
39
+ "@mearl/browser-core": "3.0.0-beta.8"
41
40
  },
42
41
  "devDependencies": {
43
42
  "@types/node": "^24.9.1",
@@ -45,7 +44,7 @@
45
44
  },
46
45
  "repository": {
47
46
  "type": "git",
48
- "url": "git+https://github.com/F-loat/mearl.git",
47
+ "url": "git+https://github.com/mearlhq/mearl.git",
49
48
  "directory": "packages/agent-runtime"
50
49
  },
51
50
  "scripts": {
@@ -1,68 +0,0 @@
1
- import type { AgentInstalledSkill } from '@mearl/browser-core/agentSkills';
2
- import fs from 'node:fs';
3
- import type * as acp from '@agentclientprotocol/sdk';
4
- import type { AgentChatImageInput, AgentChatModelInfo, AgentChatProviderDescriptor } from '@mearl/browser-core/agentChat';
5
- import type { AgentProvider, AgentProviderEvent, AgentProviderListSessionsOptions, AgentProviderListSessionsResult, AgentProviderResumeResult, AgentProviderSessionOptions } from './provider.js';
6
- export declare function qoderModelInfo(configOptions: acp.SessionConfigOption[] | null | undefined): AgentChatModelInfo | undefined;
7
- export declare function parseQoderSkillsList(output: string, cwd: string, home?: string): AgentInstalledSkill[];
8
- export declare function qoderInstallCommand(platform?: NodeJS.Platform): string;
9
- interface QoderExecutableCandidateOptions {
10
- env?: NodeJS.ProcessEnv;
11
- homedir?: () => string;
12
- platform?: NodeJS.Platform;
13
- processExecPath?: string;
14
- readdirSync?: typeof fs.readdirSync;
15
- }
16
- export declare function qoderExecutableCandidatePaths(options?: QoderExecutableCandidateOptions): string[];
17
- export declare class QoderAgentProvider implements AgentProvider {
18
- private readonly log;
19
- private readonly clientVersion;
20
- readonly id = "qoder";
21
- private process;
22
- private sdk;
23
- private connection;
24
- private context;
25
- private initializeResponse;
26
- private startPromise;
27
- private lifecycle;
28
- private listeners;
29
- private sessions;
30
- private pendingPermissions;
31
- private historyCaptures;
32
- private nextTurnId;
33
- private readonly executableResolver;
34
- constructor(log: (...args: any[]) => void, clientVersion: string);
35
- getDescriptor(): Promise<AgentChatProviderDescriptor>;
36
- prepare(): Promise<void>;
37
- onEvent(listener: (event: AgentProviderEvent) => void): () => void;
38
- listSessions(options: AgentProviderListSessionsOptions): Promise<AgentProviderListSessionsResult>;
39
- listSkills(cwd?: string): Promise<AgentInstalledSkill[]>;
40
- startSession(options: AgentProviderSessionOptions): Promise<string>;
41
- resumeSession(providerSessionId: string, options: AgentProviderSessionOptions): Promise<AgentProviderResumeResult>;
42
- getSessionModel(providerSessionId: string): AgentChatModelInfo | undefined;
43
- validateMessageInput(images: AgentChatImageInput[]): Promise<void>;
44
- sendMessage(providerSessionId: string, message: string, images?: AgentChatImageInput[]): Promise<{
45
- turnId: string;
46
- }>;
47
- interrupt(providerSessionId: string, _turnId: string): Promise<void>;
48
- closeSession(providerSessionId: string): Promise<void>;
49
- respondToRequest(requestId: string | number, result: unknown): void;
50
- dispose(): void;
51
- private ensureStarted;
52
- private start;
53
- private runPrompt;
54
- private handleSessionUpdate;
55
- private emitToolUpdate;
56
- private handlePermissionRequest;
57
- private cancelPermissions;
58
- private handleExit;
59
- private getContext;
60
- private assertImageInputSupported;
61
- private withTimeout;
62
- private probeExecutable;
63
- private runSkillsList;
64
- private resolveExecutable;
65
- private resolveExecutableCandidates;
66
- private emit;
67
- }
68
- export {};
@@ -1,968 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { spawn } from 'node:child_process';
5
- import { Readable, Writable } from 'node:stream';
6
- import { agentExecutableCandidatePaths, AgentExecutableResolver, executableFileCandidates, probeAgentExecutable, } from './executable-resolver.js';
7
- import { agentSpawnCommand, createAgentChildEnv } from './process-env.js';
8
- const PROBE_TIMEOUT_MS = 5_000;
9
- const REQUEST_TIMEOUT_MS = 30_000;
10
- const MAX_MODEL_CHARS = 256;
11
- const QODER_NOT_FOUND_ERROR = 'Qoder CLI was not found. Install Qoder CLI or set MEARL_QODER_PATH.';
12
- function errorMessage(error) {
13
- return error instanceof Error ? error.message : String(error);
14
- }
15
- function contentText(content) {
16
- return content.type === 'text' ? content.text : null;
17
- }
18
- function planText(entries) {
19
- return entries
20
- .map(entry => {
21
- const marker = entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '→' : '•';
22
- return `${marker} ${entry.content}`;
23
- })
24
- .join('\n');
25
- }
26
- export function qoderModelInfo(configOptions) {
27
- const modelOption = configOptions?.find(option => option.category === 'model' && option.type === 'select');
28
- if (!modelOption || typeof modelOption.currentValue !== 'string')
29
- return undefined;
30
- const id = modelOption.currentValue.trim().slice(0, MAX_MODEL_CHARS);
31
- if (!id)
32
- return undefined;
33
- const options = modelOption.options.flatMap(option => 'options' in option ? option.options : [option]);
34
- const displayName = options
35
- .find(option => option.value === modelOption.currentValue)
36
- ?.name.trim()
37
- .slice(0, MAX_MODEL_CHARS);
38
- return {
39
- id,
40
- ...(displayName && displayName !== id ? { displayName } : {}),
41
- };
42
- }
43
- export function parseQoderSkillsList(output, cwd, home = os.homedir()) {
44
- const skills = [];
45
- let current;
46
- const finish = () => {
47
- if (!current)
48
- return;
49
- const skillPath = current.path || (current.builtIn ? 'bundled' : '');
50
- const relativeToCwd = skillPath && path.isAbsolute(skillPath) ? path.relative(cwd, skillPath) : '';
51
- const inProject = Boolean(relativeToCwd) &&
52
- relativeToCwd !== '..' &&
53
- !relativeToCwd.startsWith(`..${path.sep}`) &&
54
- !path.isAbsolute(relativeToCwd);
55
- skills.push({
56
- name: current.name,
57
- path: skillPath,
58
- scope: current.builtIn ? 'built-in' : cwd !== home && inProject ? 'project' : 'global',
59
- });
60
- current = undefined;
61
- };
62
- for (const line of output.split(/\r?\n/)) {
63
- const heading = /^(\S+)\s+\[(Enabled|Disabled)\](?:\s+\[Built-in\])?\s*$/.exec(line);
64
- if (heading?.[1] && heading[2]) {
65
- finish();
66
- current = {
67
- name: heading[1],
68
- builtIn: line.includes('[Built-in]'),
69
- };
70
- continue;
71
- }
72
- if (!current)
73
- continue;
74
- const skillPath = /^\s+Location:\s*(.*)$/.exec(line)?.[1];
75
- if (skillPath !== undefined)
76
- current.path = skillPath;
77
- }
78
- finish();
79
- return skills;
80
- }
81
- function qoderManagedExecutableCandidates(home, readdirSync = fs.readdirSync) {
82
- const directory = path.join(home, '.qoder', 'bin', 'qodercli');
83
- try {
84
- return readdirSync(directory, { withFileTypes: true })
85
- .filter(entry => entry.isFile() && entry.name.startsWith('qodercli-'))
86
- .sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }))
87
- .map(entry => path.join(directory, entry.name));
88
- }
89
- catch {
90
- return [];
91
- }
92
- }
93
- function isPermissionResponse(value) {
94
- if (!value || typeof value !== 'object')
95
- return false;
96
- const outcome = value.outcome;
97
- if (!outcome || typeof outcome !== 'object')
98
- return false;
99
- if (outcome.outcome === 'cancelled')
100
- return true;
101
- return outcome.outcome === 'selected' && typeof outcome.optionId === 'string';
102
- }
103
- export function qoderInstallCommand(platform = process.platform) {
104
- return platform === 'win32'
105
- ? 'npm install -g @qoder-ai/qodercli'
106
- : 'curl -fsSL https://qoder.com/install | bash';
107
- }
108
- export function qoderExecutableCandidatePaths(options = {}) {
109
- const env = options.env ?? process.env;
110
- const home = (options.homedir ?? os.homedir)();
111
- const platform = options.platform ?? process.platform;
112
- const executableNames = platform === 'win32' ? ['qodercli.exe', 'qodercli.cmd'] : ['qodercli'];
113
- return agentExecutableCandidatePaths({
114
- executableNames,
115
- configuredPath: env.MEARL_QODER_PATH,
116
- env,
117
- extraUserCandidates: [
118
- ...qoderManagedExecutableCandidates(home, options.readdirSync),
119
- ...executableNames.map(name => path.join(home, '.qoder', 'bin', name)),
120
- ],
121
- home,
122
- platform,
123
- processExecPath: options.processExecPath,
124
- readdirSync: options.readdirSync,
125
- });
126
- }
127
- function sessionOptions(options) {
128
- const cwd = path.resolve(options.cwd || os.homedir());
129
- return {
130
- ...options,
131
- cwd,
132
- instructionsSent: false,
133
- };
134
- }
135
- function parsedTimestamp(value) {
136
- if (!value)
137
- return undefined;
138
- const timestamp = Date.parse(value);
139
- return Number.isNaN(timestamp) ? undefined : timestamp;
140
- }
141
- function historyItemId(capture, kind, sourceId) {
142
- if (sourceId)
143
- return `qoder_history_${kind}_${sourceId}`;
144
- const id = `qoder_history_${kind}_${capture.nextId}`;
145
- capture.nextId += 1;
146
- return id;
147
- }
148
- function historyMessageKey(capture, role, messageId) {
149
- if (messageId) {
150
- capture.lastAnonymousMessage = undefined;
151
- return `${role}:${messageId}`;
152
- }
153
- if (capture.lastAnonymousMessage?.role === role)
154
- return capture.lastAnonymousMessage.key;
155
- const key = `${role}:anonymous:${capture.nextId}`;
156
- capture.nextId += 1;
157
- capture.lastAnonymousMessage = { role, key };
158
- return key;
159
- }
160
- function captureHistoryMessage(capture, role, update) {
161
- const text = contentText(update.content);
162
- const imageCount = update.content.type === 'image' ? 1 : 0;
163
- if (!text && imageCount === 0)
164
- return;
165
- const key = historyMessageKey(capture, role, update.messageId);
166
- const existing = capture.messageEvents.get(key);
167
- if (existing) {
168
- if (role === 'user') {
169
- const params = existing.params;
170
- if (text)
171
- params.text += text;
172
- if (imageCount > 0)
173
- params.imageCount = (params.imageCount || 0) + imageCount;
174
- }
175
- else if (role === 'agent') {
176
- const params = existing.params;
177
- if (text)
178
- params.item.text += text;
179
- }
180
- else if (text) {
181
- const params = existing.params;
182
- params.item.summary[0] = `${params.item.summary[0] || ''}${text}`;
183
- }
184
- return;
185
- }
186
- const itemId = historyItemId(capture, role, update.messageId);
187
- const event = role === 'user'
188
- ? {
189
- providerSessionId: capture.providerSessionId,
190
- kind: 'lifecycle',
191
- method: 'chat/userMessage',
192
- params: { text: text || '', ...(imageCount > 0 ? { imageCount } : {}) },
193
- }
194
- : {
195
- providerSessionId: capture.providerSessionId,
196
- kind: 'notification',
197
- method: 'item/completed',
198
- params: {
199
- item: {
200
- id: itemId,
201
- type: role === 'agent' ? 'agentMessage' : 'reasoning',
202
- ...(role === 'agent' ? { text: text || '' } : { summary: [text || ''] }),
203
- },
204
- },
205
- };
206
- capture.messageEvents.set(key, event);
207
- capture.events.push(event);
208
- }
209
- function captureHistoryUpdate(capture, update) {
210
- switch (update.sessionUpdate) {
211
- case 'user_message_chunk':
212
- captureHistoryMessage(capture, 'user', update);
213
- return;
214
- case 'agent_message_chunk':
215
- captureHistoryMessage(capture, 'agent', update);
216
- return;
217
- case 'agent_thought_chunk':
218
- captureHistoryMessage(capture, 'reasoning', update);
219
- return;
220
- case 'tool_call':
221
- case 'tool_call_update': {
222
- capture.lastAnonymousMessage = undefined;
223
- const completed = update.status === 'completed' || update.status === 'failed';
224
- const existing = capture.toolEvents.get(update.toolCallId);
225
- const existingItem = existing
226
- ? existing.params.item
227
- : undefined;
228
- const item = {
229
- id: historyItemId(capture, 'tool', update.toolCallId),
230
- type: 'dynamicToolCall',
231
- namespace: 'qoder',
232
- tool: update.title || existingItem?.tool || update.toolCallId,
233
- status: update.status || (completed ? 'completed' : 'inProgress'),
234
- kind: update.kind || existingItem?.kind,
235
- };
236
- if (existing) {
237
- existing.method = completed ? 'item/completed' : 'item/started';
238
- existing.params = { item };
239
- }
240
- else {
241
- const event = {
242
- providerSessionId: capture.providerSessionId,
243
- kind: 'notification',
244
- method: completed ? 'item/completed' : 'item/started',
245
- params: { item },
246
- };
247
- capture.toolEvents.set(update.toolCallId, event);
248
- capture.events.push(event);
249
- }
250
- return;
251
- }
252
- case 'plan': {
253
- capture.lastAnonymousMessage = undefined;
254
- const item = {
255
- id: historyItemId(capture, 'plan', 'current'),
256
- type: 'plan',
257
- text: planText(update.entries),
258
- };
259
- if (capture.planEvent) {
260
- capture.planEvent.params = { item };
261
- }
262
- else {
263
- capture.planEvent = {
264
- providerSessionId: capture.providerSessionId,
265
- kind: 'notification',
266
- method: 'item/completed',
267
- params: { item },
268
- };
269
- capture.events.push(capture.planEvent);
270
- }
271
- return;
272
- }
273
- default:
274
- capture.lastAnonymousMessage = undefined;
275
- }
276
- }
277
- export class QoderAgentProvider {
278
- log;
279
- clientVersion;
280
- id = 'qoder';
281
- process = null;
282
- sdk;
283
- connection = null;
284
- context = null;
285
- initializeResponse = null;
286
- startPromise = null;
287
- lifecycle = 0;
288
- listeners = new Set();
289
- sessions = new Map();
290
- pendingPermissions = new Map();
291
- historyCaptures = new Map();
292
- nextTurnId = 1;
293
- executableResolver;
294
- constructor(log, clientVersion) {
295
- this.log = log;
296
- this.clientVersion = clientVersion;
297
- this.executableResolver = new AgentExecutableResolver({
298
- candidates: () => this.resolveExecutableCandidates(),
299
- probe: executable => this.probeExecutable(executable),
300
- notFoundError: QODER_NOT_FOUND_ERROR,
301
- unusableError: 'Qoder CLI candidates were found, but none is usable.',
302
- onFailure: (executable, error) => {
303
- this.log('[Qoder] Skipping unusable CLI:', executable, error);
304
- },
305
- });
306
- }
307
- async getDescriptor() {
308
- const { error } = await this.resolveExecutable();
309
- return {
310
- id: this.id,
311
- name: 'Qoder',
312
- available: error === null,
313
- capabilities: {
314
- streaming: true,
315
- resume: true,
316
- listSessions: 'runtime',
317
- refreshHistory: false,
318
- imageInput: 'runtime',
319
- interrupt: true,
320
- requests: true,
321
- },
322
- ...(error ? { error } : {}),
323
- setup: {
324
- installCommand: qoderInstallCommand(),
325
- loginCommand: 'qodercli',
326
- docsUrl: 'https://docs.qoder.com/en/cli/quick-start',
327
- },
328
- };
329
- }
330
- async prepare() {
331
- await this.ensureStarted();
332
- }
333
- onEvent(listener) {
334
- this.listeners.add(listener);
335
- return () => this.listeners.delete(listener);
336
- }
337
- async listSessions(options) {
338
- await this.ensureStarted();
339
- if (!this.initializeResponse?.agentCapabilities?.sessionCapabilities?.list) {
340
- throw new Error('This Qoder CLI version cannot list existing ACP sessions');
341
- }
342
- const result = await this.withTimeout(this.getContext().request(this.sdk.methods.agent.session.list, {
343
- cursor: options.cursor ?? null,
344
- ...(options.cwd ? { cwd: options.cwd } : {}),
345
- }), 'Qoder session list');
346
- return {
347
- sessions: result.sessions.map(session => {
348
- const updatedAt = parsedTimestamp(session.updatedAt);
349
- return {
350
- providerSessionId: session.sessionId,
351
- ...(session.title ? { title: session.title } : {}),
352
- ...(session.cwd ? { cwd: session.cwd } : {}),
353
- ...(updatedAt !== undefined ? { updatedAt } : {}),
354
- };
355
- }),
356
- ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}),
357
- };
358
- }
359
- async listSkills(cwd) {
360
- const resolution = await this.resolveExecutable();
361
- if (resolution.executable === null) {
362
- throw new Error(resolution.error);
363
- }
364
- const { executable } = resolution;
365
- const resolvedCwd = cwd || os.homedir();
366
- const output = await this.runSkillsList(executable, resolvedCwd);
367
- return parseQoderSkillsList(output, resolvedCwd);
368
- }
369
- async startSession(options) {
370
- const session = sessionOptions(options);
371
- await this.ensureStarted();
372
- const result = await this.withTimeout(this.getContext().request(this.sdk.methods.agent.session.new, {
373
- cwd: session.cwd,
374
- mcpServers: [],
375
- }), 'Qoder session creation');
376
- if (!result.sessionId)
377
- throw new Error('Qoder did not return a session id');
378
- const model = qoderModelInfo(result.configOptions);
379
- if (model)
380
- session.model = model;
381
- this.sessions.set(result.sessionId, session);
382
- return result.sessionId;
383
- }
384
- async resumeSession(providerSessionId, options) {
385
- const session = sessionOptions(options);
386
- await this.ensureStarted();
387
- const capabilities = this.initializeResponse?.agentCapabilities;
388
- let configOptions;
389
- const historyCapture = capabilities?.loadSession
390
- ? {
391
- providerSessionId,
392
- events: [],
393
- messageEvents: new Map(),
394
- toolEvents: new Map(),
395
- nextId: 1,
396
- }
397
- : null;
398
- if (historyCapture)
399
- this.historyCaptures.set(providerSessionId, historyCapture);
400
- try {
401
- if (historyCapture) {
402
- const result = await this.withTimeout(this.getContext().request(this.sdk.methods.agent.session.load, {
403
- sessionId: providerSessionId,
404
- cwd: session.cwd,
405
- mcpServers: [],
406
- }), 'Qoder session load');
407
- configOptions = result.configOptions;
408
- }
409
- else if (capabilities?.sessionCapabilities?.resume) {
410
- const result = await this.withTimeout(this.getContext().request(this.sdk.methods.agent.session.resume, {
411
- sessionId: providerSessionId,
412
- cwd: session.cwd,
413
- mcpServers: [],
414
- }), 'Qoder session resume');
415
- configOptions = result.configOptions;
416
- }
417
- else {
418
- throw new Error('This Qoder CLI version cannot resume an existing ACP session');
419
- }
420
- }
421
- finally {
422
- this.historyCaptures.delete(providerSessionId);
423
- }
424
- const model = qoderModelInfo(configOptions);
425
- if (model)
426
- session.model = model;
427
- this.sessions.set(providerSessionId, session);
428
- return {
429
- providerSessionId,
430
- ...(historyCapture ? { history: historyCapture.events, historyComplete: true } : {}),
431
- };
432
- }
433
- getSessionModel(providerSessionId) {
434
- const model = this.sessions.get(providerSessionId)?.model;
435
- return model ? { ...model } : undefined;
436
- }
437
- async validateMessageInput(images) {
438
- if (images.length === 0)
439
- return;
440
- await this.ensureStarted();
441
- this.assertImageInputSupported();
442
- }
443
- async sendMessage(providerSessionId, message, images = []) {
444
- await this.ensureStarted();
445
- const session = this.sessions.get(providerSessionId);
446
- if (!session)
447
- throw new Error(`Unknown Qoder session: ${providerSessionId}`);
448
- if (session.activeTurn)
449
- throw new Error('The current Qoder turn has not finished');
450
- if (images.length > 0)
451
- this.assertImageInputSupported();
452
- const turnId = `qoder_turn_${Date.now()}_${this.nextTurnId++}`;
453
- const turn = {
454
- id: turnId,
455
- assistantItemId: `${turnId}_message`,
456
- assistantText: '',
457
- reasoningItemId: `${turnId}_reasoning`,
458
- reasoningText: '',
459
- reasoningStarted: false,
460
- planItemId: `${turnId}_plan`,
461
- toolTitles: new Map(),
462
- };
463
- session.activeTurn = turn;
464
- const prompt = !session.instructionsSent && session.developerInstructions
465
- ? `${session.developerInstructions}\n\nUser request:\n${message}`
466
- : message;
467
- const promptContent = [
468
- ...(prompt ? [{ type: 'text', text: prompt }] : []),
469
- ...images.map(image => ({
470
- type: 'image',
471
- data: image.data,
472
- mimeType: image.mimeType,
473
- })),
474
- ];
475
- session.instructionsSent = true;
476
- this.emit({
477
- providerSessionId,
478
- kind: 'notification',
479
- method: 'turn/started',
480
- params: { turn: { id: turnId, status: 'inProgress' } },
481
- });
482
- void this.runPrompt(providerSessionId, session, turn, promptContent);
483
- return { turnId };
484
- }
485
- async interrupt(providerSessionId, _turnId) {
486
- await this.ensureStarted();
487
- await this.getContext().notify(this.sdk.methods.agent.session.cancel, {
488
- sessionId: providerSessionId,
489
- });
490
- this.cancelPermissions(providerSessionId);
491
- }
492
- async closeSession(providerSessionId) {
493
- const session = this.sessions.get(providerSessionId);
494
- if (session?.activeTurn && this.context) {
495
- await this.context
496
- .notify(this.sdk.methods.agent.session.cancel, { sessionId: providerSessionId })
497
- .catch(() => { });
498
- }
499
- this.cancelPermissions(providerSessionId);
500
- this.sessions.delete(providerSessionId);
501
- }
502
- respondToRequest(requestId, result) {
503
- const pending = this.pendingPermissions.get(requestId);
504
- if (!pending)
505
- throw new Error(`Unknown Qoder permission request: ${String(requestId)}`);
506
- if (!isPermissionResponse(result)) {
507
- throw new Error('Invalid Qoder permission response');
508
- }
509
- this.pendingPermissions.delete(requestId);
510
- pending.resolve(result);
511
- this.emit({
512
- providerSessionId: pending.providerSessionId,
513
- kind: 'notification',
514
- method: 'serverRequest/resolved',
515
- params: { requestId },
516
- });
517
- }
518
- dispose() {
519
- this.lifecycle++;
520
- for (const pending of this.pendingPermissions.values()) {
521
- pending.resolve({ outcome: { outcome: 'cancelled' } });
522
- }
523
- this.pendingPermissions.clear();
524
- this.historyCaptures.clear();
525
- this.sessions.clear();
526
- const connection = this.connection;
527
- const child = this.process;
528
- this.process = null;
529
- this.connection = null;
530
- this.context = null;
531
- this.initializeResponse = null;
532
- this.startPromise = null;
533
- this.executableResolver.reset();
534
- connection?.close(new Error('Qoder provider disposed'));
535
- if (child && !child.killed)
536
- child.kill('SIGTERM');
537
- }
538
- async ensureStarted() {
539
- if (this.process && !this.process.killed && this.context)
540
- return;
541
- if (this.startPromise)
542
- return this.startPromise;
543
- const pending = this.start();
544
- this.startPromise = pending;
545
- try {
546
- await pending;
547
- }
548
- catch (error) {
549
- if (this.startPromise === pending)
550
- this.startPromise = null;
551
- throw error;
552
- }
553
- }
554
- async start() {
555
- const lifecycle = this.lifecycle;
556
- const resolution = await this.resolveExecutable();
557
- if (lifecycle !== this.lifecycle)
558
- throw new Error('Qoder provider disposed during startup');
559
- if (resolution.executable === null) {
560
- throw new Error(resolution.error);
561
- }
562
- const { executable } = resolution;
563
- const sdk = await import('@agentclientprotocol/sdk');
564
- if (lifecycle !== this.lifecycle)
565
- throw new Error('Qoder provider disposed during startup');
566
- this.sdk = sdk;
567
- const launch = agentSpawnCommand(executable, ['--acp']);
568
- const child = spawn(launch.command, launch.args, {
569
- stdio: ['pipe', 'pipe', 'pipe'],
570
- windowsHide: true,
571
- env: createAgentChildEnv(),
572
- });
573
- this.process = child;
574
- child.stderr.on('data', chunk => {
575
- const message = chunk.toString('utf8').trim();
576
- if (message)
577
- this.log('[Qoder]', message);
578
- });
579
- child.once('error', error => this.handleExit(child, error));
580
- child.once('exit', (code, signal) => {
581
- this.handleExit(child, new Error(`Qoder ACP exited (code=${String(code)}, signal=${String(signal)})`));
582
- });
583
- const stream = sdk.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
584
- const app = sdk
585
- .client({ name: 'mearl' })
586
- .onRequest(this.sdk.methods.client.session.requestPermission, context => this.handlePermissionRequest(context.requestId, context.params))
587
- .onNotification(this.sdk.methods.client.session.update, context => {
588
- this.handleSessionUpdate(context.params);
589
- });
590
- const connection = app.connect(stream);
591
- this.connection = connection;
592
- this.context = connection.agent;
593
- try {
594
- const response = await this.withTimeout(connection.agent.request(this.sdk.methods.agent.initialize, {
595
- protocolVersion: sdk.PROTOCOL_VERSION,
596
- clientCapabilities: {},
597
- clientInfo: {
598
- name: 'mearl',
599
- title: 'Mearl',
600
- version: this.clientVersion,
601
- },
602
- }), 'Qoder ACP initialization');
603
- if (lifecycle !== this.lifecycle || this.process !== child)
604
- throw new Error('Qoder provider disposed during startup');
605
- this.initializeResponse = response;
606
- }
607
- catch (error) {
608
- if (this.process === child) {
609
- this.process = null;
610
- this.connection = null;
611
- this.context = null;
612
- this.executableResolver.reset();
613
- }
614
- connection.close(error);
615
- if (!child.killed)
616
- child.kill('SIGTERM');
617
- throw new Error(`Qoder ACP failed to initialize. Start "qodercli", enter "/login", and try again: ${errorMessage(error)}`, { cause: error });
618
- }
619
- }
620
- async runPrompt(providerSessionId, session, turn, prompt) {
621
- try {
622
- const response = await this.getContext().request(this.sdk.methods.agent.session.prompt, {
623
- sessionId: providerSessionId,
624
- prompt,
625
- });
626
- if (turn.reasoningStarted) {
627
- this.emit({
628
- providerSessionId,
629
- kind: 'notification',
630
- method: 'item/completed',
631
- params: {
632
- turnId: turn.id,
633
- item: {
634
- id: turn.reasoningItemId,
635
- type: 'reasoning',
636
- summary: turn.reasoningText ? [turn.reasoningText] : [],
637
- },
638
- },
639
- });
640
- }
641
- if (turn.assistantText) {
642
- this.emit({
643
- providerSessionId,
644
- kind: 'notification',
645
- method: 'item/completed',
646
- params: {
647
- turnId: turn.id,
648
- item: {
649
- id: turn.assistantItemId,
650
- type: 'agentMessage',
651
- text: turn.assistantText,
652
- },
653
- },
654
- });
655
- }
656
- if (response.usage) {
657
- this.emit({
658
- providerSessionId,
659
- kind: 'notification',
660
- method: 'thread/tokenUsage/updated',
661
- params: { turnId: turn.id, usage: response.usage },
662
- });
663
- }
664
- this.emit({
665
- providerSessionId,
666
- kind: 'notification',
667
- method: 'turn/completed',
668
- params: {
669
- turn: {
670
- id: turn.id,
671
- status: response.stopReason === 'cancelled' ? 'cancelled' : 'completed',
672
- stopReason: response.stopReason,
673
- },
674
- },
675
- });
676
- }
677
- catch (error) {
678
- this.emit({
679
- providerSessionId,
680
- kind: 'error',
681
- method: 'chat/error',
682
- params: { turnId: turn.id, message: errorMessage(error) },
683
- });
684
- this.emit({
685
- providerSessionId,
686
- kind: 'notification',
687
- method: 'turn/completed',
688
- params: { turn: { id: turn.id, status: 'failed' } },
689
- });
690
- }
691
- finally {
692
- if (session.activeTurn === turn)
693
- delete session.activeTurn;
694
- }
695
- }
696
- handleSessionUpdate(notification) {
697
- const historyCapture = this.historyCaptures.get(notification.sessionId);
698
- if (historyCapture) {
699
- captureHistoryUpdate(historyCapture, notification.update);
700
- return;
701
- }
702
- const session = this.sessions.get(notification.sessionId);
703
- const update = notification.update;
704
- if (update.sessionUpdate === 'config_option_update') {
705
- const model = qoderModelInfo(update.configOptions);
706
- if (session) {
707
- if (model)
708
- session.model = model;
709
- else
710
- delete session.model;
711
- }
712
- this.emit({
713
- providerSessionId: notification.sessionId,
714
- kind: 'notification',
715
- method: 'session/model/updated',
716
- params: { model: model ?? null },
717
- });
718
- this.emit({
719
- providerSessionId: notification.sessionId,
720
- kind: 'notification',
721
- method: 'qoder/sessionUpdate',
722
- params: notification,
723
- });
724
- return;
725
- }
726
- const turn = session?.activeTurn;
727
- if (!turn) {
728
- this.emit({
729
- providerSessionId: notification.sessionId,
730
- kind: 'notification',
731
- method: 'qoder/sessionUpdate',
732
- params: notification,
733
- });
734
- return;
735
- }
736
- switch (update.sessionUpdate) {
737
- case 'agent_message_chunk': {
738
- const delta = contentText(update.content);
739
- if (!delta)
740
- return;
741
- turn.assistantText += delta;
742
- this.emit({
743
- providerSessionId: notification.sessionId,
744
- kind: 'notification',
745
- method: 'item/agentMessage/delta',
746
- params: { turnId: turn.id, itemId: turn.assistantItemId, delta },
747
- });
748
- return;
749
- }
750
- case 'agent_thought_chunk': {
751
- const delta = contentText(update.content);
752
- if (!delta)
753
- return;
754
- if (!turn.reasoningStarted) {
755
- turn.reasoningStarted = true;
756
- this.emit({
757
- providerSessionId: notification.sessionId,
758
- kind: 'notification',
759
- method: 'item/started',
760
- params: {
761
- turnId: turn.id,
762
- item: { id: turn.reasoningItemId, type: 'reasoning', summary: [] },
763
- },
764
- });
765
- }
766
- turn.reasoningText += delta;
767
- this.emit({
768
- providerSessionId: notification.sessionId,
769
- kind: 'notification',
770
- method: 'item/reasoning/summaryTextDelta',
771
- params: { turnId: turn.id, itemId: turn.reasoningItemId, delta },
772
- });
773
- return;
774
- }
775
- case 'tool_call':
776
- turn.toolTitles.set(update.toolCallId, update.title);
777
- this.emitToolUpdate(notification.sessionId, turn, update, update.title);
778
- return;
779
- case 'tool_call_update': {
780
- const title = update.title || turn.toolTitles.get(update.toolCallId) || update.toolCallId;
781
- turn.toolTitles.set(update.toolCallId, title);
782
- this.emitToolUpdate(notification.sessionId, turn, update, title);
783
- return;
784
- }
785
- case 'plan': {
786
- const text = planText(update.entries);
787
- this.emit({
788
- providerSessionId: notification.sessionId,
789
- kind: 'notification',
790
- method: 'item/completed',
791
- params: {
792
- turnId: turn.id,
793
- item: { id: turn.planItemId, type: 'plan', text },
794
- },
795
- });
796
- return;
797
- }
798
- case 'usage_update':
799
- this.emit({
800
- providerSessionId: notification.sessionId,
801
- kind: 'notification',
802
- method: 'thread/tokenUsage/updated',
803
- params: { turnId: turn.id, usage: update },
804
- });
805
- return;
806
- default:
807
- this.emit({
808
- providerSessionId: notification.sessionId,
809
- kind: 'notification',
810
- method: 'qoder/sessionUpdate',
811
- params: { turnId: turn.id, update },
812
- });
813
- }
814
- }
815
- emitToolUpdate(providerSessionId, turn, update, title) {
816
- const completed = update.status === 'completed' || update.status === 'failed';
817
- this.emit({
818
- providerSessionId,
819
- kind: 'notification',
820
- method: completed ? 'item/completed' : 'item/started',
821
- params: {
822
- turnId: turn.id,
823
- item: {
824
- id: update.toolCallId,
825
- type: 'dynamicToolCall',
826
- namespace: 'qoder',
827
- tool: title,
828
- status: update.status || (completed ? 'completed' : 'inProgress'),
829
- kind: update.kind,
830
- rawInput: update.rawInput,
831
- rawOutput: update.rawOutput,
832
- content: update.content,
833
- locations: update.locations,
834
- },
835
- },
836
- });
837
- }
838
- handlePermissionRequest(requestId, request) {
839
- if (requestId === null) {
840
- return Promise.resolve({ outcome: { outcome: 'cancelled' } });
841
- }
842
- return new Promise(resolve => {
843
- this.pendingPermissions.set(requestId, {
844
- providerSessionId: request.sessionId,
845
- resolve,
846
- });
847
- this.emit({
848
- providerSessionId: request.sessionId,
849
- kind: 'request',
850
- method: 'qoder/requestPermission',
851
- params: request,
852
- requestId,
853
- });
854
- });
855
- }
856
- cancelPermissions(providerSessionId) {
857
- for (const [requestId, pending] of this.pendingPermissions) {
858
- if (pending.providerSessionId !== providerSessionId)
859
- continue;
860
- this.pendingPermissions.delete(requestId);
861
- pending.resolve({ outcome: { outcome: 'cancelled' } });
862
- }
863
- }
864
- handleExit(child, error) {
865
- if (this.process !== child)
866
- return;
867
- this.process = null;
868
- this.connection?.close(error);
869
- this.connection = null;
870
- this.context = null;
871
- this.initializeResponse = null;
872
- this.startPromise = null;
873
- this.executableResolver.reset();
874
- for (const providerSessionId of this.sessions.keys()) {
875
- this.cancelPermissions(providerSessionId);
876
- this.emit({
877
- providerSessionId,
878
- kind: 'error',
879
- method: 'provider/exited',
880
- params: { message: error.message },
881
- });
882
- }
883
- }
884
- getContext() {
885
- if (!this.context)
886
- throw new Error('Qoder ACP is not running');
887
- return this.context;
888
- }
889
- assertImageInputSupported() {
890
- if (!this.initializeResponse?.agentCapabilities?.promptCapabilities?.image) {
891
- throw new Error('This Qoder CLI version does not support image prompts');
892
- }
893
- }
894
- withTimeout(promise, operation) {
895
- return new Promise((resolve, reject) => {
896
- const timer = setTimeout(() => reject(new Error(`${operation} timed out`)), REQUEST_TIMEOUT_MS);
897
- promise.then(result => {
898
- clearTimeout(timer);
899
- resolve(result);
900
- }, error => {
901
- clearTimeout(timer);
902
- reject(error);
903
- });
904
- });
905
- }
906
- probeExecutable(executable) {
907
- return probeAgentExecutable(executable, {
908
- args: ['--version'],
909
- timeoutMs: PROBE_TIMEOUT_MS,
910
- timeoutError: 'Timed out while checking Qoder CLI availability.',
911
- startError: error => `Failed to start Qoder CLI: ${error.message}`,
912
- exitError: code => `Qoder CLI check exited with code ${String(code)}`,
913
- });
914
- }
915
- runSkillsList(executable, cwd) {
916
- return new Promise((resolve, reject) => {
917
- const launch = agentSpawnCommand(executable, ['skill', 'list', '--all']);
918
- const child = spawn(launch.command, launch.args, {
919
- cwd,
920
- stdio: ['ignore', 'pipe', 'pipe'],
921
- windowsHide: true,
922
- env: createAgentChildEnv(),
923
- });
924
- let stdout = '';
925
- let stderr = '';
926
- let settled = false;
927
- const finish = (error) => {
928
- if (settled)
929
- return;
930
- settled = true;
931
- clearTimeout(timer);
932
- if (error)
933
- reject(error);
934
- else
935
- resolve(stdout);
936
- };
937
- const timer = setTimeout(() => {
938
- child.kill('SIGTERM');
939
- finish(new Error('Timed out while listing Qoder skills.'));
940
- }, REQUEST_TIMEOUT_MS);
941
- child.stdout.on('data', chunk => {
942
- if (stdout.length < 1_000_000)
943
- stdout += chunk.toString('utf8');
944
- });
945
- child.stderr.on('data', chunk => {
946
- if (stderr.length < 10_000)
947
- stderr += chunk.toString('utf8');
948
- });
949
- child.once('error', error => finish(error));
950
- child.once('exit', code => {
951
- if (code === 0)
952
- finish();
953
- else
954
- finish(new Error(stderr.trim() || `Qoder skill list exited with code ${String(code)}`));
955
- });
956
- });
957
- }
958
- resolveExecutable() {
959
- return this.executableResolver.resolve();
960
- }
961
- resolveExecutableCandidates() {
962
- return executableFileCandidates(qoderExecutableCandidatePaths());
963
- }
964
- emit(event) {
965
- for (const listener of this.listeners)
966
- listener(event);
967
- }
968
- }