dsh-advisor 0.4.0 → 0.5.0

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/lib/index.js CHANGED
@@ -31,11 +31,11 @@
31
31
  * (`ctx.get('tuiSettingsSections') !== undefined`), so the hint names the TUI
32
32
  * `/settings` Advisor section as a write path exactly while the seam is
33
33
  * mounted; the readback itself stays session-less and read-only.
34
- * Settings (plan dsh-advisor-settings-n2): the plugin-row config is the
35
- * composition base of the `advisor` settings namespace (`src/settings.ts`),
36
- * read live through the bridge source; committed settings edits re-apply
37
- * derived state (immuneTurns / maxDeltaMessages / per-session runtimes)
38
- * without a restart.
34
+ * Settings (plan dsh-advisor-settings-n2): the plugin-row entry config's six
35
+ * live fields are schema-volatile (`src/config.ts`), read live through the
36
+ * bridge source (`src/settings.ts`); committed volatile edits (Loader
37
+ * `loader/volatile-update`) re-apply derived state (immuneTurns /
38
+ * maxDeltaMessages / per-session runtimes) without a restart.
39
39
  * Config gateway (plan dsh-advisor-settings-gateway-n5): `apply` also
40
40
  * registers the host-side `AdvisorConfigGateway` (`src/gateway.ts`) — the
41
41
  * `/api/advisor/get` + `/api/advisor/set` endpoints (explicit
@@ -45,14 +45,67 @@
45
45
  *
46
46
  * @module dsh-advisor
47
47
  */
48
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
49
+ if (value !== null && value !== void 0) {
50
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
51
+ var dispose, inner;
52
+ if (async) {
53
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
54
+ dispose = value[Symbol.asyncDispose];
55
+ }
56
+ if (dispose === void 0) {
57
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
58
+ dispose = value[Symbol.dispose];
59
+ if (async) inner = dispose;
60
+ }
61
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
62
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
63
+ env.stack.push({ value: value, dispose: dispose, async: async });
64
+ }
65
+ else if (async) {
66
+ env.stack.push({ async: true });
67
+ }
68
+ return value;
69
+ };
70
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
71
+ return function (env) {
72
+ function fail(e) {
73
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
74
+ env.hasError = true;
75
+ }
76
+ var r, s = 0;
77
+ function next() {
78
+ while (r = env.stack.pop()) {
79
+ try {
80
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
81
+ if (r.dispose) {
82
+ var result = r.dispose.call(r.value);
83
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
84
+ }
85
+ else s |= 1;
86
+ }
87
+ catch (e) {
88
+ fail(e);
89
+ }
90
+ }
91
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
92
+ if (env.hasError) throw env.error;
93
+ }
94
+ return next();
95
+ };
96
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
97
+ var e = new Error(message);
98
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
99
+ });
48
100
  import { resolveAdvisorConfig } from './config.js';
49
101
  import { installAdvisorSettings } from './settings.js';
50
- import { AdvisorConfigGateway, advisorTypertContribution } from './gateway.js';
102
+ import { AdvisorConfigGateway, advisorSessionError, advisorTypertContribution } from './gateway.js';
51
103
  import { SessionTranscriptObserver } from './transcript.js';
52
104
  import { AdvisorRuntime } from './advisor-runtime.js';
53
105
  import { AdvisorDelivery } from './delivery.js';
54
106
  import { DEFAULT_ADVISOR_SYSTEM_PROMPT } from './prompts.js';
55
- import { AdvisorSessionOverrides, registerAdvisorCommands, summarizeSystemPrompt } from './commands.js';
107
+ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
108
+ import { AdvisorSessionOverrides, registerAdvisorCommands, summarizeSystemPrompt, ADVISOR_MODEL_VALIDATION_TIMEOUT_MS } from './commands.js';
56
109
  import { installTuiClient } from './tui.js';
57
110
  import { TUI_SETTINGS_SECTIONS, installTuiSettingsSection } from './tui-settings.js';
58
111
  export const name = 'dsh-advisor';
@@ -64,15 +117,13 @@ export { Config } from './config.js';
64
117
  * fibers (observed: 3 active); with the global session/event subscription every
65
118
  * instance would observe every session and N×-review/N×-call per round. The
66
119
  * FIRST apply to claim the reviewer role wires the observer/runtime/delivery
67
- * and the /advisor commands; later instances attempt the settings registration
68
- * only (qc1 W-5: it is NOT idempotent — dsh-settings register throws on a
69
- * duplicate; installAdvisorSettings dedupes it and falls back to the entry
70
- * source, so the first registration owns the live namespace) and stay inert
71
- * otherwise. The claim is taken only AFTER the construction-time config gate
72
- * (qc1 W-4: a rejected first row never leaves the flag claimed) and is
73
- * released when the claiming fiber is disposed, so a later re-apply/re-mount
74
- * can take over. The flag rides globalThis so it survives even module-copy
75
- * divergence. */
120
+ * and the /advisor commands; later instances keep only their settings bridge
121
+ * (each fiber's bridge listens on its own fiber's `loader/volatile-update` —
122
+ * the owning-filter keeps them independent) and stay inert otherwise. The
123
+ * claim is taken only AFTER the construction-time config gate (qc1 W-4: a
124
+ * rejected first row never leaves the flag claimed) and is released when the
125
+ * claiming fiber is disposed, so a later re-apply/re-mount can take over. The
126
+ * flag rides globalThis so it survives even module-copy divergence. */
76
127
  const REVIEWER_KEY = '__dshAdvisorReviewer__';
