@yeaft/webchat-agent 1.0.171 → 1.0.173

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.171",
3
+ "version": "1.0.173",
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",
package/yeaft/engine.js CHANGED
@@ -524,6 +524,9 @@ export class Engine {
524
524
  /** @type {Array<{content:string|Array, preview:string}>} */
525
525
  #pendingUserMessages = [];
526
526
 
527
+ /** True after the bridge queued input in its external per-thread buffer. */
528
+ #externalUserWakePending = false;
529
+
527
530
  /**
528
531
  * Tasks (background bash, sub-agent spawns) that were launched DURING
529
532
  * the currently-running query() and have NOT terminated yet. The query
@@ -1654,6 +1657,7 @@ export class Engine {
1654
1657
 
1655
1658
  #drainPendingUserMessages(drainPendingUserMessages) {
1656
1659
  const pending = [];
1660
+ this.#externalUserWakePending = false;
1657
1661
  if (typeof drainPendingUserMessages === 'function') {
1658
1662
  try {
1659
1663
  const drained = drainPendingUserMessages();
@@ -1809,6 +1813,7 @@ export class Engine {
1809
1813
  this.#abortReason = null;
1810
1814
  this.#currentThreadId = MAIN_THREAD_ID;
1811
1815
  this.#pendingUserMessages.length = 0;
1816
+ this.#externalUserWakePending = false;
1812
1817
  this.#asyncTaskToolMeta.clear();
1813
1818
  this.#pendingTaskResultMessages.length = 0;
1814
1819
  this.#pendingTaskResultUpdates.length = 0;
@@ -2955,7 +2960,8 @@ export class Engine {
2955
2960
  while (!signal?.aborted
2956
2961
  && this.#pendingTaskResultMessages.length === 0
2957
2962
  && this.#pendingTaskResultUpdates.length === 0
2958
- && this.#pendingUserMessages.length === 0) {
2963
+ && this.#pendingUserMessages.length === 0
2964
+ && !this.#externalUserWakePending) {
2959
2965
  if (this.#pendingAsyncTaskIds.size === 0) break;
2960
2966
  await this.#waitForAsyncWake(signal);
2961
2967
  }
@@ -3680,6 +3686,20 @@ export class Engine {
3680
3686
  return true;
3681
3687
  }
3682
3688
 
3689
+ /**
3690
+ * Wake a query whose external pending-message hook has received input.
3691
+ * The bridge keeps the full message (attachments and provenance included)
3692
+ * in its thread queue; this method only releases an async-task wait so the
3693
+ * normal drain callback can consume it at the next safe adapter boundary.
3694
+ * @returns {boolean}
3695
+ */
3696
+ wakeForPendingUserMessage() {
3697
+ if (!this.isRunning) return false;
3698
+ this.#externalUserWakePending = true;
3699
+ this.#wakeAsyncTaskWaiters();
3700
+ return true;
3701
+ }
3702
+
3683
3703
  /**
3684
3704
  * Install the coordinator that lets an external dispatcher (web-bridge)
3685
3705
  * route a background task's terminal event back to THIS engine while
@@ -3851,6 +3871,7 @@ export class Engine {
3851
3871
  if (this.#pendingTaskResultMessages.length > 0) return resolve();
3852
3872
  if (this.#pendingTaskResultUpdates.length > 0) return resolve();
3853
3873
  if (this.#pendingUserMessages.length > 0) return resolve();
3874
+ if (this.#externalUserWakePending) return resolve();
3854
3875
  if (signal?.aborted) return resolve();
3855
3876
  this.#asyncTaskWaiters.push(resolve);
3856
3877
  if (signal && typeof signal.addEventListener === 'function') {
@@ -66,6 +66,13 @@ Guidelines:
66
66
  }
67
67
 
68
68
  const answers = await ctx.askUser({ question, options });
69
+ if (answers?.__yeaftTimedOut) {
70
+ return JSON.stringify({
71
+ question,
72
+ timedOut: true,
73
+ message: 'The user did not answer before the request expired. Continue without this input.',
74
+ });
75
+ }
69
76
  return JSON.stringify({ question, answers });
70
77
  },
71
78
  });
@@ -80,7 +80,27 @@ const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
80
80
  const PROJECT_SKILL_TIERS = new Set(['project', 'project-claude', 'project-codex']);
81
81
  const BASE_RUNTIME_KEY = '__base__';
82
82
 
83
- /** @type {Map<string, {resolve:Function, sessionId:string, vpId:string, threadId:string, turnId:string, signal?:AbortSignal, onAbort?:Function}>} */
83
+ /**
84
+ * Live AskUser requests. They are Session-scoped runtime state rather than a
85
+ * turn lock: every connected client may answer the same request, while the
86
+ * first valid answer wins. Pending requests are replayed when another device
87
+ * loads the Session.
88
+ * @type {Map<string, {
89
+ * resolve:Function,
90
+ * reject?:Function,
91
+ * sessionId:string,
92
+ * vpId:string,
93
+ * threadId:string,
94
+ * turnId:string,
95
+ * question:string,
96
+ * options:Array<string>,
97
+ * createdAt:number,
98
+ * expiresAt:number,
99
+ * timer?:NodeJS.Timeout|null,
100
+ * resumeQueryTimer?:Function,
101
+ * signal?:AbortSignal,
102
+ * onAbort?:Function,
103
+ * }>} */
84
104
  const pendingUserPrompts = new Map();
85
105
 
86
106
  /** @type {import('./session.js').Session | null} */
@@ -212,6 +232,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
212
232
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
213
233
  tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
214
234
  }, { sessionId });
