@yeaft/webchat-agent 1.0.43 → 1.0.45

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.45",
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
 
@@ -164,6 +164,41 @@ async function sendDreamSnapshotForSession(sessionId, extra = {}) {
164
164
  return snapshot;
165
165
  }
166
166
 
167
+ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
168
+ const replaySession = session;
169
+ const replayConversationId = yeaftConversationId;
170
+ setTimeout(() => {
171
+ try {
172
+ if (!replaySession) return;
173
+ refreshLiveSessionConfig();
174
+ hydrateYeaftStatusFromSession(replaySession, { reason: 'history_load', emitEvent: true });
175
+ sendSessionEvent({
176
+ type: 'session_ready',
177
+ conversationId: replayConversationId,
178
+ model: replaySession.config.primaryModel || replaySession.config.model,
179
+ modelEffort: replaySession.config.modelEffort || null,
180
+ availableModels: replaySession.config.availableModels || [],
181
+ skills: replaySession.status.skills,
182
+ mcpServers: replaySession.status.mcpServers,
183
+ tools: replaySession.status.tools,
184
+ yeaftDir: ctx.CONFIG?.yeaftDir || null,
185
+ tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
186
+ });
187
+ sendSessionSnapshotBroadcast();
188
+ if (sessionId && session === replaySession) {
189
+ sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
190
+ }
191
+ try {
192
+ getVpStatusBroker().broadcastSnapshot();
193
+ } catch (err) {
194
+ console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
195
+ }
196
+ } catch (err) {
197
+ console.warn('[Yeaft] load-history metadata replay failed:', err?.message || err);
198
+ }
199
+ }, 0);
200
+ }
201
+
167
202
 
168
203
  /**
169
204
  * Single in-flight AbortController for legacy 1:1 chat. A new 1:1 user message
@@ -4819,6 +4854,7 @@ export function handleYeaftModelSwitch(msg) {
4819
4854
  */
4820
4855
  export async function handleYeaftLoadHistory(msg) {
4821
4856
  const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
4857
+ const metadataOnly = msg && Number.isFinite(msg.limit) && msg.limit <= 0;
4822
4858
  // `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
4823
4859
  // `loadRecentBySession` use turn-based slicing so the cut never lands
4824
4860
  // mid-tool-arc. Pass `undefined` to use the persistence-layer default
@@ -4828,6 +4864,7 @@ export async function handleYeaftLoadHistory(msg) {
4828
4864
  let historyAlreadyReplayed = false;
4829
4865
 
4830
4866
  const replayHistoryFromStore = () => {
4867
+ if (metadataOnly) return;
4831
4868
  // Delta path: caller knows the latest seq (or message id) it has cached
4832
4869
  // and wants only the messages that arrived after that cursor. Returns
4833
4870
  // mode:'delta' so the frontend can append+dedupe instead of replacing
@@ -4959,7 +4996,7 @@ export async function handleYeaftLoadHistory(msg) {
4959
4996
  afterSeq,
4960
4997
  });
4961
4998
  sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: projectedMessages.length, sessionId, latestSeq: delta.latestSeq, afterSeq });
4962
- } else {
4999
+ } else if (!metadataOnly) {
4963
5000
  emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent' });
4964
5001
  }
4965
5002
  historyAlreadyReplayed = true;
@@ -4972,9 +5009,6 @@ export async function handleYeaftLoadHistory(msg) {
4972
5009
  });
4973
5010
  installYeaftRuntimeBridge(session);
4974
5011
 
4975
- refreshLiveSessionConfig();
4976
- hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
4977
-
4978
5012
  // Per-group history hydrates lazily via getOrCreateSessionHistory.
4979
5013
  // When the load-history call carries a sessionId, force-refresh THAT
4980
5014
  // group's tape so the next user message sees on-disk state. When
@@ -4984,8 +5018,6 @@ export async function handleYeaftLoadHistory(msg) {
4984
5018
  } else {
4985
5019
  replayHistoryFromStore();
4986
5020
  historyAlreadyReplayed = true;
4987
- refreshLiveSessionConfig();
4988
- hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
4989
5021
  }
4990
5022
 
4991
5023
  if (sessionId) {
@@ -4995,31 +5027,11 @@ export async function handleYeaftLoadHistory(msg) {
4995
5027
  setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
4996
5028
  }
4997
5029
 
4998
- // Always replay session_ready so refresh / reconnect rebuilds UI state.
4999
- sendSessionEvent({
5000
- type: 'session_ready',
5001
- conversationId: yeaftConversationId,
5002
- model: session.config.primaryModel || session.config.model,
5003
- modelEffort: session.config.modelEffort || null,
5004
- availableModels: session.config.availableModels || [],
5005
- skills: session.status.skills,
5006
- mcpServers: session.status.mcpServers,
5007
- tools: session.status.tools,
5008
- yeaftDir: ctx.CONFIG?.yeaftDir || null,
5009
- tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
5010
- });
5011
- sendSessionSnapshotBroadcast();
5012
- if (sessionId) {
5013
- await sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
5014
- }
5015
- // vp-status: replay the authoritative table on reconnect so a refreshed
5016
- // frontend doesn't have to wait for the next transition to learn each
5017
- // VP's current state.
5018
- try {
5019
- getVpStatusBroker().broadcastSnapshot();
5020
- } catch (err) {
5021
- console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
5022
- }
5030
+ // Always replay session_ready so refresh / reconnect rebuilds UI state, but
5031
+ // never make the history response wait for bulky metadata snapshots. The
5032
+ // first visible chunk has already been sent above; defer metadata to the next
5033
+ // tick so the browser can paint messages before VP/session/dream snapshots.
5034
+ scheduleYeaftLoadHistoryMetadataReplay(sessionId);
5023
5035
 
5024
5036
  if (historyAlreadyReplayed) return;
5025
5037