@yeaft/webchat-agent 1.0.649 → 1.0.651

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.
@@ -4,8 +4,10 @@ import { sendToServer, parseMessage } from './buffer.js';
4
4
  import { startAgentHeartbeat, stopAgentHeartbeat, scheduleReconnect } from './heartbeat.js';
5
5
  import { handleMessage } from './message-router.js';
6
6
  import { cleanupTerminalsForDisconnect } from '../terminal.js';
7
+ import { invalidateYeaftProjectContexts } from '../yeaft/web-bridge.js';
7
8
 
8
9
  export function resetConnectionTransport() {
10
+ invalidateYeaftProjectContexts();
9
11
  ctx.sessionKey = null;
10
12
  ctx.serverEncryptionRequired = true;
11
13
  ctx.serverCapabilities = new Set();
@@ -106,6 +108,7 @@ export function connect(WebSocketImpl = WebSocket) {
106
108
  socket.on('close', (code, reason) => {
107
109
  if (socket !== ctx.ws) return;
108
110
  console.log(`Disconnected from server: ${code} ${reason}`);
111
+ invalidateYeaftProjectContexts();
109
112
  ctx.sessionKey = null;
110
113
  ctx.pendingAuthTempId = null;
111
114
  stopAgentHeartbeat();
@@ -30,7 +30,7 @@ import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, g
30
30
  import { loadConfig } from '../yeaft/config.js';
31
31
  import { discoverLlmModels } from '../llm-model-discovery.js';
32
32
  import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
33
- import { handleYeaftSessionSend, handleYeaftAskUserAnswer, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, refreshLiveSessionConfig, handleYeaftLoadHistory, handleYeaftLoadHistoryOutline, handleYeaftSearchHistory, handleYeaftLoadHistoryWindow, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftProjectContextSync, handleYeaftProjectMutation, handleYeaftCreateSession, handleYeaftCopySession, handleYeaftMergeSessions, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, handleYeaftPluginCatalog, handleYeaftManagedSkill, ensureSessionLoaded, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager, broadcastYeaftVpSnapshotEager, preloadYeaftSkillSlashCommands } from '../yeaft/web-bridge.js';
33
+ import { invalidateYeaftProjectContexts, startYeaftScheduledTasks, handleYeaftScheduledTask, handleYeaftSessionSend, handleYeaftAskUserAnswer, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, refreshLiveSessionConfig, handleYeaftLoadHistory, handleYeaftLoadHistoryOutline, handleYeaftSearchHistory, handleYeaftLoadHistoryWindow, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftProjectContextSync, handleYeaftProjectMutation, handleYeaftCreateSession, handleYeaftCopySession, handleYeaftMergeSessions, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, handleYeaftPluginCatalog, handleYeaftManagedSkill, ensureSessionLoaded, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager, broadcastYeaftVpSnapshotEager, preloadYeaftSkillSlashCommands } from '../yeaft/web-bridge.js';
34
34
  import { startYeaftStatusRefresh, forceRefreshYeaftStatus } from '../yeaft/status-cache.js';
35
35
  import { handleWorkCenterRequest } from '../yeaft/work-center/bridge.js';
36
36
  import { handlePersonRequest } from '../yeaft/person/bridge.js';
@@ -175,6 +175,7 @@ export async function handleMessage(msg) {
175
175
  if (await handleBrowserRuntimeMessage(msg)) return;
176
176
  switch (msg.type) {
177
177
  case 'registered':
178
+ invalidateYeaftProjectContexts();
178
179
  applyRegisteredTransport(msg);
179
180
 
180
181
  // 只保存基本配置。instanceId 是本地服务实例身份;agentName 只用于展示。
@@ -210,6 +211,7 @@ export async function handleMessage(msg) {
210
211
  // The callee already wraps its FS scan + emit in try/catch and
211
212
  // logs via console.warn — no second guard needed here.
212
213
  broadcastYeaftSessionSnapshotEager();
214
+ startYeaftScheduledTasks();
213
215
  // Stock VPs are bundled Agent data. Seed + publish them during register so
214
216
  // a first-install user can open the create dialog immediately instead of
215
217
  // depending on a later Session runtime or modal subscription race.
@@ -847,6 +849,9 @@ export async function handleMessage(msg) {
847
849
  handleYeaftRestoreSession(msg);
848
850
  break;
849
851
  // Phase 2: session_send is just group_chat (N≥1 fan-out already works).
852
+ case 'yeaft_scheduled_task':
853
+ handleYeaftScheduledTask(msg);
854
+ break;
850
855
  case 'yeaft_session_send':
851
856
  handleYeaftSessionSend(msg);
852
857
  break;
package/index.js CHANGED
@@ -178,7 +178,7 @@ async function detectCapabilities() {
178
178
  // agent build can speak plaintext WS frames. New servers see this and
179
179
  // flip `agent.encryptOutbound = false`, stopping outbound encryption
180
180
  // to this peer. Old servers ignore the unknown capability token.
181
- const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'workbench_file_content_chunks', 'workbench_video_stream', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'session_fork_from_turn', 'session_fork_while_running', 'session_merge', 'file_reference_resolution', 'response_image_preview', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation', 'work_center_feature_settings'];
181
+ const capabilities = ['scheduled_tasks', 'scheduled_task_month_days', 'background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'workbench_file_content_chunks', 'workbench_video_stream', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'session_fork_from_turn', 'session_fork_while_running', 'session_merge', 'file_reference_resolution', 'response_image_preview', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation', 'work_center_feature_settings'];
182
182
  capabilities.push('digital_person', getAgentUpgradeCapability());
183
183
  if (ctx.CONFIG?.workCenterEnabled === true) {
184
184
  capabilities.push('work_center', 'work_center_message_v2', 'work_center_workbench');
@@ -404,6 +404,7 @@ const yeaftSessionsTable = `
404
404
  default_vp_id TEXT,
405
405
  work_dir TEXT,
406
406
  config_json TEXT,
407
+ task_json TEXT,
407
408
  announcement TEXT,
408
409
  created_at INTEGER,
409
410
  updated_at INTEGER NOT NULL,
@@ -519,6 +520,7 @@ const yeaftMigrations = [
519
520
  `ALTER TABLE yeaft_sessions ADD COLUMN is_pinned INTEGER DEFAULT 0`,
520
521
  `ALTER TABLE yeaft_sessions ADD COLUMN sort_order INTEGER`,
521
522
  `ALTER TABLE yeaft_sessions ADD COLUMN metadata_updated_at INTEGER`,
523
+ `ALTER TABLE yeaft_sessions ADD COLUMN task_json TEXT`,
522
524
  ];
523
525
  for (const migration of yeaftMigrations) {
524
526
  try { db.exec(migration); } catch (_) { /* column exists */ }
@@ -1408,8 +1410,8 @@ export const stmts = {
1408
1410
  upsertYeaftSession: db.prepare(`
1409
1411
  INSERT INTO yeaft_sessions
1410
1412
  (id, user_id, agent_id, name, roster_json, default_vp_id, work_dir,
1411
- config_json, announcement, created_at, updated_at, is_archived)
1412
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1413
+ config_json, announcement, created_at, updated_at, is_archived, task_json)
1414
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1413
1415
  ON CONFLICT(user_id, agent_id, id) DO UPDATE SET
1414
1416
  user_id = COALESCE(excluded.user_id, user_id),
1415
1417
  name = excluded.name,
@@ -1417,6 +1419,7 @@ export const stmts = {
1417
1419
  default_vp_id = excluded.default_vp_id,
1418
1420
  work_dir = excluded.work_dir,
1419
1421
  config_json = excluded.config_json,
1422
+ task_json = excluded.task_json,
1420
1423
  announcement = excluded.announcement,
1421
1424
  created_at = COALESCE(yeaft_sessions.created_at, excluded.created_at),
1422
1425
  updated_at = excluded.updated_at,
@@ -267,6 +267,8 @@ export const yeaftProjectDb = {
267
267
  for (const rawSessionId of Array.isArray(row.sessionIds) ? row.sessionIds : []) {
268
268
  const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : '';
269
269
  if (!sessionId || !sessionExists(sessionId)) continue;
270
+ // Legacy discovery is not authority over an existing Server membership.
271
+ if (stmts.getYeaftProjectForSession.get(ownerId, targetAgentId, sessionId)) continue;
270
272
  stmts.deleteYeaftProjectSessionMembership.run(ownerId, targetAgentId, sessionId);
271
273
  stmts.insertYeaftProjectSessionMembership.run(
272
274
  ownerId,
@@ -39,7 +39,9 @@ function sessionOrderKey(agentId, sessionId) {
39
39
 
40
40
  function mapRow(row) {
41
41
  if (!row) return row;
42
+ const task = safeJsonParse(row.task_json, null);
42
43
  return {
44
+ ...(task && typeof task === 'object' && !Array.isArray(task) ? { task } : {}),
43
45
  id: row.id,
44
46
  userId: row.user_id || null,
45
47
  agentId: row.agent_id,
@@ -72,7 +74,7 @@ export const yeaftSessionDb = {
72
74
  * @param {string} userId
73
75
  * @param {string} agentId
74
76
  * @param {object} session Shape from `web-bridge.snapshotSessions()`:
75
- * { id, name, roster[], defaultVpId, workDir, config, announcement, createdAt }
77
+ * { id, name, roster[], defaultVpId, workDir, config, announcement, createdAt, task? }
76
78
  */
77
79
  upsertFromSnapshot(userId, agentId, session) {
78
80
  if (!session || !session.id || !agentId) return;
@@ -90,6 +92,7 @@ export const yeaftSessionDb = {
90
92
  session.createdAt || now,
91
93
  now,
92
94
  0,
95
+ safeJsonStringify(session.task && typeof session.task === 'object' && !Array.isArray(session.task) ? session.task : null),
93
96
  );
94
97
  if (session.metadataUpdatedAt) {
95
98
  stmts.touchYeaftSessionMetadata.run(
@@ -6,6 +6,7 @@ import { advanceYeaftDebugRequestChunk, consumeYeaftDebugRequest, webClients, pr
6
6
  import { CONFIG } from '../config.js';
7
7
  import { yeaftAssetStore } from '../yeaft-asset-store.js';
8
8
  import { recordPerfTraceEvent } from '../perf-trace.js';
9
+ import { syncProjectContextsToAgents } from './client-conversation.js';
9
10
 
10
11
 
11
12
  /** Keep Skill detail read-only and browser-safe, including replies from newer Agents. */
@@ -69,7 +70,7 @@ function reconcileAuthoritativeSessionSnapshot(ownerId, agentId, sessions) {
69
70
  return rows;
70
71
  }
71
72
 
72
- function syncYeaftSessionMetadata(agentId, agent, event) {
73
+ async function syncYeaftSessionMetadata(agentId, agent, event) {
73
74
  if (!event || typeof event !== 'object') return event;
74
75
  const ownerId = agent?.ownerId || null;
75
76
 
@@ -80,6 +81,8 @@ function syncYeaftSessionMetadata(agentId, agent, event) {
80
81
  .map(session => [session.id, { ...session }]));
81
82
  try {
82
83
  if (ownerId) {
84
+ // Catalog discovery can precede the one-time legacy Project import.
85
+ // Existing memberships are protected by importLegacyProjects itself.
83
86
  reconcileAuthoritativeSessionSnapshot(ownerId, agentId, rows);
84
87
  yeaftProjectDb.importLegacyProjects(
85
88
  ownerId,
@@ -87,6 +90,7 @@ function syncYeaftSessionMetadata(agentId, agent, event) {
87
90
  event.projects,
88
91
  sessionId => rows.some(row => row?.id === sessionId),
89
92
  );
93
+ await syncProjectContextsToAgents(ownerId, agentId);
90
94
  }
91
95
  } catch (e) {
92
96
  console.warn(`[Server] yeaft session persist failed for agent ${agentId}:`, e?.message || e);
@@ -99,12 +103,30 @@ function syncYeaftSessionMetadata(agentId, agent, event) {
99
103
  const sessionId = event.sessionId;
100
104
  if (event.ok && op === 'list' && Array.isArray(event.sessions)) {
101
105
  try {
102
- if (ownerId) reconcileAuthoritativeSessionSnapshot(ownerId, agentId, event.sessions);
106
+ if (ownerId) {
107
+ reconcileAuthoritativeSessionSnapshot(ownerId, agentId, event.sessions);
108
+ await syncProjectContextsToAgents(ownerId, agentId);
109
+ }
103
110
  } catch (e) {
104
111
  console.warn(`[Server] yeaft session list persist failed for agent ${agentId}:`, e?.message || e);
105
112
  }
106
113
  return { ...event, sessions: decorateYeaftSessionsWithPinned(agentId, event.sessions) };
107
114
  }
115
+ // Task state is Agent-authored. Persist successful CRUD rows as well as full
116
+ // snapshots so reloads after an acknowledgement cannot show stale run state.
117
+ if (event.ok && ownerId && event.session?.id
118
+ && (['update_task', 'run_task'].includes(op)
119
+ || (event.session.task && op !== 'copy' && op !== 'merge'))) {
120
+ try {
121
+ const existing = yeaftSessionDb.getForAgent(ownerId, agentId, event.session.id);
122
+ yeaftSessionDb.upsertFromSnapshot(ownerId, agentId, { ...existing, ...event.session });
123
+ agent.yeaftSessions ??= new Map();
124
+ agent.yeaftSessions.set(event.session.id, { ...existing, ...event.session });
125
+ if (op === 'create') await syncProjectContextsToAgents(ownerId, agentId);
126
+ } catch (e) {
127
+ console.warn(`[Server] task Session persist failed for agent ${agentId}:`, e?.message || e);
128
+ }
129
+ }
108
130
  // A fork/merge is a new Session on the same Agent, not a new Project. Resolve
109
131
  // membership here (never from browser/Agent project metadata) before the
110
132
  // successful acknowledgement can focus the new Session in the browser.
@@ -628,7 +650,7 @@ export async function handleAgentOutput(agentId, agent, msg) {
628
650
  case 'session_output': {
629
651
  if (isRetiredDreamEvent(msg.event)) break;
630
652
  const data = hydrateInlinePreviewData(msg.data);
631
- let event = syncYeaftSessionMetadata(agentId, agent, msg.event);
653
+ let event = await syncYeaftSessionMetadata(agentId, agent, msg.event);
632
654
  if ((event?.type === 'session_list_updated' || event?.type === 'session_crud_result') && agent.ownerId) {
633
655
  event = {
634
656
  ...event,
@@ -636,7 +658,12 @@ export async function handleAgentOutput(agentId, agent, msg) {
636
658
  projectsAuthoritative: true,
637
659
  };
638
660
  }
639
- let catalogChanged = false;
661
+ // Task notifications are live owner events, never request-client events.
662
+ // Do not widen this boundary in skip-auth mode when an owner is known.
663
+ const taskNotification = event?.type === 'scheduled_task_notification';
664
+ const taskResult = event?.type === 'session_crud_result'
665
+ && (['update_task', 'run_task'].includes(event.op) || event.session?.task);
666
+ let catalogChanged = !!(taskResult && event.ok && event.session?.id);
640
667
  if (event?.type === 'yeaft_status') {
641
668
  agent.yeaftStatus = event;
642
669
  await broadcastAgentList();
@@ -655,6 +682,7 @@ export async function handleAgentOutput(agentId, agent, msg) {
655
682
  defaultVpId: event.defaultVpId != null ? event.defaultVpId : (existing.defaultVpId || null),
656
683
  workDir: existing.workDir || '',
657
684
  config: existing.config || {},
685
+ ...(existing.task ? { task: existing.task } : {}),
658
686
  announcement: typeof event.announcement === 'string'
659
687
  ? event.announcement
660
688
  : (existing.announcement || ''),
@@ -685,7 +713,8 @@ export async function handleAgentOutput(agentId, agent, msg) {
685
713
  }
686
714
  // History request completions are client-scoped; live Session output stays
687
715
  // broadcast to every authenticated tab owned by this user.
688
- const hasRequestedClient = typeof msg._requestClientId === 'string' && !!msg._requestClientId;
716
+ const hasRequestedClient = !taskNotification
717
+ && typeof msg._requestClientId === 'string' && !!msg._requestClientId;
689
718
  const requestedClient = hasRequestedClient ? webClients.get(msg._requestClientId) : null;
690
719
  const outputClients = hasRequestedClient
691
720
  ? (requestedClient ? [[msg._requestClientId, requestedClient]] : [])
@@ -715,7 +744,10 @@ export async function handleAgentOutput(agentId, agent, msg) {
715
744
  // are non-empty in practice, but we don't want the relay silently
716
745
  // eating a legitimate "" or 0 if one ever shows up.
717
746
  for (const [cId, c] of outputClients) {
718
- if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
747
+ const ownerMatches = taskNotification || taskResult
748
+ ? !!agent.ownerId && c.userId === agent.ownerId
749
+ : CONFIG.skipAuth || c.userId === agent.ownerId;
750
+ if (c.authenticated && ownerMatches) {
719
751
  await sendToWebClient(c, {
720
752
  type: 'yeaft_output',
721
753
  conversationId: msg.conversationId,
@@ -977,7 +1009,7 @@ export async function handleAgentOutput(agentId, agent, msg) {
977
1009
  // Use one batch read (`getByAgent`) and a Set lookup instead of an
978
1010
  // N+1 `get(id)` per row — relays this size hot path on every
979
1011
  // snapshot per connected client.
980
- const syncedEvent = syncYeaftSessionMetadata(agentId, agent, msg);
1012
+ const syncedEvent = await syncYeaftSessionMetadata(agentId, agent, msg);
981
1013
  const decoratedSessions = syncedEvent.sessions;
982
1014
  await broadcastSessionCatalog(agent.ownerId);
983
1015
  // Relay verbatim to web (agentId stamped so the web sessions store
@@ -1020,6 +1052,7 @@ export async function handleAgentOutput(agentId, agent, msg) {
1020
1052
  : (existing?.defaultVpId || null),
1021
1053
  workDir: existing?.workDir || '',
1022
1054
  config: existing?.config || {},
1055
+ ...(existing?.task ? { task: existing.task } : {}),
1023
1056
  announcement: typeof msg.announcement === 'string'
1024
1057
  ? msg.announcement
1025
1058
  : (existing?.announcement || ''),
@@ -1042,12 +1075,19 @@ export async function handleAgentOutput(agentId, agent, msg) {
1042
1075
  }
1043
1076
 
1044
1077
  case 'session_crud_result': {
1078
+ // Task-capable Agents may use the top-level CRUD alias. Normalize it to
1079
+ // the same requester-scoped envelope as nested task acknowledgements.
1080
+ if (['update_task', 'run_task'].includes(msg.op) || msg.session?.task) {
1081
+ const { type: _type, _requestClientId, ...event } = msg;
1082
+ await handleAgentOutput(agentId, agent, { type: 'yeaft_output', _requestClientId, event });
1083
+ break;
1084
+ }
1045
1085
  // Surface op result to the web (the only client that cares about
1046
1086
  // requestId routing). Keep the server shadow table in sync for list
1047
1087
  // probes too: entering Yeaft asks each connected agent for its opened
1048
1088
  // sessions, and that response must carry persisted server-side pin
1049
1089
  // state before it hits the web store.
1050
- const syncedMsg = syncYeaftSessionMetadata(agentId, agent, msg);
1090
+ const syncedMsg = await syncYeaftSessionMetadata(agentId, agent, msg);
1051
1091
  const outboundMsg = agent.ownerId
1052
1092
  ? { ...syncedMsg, projects: yeaftProjectDb.listForAgent(agent.ownerId, agentId), projectsAuthoritative: true }
1053
1093
  : { ...syncedMsg, projects: [], projectsAuthoritative: true };
@@ -22,6 +22,7 @@ import {
22
22
  verifyConversationOwnership, verifyAgentOwnership, resolveAgentAccessError
23
23
  } from '../ws-utils.js';
24
24
  import { routeSessionPin } from './session-pin-router.js';
25
+ import { handleClientScheduledTasks } from './client-scheduled-tasks.js';
25
26
  import { recordPerfTraceEvent } from '../perf-trace.js';
26
27
  import {
27
28
  chatCatalogKey,
@@ -119,9 +120,10 @@ async function sendVpSnapshotError(client, msg, error) {
119
120
  });
120
121
  }
121
122
 
122
- export async function syncProjectContextsToAgents(userId) {
123
+ export async function syncProjectContextsToAgents(userId, targetAgentId = null) {
123
124
  try {
124
- const sessionsByAgent = groupOnlineYeaftSessions(yeaftSessionDb.getByUser(userId));
125
+ const sessionsByAgent = groupOnlineYeaftSessions(yeaftSessionDb.getByUser(userId)
126
+ .filter(row => !targetAgentId || row.agentId === targetAgentId));
125
127
  await Promise.all(Object.entries(sessionsByAgent).map(async ([agentId, sessions]) => {
126
128
  try {
127
129
  const contexts = sessions.map(session => ({
@@ -303,6 +305,7 @@ function skippedYeaftDreamResult(msg, reason) {
303
305
  * update_conversation_settings, ask_user_answer, btw_question
304
306
  */
305
307
  export async function handleClientConversation(clientId, client, msg, checkAgentAccess) {
308
+ if (await handleClientScheduledTasks(clientId, client, msg)) return true;
306
309
  switch (msg.type) {
307
310
  case 'get_agents':
308
311
  // 前端可能附带 conversationIds(server 重启后恢复场景)
@@ -1736,6 +1739,15 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
1736
1739
  if (typeof msg.type === 'string' && (msg.type.startsWith('yeaft_') || msg.type.startsWith('unify_'))) {
1737
1740
  const relayType = msg.type.startsWith('unify_') ? `yeaft_${msg.type.slice('unify_'.length)}` : msg.type;
1738
1741
  const relayAgentId = msg.agentId || client.currentAgent;
1742
+ // Internal authority messages are generated from Server SQLite only.
1743
+ // Generic relay must not turn a browser assertion into trusted context.
1744
+ if (relayType === 'yeaft_project_context_sync') {
1745
+ await sendToWebClient(client, {
1746
+ type: 'error', code: 'server_only_message', message: 'Project context synchronization is Server-only.',
1747
+ requestId: msg.requestId ?? null, agentId: relayAgentId || null,
1748
+ });
1749
+ return true;
1750
+ }
1739
1751
  const isSessionSend = relayType === 'yeaft_session_send' || relayType === 'yeaft_session_chat';
1740
1752
  if (msg.perfTraceId) {
1741
1753
  recordPerfTraceEvent({
@@ -0,0 +1,121 @@
1
+ import { agents } from '../context.js';
2
+ import { yeaftProjectDb, yeaftSessionDb } from '../database.js';
3
+ import { forwardToAgent, resolveAgentAccessError, sendToWebClient } from '../ws-utils.js';
4
+
5
+ const CREATE_TYPES = new Set(['yeaft_create_session', 'yeaft_create_group', 'unify_create_group', 'unify_create_session']);
6
+ const TASK_TYPES = new Set(['yeaft_scheduled_task', 'unify_scheduled_task']);
7
+ const CREATE_FIELDS = ['name', 'roster', 'defaultVpId', 'workDir', 'config'];
8
+
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+
13
+ function validIdentity(value) {
14
+ return typeof value === 'string' && value.length > 0 && value.length <= 256
15
+ && value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value);
16
+ }
17
+
18
+ /** Only user-authored configuration crosses this boundary. Runtime state is Agent-owned. */
19
+ function projectTaskInput(source) {
20
+ if (!isRecord(source)) throw new Error('task must be an object');
21
+ if (Buffer.byteLength(JSON.stringify(source)) > 96 * 1024) throw new Error('Task input is too large');
22
+ const task = {};
23
+ if (Object.hasOwn(source, 'prompt')) {
24
+ if (typeof source.prompt !== 'string' || !source.prompt.trim() || source.prompt.length > 65536) {
25
+ throw new Error('Task prompt must be non-empty text of at most 65536 characters');
26
+ }
27
+ task.prompt = source.prompt;
28
+ }
29
+ for (const key of ['enabled', 'notify']) {
30
+ if (!Object.hasOwn(source, key)) continue;
31
+ if (typeof source[key] !== 'boolean') throw new Error(`${key} must be a boolean`);
32
+ task[key] = source[key];
33
+ }
34
+ if (Object.hasOwn(source, 'trigger')) {
35
+ const trigger = source.trigger;
36
+ if (!isRecord(trigger) || !['manual', 'schedule'].includes(trigger.type)) throw new Error('Invalid task trigger');
37
+ task.trigger = { type: trigger.type };
38
+ if (trigger.type === 'schedule') {
39
+ if (!Number.isSafeInteger(trigger.scheduledFor) || trigger.scheduledFor < 0) throw new Error('Invalid scheduledFor');
40
+ task.trigger.scheduledFor = trigger.scheduledFor;
41
+ if (Object.hasOwn(trigger, 'recurrence')) {
42
+ if (trigger.recurrence !== null && !isRecord(trigger.recurrence)) throw new Error('Invalid recurrence');
43
+ // Calendar-rule validation belongs to the Agent; never forward shared input objects.
44
+ const recurrence = JSON.stringify(trigger.recurrence);
45
+ if (recurrence.length > 4096) throw new Error('Recurrence is too large');
46
+ task.trigger.recurrence = JSON.parse(recurrence);
47
+ }
48
+ }
49
+ }
50
+ if (!Object.keys(task).length) throw new Error('No editable task fields supplied');
51
+ return task;
52
+ }
53
+
54
+ /** Task creation shares Session CRUD, but must never fall through to an older Agent. */
55
+ export async function handleClientScheduledTasks(clientId, client, msg) {
56
+ const isCreate = CREATE_TYPES.has(msg.type) && isRecord(msg.payload) && Object.hasOwn(msg.payload, 'task');
57
+ if (!isCreate && !TASK_TYPES.has(msg.type)) return false;
58
+ const agentId = msg.agentId;
59
+ const sessionId = isCreate ? null : msg.sessionId;
60
+ const requestId = validIdentity(msg.requestId) ? msg.requestId : null;
61
+ const op = isCreate ? 'create' : (msg.op === 'update' ? 'update_task' : 'run_task');
62
+ const reject = async (code, message) => {
63
+ await sendToWebClient(client, {
64
+ type: 'yeaft_output', agentId: typeof agentId === 'string' ? agentId : null,
65
+ ...(sessionId != null ? { sessionId } : {}), requestId,
66
+ event: { type: 'session_crud_result', op, requestId, ok: false, error: { code, message } },
67
+ });
68
+ return true;
69
+ };
70
+ // No currentAgent fallback: a task action must name its full durable identity.
71
+ if (!validIdentity(agentId) || (!isCreate && !validIdentity(sessionId))) {
72
+ return reject('invalid_identity', 'An explicit Agent and Session identity is required');
73
+ }
74
+ if (!requestId || (!isCreate && !['update', 'run'].includes(msg.op))) {
75
+ return reject('invalid_request', 'A requestId and valid operation are required');
76
+ }
77
+ const accessError = resolveAgentAccessError(agentId, client.userId, client.role);
78
+ if (!client.authenticated || accessError) {
79
+ const denied = !client.authenticated || accessError === 'Agent access denied';
80
+ return reject(denied ? 'agent_access_denied' : 'agent_offline', denied ? 'Agent access denied' : 'Agent is offline');
81
+ }
82
+ const agent = agents.get(agentId);
83
+ if (!agent?.capabilities?.includes('scheduled_tasks')) {
84
+ return reject('scheduled_tasks_unsupported', 'This Agent does not support scheduled tasks; upgrade it first');
85
+ }
86
+ if (!isCreate && !yeaftSessionDb.getForAgent(client.userId, agentId, sessionId)) {
87
+ return reject('session_access_denied', 'Session not found or access denied');
88
+ }
89
+ const outbound = {
90
+ type: isCreate ? 'yeaft_create_session' : 'yeaft_scheduled_task',
91
+ requestId, _requestClientId: clientId,
92
+ };
93
+ try {
94
+ if (isCreate) {
95
+ outbound.payload = Object.fromEntries(CREATE_FIELDS.filter(key => Object.hasOwn(msg.payload, key)).map(key => [key, msg.payload[key]]));
96
+ outbound.payload.task = projectTaskInput(msg.payload.task);
97
+ } else {
98
+ outbound.op = msg.op;
99
+ outbound.sessionId = sessionId;
100
+ if (msg.op === 'update') {
101
+ if (typeof msg.expectedRevision !== 'string' || !msg.expectedRevision) {
102
+ return reject('task_conflict', 'A task revision is required; refresh before editing');
103
+ }
104
+ outbound.expectedRevision = msg.expectedRevision;
105
+ outbound.task = projectTaskInput(msg.task);
106
+ } else {
107
+ if (Object.hasOwn(msg, 'task')) throw new Error('run does not accept task configuration');
108
+ // Never relay a browser-supplied Project; this authority comes only from SQLite.
109
+ outbound.projectContext = yeaftProjectDb.contextForSession(client.userId, agentId, sessionId);
110
+ }
111
+ }
112
+ } catch (error) {
113
+ return reject('invalid_task', error.message);
114
+ }
115
+ // Override private routing instead of accepting any browser-provided identity.
116
+ // A socket may disappear between the access check and the write.
117
+ try {
118
+ if (await forwardToAgent(agentId, outbound)) return true;
119
+ } catch { /* Report a correlated failure instead of leaving the CRUD promise pending. */ }
120
+ return reject('agent_offline', 'Agent delivery could not be confirmed; refresh before retrying');
121
+ }
@@ -88,6 +88,7 @@ export function projectSessionCatalog({
88
88
  rows.push({
89
89
  catalogKey,
90
90
  runtimeProvider: 'yeaft',
91
+ ...(session.task ? { task: session.task } : {}),
91
92
  routeRef: { runtimeProvider: 'yeaft', agentId: session.agentId, sessionId: session.id },
92
93
  title: session.name || session.id,
93
94
  workDir: session.workDir || '',
@@ -144,6 +144,7 @@ export function handleWebConnection(ws, url, req = {}) {
144
144
  acceptPlaintext: true,
145
145
  yeaftSessionInventoryComplete: true,
146
146
  sessionCatalogProtocol: 1,
147
+ scheduledTaskProtocol: 1,
147
148
  workbenchRouteProtocol: WORKBENCH_ROUTE_PROTOCOL,
148
149
  workCenterWorkbenchProtocol: WORK_CENTER_WORKBENCH_PROTOCOL,
149
150
  browserRuntimeProtocol: BROWSER_RUNTIME_PROTOCOL,
@@ -1 +1 @@
1
- {"version":"1.0.649"}
1
+ {"version":"1.0.651"}