@yeaft/webchat-agent 0.1.859 → 0.1.863
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/connection/message-router.js +21 -1
- package/conversation.js +79 -6
- package/history.js +22 -59
- package/package.json +1 -1
- package/providers/acp-client.js +135 -0
- package/providers/base.js +41 -0
- package/providers/claude-code.js +62 -1
- package/providers/copilot-models.js +17 -0
- package/providers/copilot.js +681 -176
- package/yeaft/chats/chat-store.js +224 -0
- package/yeaft/conversation/persist.js +98 -0
- package/yeaft/dream-v2/apply.js +8 -0
- package/yeaft/dream-v2/prompts/index.js +3 -0
- package/yeaft/dream-v2/triage.js +16 -1
- package/yeaft/engine.js +24 -8
- package/yeaft/groups/pre-flow.js +13 -4
- package/yeaft/llm/models-dev.js +7 -1
- package/yeaft/memory/segment.js +1 -1
- package/yeaft/memory/store-v2.js +41 -2
- package/yeaft/web-bridge.js +326 -3
|
@@ -37,7 +37,7 @@ import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
|
37
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
38
38
|
import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../yeaft/config-api.js';
|
|
39
39
|
import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
|
|
40
|
-
import { handleYeaftGroupChat, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListGroups, handleYeaftCreateGroup, handleYeaftRenameGroup, handleYeaftUpdateGroup, handleYeaftUpdateGroupConfig, handleYeaftArchiveGroup, handleYeaftDeleteGroup, handleYeaftAddMember, handleYeaftRemoveMember, handleYeaftSetDefaultVp, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, broadcastLanguageChange } from '../yeaft/web-bridge.js';
|
|
40
|
+
import { handleYeaftGroupChat, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListGroups, handleYeaftCreateGroup, handleYeaftRenameGroup, handleYeaftUpdateGroup, handleYeaftUpdateGroupConfig, handleYeaftArchiveGroup, handleYeaftDeleteGroup, handleYeaftAddMember, handleYeaftRemoveMember, handleYeaftSetDefaultVp, handleYeaftChatSend, handleYeaftListChats, handleYeaftCreateChat, handleYeaftRenameChat, handleYeaftArchiveChat, handleYeaftDeleteChat, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, broadcastLanguageChange } from '../yeaft/web-bridge.js';
|
|
41
41
|
|
|
42
42
|
export async function handleMessage(msg) {
|
|
43
43
|
switch (msg.type) {
|
|
@@ -547,6 +547,26 @@ export async function handleMessage(msg) {
|
|
|
547
547
|
handleYeaftSetDefaultVp(msg);
|
|
548
548
|
break;
|
|
549
549
|
|
|
550
|
+
// Yeaft Chat Mode (1:1 single-VP) — separate from group fan-out.
|
|
551
|
+
case 'yeaft_chat_send':
|
|
552
|
+
await handleYeaftChatSend(msg);
|
|
553
|
+
break;
|
|
554
|
+
case 'yeaft_list_chats':
|
|
555
|
+
await handleYeaftListChats(msg);
|
|
556
|
+
break;
|
|
557
|
+
case 'yeaft_create_chat':
|
|
558
|
+
await handleYeaftCreateChat(msg);
|
|
559
|
+
break;
|
|
560
|
+
case 'yeaft_rename_chat':
|
|
561
|
+
await handleYeaftRenameChat(msg);
|
|
562
|
+
break;
|
|
563
|
+
case 'yeaft_archive_chat':
|
|
564
|
+
await handleYeaftArchiveChat(msg);
|
|
565
|
+
break;
|
|
566
|
+
case 'yeaft_delete_chat':
|
|
567
|
+
await handleYeaftDeleteChat(msg);
|
|
568
|
+
break;
|
|
569
|
+
|
|
550
570
|
// wave-6b: manual dream trigger from VP detail page
|
|
551
571
|
case 'yeaft_dream_trigger':
|
|
552
572
|
case 'unify_dream_trigger':
|
package/conversation.js
CHANGED
|
@@ -287,6 +287,11 @@ export function parseSlashCommand(message) {
|
|
|
287
287
|
export async function sendConversationList() {
|
|
288
288
|
const list = [];
|
|
289
289
|
for (const [id, state] of ctx.conversations) {
|
|
290
|
+
let providerCaps = state.capabilities;
|
|
291
|
+
if (!providerCaps) {
|
|
292
|
+
try { providerCaps = getProvider(state.providerName || DEFAULT_PROVIDER).capabilities || null; }
|
|
293
|
+
catch { providerCaps = null; }
|
|
294
|
+
}
|
|
290
295
|
const entry = {
|
|
291
296
|
id,
|
|
292
297
|
workDir: state.workDir,
|
|
@@ -295,7 +300,8 @@ export async function sendConversationList() {
|
|
|
295
300
|
processing: !!state.turnActive,
|
|
296
301
|
userId: state.userId,
|
|
297
302
|
username: state.username,
|
|
298
|
-
provider: state.providerName || DEFAULT_PROVIDER
|
|
303
|
+
provider: state.providerName || DEFAULT_PROVIDER,
|
|
304
|
+
capabilities: providerCaps || undefined,
|
|
299
305
|
};
|
|
300
306
|
list.push(entry);
|
|
301
307
|
}
|
|
@@ -390,10 +396,14 @@ export async function createConversation(msg) {
|
|
|
390
396
|
resumeSessionId: null,
|
|
391
397
|
userId,
|
|
392
398
|
username,
|
|
399
|
+
providerOptions: msg.providerOptions || {},
|
|
393
400
|
});
|
|
394
401
|
state.disallowedTools = disallowedTools || null;
|
|
395
402
|
}
|
|
396
403
|
|
|
404
|
+
let createdCaps = null;
|
|
405
|
+
try { createdCaps = getProvider(provider).capabilities || null; } catch { /* noop */ }
|
|
406
|
+
|
|
397
407
|
ctx.sendToServer({
|
|
398
408
|
type: 'conversation_created',
|
|
399
409
|
conversationId,
|
|
@@ -401,6 +411,7 @@ export async function createConversation(msg) {
|
|
|
401
411
|
userId,
|
|
402
412
|
username,
|
|
403
413
|
provider,
|
|
414
|
+
capabilities: createdCaps || undefined,
|
|
404
415
|
disallowedTools: disallowedTools || null
|
|
405
416
|
});
|
|
406
417
|
|
|
@@ -441,9 +452,11 @@ export async function resumeConversation(msg) {
|
|
|
441
452
|
console.log(`[Resume] workDir: ${effectiveWorkDir} (lazy start)`);
|
|
442
453
|
|
|
443
454
|
// 清理旧条目:同 conversationId 或同 claudeSessionId 的条目(避免重复恢复同一个 session 累积)
|
|
455
|
+
let priorProviderOptions = null;
|
|
444
456
|
for (const [id, conv] of ctx.conversations) {
|
|
445
457
|
if (id === conversationId || (claudeSessionId && conv.claudeSessionId === claudeSessionId)) {
|
|
446
458
|
console.log(`[Resume] Cleaning up old conversation: ${id} (claudeSessionId: ${conv.claudeSessionId})`);
|
|
459
|
+
if (conv.providerOptions && !priorProviderOptions) priorProviderOptions = conv.providerOptions;
|
|
447
460
|
if (conv.abortController) {
|
|
448
461
|
conv.abortController.abort();
|
|
449
462
|
}
|
|
@@ -454,7 +467,15 @@ export async function resumeConversation(msg) {
|
|
|
454
467
|
}
|
|
455
468
|
}
|
|
456
469
|
|
|
457
|
-
|
|
470
|
+
let driverForHistory;
|
|
471
|
+
try { driverForHistory = getProvider(provider); }
|
|
472
|
+
catch (err) {
|
|
473
|
+
console.warn(`[Resume] unknown provider "${provider}", falling back to claude-code history loader:`, err?.message || err);
|
|
474
|
+
driverForHistory = {};
|
|
475
|
+
}
|
|
476
|
+
const historyMessages = typeof driverForHistory.loadHistory === 'function'
|
|
477
|
+
? await driverForHistory.loadHistory(effectiveWorkDir, claudeSessionId)
|
|
478
|
+
: loadSessionHistory(effectiveWorkDir, claudeSessionId);
|
|
458
479
|
if (username) console.log(`[Resume] User: ${username} (${userId})`);
|
|
459
480
|
console.log(`Loaded ${historyMessages.length} history messages`);
|
|
460
481
|
|
|
@@ -492,10 +513,14 @@ export async function resumeConversation(msg) {
|
|
|
492
513
|
resumeSessionId: claudeSessionId || null,
|
|
493
514
|
userId,
|
|
494
515
|
username,
|
|
516
|
+
providerOptions: msg.providerOptions || priorProviderOptions || {},
|
|
495
517
|
});
|
|
496
518
|
state.disallowedTools = disallowedTools || null;
|
|
497
519
|
}
|
|
498
520
|
|
|
521
|
+
let resumedCaps = null;
|
|
522
|
+
try { resumedCaps = getProvider(provider).capabilities || null; } catch { /* noop */ }
|
|
523
|
+
|
|
499
524
|
ctx.sendToServer({
|
|
500
525
|
type: 'conversation_resumed',
|
|
501
526
|
conversationId,
|
|
@@ -504,7 +529,8 @@ export async function resumeConversation(msg) {
|
|
|
504
529
|
historyMessages,
|
|
505
530
|
userId,
|
|
506
531
|
username,
|
|
507
|
-
provider
|
|
532
|
+
provider,
|
|
533
|
+
capabilities: resumedCaps || undefined
|
|
508
534
|
});
|
|
509
535
|
|
|
510
536
|
// 立即发送 agent 级别的 MCP servers 列表
|
|
@@ -656,9 +682,15 @@ export async function handleCancelExecution(msg) {
|
|
|
656
682
|
// 标记为取消状态,防止 processClaudeOutput 的 finally 发送 conversation_closed
|
|
657
683
|
state.cancelled = true;
|
|
658
684
|
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
state.
|
|
685
|
+
// 通过 driver 中止当前查询(Claude 走 abortController;Copilot 走 SIGTERM)
|
|
686
|
+
try {
|
|
687
|
+
const driver = getProvider(state.providerName || DEFAULT_PROVIDER);
|
|
688
|
+
if (typeof driver.abort === 'function') driver.abort(state);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
console.warn(`[${conversationId}] driver.abort failed:`, err?.message || err);
|
|
691
|
+
if (state.abortController) {
|
|
692
|
+
try { state.abortController.abort(); } catch { /* noop */ }
|
|
693
|
+
}
|
|
662
694
|
}
|
|
663
695
|
|
|
664
696
|
// 关闭输入流
|
|
@@ -748,6 +780,23 @@ export async function handleUserInput(msg) {
|
|
|
748
780
|
});
|
|
749
781
|
}
|
|
750
782
|
if (workDir) state.workDir = workDir;
|
|
783
|
+
|
|
784
|
+
// /clear for capable providers — reset session in-place without spawning new turn
|
|
785
|
+
if (slashCommand.type === 'slash' && slashCommand.command === '/clear') {
|
|
786
|
+
if (typeof driver.clear === 'function' && driver.capabilities?.clear) {
|
|
787
|
+
try { await driver.clear(state); } catch (err) {
|
|
788
|
+
console.warn(`[${conversationId}] driver.clear failed:`, err?.message || err);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
ctx.sendToServer({
|
|
792
|
+
type: 'turn_completed',
|
|
793
|
+
conversationId,
|
|
794
|
+
claudeSessionId: state.sessionId || state.claudeSessionId,
|
|
795
|
+
workDir: state.workDir
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
751
800
|
sendOutput(conversationId, { type: 'user', message: { role: 'user', content: prompt } });
|
|
752
801
|
state.turnActive = true;
|
|
753
802
|
sendConversationList();
|
|
@@ -913,6 +962,30 @@ export function handleAskUserQuestion(conversationId, input, toolCtx) {
|
|
|
913
962
|
* 则按 conversationId 查找该 conversation 下唯一的 pending question 并 resolve。
|
|
914
963
|
*/
|
|
915
964
|
export function handleAskUserAnswer(msg) {
|
|
965
|
+
// Provider-driven permission round-trip (e.g. Copilot ACP
|
|
966
|
+
// session/request_permission): if any registered driver owns this
|
|
967
|
+
// requestId, route the answer back to its `respondToPermissionRequest`
|
|
968
|
+
// instead of through the generic AskUserQuestion path. Dispatch by
|
|
969
|
+
// capability + ownership, not by string prefix, so future providers
|
|
970
|
+
// (hermes-agent etc.) plug in without touching this code.
|
|
971
|
+
if (msg.conversationId && msg.requestId) {
|
|
972
|
+
const state = ctx.conversations.get(msg.conversationId);
|
|
973
|
+
if (state?.pendingPermissions?.has(msg.requestId)) {
|
|
974
|
+
try {
|
|
975
|
+
const driver = getProvider(state.providerName || DEFAULT_PROVIDER);
|
|
976
|
+
if (typeof driver.respondToPermissionRequest === 'function') {
|
|
977
|
+
const ans = msg.answers || {};
|
|
978
|
+
const optionId = typeof ans === 'string' ? ans
|
|
979
|
+
: ans.optionId || ans.option || Object.values(ans)[0];
|
|
980
|
+
driver.respondToPermissionRequest(state, msg.requestId, optionId);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
} catch (err) {
|
|
984
|
+
console.warn('[AskUser] provider perm routing failed:', err?.message || err);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
916
989
|
let pending = ctx.pendingUserQuestions.get(msg.requestId);
|
|
917
990
|
let matchedRequestId = msg.requestId;
|
|
918
991
|
|
package/history.js
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from 'os';
|
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import ctx from './context.js';
|
|
5
|
+
import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
|
|
5
6
|
|
|
6
7
|
// Claude 项目目录
|
|
7
8
|
export function getClaudeProjectsDir() {
|
|
@@ -184,18 +185,23 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
export async function handleListHistorySessions(msg) {
|
|
187
|
-
const { workDir, requestId, _requestClientId } = msg;
|
|
188
|
+
const { workDir, requestId, _requestClientId, provider } = msg;
|
|
188
189
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
190
|
+
const providerName = provider || DEFAULT_PROVIDER;
|
|
189
191
|
|
|
190
|
-
console.log(`Listing history sessions for: ${effectiveWorkDir}`);
|
|
192
|
+
console.log(`Listing history sessions for: ${effectiveWorkDir} (provider=${providerName})`);
|
|
191
193
|
|
|
192
194
|
try {
|
|
193
|
-
const
|
|
195
|
+
const driver = getProvider(providerName);
|
|
196
|
+
const sessions = typeof driver.listSessions === 'function'
|
|
197
|
+
? await driver.listSessions(effectiveWorkDir)
|
|
198
|
+
: await getHistorySessions(effectiveWorkDir);
|
|
194
199
|
ctx.sendToServer({
|
|
195
200
|
type: 'history_sessions_list',
|
|
196
201
|
requestId,
|
|
197
202
|
_requestClientId,
|
|
198
203
|
workDir: effectiveWorkDir,
|
|
204
|
+
provider: providerName,
|
|
199
205
|
sessions
|
|
200
206
|
});
|
|
201
207
|
} catch (e) {
|
|
@@ -205,85 +211,42 @@ export async function handleListHistorySessions(msg) {
|
|
|
205
211
|
requestId,
|
|
206
212
|
_requestClientId,
|
|
207
213
|
workDir: effectiveWorkDir,
|
|
214
|
+
provider: providerName,
|
|
208
215
|
sessions: [],
|
|
209
216
|
error: e.message
|
|
210
217
|
});
|
|
211
218
|
}
|
|
212
219
|
}
|
|
213
220
|
|
|
214
|
-
//
|
|
221
|
+
// 列出指定 provider 下所有 folder (工作目录)
|
|
215
222
|
export async function handleListFolders(msg) {
|
|
216
|
-
const { requestId, _requestClientId } = msg;
|
|
217
|
-
const
|
|
223
|
+
const { requestId, _requestClientId, provider } = msg;
|
|
224
|
+
const providerName = provider || DEFAULT_PROVIDER;
|
|
218
225
|
|
|
219
|
-
console.log(`Listing folders
|
|
226
|
+
console.log(`Listing folders for provider=${providerName}`);
|
|
220
227
|
|
|
221
228
|
try {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
for (const entry of entries) {
|
|
228
|
-
const entryPath = join(projectsDir, entry);
|
|
229
|
-
const stats = statSync(entryPath);
|
|
230
|
-
|
|
231
|
-
if (stats.isDirectory()) {
|
|
232
|
-
// 过滤掉 crew 角色的 session 文件夹
|
|
233
|
-
// crew 角色的 cwd 在 .crew/roles/{roleName} 下,对应的文件夹名包含 --crew-roles-
|
|
234
|
-
if (entry.includes('--crew-roles-')) {
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 从 session 文件读取真实的工作目录路径
|
|
239
|
-
const originalPath = getWorkDirFromProjectFolder(entryPath, entry);
|
|
240
|
-
|
|
241
|
-
// 快速计数:只数 .jsonl 文件数量,不读取文件内容
|
|
242
|
-
let sessionCount = 0;
|
|
243
|
-
let lastModified = stats.mtime.getTime();
|
|
244
|
-
|
|
245
|
-
try {
|
|
246
|
-
const files = readdirSync(entryPath);
|
|
247
|
-
|
|
248
|
-
for (const file of files) {
|
|
249
|
-
if (file.endsWith('.jsonl')) {
|
|
250
|
-
sessionCount++;
|
|
251
|
-
try {
|
|
252
|
-
const fileStats = statSync(join(entryPath, file));
|
|
253
|
-
if (fileStats.mtime.getTime() > lastModified) {
|
|
254
|
-
lastModified = fileStats.mtime.getTime();
|
|
255
|
-
}
|
|
256
|
-
} catch {}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
260
|
-
|
|
261
|
-
folders.push({
|
|
262
|
-
name: entry,
|
|
263
|
-
path: originalPath,
|
|
264
|
-
sessionCount,
|
|
265
|
-
lastModified
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
229
|
+
const driver = getProvider(providerName);
|
|
230
|
+
let folders = [];
|
|
231
|
+
if (typeof driver.listFolders === 'function') {
|
|
232
|
+
folders = await driver.listFolders();
|
|
269
233
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
console.log(`Found ${folders.length} folders, sending response...`);
|
|
234
|
+
folders.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
|
235
|
+
console.log(`Found ${folders.length} folders (provider=${providerName}), sending response...`);
|
|
274
236
|
ctx.sendToServer({
|
|
275
237
|
type: 'folders_list',
|
|
276
238
|
requestId,
|
|
277
239
|
_requestClientId,
|
|
240
|
+
provider: providerName,
|
|
278
241
|
folders
|
|
279
242
|
});
|
|
280
|
-
console.log(`folders_list sent with ${folders.length} folders`);
|
|
281
243
|
} catch (e) {
|
|
282
244
|
console.error('Error listing folders:', e);
|
|
283
245
|
ctx.sendToServer({
|
|
284
246
|
type: 'folders_list',
|
|
285
247
|
requestId,
|
|
286
248
|
_requestClientId,
|
|
249
|
+
provider: providerName,
|
|
287
250
|
folders: [],
|
|
288
251
|
error: e.message
|
|
289
252
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny JSON-RPC 2.0 client over stdio for Agent Client Protocol (ACP).
|
|
3
|
+
*
|
|
4
|
+
* ACP uses newline-delimited JSON (one JSON object per line on each direction).
|
|
5
|
+
* This client:
|
|
6
|
+
* - sends `request(method, params)` → Promise<result>
|
|
7
|
+
* - sends `notify(method, params)` (no response expected)
|
|
8
|
+
* - dispatches incoming notifications via onNotification(method, params)
|
|
9
|
+
* - dispatches incoming requests (server → client) via onRequest(method, params)
|
|
10
|
+
* which must return a value (resolved as result) or throw to send an error
|
|
11
|
+
*
|
|
12
|
+
* Designed to be reusable by any ACP-style backend (Copilot today,
|
|
13
|
+
* hermes-agent later).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class AcpClient {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {NodeJS.WritableStream} opts.stdin process.stdin of the child
|
|
20
|
+
* @param {NodeJS.ReadableStream} opts.stdout process.stdout of the child
|
|
21
|
+
* @param {(method:string, params:any)=>void} [opts.onNotification]
|
|
22
|
+
* @param {(method:string, params:any)=>Promise<any>|any} [opts.onRequest]
|
|
23
|
+
* @param {(err:Error)=>void} [opts.onError] transport-level error sink
|
|
24
|
+
*/
|
|
25
|
+
constructor({ stdin, stdout, onNotification, onRequest, onError }) {
|
|
26
|
+
this._stdin = stdin;
|
|
27
|
+
this._stdout = stdout;
|
|
28
|
+
this._onNotification = onNotification || (() => {});
|
|
29
|
+
this._onRequest = onRequest || (() => { throw new Error('no onRequest handler'); });
|
|
30
|
+
this._onError = onError || (() => {});
|
|
31
|
+
this._nextId = 1;
|
|
32
|
+
this._pending = new Map(); // id → { resolve, reject }
|
|
33
|
+
this._buf = '';
|
|
34
|
+
this._closed = false;
|
|
35
|
+
|
|
36
|
+
stdout.on('data', (chunk) => this._onData(chunk));
|
|
37
|
+
stdout.on('error', (err) => this._onError(err));
|
|
38
|
+
stdout.on('close', () => this._handleClose());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Send a JSONRPC request and await its response. */
|
|
42
|
+
request(method, params) {
|
|
43
|
+
if (this._closed) return Promise.reject(new Error('acp client closed'));
|
|
44
|
+
const id = this._nextId++;
|
|
45
|
+
const payload = { jsonrpc: '2.0', id, method, params };
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
this._pending.set(id, { resolve, reject });
|
|
48
|
+
try {
|
|
49
|
+
this._stdin.write(JSON.stringify(payload) + '\n');
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this._pending.delete(id);
|
|
52
|
+
reject(err);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Send a JSONRPC notification (no id, no response). */
|
|
58
|
+
notify(method, params) {
|
|
59
|
+
if (this._closed) return;
|
|
60
|
+
const payload = { jsonrpc: '2.0', method, params };
|
|
61
|
+
try { this._stdin.write(JSON.stringify(payload) + '\n'); }
|
|
62
|
+
catch (err) { this._onError(err); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mark closed; reject all pending requests. */
|
|
66
|
+
close(reason) {
|
|
67
|
+
if (this._closed) return;
|
|
68
|
+
this._closed = true;
|
|
69
|
+
const err = new Error(reason || 'acp client closed');
|
|
70
|
+
for (const { reject } of this._pending.values()) {
|
|
71
|
+
try { reject(err); } catch { /* noop */ }
|
|
72
|
+
}
|
|
73
|
+
this._pending.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_handleClose() {
|
|
77
|
+
this.close('child stdout closed');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_onData(chunk) {
|
|
81
|
+
this._buf += chunk.toString('utf8');
|
|
82
|
+
let idx;
|
|
83
|
+
while ((idx = this._buf.indexOf('\n')) >= 0) {
|
|
84
|
+
const line = this._buf.slice(0, idx).trim();
|
|
85
|
+
this._buf = this._buf.slice(idx + 1);
|
|
86
|
+
if (!line) continue;
|
|
87
|
+
let msg;
|
|
88
|
+
try { msg = JSON.parse(line); }
|
|
89
|
+
catch (err) { this._onError(new Error(`acp: unparsable line: ${line.slice(0, 200)}`)); continue; }
|
|
90
|
+
this._dispatch(msg);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_dispatch(msg) {
|
|
95
|
+
// Response (has id and result/error, no method)
|
|
96
|
+
if (msg.id != null && (Object.prototype.hasOwnProperty.call(msg, 'result') || Object.prototype.hasOwnProperty.call(msg, 'error'))) {
|
|
97
|
+
const slot = this._pending.get(msg.id);
|
|
98
|
+
if (!slot) return; // stale
|
|
99
|
+
this._pending.delete(msg.id);
|
|
100
|
+
if (msg.error) slot.reject(Object.assign(new Error(msg.error.message || 'acp error'), { code: msg.error.code, data: msg.error.data }));
|
|
101
|
+
else slot.resolve(msg.result);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Request from server (has id and method) — synchronous response required
|
|
105
|
+
if (msg.id != null && typeof msg.method === 'string') {
|
|
106
|
+
Promise.resolve()
|
|
107
|
+
.then(() => this._onRequest(msg.method, msg.params))
|
|
108
|
+
.then((result) => {
|
|
109
|
+
this._safeWrite({ jsonrpc: '2.0', id: msg.id, result: result === undefined ? null : result });
|
|
110
|
+
})
|
|
111
|
+
.catch((err) => {
|
|
112
|
+
this._safeWrite({
|
|
113
|
+
jsonrpc: '2.0',
|
|
114
|
+
id: msg.id,
|
|
115
|
+
error: { code: err?.code || -32603, message: err?.message || String(err) },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Notification (method, no id)
|
|
121
|
+
if (typeof msg.method === 'string') {
|
|
122
|
+
try { this._onNotification(msg.method, msg.params); }
|
|
123
|
+
catch (err) { this._onError(err); }
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this._onError(new Error('acp: unknown message shape'));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_safeWrite(payload) {
|
|
130
|
+
try { this._stdin.write(JSON.stringify(payload) + '\n'); }
|
|
131
|
+
catch (err) { this._onError(err); }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default AcpClient;
|
package/providers/base.js
CHANGED
|
@@ -15,9 +15,33 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @typedef {Object} ChatProvider
|
|
17
17
|
* @property {string} name
|
|
18
|
+
* @property {ProviderCapabilities} capabilities
|
|
19
|
+
* Static feature flags the provider supports. UI uses these to decide which
|
|
20
|
+
* header buttons / panels to show, rather than string-matching provider names.
|
|
18
21
|
* @property {(opts: StartOpts) => Promise<Object>} start
|
|
19
22
|
* @property {(state: Object, prompt: string, opts?: Object) => Promise<void>} sendInput
|
|
20
23
|
* @property {(state: Object) => void} abort
|
|
24
|
+
* @property {(state: Object) => Promise<void>} [clear]
|
|
25
|
+
* Optional. Reset in-flight conversation state (e.g. start a new ACP session
|
|
26
|
+
* under the same conversationId). If not provided, the frontend falls back to
|
|
27
|
+
* a client-side message wipe only.
|
|
28
|
+
* @property {() => Promise<FolderInfo[]>} listFolders
|
|
29
|
+
* Return the list of work-directories that this provider has sessions for.
|
|
30
|
+
* @property {(workDir: string) => Promise<SessionInfo[]>} listSessions
|
|
31
|
+
* Return resumable sessions for a given work-directory.
|
|
32
|
+
* @property {(workDir: string, sessionId: string, limit?: number) => Promise<HistoryMessage[]>} loadHistory
|
|
33
|
+
* Return the resumable transcript as an array of `claude_output`-compatible
|
|
34
|
+
* envelopes (the same shape the live stream would have produced).
|
|
35
|
+
*
|
|
36
|
+
* @typedef {Object} ProviderCapabilities
|
|
37
|
+
* @property {boolean} [compact] provider supports /compact (auto + manual)
|
|
38
|
+
* @property {boolean} [clear] provider supports in-place /clear
|
|
39
|
+
* @property {boolean} [expert] provider supports the expert panel / subagent injection
|
|
40
|
+
* @property {boolean} [mcp] provider exposes MCP server toggles per conversation
|
|
41
|
+
* @property {boolean} [subagents] provider drives subagent watcher events
|
|
42
|
+
* @property {boolean} [attachments] provider accepts file / image attachments in prompts
|
|
43
|
+
* @property {boolean} [askUser] provider supports the round-trip ask-user permission prompt
|
|
44
|
+
* @property {boolean} [modelPicker] provider supports switching model from the UI
|
|
21
45
|
*
|
|
22
46
|
* @typedef {Object} StartOpts
|
|
23
47
|
* @property {string} conversationId
|
|
@@ -25,6 +49,23 @@
|
|
|
25
49
|
* @property {string|null} [resumeSessionId]
|
|
26
50
|
* @property {string} [userId]
|
|
27
51
|
* @property {string} [username]
|
|
52
|
+
* @property {Object} [providerOptions] per-provider knobs (model, allowAllTools, ...)
|
|
53
|
+
*
|
|
54
|
+
* @typedef {Object} FolderInfo
|
|
55
|
+
* @property {string} name opaque folder identifier (provider-specific)
|
|
56
|
+
* @property {string} path original cwd path
|
|
57
|
+
* @property {number} sessionCount
|
|
58
|
+
* @property {number} lastModified epoch ms
|
|
59
|
+
*
|
|
60
|
+
* @typedef {Object} SessionInfo
|
|
61
|
+
* @property {string} sessionId
|
|
62
|
+
* @property {string} workDir
|
|
63
|
+
* @property {string} title
|
|
64
|
+
* @property {string} [preview]
|
|
65
|
+
* @property {number} lastModified
|
|
66
|
+
* @property {number} [size]
|
|
67
|
+
*
|
|
68
|
+
* @typedef {Object} HistoryMessage a single claude_output `data` envelope
|
|
28
69
|
*/
|
|
29
70
|
|
|
30
71
|
export const PROVIDER_NAMES = Object.freeze(['claude-code', 'copilot']);
|
package/providers/claude-code.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import { startClaudeQuery } from '../claude.js';
|
|
4
|
+
import {
|
|
5
|
+
getClaudeProjectsDir,
|
|
6
|
+
getHistorySessions,
|
|
7
|
+
loadSessionHistory,
|
|
8
|
+
getWorkDirFromProjectFolder,
|
|
9
|
+
} from '../history.js';
|
|
2
10
|
|
|
3
11
|
export const name = 'claude-code';
|
|
4
12
|
|
|
13
|
+
export const capabilities = Object.freeze({
|
|
14
|
+
compact: true,
|
|
15
|
+
clear: true,
|
|
16
|
+
expert: true,
|
|
17
|
+
mcp: true,
|
|
18
|
+
subagents: true,
|
|
19
|
+
attachments: true,
|
|
20
|
+
askUser: true,
|
|
21
|
+
modelPicker: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
5
24
|
/**
|
|
6
25
|
* Start (or resume) a Claude Code CLI session.
|
|
7
26
|
* Returns the same state object that startClaudeQuery stores in ctx.conversations.
|
|
@@ -31,4 +50,46 @@ export function abort(state) {
|
|
|
31
50
|
}
|
|
32
51
|
}
|
|
33
52
|
|
|
34
|
-
|
|
53
|
+
// ---------- history surface ----------
|
|
54
|
+
|
|
55
|
+
export async function listFolders() {
|
|
56
|
+
const projectsDir = getClaudeProjectsDir();
|
|
57
|
+
const folders = [];
|
|
58
|
+
if (!existsSync(projectsDir)) return folders;
|
|
59
|
+
|
|
60
|
+
for (const entry of readdirSync(projectsDir)) {
|
|
61
|
+
const entryPath = join(projectsDir, entry);
|
|
62
|
+
let stats;
|
|
63
|
+
try { stats = statSync(entryPath); } catch { continue; }
|
|
64
|
+
if (!stats.isDirectory()) continue;
|
|
65
|
+
if (entry.includes('--crew-roles-')) continue;
|
|
66
|
+
|
|
67
|
+
const originalPath = getWorkDirFromProjectFolder(entryPath, entry);
|
|
68
|
+
let sessionCount = 0;
|
|
69
|
+
let lastModified = stats.mtime.getTime();
|
|
70
|
+
try {
|
|
71
|
+
for (const file of readdirSync(entryPath)) {
|
|
72
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
73
|
+
sessionCount++;
|
|
74
|
+
try {
|
|
75
|
+
const fs = statSync(join(entryPath, file));
|
|
76
|
+
if (fs.mtime.getTime() > lastModified) lastModified = fs.mtime.getTime();
|
|
77
|
+
} catch { /* noop */ }
|
|
78
|
+
}
|
|
79
|
+
} catch { /* noop */ }
|
|
80
|
+
|
|
81
|
+
folders.push({ name: entry, path: originalPath, sessionCount, lastModified });
|
|
82
|
+
}
|
|
83
|
+
folders.sort((a, b) => b.lastModified - a.lastModified);
|
|
84
|
+
return folders;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function listSessions(workDir) {
|
|
88
|
+
return await getHistorySessions(workDir);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function loadHistory(workDir, sessionId, limit = 500) {
|
|
92
|
+
return loadSessionHistory(workDir, sessionId, limit);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default { name, capabilities, start, sendInput, abort, listFolders, listSessions, loadHistory };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard-coded known Copilot CLI model IDs. The CLI accepts `--model <id>` but
|
|
3
|
+
* doesn't expose a list over ACP today, so we ship a curated set that maps
|
|
4
|
+
* to what `copilot /model` shows in interactive mode. Add to this list as
|
|
5
|
+
* Copilot publishes new models — no other code change required.
|
|
6
|
+
*/
|
|
7
|
+
export const COPILOT_MODELS = Object.freeze([
|
|
8
|
+
{ id: 'gpt-5', label: 'GPT-5' },
|
|
9
|
+
{ id: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
|
10
|
+
{ id: 'claude-sonnet-4', label: 'Claude Sonnet 4' },
|
|
11
|
+
{ id: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
|
|
12
|
+
{ id: 'claude-opus-4', label: 'Claude Opus 4' },
|
|
13
|
+
{ id: 'gpt-4o', label: 'GPT-4o' },
|
|
14
|
+
{ id: 'o1', label: 'o1' },
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_COPILOT_MODEL = 'claude-sonnet-4.5';
|