235
+ if (sessionId) replayPendingUserPrompts(sessionId);
215
236
  sendSessionSnapshotBroadcast();
216
237
  if (sessionId && session === replaySession) {
217
238
  sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
@@ -796,6 +817,8 @@ export function broadcastLanguageChange(language) {
796
817
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
797
818
  const QUERY_TIMEOUT_MS = 120_000;
798
819
  const HIGH_REASONING_QUERY_TIMEOUT_MS = 300_000;
820
+ /** AskUser is human-paced but must never pin a VP turn forever. */
821
+ const ASK_USER_TIMEOUT_MS = 10 * 60_000;
799
822
 
800
823
  function isHighReasoningEffort(effort) {
801
824
  const value = typeof effort === 'string' ? effort.trim().toLowerCase() : '';
@@ -1378,6 +1401,12 @@ export function __testGetVpThreads(sessionId, vpId) {
1378
1401
  }));
1379
1402
  }
1380
1403
 
1404
+ /** Test-only: attach an Engine-like wake target to a seeded VP thread. */
1405
+ export function __testSetVpThreadEngine({ sessionId, vpId, threadId, engine }) {
1406
+ const thread = getOrCreateVpThread({ sessionId, vpId, threadId });
1407
+ thread.engine = engine || null;
1408
+ }
1409
+
1381
1410
  /** Test-only: seed a VP thread without starting its engine driver. */
1382
1411
  export function __testSeedVpThread({ sessionId, vpId, threadId, title = 'test thread', status = 'queued' }) {
1383
1412
  const thread = getOrCreateVpThread({ sessionId, vpId, threadId, title });
@@ -1725,31 +1754,43 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1725
1754
  senderVpId: isInternalAppend ? (envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null) : null,
1726
1755
  sourceThreadId: isInternalAppend ? visibleInboundThreadId(envelope, thread.threadId) : null,
1727
1756
  });
