@bridge4dev/runner 0.50.0 → 0.52.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.
@@ -284,6 +284,12 @@ class CodexSession {
284
284
  sessionId: this.spec.sessionId,
285
285
  });
286
286
  }
287
+ // Before the thread, not after: a model this CLI no longer offers is
288
+ // refused by `thread/start` itself, and the pin is stored — so the same
289
+ // session would fail again on every relaunch (#374).
290
+ await this.dropModelTheCliNoLongerHas();
291
+ if (this.stopped)
292
+ return;
287
293
  const thread = await this.openThread();
288
294
  if (this.stopped)
289
295
  return;
@@ -1915,6 +1921,93 @@ class CodexSession {
1915
1921
  };
1916
1922
  this.emit({ type: 'capabilities', capabilities });
1917
1923
  }
1924
+ /**
1925
+ * A pinned model the installed CLI does not have is dropped before it can
1926
+ * fail the session (#374).
1927
+ *
1928
+ * The pin is chosen from a catalogue that was measured the last time an agent
1929
+ * ran on this machine, and the CLI moves underneath it: codex 0.153.4 gained
1930
+ * `gpt-6-astra` and lost `gpt-5.3-codex-spark`, so a machine that had not run
1931
+ * a session since the update was offering — and storing — a model that no
1932
+ * longer exists. `turn/start` does not warn about that, it refuses, and since
1933
+ * the pin is persisted the refusal repeats on every relaunch.
1934
+ *
1935
+ * Asked of `model/list` rather than recognised in an error message: matching
1936
+ * on text is what the retry rules were explicitly moved away from in 0.44.1,
1937
+ * because the wording is the vendor's to change.
1938
+ *
1939
+ * A probe that itself fails changes nothing. Diagnosing the start must not be
1940
+ * able to prevent it — an unreachable `model/list` on a CLI that would have
1941
+ * accepted the model would otherwise cost the user their choice.
1942
+ */
1943
+ async dropModelTheCliNoLongerHas() {
1944
+ const pinned = this.model;
1945
+ if (!pinned)
1946
+ return;
1947
+ let known;
1948
+ try {
1949
+ known = await this.probeModelIds();
1950
+ }
1951
+ catch (error) {
1952
+ log.warn('codex: could not check the pinned model — leaving it alone', {
1953
+ sessionId: this.spec.sessionId,
1954
+ error: describe(error),
1955
+ });
1956
+ return;
1957
+ }
1958
+ // Only a COMPLETE list can convict a model. An empty answer means this build
1959
+ // does not really implement `model/list`; a truncated one means the answer
1960
+ // continues on a page we did not ask for. Both must read as «no verdict»,
1961
+ // or the check invents an absence and takes the user's choice away.
1962
+ if (!known.complete || known.ids.size === 0)
1963
+ return;
1964
+ if (known.ids.has(pinned))
1965
+ return;
1966
+ delete this.model;
1967
+ // Said out loud, because the session is about to run on a different model
1968
+ // than the one that was picked — silently substituting it would be worse
1969
+ // than the failure this replaces.
1970
+ this.notice('warn', `This server's codex no longer offers ${pinned} — starting on the agent's own default instead.`);
1971
+ // Cleared everywhere, not just here: the API stores the pin and hands it
1972
+ // back in the next relaunch descriptor, so a pin dropped only in memory
1973
+ // comes back on the next restart (the same trap as QA-100 MAJOR-5).
1974
+ //
1975
+ // What the column ends up holding is «nothing pinned» only until the thread
1976
+ // opens: the capabilities frame right after it reports `currentModel`, and
1977
+ // with no pin that is the thread's own model. Either way the invariant the
1978
+ // clearing exists for holds — what is stored is a model this CLI has.
1979
+ this.emit({ type: 'settings', model: null });
1980
+ if (this.effort) {
1981
+ // The level was chosen for a model that is not there. Keeping it would
1982
+ // apply «Ultra» to whatever the CLI defaults to, which may not have it.
1983
+ delete this.effort;
1984
+ this.emit({ type: 'settings', effort: null });
1985
+ }
1986
+ }
1987
+ /**
1988
+ * Every model id this CLI knows, for the pin check only — including the ones
1989
+ * `listModels()` filters out of the PICKER.
1990
+ *
1991
+ * `hidden` means «do not offer this», not «this does not exist»: codex ships
1992
+ * internal entries like `codex-auto-review` that way, and a pin is not an
1993
+ * offer. Judging a stored pin by the picker's list would delete a model the
1994
+ * CLI still accepts.
1995
+ *
1996
+ * `complete` is false when the answer is paged, and a paged answer proves
1997
+ * nothing about what is missing.
1998
+ */
1999
+ async probeModelIds() {
2000
+ const result = asRecord(await this.client.request('model/list', { limit: 50 }, 15_000));
2001
+ const data = Array.isArray(result['data']) ? result['data'] : [];
2002
+ const ids = new Set();
2003
+ for (const entry of data) {
2004
+ const row = asRecord(entry);
2005
+ const id = str(row['id']) ?? str(row['model']);
2006
+ if (id)
2007
+ ids.add(id);
2008
+ }
2009
+ return { ids, complete: !str(result['nextCursor']) };
2010
+ }
1918
2011
  async listModels() {
1919
2012
  const result = asRecord(await this.client.request('model/list', { limit: 50 }, 15_000));
1920
2013
  const data = Array.isArray(result['data']) ? result['data'] : [];
@@ -406,9 +406,16 @@ export type AgentEvent = {
406
406
  } | {
407
407
  type: 'capabilities';
408
408
  capabilities: AgentCapabilities;
409
- } | {
409
+ }
410
+ /**
411
+ * `model: null` — the pin is GONE, not merely unchanged: the installed CLI
412
+ * stopped offering it (#374). Spelled like `effort: null` and read the same
413
+ * way by the API, which clears the stored column instead of handing the dead
414
+ * pin back in the next descriptor.
415
+ */
416
+ | {
410
417
  type: 'settings';
411
- model?: string;
418
+ model?: string | null;
412
419
  mode?: AgentMode;
413
420
  effort?: string | null;
414
421
  } | {
package/dist/index.js CHANGED
@@ -465,6 +465,12 @@ function runnerCapabilities(apiUrlOverride) {
465
465
  ? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
466
466
  : []),
467
467
  ...(agentInstallEnabled ? ['agent_install'] : []),
468
+ // «Перемерить версии агентов сейчас» — единственный путь, которым машину
469
+ // можно СПРОСИТЬ про версии; всё остальное она присылает сама и по своему
470
+ // расписанию. Объявлено отдельно от `agent_install` и БЕЗ его условия:
471
+ // это чтение, а не установка, и машину, чей владелец запретил ставить из
472
+ // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
473
+ 'agent_versions_refresh',
468
474
  ],
469
475
  };
