@yeaft/webchat-agent 1.0.42 → 1.0.44
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/conversation.js +10 -0
- package/package.json +1 -1
- package/providers/acp-client.js +25 -4
- package/providers/copilot.js +83 -15
- package/yeaft/engine.js +2 -2
package/conversation.js
CHANGED
|
@@ -369,6 +369,11 @@ export async function createConversation(msg) {
|
|
|
369
369
|
});
|
|
370
370
|
} else {
|
|
371
371
|
// Non-Claude providers own their own state construction.
|
|
372
|
+
// deferBoot: don't block conversation_created on the provider's slow boot
|
|
373
|
+
// (Copilot's ACP handshake spawns a CLI and can take tens of seconds). The
|
|
374
|
+
// sidebar row must appear immediately; boot runs in the background and the
|
|
375
|
+
// first sendInput joins the same in-flight boot. Mirrors the Claude path,
|
|
376
|
+
// which prestarts in the background below.
|
|
372
377
|
const driver = getProvider(provider);
|
|
373
378
|
const state = await driver.start({
|
|
374
379
|
conversationId,
|
|
@@ -377,6 +382,7 @@ export async function createConversation(msg) {
|
|
|
377
382
|
userId,
|
|
378
383
|
username,
|
|
379
384
|
providerOptions: msg.providerOptions || {},
|
|
385
|
+
deferBoot: true,
|
|
380
386
|
});
|
|
381
387
|
state.disallowedTools = disallowedTools || null;
|
|
382
388
|
}
|
|
@@ -495,6 +501,9 @@ export async function resumeConversation(msg) {
|
|
|
495
501
|
// Non-Claude providers: re-init state via driver so sessionId/providerName are set correctly.
|
|
496
502
|
if (provider !== 'claude-code') {
|
|
497
503
|
const driver = getProvider(provider);
|
|
504
|
+
// deferBoot: same reasoning as createConversation — emit conversation_resumed
|
|
505
|
+
// immediately so the row shows, and boot the provider session in the
|
|
506
|
+
// background instead of blocking restore on the ACP handshake.
|
|
498
507
|
const state = await driver.start({
|
|
499
508
|
conversationId,
|
|
500
509
|
workDir: effectiveWorkDir,
|
|
@@ -502,6 +511,7 @@ export async function resumeConversation(msg) {
|
|
|
502
511
|
userId,
|
|
503
512
|
username,
|
|
504
513
|
providerOptions: msg.providerOptions || priorProviderOptions || {},
|
|
514
|
+
deferBoot: true,
|
|
505
515
|
});
|
|
506
516
|
state.disallowedTools = disallowedTools || null;
|
|
507
517
|
}
|
package/package.json
CHANGED
package/providers/acp-client.js
CHANGED
|
@@ -38,17 +38,36 @@ export class AcpClient {
|
|
|
38
38
|
stdout.on('close', () => this._handleClose());
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Send a JSONRPC request and await its response.
|
|
43
|
+
* @param {string} method
|
|
44
|
+
* @param {any} params
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {number} [opts.timeoutMs] reject if no response arrives within this
|
|
47
|
+
* window. Omit for no timeout (e.g. session/prompt, which can legitimately
|
|
48
|
+
* run for minutes). Use it on the boot handshake so a wedged CLI surfaces an
|
|
49
|
+
* error instead of hanging the session forever.
|
|
50
|
+
*/
|
|
51
|
+
request(method, params, opts = {}) {
|
|
43
52
|
if (this._closed) return Promise.reject(new Error('acp client closed'));
|
|
44
53
|
const id = this._nextId++;
|
|
45
54
|
const payload = { jsonrpc: '2.0', id, method, params };
|
|
46
55
|
return new Promise((resolve, reject) => {
|
|
47
|
-
|
|
56
|
+
let timer = null;
|
|
57
|
+
if (opts.timeoutMs > 0) {
|
|
58
|
+
timer = setTimeout(() => {
|
|
59
|
+
if (!this._pending.has(id)) return;
|
|
60
|
+
this._pending.delete(id);
|
|
61
|
+
reject(new Error(`acp request '${method}' timed out after ${opts.timeoutMs}ms`));
|
|
62
|
+
}, opts.timeoutMs);
|
|
63
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
64
|
+
}
|
|
65
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
48
66
|
try {
|
|
49
67
|
this._stdin.write(JSON.stringify(payload) + '\n');
|
|
50
68
|
} catch (err) {
|
|
51
69
|
this._pending.delete(id);
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
52
71
|
reject(err);
|
|
53
72
|
}
|
|
54
73
|
});
|
|
@@ -67,7 +86,8 @@ export class AcpClient {
|
|
|
67
86
|
if (this._closed) return;
|
|
68
87
|
this._closed = true;
|
|
69
88
|
const err = new Error(reason || 'acp client closed');
|
|
70
|
-
for (const { reject } of this._pending.values()) {
|
|
89
|
+
for (const { reject, timer } of this._pending.values()) {
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
71
91
|
try { reject(err); } catch { /* noop */ }
|
|
72
92
|
}
|
|
73
93
|
this._pending.clear();
|
|
@@ -97,6 +117,7 @@ export class AcpClient {
|
|
|
97
117
|
const slot = this._pending.get(msg.id);
|
|
98
118
|
if (!slot) return; // stale
|
|
99
119
|
this._pending.delete(msg.id);
|
|
120
|
+
if (slot.timer) clearTimeout(slot.timer);
|
|
100
121
|
if (msg.error) slot.reject(Object.assign(new Error(msg.error.message || 'acp error'), { code: msg.error.code, data: msg.error.data }));
|
|
101
122
|
else slot.resolve(msg.result);
|
|
102
123
|
return;
|
package/providers/copilot.js
CHANGED
|
@@ -25,6 +25,11 @@ const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
|
|
|
25
25
|
// YOLO is now opt-in only; per-conv allowAllTools is the normal channel.
|
|
26
26
|
const YOLO = process.env.COPILOT_YOLO === '1';
|
|
27
27
|
const ACP_PROTOCOL_VERSION = 1;
|
|
28
|
+
// Boot handshake (initialize / session/new / session/load) timeout. A wedged or
|
|
29
|
+
// mis-installed CLI would otherwise leave the request pending forever — and with
|
|
30
|
+
// deferred boot that means a session that never finishes connecting. session/prompt
|
|
31
|
+
// is deliberately NOT bounded: a turn can legitimately run for minutes.
|
|
32
|
+
const BOOT_REQUEST_TIMEOUT_MS = 120_000;
|
|
28
33
|
|
|
29
34
|
export function resolveCopilotLaunchOptions({ cwd, env = process.env, platform = osPlatform(), bin = COPILOT_BIN } = {}) {
|
|
30
35
|
const isWindows = platform === 'win32';
|
|
@@ -78,27 +83,80 @@ export async function start(opts) {
|
|
|
78
83
|
allowAllTools,
|
|
79
84
|
capabilities,
|
|
80
85
|
initialized: false,
|
|
86
|
+
_bootPromise: null, // in-flight _bootAcp() promise; coalesces concurrent boots
|
|
81
87
|
pendingPermissions: new Map(), // requestId → { resolve, reject } for ask-user round-trip
|
|
82
88
|
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
83
89
|
};
|
|
84
90
|
ctx.conversations.set(conversationId, state);
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
const resumeSessionId = opts.resumeSessionId || null;
|
|
93
|
+
|
|
94
|
+
// deferBoot: return immediately without awaiting the ACP handshake. The slow
|
|
95
|
+
// part — spawn `copilot --acp` → initialize → session/new — would otherwise
|
|
96
|
+
// block the caller (session create/resume) from emitting conversation_created,
|
|
97
|
+
// so the sidebar row only appears after the whole handshake (~tens of seconds
|
|
98
|
+
// on a cold CLI). With deferBoot the row shows instantly and the boot runs in
|
|
99
|
+
// the background; the first sendInput joins the same in-flight boot via
|
|
100
|
+
// _ensureBooted instead of spawning a second child.
|
|
101
|
+
if (opts.deferBoot) {
|
|
102
|
+
_ensureBooted(state, resumeSessionId).catch((err) => {
|
|
103
|
+
// _emitBootError writes to the WebSocket; if that itself throws (server
|
|
104
|
+
// gone) there's nothing more to do — don't turn it into an unhandled rejection.
|
|
105
|
+
try { _emitBootError(state, err); } catch { /* noop */ }
|
|
106
|
+
});
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Foreground boot (self-heal paths that must have a live session before
|
|
111
|
+
// dispatching the very next action). Best-effort; failures emit a result
|
|
112
|
+
// envelope and leave state in place so the next sendInput retries.
|
|
88
113
|
try {
|
|
89
|
-
await
|
|
114
|
+
await _ensureBooted(state, resumeSessionId);
|
|
90
115
|
} catch (err) {
|
|
91
|
-
|
|
92
|
-
type: 'result',
|
|
93
|
-
subtype: 'error',
|
|
94
|
-
session_id: state.sessionId,
|
|
95
|
-
is_error: true,
|
|
96
|
-
error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
|
|
97
|
-
});
|
|
116
|
+
_emitBootError(state, err);
|
|
98
117
|
}
|
|
99
118
|
return state;
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
function _emitBootError(state, err) {
|
|
122
|
+
sendOutput(state.conversationId, {
|
|
123
|
+
type: 'result',
|
|
124
|
+
subtype: 'error',
|
|
125
|
+
session_id: state.sessionId,
|
|
126
|
+
is_error: true,
|
|
127
|
+
error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Ensure the ACP child + session are up, coalescing concurrent callers onto a
|
|
133
|
+
* single in-flight boot. A backgrounded start() (deferBoot) and an early
|
|
134
|
+
* sendInput therefore share ONE boot instead of spawning two children. Throws
|
|
135
|
+
* if the boot fails; the in-flight promise is cleared either way so the next
|
|
136
|
+
* call retries from scratch.
|
|
137
|
+
*/
|
|
138
|
+
async function _ensureBooted(state, resumeSessionId) {
|
|
139
|
+
// Readiness requires a sessionId too, not just `initialized`. `initialized`
|
|
140
|
+
// flips true right after the `initialize` response but BEFORE session/new
|
|
141
|
+
// establishes state.sessionId — a message arriving in that window would
|
|
142
|
+
// otherwise prompt with sessionId:null and lose the turn. Gating on sessionId
|
|
143
|
+
// keeps callers coalesced onto the in-flight boot until the session exists.
|
|
144
|
+
if (state.initialized && state.acpClient && state.sessionId) return;
|
|
145
|
+
if (state._bootPromise) return state._bootPromise;
|
|
146
|
+
const p = (async () => {
|
|
147
|
+
try {
|
|
148
|
+
await _bootAcp(state, resumeSessionId);
|
|
149
|
+
} finally {
|
|
150
|
+
// Only clear if we're still the current boot — a crash-close (which nulls
|
|
151
|
+
// _bootPromise) followed by a fresh boot must not have its promise wiped
|
|
152
|
+
// by this stale finally.
|
|
153
|
+
if (state._bootPromise === p) state._bootPromise = null;
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
state._bootPromise = p;
|
|
157
|
+
return p;
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
async function _bootAcp(state, resumeSessionId) {
|
|
103
161
|
const args = ['--acp'];
|
|
104
162
|
if (Array.isArray(state.providerOptions?.addDirs)) {
|
|
@@ -125,6 +183,10 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
125
183
|
if (client) client.close(message);
|
|
126
184
|
});
|
|
127
185
|
child.on('close', (code) => {
|
|
186
|
+
// Ignore a late close from a child that's already been replaced (e.g. a
|
|
187
|
+
// reboot spawned a new one) or torn down — otherwise this would clobber the
|
|
188
|
+
// live boot's acpClient/_bootPromise and orphan the new child.
|
|
189
|
+
if (state.copilotChild !== child) return;
|
|
128
190
|
if (state.turnActive) {
|
|
129
191
|
const tail = stderrBuf.trim().slice(-2000);
|
|
130
192
|
_sendTurnError(state, tail || `copilot exited mid-turn (code ${code})`);
|
|
@@ -139,6 +201,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
139
201
|
state.copilotChild = null;
|
|
140
202
|
state.acpClient = null;
|
|
141
203
|
state.initialized = false;
|
|
204
|
+
state._bootPromise = null;
|
|
142
205
|
});
|
|
143
206
|
|
|
144
207
|
client = new AcpClient({
|
|
@@ -160,7 +223,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
160
223
|
const initResp = await client.request('initialize', {
|
|
161
224
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
162
225
|
clientCapabilities: {},
|
|
163
|
-
});
|
|
226
|
+
}, { timeoutMs: BOOT_REQUEST_TIMEOUT_MS });
|
|
164
227
|
state.acpCapabilities = initResp?.agentCapabilities || {};
|
|
165
228
|
state.initialized = true;
|
|
166
229
|
|
|
@@ -170,7 +233,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
170
233
|
sessionId: resumeSessionId,
|
|
171
234
|
cwd: state.workDir,
|
|
172
235
|
mcpServers: [],
|
|
173
|
-
});
|
|
236
|
+
}, { timeoutMs: BOOT_REQUEST_TIMEOUT_MS });
|
|
174
237
|
state.sessionId = resumeSessionId;
|
|
175
238
|
state.claudeSessionId = resumeSessionId;
|
|
176
239
|
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
@@ -189,7 +252,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
189
252
|
const r = await client.request('session/new', {
|
|
190
253
|
cwd: state.workDir,
|
|
191
254
|
mcpServers: [],
|
|
192
|
-
});
|
|
255
|
+
}, { timeoutMs: BOOT_REQUEST_TIMEOUT_MS });
|
|
193
256
|
state.sessionId = r?.sessionId || randomUUID();
|
|
194
257
|
state.claudeSessionId = state.sessionId;
|
|
195
258
|
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
@@ -217,9 +280,13 @@ export async function sendInput(state, prompt, opts = {}) {
|
|
|
217
280
|
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
218
281
|
|
|
219
282
|
// Ensure ACP child + session are up; reboot if a prior crash dropped them.
|
|
220
|
-
|
|
283
|
+
// _ensureBooted coalesces onto a backgrounded deferBoot still in flight, so a
|
|
284
|
+
// fast first message doesn't spawn a second child. Gate on sessionId too: a
|
|
285
|
+
// message can land after `initialize` but before session/new, when initialized
|
|
286
|
+
// is already true but sessionId is still null.
|
|
287
|
+
if (!state.initialized || !state.acpClient || !state.sessionId) {
|
|
221
288
|
try {
|
|
222
|
-
await
|
|
289
|
+
await _ensureBooted(state, state.sessionId || null);
|
|
223
290
|
} catch (err) {
|
|
224
291
|
_sendTurnError(state, `copilot ACP reinit failed: ${err?.message || err}`);
|
|
225
292
|
_completeTurn(state, conversationId);
|
|
@@ -323,6 +390,7 @@ export function dispose(state, reason = 'disposed') {
|
|
|
323
390
|
state.copilotChild = null;
|
|
324
391
|
}
|
|
325
392
|
state.initialized = false;
|
|
393
|
+
state._bootPromise = null;
|
|
326
394
|
state.turnActive = false;
|
|
327
395
|
}
|
|
328
396
|
|
package/yeaft/engine.js
CHANGED
|
@@ -1665,7 +1665,7 @@ export class Engine {
|
|
|
1665
1665
|
|
|
1666
1666
|
try {
|
|
1667
1667
|
this.#currentThreadId = threadId || MAIN_THREAD_ID;
|
|
1668
|
-
yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
|
|
1668
|
+
yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
|
|
1669
1669
|
} finally {
|
|
1670
1670
|
if (signal) {
|
|
1671
1671
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -1709,7 +1709,7 @@ export class Engine {
|
|
|
1709
1709
|
* in a try/finally without indenting the whole loop.
|
|
1710
1710
|
* @private
|
|
1711
1711
|
*/
|
|
1712
|
-
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null, explicitSkillName = null }) {
|
|
1712
|
+
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null, explicitSkillName = null }) {
|
|
1713
1713
|
|
|
1714
1714
|
const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
|
|
1715
1715
|
? collabToolPolicy
|