@yeaft/webchat-agent 1.0.299 → 1.0.301

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.
@@ -138,12 +138,12 @@ export default defineTool({
138
138
  en: `Search through past conversation history.
139
139
 
140
140
  Searches message content for all whitespace-separated terms (case-insensitive).
141
- Tool-result messages are excluded. Useful for finding previous discussions, decisions, or code snippets.
141
+ Inside a Session, search is limited to that Session plus sibling Sessions in the same Project on this Agent. Tool-result messages are excluded. Useful for finding previous discussions, decisions, or code snippets.
142
142
 
143
143
  Results are returned newest-first with a bounded matching snippet and source metadata.`,
144
144
  zh: `搜索历史对话记录。
145
145
 
146
- 在已持久化消息的正文中搜索全部空格分隔的关键词(不区分大小写),并排除工具结果消息。用于查找之前的讨论、决策或代码片段。
146
+ 在已持久化消息的正文中搜索全部空格分隔的关键词(不区分大小写)。在 Session 内仅搜索当前 Session,以及同一 Agent 上同 Project 的兄弟 Session;排除工具结果消息。用于查找之前的讨论、决策或代码片段。
147
147
 
148
148
  结果按最新优先返回,包含有界的命中片段和来源信息。`
149
149
  },
@@ -180,7 +180,16 @@ Results are returned newest-first with a bounded matching snippet and source met
180
180
 
181
181
  try {
182
182
  const telemetry = {};
183
- const results = searchMessages(yeaftDir, keyword, limit, { telemetry });
183
+ const projectSessionIds = Array.isArray(ctx?.projectSessionIds)
184
+ ? ctx.projectSessionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
185
+ : [];
186
+ const scopedSessionIds = ctx?.sessionId
187
+ ? Array.from(new Set([ctx.sessionId, ...projectSessionIds]))
188
+ : null;
189
+ const results = searchMessages(yeaftDir, keyword, limit, {
190
+ telemetry,
191
+ ...(scopedSessionIds ? { sessionIds: scopedSessionIds } : {}),
192
+ });
184
193
  const searchTelemetry = {
185
194
  resultCount: results.length,
186
195
  scannedFiles: telemetry.scannedFiles || 0,
@@ -37,6 +37,7 @@ import fileEdit from './file-edit.js';
37
37
  import globTool from './glob.js';
38
38
  import grepTool from './grep.js';
39
39
  import listDir from './list-dir.js';
40
+ import diskUsage from './disk-usage.js';
40
41
  import applyPatch from './apply-patch.js';
41
42
  import listTasks from './list-tasks.js';
42
43
  import readTaskLog from './read-task-log.js';
@@ -102,6 +103,7 @@ export const allTools = [
102
103
  globTool,
103
104
  grepTool,
104
105
  listDir,
106
+ diskUsage,
105
107
  applyPatch,
106
108
  listTasks,
107
109
  readTaskLog,
@@ -0,0 +1,211 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+
4
+ const DEFAULT_MAX_BYTES = 512 * 1024;
5
+ const DEFAULT_KILL_GRACE_MS = 250;
6
+ const DEFAULT_FORCE_SETTLE_MS = 1000;
7
+
8
+ function abortError(signal) {
9
+ if (signal?.reason instanceof Error && signal.reason.name === 'AbortError') return signal.reason;
10
+ const error = new Error(
11
+ signal?.reason instanceof Error ? signal.reason.message : 'The operation was aborted',
12
+ );
13
+ error.name = 'AbortError';
14
+ return error;
15
+ }
16
+
17
+ function killProcessTree(proc, signal, platform, spawnProcessSync) {
18
+ if (!proc.pid) return false;
19
+ if (platform === 'win32') {
20
+ try {
21
+ const result = spawnProcessSync('taskkill', ['/pid', String(proc.pid), '/t', '/f'], {
22
+ stdio: 'ignore',
23
+ windowsHide: true,
24
+ timeout: 5000,
25
+ });
26
+ if (!result.error && result.status === 0) return true;
27
+ } catch {}
28
+ try { return proc.kill(signal) !== false; } catch { return false; }
29
+ }
30
+ try {
31
+ process.kill(-proc.pid, signal);
32
+ return true;
33
+ } catch {
34
+ try { return proc.kill(signal) !== false; } catch { return false; }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Execute a binary directly without a shell and keep captured output bounded.
40
+ *
41
+ * @param {string} command
42
+ * @param {string[]} args
43
+ * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, forceSettleMs?: number, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
44
+ */
45
+ export function runProcess(command, args, options = {}) {
46
+ if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const platform = options.platform || process.platform;
50
+ const spawnProcess = options.spawnProcess || spawn;
51
+ const spawnProcessSync = options.spawnProcessSync || spawnSync;
52
+ const maxBytes = Number.isFinite(options.maxBytes)
53
+ ? Math.max(0, options.maxBytes)
54
+ : DEFAULT_MAX_BYTES;
55
+ const killGraceMs = Number.isFinite(options.killGraceMs)
56
+ ? Math.max(0, options.killGraceMs)
57
+ : DEFAULT_KILL_GRACE_MS;
58
+ const forceSettleMs = Number.isFinite(options.forceSettleMs)
59
+ ? Math.max(1, options.forceSettleMs)
60
+ : DEFAULT_FORCE_SETTLE_MS;
61
+ const proc = spawnProcess(command, args, {
62
+ cwd: options.cwd,
63
+ env: options.env || process.env,
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ windowsHide: true,
66
+ detached: platform !== 'win32',
67
+ });
68
+ const stdout = [];
69
+ const stderr = [];
70
+ let stdoutBytes = 0;
71
+ let stderrBytes = 0;
72
+ let stdoutTruncated = false;
73
+ let stderrTruncated = false;
74
+ let truncated = false;
75
+ let settled = false;
76
+ let timedOut = false;
77
+ let aborted = false;
78
+ let stopRequested = false;
79
+ let forceRequested = false;
80
+ let timer = null;
81
+ let forceTimer = null;
82
+ let forceSettleTimer = null;
83
+
84
+ let onStdout;
85
+ let onStderr;
86
+ let onError;
87
+ let onClose;
88
+ const cleanup = () => {
89
+ if (timer) clearTimeout(timer);
90
+ if (forceTimer) clearTimeout(forceTimer);
91
+ if (forceSettleTimer) clearTimeout(forceSettleTimer);
92
+ timer = null;
93
+ forceTimer = null;
94
+ forceSettleTimer = null;
95
+ options.signal?.removeEventListener('abort', onAbort);
96
+ if (onStdout) proc.stdout?.off('data', onStdout);
97
+ if (onStderr) proc.stderr?.off('data', onStderr);
98
+ if (onError) proc.off('error', onError);
99
+ if (onClose) proc.off('close', onClose);
100
+ };
101
+ const decode = (chunks, wasTruncated, preserveCarriageReturns = false) => {
102
+ const decoder = new StringDecoder('utf8');
103
+ let value = decoder.write(Buffer.concat(chunks));
104
+ if (!wasTruncated) value += decoder.end();
105
+ return preserveCarriageReturns ? value : value.replace(/\r/g, '');
106
+ };
107
+ const finish = code => {
108
+ if (settled) return;
109
+ settled = true;
110
+ cleanup();
111
+ if (aborted) {
112
+ reject(abortError(options.signal));
113
+ return;
114
+ }
115
+ resolve({
116
+ code: timedOut ? 124 : (code ?? 1),
117
+ stdout: decode(stdout, stdoutTruncated, options.preserveCarriageReturns),
118
+ stderr: decode(stderr, stderrTruncated),
119
+ truncated,
120
+ timedOut,
121
+ });
122
+ };
123
+ const forceStop = () => {
124
+ if (settled || forceRequested) return;
125
+ forceRequested = true;
126
+ killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync);
127
+ forceSettleTimer = setTimeout(() => finish(null), forceSettleMs);
128
+ forceSettleTimer.unref?.();
129
+ };
130
+ const stop = () => {
131
+ if (settled || stopRequested) return;
132
+ stopRequested = true;
133
+ if (platform === 'win32') {
134
+ // taskkill must run while the parent PID still identifies the tree.
135
+ // It is already forceful, so do not wait for the direct child to exit.
136
+ forceRequested = true;
137
+ killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync);
138
+ if (!settled) {
139
+ forceSettleTimer = setTimeout(() => finish(null), forceSettleMs);
140
+ forceSettleTimer.unref?.();
141
+ }
142
+ return;
143
+ }
144
+ killProcessTree(proc, 'SIGTERM', platform, spawnProcessSync);
145
+ forceTimer = setTimeout(forceStop, killGraceMs);
146
+ forceTimer.unref?.();
147
+ };
148
+ const onAbort = () => {
149
+ aborted = true;
150
+ stop();
151
+ };
152
+ const capture = (target, chunk, isStdout) => {
153
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
154
+ const current = isStdout ? stdoutBytes : stderrBytes;
155
+ const remaining = maxBytes - current;
156
+ if (remaining <= 0) {
157
+ truncated = true;
158
+ if (isStdout) stdoutTruncated = true;
159
+ else stderrTruncated = true;
160
+ stop();
161
+ return;
162
+ }
163
+ const bounded = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer;
164
+ target.push(bounded);
165
+ if (isStdout) stdoutBytes += bounded.length;
166
+ else stderrBytes += bounded.length;
167
+ if (bounded.length !== buffer.length) {
168
+ truncated = true;
169
+ if (isStdout) stdoutTruncated = true;
170
+ else stderrTruncated = true;
171
+ stop();
172
+ }
173
+ };
174
+
175
+ onStdout = chunk => capture(stdout, chunk, true);
176
+ onStderr = chunk => capture(stderr, chunk, false);
177
+ onError = error => {
178
+ if (settled) return;
179
+ if (stopRequested) {
180
+ finish(null);
181
+ return;
182
+ }
183
+ settled = true;
184
+ cleanup();
185
+ reject(error);
186
+ };
187
+ onClose = code => {
188
+ if (stopRequested && !forceRequested) {
189
+ // The direct child is gone. Kill any process that remained in its
190
+ // detached group before releasing the tool call.
191
+ forceRequested = true;
192
+ killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync);
193
+ }
194
+ finish(code);
195
+ };
196
+ proc.stdout.on('data', onStdout);
197
+ proc.stderr.on('data', onStderr);
198
+ proc.on('error', onError);
199
+ proc.on('close', onClose);
200
+
201
+ if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
202
+ timer = setTimeout(() => {
203
+ timedOut = true;
204
+ stop();
205
+ }, options.timeoutMs);
206
+ timer.unref?.();
207
+ }
208
+ options.signal?.addEventListener('abort', onAbort, { once: true });
209
+ if (options.signal?.aborted) onAbort();
210
+ });
211
+ }
@@ -0,0 +1,108 @@
1
+ import { extname } from 'node:path';
2
+
3
+ export const SEARCH_SKIP_DIRS = new Set([
4
+ 'node_modules', '.git', '__pycache__', '.next', '.nuxt',
5
+ 'dist', 'build', '.cache', '.venv', 'venv', '.tox',
6
+ 'vendor', 'target', '.gradle', '.idea', '.vscode',
7
+ ]);
8
+
9
+ export const SEARCH_SKIP_GLOBS = Object.freeze([
10
+ ...[...SEARCH_SKIP_DIRS].flatMap(name => [`!${name}/**`, `!**/${name}/**`]),
11
+ '!.yeaft/worktrees/**',
12
+ '!**/.yeaft/worktrees/**',
13
+ ]);
14
+
15
+ const TYPE_EXTENSIONS = {
16
+ js: ['.js', '.jsx', '.mjs', '.cjs'], ts: ['.ts', '.tsx', '.mts', '.cts'],
17
+ py: ['.py'], rust: ['.rs'], go: ['.go'], java: ['.java'],
18
+ json: ['.json'], yaml: ['.yaml', '.yml'], markdown: ['.md', '.markdown'],
19
+ html: ['.html', '.htm'], css: ['.css'], shell: ['.sh', '.bash', '.zsh'],
20
+ };
21
+
22
+ export function isSkippedSearchDirectory(relativePath, name) {
23
+ const normalized = String(relativePath || '').replace(/\\/g, '/');
24
+ return SEARCH_SKIP_DIRS.has(name)
25
+ || normalized === '.yeaft/worktrees'
26
+ || normalized.endsWith('/.yeaft/worktrees');
27
+ }
28
+
29
+ function expandBraces(pattern) {
30
+ const match = pattern.match(/\{([^{}]+)\}/);
31
+ if (!match) return [pattern];
32
+ return match[1].split(',').flatMap(part => expandBraces(
33
+ pattern.slice(0, match.index) + part + pattern.slice(match.index + match[0].length),
34
+ ));
35
+ }
36
+
37
+ function globToRegExp(pattern) {
38
+ let source = '';
39
+ for (let index = 0; index < pattern.length; index += 1) {
40
+ const char = pattern[index];
41
+ if (char === '*' && pattern[index + 1] === '*') {
42
+ index += 1;
43
+ if (pattern[index + 1] === '/') {
44
+ index += 1;
45
+ source += '(?:.*/)?';
46
+ } else {
47
+ source += '.*';
48
+ }
49
+ } else if (char === '*') {
50
+ source += '[^/]*';
51
+ } else if (char === '?') {
52
+ source += '[^/]';
53
+ } else {
54
+ source += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
55
+ }
56
+ }
57
+ return new RegExp(`^${source}$`);
58
+ }
59
+
60
+ export function createSearchPathMatcher({ glob, type } = {}) {
61
+ const normalizedGlob = String(glob || '').replace(/\\/g, '/');
62
+ const globMatchers = normalizedGlob
63
+ ? expandBraces(normalizedGlob).map(globToRegExp)
64
+ : [];
65
+ const matchBase = normalizedGlob && !normalizedGlob.includes('/');
66
+ const extensions = type ? TYPE_EXTENSIONS[type] : null;
67
+
68
+ return path => {
69
+ const normalized = String(path || '').replace(/\\/g, '/');
70
+ const candidate = matchBase ? normalized.split('/').pop() : normalized;
71
+ if (globMatchers.length && !globMatchers.some(matcher => matcher.test(candidate))) return false;
72
+ if (type && !extensions?.includes(extname(normalized).toLowerCase())) return false;
73
+ return true;
74
+ };
75
+ }
76
+
77
+ export function throwIfAborted(signal) {
78
+ if (!signal?.aborted) return;
79
+ if (signal.reason instanceof Error && signal.reason.name === 'AbortError') {
80
+ throw signal.reason;
81
+ }
82
+ const error = new Error(
83
+ signal.reason instanceof Error ? signal.reason.message : 'The operation was aborted',
84
+ );
85
+ error.name = 'AbortError';
86
+ throw error;
87
+ }
88
+
89
+ export function isAbortError(error) {
90
+ return error?.name === 'AbortError';
91
+ }
92
+
93
+ export function waitForAbortable(promise, signal) {
94
+ throwIfAborted(signal);
95
+ if (!signal) return promise;
96
+ return new Promise((resolve, reject) => {
97
+ const onAbort = () => {
98
+ cleanup();
99
+ try { throwIfAborted(signal); } catch (error) { reject(error); }
100
+ };
101
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
102
+ signal.addEventListener('abort', onAbort, { once: true });
103
+ Promise.resolve(promise).then(
104
+ value => { cleanup(); resolve(value); },
105
+ error => { cleanup(); reject(error); },
106
+ );
107
+ });
108
+ }
@@ -14,6 +14,7 @@
14
14
  * @typedef {Object} ToolContext