470
476
  }
@@ -244,7 +244,17 @@ export declare class Supervisor {
244
244
  agent: string;
245
245
  from: string | null;
246
246
  to: string;
247
- }): Promise<void>;
247
+ },
248
+ /**
249
+ * Force a fresh probe on a path the reason alone does not describe.
250
+ *
251
+ * «Перемерить сейчас» sends `measured` — it IS a plain measurement — but it
252
+ * is also the one caller that must not be answered out of the hour-long
253
+ * cache, because a person pressed a button precisely to get past it.
254
+ */
255
+ options?: {
256
+ force?: boolean;
257
+ }): Promise<AgentVersionsMeasurement | null>;
248
258
  private queueVersionChange;
249
259
  /**
250
260
  * Р13: the agent of a starting session, moved forward in the background.
@@ -259,14 +259,22 @@ export class Supervisor {
259
259
  * missing audit lines are not the problem worth solving.
260
260
  */
261
261
  static MAX_PENDING_VERSION_CHANGES = 8;
262
- async publishAgentVersions(reason, changed) {
262
+ async publishAgentVersions(reason, changed,
263
+ /**
264
+ * Force a fresh probe on a path the reason alone does not describe.
265
+ *
266
+ * «Перемерить сейчас» sends `measured` — it IS a plain measurement — but it
267
+ * is also the one caller that must not be answered out of the hour-long
268
+ * cache, because a person pressed a button precisely to get past it.
269
+ */
270
+ options) {
263
271
  try {
264
272
  const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
265
273
  // After an install — ours or the auto one — the cached number is a lie,
266
274
  // and a stale `at` with it would be dropped by the API's ordering guard
267
275
  // together with the audit line the install owes. Forcing it here means a
268
276
  // caller cannot forget to invalidate the cache first.
269
- const measurement = await measure({ force: reason !== 'measured' });
277
+ const measurement = await measure({ force: options?.force ?? reason !== 'measured' });
270
278
  // A plain tick carries an earlier install's news rather than replacing it.
271
279
  // The API keys the audit line by the measurement's `at`, so the same
272
280
  // change arriving under a later `at` is one line, not two.
@@ -294,11 +302,15 @@ export class Supervisor {
294
302
  void this.publishAgentVersions('measured');
295
303
  }
296
304
  }
305
+ // Handed back so a caller can answer with EXACTLY what was published,
306
+ // rather than measuring a second time and hoping the two agree.
307
+ return measurement;
297
308
  }
298
309
  catch (error) {
299
310
  if (changed && reason !== 'measured')
300
311
  this.queueVersionChange({ reason, changed });
301
312
  log.warn('supervisor: could not report agent versions', { error: String(error) });
313
+ return null;
302
314
  }
303
315
  }
