@yeaft/webchat-agent 1.0.12 → 1.0.14

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.
@@ -38,7 +38,7 @@ import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../yeaft/config-api.js';
39
39
  import { discoverLlmModels } from '../llm-model-discovery.js';
40
40
  import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
41
- import { handleYeaftSessionSend, handleYeaftSubAgentPrompt, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager } from '../yeaft/web-bridge.js';
41
+ import { handleYeaftSessionSend, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager } from '../yeaft/web-bridge.js';
42
42
  import { startYeaftStatusRefresh, refreshYeaftStatus } from '../yeaft/status-cache.js';
43
43
 
44
44
  export async function handleMessage(msg) {
@@ -658,6 +658,9 @@ export async function handleMessage(msg) {
658
658
  case 'yeaft_sub_agent_prompt':
659
659
  handleYeaftSubAgentPrompt(msg);
660
660
  break;
661
+ case 'yeaft_task_cancel':
662
+ handleYeaftTaskCancel(msg);
663
+ break;
661
664
 
662
665
  // wave-6b: manual dream trigger from VP detail page
663
666
  case 'yeaft_dream_trigger':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
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",
@@ -12,6 +12,7 @@ import { getRuntimePlatformInfo } from '../runtime-platform.js';
12
12
 
13
13
  const LOG_PREVIEW_BYTES = 4096;
14
14
  const SUB_AGENT_LOG_PREVIEW_BYTES = 1024 * 1024;
15
+ const DEFAULT_CANCEL_ESCALATION_MS = 2000;
15
16
 
16
17
  function logPreviewBytesFor(task) {
17
18
  return task?.kind === 'sub_agent' ? SUB_AGENT_LOG_PREVIEW_BYTES : LOG_PREVIEW_BYTES;
@@ -45,14 +46,23 @@ function publicSnapshot(task) {
45
46
  };
46
47
  }
47
48
 
49
+ function taskCommand(task) {
50
+ const command = task?.runtime?.command;
51
+ return typeof command === 'string' && command.trim() ? command.trim() : '';
52
+ }
53
+
48
54
  export class TaskManager {
49
- constructor({ yeaftDir, onEvent = null, runtimePlatform = null } = {}) {
55
+ constructor({ yeaftDir, onEvent = null, runtimePlatform = null, cancelEscalationMs = DEFAULT_CANCEL_ESCALATION_MS } = {}) {
50
56
  if (!yeaftDir) throw new Error('TaskManager requires yeaftDir');
51
57
  this.store = new TaskStore({ yeaftDir });
52
58
  this.onEvent = typeof onEvent === 'function' ? onEvent : null;
53
59
  this.runtimePlatform = runtimePlatform || getRuntimePlatformInfo();
60
+ this.cancelEscalationMs = Number.isFinite(cancelEscalationMs)
61
+ ? Math.max(0, Math.floor(cancelEscalationMs))
62
+ : DEFAULT_CANCEL_ESCALATION_MS;
54
63
  this.active = new Map();
55
64
  this.processes = new Map();
65
+ this.cancelEscalationTimers = new Map();
56
66
  this.#loadPersistedRunningTasks();
57
67
  }
58
68
 
@@ -191,9 +201,15 @@ export class TaskManager {
191
201
  const key = this.#key(sessionId, taskId);
192
202
  const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
193
203
  if (!task || isTerminalTaskStatus(task.status)) return publicSnapshot(task);
204
+ const escalationTimer = this.cancelEscalationTimers.get(key);
205
+ if (escalationTimer) {
206
+ clearTimeout(escalationTimer);
207
+ this.cancelEscalationTimers.delete(key);
208
+ }
194
209
  const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
195
210
  const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: logPreviewBytesFor(task) });
196
- task.status = status || TASK_STATUS.FAILED;
211
+ const cancelRequested = !!task.runtime?.cancelRequestedAt;
212
+ task.status = cancelRequested ? TASK_STATUS.CANCELLED : (status || TASK_STATUS.FAILED);
197
213
  task.updatedAt = nowIso();
198
214
  task.endedAt = nowIso();
199
215
  task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
@@ -212,19 +228,61 @@ export class TaskManager {
212
228
  if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
213
229
  if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
214
230
  const runner = this.processes.get(key);
215
- const killed = runner ? runner.kill('SIGTERM') : false;
216
- if (!killed) {
231
+ if (!runner) {
217
232
  return {
218
233
  ok: false,
219
- error: 'Unable to cancel task: no live process handle or process-tree kill failed.',
234
+ error: 'Unable to cancel task: no live process handle.',
220
235
  task: publicSnapshot(task),
221
236
  };
222
237
  }
223
- const completed = this.#completeTask(sessionId, taskId, {
224
- status: TASK_STATUS.CANCELLED,
225
- signal: 'SIGTERM',
226
- });
227
- return { ok: true, task: completed };
238
+
239
+ if (!task.runtime?.cancelRequestedAt) {
240
+ const signalled = runner.kill('SIGTERM');
241
+ if (!signalled) {
242
+ return {
243
+ ok: false,
244
+ error: 'Unable to cancel task: process-tree signal failed.',
245
+ task: publicSnapshot(task),
246
+ };
247
+ }
248
+ const cancelRequestedAt = nowIso();
249
+ task.runtime = {
250
+ ...(task.runtime || {}),
251
+ cancelRequestedAt,
252
+ cancelSignal: 'SIGTERM',
253
+ cancelEscalationMs: this.cancelEscalationMs,
254
+ };
255
+ task.updatedAt = cancelRequestedAt;
256
+ this.store.writeTask(task);
257
+ this.active.set(key, task);
258
+ this.store.appendEvent(sessionId, { event: 'cancel_requested', taskId, signal: 'SIGTERM' });
259
+ this.#emit('updated', task, { cancelRequested: true });
260
+
261
+ if (this.cancelEscalationMs >= 0 && !this.cancelEscalationTimers.has(key)) {
262
+ const timer = setTimeout(() => {
263
+ this.cancelEscalationTimers.delete(key);
264
+ const current = this.active.get(key) || this.store.readTask(sessionId, taskId);
265
+ if (!current || isTerminalTaskStatus(current.status)) return;
266
+ const liveRunner = this.processes.get(key);
267
+ const escalated = liveRunner ? liveRunner.kill('SIGKILL') : false;
268
+ current.runtime = {
269
+ ...(current.runtime || {}),
270
+ cancelEscalatedAt: nowIso(),
271
+ cancelEscalatedSignal: 'SIGKILL',
272
+ cancelEscalationFailed: !escalated,
273
+ };
274
+ current.updatedAt = current.runtime.cancelEscalatedAt;
275
+ this.store.writeTask(current);
276
+ this.active.set(key, current);
277
+ this.store.appendEvent(sessionId, { event: 'cancel_escalated', taskId, signal: 'SIGKILL', ok: escalated });
278
+ this.#emit('updated', current, { cancelEscalated: true, cancelEscalationOk: escalated });
279
+ }, this.cancelEscalationMs);
280
+ if (typeof timer.unref === 'function') timer.unref();
281
+ this.cancelEscalationTimers.set(key, timer);
282
+ }
283
+ }
284
+
285
+ return { ok: true, task: publicSnapshot(task), pending: true };
228
286
  }
229
287
 
230
288
  listActiveTasks(sessionId = null) {
@@ -274,7 +332,9 @@ export class TaskManager {
274
332
  const lines = ['<active_tasks>'];
275
333
  for (const task of tasks) {
276
334
  const preview = (task.log?.preview || '').trim().split('\n').slice(-3).join(' | ');
277
- lines.push(`- ${task.id} | ${task.kind} | ${task.status} | owner=${task.ownerVpId || 'unknown'} | title=${JSON.stringify(task.title)} | log=${task.log?.path || ''}${preview ? ` | tail=${JSON.stringify(preview)}` : ''}`);
335
+ const command = taskCommand(task);
336
+ const cancelRequestedAt = typeof task.runtime?.cancelRequestedAt === 'string' ? task.runtime.cancelRequestedAt : '';
337
+ lines.push(`- ${task.id} | ${task.kind} | ${task.status} | owner=${task.ownerVpId || 'unknown'} | title=${JSON.stringify(task.title)}${command ? ` | command=${JSON.stringify(command)}` : ''}${cancelRequestedAt ? ` | cancelRequestedAt=${JSON.stringify(cancelRequestedAt)}` : ''} | log=${task.log?.path || ''}${preview ? ` | tail=${JSON.stringify(preview)}` : ''}`);
278
338
  }
279
339
  lines.push('</active_tasks>');
280
340
  return lines.join('\n');
@@ -1217,6 +1217,7 @@ function formatTaskResultForVp(task) {
1217
1217
  `<task-result id="${task.id}" kind="${task.kind}" status="${task.status}">`,
1218
1218
  `title: ${task.title || task.kind || task.id}`,
1219
1219
  ];
1220
+ if (task?.runtime?.command) lines.push(`command: ${task.runtime.command}`);
1220
1221
  if (result.exitCode !== undefined && result.exitCode !== null) lines.push(`exitCode: ${result.exitCode}`);
1221
1222
  if (result.signal) lines.push(`signal: ${result.signal}`);
1222
1223
  if (result.error) lines.push(`error: ${result.error}`);
@@ -2354,6 +2355,89 @@ function maybeTransitionVpStatus(hctx, state) {
2354
2355
  }
2355
2356
  }
2356
2357
 
2358
+ const STREAM_TEXT_BATCH_MAX_CHARS = 200;
2359
+ const STREAM_TEXT_BATCH_MAX_MS = 200;
2360
+
2361
+ function createStreamTextBatch() {
2362
+ return {
2363
+ parts: [],
2364
+ charCount: 0,
2365
+ timer: null,
2366
+ envelope: null,
2367
+ immediateNext: true,
2368
+ };
2369
+ }
2370
+
2371
+ function getStreamTextBatch(hctx) {
2372
+ if (!hctx) return null;
2373
+ if (!hctx.streamTextBatch) hctx.streamTextBatch = createStreamTextBatch();
2374
+ return hctx.streamTextBatch;
2375
+ }
2376
+
2377
+ function clearStreamTextBatchTimer(batch) {
2378
+ if (!batch?.timer) return;
2379
+ clearTimeout(batch.timer);
2380
+ batch.timer = null;
2381
+ }
2382
+
2383
+ function sendAssistantTextFrame(text, envelope) {
2384
+ if (!text) return;
2385
+ sendSessionOutputFrame({
2386
+ type: 'assistant',
2387
+ message: { content: [{ type: 'text', text }] },
2388
+ }, envelope);
2389
+ }
2390
+
2391
+ function flushStreamTextBatch(hctx, envelope, { resetImmediate = false } = {}) {
2392
+ const batch = hctx?.streamTextBatch;
2393
+ if (!batch) return false;
2394
+ clearStreamTextBatchTimer(batch);
2395
+ const text = batch.parts.join('');
2396
+ batch.parts = [];
2397
+ batch.charCount = 0;
2398
+ const flushEnvelope = envelope || batch.envelope;
2399
+ batch.envelope = flushEnvelope || null;
2400
+ if (resetImmediate) batch.immediateNext = true;
2401
+ if (!text) return false;
2402
+ sendAssistantTextFrame(text, flushEnvelope);
2403
+ return true;
2404
+ }
2405
+
2406
+ function scheduleStreamTextBatchFlush(hctx, batch) {
2407
+ if (!hctx || batch.timer) return;
2408
+ batch.timer = setTimeout(() => {
2409
+ batch.timer = null;
2410
+ flushStreamTextBatch(hctx, batch.envelope);
2411
+ }, STREAM_TEXT_BATCH_MAX_MS);
2412
+ if (batch.timer && typeof batch.timer.unref === 'function') {
2413
+ batch.timer.unref();
2414
+ }
2415
+ }
2416
+
2417
+ function queueStreamTextDelta(hctx, text, envelope) {
2418
+ if (typeof text !== 'string' || text.length === 0) return;
2419
+ const batch = getStreamTextBatch(hctx);
2420
+ if (!batch) {
2421
+ sendAssistantTextFrame(text, envelope);
2422
+ return;
2423
+ }
2424
+
2425
+ batch.envelope = envelope;
2426
+ if (batch.immediateNext) {
2427
+ batch.immediateNext = false;
2428
+ sendAssistantTextFrame(text, envelope);
2429
+ return;
2430
+ }
2431
+
2432
+ batch.parts.push(text);
2433
+ batch.charCount += text.length;
2434
+ if (batch.charCount >= STREAM_TEXT_BATCH_MAX_CHARS) {
2435
+ flushStreamTextBatch(hctx, envelope);
2436
+ return;
2437
+ }
2438
+ scheduleStreamTextBatchFlush(hctx, batch);
2439
+ }
2440
+
2357
2441
  /**
2358
2442
  * Handle a single engine event unwrapped from an `engine_event` envelope.
2359
2443
  * Stamps threadId on every outgoing frame so frontend grouping, tools,
@@ -2375,13 +2459,17 @@ function handleEngineEvent(event, hctx) {
2375
2459
  threadId: hctx.threadId || event.threadId,
2376
2460
  };
2377
2461
 
2462
+ if (event.type !== 'text_delta') {
2463
+ // Preserve wire order. Any boundary/metadata/tool event must see all text
2464
+ // accepted before it flushed first; otherwise the browser can render a tool
2465
+ // call or terminal result before the text that led to it.
2466
+ flushStreamTextBatch(hctx, envelope, { resetImmediate: true });
2467
+ }
2468
+
2378
2469
  switch (event.type) {
2379
2470
  case 'text_delta':
2380
2471
  hctx.assistantTextParts.push(event.text);
2381
- sendSessionOutputFrame({
2382
- type: 'assistant',
2383
- message: { content: [{ type: 'text', text: event.text }] },
2384
- }, envelope);
2472
+ queueStreamTextDelta(hctx, event.text, envelope);
2385
2473
  // vp-status: first text-delta of a (thinking|tool) phase flips
2386
2474
  // the row to 'streaming'. transition() is a no-op when already
2387
2475
  // streaming, so subsequent deltas are cheap.
@@ -3410,6 +3498,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3410
3498
  let turnEndReason = 'end_turn';
3411
3499
  let turnEndEmitted = false;
3412
3500
  let turnEndDetail = null;
3501
+ let handlerCtx = null;
3413
3502
  const markTurnEnd = (reason) => { turnEndEmitted = true; turnEndReason = reason; };
3414
3503
  const emitVpTurnEnd = (reason, detail = null) => {
3415
3504
  if (turnEndEmitted) return;
@@ -3483,7 +3572,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3483
3572
  vpEngine = getOrCreateVpEngine(sessionId, vpId, threadId);
3484
3573
  if (thread) thread.engine = vpEngine;
3485
3574
 
3486
- const handlerCtx = {
3575
+ handlerCtx = {
3487
3576
  assistantTextParts,
3488
3577
  toolCallsAccum,
3489
3578
  toolResultsAccum,
@@ -3529,6 +3618,8 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3529
3618
  handleEngineEvent(event, handlerCtx);
3530
3619
  }
3531
3620
 
3621
+ flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
3622
+
3532
3623
  // Turn completed — atomically append this VP's output to shared history.
3533
3624
  // route_forward handoff text is an internal trigger, already visible as
3534
3625
  // the source VP's tool action. Do not append it as a visible prompt for
@@ -3557,6 +3648,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3557
3648
  } catch (err) {
3558
3649
  const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
3559
3650
  if (isAbort) {
3651
+ flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
3560
3652
  sendSessionOutputFrame({
3561
3653
  type: 'result',
3562
3654
  result_text: '',
@@ -3581,6 +3673,8 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3581
3673
  console.warn('[Yeaft] vp-status error transition failed:', brokerErr?.message || brokerErr);
3582
3674
  }
3583
3675
 
3676
+ flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
3677
+
3584
3678
  if (isPermissionErrorMsg(err.message)) {
3585
3679
  if (!_permissionDiagnosticSent) {
3586
3680
  _permissionDiagnosticSent = true;
@@ -4489,6 +4583,54 @@ export function handleYeaftSubAgentPrompt(msg) {
4489
4583
  }, { sessionId, vpId: task.ownerVpId || null, threadId: task.source?.threadId || null });
4490
4584
  }
4491
4585
 
4586
+ export function handleYeaftTaskCancel(msg) {
4587
+ const sessionId = typeof msg?.sessionId === 'string' ? msg.sessionId.trim() : '';
4588
+ const taskId = typeof msg?.taskId === 'string' ? msg.taskId.trim() : '';
4589
+ const clientRequestId = typeof msg?.clientRequestId === 'string' ? msg.clientRequestId.trim() : '';
4590
+ const fail = (error, task = null) => {
4591
+ sendSessionEvent({
4592
+ type: 'yeaft_task_cancel_result',
4593
+ success: false,
4594
+ taskId: taskId || null,
4595
+ clientRequestId: clientRequestId || null,
4596
+ error,
4597
+ ...(task ? { task } : {}),
4598
+ }, sessionId ? { sessionId, vpId: task?.ownerVpId || null, threadId: task?.source?.threadId || null } : undefined);
4599
+ };
4600
+
4601
+ if (!sessionId || !taskId) {
4602
+ fail('sessionId and taskId are required');
4603
+ return;
4604
+ }
4605
+ if (!session?.taskManager || typeof session.taskManager.cancelTask !== 'function') {
4606
+ fail('task manager unavailable');
4607
+ return;
4608
+ }
4609
+
4610
+ let result;
4611
+ try {
4612
+ result = session.taskManager.cancelTask(sessionId, taskId);
4613
+ } catch (err) {
4614
+ fail(err?.message || String(err));
4615
+ return;
4616
+ }
4617
+
4618
+ const task = result?.task || session.taskManager.getTask?.(sessionId, taskId) || null;
4619
+ if (!result?.ok) {
4620
+ fail(result?.error || 'Failed to cancel task', task);
4621
+ return;
4622
+ }
4623
+
4624
+ sendSessionEvent({
4625
+ type: 'yeaft_task_cancel_result',
4626
+ success: true,
4627
+ taskId,
4628
+ clientRequestId: clientRequestId || null,
4629
+ pending: !!result?.pending,
4630
+ task,
4631
+ }, { sessionId, vpId: task?.ownerVpId || null, threadId: task?.source?.threadId || null });
4632
+ }
4633
+
4492
4634
  /** Deprecated mode switch — Yeaft is single-mode. */
4493
4635
  export function handleYeaftModeSwitch(_msg) {
4494
4636
  console.warn('[Yeaft] yeaft_mode_switch is deprecated and ignored — Yeaft now runs in a single unified mode.');