1728
- persistInboundMessageOnceByMsgId({
1729
- msgId: envelope?.msg?.id,
1730
- text,
1731
- sessionId,
1732
- threadId: visibleInboundThreadId(envelope, thread.threadId),
1733
- role: isInternalAppend ? 'assistant' : 'user',
1734
- speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
1735
- attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1736
- internal: isInternalAppend,
1737
- ts: envelope?.msg?.ts || null,
1738
- clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
1739
- });
1740
- thread.updatedAt = Date.now();
1741
- try {
1742
- sendSessionEvent({
1743
- type: 'vp_thread_user_appended',
1757
+ // A thread parked on a background task has no adapter activity to poll this
1758
+ // queue. Wake its Engine explicitly so the new user message is consumed
1759
+ // immediately while the task remains tracked in TaskManager. If the Engine
1760
+ // is not actually running, this was a stale classifier hit; remove the
1761
+ // append and fall through to a normal queued turn instead of orphaning it.
1762
+ let appendedToRunningThread = false;
1763
+ try { appendedToRunningThread = thread.engine?.wakeForPendingUserMessage?.() === true; } catch { /* best-effort wake */ }
1764
+ if (!appendedToRunningThread) {
1765
+ thread.pendingQueries.pop();
1766
+ related = false;
1767
+ } else {
1768
+ persistInboundMessageOnceByMsgId({
1769
+ msgId: envelope?.msg?.id,
1770
+ text,
1744
1771
  sessionId,
1745
- vpId,
1746
- threadId: thread.threadId,
1747
- title: thread.title,
1748
- turnId,
1749
- ts: Date.now(),
1750
- }, { sessionId, vpId, threadId: thread.threadId, turnId });
1751
- } catch { /* never crash WS pipeline */ }
1752
- return thread.threadId;
1772
+ threadId: visibleInboundThreadId(envelope, thread.threadId),
1773
+ role: isInternalAppend ? 'assistant' : 'user',
1774
+ speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
1775
+ attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1776
+ internal: isInternalAppend,
1777
+ ts: envelope?.msg?.ts || null,
1778
+ clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
1779
+ });
1780
+ thread.updatedAt = Date.now();
1781
+ try {
1782
+ sendSessionEvent({
1783
+ type: 'vp_thread_user_appended',
1784
+ sessionId,
1785
+ vpId,
1786
+ threadId: thread.threadId,
1787
+ title: thread.title,
1788
+ turnId,
1789
+ ts: Date.now(),
1790
+ }, { sessionId, vpId, threadId: thread.threadId, turnId });
1791
+ } catch { /* never crash WS pipeline */ }
1792
+ return thread.threadId;
1793
+ }
1753
1794
  }
1754
1795
 
1755
1796
  const key = threadKey(sessionId, vpId, thread.threadId);
@@ -2335,6 +2376,60 @@ function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, pe
2335
2376
  });
2336
2377
  }
2337
2378
 