304
316
  queueVersionChange(entry) {
@@ -2444,7 +2456,13 @@ export class Supervisor {
2444
2456
  case 'settings':
2445
2457
  if (event.mode)
2446
2458
  running.mode = event.mode;
2447
- if (event.model)
2459
+ // null = the agent dropped the pinned MODEL, because the installed CLI
2460
+ // stopped offering it (#374). Dropped from the local record too: this
2461
+ // object is what a relaunch inside the same runner process starts from,
2462
+ // so keeping it would re-pin the model the adapter just refused.
2463
+ if (event.model === null)
2464
+ delete running.model;
2465
+ else if (event.model)
2448
2466
  running.model = event.model;
2449
2467
  // null = the agent dropped the pinned level (model without it) — the
2450
2468
  // API must clear its column, so the value is forwarded as-is.
@@ -2453,7 +2471,7 @@ export class Supervisor {
2453
2471
  else if (event.effort)
2454
2472
  running.effort = event.effort;
2455
2473
  this.sendEvent(running, 'settings', {
2456
- model: event.model,
2474
+ ...(event.model === undefined ? {} : { model: event.model }),
2457
2475
  mode: event.mode,
2458
2476
  ...(event.effort === undefined ? {} : { effort: event.effort }),
2459
2477
  });
@@ -5119,6 +5137,43 @@ export class Supervisor {
5119
5137
  void this.publishAgentVersions('manual', moved);
5120
5138
  return;
5121
5139
  }
5140
+ /**
5141
+ * «Перемерить версии агентов прямо сейчас» — за кнопкой «Check again».
5142
+ *
5143
+ * Единственный путь, которым машину можно СПРОСИТЬ про версии. Всё
5144
+ * остальное присылает она сама и по своему расписанию: кадр уходит на
5145
+ * каждом переподключении (из кэша), раз в час по таймеру, и свежий —
5146
+ * только после установки, которую сделал сам DevBridge.
5147
+ *
5148
+ * Отсюда и дыра, которую эта команда закрывает: человек обновил агента
5149
+ * руками через `claude update` на машине, раннеру об этом никто не
5150
+ * сказал, и карточка держала старое число до часа. «Новая сессия
5151
+ * чинила» его побочным эффектом — автообновление шло ставить, честно
5152
+ * мерило перед установкой и сбрасывало кэш, — а на машине с
5153
+ * ВЫКЛЮЧЕННЫМ автообновлением не происходило и этого.
5154
+ *
5155
+ * Меряем один раз и отдаём измеренное в ответе, а не только кадром:
5156
+ * кадр и ответ идут по одному сокету, но обрабатываются на той стороне
5157
+ * независимо, и запрос, который вернулся раньше, чем доехала запись,
5158
+ * оставил бы на экране ровно то устаревшее число, ради которого кнопку
5159
+ * и нажали. Кадр при этом всё равно уходит — у него свой путь с
5160
+ * аудитом, и повторная запись с тем же `at` отбрасывается сторожем
5161
+ * порядка на стороне API.
5162
+ */
5163
+ case 'agent_versions_refresh': {
5164
+ // Один замер на нажатие, и это структура, а не совпадение: публикация
5165
+ // отдаёт то, что измерила, и ответ собирается из НЕГО. Мерить второй
5166
+ // раз «для ответа» значило бы держаться за кэш, который вызывающий не
5167
+ // видит, — а кэш здесь и есть то, мимо чего нажимают кнопку.
5168
+ const snapshot = await this.publishAgentVersions('measured', undefined, { force: true });
5169
+ if (!snapshot) {
5170
+ return void reply({ ok: false, error: 'Could not measure the agents' });
5171
+ }
5172
+ return void reply({
5173
+ ok: true,
5174
+ result: { at: snapshot.at, reason: 'measured', agents: snapshot.agents },
5175
+ });
5176
+ }
5122
5177
  /**
5123
5178
  * «Would this file reach the agent, and how big is it» (ticket #192).
5124
5179
  *
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.50.0";
1
+ export declare const RUNNER_VERSION = "0.52.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.50.0';
2
+ export const RUNNER_VERSION = '0.52.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",