@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.
@@ -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
- const historyMessages = loadSessionHistory(effectiveWorkDir, claudeSessionId);
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
- if (state.abortController) {
661
- state.abortController.abort();
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 sessions = await getHistorySessions(effectiveWorkDir);
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
- // 列出 Claude projects 目录下的所有 folder (工作目录)
221
+ // 列出指定 provider 下所有 folder (工作目录)
215
222
  export async function handleListFolders(msg) {
216
- const { requestId, _requestClientId } = msg;
217
- const projectsDir = getClaudeProjectsDir();
223
+ const { requestId, _requestClientId, provider } = msg;
224
+ const providerName = provider || DEFAULT_PROVIDER;
218
225
 
219
- console.log(`Listing folders from: ${projectsDir}`);
226
+ console.log(`Listing folders for provider=${providerName}`);
220
227
 
221
228
  try {
222
- const folders = [];
223
-
224
- if (existsSync(projectsDir)) {
225
- const entries = readdirSync(projectsDir);
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
- folders.sort((a, b) => b.lastModified - a.lastModified);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.859",
3
+ "version": "0.1.863",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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']);
@@ -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
- export default { name, start, sendInput, abort };
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';