2379
+ function pendingUserPromptEvent(requestId, pending, extra = {}) {
2380
+ return {
2381
+ type: 'ask_user_question',
2382
+ requestId,
2383
+ questions: [{
2384
+ question: pending.question,
2385
+ options: pending.options.map(label => ({ label, description: '' })),
2386
+ multiSelect: false,
2387
+ }],
2388
+ createdAt: pending.createdAt,
2389
+ expiresAt: pending.expiresAt,
2390
+ ...extra,
2391
+ };
2392
+ }
2393
+
2394
+ function sendPendingUserPrompt(requestId, pending, extra = {}) {
2395
+ sendSessionEvent(pendingUserPromptEvent(requestId, pending, extra), {
2396
+ sessionId: pending.sessionId,
2397
+ vpId: pending.vpId,
2398
+ threadId: pending.threadId,
2399
+ turnId: pending.turnId,
2400
+ });
2401
+ }
2402
+
2403
+ function replayPendingUserPrompts(sessionId) {
2404
+ const now = Date.now();
2405
+ for (const [requestId, pending] of pendingUserPrompts) {
2406
+ if (pending.sessionId !== sessionId || pending.expiresAt <= now) continue;
2407
+ sendPendingUserPrompt(requestId, pending, { replay: true });
2408
+ }
2409
+ }
2410
+
2411
+ function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut = false } = {}) {
2412
+ if (pendingUserPrompts.get(requestId) !== pending) return false;
2413
+ pendingUserPrompts.delete(requestId);
2414
+ if (pending.timer) clearTimeout(pending.timer);
2415
+ if (pending.signal && pending.onAbort) {
2416
+ try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
2417
+ }
2418
+ try { pending.resumeQueryTimer?.(); } catch { /* best-effort */ }
2419
+ sendSessionEvent({
2420
+ type: timedOut ? 'ask_user_expired' : 'ask_user_answered',
2421
+ requestId,
2422
+ ...(timedOut ? { expiredAt: Date.now() } : { answers: answers || {} }),
2423
+ }, {
2424
+ sessionId: pending.sessionId,
2425
+ vpId: pending.vpId,
2426
+ threadId: pending.threadId,
2427
+ turnId: pending.turnId,
2428
+ });
2429
+ pending.resolve(timedOut ? { __yeaftTimedOut: true } : (answers || {}));
2430
+ return true;
2431
+ }
2432
+
2338
2433
  function configuredVpPaths() {
2339
2434
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2340
2435
  if (typeof yeaftDir !== 'string' || !yeaftDir.trim()) return {};
@@ -4560,40 +4655,44 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4560
4655
  askUser: ({ question, options }) => new Promise((resolve, reject) => {
4561
4656
  const requestId = `ask_${randomUUID()}`;
4562
4657
  const signal = vpAbort.signal;
4563
- // User think time is not engine silence. Pause the query watchdog until
4564
- // the card is answered; the normal tool/LLM budget resumes afterward.
4658
+ const createdAt = Date.now();
4659
+ // Human think time is not engine silence. Pause the query watchdog,
4660
+ // but keep a separate bounded AskUser lifetime so an abandoned card
4661
+ // cannot pin the VP forever.
4565
4662
  if (queryTimer) {
4566
4663
  clearTimeout(queryTimer);
4567
4664
  queryTimer = null;
4568
4665
  }
4569
- const onAbort = () => {
4570
- pendingUserPrompts.delete(requestId);
4571
- reject(new Error('aborted'));
4572
- };
4573
- pendingUserPrompts.set(requestId, {
4574
- resolve: answers => {
4575
- resetQueryTimer();
4576
- resolve(answers);
4577
- },
4666
+ const pending = {
4667
+ resolve,
4668
+ reject,
4578
4669
  sessionId,
4579
4670
  vpId,
4580
4671
  threadId,
4581
4672
  turnId,
4673
+ question,
4674
+ options: Array.isArray(options) ? options.filter(label => typeof label === 'string') : [],
4675
+ createdAt,
4676
+ expiresAt: createdAt + ASK_USER_TIMEOUT_MS,
4677
+ timer: null,
4678
+ resumeQueryTimer: resetQueryTimer,
4582
4679
  signal,
4583
- onAbort,
4584
- });
4680
+ onAbort: null,
4681
+ };
4682
+ const onAbort = () => {
4683
+ if (pendingUserPrompts.get(requestId) !== pending) return;
4684
+ pendingUserPrompts.delete(requestId);
4685
+ if (pending.timer) clearTimeout(pending.timer);
4686
+ reject(new Error('aborted'));
4687
+ };
4688
+ pending.onAbort = onAbort;
4689
+ pending.timer = setTimeout(() => {
4690
+ settlePendingUserPrompt(requestId, pending, { timedOut: true });
4691
+ }, ASK_USER_TIMEOUT_MS);
4692
+ if (typeof pending.timer.unref === 'function') pending.timer.unref();
4693
+ pendingUserPrompts.set(requestId, pending);
4585
4694
  signal.addEventListener('abort', onAbort, { once: true });
4586
- sendSessionEvent({
4587
- type: 'ask_user_question',
4588
- requestId,
4589
- questions: [{
4590
- question,
4591
- options: Array.isArray(options)
4592
- ? options.map(label => ({ label, description: '' }))
4593
- : [],
4594
- multiSelect: false,
4595
- }],
4596
- }, envelope);
4695
+ sendPendingUserPrompt(requestId, pending);
4597
4696
  }),
4598
4697
  threadId,
4599
4698
  vpTurnId: turnId,
@@ -5639,12 +5738,7 @@ export function handleYeaftAskUserAnswer(msg) {
5639
5738
  if (msg.turnId && msg.turnId !== pending.turnId) return false;
5640
5739
  if (msg.threadId && msg.threadId !== pending.threadId) return false;
5641
5740
 
5642
- pendingUserPrompts.delete(requestId);
5643
- if (pending.signal && pending.onAbort) {
5644
- try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
5645
- }
5646
- pending.resolve(msg.answers || {});
5647
- return true;
5741
+ return settlePendingUserPrompt(requestId, pending, { answers: msg.answers || {} });
5648
5742
  }
5649
5743
 
5650
5744
  export async function handleYeaftSessionSend(msg) {
@@ -6543,13 +6637,21 @@ export const __testHooks = {
6543
6637
  vpAborts.clear();
6544
6638
  vpInboxes.clear();
6545
6639
  },
6546
- seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test' } = {}) {
6640
+ seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
6547
6641
  let resolved;
6548
6642
  const promise = new Promise(resolve => { resolved = resolve; });
6549
- pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId });
6643
+ pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, question, options, createdAt, expiresAt, timer: null });
6550
6644
  return promise;