77
128
  function claimReviewer() {
78
129
  const g = globalThis;
@@ -81,16 +132,72 @@ function claimReviewer() {
81
132
  g[REVIEWER_KEY] = true;
82
133
  return true;
83
134
  }
135
+ /** Capability-owned code stamped onto the model-validation deadline's TimeoutReason. */
136
+ const ADVISOR_MODEL_VALIDATION_TIMEOUT = 'ADVISOR_MODEL_VALIDATION_TIMEOUT';
137
+ /**
138
+ * Fuse several upstream signals into one (spec §5.3 validation): the fused
139
+ * signal aborts when ANY upstream aborts. Used to combine the invoking
140
+ * command's cancellation signal with the owner-teardown signal — a hung
141
+ * `resolveModelInfo` lookup that honors cancellation aborts with whichever
142
+ * lands first, and the `dsh-timeout` deadline adds the 60 s bound on top.
143
+ * `cleanup` removes the listeners (the teardown signal outlives every
144
+ * short-lived validation).
145
+ */
146
+ function fuseSignals(...upstreams) {
147
+ const controller = new AbortController();
148
+ const onAbort = (event) => {
149
+ controller.abort(event.target.reason);
150
+ };
151
+ for (const upstream of upstreams) {
152
+ if (upstream === undefined)
153
+ continue;
154
+ if (upstream.aborted)
155
+ controller.abort(upstream.reason);
156
+ else
157
+ upstream.addEventListener('abort', onAbort);
158
+ }
159
+ return {
160
+ signal: controller.signal,
161
+ cleanup: () => {
162
+ for (const upstream of upstreams)
163
+ upstream?.removeEventListener('abort', onAbort);
164
+ },
165
+ };
166
+ }
167
+ /**
168
+ * Race one promise against a signal (same shape as the runtime's per-chunk
169
+ * `raceIteratorNext`): settle with the promise's result, or `'aborted'` when
170
+ * the signal aborts first. Needed because `LlmRuntime.resolveModelInfo` only
171
+ * THREADS the signal into the adapter — an adapter whose lookup ignores
172
+ * cancellation would otherwise wedge the validation past the deadline; the
173
+ * race makes the 60 s bound (and the owner-teardown/command aborts) real for
174
+ * the caller regardless of adapter behavior.
175
+ */
176
+ function raceSignal(promise, signal) {
177
+ if (signal.aborted)
178
+ return Promise.resolve('aborted');
179
+ return new Promise((resolve, reject) => {
180
+ const onAbort = () => resolve('aborted');
181
+ signal.addEventListener('abort', onAbort, { once: true });
182
+ promise.then((value) => {
183
+ signal.removeEventListener('abort', onAbort);
184
+ resolve(value);
185
+ }, (error) => {
186
+ signal.removeEventListener('abort', onAbort);
187
+ reject(error);
188
+ });
189
+ });
190
+ }
84
191
  export function apply(ctx, config) {
85
192
  // T2: the explicit provider/model gate — no model call without both
86
193
  // (spec §5.2). Unknown keys / malformed config throw here, rejecting the
87
194
  // plugin row at load; the gate resolves to disabled-with-reason instead.
88
195
  //
89
- // T1-settings (plan dsh-advisor-settings-n2): the plugin-row config is the
90
- // composition BASE of the `advisor` settings namespace. The runtime reads
91
- // the LIVE composed value through the bridge source (schema defaults →
92
- // base → settings user layer); with no settings service the source is
93
- // exactly `config` — behavior identical to today. The hard gate is applied
196
+ // T1-settings (plan dsh-advisor-settings-n2): the plugin-row entry config's
197
+ // six live fields are schema-volatile (0.1.7-rc.1). The runtime reads the
198
+ // LIVE values through the bridge source (per-field volatile reference
199
+ // unwrap); the Loader commits edits into those references without a remount
200
+ // and announces them via `loader/volatile-update`. The hard gate is applied
94
201
  // to every read: `resolveAdvisorConfig` stays the SSOT for the
95
202
  // disabled-with-reason resolution.
96
203
  const bridge = installAdvisorSettings(ctx, config);
@@ -112,8 +219,16 @@ export function apply(ctx, config) {
112
219
  // registrations are fiber effects (unregistered when this fiber disposes),
113
220
  // so a re-apply/re-mount can take over. The instance needs no handle here:
114
221
  // the typertGateway dispatches through `ctx.get('advisor')`.
222
+ //
223
+ // B2 (issue #88 web session control): the session endpoints route through
224
+ // the SAME controller the `/advisor model` commands drive — the face below
225
+ // is assigned by the elected (reviewer-claiming) fiber AFTER its controller
226
+ // exists; the gateway resolves it lazily per request, so a gateway on a
227
+ // fiber that lost the service-key race (or that never claimed the reviewer
228
+ // role) answers `advisor/unavailable` in data and stays inert.
229
+ let sessionControl;
115
230
  try {
116
- new AdvisorConfigGateway(ctx, bridge);
231
+ new AdvisorConfigGateway(ctx, bridge, () => sessionControl);
117
232
  }
118
233
  catch (error) {
119
234
  if (!(error instanceof Error) || !error.message.includes('has been registered'))
@@ -139,18 +254,18 @@ export function apply(ctx, config) {
139
254
  });
140
255
  const sourceConfig = () => bridge.source();
141
256
  const resolved = () => resolveAdvisorConfig(sourceConfig());
142
- // qc2 W-1 containment: a settings user layer the resolver rejects (e.g. an
143
- // unknown key survives the non-strict settings schema) must never wedge the
144
- // live hot path — every read that can run inside an event handler goes
145
- // through the safe wrappers, which catch resolver throws and return
146
- // disabled-with-reason carrying the message, so gate semantics hold (no
147
- // model call can start) and handlers stay functional. The LOAD-TIME
257
+ // qc2 W-1 containment: an entry config the resolver rejects (e.g. an
258
+ // unknown key that survived the non-strict schemastery object merge) must
259
+ // never wedge the live hot path — every read that can run inside an event
260
+ // handler goes through the safe wrappers, which catch resolver throws and
261
+ // return disabled-with-reason carrying the message, so gate semantics hold
262
+ // (no model call can start) and handlers stay functional. The LOAD-TIME
148
263
  // plugin-row throw contract is unchanged: construction-time reads below
149
264
  // (delivery/observer latches) still use the throwing `resolved()`, so a bad
150
265
  // entry rejects the plugin row at load (config.test.ts ⑤).
151
266
  const safeFallback = (reason) => {
152
267
  // S1 (gateway readConfig parity): when the raw source is still readable,
153
- // seed the scalar latches from it — an invalid user layer only drops the
268
+ // seed the scalar latches from it — an invalid config only drops the
154
269
  // offending keys, so /advisor config (and /advisor status) never
155
270
  // misreport immuneTurns / maxDeltaMessages / systemPrompt vs the web
156
271
  // card's /api/advisor/get readback.
@@ -177,9 +292,23 @@ export function apply(ctx, config) {
177
292
  return safeFallback(error instanceof Error ? error.message : String(error));
178
293
  }
179
294
  };
295
+ // Spec §5.3 — the ONE effective resolver: (1) the global shape is validated
296
+ // first (a malformed entry still throws → safeFallback: fail closed for
297
+ // every session); (2) enable = session enable override ?? global; (3) route
298
+ // = the session's COMPLETE modelOverride pair ?? the composed global pair —
299
+ // the pair is atomic, so the levels are never merged; (4) the explicit pair
300
+ // gate (§5.2) applies AFTER this resolution on the effective values: a
301
+ // complete session pair satisfies a pairless global default, while an
302
+ // invalid global schema can never be bypassed.
180
303
  const safeEffective = (sessionId) => {
181
304
  try {
182
- return resolveAdvisorConfig({ ...sourceConfig(), enabled: effectiveEnabled(sessionId) });
305
+ const source = sourceConfig();
306
+ const pair = overrides.model(sessionId);
307
+ return resolveAdvisorConfig({
308
+ ...source,
309
+ enabled: effectiveEnabled(sessionId),
310
+ ...(pair === undefined ? {} : { provider: pair.provider, model: pair.model }),
311
+ });
183
312
  }
184
313
  catch (error) {
185
314
  return safeFallback(error instanceof Error ? error.message : String(error));
@@ -194,17 +323,30 @@ export function apply(ctx, config) {
194
323
  // commands start/stop per-session runtimes WITHOUT touching the persisted
195
324
  // config (spec §4 mapping — omp `/advisor` semantics). Ephemeral: entries
196
325
  // are cleared on `agent/disposed` / `session/disposed` below. Seeded with
197
- // the RAW config switch (not the post-gate `resolved.enabled`): a config-
198
- // enabled-but-gate-blocked session (enabled without provider/model) then
199
- // re-derives the disabled-with-reason through the resolver, so `/advisor
200
- // status` shows the reason (spec §5.2; qc3 I-1) — the gate itself still
201
- // blocks every runtime (the resolver is the SSOT for the gate).
202
- const overrides = new AdvisorSessionOverrides(config.enabled);
326
+ // the LIVE config switch read through the bridge (the raw `config` fields
327
+ // are volatile references on a Loader composition — an unwrapped snapshot
328
+ // shares the source with `safeResolved`): a config-enabled-but-gate-blocked
329
+ // session (enabled without provider/model) then re-derives the
330
+ // disabled-with-reason through the resolver, so `/advisor status` shows the
331
+ // reason (spec §5.2; qc3 I-1) — the gate itself still blocks every runtime
332
+ // (the resolver is the SSOT for the gate).
333
+ const overrides = new AdvisorSessionOverrides(bridge.source().enabled);
203
334
  const effectiveEnabled = (sessionId) => overrides.effective(sessionId);
204
335
  // Live-path alias: every consumer reads the effective config through the
205
336
  // safe wrapper (qc2 W-1 — a throwing resolver must not break the
206
337
  // session/event handler or `/advisor status`).
207
338
  const effectiveConfig = (sessionId) => safeEffective(sessionId);
339
+ // n4 root-cause (host-observed NO_ADAPTER): this plugin's ctx may live in an
340
+ // isolated scope whose local llm service lacks the provider adapters (adapter
341
+ // registrations live on the application root's LlmRuntime). The APPLICATION
342
+ // ROOT's llm serves BOTH the advisor model calls (ensureRuntime) and the
343
+ // `/advisor model set` validation lookups (spec §5.3).
344
+ const appRootLlm = () => ctx.root?.get?.('llm') ?? ctx.llm;
345
+ // Spec §5.3 — model-validation teardown signal: fused into every in-flight
346
+ // `/advisor model set` lookup, so owner teardown aborts them promptly; the
347
+ // generation wipe (disposeAll, below) makes any completion that still
348
+ // settles unable to commit.
349
+ const validationTeardown = new AbortController();
208
350
  // T3+T4: per-session transcript observation wired into one advisor runtime
209
351
  // per session. On each stepped reviewable turn/end a bounded markdown delta
210
352
  // is rendered and queued on the session's runtime; the runtime drains it
@@ -232,7 +374,9 @@ export function apply(ctx, config) {
232
374
  * creation and compared on every settings change (qc3 W-1 / qc1 W-2): only
233
375
  * a signature change tears the runtime down — an immuneTurns/
234
376
  * maxDeltaMessages-only edit updates the latches in place and keeps every
235
- * in-flight call and backlog.
377
+ * in-flight call and backlog. The triple reads the EFFECTIVE config
378
+ * (spec §5.3), so a session-pinned pair freezes the signature against later
379
+ * global pair edits — global edits reach inheritors, never pinned sessions.
236
380
  */
237
381
  const runtimeSignatures = new Map();
238
382
  const runtimeSignature = (sessionId) => {
@@ -267,12 +411,7 @@ export function apply(ctx, config) {
267
411
  provider: effective.provider,
268
412
  model: effective.model,
269
413
  systemPrompt: safeResolved().systemPrompt || DEFAULT_ADVISOR_SYSTEM_PROMPT,
270
- // n4 root-cause (host-observed NO_ADAPTER): this plugin's ctx may live in
271
- // an isolated scope whose local llm service lacks the provider adapters
272
- // (adapter registrations live on the application root's LlmRuntime). Resolve
273
- // the llm service from the APPLICATION ROOT so the advisor's model calls
274
- // reach the registered deepseek-official adapter.
275
- llm: ctx.root?.get?.('llm') ?? ctx.llm,
414
+ llm: appRootLlm(),
276
415
  onNote: (note) => {
277
416
  // Accepted notes only — the runtime's emission guard (T5) already
278
417
  // filtered suppressed ones. T6 routes the accepted note to the primary
@@ -298,14 +437,36 @@ export function apply(ctx, config) {
298
437
  runtimeSignatures.delete(sessionId);
299
438
  runtime.dispose();
300
439
  };
440
+ /**
441
+ * Apply a committed effective-route change (spec §5.3): abort the session's
442
+ * old advisor call + drop its backlog (disposeRuntime), re-seed the observer
443
+ * cursor to the current transcript length, and recreate the runtime — the
444
+ * change takes effect at the next new reviewable delta (no replay of the
445
+ * current window). A disabled session keeps its (new) pin for a later
446
+ * `/advisor on`; an unchanged effective route restarts nothing.
447
+ * @param before - the effective config captured BEFORE the override state
448
+ * changed (the caller mutates `overrides` between the capture and here).
449
+ */
450
+ const applyRouteChange = (sessionId, before, sessionLength) => {
451
+ if (!effectiveEnabled(sessionId))
452
+ return;
453
+ const after = effectiveConfig(sessionId);
454
+ if (after.provider === before.provider && after.model === before.model)
455
+ return;
456
+ if (sessionLength !== undefined)
457
+ observer.seedTo(sessionId, sessionLength);
458
+ disposeRuntime(sessionId);
459
+ ensureRuntime(sessionId);
460
+ };
301
461
  // n4 QC F-6: claim the single-reviewer role HERE — after every
302
462
  // construction-time throwing read (the delivery latch above resolves the
303
463
  // config and can throw on a rejected row), so a first fiber whose config
304
464
  // fails the gate never leaves the flag claimed (qc1 W-4). Non-reviewer
305
465
  // instances stop here — observer/runtime/delivery and the /advisor commands
306
- // are wired only by the single claimed reviewer. The settings registration
307
- // (installAdvisorSettings above) already ran deduped (qc1 W-5): the first
308
- // registration owns the live namespace on every composition.
466
+ // are wired only by the single claimed reviewer. Their own settings bridge
467
+ // (installAdvisorSettings above) stays live: it is per-fiber by
468
+ // construction, nothing to dedupe (qc1 W-5's registration is gone with the
469
+ // 0.1.7-rc.1 namespace model).
309
470
  const reviewer = claimReviewer();
310
471
  if (!reviewer) {
311
472
  ctx.logger('advisor').debug('non-reviewer instance — observer/runtime/commands skipped (single-reviewer guard)');
@@ -314,9 +475,14 @@ export function apply(ctx, config) {
314
475
  // qc1 W-4: release the claim when THIS (reviewer) fiber is disposed, so a
315
476
  // later re-apply/re-mount (plugin-row removal, composition reload, host hot
316
477
  // reload) can take over instead of leaving the advisor silently inert for
317
- // the process lifetime. Registered only on the claiming fiber.
478
+ // the process lifetime. Registered only on the claiming fiber. The teardown
479
+ // also owns the override state (spec §5.3): abort in-flight model
480
+ // validations and wipe every per-session override entry/generation — a
481
+ // delayed validation completion can never commit after the owner is gone.
318
482
  ctx.effect(() => () => {
319
483
  delete globalThis[REVIEWER_KEY];
484
+ validationTeardown.abort('advisor owner disposed');
485
+ overrides.disposeAll();
320
486
  }, 'advisor: release reviewer claim');
321
487
  const observer = new SessionTranscriptObserver({
322
488
  maxDeltaMessages: resolved().maxDeltaMessages,
@@ -372,9 +538,26 @@ export function apply(ctx, config) {
372
538
  // creates the runtime eagerly (plan T4) when the session is enabled and
373
539
  // registers the agent in the KD-4 delivery map (T6); the observer fallback
374
540
  // covers pre-existing agents (KD-4-style robustness).
541
+ //
542
+ // 0.1.6-alpha.2 seam: `agent/created` became a SERIAL event — its listeners
543
+ // run in order and are AWAITED before creation resolves, and a throw or
544
+ // rejection FAILS creation and skips later listeners (0.1.5-rc.2 dispatched
545
+ // it fire-and-forget). Two obligations follow for this handler:
546
+ // - return `undefined`: the listener contract is
547
+ // `undefined | Promise<undefined>`, so a bare `void` body no longer
548
+ // typechecks against the host Events table;
549
+ // - never throw: an advisory-only plugin must not be able to fail the
550
+ // primary agent's creation, so setup failures are contained and logged
551
+ // here rather than crossing the event boundary.
375
552
  ctx.on('agent/created', ({ agent }) => {
376
- ensureRuntime(agent.id);
377
- delivery.registerAgent(agent);
553
+ try {
554
+ ensureRuntime(agent.id);
555
+ delivery.registerAgent(agent);
556
+ }
557
+ catch (error) {
558
+ ctx.logger('advisor').warn('advisor: agent/created setup failed — contained', { error, sessionId: agent.id });
559
+ }
560
+ return undefined;
378
561
  }, { global: true });
379
562
  ctx.on('agent/disposed', ({ agent }) => {
380
563
  observer.disposeSession(agent.id);
@@ -391,17 +574,18 @@ export function apply(ctx, config) {
391
574
  // T1-settings live re-apply: construction-time latches (immuneTurns on the
392
575
  // delivery, maxDeltaMessages on the observer, systemPrompt + provider/model
393
576
  // on each per-session runtime) are re-derived from the NEW source on every
394
- // committed settings change and re-applied — delivery/observer update in
395
- // place, per-session runtimes rebuild only when their runtime-affecting
396
- // signature actually changed (qc3 W-1 / qc1 W-2: an immuneTurns/
397
- // maxDeltaMessages-only edit must not abort in-flight advisor calls or drop
398
- // backlogs). The S4 gate is re-applied by the resolver on every read, so a
399
- // settings edit can never start a gated model call (SSOT unchanged); the
400
- // config-level fallback switch follows the live source so new sessions pick
401
- // up a Settings-page `enabled` edit immediately. A settings user layer the
402
- // resolver rejects (qc2 W-1 — unknown key) stops the advisor without
403
- // wedging the re-apply path, and the last-good latches stay until the
404
- // config is repaired.
577
+ // committed volatile edit — the Loader's `loader/volatile-update` event,
578
+ // dispatched to this fiber only after the committed values are readable —
579
+ // and re-applied: delivery/observer update in place, per-session runtimes
580
+ // rebuild only when their runtime-affecting signature actually changed
581
+ // (qc3 W-1 / qc1 W-2: an immuneTurns/maxDeltaMessages-only edit must not
582
+ // abort in-flight advisor calls or drop backlogs). The S4 gate is
583
+ // re-applied by the resolver on every read, so a config edit can never
584
+ // start a gated model call (SSOT unchanged); the config-level fallback
585
+ // switch follows the live source so new sessions pick up an enabled edit
586
+ // immediately. An entry config the resolver rejects (qc2 W-1 — unknown
587
+ // key) stops the advisor without wedging the re-apply path, and the
588
+ // last-good latches stay until the config is repaired.
405
589
  bridge.onChange(() => {
406
590
  let next;
407
591
  try {
@@ -427,8 +611,16 @@ export function apply(ctx, config) {
427
611
  delivery.setImmuneTurns(next.immuneTurns);
428
612
  observer.setMaxDeltaMessages(next.maxDeltaMessages);
429
613
  overrides.setConfigEnabled(sourceConfig().enabled);
614
+ // Candidates = live runtimes ∪ sessions holding an override. The second
615
+ // set covers an `/advisor on`-enabled session whose runtime is ABSENT
616
+ // because it was gate-blocked: when the defaults become usable (a global
617
+ // pair is committed) it must gain its runtime here, not wait for its next
618
+ // delta (plan: include enabled-gate-blocked sessions with no runtime when
619
+ // defaults become usable). The signature compare still gates actual
620
+ // rebuilds; ensureRuntime re-applies the post-gate gate on every call.
621
+ const candidates = new Set([...runtimes.keys(), ...overrides.overrideSessionIds()]);
430
622
  let rebuilt = 0;
431
- for (const sessionId of [...runtimes.keys()]) {
623
+ for (const sessionId of candidates) {
432
624
  if (runtimeSignatures.get(sessionId) === runtimeSignature(sessionId))
433
625
  continue;
434
626
  disposeRuntime(sessionId);
@@ -449,7 +641,34 @@ export function apply(ctx, config) {
449
641
  // no full-history replay) and creates/resumes/recoveries the session runtime;
450
642
  // `/advisor off` disposes it (abort in-flight, drop backlog). The S4 gate
451
643
  // reason is re-derived through the config resolver, the SSOT for the
452
- // disabled-with-reason text (spec §5.2).
644
+ // disabled-with-reason text (spec §5.2). `/advisor model set|reset` mutate
645
+ // the runtime-only session pair (spec §5.3) through the generation-fenced
646
+ // validation + applyRouteChange seams below.
647
+ const sessionStatus = (sessionId) => {
648
+ const runtime = runtimes.get(sessionId);
649
+ const effective = effectiveConfig(sessionId);
650
+ const pair = overrides.model(sessionId);
651
+ // Spec §5.3: the status reports the EFFECTIVE route, labeled by source.
652
+ // The label is `session` only when the effective pair IS the session's
653
+ // pin (a malformed global fails closed through safeFallback — provider/
654
+ // model undefined — and a pinned session under it reports no pair, with
655
+ // the reason carried by disabledReason).
656
+ const source = effective.provider === undefined || effective.model === undefined
657
+ ? undefined
658
+ : pair !== undefined && pair.provider === effective.provider && pair.model === effective.model
659
+ ? 'session'
660
+ : 'global';
661
+ return {
662
+ enabled: effective.enabled,
663
+ ...(effective.disabledReason === undefined ? {} : { disabledReason: effective.disabledReason }),
664
+ provider: effective.provider,
665
+ model: effective.model,
666
+ ...(source === undefined ? {} : { modelSource: source }),
667
+ runtimeStatus: runtime?.status() ?? 'disabled',
668
+ pendingCount: runtime?.pendingCount ?? 0,
669
+ lastActivityAt: runtime?.lastActivity,
670
+ };
671
+ };
453
672
  const controller = {
454
673
  setEnabled(sessionId, enabled, sessionLength) {
455
674
  // Recovery, not just a switch flip: `/advisor on` (and toggle-to-on)
@@ -486,29 +705,121 @@ export function apply(ctx, config) {
486
705
  }
487
706
  },
488
707
  getStatus(sessionId) {
489
- const runtime = runtimes.get(sessionId);
708
+ return sessionStatus(sessionId);
709
+ },
710
+ // Spec §5.3: the effective route for `/advisor model` — the resolver SSOT
711
+ // (effectiveConfig) supplies the pair, the session pin supplies the label.
712
+ getModelRoute(sessionId) {
490
713
  const effective = effectiveConfig(sessionId);
491
- return {
492
- enabled: effective.enabled,
493
- ...(effective.disabledReason === undefined ? {} : { disabledReason: effective.disabledReason }),
494
- provider: safeResolved().provider,
495
- model: safeResolved().model,
496
- runtimeStatus: runtime?.status() ?? 'disabled',
497
- pendingCount: runtime?.pendingCount ?? 0,
498
- lastActivityAt: runtime?.lastActivity,
499
- };
714
+ const pair = overrides.model(sessionId);
715
+ const source = pair !== undefined && pair.provider === effective.provider && pair.model === effective.model
716
+ ? 'session'
717
+ : 'global';
718
+ return { provider: effective.provider, model: effective.model, source };
719
+ },
720
+ // Spec §5.3 — validate, then commit, an atomic session pair. Fence FIRST
721
+ // (bump the generation), so a newer set/reset — or any state wipe — makes
722
+ // this attempt stale. The lookup rides the app-root LLM service, bound to
723
+ // the 60 s deadline fused with the invoking command's signal and the owner
724
+ // teardown signal, with NO automatic retry; failure leaves the previous
725
+ // selection untouched. Catalog membership is advisory — a successful
726
+ // resolution is the acceptance gate. Selection never starts a generation
727
+ // call and never touches the enable override.
728
+ async setModel(sessionId, provider, model, agent, signal) {
729
+ const generation = overrides.beginModelGeneration(sessionId);
730
+ const fused = fuseSignals(signal, validationTeardown.signal);
731
+ try {
732
+ const env_1 = { stack: [], error: void 0, hasError: false };
733
+ try {
734
+ const deadlineHandle = __addDisposableResource(env_1, deadline(fused.signal, ADVISOR_MODEL_VALIDATION_TIMEOUT_MS, ADVISOR_MODEL_VALIDATION_TIMEOUT)
735
+ // Race the lookup against the fused signal: a hung adapter lookup that
736
+ // ignores cancellation settles here as 'aborted' at the deadline, so
737
+ // the 60 s bound holds regardless of adapter behavior. There is NO
738
+ // automatic retry — one lookup, one outcome.
739
+ , false);
740
+ // Race the lookup against the fused signal: a hung adapter lookup that
741
+ // ignores cancellation settles here as 'aborted' at the deadline, so
742
+ // the 60 s bound holds regardless of adapter behavior. There is NO
743
+ // automatic retry — one lookup, one outcome.
744
+ const resolvedInfo = await raceSignal(appRootLlm().resolveModelInfo(provider, model, deadlineHandle.signal), deadlineHandle.signal);
745
+ if (resolvedInfo === 'aborted') {
746
+ // Deadline fired → the timed-out failure; otherwise the invoking
747
+ // command was cancelled or the owner/session went away.
748
+ if (timeoutOf(deadlineHandle.signal, ADVISOR_MODEL_VALIDATION_TIMEOUT) !== undefined) {
749
+ return {
750
+ kind: 'failed',
751
+ reason: `model validation timed out after ${ADVISOR_MODEL_VALIDATION_TIMEOUT_MS}ms`,
752
+ };
753
+ }
754
+ if (signal?.aborted === true)
755
+ return { kind: 'cancelled' };
756
+ return { kind: 'gone' };
757
+ }
758
+ }
759
+ catch (e_1) {
760
+ env_1.error = e_1;
761
+ env_1.hasError = true;
762
+ }
763
+ finally {
764
+ __disposeResources(env_1);
765
+ }
766
+ }
767
+ catch (error) {
768
+ // A rejecting lookup (unknown route, adapter throw) — classify the
769
+ // same abort vocabulary, else report the failure verbatim.
770
+ if (signal?.aborted === true)
771
+ return { kind: 'cancelled' };
772
+ if (validationTeardown.signal.aborted)
773
+ return { kind: 'gone' };
774
+ return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) };
775
+ }
776
+ finally {
777
+ fused.cleanup();
778
+ }
779
+ // Fence checks AFTER the lookup settled — session/owner liveness first
780
+ // (a dispose/reload cannot be undone by a delayed validation completion),
781
+ // then the generation: a newer set/reset supersedes unresolved older
782
+ // work while the session is still live.
783
+ if (validationTeardown.signal.aborted || ctx.agents.get(sessionId) === undefined) {
784
+ return { kind: 'gone' };
785
+ }
786
+ if (overrides.modelGeneration(sessionId) !== generation)
787
+ return { kind: 'superseded' };
788
+ // Commit: the route-change effect fires only when the effective pair
789
+ // actually changed (an equal-default pin is recorded, not restarted).
790
+ const before = effectiveConfig(sessionId);
791
+ overrides.setModel(sessionId, { provider, model });
792
+ applyRouteChange(sessionId, before, agent.session.seq);
793
+ const status = sessionStatus(sessionId);
794
+ return before.provider === status.provider && before.model === status.model
795
+ ? { kind: 'unchanged', status }
796
+ : { kind: 'committed', status };
797
+ },
798
+ // Spec §5.3 — drop the pin and re-inherit the CURRENT global defaults.
799
+ // Synchronous (no lookup needed). The generation bumps FIRST — a reset is
800
+ // a newer model command even when there is no pin to remove (noop), so it
801
+ // supersedes any in-flight validation. Reset never touches the enable
802
+ // override, and reset to a missing global pair "succeeds" while the
803
+ // post-reset status reports the gate-blocked/no-call state.
804
+ resetModel(sessionId, sessionLength) {
805
+ overrides.beginModelGeneration(sessionId);
806
+ const before = effectiveConfig(sessionId);
807
+ if (!overrides.clearModel(sessionId))
808
+ return { kind: 'noop' };
809
+ applyRouteChange(sessionId, before, sessionLength);
810
+ return { kind: 'reset', status: sessionStatus(sessionId) };
500
811
  },
501
812
  // T2 (plan dsh-advisor-tui-client-n8): the composed-config readback.
502
- // Session-less BY DESIGN — reads `safeResolved()` (schema defaults →
503
- // plugin-row base → settings user layer, with the hard gate applied),
504
- // the SAME bridge source the web card reads through `resolveAdvisorConfig`
505
- // (`/api/advisor/get` has no session either). NEVER `effectiveConfig`/
506
- // `safeEffective` here: those bake the per-session `/advisor` override
507
- // into `enabled`, and a `/advisor off` session toggle must never make
508
- // the settings readback misreport settings.yaml. Runtime state stays
509
- // owned by `status`; this read reports config only. Every field comes
510
- // from that one resolved value; the systemPrompt summary is the first
511
- // line (≤ 80 chars) of `resolved.systemPrompt`, '' → unset.
813
+ // Session-less BY DESIGN — reads `safeResolved()` (the live entry config
814
+ // with the hard gate applied), the SAME bridge source the web card reads
815
+ // through `resolveAdvisorConfig` (`/api/advisor/get` has no session
816
+ // either). NEVER `effectiveConfig`/`safeEffective` here: those bake the
817
+ // per-session `/advisor` override into `enabled`, and a `/advisor off`
818
+ // session toggle must never make the config readback misreport the
819
+ // persisted config. Runtime state stays owned by `status`; this read
820
+ // reports config only. Every field comes from that one resolved value;
821
+ // the systemPrompt summary is the first line (≤ 80 chars) of
822
+ // `resolved.systemPrompt`, '' → unset.
512
823
  getConfig() {
513
824
  const resolved = safeResolved();
514
825
  return {
@@ -541,6 +852,96 @@ export function apply(ctx, config) {
541
852
  };
542
853
  },
543
854
  };
855
+ // B2 (issue #88 web session control): the elected owner's session face —
856
+ // the web surface of the SAME `controller` above (`/api/advisor/getSession`
857
+ // + `/api/advisor/setSessionModel`; selection null = reset). Snapshot reads
858
+ // ride `sessionStatus` (the resolver SSOT); writes delegate to
859
+ // `controller.setModel` / `controller.resetModel`, so pre-commit
860
+ // `resolveModelInfo` validation, per-session generation fencing, and the
861
+ // route-change semantics are inherited from the B1 command face, never
862
+ // reimplemented. Liveness is checked BEFORE any controller call (a dead
863
+ // target must not allocate a generation — `setModel` bumps one on entry),
864
+ // using the SAME seam the controller's own post-lookup fence uses
865
+ // (`ctx.agents.get`), so a disposed/unknown session is rejected without
866
+ // allocating state. SessionId is NOT authorization: the request still
867
+ // crossed the Connection Host/Origin + browser-auth boundary upstream;
868
+ // this face adds no ACL layer. Business outcomes ride the returned data
869
+ // (plugin-domain error tags) — the dsh failure vocabulary stays frozen.
870
+ const sessionLiveError = (sessionId) => ctx.agents.get(sessionId) === undefined
871
+ ? advisorSessionError('advisor/session-unknown', `advisor: session ${sessionId} is not live`)
872
+ : undefined;
873
+ const snapshotOfStatus = (sessionId, status) => {
874
+ const effective = status.provider !== undefined && status.model !== undefined
875
+ ? { provider: status.provider, model: status.model }
876
+ : undefined;
877
+ // The pin, when present, always IS the effective route (atomic override),
878
+ // so `modelSource === 'session'` marks a pinned `modelOverride`. A pin
879
+ // equal to the global default still reports `session` — the explicit pin
880
+ // is protected from later default edits.
881
+ const pinned = status.modelSource === 'session' ? effective : undefined;
882
+ return {
883
+ snapshot: {
884
+ sessionId,
885
+ enabled: status.enabled,
886
+ lifetime: 'live-session',
887
+ ...(pinned === undefined ? {} : { modelOverride: pinned }),
888
+ ...(status.modelSource === undefined ? {} : { modelSource: status.modelSource }),
889
+ ...(effective === undefined ? {} : { effectiveModel: effective }),
890
+ ...(status.disabledReason === undefined ? {} : { disabledReason: status.disabledReason }),
891
+ },
892
+ };
893
+ };
894
+ const mapSetOutcome = (sessionId, outcome) => {
895
+ switch (outcome.kind) {
896
+ case 'committed':
897
+ case 'unchanged':
898
+ return snapshotOfStatus(sessionId, outcome.status);
899
+ case 'rejected':
900
+ return advisorSessionError('advisor/rejected', `advisor: ${outcome.reason}`);
901
+ case 'failed':
902
+ return advisorSessionError('advisor/failed', `advisor: model validation failed — previous selection untouched: ${outcome.reason}`);
903
+ case 'cancelled':
904
+ return advisorSessionError('advisor/cancelled', 'advisor: the model set was cancelled — previous selection untouched');
905
+ case 'superseded':
906
+ return advisorSessionError('advisor/superseded', 'advisor: superseded by a newer model command — previous selection untouched');
907
+ case 'gone':
908
+ return advisorSessionError('advisor/session-unknown', 'advisor: this session is no longer live — selection untouched');
909
+ }
910
+ };
911
+ const mapResetOutcome = (sessionId, outcome) => outcome.kind === 'reset' ? snapshotOfStatus(sessionId, outcome.status) : snapshotOfStatus(sessionId, sessionStatus(sessionId));
912
+ const sessionControlFace = {
913
+ getSession(sessionId) {
914
+ const unknown = sessionLiveError(sessionId);
915
+ if (unknown !== undefined)
916
+ return unknown;
917
+ return snapshotOfStatus(sessionId, sessionStatus(sessionId));
918
+ },
919
+ async setSessionModel(sessionId, provider, model) {
920
+ const unknown = sessionLiveError(sessionId);
921
+ if (unknown !== undefined)
922
+ return unknown;
923
+ const agent = ctx.agents.get(sessionId);
924
+ if (agent === undefined)
925
+ return sessionLiveError(sessionId);
926
+ // No per-request signal: the web RPC has no cancellation wire — the
927
+ // 60 s deadline and the owner-teardown signal (fused inside setModel)
928
+ // bound every lookup exactly like the command face's no-signal path.
929
+ return mapSetOutcome(sessionId, await controller.setModel(sessionId, provider, model, agent));
930
+ },
931
+ resetSessionModel(sessionId) {
932
+ const unknown = sessionLiveError(sessionId);
933
+ if (unknown !== undefined)
934
+ return unknown;
935
+ const agent = ctx.agents.get(sessionId);
936
+ if (agent === undefined)
937
+ return sessionLiveError(sessionId);
938
+ // Reset with no pin is a SUCCESS (`noop`) — the post-reset status
939
+ // reports the inheriting (possibly gate-blocked) state, matching the
940
+ // command face's truthfulness.
941
+ return mapResetOutcome(sessionId, controller.resetModel(sessionId, agent.session.seq));
942
+ },
943
+ };
944
+ sessionControl = sessionControlFace;
544
945
  // T7: the command child activates ONLY when a command registry is composed
545
946
  // (conditional child activation — `commands` must NOT join the top-level
546
947
  // `inject` list, T1 fix). `/advisor` toggle/on/off/status (spec §2 S5).