@yeaft/webchat-agent 0.1.741 → 0.1.746

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": "0.1.741",
3
+ "version": "0.1.746",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -26,13 +26,22 @@ export const DEFAULT_INTERVAL_MS = DREAM_INTERVAL_HOURS * 60 * 60 * 1000;
26
26
  * everything `runDream` needs (memory root, llm, message-store hooks,
27
27
  * onProgress sink); the scheduler only knows how to call it.
28
28
  *
29
+ * `keepAlive` controls whether the interval timer holds the event loop
30
+ * open. Default `false` preserves the original behaviour (CLI / one-shot
31
+ * invocations should not be kept alive solely by the dream ticker). The
32
+ * web server passes `true` because (a) the HTTP listener already pins
33
+ * the loop, so unref'ing buys nothing, and (b) on Node 22 some platforms
34
+ * never schedule unref'd timers when nothing else wakes them — meaning
35
+ * the server saw zero ticks in production. See PR fix/dream-cadence.
36
+ *
29
37
  * @param {{
30
38
  * run: (opts: { manual: boolean, scopeFilter?: string[] }) => Promise<object>,
31
39
  * intervalMs?: number,
40
+ * keepAlive?: boolean,
32
41
  * logger?: { info?: (...a:any) => void, warn?: (...a:any) => void, error?: (...a:any) => void },
33
42
  * }} args
34
43
  */
