copilot-operator 0.1.34 → 0.1.36

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.
Files changed (36) hide show
  1. package/dist/src/api/operator.service.js +30 -1
  2. package/dist/src/cli.js +11 -8
  3. package/dist/src/config/schema.js +6 -0
  4. package/dist/src/orchestrator/taskRunner.js +82 -10
  5. package/dist/src/plan/systemGuide.js +40 -0
  6. package/dist/src/transport/copilotTransport.js +107 -18
  7. package/dist/src/transport/locators.js +7 -2
  8. package/dist/src/transport/modelMatch.js +12 -1
  9. package/dist/web/404.html +2 -2
  10. package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +1 -0
  11. package/dist/web/_next/static/chunks/app/defaults/page-879158e9e550eea7.js +1 -0
  12. package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +1 -0
  13. package/dist/web/appearance.html +2 -2
  14. package/dist/web/appearance.txt +10 -10
  15. package/dist/web/defaults.html +2 -2
  16. package/dist/web/defaults.txt +10 -10
  17. package/dist/web/history.html +2 -2
  18. package/dist/web/history.txt +10 -10
  19. package/dist/web/import.html +2 -2
  20. package/dist/web/import.txt +10 -10
  21. package/dist/web/index.html +2 -2
  22. package/dist/web/index.txt +10 -10
  23. package/dist/web/level1.html +2 -2
  24. package/dist/web/level1.txt +10 -10
  25. package/dist/web/presets.html +2 -2
  26. package/dist/web/presets.txt +10 -10
  27. package/dist/web/sessions/view.html +2 -2
  28. package/dist/web/sessions/view.txt +10 -10
  29. package/dist/web/system.html +2 -2
  30. package/dist/web/system.txt +10 -10
  31. package/package.json +2 -1
  32. package/dist/web/_next/static/chunks/867-dfaaa8047cbf2fc2.js +0 -1
  33. package/dist/web/_next/static/chunks/app/defaults/page-54f8f1ab4cac47fa.js +0 -1
  34. package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +0 -1
  35. /package/dist/web/_next/static/{pfa56tOBq6o-WBQZnyUn2 → rWkIIFdTKzD4fL0Pe-y-9}/_buildManifest.js +0 -0
  36. /package/dist/web/_next/static/{pfa56tOBq6o-WBQZnyUn2 → rWkIIFdTKzD4fL0Pe-y-9}/_ssgManifest.js +0 -0
