@yeaft/webchat-agent 1.0.195 → 1.0.197

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.195",
3
+ "version": "1.0.197",
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
@@ -1743,7 +1743,7 @@ export class Engine {
1743
1743
  * string-prompt shape (no regression for existing callers).
1744
1744
  * @yields {EngineEvent}
1745
1745
  */
1746
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1746
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1747
1747
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1748
1748
  yield {
1749
1749
  type: 'error',
@@ -1816,7 +1816,7 @@ export class Engine {
1816
1816
 
1817
1817
  try {
1818
1818
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1819
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
1819
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
1820
1820
  } finally {
1821
1821
  if (signal) {
1822
1822
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1850,7 +1850,7 @@ export class Engine {
1850
1850
  * in a try/finally without indenting the whole loop.
1851
1851
  * @private
1852
1852
  */
1853
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null, explicitSkillName = null }) {
1853
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null }) {
1854
1854
 
1855
1855
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1856
1856
  ? collabToolPolicy
@@ -3035,8 +3035,29 @@ export class Engine {
3035
3035
  // into the regular end_turn path.
3036
3036
  }
3037
3037
 
3038
- // If no tool calls, we're done
3038
+ // If no tool calls, we're done. Callers with a durable append queue may
3039
+ // atomically close it here. A failed close means input won the race with
3040
+ // terminal completion, so drain it and keep this same Engine query alive.
3039
3041
  if (stopReason !== 'tool_use' || toolCalls.length === 0) {
3042
+ if (typeof closePendingUserInput === 'function' && !closePendingUserInput()) {
3043
+ const appendedBeforeClose = this.#drainPendingUserMessages(drainPendingUserMessages);
3044
+ if (appendedBeforeClose.length === 0) {
3045
+ throw new Error('Could not close pending user input for terminal completion');
3046
+ }
3047
+ for (const item of appendedBeforeClose) {
3048
+ conversationMessages.push({ role: 'user', content: item.content });
3049
+ yield {
3050
+ type: 'user_append',
3051
+ turnId: queryTurnId,
3052
+ loopNumber: turnNumber,
3053
+ threadId,
3054
+ preview: String(item.preview || '').slice(0, 200),
3055
+ internal: Boolean(item.internal),
3056
+ };
3057
+ }
3058
+ yield { type: 'turn_end', turnNumber, stopReason: 'user_append_continue', threadId };
3059
+ continue;
3060
+ }
3040
3061
  if (pendingSubAgentNotifs.length > 0) {
3041
3062
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
3042
3063
  }
package/yeaft/skills.js CHANGED
@@ -458,6 +458,9 @@ export class SkillManager {
458
458
  /** @type {Map<string, { workspaceRoot: string, relativeRoot: string }>} */
459
459
  #secureWorkspaceByDir;
460
460
 
461
+ /** Hash of the effective loaded skill set, including prompt content. */
462
+ #snapshotHash = '';
463
+
461
464
  /**
462
465
  * @param {string | string[]} dirs — single directory (back-compat) or array of
463
466
  * directories in priority order (lowest → highest). Falsy entries are
@@ -526,14 +529,16 @@ export class SkillManager {
526
529
  * tagged with `_tier` (from the constructor's `tierByDir` map, or the dir
527
530
  * basename as fallback) so consumers can show provenance.
528
531
  *
529
- * @returns {{ loaded: number, errors: string[] }}
532
+ * @returns {{ loaded: number, errors: string[], changed: boolean }}
530
533
  */
531
534
  load() {
535
+ const previousHash = this.#snapshotHash;
532
536
  this.#skills.clear();
533
537
  const allErrors = [];
534
538
 
535
539
  if (this.#skillsDirs.length === 0) {
536
- return { loaded: 0, errors: [] };
540
+ this.#snapshotHash = this.#computeSnapshotHash();
541
+ return { loaded: 0, errors: [], changed: this.#snapshotHash !== previousHash };
537
542
  }
538
543
 
539
544
  for (const dir of this.#skillsDirs) {
@@ -554,7 +559,33 @@ export class SkillManager {
554
559
  allErrors.push(...errors);
555
560
  }
556
561
 
557
- return { loaded: this.#skills.size, errors: allErrors };
562
+ this.#snapshotHash = this.#computeSnapshotHash();
563
+ return {
564
+ loaded: this.#skills.size,
565
+ errors: allErrors,
566
+ changed: this.#snapshotHash !== previousHash,
567
+ };
568
+ }
569
+
570
+ #computeSnapshotHash() {
571
+ const records = [...this.#skills.values()]
572
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)))
573
+ .map(skill => ({
574
+ name: skill.name || '',
575
+ description: skill.description || '',
576
+ trigger: skill.trigger || '',
577
+ mode: skill.mode || 'both',
578
+ category: skill.category || '',
579
+ platforms: skill.platforms || [],
580
+ keywords: skill.keywords || [],
581
+ content: skill.content || '',
582
+ source: skill._source || '',
583
+ path: skill._path || '',
584
+ tier: skill._tier || '',
585
+ references: skill._references || [],
586
+ templates: skill._templates || [],
587
+ }));
588
+ return JSON.stringify(records);
558
589
  }
559
590
 
560
591
  /**
@@ -746,6 +777,7 @@ export class SkillManager {
746
777
  _path: filePath,
747
778
  _tier: userTier,
748
779
  });
780
+ this.#snapshotHash = this.#computeSnapshotHash();
749
781
 
750
782
  return filename;
751
783
  }
@@ -783,6 +815,7 @@ export class SkillManager {
783
815
  }
784
816
 
785
817
  this.#skills.delete(name);
818
+ this.#snapshotHash = this.#computeSnapshotHash();
786
819
  return true;
787
820
  }
788
821
 
@@ -80,6 +80,7 @@ const LEGACY_SKILL_COMMAND_PREFIX = 'skill:';
80
80
  const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
81
81
  const PROJECT_SKILL_TIERS = new Set(['project', 'project-claude', 'project-codex']);
82
82
  const BASE_RUNTIME_KEY = '__base__';
83
+ const SKILL_RELOAD_INTERVAL_MS = 2_000;
83
84
 
84
85
  /**
85
86
  * Live AskUser requests. They are Session-scoped runtime state rather than a
@@ -591,6 +592,8 @@ const projectRuntimes = new Map();
591
592
  /** @type {Map<string, Promise<any>>} */
592
593
  const baseRuntimeLoadPromises = new Map();
593
594
  let activeRuntimeKey = BASE_RUNTIME_KEY;
595
+ let skillReloadTimer = null;
596
+ let skillReloadRunning = false;
594
597
 
595
598
  function replaceSessionMcpTools(mcpManager) {
596
599
  if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
@@ -651,6 +654,7 @@ function activateProjectRuntime(runtime) {
651
654
  }
652
655
 
653
656
  async function shutdownProjectRuntimes() {
657
+ stopSkillHotReload();
654
658
  const runtimes = Array.from(projectRuntimes.values());
655
659
  projectRuntimes.clear();
656
660
  projectRuntimeLoadPromises.clear();
@@ -936,7 +940,6 @@ let yeaftConversationId = null;
936
940
  * creates/replaces the virtual Yeaft conversation id so `/` autocomplete never
937
941
  * falls back to built-ins while full Session metadata is still loading. */
938
942
  let lastYeaftSlashCommandSnapshot = null;
939
- let lastYeaftGeneratedSlashCommands = new Set();
940
943
  /** @type {Map<string, Promise<any>>} */
941
944
  const projectRuntimeLoadPromises = new Map();
942
945
 
@@ -2181,6 +2184,7 @@ export function buildMergedSkillSlashCommands(skillManagers = []) {
2181
2184
  function sendSkillSlashCommandsUpdate({ conversationId, slashCommands, slashCommandDescriptions }) {
2182
2185
  sendToServer({
2183
2186
  type: 'slash_commands_update',
2187
+ commandSet: 'yeaft',
2184
2188
  agentId: ctx.AGENT_ID || ctx.agentId || null,
2185
2189
  conversationId,
2186
2190
  slashCommands,
@@ -2221,21 +2225,10 @@ export function preloadYeaftSkillSlashCommands() {
2221
2225
 
2222
2226
  function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2223
2227
  const managers = [sessionLike?.skillManager, ...extraSkillManagers].filter(Boolean);
2224
- const { commands, descriptions } = buildMergedSkillSlashCommands(managers);
2225
- const isYeaftSkillCommand = (cmd) => typeof cmd === 'string'
2226
- && (lastYeaftGeneratedSlashCommands.has(cmd)
2227
- || cmd.startsWith(LEGACY_SKILL_COMMAND_PREFIX)
2228
- || cmd.startsWith(YEAFT_SKILL_COMMAND_PREFIX));
2229
- const nonSkillCommands = (ctx.slashCommands || []).filter(cmd => !isYeaftSkillCommand(cmd));
2230
- const slashCommands = [...new Set([...nonSkillCommands, ...commands])];
2231
- const slashCommandDescriptions = Object.fromEntries(
2232
- Object.entries(ctx.slashCommandDescriptions || {})
2233
- .filter(([cmd]) => !isYeaftSkillCommand(cmd))
2234
- );
2235
- Object.assign(slashCommandDescriptions, descriptions);
2236
- ctx.slashCommands = slashCommands;
2237
- ctx.slashCommandDescriptions = slashCommandDescriptions;
2238
- lastYeaftGeneratedSlashCommands = new Set(commands);
2228
+ const { commands: slashCommands, descriptions: slashCommandDescriptions } = buildMergedSkillSlashCommands(managers);
2229
+ // Yeaft owns an isolated command catalogue. Reusing ctx's Claude Chat
2230
+ // commands made unsupported entries such as /compact and /mcp appear in a
2231
+ // Session even though the Yeaft engine only parses effort and Skill prefixes.
2239
2232
  lastYeaftSlashCommandSnapshot = { slashCommands, slashCommandDescriptions };
2240
2233
  sendSkillSlashCommandsUpdate({
2241
2234
  conversationId: yeaftConversationId || '__preload__',
@@ -2244,6 +2237,81 @@ function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2244
2237
  });
2245
2238
  }
2246
2239
 
2240
+ function skillManagersForHotReload() {
2241
+ const managers = new Set();
2242
+ if (session?.skillManager) managers.add(session.skillManager);
2243
+ for (const runtime of projectRuntimes.values()) {
2244
+ if (runtime?.skillManager) managers.add(runtime.skillManager);
2245
+ }
2246
+ return [...managers];
2247
+ }
2248
+
2249
+ function reloadActiveSkills() {
2250
+ if (skillReloadRunning) return { changed: false, loaded: 0, errors: [] };
2251
+ skillReloadRunning = true;
2252
+ try {
2253
+ let changed = false;
2254
+ let loaded = 0;
2255
+ const errors = [];
2256
+ const changedManagers = new Set();
2257
+ const managers = skillManagersForHotReload();
2258
+ for (const manager of managers) {
2259
+ if (typeof manager?.load !== 'function') continue;
2260
+ const result = manager.load();
2261
+ if (result?.changed) {
2262
+ changed = true;
2263
+ changedManagers.add(manager);
2264
+ }
2265
+ loaded += Number(result?.loaded) || 0;
2266
+ errors.push(...(result?.errors || []));
2267
+ }
2268
+ if (changed) {
2269
+ if (session?.status && changedManagers.has(session.skillManager)) {
2270
+ session.status.skills = session.skillManager?.size || 0;
2271
+ }
2272
+ for (const runtime of projectRuntimes.values()) {
2273
+ if (runtime?.status && changedManagers.has(runtime.skillManager)) {
2274
+ runtime.status.skills = runtime.skillManager?.size || 0;
2275
+ }
2276
+ }
2277
+ if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2278
+ broadcastSkillSlashCommands(session);
2279
+ } else {
2280
+ const runtime = projectRuntimes.get(activeRuntimeKey);
2281
+ broadcastSkillSlashCommands(session, runtime?.skillManager ? [runtime.skillManager] : []);
2282
+ }
2283
+ const activeRuntime = activeRuntimeKey === BASE_RUNTIME_KEY ? null : projectRuntimes.get(activeRuntimeKey);
2284
+ const activeStatus = activeRuntime ? mergedStatusForProjectRuntime(activeRuntime) : session?.status;
2285
+ hydrateYeaftStatusFromSession(
2286
+ activeStatus && session ? { ...session, status: activeStatus } : session,
2287
+ { reason: 'skills_hot_reload', emitEvent: true },
2288
+ );
2289
+ }
2290
+ if (errors.length > 0) {
2291
+ console.warn(`[Yeaft] skill hot reload completed with ${errors.length} error(s):`, errors.join('; '));
2292
+ }
2293
+ return { changed, loaded, errors };
2294
+ } finally {
2295
+ skillReloadRunning = false;
2296
+ }
2297
+ }
2298
+
2299
+ function startSkillHotReload() {
2300
+ if (skillReloadTimer) return;
2301
+ skillReloadTimer = setInterval(() => {
2302
+ try { reloadActiveSkills(); }
2303
+ catch (err) { console.warn('[Yeaft] skill hot reload failed:', err?.message || err); }
2304
+ }, SKILL_RELOAD_INTERVAL_MS);
2305
+ skillReloadTimer.unref?.();
2306
+ }
2307
+
2308
+ function stopSkillHotReload() {
2309
+ if (!skillReloadTimer) return;
2310
+ clearInterval(skillReloadTimer);
2311
+ skillReloadTimer = null;
2312
+ skillReloadRunning = false;
2313
+ }
2314
+
2247
2315
  async function loadBaseRuntime() {
2248
2316
  if (!session) return null;
2249
2317
  const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
@@ -2267,6 +2335,7 @@ async function loadBaseRuntime() {
2267
2335
  } else {
2268
2336
  broadcastSkillSlashCommands(session);
2269
2337
  }
2338
+ startSkillHotReload();
2270
2339
  hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_skills', emitEvent: true });
2271
2340
 
2272
2341
  if (mcpConfig.servers.length > 0) {
@@ -2341,6 +2410,7 @@ async function loadProjectRuntime(workDir) {
2341
2410
  // Skill metadata is available after the filesystem scan; publish it before
2342
2411
  // any MCP server startup so autocomplete does not wait on external processes.
2343
2412
  activateProjectRuntime(runtime);
2413
+ startSkillHotReload();
2344
2414
  if (mcpConfig.servers.length > 0) {
2345
2415
  mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2346
2416
  runtime.mcpStatus = mcpStatus;
@@ -6716,6 +6786,9 @@ export const __testHooks = {
6716
6786
  preloadYeaftSkillSlashCommandsForTest() {
6717
6787
  return broadcastSkillSlashCommands(session);
6718
6788
  },
6789
+ reloadActiveSkillsForTest() {
6790
+ return reloadActiveSkills();
6791
+ },
6719
6792
  loadAndBroadcastYeaftSkillSlashCommandsForTest() {
6720
6793
  return loadAndBroadcastYeaftSkillSlashCommands();
6721
6794
  },
@@ -249,18 +249,35 @@ export class WorkflowController {
249
249
  const workItem = this.store.getWorkItem(id);
250
250
  if (!workItem) throw new Error(`WorkItem not found: ${id}`);
251
251
  if (['ready', 'running'].includes(workItem.status)) {
252
- if (workItem.currentActionId !== input.actionId || workItem.revision !== input.revision) {
253
- throw new Error('Action changed before input was applied; refresh and try again');
252
+ const activeAction = this.store.getAction(input.actionId);
253
+ if (activeAction?.status === 'running' && addedAttachmentCount > 0) {
254
+ throw new Error('Files cannot be added while an Action is running; send text now or wait for the next Action boundary');
254
255
  }
255
- return this.guide(id, {
256
- guidance: text,
256
+ const inputSummary = text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
257
+ return this.store.addActionInput(id, inputSummary, {
257
258
  actionId: input.actionId,
258
259
  generation: input.generation,
259
260
  revision: input.revision,
260
- addedAttachmentCount,
261
- addedAttachments: input.addedAttachments,
262
- attachments: input.attachments,
263
- });
261
+ }, (current, action) => {
262
+ const context = [...(action.context || []), {
263
+ type: 'input', role: 'user', summary: inputSummary, evidence: [],
264
+ }];
265
+ const step = {
266
+ type: action.type,
267
+ stageId: action.stageId || action.type,
268
+ assignmentPolicy: action.assignmentPolicy,
269
+ modelPolicy: action.modelPolicy,
270
+ requiredRole: action.requiredRole,
271
+ dependsOnStageIds: action.dependsOnStageIds,
272
+ workspaceMode: action.workspaceMode,
273
+ changesRequestedStageId: action.changesRequestedStageId,
274
+ brief: action.brief,
275
+ };
276
+ return {
277
+ context,
278
+ instruction: actionInstruction(step, current, context, renderSessionContextSnapshot(current.sessionContext)),
279
+ };
280
+ }, input.attachments, input.addedAttachments);
264
281
  }
265
282
  if (!['waiting', 'needs_attention'].includes(workItem.status)) {
266
283
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
@@ -341,6 +358,13 @@ export class WorkflowController {
341
358
  const activeAction = activeRun ? this.store.getAction(activeRun.actionId) : null;
342
359
  if (!activeRun || !activeAction) throw new Error('Run is stale, cancelled, or already finished');
343
360
  const activeWorkItem = this.store.getWorkItem(activeRun.workItemId);
361
+ if (activeRun.acceptingInput !== false
362
+ && !this.store.closeRunInput(runId, ownerBootId, leaseEpoch)) {
363
+ if (!this.store.isActiveRun(runId, ownerBootId, leaseEpoch)) {
364
+ throw new Error('Run is stale, cancelled, expired, or already finished');
365
+ }
366
+ throw new Error('Run has unconsumed Action input and cannot finish yet');
367
+ }
344
368
  const result = normalizeTerminalResult(rawResult, activeAction);
345
369
  validateCompletedResult(result, activeAction, activeWorkItem);
346
370
  let validatedGeneratedWorkflow = null;
@@ -113,12 +113,30 @@ function runResponseMessage(run) {
113
113
  });
114
114
  }
115
115
 
116
+ function loopOutputMessages(action, events) {
117
+ return (Array.isArray(events) ? events : [])
118
+ .filter(event => event?.actionId === action?.id && event.type === 'run.loop_output')
119
+ .map(event => normalizeProjectedMessage({
120
+ id: `event:${event.id}`,
121
+ role: 'assistant',
122
+ kind: 'response',
123
+ status: 'completed',
124
+ text: event.data?.response || '',
125
+ createdAt: event.createdAt,
126
+ }))
127
+ .filter(Boolean);
128
+ }
129
+
116
130
  function actionMessages(action, runs, events) {
117
131
  const matchingRuns = Array.isArray(runs)
118
132
  ? runs.filter(run => run?.actionId === action?.id)
119
133
  : [];
120
- return [...actionInputMessages(action, events), ...matchingRuns
134
+ const runsWithLoopOutput = new Set((Array.isArray(events) ? events : [])
135
+ .filter(event => event?.actionId === action?.id && event.type === 'run.loop_output' && event.runId)
136
+ .map(event => event.runId));
137
+ return [...actionInputMessages(action, events), ...loopOutputMessages(action, events), ...matchingRuns
121
138
  .sort((left, right) => count(left.startedAt) - count(right.startedAt))
139
+ .filter(run => !runsWithLoopOutput.has(run.id))
122
140
  .map(run => runResponseMessage(run))
123
141
  .filter(Boolean)]
124
142
  .sort((left, right) => left.createdAt - right.createdAt
@@ -294,6 +312,18 @@ function projectAction(action, runs, events, includeBody = true) {
294
312
  key,
295
313
  truncateUtf8(value, includeBody ? MAX_CURRENT_BRIEF_BYTES : MAX_HISTORICAL_BRIEF_CHARS),
296
314
  ]));
315
+ const matchingRuns = Array.isArray(runs) ? runs.filter(run => run?.actionId === action.id) : [];
316
+ const latestRun = [...matchingRuns].sort((left, right) => (
317
+ count(right.startedAt) - count(left.startedAt) || count(right.progressRevision) - count(left.progressRevision)
318
+ ))[0];
319
+ const assignedVp = latestRun?.vpSnapshot ? {
320
+ id: latestRun.vpSnapshot.id || null,
321
+ name: latestRun.vpSnapshot.name || latestRun.vpSnapshot.id || null,
322
+ } : null;
323
+ const contentSummary = truncateUtf8(
324
+ execution.response || latestRun?.summary || brief?.objective || brief?.expectedOutcome || '',
325
+ MAX_HISTORICAL_BRIEF_CHARS,
326
+ );
297
327
  return {
298
328
  id: action.id,
299
329
  sequence: action.sequence,
@@ -307,6 +337,8 @@ function projectAction(action, runs, events, includeBody = true) {
307
337
  requiredRole: action.requiredRole || '',
308
338
  brief: projectedBrief,
309
339
  status: action.status,
340
+ assignedVp,
341
+ contentSummary,
310
342
  executionStats: executionStats(execution),
311
343
  loopCount: execution.loopCount,
312
344
  toolCount: execution.toolCount,
@@ -366,6 +398,8 @@ function projectActionStats(detail) {
366
398
  const stats = {
367
399
  id: projected.id,
368
400
  status: projected.status,
401
+ assignedVp: projected.assignedVp,
402
+ contentSummary: projected.contentSummary,
369
403
  executionStats: projected.executionStats,
370
404
  loopCount: projected.loopCount,
371
405
  toolCount: projected.toolCount,
@@ -783,7 +783,7 @@ export class WorkItemRunner {
783
783
  }
784
784
  }
785
785
 
786
- async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader }) {
786
+ async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader, registerInputWake }) {
787
787
  const runtime = await this.runtimeProvider();
788
788
  const currentSettings = workItem?.workflowSnapshot?.planningMode === 'ai' && this.policyProvider
789
789
  ? await this.policyProvider()
@@ -840,6 +840,7 @@ export class WorkItemRunner {
840
840
  reuseMemory: workItem.reuseMemory,
841
841
  });
842
842
  const attachmentContext = buildWorkItemAttachmentContext(workItem, { root: this.attachmentRoot });
843
+ const attachmentFileById = new Map(attachmentContext.files.map(file => [file.id, file]));
843
844
  const fixedPromptSuffix = `${resumeBlock}${attachmentContext.promptBlock}${completionContract(executionAction, workItem)}`;
844
845
  const reservedPromptBytes = Buffer.byteLength(fixedPromptSuffix, 'utf8');
845
846
  const mainline = v2Execution
@@ -992,6 +993,29 @@ export class WorkItemRunner {
992
993
  taskManager: null,
993
994
  vpId: vp.id,
994
995
  });
996
+ if (typeof registerInputWake === 'function') {
997
+ registerInputWake(() => engine.wakeForPendingUserMessage?.());
998
+ }
999
+ const drainPendingUserMessages = () => {
1000
+ const pending = this.store.listPendingActionInputs?.(
1001
+ action.id, run.id, ownerBootId, run.leaseEpoch,
1002
+ ) || [];
1003
+ const accepted = [];
1004
+ for (const item of pending) {
1005
+ const attachmentLines = item.attachments.map(attachment => {
1006
+ const file = attachmentFileById.get(attachment.id);
1007
+ return file ? `- ${attachment.name}: ${file.ref}` : `- ${attachment.name}`;
1008
+ });
1009
+ const content = [item.text, attachmentLines.length > 0
1010
+ ? `Additional WorkItem attachments:\n${attachmentLines.join('\n')}` : '']
1011
+ .filter(Boolean).join('\n\n');
1012
+ if (!content || !this.store.acknowledgeActionInput?.(
1013
+ item.id, action.id, run.id, ownerBootId, run.leaseEpoch,
1014
+ )) continue;
1015
+ accepted.push({ content, preview: item.text || '[attachments]' });
1016
+ }
1017
+ return accepted;
1018
+ };
995
1019
  try {
996
1020
  const prompt = v2Execution
997
1021
  ? `${renderMainlineContextSnapshot(mainline.contextSnapshot)}${fixedPromptSuffix}`
@@ -1015,9 +1039,19 @@ export class WorkItemRunner {
1015
1039
  workCenterInstructions: workItem?.workflowSnapshot?.globalInstructions || '',
1016
1040
  workDir,
1017
1041
  userAlreadyPersisted: true,
1042
+ drainPendingUserMessages,
1043
+ closePendingUserInput: () => this.store.closeRunInput(
1044
+ run.id, ownerBootId, run.leaseEpoch,
1045
+ ),
1018
1046
  collabToolPolicy: 'single-vp',
1019
1047
  })) {
1020
- if (event?.type === 'loop') loopCount += 1;
1048
+ if (event?.type === 'loop') {
1049
+ loopCount += 1;
1050
+ this.store.appendRunLoop?.(run.id, ownerBootId, run.leaseEpoch, {
1051
+ ...event,
1052
+ response: publicWorkItemResponse(event.response),
1053
+ });
1054
+ }
1021
1055
  else if (event?.type === 'tool_start') toolInputs.set(event.id, event.input);
1022
1056
  else if (event?.type === 'tool_end') {
1023
1057
  toolCount += 1;
@@ -93,7 +93,7 @@ export class WorkCenterService {
93
93
  case 'get_action_messages': {
94
94
  const detail = this.#requiredItem(payload.id);
95
95
  const action = this.#requiredAction(detail, payload.actionId);
96
- return projectActionMessagePage(action, detail.runs, detail.events, {
96
+ return projectActionMessagePage(action, detail.runs, this.store.listActionEvents(action.id), {
97
97
  cursor: payload.cursor,
98
98
  limit: payload.limit,
99
99
  });
@@ -237,6 +237,7 @@ export class WorkCenterService {
237
237
  throw error;
238
238
  }
239
239
  this.watcher.abortInvalidWorkItemRuns(id);
240
+ this.watcher.notifyActionInput(id, payload.actionId);
240
241
  this.#emit({ type: 'action.input_added', workItem: detail });
241
242
  return detail;
242
243
  }