15
15
  * @property {AbortSignal} [signal] — cancellation signal
16
16
  * @property {string} [yeaftDir] — Yeaft data directory
17
+ * @property {Promise<Array> & {toolReady?: Record<string, Promise<object>>}} [managedCliReady] — resolves after optional managed CLI setup; toolReady exposes per-command readiness
17
18
  * @property {ReturnType<import('../runtime-platform.js').getRuntimePlatformInfo>} [runtimePlatform]
18
19
  * — runtime OS/shell facts for platform-aware tools
19
20
  * @property {string} [cwd] — working directory
@@ -23,6 +24,7 @@
23
24
  * @property {object} [config] — engine config
24
25
  * @property {import('../tasks/manager.js').TaskManager} [taskManager] — Session task manager
25
26
  * @property {string} [sessionId] — current Session id
27
+ * @property {string[]} [projectSessionIds] — same-Agent sibling Session ids in the current Project
26
28
  * @property {string} [threadId] — current Session thread id
27
29
  * @property {string} [currentVpId] — R6: VP id of the caller (set in multi-VP groups)
28
30
  * @property {string} [currentGroupId] — R6: group id of the caller's RoleInstance
@@ -589,6 +589,54 @@ const vpCurrentTodos = new Map();
589
589
  * sessionHandle: object }>}