@@ -1102,6 +1102,7 @@ let OperatorService = class OperatorService {
1102
1102
  // The page is the source of the model names: Settings and the saved list follow it (see `ModelHooks`).
1103
1103
  models: {
1104
1104
  locate: async (name) => (await this.store.getModels())?.options.find((o) => sameModel(o.name, name))?.locator,
1105
+ askHelp: (ask) => this.askModelHelp(ask, controller.signal),
1105
1106
  renamedDefault: async (which, from, to) => {
1106
1107
  const now = (await this.settings.load()).copilot;
1107
1108
  if (which === 'model' && (now.defaultModel ?? '').trim() === from)
@@ -1866,6 +1867,34 @@ let OperatorService = class OperatorService {
1866
1867
  };
1867
1868
  }
1868
1869
  // --- approvals ------------------------------------------------------------------------
1870
+ /**
1871
+ * Asks the operator to choose the model in the Copilot window, when the runner could not (operator's
1872
+ * request, 2026-10-06). It waits on the approvals card like a held step, in every mode, until it is
1873
+ * answered or the run is stopped.
1874
+ */
1875
+ askModelHelp(ask, signal) {
1876
+ return new Promise((resolveAnswer) => {
1877
+ if (signal.aborted) {
1878
+ resolveAnswer('stop');
1879
+ return;
1880
+ }
1881
+ const approval = {
1882
+ id: newId('a-'),
1883
+ sessionId: ask.sessionId,
1884
+ taskId: '',
1885
+ stepId: 0,
1886
+ description: ask.asked ? `Choose "${ask.asked}" in the Copilot window` : 'Choose the model in the Copilot window',
1887
+ createdAt: new Date().toISOString(),
1888
+ model: { asked: ask.asked, shown: ask.shown, why: ask.why, tries: ask.tries, ...(ask.manual ? { manual: true } : {}) },
1889
+ };
1890
+ this.waiting.set(approval.id, {
1891
+ approval,
1892
+ resolve: (d) => resolveAnswer(d.action === 'run' ? 'recheck' : d.action === 'skip' ? 'continue' : 'stop'),
1893
+ });
1894
+ this.bus.publish({ sessionId: ask.sessionId, type: 'approval-requested', level: 'warn',
1895
+ message: `choose "${ask.asked}" in the Copilot window, then answer on the card`, data: { approvalId: approval.id, model: approval.model } });
1896
+ });
1897
+ }
1869
1898
  pendingApprovals(sessionId) {
1870
1899
  return [...this.waiting.values()].map((w) => w.approval).filter((a) => !sessionId || a.sessionId === sessionId);
1871
1900
  }
@@ -1952,7 +1981,7 @@ let OperatorService = class OperatorService {
1952
1981
  if (w.approval.sessionId !== sessionId)
1953
1982
  continue;
1954
1983
  // A fetch waits for its own answer: "run the rest without asking" is not an answer to it.
1955
- if (w.approval.network)
1984
+ if (w.approval.network || w.approval.model)
1956
1985
  continue;
1957
1986
  this.waiting.delete(id);
1958
1987
  w.resolve({ action: 'run' });
package/dist/src/cli.js CHANGED
@@ -195,7 +195,7 @@ program
195
195
  replyTimeoutMs: 60_000,
196
196
  signInTimeoutMs: 15 * 60_000,
197
197
  humanWaitMs: 15 * 60_000,
198
- onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
198
+ onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
199
199
  `),
200
200
  });
201
201
  await transport.open();
@@ -383,6 +383,7 @@ program
383
383
  .option('--url <url>', 'chat url', Url.chat)
384
384
  .option('--json', 'print the raw list, for pasting into an issue')
385
385
  .option('--set <name>', 'also switch the chat to this model, by its exact name')
386
+ .option('--steps', 'with --set: print each step of the choice with its time, and keep a picture of each in runs/_models')
386
387
  .action(async (opts) => {
387
388
  const transport = new CopilotTransport({
388
389
  profileDir: opts.profile,
@@ -392,7 +393,9 @@ program
392
393
  headless: false,
393
394
  replyTimeoutMs: 60_000,
394
395
  signInTimeoutMs: 900_000,
395
- onEvent: (e, d) => console.log(` [${e}]${d ? ' ' + JSON.stringify(d) : ''}`),
396
+ // The time of each step, to the millisecond: the pauses between them are what is being looked at.
397
+ onEvent: (e, d) => console.log(` ${new Date().toISOString().slice(11, 23)} [${e}]${d ? ' ' + JSON.stringify(d) : ''}`),
398
+ ...(opts.steps ? { modelStepShots: true } : {}),
396
399
  });
397
400
  try {
398
401
  await transport.open();
@@ -488,14 +491,14 @@ program
488
491
  console.log('');
489
492
  console.log(`task "${done?.title}": ${done?.status} after ${done?.iterations ?? 0} iteration(s)`);
490
493
  if (done?.summary)
491
- console.log(`
492
- Summary:
494
+ console.log(`
495
+ Summary:
493
496
  ${done.summary}`);
494
497
  if (done?.reason)
495
- console.log(`
498
+ console.log(`
496
499
  Reason: ${done.reason}`);
497
500
  if (final?.chat)
498
- console.log(`
501
+ console.log(`
499
502
  chat: ${final.chat.name} — ${final.chat.url}`);
500
503
  if (done?.runId)
501
504
  console.log(`log: ${join(cfg.resolved.runsDir, done.runId, 'task-log.txt')}`);
@@ -515,8 +518,8 @@ program
515
518
  return;
516
519
  }
517
520
  const c = withChat.chat;
518
- console.log(`${c.name}
519
- ${c.url}
521
+ console.log(`${c.name}
522
+ ${c.url}
520
523
  session ${withChat.id}, ${withChat.tasks.length} task(s), created ${c.createdAt}`);
521
524
  });
522
525
  program.parseAsync(process.argv).catch((e) => {
@@ -97,6 +97,12 @@ export const RunConfigSchema = z.object({
97
97
  * different from whatever did the work.
98
98
  */
99
99
  defaultReviewModel: z.string().default(''),
100
+ /**
101
+ * Choose the model by hand (operator's workaround, 2026-10-06): at the start of a run, and of a review's
102
+ * conversation, the runner does not touch the picker; a card in the UI asks the operator to choose the
103
+ * model in the Copilot window and press "Ready". A conversation already on the model is not asked about.
104
+ */
105
+ manualModel: z.boolean().default(false),
100
106
  replyTimeoutSec: z.number().int().positive().default(900),
101
107
  signInTimeoutSec: z.number().int().positive().default(900),
102
108
  humanWaitSec: z.number().int().positive().default(900),
@@ -13,6 +13,7 @@
13
13
  import { createHash } from 'node:crypto';
14
14
  import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
15
15
  import { join, resolve } from 'node:path';
16
+ import { buttonShows } from '../transport/modelMatch.js';
16
17
  import { createTransport, isReplyTimeout } from '../transport/chatTransport.js';
17
18
  import { buildChatName, chatCode, loadPointer, savePointer } from '../transport/chatSession.js';
18
19
  import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
@@ -532,14 +533,44 @@ export function effectiveModels(session, cfg) {
532
533
  async function applySessionModel(transport, session, bus, cfg, store, hooks) {
533
534
  const { model: wanted, modelFrom } = effectiveModels(session, cfg);
534
535
  const fromSettings = modelFrom === 'settings';
535
- if (!wanted)
536
+ const manual = !!cfg.copilot.manualModel && !!hooks?.askHelp;
537
+ if (!wanted && !manual)
536
538
  return {};
537
- const locator = await hooks?.locate?.(wanted).catch(() => undefined);
538
- const result = await transport.selectModel(wanted, { locator }).catch((e) => ({
539
- ok: false,
540
- current: null,
541
- reason: e.message,
542
- }));
539
+ let result;
540
+ if (manual) {
541
+ /*
542
+ * Chosen by hand (operator's workaround, 2026-10-06): the picker is not touched. A conversation already
543
+ * on the model — the chat keeps its model — is not asked about; otherwise the card asks for it.
544
+ */
545
+ const shown = await transport.currentModel().catch(() => null);
546
+ const hand = wanted && buttonShows(shown, wanted)
547
+ ? { result: { ok: true, current: wanted, by: 'already' }, goOnAsItIs: false }
548
+ : await withOperatorHand(transport, wanted, { ok: false, current: shown, reason: 'Settings ask for the model to be chosen by hand' }, hooks, session.id, bus, true);
549
+ if (!wanted) {
550
+ const now = await transport.currentModel().catch(() => null);
551
+ if (hand.result.ok || hand.goOnAsItIs) {
552
+ await store?.updateSession(session.id, (s) => { s.modelInUse = now ?? undefined; }).catch(() => undefined);
553
+ return { current: now ?? undefined };
554
+ }
555
+ return { refused: 'The model was to be chosen by hand, and the operator stopped the run. Nothing was sent.' };
556
+ }
557
+ result = hand.result;
558
+ if (hand.goOnAsItIs) {
559
+ bus.publish({ sessionId: session.id, type: 'model-selected', level: 'warn', message: `model: ${result.current ?? 'unknown'} (chosen by hand; not "${wanted}")`, data: { asked: wanted, current: result.current, ok: false, by: 'hand' } });
560
+ return { current: result.current ?? undefined };
561
+ }
562
+ }
563
+ else {
564
+ const locator = await hooks?.locate?.(wanted).catch(() => undefined);
565
+ result = await transport.selectModel(wanted, { locator }).catch((e) => ({
566
+ ok: false,
567
+ current: null,
568
+ reason: e.message,
569
+ }));
570
+ }
571
+ const hand = await withOperatorHand(transport, wanted, result, hooks, session.id, bus, false);
572
+ result = hand.result;
573
+ const goOnAsItIs = hand.goOnAsItIs;
543
574
  const renamed = await followPageModels('model', wanted, result, session, store, hooks, modelFrom);
544
575
  if (renamed)
545
576
  bus.publish({ sessionId: session.id, type: 'model-renamed', level: 'warn', message: renamed, data: { asked: wanted, chosen: result.matched } });
@@ -550,7 +581,7 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
550
581
  message: result.ok
551
582
  ? `model: ${result.current ?? wanted} (${modelFrom === 'settings' ? 'from Settings' : modelFrom === 'session' ? "the session's own" : 'from the plan'})`
552
583
  : `could not switch to "${wanted}" (${modelFrom === 'settings' ? 'the default model in Settings' : modelFrom === 'session' ? "the session's own" : 'from the plan'}): ${(result.reason ?? 'unknown reason').replace(/[.\s]+$/, '')}. Nothing is sent on ${result.current ?? 'the chat default'} instead.`,
553
- data: { asked: wanted, from: modelFrom, fromSettings, current: result.current, ok: result.ok, by: result.by ?? null, savedLocator: !!locator },
584
+ data: { asked: wanted, from: modelFrom, fromSettings, current: result.current, ok: result.ok, by: result.by ?? null, manual },
554
585
  });
555
586
  // Kept on the session, so each task's record says which model it ran on and why (see `runTask`).
556
587
  await store
@@ -570,6 +601,8 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
570
601
  * A model the operator chose and the chat is not on is not worked around: the run went on Auto with a
571
602
  * warning nobody saw, and its work was then taken for the chosen model's (2026-10-05). Nothing goes out.
572
603
  */
604
+ if (!result.ok && goOnAsItIs)
605
+ return { current: result.current ?? undefined };
573
606
  if (!result.ok) {
574
607
  return {
575
608
  current: result.current ?? undefined,
@@ -579,6 +612,35 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
579
612
  }
580
613
  return { current: result.current ?? undefined };
581
614
  }
615
+ /**
616
+ * The operator's hand, when the runner could not put the chat on the model, or when Settings ask for it to
617
+ * be chosen by hand (operator's requests, 2026-10-06): the run waits on a card in the UI asking them to
618
+ * choose it in the Copilot window; "check and go on" reads the picker button again, and asks again while it
619
+ * still shows another model. With no model asked for, whatever the operator chose is taken.
620
+ */
621
+ async function withOperatorHand(transport, wanted, first, hooks, sessionId, bus, manual) {
622
+ let result = first;
623
+ for (let tries = 1; !result.ok && hooks?.askHelp; tries += 1) {
624
+ const shown = await transport.currentModel().catch(() => null);
625
+ bus.publish({ sessionId, type: 'model-help-asked', level: 'warn',
626
+ message: manual
627
+ ? `asking the operator to choose ${wanted ? `"${wanted}"` : 'the model'} in the Copilot window (Settings: by hand; picker shows "${shown ?? 'nothing'}")`
628
+ : `the chat could not be put on "${wanted}" by the runner; asking the operator to choose it in the Copilot window (picker shows "${shown ?? 'nothing'}")`,
629
+ data: { asked: wanted, shown, why: result.reason ?? '', tries, manual } });
630
+ const answer = await hooks.askHelp({ sessionId, asked: wanted, shown, why: result.reason ?? 'unknown reason', tries, ...(manual ? { manual: true } : {}) });
631
+ if (answer === 'stop')
632
+ return { result, goOnAsItIs: false };
633
+ if (answer === 'continue') {
634
+ bus.publish({ sessionId, type: 'model-help-continue', level: 'warn', message: `the operator chose to go on with the model the chat is on ("${shown ?? 'unknown'}")${wanted ? `, not "${wanted}"` : ''}`, data: { asked: wanted, shown } });
635
+ return { result: { ok: false, current: shown, reason: `the operator chose to go on with "${shown ?? 'unknown'}"` }, goOnAsItIs: true };
636
+ }
637
+ const now = await transport.currentModel().catch(() => null);
638
+ result = !wanted || buttonShows(now, wanted)
639
+ ? { ok: true, current: wanted || now, by: 'hand' }
640
+ : { ok: false, current: now, reason: `the picker still shows "${now ?? 'nothing'}", not "${wanted}"` };
641
+ }
642
+ return { result, goOnAsItIs: false };
643
+ }
582
644
  /** Runs one task to completion inside an already-open transport. */
583
645
  export async function runTask(transport, session, task, deps) {
584
646
  const { cfg, store, bus, authorizer, signal } = deps;
@@ -1756,8 +1818,18 @@ export async function runTask(transport, session, task, deps) {
1756
1818
  try {
1757
1819
  await transport.newChat();
1758
1820
  if (model) {
1759
- const reviewLocator = await deps.models?.locate?.(model).catch(() => undefined);
1760
- const picked = await transport.selectModel(model, { locator: reviewLocator }).catch((e) => ({ ok: false, current: null, reason: e.message }));
1821
+ let picked;
1822
+ if (cfg.copilot.manualModel && deps.models?.askHelp) {
1823
+ // By hand, as the work's model (see `applySessionModel`): the review's fresh conversation is asked about too.
1824
+ const shown = await transport.currentModel().catch(() => null);
1825
+ picked = buttonShows(shown, model)
1826
+ ? { ok: true, current: model, by: 'already' }
1827
+ : (await withOperatorHand(transport, model, { ok: false, current: shown, reason: 'Settings ask for the model to be chosen by hand (the review)' }, deps.models, session.id, bus, true)).result;
1828
+ }
1829
+ else {
1830
+ const reviewLocator = await deps.models?.locate?.(model).catch(() => undefined);
1831
+ picked = await transport.selectModel(model, { locator: reviewLocator }).catch((e) => ({ ok: false, current: null, reason: e.message }));
1832
+ }
1761
1833
  const renamed = await followPageModels('review', model, picked, session, store, deps.models, effectiveModels(session, cfg).reviewModelFrom);
1762
1834
  if (renamed)
1763
1835
  sink.event('model-renamed', { asked: model, chosen: picked.matched, review: true }, renamed, 'warn');
@@ -230,6 +230,38 @@ export const SYSTEM_GUIDE = [
230
230
  doesEn: 'Ends the whole task here, aborted; its remaining steps are not run.',
231
231
  doesBg: 'Прекратява цялата задача тук; останалите ѝ стъпки не се изпълняват.',
232
232
  },
233
+ {
234
+ key: 'approval.modelChosen',
235
+ kind: 'button',
236
+ en: 'I chose it — check and continue',
237
+ bg: 'Избрах го — провери и продължи',
238
+ doesEn: 'On the card that appears when the bot could not choose the model: after you chose it in the Copilot window, the bot reads the picker and goes on, or asks again if it shows another model.',
239
+ doesBg: 'На картата, която се появява, когато ботът не успее да избере модела: след като го изберете в прозореца на Copilot, ботът проверява менюто и продължава, или пита пак, ако то показва друг модел.',
240
+ },
241
+ {
242
+ key: 'approval.manualReady',
243
+ kind: 'button',
244
+ en: 'Ready — check and continue',
245
+ bg: 'Готово — провери и продължи',
246
+ doesEn: 'On the card shown when Settings ask for the model to be chosen by hand: after you chose it in the Copilot window, the bot checks the picker and starts.',
247
+ doesBg: 'На картата при ръчен избор на модела (от Настройки): след като го изберете в прозореца на Copilot, ботът проверява менюто и започва.',
248
+ },
249
+ {
250
+ key: 'approval.modelAsItIs',
251
+ kind: 'button',
252
+ en: 'Continue on the model it is on',
253
+ bg: 'Продължи на текущия модел',
254
+ doesEn: 'The run goes on with whatever model the chat is on, and the record says so.',
255
+ doesBg: 'Изпълнението продължава с модела, на който е чатът, и записът го казва.',
256
+ },
257
+ {
258
+ key: 'approval.modelStop',
259
+ kind: 'button',
260
+ en: 'Stop',
261
+ bg: 'Спри',
262
+ doesEn: 'Nothing is sent: the session stops, its tasks stay queued.',
263
+ doesBg: 'Нищо не се изпраща: сесията спира, задачите ѝ остават на опашката.',
264
+ },
233
265
  ],
234
266
  },
235
267
  ],
@@ -3004,6 +3036,14 @@ export const SYSTEM_GUIDE = [
3004
3036
  doesEn: 'How many different approaches a "blocked" must list (2–20); with fewer the chat is asked for another.',
3005
3037
  doesBg: 'Колко различни подхода трябва да изброи „blocked“ (2–20); с по-малко чатът е помолен за още един.',
3006
3038
  },
3039
+ {
3040
+ key: 'def.manualModel',
3041
+ kind: 'field',
3042
+ en: 'Choose the model by hand at the start of each run',
3043
+ bg: 'Избирай модела ръчно в началото на всяко пускане',
3044
+ doesEn: 'The bot leaves the model picker alone and asks, on a card, for the model to be chosen in the Copilot window; a conversation already on the model is not asked about.',
3045
+ doesBg: 'Ботът не пипа менюто с моделите и моли, с карта, моделът да бъде избран в прозореца на Copilot; разговор, който вече е на модела, не се пита.',
3046
+ },
3007
3047
  {
3008
3048
  key: 'exec.maxReviewRounds',
3009
3049
  kind: 'field',
@@ -940,31 +940,60 @@ Current URL: ${url}`);
940
940
  const textOf = async (el) => (((await el.innerText().catch(() => '')) ?? '').split('\n')[0] ?? '').trim();
941
941
  // A fixed pause after every opening and every press (see `ModelPicking`); the checks give it less.
942
942
  const settle = () => this.p.waitForTimeout(this.opts.modelSettleMs ?? ModelPicking.settleMs);
943
- // And a fixed pause before every press in the picker: the button, the group, the model (2026-10-06).
944
- const beforePress = () => this.p.waitForTimeout(this.opts.modelBeforePressMs ?? ModelPicking.beforePressMs);
943
+ const pause = this.opts.modelBeforePressMs ?? ModelPicking.beforePressMs;
944
+ const poll = this.opts.modelPollMs ?? ModelPicking.pollMs;
945
945
  const appear = this.opts.modelAppearMs ?? ModelPicking.appearMs;
946
- /** Waits until the element is there and visible. */
947
- const present = async (el) => await el.waitFor({ state: 'visible', timeout: appear }).then(() => true).catch(() => false);
946
+ let shot = 0;
947
+ /** With `modelStepShots`, a picture and the menu's HTML at each step, numbered, beside the transport's files. */
948
+ const picture = async (label) => {
949
+ if (!this.opts.modelStepShots)
950
+ return;
951
+ shot += 1;
952
+ await this.captureMenu(`step-${String(shot).padStart(2, '0')}-${label.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, await this.readMenuRows().catch(() => [])).catch(() => undefined);
953
+ };
954
+ /*
955
+ * The operator's rhythm for every element of the picker (2026-10-06): look for it; while it is not
956
+ * there, wait two seconds and look again; once it is there, wait two seconds more, then press it.
957
+ * Each look and each pause is in the record with its time, so the pauses can be seen.
958
+ */
959
+ const waitThenFind = async (label, find) => {
960
+ const deadline = Date.now() + appear;
961
+ let looks = 1;
962
+ let el = await find();
963
+ while (!el && Date.now() < deadline) {
964
+ this.emit('model-step', { step: 'not there yet, waiting', what: label, look: looks, waitMs: poll });
965
+ await this.p.waitForTimeout(poll);
966
+ looks += 1;
967
+ el = await find();
968
+ }
969
+ if (!el) {
970
+ this.emit('model-step', { step: 'never appeared', what: label, looks });
971
+ return null;
972
+ }
973
+ this.emit('model-step', { step: 'there, pausing before the press', what: label, look: looks, text: await textOf(el), pauseMs: pause });
974
+ await this.p.waitForTimeout(pause);
975
+ await picture(`before-${label}`);
976
+ return el;
977
+ };
978
+ const visible = async (el) => (el && (await el.isVisible().catch(() => false)) ? el : null);
948
979
  const reach = async () => {
949
- const button = await this.resolveModelButton();
980
+ const button = await waitThenFind('the picker button', async () => visible(await this.resolveModelButton(1_000)));
950
981
  if (!button)
951
982
  return { problem: 'This chat does not show a model picker.' };
952
- await beforePress();
953
- await button.click();
983
+ await button.click({ timeout: 5_000 }).catch(() => undefined);
954
984
  await settle();
955
985
  if (!(await this.waitForPopup()))
956
986
  return { problem: 'The model picker did not open.' };
957
987
  this.emit('model-step', { step: 'picker opened' });
958
988
  if (target.group) {
959
- const group = await this.rowByXPath(target.group.xpath, target.group.name, appear);
960
- if (!group || !(await present(group)))
989
+ const group = await waitThenFind(`the "${target.group.name}" group`, () => this.rowByXPath(target.group.xpath, target.group.name, 0));
990
+ if (!group)
961
991
  return { problem: `the "${target.group.name}" row was not found by ${target.group.xpath}` };
962
- this.emit('model-step', { step: 'group found', text: await textOf(group) });
963
992
  /*
964
993
  * Opened without the mouse first: a mouse click goes to coordinates, and the picker moves while it
965
994
  * opens, so on the work machine a press meant for "GPT" (now second) opened "Claude" (now first)
966
995
  * (2026-10-06). A click sent to the element itself cannot land on another row; then the keyboard
967
- * (focus, ArrowRight opens a submenu); the mouse only last. Each time, its model must be on screen.
996
+ * (focus, ArrowRight opens a submenu); the mouse only last. Each time, its model must appear.
968
997
  */
969
998
  const ways = [
970
999
  ['click on the element', async () => { await group.evaluate((el) => el.click(), undefined, { timeout: 3_000 }); }],
@@ -973,26 +1002,75 @@ Current URL: ${url}`);
973
1002
  ['mouse click', async () => { await group.click({ timeout: 3_000 }); }],
974
1003
  ];
975
1004
  for (const [how, act] of ways) {
976
- await beforePress();
977
1005
  await act().catch(() => undefined);
978
1006
  await settle();
979
- const row = await this.rowByXPath(target.xpath, target.model, appear);
980
- if (row && (await present(row))) {
1007
+ const row = await waitThenFind(`"${target.model}"`, () => this.rowByXPath(target.xpath, target.model, 0));
1008
+ if (row) {
981
1009
  this.emit('model-step', { step: 'group opened', how, model: await textOf(row) });
982
1010
  return { row };
983
1011
  }
984
1012
  this.emit('model-step', { step: 'group not opened', how });
1013
+ await this.p.waitForTimeout(pause);
985
1014
  }
986
1015
  await this.captureMenu('select-failed', await this.readMenuRows().catch(() => [])).catch(() => undefined);
987
1016
  return { problem: `"${target.model}" did not appear after opening "${target.group.name}" every way (${target.xpath}); the menu is kept as models-select-failed.html/.png` };
988
1017
  }
989
- const row = await this.rowByXPath(target.xpath, target.model, appear);
990
- if (!row || !(await present(row))) {
1018
+ const row = await waitThenFind(`"${target.model}"`, () => this.rowByXPath(target.xpath, target.model, 0));
1019
+ if (!row) {
991
1020
  await this.captureMenu('select-failed', await this.readMenuRows().catch(() => [])).catch(() => undefined);
992
1021
  return { problem: `"${target.model}" was not found by ${target.xpath}; the menu is kept as models-select-failed.html/.png` };
993
1022
  }
994
1023
  return { row };
995
1024
  };
1025
+ /*
1026
+ * A model in a group, by the keyboard (operator's choice, 2026-10-06: the submenu failed when it was
1027
+ * expanded by click or hover). The picker opened, the group's row is focused, ArrowRight opens its
1028
+ * submenu and puts the focus in it, ArrowDown moves the focus until it is on the model — read from the
1029
+ * focused row's own text — and Enter chooses it. No hover, no coordinates.
1030
+ */
1031
+ const byKeyboard = async (attempt) => {
1032
+ if (!target.group)
1033
+ return false;
1034
+ const button = await waitThenFind('the picker button', async () => visible(await this.resolveModelButton(1_000)));
1035
+ if (!button)
1036
+ return false;
1037
+ await button.click({ timeout: 5_000 }).catch(() => undefined);
1038
+ await settle();
1039
+ if (!(await this.waitForPopup()))
1040
+ return false;
1041
+ const group = await waitThenFind(`the "${target.group.name}" group`, () => this.rowByXPath(target.group.xpath, target.group.name, 0));
1042
+ if (!group) {
1043
+ await this.closeMenu();
1044
+ return false;
1045
+ }
1046
+ await group.focus({ timeout: 3_000 }).catch(() => undefined);
1047
+ await this.p.keyboard.press('ArrowRight');
1048
+ this.emit('model-step', { step: 'group opened by the keyboard (ArrowRight)', attempt });
1049
+ await settle();
1050
+ const focused = () => this.p.evaluate(() => {
1051
+ const el = document.activeElement;
1052
+ const row = el?.closest('[role="menuitemradio"],[role="menuitem"]');
1053
+ return { text: (row?.innerText ?? '').trim(), radio: !!el?.closest('[role="menuitemradio"]') };
1054
+ });
1055
+ for (let presses = 0; presses < 12; presses += 1) {
1056
+ const now = await focused();
1057
+ const line = now.text.split('\n')[0]?.trim() ?? '';
1058
+ if (now.radio && (sameModel(line, name) || sameModel(line, target.model) || containsModel(now.text, target.model))) {
1059
+ this.emit('model-step', { step: 'model reached by the keyboard, pausing before Enter', attempt, text: line, presses, pauseMs: pause });
1060
+ await this.p.waitForTimeout(pause);
1061
+ await picture('before-enter');
1062
+ await this.p.keyboard.press('Enter');
1063
+ await settle();
1064
+ return true;
1065
+ }
1066
+ await this.p.keyboard.press('ArrowDown');
1067
+ await this.p.waitForTimeout(400);
1068
+ }
1069
+ this.emit('model-step', { step: 'model not reached by the keyboard', attempt });
1070
+ await this.closeMenu();
1071
+ await settle();
1072
+ return false;
1073
+ };
996
1074
  const checked = async (row) => (await row.getAttribute('aria-checked').catch(() => null)) === 'true';
997
1075
  /*
998
1076
  * The whole choice, tried again from a closed menu while the picker does not show the model as chosen,
@@ -1002,6 +1080,17 @@ Current URL: ${url}`);
1002
1080
  let why = '';
1003
1081
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
1004
1082
  this.emit('model-step', { step: 'attempt', attempt, of: attempts, model: name });
1083
+ // A model in a group: the keyboard first; the other ways only when it does not get there.
1084
+ if (target.group && (await byKeyboard(attempt))) {
1085
+ await this.closeMenu();
1086
+ await settle();
1087
+ const shownNow = await this.currentModel();
1088
+ if (buttonShows(shownNow, name)) {
1089
+ this.emit('model-selected', { model: name, buttonShows: shownNow, by: 'operator', attempt, seenOn: 'button', how: 'keyboard' });
1090
+ return { ok: true, current: name, by: 'operator' };
1091
+ }
1092
+ this.emit('model-step', { step: 'not shown on the button after Enter, trying the other ways', attempt, buttonShows: shownNow });
1093
+ }
1005
1094
  const found = await reach();
1006
1095
  if ('problem' in found) {
1007
1096
  why = found.problem;
@@ -1017,14 +1106,14 @@ Current URL: ${url}`);
1017
1106
  }
1018
1107
  // On the element itself, for the same reason as the group; the mouse only when that does nothing.
1019
1108
  this.emit('model-step', { step: 'model pressed', attempt, text: await textOf(found.row) });
1020
- await beforePress();
1021
1109
  await found.row.evaluate((el) => el.click(), undefined, { timeout: 3_000 }).catch(() => undefined);
1022
1110
  await settle();
1023
1111
  if ((await this.modelMenuOpen()) && !(await checked(found.row))) {
1024
- await beforePress();
1112
+ await this.p.waitForTimeout(pause);
1025
1113
  await found.row.click({ timeout: 3_000 }).catch(() => undefined);
1026
1114
  await settle();
1027
1115
  }
1116
+ await picture('after-press');
1028
1117
  await this.closeMenu();
1029
1118
  await settle();
1030
1119
  /*
@@ -167,10 +167,15 @@ export const OperatorModelLocators = {
167
167
  */
168
168
  export const ModelPicking = {
169
169
  attempts: 6,
170
- /** A fixed pause before every press in the picker: the button, the group, the model (2026-10-06: 2 s). */
170
+ /**
171
+ * The operator's rhythm (2026-10-06): an element of the picker is looked for; while it is not there the
172
+ * runner waits `pollMs` and looks again, up to `appearMs`; once it is there it waits `beforePressMs`
173
+ * more, then presses it.
174
+ */
175
+ pollMs: 2_000,
171
176
  beforePressMs: 2_000,
172
177
  settleMs: 1_500,
173
- appearMs: 10_000,
178
+ appearMs: 20_000,
174
179
  };
175
180
  /** A text as an XPath string literal, whatever quotes it holds. */
176
181
  export function xpathText(text) {
@@ -19,7 +19,18 @@ export function buttonShows(shown, asked) {
19
19
  return false;
20
20
  const s = normModel(shown);
21
21
  const a = normModel(asked);
22
- return s.length > 0 && (s === a || a.startsWith(`${s} `));
22
+ if (s.length === 0)
23
+ return false;
24
+ if (s === a)
25
+ return true;
26
+ /*
27
+ * The button drops the last word ("GPT-5.6 Sol Think deeper" reads "GPT-5.6 Sol Think"), no more: a
28
+ * shorter start — "GPT" alone — would agree with every model of the group, and a run would take the
29
+ * chat for set and choose nothing (2026-10-06).
30
+ */
31
+ const sw = s.split(' ');
32
+ const aw = a.split(' ');
33
+ return sw.length >= aw.length - 1 && aw.length > 1 && a.startsWith(`${s} `);
23
34
  }
24
35
  const words = (name) => normModel(name).split(' ').filter(Boolean);
25
36
  const isVersion = (w) => /^v?\d+(?:\.\d+)*$/.test(w);