@yeaft/webchat-agent 1.0.43 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.43",
3
+ "version": "1.0.44",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -38,17 +38,36 @@ export class AcpClient {
38
38
  stdout.on('close', () => this._handleClose());
39
39
  }
40
40
 
41
- /** Send a JSONRPC request and await its response. */
42
- request(method, params) {
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
- this._pending.set(id, { resolve, reject });
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;
@@ -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
- // Best-effort start; failures emit a result envelope and leave state in place
87
- // so the next sendInput retries.
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 _bootAcp(state, opts.resumeSessionId || null);
114
+ await _ensureBooted(state, resumeSessionId);
90
115
  } catch (err) {
91
- sendOutput(conversationId, {
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
- if (!state.initialized || !state.acpClient) {
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 _bootAcp(state, state.sessionId || null);
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