copilot-operator 0.1.25 → 0.1.27

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.
@@ -12,6 +12,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
12
12
  * the UI resolves), where events go (the bus, streamed as SSE), and how a run is stopped
13
13
  * (an AbortController per session).
14
14
  */
15
+ import { pageModelFor, sameModel } from '../transport/modelMatch.js';
15
16
  import { Injectable } from '@nestjs/common';
16
17
  import { readFile, readdir } from 'node:fs/promises';
17
18
  import { existsSync } from 'node:fs';
@@ -1095,6 +1096,7 @@ let OperatorService = class OperatorService {
1095
1096
  ...(transport || !runGroup ? {} : { beforeBrowser: () => this.browserRequested(runGroup.id, [sessionId]) }),
1096
1097
  // The page is the source of the model names: Settings and the saved list follow it (see `ModelHooks`).
1097
1098
  models: {
1099
+ locate: async (name) => (await this.store.getModels())?.options.find((o) => sameModel(o.name, name))?.locator,
1098
1100
  renamedDefault: async (which, from, to) => {
1099
1101
  const now = (await this.settings.load()).copilot;
1100
1102
  if (which === 'model' && (now.defaultModel ?? '').trim() === from)
@@ -3027,6 +3029,16 @@ let OperatorService = class OperatorService {
3027
3029
  const { options, current, note } = await transport.listModels();
3028
3030
  const catalogue = { options, current: current ?? undefined, readAt: new Date().toISOString(), note };
3029
3031
  await this.store.saveModels(catalogue);
3032
+ // A saved choice the page now offers under another name follows it here, as a run does (see `modelMatch.ts`).
3033
+ const saved = cfg.copilot;
3034
+ const work = (saved.defaultModel ?? '').trim();
3035
+ const review = (saved.defaultReviewModel ?? '').trim();
3036
+ const workNow = work ? pageModelFor(work, options) : null;
3037
+ const reviewNow = review ? pageModelFor(review, options) : null;
3038
+ if (workNow && !sameModel(workNow.name, work))
3039
+ await this.setDefaultModel(workNow.name);
3040
+ if (reviewNow && !sameModel(reviewNow.name, review))
3041
+ await this.setDefaultReviewModel(reviewNow.name);
3030
3042
  return catalogue;
3031
3043
  }
3032
3044
  finally {
@@ -523,8 +523,9 @@ export function effectiveModels(session, cfg) {
523
523
  async function applySessionModel(transport, session, bus, cfg, store, hooks) {
524
524
  const wanted = effectiveModels(session, cfg).model;
525
525
  if (!wanted)
526
- return undefined;
527
- const result = await transport.selectModel(wanted).catch((e) => ({
526
+ return {};
527
+ const locator = await hooks?.locate?.(wanted).catch(() => undefined);
528
+ const result = await transport.selectModel(wanted, { locator }).catch((e) => ({
528
529
  ok: false,
529
530
  current: null,
530
531
  reason: e.message,
@@ -538,8 +539,8 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
538
539
  level: result.ok ? 'info' : 'warn',
539
540
  message: result.ok
540
541
  ? `model: ${result.current ?? wanted}${session.model?.trim() ? '' : ' (from Settings)'}`
541
- : `could not switch to "${wanted}"${session.model?.trim() ? '' : ' (the default model in Settings — change it there)'}: ${(result.reason ?? 'unknown reason').replace(/[.\s]+$/, '')}. Continuing on ${result.current ?? 'the chat default'}.`,
542
- data: { asked: wanted, fromSettings: !session.model?.trim(), current: result.current, ok: result.ok },
542
+ : `could not switch to "${wanted}"${session.model?.trim() ? '' : ' (the default model in Settings)'}: ${(result.reason ?? 'unknown reason').replace(/[.\s]+$/, '')}. Nothing is sent on ${result.current ?? 'the chat default'} instead.`,
543
+ data: { asked: wanted, fromSettings: !session.model?.trim(), current: result.current, ok: result.ok, by: result.by ?? null, savedLocator: !!locator },
543
544
  });
544
545
  // Kept on the session, so each task's record says which model it ran on and why (see `runTask`).
545
546
  await store
@@ -555,7 +556,18 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
555
556
  };
556
557
  })
557
558
  .catch(() => undefined);
558
- return result.current ?? undefined;
559
+ /*
560
+ * A model the operator chose and the chat is not on is not worked around: the run went on Auto with a
561
+ * warning nobody saw, and its work was then taken for the chosen model's (2026-10-05). Nothing goes out.
562
+ */
563
+ if (!result.ok) {
564
+ return {
565
+ current: result.current ?? undefined,
566
+ refused: `The chat could not be put on the chosen model "${wanted}"${session.model?.trim() ? '' : ' (Settings)'}: ${(result.reason ?? 'unknown reason').replace(/[.\s]+$/, '')}. ` +
567
+ 'Nothing was sent. Read the list again under Settings → Model, choose the model there, and start again.',
568
+ };
569
+ }
570
+ return { current: result.current ?? undefined };
559
571
  }
560
572
  /** Runs one task to completion inside an already-open transport. */
561
573
  export async function runTask(transport, session, task, deps) {
@@ -580,12 +592,39 @@ export async function runTask(transport, session, task, deps) {
580
592
  */
581
593
  let sentCount = 0;
582
594
  let inOpening = false;
595
+ /*
596
+ * A conversation Copilot had not yet put in its sidebar when it was registered is named before a later
597
+ * message goes out in it: the row comes some seconds after the first reply, and fifteen were not always
598
+ * enough (live run 2026-10-05), which left the chat under Copilot's own title, where a later run looking
599
+ * for it by name could not find it. A few tries, only while the page is on that conversation.
600
+ */
601
+ let nameTries = 0;
602
+ const nameIfStillUnnamed = async (target) => {
603
+ const chat = session.chat;
604
+ if (!chat || chat.named !== false || nameTries >= 3)
605
+ return;
606
+ if ((await target.currentChatId().catch(() => null)) !== chat.chatId)
607
+ return;
608
+ nameTries += 1;
609
+ const named = await target.nameChat(chat.chatId, chat.name, { waitMs: 5_000 }).catch(() => false);
610
+ if (!named)
611
+ return;
612
+ const { named: _was, ...done } = chat;
613
+ session.chat = done;
614
+ await store.updateSession(session.id, (s) => {
615
+ if (s.chat?.chatId === chat.chatId)
616
+ s.chat = { ...s.chat, named: undefined };
617
+ }).catch(() => undefined);
618
+ await savePointer(log.path('chat.json'), done).catch(() => undefined);
619
+ sink.event('chat-named-later', { chatId: chat.chatId, name: chat.name, tries: nameTries }, `chat named "${chat.name}" in Copilot's sidebar, now that it is there`);
620
+ };
583
621
  const chatOf = transport;
584
622
  transport = new Proxy(chatOf, {
585
623
  get(target, key) {
586
624
  if (key !== 'sendAndConfirm')
587
625
  return Reflect.get(target, key);
588
626
  return async (...args) => {
627
+ await nameIfStillUnnamed(target);
589
628
  const before = await target.sendAndConfirm(...args);
590
629
  sentCount += 1;
591
630
  const [text, attachments] = args;
@@ -1240,9 +1279,13 @@ export async function runTask(transport, session, task, deps) {
1240
1279
  const modelNow = await applySessionModel(transport, session, bus, cfg, store, deps.models);
1241
1280
  if (effectiveModels(session, cfg).model) {
1242
1281
  await store.updateSession(session.id, (s) => {
1243
- s.modelInUse = modelNow;
1282
+ s.modelInUse = modelNow.current;
1244
1283
  });
1245
1284
  }
1285
+ if (modelNow.refused) {
1286
+ stopCode = 'environment';
1287
+ return await finish('blocked', modelNow.refused);
1288
+ }
1246
1289
  }
1247
1290
  }
1248
1291
  // --- opening messages -------------------------------------------------------------
@@ -1699,7 +1742,8 @@ export async function runTask(transport, session, task, deps) {
1699
1742
  try {
1700
1743
  await transport.newChat();
1701
1744
  if (model) {
1702
- const picked = await transport.selectModel(model).catch((e) => ({ ok: false, current: null, reason: e.message }));
1745
+ const reviewLocator = await deps.models?.locate?.(model).catch(() => undefined);
1746
+ const picked = await transport.selectModel(model, { locator: reviewLocator }).catch((e) => ({ ok: false, current: null, reason: e.message }));
1703
1747
  const renamed = await followPageModels('review', model, picked, session, store, deps.models);
1704
1748
  if (renamed)
1705
1749
  sink.event('model-renamed', { asked: model, chosen: picked.matched, review: true }, renamed, 'warn');
@@ -2590,9 +2634,12 @@ export async function runSession(sessionId, deps) {
2590
2634
  const modelInUse = await applySessionModel(chat, session, bus, cfg, store, deps.models);
2591
2635
  if (effectiveModels(session, cfg).model) {
2592
2636
  await store.updateSession(sessionId, (s) => {
2593
- s.modelInUse = modelInUse;
2637
+ s.modelInUse = modelInUse.current;
2594
2638
  });
2595
2639
  }
2640
+ // Refused before a task is marked started: its tasks stay queued, and the run says why.
2641
+ if (modelInUse.refused)
2642
+ throw new Error(modelInUse.refused);
2596
2643
  for (const queuedTask of queued) {
2597
2644
  if (deps.signal?.aborted)
2598
2645
  break;
@@ -2671,9 +2718,11 @@ export async function runSession(sessionId, deps) {
2671
2718
  const freshModel = await applySessionModel(chat, session, bus, cfg, store, deps.models);
2672
2719
  if (effectiveModels(session, cfg).model) {
2673
2720
  session = await store.updateSession(sessionId, (s) => {
2674
- s.modelInUse = freshModel;
2721
+ s.modelInUse = freshModel.current;
2675
2722
  });
2676
2723
  }
2724
+ if (freshModel.refused)
2725
+ throw new Error(freshModel.refused);
2677
2726
  const fresh = session.tasks.find((x) => x.id === task.id);
2678
2727
  if (!fresh)
2679
2728
  break;