590
590
  */
591
591
  const sessionContexts = new Map();
592
+ /** Latest server-authoritative Project identity and same-Agent siblings per Session. */
593
+ const projectContextBySession = new Map();
594
+
595
+ function normalizeProjectContext(value, sessionId) {
596
+ if (!value || typeof value !== 'object' || !Array.isArray(value.sessionIds)) return null;
597
+ const currentSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
598
+ return {
599
+ projectId: typeof value.projectId === 'string' && value.projectId.trim()
600
+ ? value.projectId.trim()
601
+ : null,
602
+ projectName: typeof value.projectName === 'string' && value.projectName.trim()
603
+ ? value.projectName.trim()
604
+ : null,
605
+ sessionIds: Array.from(new Set(value.sessionIds
606
+ .filter(id => typeof id === 'string' && id.trim())
607
+ .map(id => id.trim())
608
+ .filter(id => id !== currentSessionId))),
609
+ };
610
+ }
611
+
612
+ function legacyProjectContext(yeaftDir, sessionId) {
613
+ const project = loadProjects(yeaftDir).find(row => row.sessionIds.includes(sessionId));
614
+ if (!project) return null;
615
+ return normalizeProjectContext({
616
+ projectId: project.id,
617
+ projectName: project.name,
618
+ sessionIds: project.sessionIds,
619
+ }, sessionId);
620
+ }
621
+
622
+ function buildProjectSharedBlock(projectContext, summaries = '') {
623
+ const context = normalizeProjectContext(projectContext, null);
624
+ const body = typeof summaries === 'string' ? summaries.trim() : '';
625
+ if (!context?.projectId && !body) return '';
626
+ const lines = ['[Project Shared Context]'];
627
+ if (context?.projectId) {
628
+ const label = context.projectName
629
+ ? `${context.projectName} (${context.projectId})`
630
+ : context.projectId;
631
+ lines.push(`Project: ${label}`);
632
+ lines.push('Sharing boundary: sibling Sessions in this Project on this Agent only.');
633
+ } else {
634
+ lines.push('Sharing boundary: sibling Sessions in the same Project on this Agent only.');
635
+ }
636
+ lines.push('Read-only memory summaries preserve each source Session identity.');
637
+ if (body) lines.push('', body);
638
+ return lines.join('\n');
639
+ }
592
640
 
