@yeaft/webchat-agent 0.1.494 → 0.1.495

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.
@@ -24,7 +24,7 @@ import {
24
24
  import { sendToServer, flushMessageBuffer } from './buffer.js';
25
25
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
26
26
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
27
- import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
27
+ import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
28
28
  import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread } from '../unify/web-bridge.js';
29
29
 
30
30
  export async function handleMessage(msg) {
@@ -320,6 +320,30 @@ export async function handleMessage(msg) {
320
320
  break;
321
321
  }
322
322
 
323
+ // task-318: Unify runtime settings (thread concurrency + auto-archive).
324
+ // Read/write the nested `unify` section of config.json — LLM fields
325
+ // untouched. On update we broadcast a `unify_settings_updated` event
326
+ // so the UI reflects the new values and in-process consumers
327
+ // (ThreadEngineRegistry, ThreadStore) can reload their caps.
328
+ case 'get_unify_settings': {
329
+ const settings = getUnifySettings(ctx.CONFIG?.yeaftDir);
330
+ sendToServer({ type: 'unify_settings', ...settings });
331
+ break;
332
+ }
333
+
334
+ case 'update_unify_settings': {
335
+ const result = updateUnifySettings(msg.settings || msg.config || {}, ctx.CONFIG?.yeaftDir);
336
+ // Let live consumers pick up the new caps without a session restart.
337
+ // The registry/store are created per-session; we update the exported
338
+ // accessors so subsequent dispatches see the new values.
339
+ if (!result.error && ctx.unifyRuntimeSettings) {
340
+ ctx.unifyRuntimeSettings.maxConcurrentThreads = result.maxConcurrentThreads;
341
+ ctx.unifyRuntimeSettings.autoArchiveIdleDays = result.autoArchiveIdleDays;
342
+ }
343
+ sendToServer({ type: 'unify_settings_updated', ...result });
344
+ break;
345
+ }
346
+
323
347
  // Unify — independent chat via Engine
324
348
  case 'unify_chat':
325
349
  await handleUnifyChat(msg);
package/context.js CHANGED
@@ -30,4 +30,9 @@ export default {
30
30
  sendToServer: null,
31
31
  // 由 index.js 注册的配置保存函数
32
32
  saveConfig: null,
33
+ // task-318: live Unify runtime caps. Mutated in-place by
34
+ // `update_unify_settings` so in-process consumers (web-bridge) can
35
+ // pick up the new values without a session restart. `null` until the
36
+ // Unify session has finished loading.
37
+ unifyRuntimeSettings: null,
33
38
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.494",
3
+ "version": "0.1.495",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
15
+ import { normaliseUnifySection } from './config.js';
15
16
 
16
17
  /**
17
18
  * Read the LLM-relevant portion of config.json.
@@ -126,3 +127,91 @@ export function updateLlmConfig(update, dir) {
126
127
  language: existing.language || 'en',
127
128
  };
128
129
  }
130
+
131
+ // ─── Unify runtime settings (task-318) ────────────────────────────
132
+
133
+ /**
134
+ * Read the Unify-section of config.json. Returns defaults when the file
135
+ * or section is absent. Callers (UI, registry, ThreadStore) rely on a
136
+ * stable shape — `normaliseUnifySection` guarantees that.
137
+ *
138
+ * @param {string} [dir] — Yeaft data directory
139
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number } | { error: string }}
140
+ */
141
+ export function getUnifySettings(dir) {
142
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
143
+ const configPath = join(root, 'config.json');
144
+ if (!existsSync(configPath)) return normaliseUnifySection(null);
145
+ try {
146
+ const raw = readFileSync(configPath, 'utf8');
147
+ const json = JSON.parse(raw);
148
+ return normaliseUnifySection(json.unify);
149
+ } catch (e) {
150
+ return { error: `Failed to read config.json: ${e.message}` };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Update the Unify-section of config.json. Merges into existing config
156
+ * (LLM provider / model fields are untouched) and validates each field:
157
+ * `maxConcurrentThreads` must be 1..50, `autoArchiveIdleDays` must be
158
+ * 1..3650. Invalid values are rejected outright so the UI sees an error
159
+ * rather than silently reverting — a silent revert would make "I set it
160
+ * to 100 and nothing happened" impossible to debug.
161
+ *
162
+ * @param {{ maxConcurrentThreads?: number, autoArchiveIdleDays?: number }} update
163
+ * @param {string} [dir]
164
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number } | { error: string }}
165
+ */
166
+ export function updateUnifySettings(update, dir) {
167
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
168
+ const configPath = join(root, 'config.json');
169
+
170
+ if (!update || typeof update !== 'object') {
171
+ return { error: 'update payload required' };
172
+ }
173
+
174
+ // Validate before touching the file. We enforce the same clamp bounds
175
+ // that `normaliseUnifySection` uses for reads so round-trip is stable.
176
+ if (update.maxConcurrentThreads !== undefined) {
177
+ const n = Number(update.maxConcurrentThreads);
178
+ if (!Number.isFinite(n) || n < 1 || n > 50) {
179
+ return { error: 'maxConcurrentThreads must be between 1 and 50' };
180
+ }
181
+ }
182
+ if (update.autoArchiveIdleDays !== undefined) {
183
+ const n = Number(update.autoArchiveIdleDays);
184
+ if (!Number.isFinite(n) || n < 1 || n > 3650) {
185
+ return { error: 'autoArchiveIdleDays must be between 1 and 3650' };
186
+ }
187
+ }
188
+
189
+ // Read existing config (preserve LLM and other top-level fields).
190
+ let existing = {};
191
+ if (existsSync(configPath)) {
192
+ try {
193
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
194
+ } catch {
195
+ existing = {};
196
+ }
197
+ }
198
+
199
+ const prev = normaliseUnifySection(existing.unify);
200
+ const merged = {
201
+ maxConcurrentThreads: update.maxConcurrentThreads !== undefined
202
+ ? Math.floor(Number(update.maxConcurrentThreads))
203
+ : prev.maxConcurrentThreads,
204
+ autoArchiveIdleDays: update.autoArchiveIdleDays !== undefined
205
+ ? Math.floor(Number(update.autoArchiveIdleDays))
206
+ : prev.autoArchiveIdleDays,
207
+ };
208
+ existing.unify = merged;
209
+
210
+ try {
211
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
212
+ } catch (e) {
213
+ return { error: `Failed to write config.json: ${e.message}` };
214
+ }
215
+
216
+ return merged;
217
+ }
package/unify/config.js CHANGED
@@ -33,6 +33,15 @@ const DEFAULTS = {
33
33
  maxOutputTokens: 16384,
34
34
  messageTokenBudget: 8192,
35
35
  maxContinueTurns: 3,
36
+ // task-318: Unify-specific runtime caps. `maxConcurrentThreads` gates
37
+ // how many ThreadEngineRegistry instances may be live at once; dispatch
38
+ // refuses new threads beyond this. The count INCLUDES the always-on
39
+ // `main` thread, so the default 6 gives users 5 user-initiated threads
40
+ // on top of main. `autoArchiveIdleDays` is consumed by ThreadStore's
41
+ // idle-archive pass (task-317 will wire the pass itself; here we just
42
+ // plumb the knob through so the UI can set it).
43
+ unifyMaxConcurrentThreads: 6,
44
+ unifyAutoArchiveIdleDays: 30,
36
45
  };
37
46
 
38
47
  // ─── config.json reader ─────────────────────────────────────────
@@ -134,6 +143,52 @@ function isTruthy(val) {
134
143
  return val === '1' || val === 'true' || val === 'yes';
135
144
  }
136
145
 
146
+ /**
147
+ * Clamp and normalise the `unify` config section. Missing / non-numeric
148
+ * inputs fall back to the DEFAULTS; numeric-but-out-of-range inputs are
149
+ * *clamped* to the valid range rather than silently reverting to the
150
+ * default. This keeps the read path aligned with `updateUnifySettings`,
151
+ * which rejects out-of-range writes up front — a hand-edited
152
+ * `config.json` with `maxConcurrentThreads: 100` now loads as 50 (the
153
+ * nearest valid value) instead of collapsing back to 5.
154
+ *
155
+ * Exported for tests and for `config-api.js` to share the same clamp
156
+ * bounds via `clampUnifyField`.
157
+ *
158
+ * @param {any} raw — jsonConfig.unify (may be undefined / malformed)
159
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number }}
160
+ */
161
+ export function normaliseUnifySection(raw) {
162
+ const out = {
163
+ maxConcurrentThreads: DEFAULTS.unifyMaxConcurrentThreads,
164
+ autoArchiveIdleDays: DEFAULTS.unifyAutoArchiveIdleDays,
165
+ };
166
+ if (!raw || typeof raw !== 'object') return out;
167
+ const mc = clampUnifyField(raw.maxConcurrentThreads, 'maxConcurrentThreads');
168
+ if (mc !== null) out.maxConcurrentThreads = mc;
169
+ const ad = clampUnifyField(raw.autoArchiveIdleDays, 'autoArchiveIdleDays');
170
+ if (ad !== null) out.autoArchiveIdleDays = ad;
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Clamp a single unify field to its valid range. Returns `null` if the
176
+ * input is not a finite number (so callers can treat it as "not set" and
177
+ * keep the previous value). Centralises the bounds used on both read
178
+ * (`normaliseUnifySection`) and write (`updateUnifySettings` validation).
179
+ *
180
+ * @param {unknown} v
181
+ * @param {'maxConcurrentThreads'|'autoArchiveIdleDays'} field
182
+ * @returns {number | null}
183
+ */
184
+ export function clampUnifyField(v, field) {
185
+ if (v === null || v === undefined) return null;
186
+ const n = Number(v);
187
+ if (!Number.isFinite(n)) return null;
188
+ const [lo, hi] = field === 'maxConcurrentThreads' ? [1, 50] : [1, 3650];
189
+ return Math.min(hi, Math.max(lo, Math.floor(n)));
190
+ }
191
+
137
192
  /**
138
193
  * Build config from legacy config.md + .env + env vars.
139
194
  * @deprecated — used only when config.json doesn't exist.
@@ -160,6 +215,8 @@ function loadLegacyConfig(dir, overrides) {
160
215
  maxOutputTokens: overrides.maxOutputTokens ?? fileConfig.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
161
216
  messageTokenBudget: overrides.messageTokenBudget ?? (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ?? fileConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
162
217
  maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
218
+ // task-318: legacy path never had the `unify` section — defaults.
219
+ unify: normaliseUnifySection(null),
163
220
  providers: null,
164
221
  primaryModel: null,
165
222
  fastModel: null,
@@ -252,6 +309,10 @@ export function loadConfig(overrides = {}) {
252
309
  messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
253
310
  maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
254
311
 
312
+ // task-318: Unify runtime caps. `unify` is a nested section so we
313
+ // don't pollute the flat config namespace used by chat/crew code.
314
+ unify: normaliseUnifySection(jsonConfig.unify),
315
+
255
316
  // Legacy fields (null when using config.json)
256
317
  apiKey: overrides.apiKey || null,
257
318
  openaiApiKey: null,
package/unify/session.js CHANGED
@@ -131,7 +131,13 @@ export async function loadSession(options = {}) {
131
131
  // ─── 5b. Initialize thread store (task-299 Phase 1) ────
132
132
  // task-307a: now file-backed under ~/.yeaft/threads/. Passing the
133
133
  // yeaftDir switches on disk persistence; read-only mode is honoured.
134
- initThreadStore(yeaftDir, { readOnly: config._readOnly || false, force: true });
134
+ // task-318: forward the Unify autoArchiveIdleDays knob so the
135
+ // archive pass (owned by task-317) can read it off the store.
136
+ initThreadStore(yeaftDir, {
137
+ readOnly: config._readOnly || false,
138
+ force: true,
139
+ idleArchiveDays: config.unify?.autoArchiveIdleDays ?? 0,
140
+ });
135
141
 
136
142
  // ─── 6. Load skills ────────────────────────────────────
137
143
  let skillManager;
@@ -187,6 +193,8 @@ export async function loadSession(options = {}) {
187
193
  skillManager,
188
194
  mcpManager,
189
195
  yeaftDir,
196
+ // task-318: concurrent-thread cap (UI-adjustable via Settings).
197
+ maxConcurrent: config.unify?.maxConcurrentThreads ?? null,
190
198
  });
191
199
  // Seed the main-thread instance so listActive() is non-empty from T=0.
192
200
  engineRegistry.ensure(MAIN_THREAD_ID);
@@ -254,6 +262,11 @@ export async function loadSession(options = {}) {
254
262
  toolRegistry,
255
263
  trace,
256
264
  yeaftDir,
265
+ // task-318 rev-1 fix: expose the live ThreadStore handle so callers
266
+ // (web-bridge, message-router via ctx) can invoke setIdleArchiveDays()
267
+ // on the exact instance that's wired into the dispatcher. Without this
268
+ // export the setter was effectively dead code.
269
+ threadStore: getThreadStore(),
257
270
  status,
258
271
  shutdown,
259
272
  };
@@ -29,6 +29,17 @@
29
29
  import { MAIN_THREAD_ID } from './store.js';
30
30
  import { createEngineInstance } from './engine-instance.js';
31
31
 
32
+ /**
33
+ * Coerce a maxConcurrent input to either a positive integer or null
34
+ * (meaning "no cap"). Keeps the cap logic branch-free elsewhere.
35
+ */
36
+ function normaliseMaxConcurrent(n) {
37
+ if (n === null || n === undefined) return null;
38
+ const v = Number(n);
39
+ if (!Number.isFinite(v) || v <= 0) return null;
40
+ return Math.floor(v);
41
+ }
42
+
32
43
  export class ThreadEngineRegistry {
33
44
  /** @type {Map<string, import('./engine-instance.js').EngineInstance>} */
34
45
  #instances;
@@ -39,18 +50,33 @@ export class ThreadEngineRegistry {
39
50
  /** @type {string} */
40
51
  #currentThreadId;
41
52
 
53
+ /**
54
+ * task-318: soft cap on concurrent live engine instances. When set,
55
+ * `ensure()` refuses to spawn a new instance beyond this many live
56
+ * entries. Already-live instances continue to serve query()s — the
57
+ * cap only gates net-new thread creation. Can be mutated at runtime
58
+ * by `setMaxConcurrent()` so the Settings UI takes effect without a
59
+ * session restart.
60
+ *
61
+ * Null / 0 / negative → unlimited (treat as "no cap").
62
+ * @type {number | null}
63
+ */
64
+ #maxConcurrent;
65
+
42
66
  /**
43
67
  * @param {{
44
68
  * factory: (threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance,
69
+ * maxConcurrent?: number | null,
45
70
  * }} params
46
71
  */
47
- constructor({ factory } = {}) {
72
+ constructor({ factory, maxConcurrent } = {}) {
48
73
  if (typeof factory !== 'function') {
49
74
  throw new Error('ThreadEngineRegistry: factory function is required');
50
75
  }
51
76
  this.#instances = new Map();
52
77
  this.#factory = factory;
53
78
  this.#currentThreadId = MAIN_THREAD_ID;
79
+ this.#maxConcurrent = normaliseMaxConcurrent(maxConcurrent);
54
80
  }
55
81
 
56
82
  /** @returns {string} */
@@ -83,6 +109,22 @@ export class ThreadEngineRegistry {
83
109
  }
84
110
  const existing = this.#instances.get(threadId);
85
111
  if (existing && !existing.terminated) return existing;
112
+ // task-318: enforce concurrency cap before spawning a net-new
113
+ // instance. Replacing a terminated slot for the same threadId does
114
+ // NOT count against the cap — it's the same thread resuming.
115
+ if (!existing && this.#maxConcurrent !== null) {
116
+ const live = this.#countLive();
117
+ if (live >= this.#maxConcurrent) {
118
+ const err = new Error(
119
+ `ThreadEngineRegistry: concurrent thread limit reached (${live}/${this.#maxConcurrent}). ` +
120
+ `Archive or terminate an existing thread before starting a new one.`
121
+ );
122
+ err.code = 'ERR_MAX_CONCURRENT_THREADS';
123
+ err.limit = this.#maxConcurrent;
124
+ err.live = live;
125
+ throw err;
126
+ }
127
+ }
86
128
  const instance = this.#factory(threadId, opts);
87
129
  if (!instance || typeof instance.query !== 'function') {
88
130
  throw new Error(`ThreadEngineRegistry.ensure: factory did not return an EngineInstance for ${threadId}`);
@@ -91,6 +133,31 @@ export class ThreadEngineRegistry {
91
133
  return instance;
92
134
  }
93
135
 
136
+ /**
137
+ * Count currently non-terminated instances — the denominator for the
138
+ * concurrency cap. O(n) scan, n is small (≤ 50 in practice).
139
+ * @returns {number}
140
+ */
141
+ #countLive() {
142
+ let n = 0;
143
+ for (const inst of this.#instances.values()) {
144
+ if (!inst.terminated) n += 1;
145
+ }
146
+ return n;
147
+ }
148
+
149
+ /**
150
+ * task-318: read or update the concurrency cap. `setMaxConcurrent(null)`
151
+ * disables the cap entirely. Existing live instances are NOT terminated
152
+ * if the cap is lowered below their count — the cap only gates new
153
+ * `ensure()` calls going forward.
154
+ * @returns {number | null}
155
+ */
156
+ get maxConcurrent() { return this.#maxConcurrent; }
157
+ setMaxConcurrent(n) {
158
+ this.#maxConcurrent = normaliseMaxConcurrent(n);
159
+ }
160
+
94
161
  /**
95
162
  * Set the current thread marker. Does not lazy-create — caller must
96
163
  * ensure() if they want an instance for an unseen thread.
@@ -188,5 +255,6 @@ export function createThreadEngineRegistry(deps) {
188
255
  ...opts,
189
256
  threadId,
190
257
  }),
258
+ maxConcurrent: deps?.maxConcurrent ?? null,
191
259
  });
192
260
  }
@@ -52,6 +52,18 @@ export const MAIN_THREAD_ID = 'main';
52
52
  /** Valid thread status values. Mirrors design doc §5. */
53
53
  export const THREAD_STATUSES = ['active', 'idle', 'archived'];
54
54
 
55
+ /**
56
+ * task-318: Coerce an idle-archive days input to a non-negative integer
57
+ * (0 disables the feature). Anything invalid falls through to 0 so a
58
+ * malformed config can never silently enable auto-archive.
59
+ */
60
+ function normaliseIdleDays(n) {
61
+ if (n === null || n === undefined) return 0;
62
+ const v = Number(n);
63
+ if (!Number.isFinite(v) || v <= 0) return 0;
64
+ return Math.floor(v);
65
+ }
66
+
55
67
  /** Debounce window for grouped disk writes. Kept short so tests don't hang. */
56
68
  const FLUSH_DEBOUNCE_MS = 8;
57
69
 
@@ -275,10 +287,12 @@ export class ThreadStore {
275
287
  #dirtyAttachments;
276
288
  /** @type {any} NodeJS.Timeout */
277
289
  #flushTimer;
290
+ /** @type {number} task-318: days of inactivity before auto-archive (0 = disabled) */
291
+ #idleArchiveDays;
278
292
 
279
293
  /**
280
294
  * @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
281
- * @param {{ readOnly?: boolean }} [opts]
295
+ * @param {{ readOnly?: boolean, idleArchiveDays?: number }} [opts]
282
296
  */
283
297
  constructor(yeaftDir, opts = {}) {
284
298
  this.#threads = new Map();
@@ -290,6 +304,11 @@ export class ThreadStore {
290
304
  this.#flushTimer = null;
291
305
 
292
306
  this.#readOnly = !!opts.readOnly;
307
+ // task-318: idle-archive threshold (days). Consumed by the archive
308
+ // pass in task-317; here we just accept + expose the knob so the
309
+ // config plumbing is testable end-to-end today. A value ≤ 0 means
310
+ // "no auto-archive" (feature disabled).
311
+ this.#idleArchiveDays = normaliseIdleDays(opts.idleArchiveDays);
293
312
  if (yeaftDir) {
294
313
  this.#dir = join(yeaftDir, 'threads');
295
314
  this.#indexPath = join(this.#dir, 'index.md');
@@ -487,6 +506,18 @@ export class ThreadStore {
487
506
  get currentId() { return this.#currentId; }
488
507
  get size() { return this.#threads.size; }
489
508
 
509
+ /**
510
+ * task-318: idle-archive threshold (days). 0 disables auto-archive.
511
+ * The actual archive pass is owned by task-317 — this accessor is the
512
+ * contract between config and that future consumer, plus a write path
513
+ * so the Settings UI takes effect without restarting the session.
514
+ * @returns {number}
515
+ */
516
+ get idleArchiveDays() { return this.#idleArchiveDays; }
517
+ setIdleArchiveDays(days) {
518
+ this.#idleArchiveDays = normaliseIdleDays(days);
519
+ }
520
+
490
521
  get(id) { return this.#threads.get(id) || null; }
491
522
  list() { return [...this.#threads.values()]; }
492
523
  has(id) { return this.#threads.has(id); }
@@ -73,6 +73,38 @@ function sendUnifyEvent(event) {
73
73
  });
74
74
  }
75
75
 
76
+ /**
77
+ * task-318 rev-1 fix: install live-setter bridge between the session's
78
+ * runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
79
+ * which message-router's `update_unify_settings` branch reads. Previously
80
+ * that object was null and the setters were dead code. Now every
81
+ * `update_unify_settings` mutation pushes the new caps into the live
82
+ * session within the same tick — no reload required.
83
+ *
84
+ * Exported so tests can drive the same wiring with a mock session.
85
+ *
86
+ * @param {import('./session.js').Session} s
87
+ */
88
+ export function installUnifyRuntimeBridge(s) {
89
+ if (!s) return;
90
+ const initialMax = s.engineRegistry?.maxConcurrent ?? null;
91
+ const initialIdle = s.threadStore?.idleArchiveDays ?? 0;
92
+ ctx.unifyRuntimeSettings = {
93
+ get maxConcurrentThreads() { return s.engineRegistry?.maxConcurrent ?? initialMax; },
94
+ set maxConcurrentThreads(v) {
95
+ if (typeof s.engineRegistry?.setMaxConcurrent === 'function') {
96
+ s.engineRegistry.setMaxConcurrent(v);
97
+ }
98
+ },
99
+ get autoArchiveIdleDays() { return s.threadStore?.idleArchiveDays ?? initialIdle; },
100
+ set autoArchiveIdleDays(v) {
101
+ if (typeof s.threadStore?.setIdleArchiveDays === 'function') {
102
+ s.threadStore.setIdleArchiveDays(v);
103
+ }
104
+ },
105
+ };
106
+ }
107
+
76
108
  /**
77
109
  * task-301 Part 2: push the full thread list snapshot to the web client.
78
110
  * Called after any ThreadStore-mutating tool completes and at turn_end so
@@ -391,6 +423,14 @@ export async function handleUnifyChat(msg) {
391
423
  skipSkills: false,
392
424
  });
393
425
 
426
+ // task-318 rev-1 fix: expose live setters on ctx so message-router's
427
+ // update_unify_settings branch can push the new caps into the
428
+ // registry + thread store without a session reload. Previously this
429
+ // object was null and setMaxConcurrent/setIdleArchiveDays were dead
430
+ // code — the config file was updated on disk but the running
431
+ // session continued with the old caps until next restart.
432
+ installUnifyRuntimeBridge(session);
433
+
394
434
  // Create a stable conversationId for the Unify session
395
435
  unifyConversationId = `unify-${Date.now()}`;
396
436
 
@@ -737,6 +777,8 @@ export async function handleUnifyLoadHistory(msg) {
737
777
  skipMCP: false,
738
778
  skipSkills: false,
739
779
  });
780
+ // task-318 rev-1 fix: wire live setters; see handleUnifyChat.
781
+ installUnifyRuntimeBridge(session);
740
782
 
741
783
  unifyConversationId = `unify-${Date.now()}`;
742
784
 
@@ -811,6 +853,8 @@ export async function resetUnifySession() {
811
853
  skipMCP: false,
812
854
  skipSkills: false,
813
855
  });
856
+ // task-318 rev-1 fix: wire live setters; see handleUnifyChat.
857
+ installUnifyRuntimeBridge(session);
814
858
 
815
859
  unifyConversationId = `unify-${Date.now()}`;
816
860