@yeaft/webchat-agent 0.1.860 → 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 +57 -2
- package/package.json +1 -1
- package/providers/acp-client.js +135 -0
- package/providers/base.js +17 -0
- package/providers/claude-code.js +12 -1
- package/providers/copilot-models.js +17 -0
- package/providers/copilot.js +484 -185
- 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
package/providers/copilot.js
CHANGED
|
@@ -5,20 +5,34 @@ import { homedir } from 'os';
|
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
7
|
import ctx from '../context.js';
|
|
8
|
+
import { AcpClient } from './acp-client.js';
|
|
9
|
+
import { COPILOT_MODELS, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
|
|
8
10
|
|
|
9
11
|
export const name = 'copilot';
|
|
10
12
|
|
|
13
|
+
export const capabilities = Object.freeze({
|
|
14
|
+
compact: false, // TODO: probe ACP for /compact equivalent
|
|
15
|
+
clear: true, // session/new gives us a fresh transcript
|
|
16
|
+
expert: false, // Copilot has /fleet, different model
|
|
17
|
+
mcp: true, // ACP advertises mcpCapabilities at init
|
|
18
|
+
subagents: false,
|
|
19
|
+
attachments: true, // ACP promptCapabilities.image + embeddedContext
|
|
20
|
+
askUser: true, // session/request_permission round-trip
|
|
21
|
+
modelPicker: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
11
24
|
const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
|
|
12
|
-
//
|
|
13
|
-
// multi-tenant agent. Set COPILOT_YOLO=1 (and only if you know what you're
|
|
14
|
-
// doing) to skip Copilot's tool prompts.
|
|
25
|
+
// YOLO is now opt-in only; per-conv allowAllTools is the normal channel.
|
|
15
26
|
const YOLO = process.env.COPILOT_YOLO === '1';
|
|
27
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
16
28
|
|
|
17
29
|
/**
|
|
18
|
-
* Start (or resume) a Copilot session.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
30
|
+
* Start (or resume) a Copilot ACP session.
|
|
31
|
+
*
|
|
32
|
+
* Spawns one persistent `copilot --acp` child per conversation and runs the
|
|
33
|
+
* ACP handshake: initialize → session/new (or session/load). The child stays
|
|
34
|
+
* alive for the conversation's lifetime; each turn is a `session/prompt`
|
|
35
|
+
* JSONRPC request, not a fresh process.
|
|
22
36
|
*/
|
|
23
37
|
export async function start(opts) {
|
|
24
38
|
const conversationId = opts.conversationId;
|
|
@@ -27,83 +41,82 @@ export async function start(opts) {
|
|
|
27
41
|
if (prior?.copilotChild) {
|
|
28
42
|
try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
29
43
|
}
|
|
44
|
+
if (prior?.acpClient) {
|
|
45
|
+
try { prior.acpClient.close('replaced'); } catch { /* noop */ }
|
|
46
|
+
}
|
|
30
47
|
|
|
31
|
-
const sessionId = opts.resumeSessionId || randomUUID();
|
|
32
48
|
const providerOptions = opts.providerOptions || prior?.providerOptions || {};
|
|
49
|
+
const model = providerOptions.model || DEFAULT_COPILOT_MODEL;
|
|
50
|
+
const allowAllTools = YOLO || !!providerOptions.allowAllTools;
|
|
51
|
+
|
|
33
52
|
const state = {
|
|
34
53
|
providerName: name,
|
|
35
|
-
conversationId
|
|
54
|
+
conversationId,
|
|
36
55
|
query: null,
|
|
37
56
|
inputStream: null,
|
|
38
57
|
workDir: opts.workDir,
|
|
39
|
-
claudeSessionId:
|
|
40
|
-
sessionId,
|
|
58
|
+
claudeSessionId: opts.resumeSessionId || null, // set after session/new or session/load
|
|
59
|
+
sessionId: opts.resumeSessionId || null,
|
|
41
60
|
createdAt: prior?.createdAt || Date.now(),
|
|
42
61
|
abortController: null,
|
|
43
62
|
tools: [],
|
|
44
63
|
slashCommands: [],
|
|
45
|
-
model
|
|
64
|
+
model,
|
|
46
65
|
userId: opts.userId,
|
|
47
66
|
username: opts.username,
|
|
48
67
|
disallowedTools: prior?.disallowedTools || null,
|
|
49
68
|
copilotChild: null,
|
|
69
|
+
acpClient: null,
|
|
50
70
|
providerOptions,
|
|
71
|
+
allowAllTools,
|
|
72
|
+
capabilities,
|
|
73
|
+
initialized: false,
|
|
74
|
+
pendingPermissions: new Map(), // requestId → { resolve, reject } for ask-user round-trip
|
|
51
75
|
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
52
76
|
};
|
|
53
77
|
ctx.conversations.set(conversationId, state);
|
|
54
|
-
return state;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function sendInput(state, prompt, opts = {}) {
|
|
58
|
-
const conversationId = opts.conversationId || state.conversationId;
|
|
59
|
-
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
60
|
-
if (!state.sessionId) state.sessionId = randomUUID();
|
|
61
|
-
|
|
62
|
-
// Abort any in-flight turn.
|
|
63
|
-
if (state.copilotChild) {
|
|
64
|
-
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
65
|
-
state.copilotChild = null;
|
|
66
|
-
}
|
|
67
|
-
const abortController = new AbortController();
|
|
68
|
-
state.abortController = abortController;
|
|
69
|
-
state.turnActive = true;
|
|
70
|
-
state.turnResultReceived = false;
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (po.model) args.push('--model', String(po.model));
|
|
75
|
-
if (po.effort) args.push('--effort', String(po.effort));
|
|
76
|
-
if (Array.isArray(po.addDirs)) {
|
|
77
|
-
for (const d of po.addDirs) args.push('--add-dir', String(d));
|
|
78
|
-
}
|
|
79
|
-
// YOLO env var still wins as a global override; per-conv allowAllTools
|
|
80
|
-
// lets the user opt in from the UI without setting an env var.
|
|
81
|
-
if (YOLO || po.allowAllTools) args.push('--allow-all-tools');
|
|
82
|
-
|
|
83
|
-
let child;
|
|
79
|
+
// Best-effort start; failures emit a result envelope and leave state in place
|
|
80
|
+
// so the next sendInput retries.
|
|
84
81
|
try {
|
|
85
|
-
|
|
86
|
-
cwd: state.workDir,
|
|
87
|
-
env: process.env,
|
|
88
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
-
});
|
|
82
|
+
await _bootAcp(state, opts.resumeSessionId || null, model);
|
|
90
83
|
} catch (err) {
|
|
91
84
|
sendOutput(conversationId, {
|
|
92
85
|
type: 'result',
|
|
93
86
|
subtype: 'error',
|
|
94
87
|
session_id: state.sessionId,
|
|
95
88
|
is_error: true,
|
|
96
|
-
error: `copilot
|
|
89
|
+
error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
|
|
97
90
|
});
|
|
98
|
-
state.turnActive = false;
|
|
99
|
-
ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
|
|
100
|
-
return;
|
|
101
91
|
}
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function _bootAcp(state, resumeSessionId, model) {
|
|
96
|
+
const args = ['--acp'];
|
|
97
|
+
// ACP doesn't yet expose a per-session model param in its public schema, so
|
|
98
|
+
// pass --model at spawn for the lifetime of this child.
|
|
99
|
+
if (model) args.push('--model', String(model));
|
|
100
|
+
if (Array.isArray(state.providerOptions?.addDirs)) {
|
|
101
|
+
for (const d of state.providerOptions.addDirs) args.push('--add-dir', String(d));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const child = spawn(COPILOT_BIN, args, {
|
|
105
|
+
cwd: state.workDir,
|
|
106
|
+
env: process.env,
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
|
+
});
|
|
102
109
|
state.copilotChild = child;
|
|
103
110
|
|
|
104
|
-
|
|
111
|
+
let stderrBuf = '';
|
|
112
|
+
const STDERR_CAP = 64 * 1024;
|
|
113
|
+
child.stderr.on('data', (chunk) => {
|
|
114
|
+
if (stderrBuf.length < STDERR_CAP) {
|
|
115
|
+
stderrBuf += chunk.toString('utf8').slice(0, STDERR_CAP - stderrBuf.length);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
105
118
|
child.on('error', (err) => {
|
|
106
|
-
sendOutput(conversationId, {
|
|
119
|
+
sendOutput(state.conversationId, {
|
|
107
120
|
type: 'result',
|
|
108
121
|
subtype: 'error',
|
|
109
122
|
session_id: state.sessionId,
|
|
@@ -111,69 +124,236 @@ export async function sendInput(state, prompt, opts = {}) {
|
|
|
111
124
|
error: `copilot process error: ${err?.message || err}`,
|
|
112
125
|
});
|
|
113
126
|
});
|
|
127
|
+
child.on('close', (code) => {
|
|
128
|
+
if (state.turnActive) {
|
|
129
|
+
const tail = stderrBuf.trim().slice(-2000);
|
|
130
|
+
sendOutput(state.conversationId, {
|
|
131
|
+
type: 'result',
|
|
132
|
+
subtype: 'error',
|
|
133
|
+
session_id: state.sessionId,
|
|
134
|
+
is_error: true,
|
|
135
|
+
error: tail || `copilot exited mid-turn (code ${code})`,
|
|
136
|
+
});
|
|
137
|
+
ctx.sendToServer({
|
|
138
|
+
type: 'turn_completed',
|
|
139
|
+
conversationId: state.conversationId,
|
|
140
|
+
claudeSessionId: state.sessionId,
|
|
141
|
+
workDir: state.workDir,
|
|
142
|
+
});
|
|
143
|
+
state.turnActive = false;
|
|
144
|
+
}
|
|
145
|
+
// Drain any in-flight permission prompts so the frontend dialog unwedges
|
|
146
|
+
// and the Promise GC roots release.
|
|
147
|
+
_drainPendingPermissions(state, 'child closed');
|
|
148
|
+
state.copilotChild = null;
|
|
149
|
+
state.acpClient = null;
|
|
150
|
+
state.initialized = false;
|
|
151
|
+
});
|
|
114
152
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
},
|
|
153
|
+
const client = new AcpClient({
|
|
154
|
+
stdin: child.stdin,
|
|
155
|
+
stdout: child.stdout,
|
|
156
|
+
onNotification: (method, params) => _handleAcpNotification(state, method, params),
|
|
157
|
+
onRequest: (method, params) => _handleAcpRequest(state, method, params),
|
|
158
|
+
onError: (err) => {
|
|
159
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] acp transport:', err?.message || err);
|
|
160
|
+
},
|
|
123
161
|
});
|
|
162
|
+
state.acpClient = client;
|
|
124
163
|
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
164
|
+
// 1) initialize
|
|
165
|
+
const initResp = await client.request('initialize', {
|
|
166
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
167
|
+
clientCapabilities: {},
|
|
168
|
+
});
|
|
169
|
+
state.acpCapabilities = initResp?.agentCapabilities || {};
|
|
170
|
+
state.initialized = true;
|
|
128
171
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
// 2) session/new or session/load
|
|
173
|
+
if (resumeSessionId && state.acpCapabilities.loadSession) {
|
|
174
|
+
const r = await client.request('session/load', {
|
|
175
|
+
sessionId: resumeSessionId,
|
|
176
|
+
cwd: state.workDir,
|
|
177
|
+
mcpServers: [],
|
|
178
|
+
});
|
|
179
|
+
state.sessionId = resumeSessionId;
|
|
180
|
+
state.claudeSessionId = resumeSessionId;
|
|
181
|
+
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
182
|
+
} else {
|
|
183
|
+
if (resumeSessionId && !state.acpCapabilities.loadSession) {
|
|
184
|
+
// Surface the downgrade — silently handing back a fresh session would
|
|
185
|
+
// confuse a user who asked to resume.
|
|
186
|
+
sendOutput(state.conversationId, {
|
|
187
|
+
type: 'system',
|
|
188
|
+
subtype: 'info',
|
|
189
|
+
message: 'Copilot CLI does not advertise loadSession capability — starting a new session instead of resuming.',
|
|
190
|
+
});
|
|
134
191
|
}
|
|
192
|
+
const r = await client.request('session/new', {
|
|
193
|
+
cwd: state.workDir,
|
|
194
|
+
mcpServers: [],
|
|
195
|
+
});
|
|
196
|
+
state.sessionId = r?.sessionId || randomUUID();
|
|
197
|
+
state.claudeSessionId = state.sessionId;
|
|
198
|
+
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3) Emit a system_init envelope so the UI populates tools / model panels.
|
|
202
|
+
// Copilot's built-in toolset is not enumerated over ACP today; advertise the
|
|
203
|
+
// well-known core set so the panel isn't empty.
|
|
204
|
+
state.tools = _knownCopilotTools();
|
|
205
|
+
sendOutput(state.conversationId, {
|
|
206
|
+
type: 'system',
|
|
207
|
+
subtype: 'init',
|
|
208
|
+
session_id: state.sessionId,
|
|
209
|
+
model: state.model,
|
|
210
|
+
tools: state.tools,
|
|
211
|
+
mcp_servers: [],
|
|
212
|
+
permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
|
|
135
213
|
});
|
|
214
|
+
}
|
|
136
215
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
216
|
+
export async function sendInput(state, prompt, opts = {}) {
|
|
217
|
+
const conversationId = opts.conversationId || state.conversationId;
|
|
218
|
+
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
219
|
+
|
|
220
|
+
// Ensure ACP child + session are up; reboot if a prior crash dropped them.
|
|
221
|
+
if (!state.initialized || !state.acpClient) {
|
|
222
|
+
try {
|
|
223
|
+
await _bootAcp(state, state.sessionId || null, state.model);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
sendOutput(conversationId, {
|
|
226
|
+
type: 'result',
|
|
227
|
+
subtype: 'error',
|
|
228
|
+
session_id: state.sessionId,
|
|
229
|
+
is_error: true,
|
|
230
|
+
error: `copilot ACP reinit failed: ${err?.message || err}`,
|
|
231
|
+
});
|
|
232
|
+
ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
|
|
233
|
+
return;
|
|
141
234
|
}
|
|
142
|
-
}
|
|
235
|
+
}
|
|
143
236
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
237
|
+
// Per-turn provider option overrides (e.g. updated model). If the model
|
|
238
|
+
// changed we don't restart the child; ACP doesn't expose model switch yet,
|
|
239
|
+
// so we leave a warning rather than silently drop the request.
|
|
240
|
+
const po = { ...(state.providerOptions || {}), ...(opts.providerOptions || {}) };
|
|
241
|
+
if (po.model && po.model !== state.model) {
|
|
242
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] mid-conversation model switch not supported; ignoring');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const abortController = new AbortController();
|
|
246
|
+
state.abortController = abortController;
|
|
247
|
+
state.turnActive = true;
|
|
248
|
+
state.turnResultReceived = false;
|
|
249
|
+
|
|
250
|
+
// Build prompt content blocks. ACP ContentBlock variants: text, image,
|
|
251
|
+
// audio, resource, resource_link. Web attachments arrive on `opts.attachments`
|
|
252
|
+
// (existing wire shape: [{type:'image', data, mimeType} | {type:'text', text}]).
|
|
253
|
+
const promptBlocks = [{ type: 'text', text: String(prompt ?? '') }];
|
|
254
|
+
if (Array.isArray(opts.attachments)) {
|
|
255
|
+
for (const a of opts.attachments) {
|
|
256
|
+
if (!a) continue;
|
|
257
|
+
if (a.type === 'image' && a.data) {
|
|
258
|
+
promptBlocks.push({ type: 'image', data: a.data, mimeType: a.mimeType || 'image/png' });
|
|
259
|
+
} else if (a.type === 'text' && a.text) {
|
|
260
|
+
promptBlocks.push({ type: 'text', text: String(a.text) });
|
|
261
|
+
} else if (typeof a === 'string') {
|
|
262
|
+
promptBlocks.push({ type: 'text', text: a });
|
|
157
263
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
resolve();
|
|
167
|
-
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
abortController.signal.addEventListener('abort', () => {
|
|
268
|
+
if (state.acpClient && state.sessionId) {
|
|
269
|
+
try { state.acpClient.notify('session/cancel', { sessionId: state.sessionId }); }
|
|
270
|
+
catch { /* noop */ }
|
|
271
|
+
}
|
|
168
272
|
});
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const resp = await state.acpClient.request('session/prompt', {
|
|
276
|
+
sessionId: state.sessionId,
|
|
277
|
+
prompt: promptBlocks,
|
|
278
|
+
});
|
|
279
|
+
const stopReason = resp?.stopReason || 'end_turn';
|
|
280
|
+
const isErr = stopReason === 'refusal' || stopReason === 'error';
|
|
281
|
+
sendOutput(conversationId, {
|
|
282
|
+
type: 'result',
|
|
283
|
+
subtype: isErr ? 'error' : 'success',
|
|
284
|
+
session_id: state.sessionId,
|
|
285
|
+
stop_reason: stopReason,
|
|
286
|
+
is_error: isErr,
|
|
287
|
+
error: isErr ? `copilot stop_reason=${stopReason}` : undefined,
|
|
288
|
+
});
|
|
289
|
+
} catch (err) {
|
|
290
|
+
sendOutput(conversationId, {
|
|
291
|
+
type: 'result',
|
|
292
|
+
subtype: 'error',
|
|
293
|
+
session_id: state.sessionId,
|
|
294
|
+
is_error: true,
|
|
295
|
+
error: err?.message || String(err),
|
|
296
|
+
});
|
|
297
|
+
} finally {
|
|
298
|
+
state.turnActive = false;
|
|
299
|
+
ctx.sendToServer({
|
|
300
|
+
type: 'turn_completed',
|
|
301
|
+
conversationId,
|
|
302
|
+
claudeSessionId: state.sessionId,
|
|
303
|
+
workDir: state.workDir,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
169
306
|
}
|
|
170
307
|
|
|
171
308
|
export function abort(state) {
|
|
172
309
|
if (state?.abortController) {
|
|
173
310
|
try { state.abortController.abort(); } catch { /* noop */ }
|
|
174
311
|
}
|
|
175
|
-
if (state?.
|
|
176
|
-
try { state.
|
|
312
|
+
if (state?.acpClient && state.sessionId) {
|
|
313
|
+
try { state.acpClient.notify('session/cancel', { sessionId: state.sessionId }); }
|
|
314
|
+
catch { /* noop */ }
|
|
315
|
+
}
|
|
316
|
+
_drainPendingPermissions(state, 'aborted');
|
|
317
|
+
// Fallback: if Copilot ignores session/cancel and the prompt never resolves,
|
|
318
|
+
// SIGTERM the child after a grace period — the close handler will then
|
|
319
|
+
// synthesize the result envelope + turn_completed.
|
|
320
|
+
if (state?.copilotChild && !state._abortKillTimer) {
|
|
321
|
+
state._abortKillTimer = setTimeout(() => {
|
|
322
|
+
state._abortKillTimer = null;
|
|
323
|
+
if (state.turnActive && state.copilotChild) {
|
|
324
|
+
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
325
|
+
}
|
|
326
|
+
}, 10000);
|
|
327
|
+
state._abortKillTimer.unref?.();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* /clear support: ask ACP for a brand-new session under the same
|
|
333
|
+
* conversationId. Keeps the child alive — no spawn cost.
|
|
334
|
+
*/
|
|
335
|
+
export async function clear(state) {
|
|
336
|
+
if (!state?.acpClient) return;
|
|
337
|
+
// A fresh session invalidates any in-flight permission prompts.
|
|
338
|
+
_drainPendingPermissions(state, 'session cleared');
|
|
339
|
+
try {
|
|
340
|
+
const r = await state.acpClient.request('session/new', {
|
|
341
|
+
cwd: state.workDir,
|
|
342
|
+
mcpServers: [],
|
|
343
|
+
});
|
|
344
|
+
state.sessionId = r?.sessionId || randomUUID();
|
|
345
|
+
state.claudeSessionId = state.sessionId;
|
|
346
|
+
sendOutput(state.conversationId, {
|
|
347
|
+
type: 'system',
|
|
348
|
+
subtype: 'init',
|
|
349
|
+
session_id: state.sessionId,
|
|
350
|
+
model: state.model,
|
|
351
|
+
tools: state.tools,
|
|
352
|
+
mcp_servers: [],
|
|
353
|
+
permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] clear failed:', err?.message || err);
|
|
177
357
|
}
|
|
178
358
|
}
|
|
179
359
|
|
|
@@ -183,113 +363,232 @@ function sendOutput(conversationId, data) {
|
|
|
183
363
|
ctx.sendToServer({ type: 'claude_output', conversationId, data });
|
|
184
364
|
}
|
|
185
365
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
366
|
+
function _handleAcpNotification(state, method, params) {
|
|
367
|
+
if (method === 'session/update') {
|
|
368
|
+
_handleSessionUpdate(state, params);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] unknown ACP notification:', method);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function _handleAcpRequest(state, method, params) {
|
|
375
|
+
if (method === 'session/request_permission') {
|
|
376
|
+
return _handlePermissionRequest(state, params);
|
|
377
|
+
}
|
|
378
|
+
// fs/read_text_file, fs/write_text_file, terminal/* — Copilot's agent
|
|
379
|
+
// doesn't need them because it runs its own tools, but answer politely
|
|
380
|
+
// to anything we don't implement.
|
|
381
|
+
throw Object.assign(new Error(`unsupported method: ${method}`), { code: -32601 });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function _handleSessionUpdate(state, params) {
|
|
385
|
+
if (!params || !params.sessionId || params.sessionId !== state.sessionId) {
|
|
386
|
+
// Stale update from a prior session; ignore.
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const upd = params.update || params;
|
|
390
|
+
const kind = upd.sessionUpdate;
|
|
391
|
+
switch (kind) {
|
|
392
|
+
case 'agent_message_chunk': {
|
|
393
|
+
const text = _extractText(upd.content);
|
|
394
|
+
if (!text) return;
|
|
395
|
+
sendOutput(state.conversationId, {
|
|
396
|
+
type: 'assistant',
|
|
397
|
+
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case 'agent_thought_chunk': {
|
|
402
|
+
const text = _extractText(upd.content);
|
|
403
|
+
if (!text) return;
|
|
404
|
+
sendOutput(state.conversationId, {
|
|
405
|
+
type: 'assistant',
|
|
406
|
+
message: { role: 'assistant', content: [{ type: 'thinking', thinking: text }] },
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
case 'user_message_chunk': {
|
|
411
|
+
// Echo of our own prompt — drop (frontend already shows it).
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
case 'tool_call': {
|
|
415
|
+
const id = upd.toolCallId || upd.id || randomUUID();
|
|
416
|
+
const toolName = upd.title || upd.kind || 'tool';
|
|
417
|
+
const input = upd.rawInput || upd.input || {};
|
|
418
|
+
sendOutput(state.conversationId, {
|
|
419
|
+
type: 'assistant',
|
|
420
|
+
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: toolName, input }] },
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
case 'tool_call_update': {
|
|
425
|
+
const id = upd.toolCallId || upd.id;
|
|
426
|
+
if (!id) return;
|
|
427
|
+
const status = upd.status;
|
|
428
|
+
// Only emit a tool_result when the call reaches a terminal state with
|
|
429
|
+
// some content/output; intermediate "in_progress" updates would render
|
|
430
|
+
// as duplicate empty results in the existing renderer.
|
|
431
|
+
const isTerminal = status === 'completed' || status === 'failed';
|
|
432
|
+
if (!isTerminal) return;
|
|
433
|
+
const text = _extractToolContent(upd.content) || (upd.rawOutput ? _stringify(upd.rawOutput) : '');
|
|
434
|
+
sendOutput(state.conversationId, {
|
|
435
|
+
type: 'user',
|
|
436
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, is_error: status === 'failed' }] },
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
case 'plan': {
|
|
441
|
+
// Optional: render as a todo-style assistant message. Keep it minimal.
|
|
442
|
+
const entries = Array.isArray(upd.entries) ? upd.entries : [];
|
|
443
|
+
if (!entries.length) return;
|
|
444
|
+
const text = entries.map(e => `- [${e.status === 'completed' ? 'x' : ' '}] ${e.content}`).join('\n');
|
|
445
|
+
sendOutput(state.conversationId, {
|
|
446
|
+
type: 'assistant',
|
|
447
|
+
message: { role: 'assistant', content: [{ type: 'text', text: `**Plan:**\n${text}` }] },
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
case 'available_commands_update': {
|
|
452
|
+
if (Array.isArray(upd.availableCommands)) {
|
|
453
|
+
state.slashCommands = upd.availableCommands.map(c => c.name || c).filter(Boolean);
|
|
204
454
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
default:
|
|
458
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] unhandled session update:', kind);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function _handlePermissionRequest(state, params) {
|
|
463
|
+
const opt = Array.isArray(params?.options) ? params.options : [];
|
|
464
|
+
// Defensive: ACP forbids empty options but if a buggy server sends one,
|
|
465
|
+
// reject the request rather than fabricating an optionId Copilot won't
|
|
466
|
+
// recognise.
|
|
467
|
+
if (opt.length === 0) {
|
|
468
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
469
|
+
}
|
|
470
|
+
// Auto-approve if the user enabled allowAllTools (or YOLO env).
|
|
471
|
+
if (state.allowAllTools) {
|
|
472
|
+
const allow = opt.find(o => o.kind === 'allow_always' || o.kind === 'allow_once') || opt[0];
|
|
473
|
+
return { outcome: { outcome: 'selected', optionId: allow.optionId } };
|
|
474
|
+
}
|
|
475
|
+
// Otherwise route through the existing ask-user wire path. We do it inline
|
|
476
|
+
// here using a per-state Promise; the frontend responds via the standard
|
|
477
|
+
// `ask_user_response` message which conversation.js routes back into the
|
|
478
|
+
// driver via `respondToPermissionRequest(state, requestId, optionId)`.
|
|
479
|
+
const requestId = `copilot-perm-${randomUUID()}`;
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
state.pendingPermissions.set(requestId, { resolve, options: opt });
|
|
482
|
+
ctx.sendToServer({
|
|
483
|
+
type: 'ask_user_question',
|
|
484
|
+
conversationId: state.conversationId,
|
|
485
|
+
requestId,
|
|
486
|
+
question: _formatPermissionPrompt(params),
|
|
487
|
+
options: opt.map(o => ({ id: o.optionId, label: o.name || o.optionId, kind: o.kind })),
|
|
488
|
+
});
|
|
489
|
+
});
|
|
216
490
|
}
|
|
217
491
|
|
|
218
492
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* Recognized loose schemas (Copilot CLI JSON output is not yet stable, so
|
|
223
|
-
* we accept several aliases and forward only what we understand):
|
|
224
|
-
* - text: { type: 'text'|'text_delta'|'assistant_text', text|delta }
|
|
225
|
-
* - message: { type: 'message', role, content }
|
|
226
|
-
* - tool_call: { type: 'tool_call'|'tool_use', id, name|tool, input|arguments }
|
|
227
|
-
* - tool_result: { type: 'tool_result', tool_use_id|id, content|output }
|
|
228
|
-
* - done: { type: 'result'|'done'|'complete', session_id?, error? }
|
|
229
|
-
* - error: { type: 'error', message|error }
|
|
493
|
+
* Drain every in-flight permission prompt with a "cancelled" outcome. Called
|
|
494
|
+
* on child close, abort, and clear so dangling Promises don't pin GC roots
|
|
495
|
+
* and the frontend ask-user dialog unwedges.
|
|
230
496
|
*/
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const text = typeof evt.text === 'string' ? evt.text : (typeof evt.delta === 'string' ? evt.delta : '');
|
|
237
|
-
if (!text) return [];
|
|
238
|
-
return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } }];
|
|
497
|
+
function _drainPendingPermissions(state, reason) {
|
|
498
|
+
const m = state?.pendingPermissions;
|
|
499
|
+
if (!m || m.size === 0) return;
|
|
500
|
+
for (const { resolve } of m.values()) {
|
|
501
|
+
try { resolve({ outcome: { outcome: 'cancelled' } }); } catch { /* noop */ }
|
|
239
502
|
}
|
|
503
|
+
m.clear();
|
|
504
|
+
if (ctx?.CONFIG?.debug) console.warn(`[copilot] drained pending permissions: ${reason}`);
|
|
505
|
+
}
|
|
240
506
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Called by conversation.js when the frontend posts an ask_user_response for
|
|
509
|
+
* a permission prompt we issued. Exported so the message router can hand it
|
|
510
|
+
* back without exposing the state internals.
|
|
511
|
+
*/
|
|
512
|
+
export function respondToPermissionRequest(state, requestId, optionId) {
|
|
513
|
+
const slot = state?.pendingPermissions?.get(requestId);
|
|
514
|
+
if (!slot) {
|
|
515
|
+
console.warn(`[copilot] respondToPermissionRequest: no pending permission for ${requestId}`);
|
|
516
|
+
return false;
|
|
244
517
|
}
|
|
518
|
+
state.pendingPermissions.delete(requestId);
|
|
519
|
+
const opt = slot.options.find(o => o.optionId === optionId) || slot.options[0];
|
|
520
|
+
slot.resolve({ outcome: { outcome: 'selected', optionId: opt?.optionId || optionId } });
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
245
523
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
524
|
+
function _formatPermissionPrompt(params) {
|
|
525
|
+
const tc = params?.toolCall || {};
|
|
526
|
+
const title = tc.title || tc.kind || 'tool';
|
|
527
|
+
const rawIn = tc.rawInput;
|
|
528
|
+
let suffix = '';
|
|
529
|
+
if (rawIn && typeof rawIn === 'object') {
|
|
530
|
+
try { suffix = '\n```\n' + JSON.stringify(rawIn, null, 2).slice(0, 600) + '\n```'; }
|
|
531
|
+
catch { /* noop */ }
|
|
251
532
|
}
|
|
533
|
+
return `Copilot wants to run \`${title}\`. Allow?${suffix}`;
|
|
534
|
+
}
|
|
252
535
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return
|
|
536
|
+
function _extractText(content) {
|
|
537
|
+
if (!content) return '';
|
|
538
|
+
if (typeof content === 'string') return content;
|
|
539
|
+
if (content.type === 'text' && typeof content.text === 'string') return content.text;
|
|
540
|
+
if (Array.isArray(content)) {
|
|
541
|
+
return content.map(c => (c?.type === 'text' ? c.text : '')).filter(Boolean).join('');
|
|
259
542
|
}
|
|
543
|
+
return '';
|
|
544
|
+
}
|
|
260
545
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
546
|
+
function _extractToolContent(content) {
|
|
547
|
+
if (!content) return '';
|
|
548
|
+
if (Array.isArray(content)) {
|
|
549
|
+
const parts = [];
|
|
550
|
+
for (const c of content) {
|
|
551
|
+
if (!c) continue;
|
|
552
|
+
// ToolCallContent variants: content (with ContentBlock), diff
|
|
553
|
+
if (c.type === 'content' && c.content) parts.push(_extractText(c.content));
|
|
554
|
+
else if (c.type === 'diff') parts.push(`diff: ${c.path || ''}\n${c.newText || ''}`);
|
|
555
|
+
else if (typeof c === 'string') parts.push(c);
|
|
556
|
+
else parts.push(_stringify(c));
|
|
557
|
+
}
|
|
558
|
+
return parts.filter(Boolean).join('\n');
|
|
270
559
|
}
|
|
560
|
+
return _stringify(content);
|
|
561
|
+
}
|
|
271
562
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
is_error: true,
|
|
278
|
-
error: evt.message || evt.error || 'copilot error',
|
|
279
|
-
}];
|
|
280
|
-
}
|
|
563
|
+
function _stringify(v) {
|
|
564
|
+
if (v == null) return '';
|
|
565
|
+
if (typeof v === 'string') return v;
|
|
566
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
567
|
+
}
|
|
281
568
|
|
|
282
|
-
|
|
283
|
-
|
|
569
|
+
function _knownCopilotTools() {
|
|
570
|
+
// Best-effort static list (Copilot doesn't expose its toolset over ACP).
|
|
571
|
+
return [
|
|
572
|
+
{ name: 'bash', description: 'Execute shell commands' },
|
|
573
|
+
{ name: 'read', description: 'Read file contents' },
|
|
574
|
+
{ name: 'write', description: 'Write to a file' },
|
|
575
|
+
{ name: 'edit', description: 'Edit a file in place' },
|
|
576
|
+
{ name: 'grep', description: 'Search file contents' },
|
|
577
|
+
{ name: 'glob', description: 'Find files by glob' },
|
|
578
|
+
{ name: 'list_dir', description: 'List directory contents' },
|
|
579
|
+
{ name: 'web_fetch', description: 'Fetch URL contents' },
|
|
580
|
+
{ name: 'web_search', description: 'Search the web' },
|
|
581
|
+
{ name: 'ask_user', description: 'Ask the user a question' },
|
|
582
|
+
];
|
|
284
583
|
}
|
|
285
584
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return [{ type: 'text', text: String(content ?? '') }];
|
|
585
|
+
/** Exported for the model picker UI. */
|
|
586
|
+
export function listModels() {
|
|
587
|
+
return COPILOT_MODELS.slice();
|
|
290
588
|
}
|
|
291
589
|
|
|
292
|
-
export default { name, start, sendInput, abort, listFolders, listSessions, loadHistory };
|
|
590
|
+
export default { name, capabilities, start, sendInput, abort, clear, listFolders, listSessions, loadHistory, listModels, respondToPermissionRequest };
|
|
591
|
+
|
|
293
592
|
|
|
294
593
|
// ---------- history surface (reads ~/.copilot/session-store.db) ----------
|
|
295
594
|
|