593
641
  function vpKey(sessionId, vpId) {
594
642
  return `${sessionId}::${vpId}`;
@@ -1731,6 +1779,7 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1731
1779
  skillManager: session.skillManager,
1732
1780
  mcpManager: session.mcpManager,
1733
1781
  yeaftDir: session.yeaftDir,
1782
+ managedCliReady: session.managedCliReady || null,
1734
1783
  // Share the session-shared ToolUsageStats so per-VP tool calls land
1735
1784
  // in the same on-disk snapshot the `yeaft_fetch_tool_stats` handler
1736
1785
  // reads. Without this, engine's record-on-tool-exec guard
@@ -2297,6 +2346,7 @@ export async function __testResetVpState() {
2297
2346
  asyncTaskOwners.clear();
2298
2347
  vpAborts.clear();
2299
2348
  sessionContexts.clear();
2349
+ projectContextBySession.clear();
2300
2350
  vpCurrentTodos.clear();
2301
2351
  threadClassifier = defaultClassifyThread;
2302
2352
  if (_vpUnsubscribe) {
@@ -3028,8 +3078,14 @@ const PROJECT_CONTEXT_MAX_TOKENS = 4096;
3028
3078
  const PROJECT_CONTEXT_TRUNCATION_NOTICE = '\n[Summary truncated to Project context budget]';
3029
3079
 
3030
3080
  async function sharedProjectContext(yeaftDir, sessionId, options = {}) {
3031
- const project = loadProjects(yeaftDir).find(row => row.sessionIds.includes(sessionId));
3032
- if (!project) return '';
3081
+ const requestedSiblingIds = Array.isArray(options.sessionIds)
3082
+ ? options.sessionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
3083
+ : null;
3084
+ const project = requestedSiblingIds === null
3085
+ ? loadProjects(yeaftDir).find(row => row.sessionIds.includes(sessionId))
3086
+ : null;
3087
+ const sourceSessionIds = requestedSiblingIds || project?.sessionIds || [];
3088
+ if (sourceSessionIds.length === 0) return '';
3033
3089
  const memoryRoot = join(yeaftDir, 'memory');
3034
3090
  const language = options.language || 'en';
3035
3091
  const configuredBudget = Number.isFinite(options.tokenBudget) && options.tokenBudget > 0
@@ -3037,7 +3093,7 @@ async function sharedProjectContext(yeaftDir, sessionId, options = {}) {
3037
3093
  : PROJECT_CONTEXT_MAX_TOKENS;
3038
3094
  const tokenBudget = Math.min(configuredBudget, PROJECT_CONTEXT_MAX_TOKENS);
3039
3095
  let context = '';
3040
- const siblingIds = project.sessionIds
3096
+ const siblingIds = sourceSessionIds
3041
3097
  .filter(id => id !== sessionId)
3042
3098
  .slice(0, PROJECT_CONTEXT_MAX_SIBLINGS);
3043
3099
  for (const siblingId of siblingIds) {
@@ -3341,6 +3397,7 @@ export function handleYeaftArchiveSession(msg) {
3341
3397
  try {
3342
3398
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3343
3399
  const result = archiveSession(yeaftDir, sessionId);
3400
+ projectContextBySession.delete(sessionId);
3344
3401
  invalidateGroupContext(sessionId);
3345
3402
  sendSessionCrudResult({
3346
3403
  op: 'archive',
@@ -3362,6 +3419,7 @@ export function handleYeaftDeleteSession(msg) {
3362
3419
  try {
3363
3420
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3364
3421
  const result = deleteSession(yeaftDir, sessionId);
3422
+ projectContextBySession.delete(sessionId);
3365
3423
  removeSessionFromProjects(yeaftDir, sessionId);
3366
3424
  ctx.assetOutbox?.removeSession(sessionId);
3367
3425
  // Cascade: remove every persisted message stamped with this group id.
@@ -4495,6 +4553,20 @@ async function runYeaftSessionSend(msg) {
4495
4553
  },
4496
4554
  });
4497
4555
 
4556
+ const hasInboundProjectContext = Object.prototype.hasOwnProperty.call(msg, 'projectContext');
4557
+ const inboundProjectContext = normalizeProjectContext(msg.projectContext, sessionId);
4558
+ if (hasInboundProjectContext) {
4559
+ projectContextBySession.set(sessionId, inboundProjectContext || {
4560
+ projectId: null,
4561
+ projectName: null,
4562
+ sessionIds: [],
4563
+ });
4564
+ } else {
4565
+ // An old Server does not know this field. Drop any context cached from a
4566
+ // newer Server before falling back to this Agent's legacy projects.json.
4567
+ projectContextBySession.delete(sessionId);
4568
+ }
4569
+
4498
4570
  // Ingest user text. The coordinator persists, applies mention/fanout
4499
4571
  // rules, and calls deliver() (== enqueueForVp) for each chosen VP —
4500
4572
  // which both (a) emits vp_typing_start and (b) ensures a driver runs.
@@ -4519,6 +4591,9 @@ async function runYeaftSessionSend(msg) {
4519
4591
  _promptParts: attachmentBundle.promptParts,
4520
4592
  _promptSuffix: attachmentBundle.promptSuffix,
4521
4593
  _perfTraceId: perfTraceId,
4594
+ _projectContext: msg.projectContext && typeof msg.projectContext === 'object'
4595
+ ? msg.projectContext
4596
+ : null,
4522
4597
  });
4523
4598
  } catch (err) {
4524
4599
  console.warn('[Yeaft] yeaft_session_chat: coord.ingest failed', err?.message || err);
@@ -4762,6 +4837,7 @@ export async function ensureSessionLoaded(opts = {}) {
4762
4837
  skipMCP: true,
4763
4838
  skipSkills: true,
4764
4839
  serverMode: true,
4840
+ managedCliReady: ctx.managedCliReady,
4765
4841
  });
4766
4842
  claimRuntimeOwnership(session);
4767
4843
 
@@ -5159,12 +5235,19 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5159
5235
  threadId,
5160
5236
  });
5161
5237
  if (queryOpts) {
5162
- const projectContext = await sharedProjectContext(ctx.CONFIG?.yeaftDir, sessionId, {
5238
+ const envelopeProjectContext = normalizeProjectContext(inboundEnvelope?._projectContext, sessionId);
5239
+ const projectContext = envelopeProjectContext
5240
+ || projectContextBySession.get(sessionId)
5241
+ || legacyProjectContext(ctx.CONFIG?.yeaftDir, sessionId);
5242
+ const projectSessionIds = projectContext?.sessionIds || [];
5243
+ queryOpts.projectSessionIds = projectSessionIds;
5244
+ const projectSummaries = await sharedProjectContext(ctx.CONFIG?.yeaftDir, sessionId, {
5245
+ sessionIds: projectSessionIds,
5163
5246
  language: session?.config?.language,
5164
5247
  tokenBudget: Math.max(512, Math.floor((session?.config?.messageTokenBudget || 32768) / 8)),
5165
5248
  });
5166
- if (projectContext) {
5167
- const sharedBlock = `[Project Shared Context]\nRead-only memory summaries from sibling Sessions in the same Project. Preserve each source Session identity.\n\n${projectContext}`;
5249
+ const sharedBlock = buildProjectSharedBlock(projectContext, projectSummaries);
5250
+ if (sharedBlock) {
5168
5251
  queryOpts.sessionAnnouncement = queryOpts.sessionAnnouncement
5169
5252
  ? `${queryOpts.sessionAnnouncement}\n\n${sharedBlock}`
5170
5253
  : sharedBlock;
@@ -7007,6 +7090,7 @@ export async function resetYeaftSession() {
7007
7090
  vpEngineConfigKeys.clear();
7008
7091
  asyncTaskOwners.clear();
7009
7092
  sessionContexts.clear();
7093
+ projectContextBySession.clear();
7010
7094
  vpCurrentTodos.clear();
7011
7095
  threadClassifier = defaultClassifyThread;
7012
7096
  // History-dedup cache is keyed by per-session coordinator msg ids;
@@ -7030,6 +7114,7 @@ export async function resetYeaftSession() {
7030
7114
  skipMCP: true,
7031
7115
  skipSkills: true,
7032
7116
  serverMode: true,
7117
+ managedCliReady: ctx.managedCliReady,
7033
7118
  });
7034
7119
  claimRuntimeOwnership(session);
7035
7120
  installYeaftRuntimeBridge(session);
@@ -7289,6 +7374,8 @@ export async function handleYeaftMcpReload(msg = {}) {
7289
7374
  export const __testHooks = {
7290
7375
  loadProjects,
7291
7376
  sharedProjectContext,
7377
+ buildProjectSharedBlock,
7378
+ normalizeProjectContext,
7292
7379
  loadVisibleGroupHistoryPage,
7293
7380
  projectVisibleHistoryChunkMessages,
7294
7381
  persistInboundMessageOnceByMsgId,