35
- export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, logger }) {
44
+ export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, keepAlive = false, logger }) {
36
45
  if (typeof run !== 'function') throw new Error('createDreamScheduler: run callable required');
37
46
  const log = logger || {};
38
47
  let timer = null;
@@ -60,8 +69,11 @@ export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, lo
60
69
  start() {
61
70
  if (timer) return;
62
71
  timer = setInterval(() => { fire({ manual: false }).catch(() => {}); }, intervalMs);
63
- // Don't keep the event loop alive solely for the dream ticker.
64
- if (typeof timer.unref === 'function') timer.unref();
72
+ // Server / long-lived hosts pass keepAlive=true so the interval
73
+ // participates in the loop normally. Short-lived hosts (CLI, tests)
74
+ // leave keepAlive=false to avoid pinning the loop open with the
75
+ // ticker alone.
76
+ if (!keepAlive && typeof timer.unref === 'function') timer.unref();
65
77
  },
66
78
  stop() {
67
79
  if (timer) { clearInterval(timer); timer = null; }
@@ -13,7 +13,8 @@ import { join } from 'path';
13
13
  import { runDream } from './runner.js';
14
14
  import { createDreamScheduler } from './schedule.js';
15
15
  import { listGroups, openGroup } from '../groups/group-store.js';
16
- import { DREAM_NUDGE_AFTER_MESSAGES } from './limits.js';
16
+ import { readGroupState } from './state.js';
17
+ import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
17
18
 
18
19
  /**
19
20
  * Build the per-call options for runDream. Pure: takes a session and returns
@@ -154,6 +155,12 @@ export function createV2DreamScheduler(session) {
154
155
 
155
156
  const v2 = createDreamScheduler({
156
157
  run,
158
+ // Server mode — the HTTP listener already pins the event loop, so the
159
+ // ticker should be allowed to participate normally. Without this, on
160
+ // some Node 22 builds the unref'd interval stops being scheduled and
161
+ // dream effectively never fires (observed in production: 12 days
162
+ // between ticks, see fix/dream-cadence-and-ui-trigger).
163
+ keepAlive: !!session?.config?.serverMode,
157
164
  logger: session.config?.debug ? console : undefined,
158
165
  });
159
166
  // Auto-start the timer.
@@ -192,6 +199,12 @@ export function createV2DreamScheduler(session) {
192
199
  noteUserMessage: nudgeOnUserMessage,
193
200
  triggerDreamNow() { return v2.triggerNow(); },
194
201
  triggerDreamForScopes(scopeFilter) { return v2.triggerNow(scopeFilter); },
202
+ /**
203
+ * Non-manual catch-up fire. MIN_NEW_PER_GROUP still applies, so groups
204
+ * below threshold are skipped — same shape as the interval timer
205
+ * tick. Used by `bootCatchUpStaleDream`.
206
+ */
207
+ catchUpNudge() { return v2.nudge(); },
195
208
  shutdown() { v2.stop(); },
196
209
  get isRunning() { return v2.isRunning(); },
197
210
  // Preserve direct access for tests.
@@ -246,3 +259,105 @@ export async function bootInitEmptyGroups(args) {
246
259
  out.triggered = empty;
247
260
  return out;
248
261
  }
262
+
263
+ /**
264
+ * fix/dream-cadence-and-ui-trigger: stale-cadence catch-up.
265
+ *
266
+ * Walks every group on disk and reads the per-group `.dream-state`
267
+ * `lastDreamAt`. The newest of those is the effective "session was last
268
+ * cleanly Dream-processed at" timestamp. If that's older than
269
+ * `DREAM_INTERVAL_HOURS` (or there's no record at all and at least one
270
+ * group has user traffic), schedule a non-manual catch-up tick.
271
+ *
272
+ * Why this matters: the only thing previously triggering dream on a
273
+ * long-lived server was `setInterval(...).unref()` plus the user-traffic
274
+ * nudge. In practice (production: 12 days idle), neither fired reliably.
275
+ * This boot-time catch-up gives us a deterministic "if we were stale at
276
+ * boot, run once" guarantee that's independent of timer behaviour.
277
+ *
278
+ * MIN_NEW_PER_GROUP still gates per-group writes inside `runDream` —
279
+ * non-manual catch-up will not produce empty segment churn for groups
280
+ * that haven't crossed threshold.
281
+ *
282
+ * Pure side-effect, fire-and-forget.
283
+ *
284
+ * @param {{
285
+ * yeaftDir: string,
286
+ * dreamScheduler: { catchUpNudge?: () => Promise<any> },
287
+ * intervalHours?: number,
288
+ * now?: number,
289
+ * config?: { debug?: boolean },
290
+ * }} args
291
+ * @returns {Promise<{ stale: boolean, lastDreamAt: string|null, ageMs: number|null, fired: boolean }>}
292
+ */
293
+ export async function bootCatchUpStaleDream(args) {
294
+ const out = { stale: false, lastDreamAt: null, ageMs: null, fired: false };
295
+ if (!args || !args.dreamScheduler) return out;
296
+
297
+ const memoryRoot = join(args.yeaftDir, 'memory');
298
+ const groupsRoot = join(args.yeaftDir, 'groups');
299
+ const intervalMs = (args.intervalHours ?? DREAM_INTERVAL_HOURS) * 60 * 60 * 1000;
300
+ const now = args.now ?? Date.now();
301
+
302
+ let groupIds;
303
+ try { groupIds = listGroups(groupsRoot).map(g => g.id); }
304
+ catch { return out; }
305
+
306
+ // Find the newest lastDreamAt across all groups.
307
+ let newestAt = null;
308
+ let anyTraffic = false;
309
+ for (const gid of groupIds) {
310
+ let st;
311
+ try { st = await readGroupState(memoryRoot, gid); }
312
+ catch { continue; }
313
+ if (st.lastDreamAt) {
314
+ const t = Date.parse(st.lastDreamAt);
315
+ if (Number.isFinite(t) && (newestAt === null || t > newestAt)) newestAt = t;
316
+ }
317
+ if (!anyTraffic) {
318
+ try {
319
+ const h = openGroup(groupsRoot, gid);
320
+ const first = h.streamMessages().next();
321
+ if (!first.done) anyTraffic = true;
322
+ } catch { /* keep going */ }
323
+ }
324
+ }
325
+
326
+ // No groups, no traffic, nothing to catch up.
327
+ if (!anyTraffic) return out;
328
+
329
+ out.lastDreamAt = newestAt === null ? null : new Date(newestAt).toISOString();
330
+ out.ageMs = newestAt === null ? null : (now - newestAt);
331
+ out.stale = (newestAt === null) || (now - newestAt > intervalMs);
332
+ if (!out.stale) return out;
333
+
334
+ if (args.config?.debug) {
335
+ // eslint-disable-next-line no-console
336
+ console.log(`[dream-v2] boot catch-up: stale (lastDreamAt=${out.lastDreamAt}, ageMs=${out.ageMs}); firing one non-manual tick.`);
337
+ }
338
+
339
+ // Non-manual: MIN_NEW_PER_GROUP still applies. Use the public
340
+ // `catchUpNudge()` adapter — same dedupe path as the interval timer
341
+ // (both go through `fire({ manual: false })`), so a concurrent timer
342
+ // tick will be coalesced by the existing in-flight guard.
343
+ //
344
+ // Fail closed if the scheduler shim doesn't expose `catchUpNudge`:
345
+ // the previous `triggerDreamForScopes()` fallback routed through
346
+ // `v2.triggerNow()` which sets `manual: true` and bypasses
347
+ // MIN_NEW_PER_GROUP — directly contradicting the contract documented
348
+ // above. Better to skip than to silently fire a different semantic.
349
+ // PR #743 review feedback (Martin).
350
+ try {
351
+ if (typeof args.dreamScheduler.catchUpNudge === 'function') {
352
+ Promise.resolve(args.dreamScheduler.catchUpNudge()).catch(() => {});
353
+ out.fired = true;
354
+ } else {
355
+ // Shim does not expose the non-manual catch-up path. Refuse to
356
+ // substitute a manual fire — the caller should upgrade the shim.
357
+ out.fired = false;
358
+ }
359
+ } catch {
360
+ out.fired = false;
361
+ }
362
+ return out;
363
+ }
package/unify/engine.js CHANGED
@@ -1278,10 +1278,30 @@ export class Engine {
1278
1278
 
1279
1279
  // PR-L: track this query()'s tool-arc for reflection.
1280
1280
  // `turnStartIdx` is where the current user message lives; the arc
1281
- // we may collapse spans (turnStartIdx + 1 .. last assistant/tool).
1281
+ // we may collapse spans (arcStartIdx .. last assistant/tool).
1282
+ //
1283
+ // Periodic-T1 fix: T1 must fire EVERY TOOL_BATCH_SIZE (13) tool
1284
+ // calls, not just the first 13. So instead of a one-shot boolean,
1285
+ // track:
1286
+ // • `lastT1AtToolCount` — toolCount snapshot at the last T1
1287
+ // ATTEMPT (success OR error). Trigger when
1288
+ // `queryToolCount - lastT1AtToolCount >= TOOL_BATCH_SIZE`.
1289
+ // • `arcStartIdx` — first index of the current (uncollapsed)
1290
+ // tool arc. Initialised to turnStartIdx + 1; reset after each
1291
+ // successful T1 collapse to `conversationMessages.length`
1292
+ // (i.e. the slot the next assistant message will land in).
1293
+ // • `t1CollapsesDone` — count of T1 firings that ACTUALLY
1294
+ // rewrote history. Distinct from `lastT1AtToolCount` because
1295
+ // the catch block bumps the latter to back off after a
1296
+ // transient reflector error WITHOUT having collapsed
1297
+ // anything. The T2 schedule check below is gated on this
1298
+ // counter (==0 means "no T1 ever rewrote the arc, T2 may
1299
+ // fall back at end_turn").
1282
1300
  const turnStartIdx = conversationMessages.length - 1;
1283
1301
  let queryToolCount = 0;
1284
- let t1Fired = false;
1302
+ let lastT1AtToolCount = 0;
1303
+ let arcStartIdx = turnStartIdx + 1;
1304
+ let t1CollapsesDone = 0;
1285
1305
  const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
1286
1306
 
1287
1307
  // feat-6af5f9f1 PR B: a Turn = one user prompt + all AI responses.
@@ -1784,10 +1804,17 @@ export class Engine {
1784
1804
 
1785
1805
  // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
1786
1806
  // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
1787
- // (5) AND T1 didn't already collapse the arc. Kicks off the
1807
+ // (5) AND no T1 has actually rewritten the arc yet. Kicks off the
1788
1808
  // primary-model call without await; the next query()'s
1789
1809
  // `#applyPendingT2Reflections` carries the result forward.
1790
- if (queryToolCount > TURN_SUMMARY_THRESHOLD && !t1Fired) {
1810
+ //
1811
+ // Periodic-T1 fix: gate on `t1CollapsesDone === 0`, NOT
1812
+ // `lastT1AtToolCount === 0`. The catch block of T1 bumps
1813
+ // `lastT1AtToolCount` after a reflector error to avoid
1814
+ // tight-loop retries — but no collapse happened, so T2 should
1815
+ // still be allowed to fall back at end_turn. Fowler-review
1816
+ // critical finding.
1817
+ if (queryToolCount > TURN_SUMMARY_THRESHOLD && t1CollapsesDone === 0) {
1791
1818
  const arcStart = turnStartIdx + 1;
1792
1819
  const arcEnd = conversationMessages.length - 1;
1793
1820
  if (arcEnd > arcStart) {
@@ -2029,23 +2056,41 @@ export class Engine {
2029
2056
  break;
2030
2057
  }
2031
2058
 
2032
- // PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
2033
- // query() lifetime, the moment queryToolCount crosses
2034
- // TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
2035
- // assistant+tool arc since the user prompt and rewrites the history
2036
- // in place collapsing it to a SINGLE assistant message — before
2037
- // the next adapter.stream() runs.
2038
- if (!t1Fired
2039
- && queryToolCount >= TOOL_BATCH_SIZE
2040
- && !this.#reflectedTurns.has(`${queryNumber}:t1`)
2041
- && !abortedDuringTools && !signal?.aborted) {
2042
- t1Fired = true;
2043
- this.#reflectedTurns.add(`${queryNumber}:t1`);
2059
+ // PR-L: T1 in-turn (synchronous) reflection. Fires once per
2060
+ // adapter loop iteration where TOOL_BATCH_SIZE (13) tool
2061
+ // calls have accumulated since the last T1 firing — not just
2062
+ // the first 13 of the query(). Generates a markdown reflection
2063
+ // over the assistant+tool arc since the last T1 firing (or
2064
+ // since the user prompt for the first batch) and rewrites that
2065
+ // range to a SINGLE synthetic user message before the next
2066
+ // adapter.stream() runs.
2067
+ //
2068
+ // Loop semantics:
2069
+ // - First batch: arcStartIdx = turnStartIdx + 1, fires when
2070
+ // queryToolCount reaches 13.
2071
+ // - Each subsequent batch: arcStartIdx is updated to the slot
2072
+ // right after the just-inserted reflection message; fires
2073
+ // again whenever 13 more tools have run since
2074
+ // lastT1AtToolCount.
2075
+ // - The dedup Set key includes `lastT1AtToolCount` so each
2076
+ // batch within the same query gets a distinct entry — without
2077
+ // this the second batch would be silently skipped.
2078
+ const t1BatchDue = queryToolCount - lastT1AtToolCount >= TOOL_BATCH_SIZE;
2079
+ if (t1BatchDue && !abortedDuringTools && !signal?.aborted) {
2080
+ const t1DedupKey = `${queryNumber}:t1:${queryToolCount}`;
2081
+ if (this.#reflectedTurns.has(t1DedupKey)) {
2082
+ // Defensive: should never hit since t1BatchDue gates re-entry
2083
+ // and queryNumber namespaces queries. Kept as belt-and-
2084
+ // suspenders against any future external mutation of the
2085
+ // cursor (or a re-entrant query() that this code doesn't
2086
+ // anticipate).
2087
+ } else {
2088
+ this.#reflectedTurns.add(t1DedupKey);
2089
+ const batchStart = arcStartIdx;
2090
+ const batchEnd = conversationMessages.length - 1;
2044
2091
  try {
2045
- const arcStart = turnStartIdx + 1;
2046
- const arcEnd = conversationMessages.length - 1;
2047
2092
  const { pairs, assistantText } = extractToolPairsFromRange(
2048
- conversationMessages, arcStart, arcEnd,
2093
+ conversationMessages, batchStart, batchEnd,
2049
2094
  );
2050
2095
  yield {
2051
2096
  type: 'reflection',
@@ -2053,7 +2098,7 @@ export class Engine {
2053
2098
  loopNumber: turnNumber,
2054
2099
  trigger: 't1',
2055
2100
  status: 'pending',
2056
- loopRange: [arcStart, arcEnd],
2101
+ loopRange: [batchStart, batchEnd],
2057
2102
  toolCount: pairs.length,
2058
2103
  };
2059
2104
  const { content, durationMs } = await runT1Reflection({
@@ -2065,10 +2110,21 @@ export class Engine {
2065
2110
  signal,
2066
2111
  });
2067
2112
  const next = collapseRangeToReflection(
2068
- conversationMessages, arcStart, arcEnd, content,
2113
+ conversationMessages, batchStart, batchEnd, content,
2069
2114
  );
2070
2115
  conversationMessages.length = 0;
2071
2116
  for (const m of next) conversationMessages.push(m);
2117
+ // After collapse: the just-inserted reflection lives at
2118
+ // index `batchStart`. The next tool arc therefore starts
2119
+ // immediately after it, i.e. at conversationMessages.length
2120
+ // (the next assistant message will land here).
2121
+ arcStartIdx = conversationMessages.length;
2122
+ lastT1AtToolCount = queryToolCount;
2123
+ // Bump the success counter — used by the T2 schedule check
2124
+ // to decide whether T2 still has work to do at end_turn.
2125
+ // Distinct from lastT1AtToolCount which the catch block
2126
+ // also bumps (but without rewriting history).
2127
+ t1CollapsesDone += 1;
2072
2128
  yield {
2073
2129
  type: 'reflection',
2074
2130
  turnId: queryTurnId,
@@ -2078,7 +2134,7 @@ export class Engine {
2078
2134
  // so the frontend key stays stable across pending → ready and
2079
2135
  // the spinner card is replaced in place (no orphan).
2080
2136
  status: 'ready',
2081
- loopRange: [arcStart, arcEnd],
2137
+ loopRange: [batchStart, batchEnd],
2082
2138
  toolCount: pairs.length,
2083
2139
  content,
2084
2140
  durationMs,
@@ -2094,6 +2150,19 @@ export class Engine {
2094
2150
  status: 'error',
2095
2151
  error: err && err.message || String(err),
2096
2152
  };
2153
+ // Advance lastT1AtToolCount past this batch so we don't
2154
+ // tight-loop on a hiccuping reflector. The next attempt is
2155
+ // 13 tools from now, not immediately. arcStartIdx is left
2156
+ // alone because history wasn't rewritten — the tail still
2157
+ // begins where it did. The trade-off: the next batch's
2158
+ // reflection will cover the tools that just failed too,
2159
+ // which is fine (they're still in conversationMessages).
2160
+ //
2161
+ // We do NOT bump t1CollapsesDone — see the variable's
2162
+ // declaration comment. This keeps the T2 fallback path live
2163
+ // when every T1 attempt has errored.
2164
+ lastT1AtToolCount = queryToolCount;
2165
+ }
2097
2166
  }
2098
2167
  }
2099
2168
 
package/unify/session.js CHANGED
@@ -43,7 +43,7 @@ import { Engine } from './engine.js';
43
43
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
44
44
  import { seedDefaultVps } from './vp/seed-defaults.js';
45
45
  import { runSummaryBackfill } from './memory/seed-backfill.js';
46
- import { createV2DreamScheduler, bootInitEmptyGroups } from './dream-v2/session-wiring.js';
46
+ import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream-v2/session-wiring.js';
47
47
  import { openSegmentIndex } from './memory/index-db.js';
48
48
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
49
49
  import { openAmsRegistry } from './memory/ams-registry.js';
@@ -97,6 +97,7 @@ export async function loadSession(options = {}) {
97
97
  skipSkills = false,
98
98
  extraTools = [],
99
99
  configOverrides = {},
100
+ serverMode = false,
100
101
  } = options;
101
102
 
102
103
  // ─── 1. Determine yeaftDir + ensure directory structure ──
@@ -119,6 +120,10 @@ export async function loadSession(options = {}) {
119
120
 
120
121
  // ─── 2. Load config ───────────────────────────────────
121
122
  const config = loadConfig(overrides);
123
+ // fix/dream-cadence-and-ui-trigger: tag config so the dream scheduler
124
+ // can decide whether to keep its interval timer alive (server) or
125
+ // unref it (CLI / tests). Non-persisted — set per-session by caller.
126
+ if (serverMode) config.serverMode = true;
122
127
 
123
128
  // ─── 2.1 Migration state check (task-334i) ────────────
124
129
  // If the group-chat feature flag is on but migration has not
@@ -328,6 +333,21 @@ export async function loadSession(options = {}) {
328
333
  }).catch(() => { /* best-effort boot init */ });
329
334
  }
330
335
 
336
+ // fix/dream-cadence-and-ui-trigger: stale-cadence catch-up. If the
337
+ // newest per-group lastDreamAt across all groups is older than
338
+ // DREAM_INTERVAL_HOURS (or absent and there's user traffic), fire a
339
+ // single non-manual tick now. Independent of the interval timer —
340
+ // necessary because production observed 12 days between scheduled
341
+ // ticks (the unref'd interval did not fire reliably on long-lived
342
+ // server processes).
343
+ if (!config._readOnly) {
344
+ bootCatchUpStaleDream({
345
+ yeaftDir,
346
+ dreamScheduler,
347
+ config,
348
+ }).catch(() => { /* best-effort catch-up */ });
349
+ }
350
+
331
351
  // H2.f.5: thread engine registry, input queue, and dispatcher retired.
332
352
  // The session exposes a single `engine`; web-bridge calls engine.query()
333
353
  // directly. Memory recall happens via memory/preflow.js (pre-turn) and
@@ -1665,6 +1665,7 @@ async function ensureSessionLoaded() {
1665
1665
  ...(yeaftDir && { dir: yeaftDir }),
1666
1666
  skipMCP: false,
1667
1667
  skipSkills: false,
1668
+ serverMode: true,
1668
1669
  });
1669
1670
 
1670
1671
  installUnifyRuntimeBridge(session);
@@ -2367,11 +2368,27 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2367
2368
 
2368
2369
  const result = await session.dreamScheduler.triggerDreamNow();
2369
2370
 
2371
+ // fix/dream-cadence-and-ui-trigger: derive a single "entries
2372
+ // created" count for the UI bubble. The runner returns a richer
2373
+ // shape (groups[], targets[]); the front-end button only needs a
2374
+ // scalar plus the start timestamp.
2375
+ const targets = Array.isArray(result?.targets) ? result.targets : [];
2376
+ const entriesCreated = targets.filter(t => t && t.status === 'done').length;
2377
+ const lastDreamAt = result?.startedAt || new Date().toISOString();
2378
+
2379
+ // Spread `result` FIRST so derived fields (success, entriesCreated,
2380
+ // lastDreamAt) authoritatively shadow anything the runner might grow
2381
+ // with the same name. Today there is no collision (runner.js returns
2382
+ // { groups, targets, startedAt, error?, skipped? }) but the failure
2383
+ // mode of the alternative ordering is silent — review feedback from
2384
+ // PR #743.
2370
2385
  sendToServer({
2371
2386
  type: 'unify_dream_result',
2372
2387
  vpId,
2373
- success: !result.error && !result.skipped,
2374
2388
  ...result,
2389
+ success: !result.error && !result.skipped,
2390
+ entriesCreated,
2391
+ lastDreamAt,
2375
2392
  });
2376
2393
  } catch (err) {
2377
2394
  sendToServer({
@@ -2570,6 +2587,7 @@ export async function handleUnifyLoadHistory(msg) {
2570
2587
  ...(yeaftDir && { dir: yeaftDir }),
2571
2588
  skipMCP: false,
2572
2589
  skipSkills: false,
2590
+ serverMode: true,
2573
2591
  });
2574
2592
  installUnifyRuntimeBridge(session);
2575
2593
 
@@ -2762,6 +2780,7 @@ export async function resetUnifySession() {
2762
2780
  ...(yeaftDir && { dir: yeaftDir }),
2763
2781
  skipMCP: false,
2764
2782
  skipSkills: false,
2783
+ serverMode: true,
2765
2784
  });
2766
2785
  installUnifyRuntimeBridge(session);
2767
2786