6551
6645
  },
6646
+ replayPendingUserPrompts,
6647
+ settlePendingUserPromptForTest(requestId, opts = {}) {
6648
+ const pending = pendingUserPrompts.get(requestId);
6649
+ return pending ? settlePendingUserPrompt(requestId, pending, opts) : false;
6650
+ },
6552
6651
  resetPendingUserPrompts() {
6652
+ for (const pending of pendingUserPrompts.values()) {
6653
+ if (pending.timer) clearTimeout(pending.timer);
6654
+ }
6553
6655
  pendingUserPrompts.clear();
6554
6656
  },
6555
6657
  resetVpStatusBroker() {
@@ -36,6 +36,44 @@ function sumExecutionStats(values) {
36
36
  }, emptyExecutionStats());
37
37
  }
38
38
 
39
+ const MAX_FAILURE_REASON_LENGTH = 2_000;
40
+ const MAX_FAILURE_INSPECTION_LENGTH = 16_000;
41
+ const SAFE_FAILURE_FALLBACK = 'The Action failed. Sensitive details were omitted.';
42
+ const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)|api[_-]?key|access[_-]?token|authorization|password|secret|token)\s*[:=]/i;
43
+ const PROVIDER_TOKEN_PATTERN = /\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{12,}|github_pat_[A-Za-z0-9_]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|AKIA[A-Z0-9]{12,})\b/i;
44
+ const HIGH_ENTROPY_SECRET_PATTERN = /\b(?=[A-Za-z0-9_./+=-]{32,}\b)(?=[A-Za-z0-9_./+=-]*[A-Za-z])(?=[A-Za-z0-9_./+=-]*\d)[A-Za-z0-9_./+=-]+\b/;
45
+ const LOCAL_PATH_ASSIGNMENT_PATTERN = /\b(?:path|cwd|file|filename|directory)\s*[:=]\s*(?:\/(?!\/)|[A-Za-z]:\\|\\\\)/i;
46
+ const POSIX_ABSOLUTE_PATH_PATTERN = /(?:^|[\s("'`])\/(?!\/)[^\r\n]*/m;
47
+ const WINDOWS_ABSOLUTE_PATH_PATTERN = /(?:^|[\s("'`])(?:[A-Za-z]:\\|\\\\)[^\r\n]*/m;
48
+
49
+ function sanitizedUrl(raw) {
50
+ try {
51
+ const url = new URL(raw);
52
+ if (!['http:', 'https:'].includes(url.protocol)) return '[redacted URL]';
53
+ return `${url.protocol}//${url.host}${url.pathname}`;
54
+ } catch {
55
+ return '[redacted URL]';
56
+ }
57
+ }
58
+
59
+ function sanitizeFailureReason(value) {
60
+ const raw = typeof value === 'string'
61
+ ? value.trim().slice(0, MAX_FAILURE_INSPECTION_LENGTH)
62
+ : '';
63
+ if (!raw) return '';
64
+ const text = raw.replace(/https?:\/\/[^\s<>'"`]+/gi, sanitizedUrl);
65
+ if (CREDENTIAL_ASSIGNMENT_PATTERN.test(text) || PROVIDER_TOKEN_PATTERN.test(text)
66
+ || HIGH_ENTROPY_SECRET_PATTERN.test(text)) return SAFE_FAILURE_FALLBACK;
67
+ if (LOCAL_PATH_ASSIGNMENT_PATTERN.test(text) || POSIX_ABSOLUTE_PATH_PATTERN.test(text)
68
+ || WINDOWS_ABSOLUTE_PATH_PATTERN.test(text)) {
69
+ return SAFE_FAILURE_FALLBACK;
70
+ }
71
+ return text
72
+ .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, '[redacted credential]')
73
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
74
+ .slice(0, MAX_FAILURE_REASON_LENGTH);
75
+ }
76
+
39
77
  function actionExecution(action, runs) {
40
78
  const matchingRuns = Array.isArray(runs)
41
79
  ? runs.filter(run => run?.actionId === action?.id)
@@ -44,6 +82,7 @@ function actionExecution(action, runs) {
44
82
  return {
45
83
  ...executionStats(action),
46
84
  response: '',
85
+ failureReason: '',
47
86
  progressRevision: 0,
48
87
  messages: [],
49
88
  };
@@ -53,19 +92,29 @@ function actionExecution(action, runs) {
53
92
  count(right.progressRevision) - count(left.progressRevision)
54
93
  || count(right.startedAt) - count(left.startedAt)
55
94
  ))[0];
95
+ const runsByEnd = [...matchingRuns]
96
+ .sort((left, right) => count(right.endedAt || right.startedAt) - count(left.endedAt || left.startedAt));
97
+ const latestRun = runsByEnd[0];
98
+ const showCurrentFailure = action?.status === 'failed'
99
+ || ['failed', 'retryable', 'interrupted'].includes(latestRun?.status);
100
+ const latestFailure = showCurrentFailure
101
+ ? runsByEnd.find(run => sanitizeFailureReason(run?.error))
102
+ : null;
56
103
  const messages = [...matchingRuns]
57
104
  .sort((left, right) => count(left.startedAt) - count(right.startedAt))
58
105
  .map((run, index) => ({
59
106
  id: `${action.id}:${index + 1}`,
60
107
  status: run.status || 'running',
61
108
  text: typeof run.response === 'string' ? run.response.trim().slice(0, 16_000) : '',
109
+ failureReason: sanitizeFailureReason(run.error),
62
110
  createdAt: count(run.startedAt),
63
111
  updatedAt: count(run.endedAt || run.startedAt),
64
112
  }))
65
- .filter(message => message.text);
113
+ .filter(message => message.text || message.failureReason);
66
114
  return {
67
115
  ...stats,
68
116
  response: typeof latest?.response === 'string' ? latest.response : '',
117
+ failureReason: sanitizeFailureReason(latestFailure?.error),
69
118
  progressRevision: count(latest?.progressRevision),
70
119
  messages,
71
120
  };
@@ -109,6 +158,7 @@ function projectAction(action, runs) {
109
158
  loopCount: execution.loopCount,
110
159
  toolCount: execution.toolCount,
111
160
  response: execution.response,
161
+ failureReason: execution.failureReason,
112
162
  progressRevision: execution.progressRevision,
113
163
  messages: execution.messages,
114
164
  };
@@ -125,8 +175,11 @@ function projectActionStats(detail) {
125
175
  loopCount: projected.loopCount,
126
176
  toolCount: projected.toolCount,
127
177
  response: projected.response,
178
+ failureReason: projected.failureReason,
128
179
  progressRevision: projected.progressRevision,
129
- messages: projected.messages,
180
+ messages: projected.messages
181
+ .map(({ failureReason, ...message }) => message)
182
+ .filter(message => message.text),
130
183
  };
131
184
  });
132
185
  }
@@ -139,6 +192,15 @@ function waitingReason(detail) {
139
192
  ))?.waitingReason || '';
140
193
  }
141
194
 
195
+ function workItemFailureReason(detail) {
196
+ if (!['needs_attention', 'cancelled'].includes(detail?.status) || !Array.isArray(detail?.runs)) return '';
197
+ const ordered = [...detail.runs].sort((left, right) => (
198
+ count(right.endedAt || right.startedAt) - count(left.endedAt || left.startedAt)
199
+ ));
200
+ const current = ordered.find(run => run?.actionId === detail.currentActionId && sanitizeFailureReason(run.error));
201
+ return sanitizeFailureReason(current?.error || ordered.find(run => sanitizeFailureReason(run?.error))?.error);
202
+ }
203
+
142
204
  /**
143
205
  * Authenticated browser detail DTO. Raw execution records stay Agent-local;
144
206
  * the browser receives only aggregate execution stats plus the explicit user-facing response.
@@ -160,6 +222,7 @@ export function projectWorkItemDetail(detail) {
160
222
  executionStats: sumExecutionStats(Array.isArray(detail.runs) ? detail.runs : []),
161
223
  reuseMemory: detail.reuseMemory !== false,
162
224
  waitingReason: waitingReason(detail),
225
+ failureReason: workItemFailureReason(detail),
163
226
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
164
227
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
165
228
  attachments: projectAttachments(detail.attachments),
@@ -213,6 +276,7 @@ export function projectWorkItemSummary(detail) {
213
276
  status: detail.status,
214
277
  currentActionId: detail.currentActionId || null,
215
278
  executionStats: sumExecutionStats(Array.isArray(detail.runs) ? detail.runs : []),
279
+ failureReason: workItemFailureReason(detail),
216
280
  currentAction: projectedAction ? {
217
281
  id: projectedAction.id,
218
282
  type: projectedAction.type,
@@ -327,7 +327,7 @@ function completionContract(action, workItem) {
327
327
  ? ',\n "contractPatch": { "goal": "optional refined goal", "acceptanceCriteria": ["optional refined criterion"] }'
328
328
  : '';
329
329
  const planField = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
330
- ? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "what this Action must do", "approach": "how this Action should do it", "expectedOutcome": "verifiable result this Action must produce", "dependsOnActionIds": ["earlier Action id; [] means concurrent root"], "workspaceMode": "read|isolated-write|integrate|shared", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
330
+ ? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "task-specific concrete work this Action must do", "approach": "task-specific repository-aware method the executor must follow", "expectedOutcome": "task-specific verifiable result this Action must produce", "dependsOnActionIds": ["earlier Action id; [] means concurrent root"], "workspaceMode": "read|isolated-write|integrate|shared", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
331
331
  : '';
332
332
  const acceptanceChecks = (workItem?.acceptanceCriteria || []).map(criterion => ({
333
333
  criterion,
@@ -341,7 +341,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
341
341
  const typeInstruction = requestedType
342
342
  ? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
343
343
  : 'Infer one specific workItemType from the contract.';
344
- const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable graph of 1 to 8 Actions. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. Add only Actions required by this task. Do not copy a generic workflow.`;
344
+ const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable graph of 1 to 8 Actions. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. The objective, approach, and expectedOutcome must be specific to this WorkItem and that Action: describe the concrete work, the repository-aware execution method, and the verifiable result that will guide the executor. Generic Action-type boilerplate is invalid. Add only Actions required by this task. Do not copy a generic workflow.`;
345
345
  return normalizeWorkflowDefinition({
346
346
  id: 'ai-planned',
347
347
  name: 'AI planned',
@@ -434,14 +434,20 @@ export function applyGeneratedPlan(workItem, rawPlan) {
434
434
  if (seen.has(id)) throw new Error(`Duplicate AI-planned Action id: ${id}`);
435
435
  seen.add(id);
436
436
  const objective = typeof input.objective === 'string' ? input.objective.trim().slice(0, 2_000) : '';
437
- if (!objective) throw new Error(`AI-planned Action "${id}" requires an objective`);
438
- const defaults = defaultActionBrief(type);
439
- const approach = typeof input.approach === 'string' && input.approach.trim()
440
- ? input.approach.trim().slice(0, 2_000)
441
- : defaults.approach;
442
- const expectedOutcome = typeof input.expectedOutcome === 'string' && input.expectedOutcome.trim()
437
+ if (!objective) throw new Error(`AI-planned Action "${id}" requires a task-specific objective`);
438
+ const approach = typeof input.approach === 'string' ? input.approach.trim().slice(0, 2_000) : '';
439
+ if (!approach) throw new Error(`AI-planned Action "${id}" requires a task-specific approach`);
440
+ const expectedOutcome = typeof input.expectedOutcome === 'string'
443
441
  ? input.expectedOutcome.trim().slice(0, 2_000)
444
- : defaults.expectedOutcome;
442
+ : '';
443
+ if (!expectedOutcome) {
444
+ throw new Error(`AI-planned Action "${id}" requires a task-specific expectedOutcome`);
445
+ }
446
+ const defaults = defaultActionBrief(type);
447
+ if (objective === defaults.objective || approach === defaults.approach
448
+ || expectedOutcome === defaults.expectedOutcome) {
449
+ throw new Error(`AI-planned Action "${id}" must not reuse generic Action-type brief text`);
450
+ }
445
451
  const actionInstruction = Object.hasOwn(source.actionInstructions, type)
446
452
  ? source.actionInstructions[type]
447
453
  : source.actionInstructions.custom;