copilot-operator 0.1.10 → 0.1.11

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 (40) hide show
  1. package/dist/src/api/operator.controller.js +11 -0
  2. package/dist/src/api/operator.service.js +28 -1
  3. package/dist/src/config/schema.js +6 -0
  4. package/dist/src/orchestrator/taskRunner.js +44 -2
  5. package/dist/src/plan/systemGuide.js +32 -0
  6. package/dist/src/session/compose.js +1 -1
  7. package/dist/src/transport/copilotTransport.js +129 -67
  8. package/dist/src/vcs/syncCommand.js +105 -0
  9. package/dist/web/404.html +2 -2
  10. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
  11. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +1 -0
  12. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +1 -0
  13. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +1 -0
  14. package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +1 -0
  15. package/dist/web/_next/static/css/{20c6b36996df5e4c.css → 2ad2a74b7e07dcc9.css} +1 -1
  16. package/dist/web/appearance.html +2 -2
  17. package/dist/web/appearance.txt +12 -12
  18. package/dist/web/defaults.html +2 -2
  19. package/dist/web/defaults.txt +12 -12
  20. package/dist/web/history.html +2 -2
  21. package/dist/web/history.txt +12 -12
  22. package/dist/web/import.html +2 -2
  23. package/dist/web/import.txt +12 -12
  24. package/dist/web/index.html +2 -2
  25. package/dist/web/index.txt +12 -12
  26. package/dist/web/level1.html +2 -2
  27. package/dist/web/level1.txt +12 -12
  28. package/dist/web/presets.html +2 -2
  29. package/dist/web/presets.txt +12 -12
  30. package/dist/web/sessions/view.html +2 -2
  31. package/dist/web/sessions/view.txt +12 -12
  32. package/dist/web/system.html +2 -2
  33. package/dist/web/system.txt +12 -12
  34. package/package.json +3 -2
  35. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
  36. package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +0 -1
  37. package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +0 -1
  38. package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +0 -1
  39. /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → oyfXbuB_cq0sRZP7ukHlQ}/_buildManifest.js +0 -0
  40. /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → oyfXbuB_cq0sRZP7ukHlQ}/_ssgManifest.js +0 -0
@@ -466,6 +466,10 @@ let OperatorController = class OperatorController {
466
466
  * Separate from the session's own preflight because that one only speaks when version
467
467
  * control is already on, and the question here is whether it may be turned on.
468
468
  */
469
+ /** The command that brings a project's branch back to its remote's main — shown, never run. */
470
+ repoSync(dir) {
471
+ return this.ops.syncPlan(dir ?? '').catch(fail);
472
+ }
469
473
  repo(dir) {
470
474
  const problem = this.ops.repoProblem(dir ?? '');
471
475
  return problem ? { ok: false, problem } : { ok: true };
@@ -994,6 +998,13 @@ __decorate([
994
998
  __metadata("design:paramtypes", [Object]),
995
999
  __metadata("design:returntype", Promise)
996
1000
  ], OperatorController.prototype, "setProject", null);
1001
+ __decorate([
1002
+ Get('repo/sync'),
1003
+ __param(0, Query('dir')),
1004
+ __metadata("design:type", Function),
1005
+ __metadata("design:paramtypes", [String]),
1006
+ __metadata("design:returntype", Promise)
1007
+ ], OperatorController.prototype, "repoSync", null);
997
1008
  __decorate([
998
1009
  Get('repo'),
999
1010
  __param(0, Query('dir')),
@@ -15,7 +15,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
15
15
  import { Injectable } from '@nestjs/common';
16
16
  import { readFile, readdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { join } from 'node:path';
18
+ import { join, resolve } from 'node:path';
19
19
  import { installLayout } from '../config/layout.js';
20
20
  import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
21
21
  import { EventBus } from '../session/events.js';
@@ -47,10 +47,17 @@ import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } fro
47
47
  import { unattendedPrecondition } from '../exec/policy.js';
48
48
  import { createTransport } from '../transport/chatTransport.js';
49
49
  import { composeHandoff } from '../session/handoff.js';
50
+ import { planSync } from '../vcs/syncCommand.js';
50
51
  import { computeMetrics } from '../session/metrics.js';
51
52
  import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
52
53
  import { saveAndReveal } from './saveToDesktop.js';
53
54
  import { Settings } from './settings.js';
55
+ /** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
56
+ function normaliseDir(dir) {
57
+ if (!dir.trim())
58
+ return '';
59
+ return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
60
+ }
54
61
  let OperatorService = class OperatorService {
55
62
  /** Where this install keeps its prompts and its records. See `config/layout.ts`. */
56
63
  layout = installLayout();
@@ -1774,6 +1781,26 @@ let OperatorService = class OperatorService {
1774
1781
  repoProblem(dir) {
1775
1782
  return repoUnusableReason(dir);
1776
1783
  }
1784
+ /**
1785
+ * What bringing a project back to its remote's main branch would lose, and the command that does
1786
+ * it — for the operator to run. See `vcs/syncCommand.ts`; nothing here writes.
1787
+ *
1788
+ * Only for a folder this bot already works in: a project in Settings or the repository of a
1789
+ * session. The answer lists file names and commit subjects, and a local page asking about any
1790
+ * folder on the disk is not a question this API should answer.
1791
+ */
1792
+ async syncPlan(dir) {
1793
+ await this.init();
1794
+ const wanted = normaliseDir(dir);
1795
+ const cfg = await this.settings.load();
1796
+ const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.mirror?.rootDir ?? ''])]
1797
+ .filter((d) => d.trim())
1798
+ .map(normaliseDir));
1799
+ if (!wanted || !known.has(wanted)) {
1800
+ throw new Error('That folder is not one of the projects in Settings or the repository of a session, so no command is offered for it.');
1801
+ }
1802
+ return await planSync(resolve(dir.trim()));
1803
+ }
1777
1804
  // --- the project being worked on ---------------------------------------------------------
1778
1805
  /**
1779
1806
  * The folder new sessions start pointed at, and whether it can carry version control.
@@ -399,6 +399,12 @@ export const RunConfigSchema = z.object({
399
399
  * its record. Zero turns it off.
400
400
  */
401
401
  retryBlockedInFreshChat: z.number().int().nonnegative().default(2),
402
+ /*
403
+ * How many genuinely different approaches the chat must have tried before it may end a task
404
+ * "blocked". The reply format's own floor is 2 (replySchema.ts); this raises it. A "blocked"
405
+ * with fewer is sent back asking for another approach (see taskRunner.ts). Settings → Execution.
406
+ */
407
+ minApproachesBeforeBlocked: z.number().int().min(2).max(50).default(2),
402
408
  })
403
409
  .prefault({}),
404
410
  runsDir: z.string().default('./runs'),
@@ -38,6 +38,7 @@ import { writeReport } from '../exec/reportFile.js';
38
38
  import { Pacer } from '../util/pacing.js';
39
39
  import { RunLog } from '../log/runLog.js';
40
40
  import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
41
+ import { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
41
42
  import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
42
43
  import { composeHandoff } from '../session/handoff.js';
43
44
  import { ProgressWatch } from './progress.js';
@@ -185,11 +186,12 @@ export async function openBrowser(cfg, bus, transportDir, sessionId) {
185
186
  'verification-cleared': 'Verification cleared, continuing.',
186
187
  'error-banner': 'The chat reported a transient error; reloading the page.',
187
188
  'reply-files-ignored': 'The reply came with a file attached. It was not downloaded and will not be run: the runner takes only the text of a reply.',
189
+ 'model-menu-stuck': 'The model menu would not close — Escape, the menu button and a click outside all left it open. Close it in the Edge window; the run goes on.',
188
190
  };
189
191
  bus.publish({
190
192
  sessionId,
191
193
  type: `browser:${event}`,
192
- level: event === 'verification-required' || event === 'reply-files-ignored' ? 'warn' : 'info',
194
+ level: event === 'verification-required' || event === 'reply-files-ignored' || event === 'model-menu-stuck' ? 'warn' : 'info',
193
195
  message: spoken[event],
194
196
  data: detail,
195
197
  });
@@ -361,6 +363,15 @@ export async function runTask(transport, session, task, deps) {
361
363
  let runnerNotes = [];
362
364
  /** Steps the chat sent that were not run, for the handoff at the end. */
363
365
  const notRun = [];
366
+ /**
367
+ * How many different approaches "blocked" needs, from Settings, never below the format's own two.
368
+ * A "blocked" with fewer is sent back; after `MAX_EARLY_BLOCKS` of them in a row with nothing tried
369
+ * in between the verdict is accepted, so a chat that cannot think of anything else does not spend
370
+ * the rest of the iterations saying so.
371
+ */
372
+ const minApproaches = Math.max(MIN_TRIED_APPROACHES, cfg.limits.minApproachesBeforeBlocked ?? MIN_TRIED_APPROACHES);
373
+ const MAX_EARLY_BLOCKS = 3;
374
+ let earlyBlocks = 0;
364
375
  /** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
365
376
  const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
366
377
  /** Loops the older guards do not see; see `progress.ts`. */
@@ -695,6 +706,10 @@ export async function runTask(transport, session, task, deps) {
695
706
  vcsNote: prepared.note,
696
707
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
697
708
  scopeNote: scopeNote(scope, scopeEnforced),
709
+ approachesNote: minApproaches > MIN_TRIED_APPROACHES
710
+ ? `## Giving up\n\nThis task may end with status "blocked" only after at least ${minApproaches} genuinely different approaches, ` +
711
+ `each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
712
+ : undefined,
698
713
  shellNote: shellNote(shells, defaultShell),
699
714
  });
700
715
  await setTask((t) => {
@@ -1277,9 +1292,36 @@ export async function runTask(transport, session, task, deps) {
1277
1292
  * what the reply said, at greater length and one message later. The reason carries the
1278
1293
  * approaches that were tried, and that is what the register shows.
1279
1294
  */
1295
+ if (!blocked && reply.steps.length > 0)
1296
+ earlyBlocks = 0;
1297
+ if (blocked && reply.tried.length < minApproaches && earlyBlocks < MAX_EARLY_BLOCKS) {
1298
+ /*
1299
+ * Too early to give up, by the operator's own measure (Settings → Execution). Sent back as a
1300
+ * request for another approach rather than accepted: the format only knows that two is the
1301
+ * floor, the operator decided this work deserves more tries than that.
1302
+ */
1303
+ earlyBlocks += 1;
1304
+ stats.blockedTooEarly = (stats.blockedTooEarly ?? 0) + 1;
1305
+ sink.event('blocked-too-early', { tried: reply.tried.length, needed: minApproaches, time: earlyBlocks }, `"blocked" after ${reply.tried.length} approach(es); Settings ask for ${minApproaches}, so the chat is asked for another`, 'warn');
1306
+ const message = `Not yet. This task is given up only after at least ${minApproaches} genuinely different approaches, and your reply lists ` +
1307
+ `${reply.tried.length}: ${reply.tried.map((x, i) => `(${i + 1}) ${x}`).join(' ')}. Try a different approach now — not one of ` +
1308
+ 'these again, and not the same command reworded — and send its steps with status "continue". If it fails too, add it to ' +
1309
+ '"tried" and try another. Nothing you listed is lost: the runner keeps what has been tried.';
1310
+ await record('BLOCKED TOO EARLY — MESSAGE SENT', message);
1311
+ await pacer.throttleSend();
1312
+ const before = await transport.sendAndConfirm(message);
1313
+ const next = await transport.waitForReply(before);
1314
+ await saveReply(`blocked-too-early-${earlyBlocks}`, next);
1315
+ lastMarkdown = next.markdown;
1316
+ await pacer.settle();
1317
+ continue;
1318
+ }
1280
1319
  if (blocked) {
1281
1320
  sink.event('task-blocked', { tried: reply.tried, needed: reply.needed }, `the task was given up as blocked after ${reply.tried.length} approach(es)`, 'warn');
1282
- return await finish('blocked', blockedReason(reply.tried, reply.needed), reply.summary, lastMarkdown);
1321
+ const early = reply.tried.length < minApproaches
1322
+ ? ` It gave up after ${reply.tried.length} of the ${minApproaches} approaches Settings ask for, having been asked ${earlyBlocks} time(s) for another.`
1323
+ : '';
1324
+ return await finish('blocked', blockedReason(reply.tried, reply.needed) + early, reply.summary, lastMarkdown);
1283
1325
  }
1284
1326
  if (done && reply.steps.length === 0) {
1285
1327
  const verdict = await gateOnChecks();
@@ -2844,6 +2844,38 @@ export const SYSTEM_GUIDE = [
2844
2844
  doesEn: 'Seconds to wait for a chat reply (default 900); a reply not finished by then fails the task with "Copilot did not finish a reply within …".',
2845
2845
  doesBg: 'Секунди чакане на отговор от чата (по подразбиране 900); незавършен дотогава отговор проваля задачата с „Copilot did not finish a reply within …“.',
2846
2846
  },
2847
+ {
2848
+ key: 'sync.show',
2849
+ kind: 'button',
2850
+ en: 'Command to bring this project back to the remote main branch',
2851
+ bg: 'Команда за връщане на проекта към основния клон на отдалеченото хранилище',
2852
+ doesEn: 'Under each project in Settings and on a session\u2019s version control: lists what would be lost and gives a PowerShell command to paste; the bot never runs it.',
2853
+ doesBg: 'Под всеки проект в Настройки и в контрола на версиите на сесия: изброява какво ще се загуби и дава команда за PowerShell; ботът никога не я пуска.',
2854
+ },
2855
+ {
2856
+ key: 'sync.copyPreview',
2857
+ kind: 'button',
2858
+ en: 'Copy the preview (changes nothing)',
2859
+ bg: 'Копирай прегледа (не променя нищо)',
2860
+ doesEn: 'Fetches and lists the commits, changes and new files the command would remove.',
2861
+ doesBg: 'Изтегля и изброява commit-ите, промените и новите файлове, които командата би махнала.',
2862
+ },
2863
+ {
2864
+ key: 'sync.copyCommand',
2865
+ kind: 'button',
2866
+ en: 'Copy the command',
2867
+ bg: 'Копирай командата',
2868
+ doesEn: 'fetch, reset --hard to the remote main, clean -fd — each only if the step before worked.',
2869
+ doesBg: 'fetch, reset --hard към основния клон, clean -fd — всяка само ако предишната е успяла.',
2870
+ },
2871
+ {
2872
+ key: 'exec.minApproaches',
2873
+ kind: 'field',
2874
+ en: 'Approaches the chat must try before it may give up',
2875
+ bg: 'Подходи, които чатът трябва да опита, преди да се откаже',
2876
+ doesEn: 'How many different approaches a "blocked" must list (2–20); with fewer the chat is asked for another.',
2877
+ doesBg: 'Колко различни подхода трябва да изброи „blocked“ (2–20); с по-малко чатът е помолен за още един.',
2878
+ },
2847
2879
  {
2848
2880
  key: 'exec.retryBlocked',
2849
2881
  kind: 'field',
@@ -11,7 +11,7 @@ const TASK_HEADER = '## Task';
11
11
  * and a note that is empty simply leaves nothing behind.
12
12
  */
13
13
  function runnerBlock(input) {
14
- const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
14
+ const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.approachesNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
15
15
  return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
16
16
  }
17
17
  /**
@@ -57,65 +57,65 @@ export class SendRejectedError extends Error {
57
57
  * impossible — the runner notes it before the click and waits for it to move, so a copy that
58
58
  * did not happen reads as a failure rather than as the previous answer.
59
59
  */
60
- export const CLIPBOARD_GUARD = `(() => {
61
- const state = { text: '', seq: 0 };
62
- try {
63
- Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
64
- } catch (e) {
65
- window.__copClipboard = state;
66
- }
67
-
68
- const remember = (text) => {
69
- if (typeof text !== 'string' || text.length === 0) return;
70
- state.text = text;
71
- state.seq += 1;
72
- };
73
-
74
- const clip = navigator.clipboard;
75
- if (clip) {
76
- try {
77
- Object.defineProperty(clip, 'writeText', {
78
- configurable: true,
79
- writable: true,
80
- value: (text) => {
81
- remember(String(text));
82
- return Promise.resolve();
83
- },
84
- });
85
- } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
86
-
87
- try {
88
- Object.defineProperty(clip, 'write', {
89
- configurable: true,
90
- writable: true,
91
- value: async (items) => {
92
- for (const item of items || []) {
93
- try {
94
- if (item && item.types && item.types.indexOf('text/plain') >= 0) {
95
- remember(await (await item.getType('text/plain')).text());
96
- }
97
- } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
98
- }
99
- },
100
- });
101
- } catch (e) { /* as above */ }
102
- }
103
-
104
- // The old way: select text in a hidden field, then execCommand('copy'). The selection is
105
- // exactly the text being copied, so it is taken and the command is never run.
106
- try {
107
- const realExec = document.execCommand.bind(document);
108
- document.execCommand = (command, ...rest) => {
109
- if (String(command).toLowerCase() === 'copy') {
110
- const selected = String(window.getSelection() || '');
111
- if (selected.length > 0) {
112
- remember(selected);
113
- return true;
114
- }
115
- }
116
- return realExec(command, ...rest);
117
- };
118
- } catch (e) { /* as above */ }
60
+ export const CLIPBOARD_GUARD = `(() => {
61
+ const state = { text: '', seq: 0 };
62
+ try {
63
+ Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
64
+ } catch (e) {
65
+ window.__copClipboard = state;
66
+ }
67
+
68
+ const remember = (text) => {
69
+ if (typeof text !== 'string' || text.length === 0) return;
70
+ state.text = text;
71
+ state.seq += 1;
72
+ };
73
+
74
+ const clip = navigator.clipboard;
75
+ if (clip) {
76
+ try {
77
+ Object.defineProperty(clip, 'writeText', {
78
+ configurable: true,
79
+ writable: true,
80
+ value: (text) => {
81
+ remember(String(text));
82
+ return Promise.resolve();
83
+ },
84
+ });
85
+ } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
86
+
87
+ try {
88
+ Object.defineProperty(clip, 'write', {
89
+ configurable: true,
90
+ writable: true,
91
+ value: async (items) => {
92
+ for (const item of items || []) {
93
+ try {
94
+ if (item && item.types && item.types.indexOf('text/plain') >= 0) {
95
+ remember(await (await item.getType('text/plain')).text());
96
+ }
97
+ } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
98
+ }
99
+ },
100
+ });
101
+ } catch (e) { /* as above */ }
102
+ }
103
+
104
+ // The old way: select text in a hidden field, then execCommand('copy'). The selection is
105
+ // exactly the text being copied, so it is taken and the command is never run.
106
+ try {
107
+ const realExec = document.execCommand.bind(document);
108
+ document.execCommand = (command, ...rest) => {
109
+ if (String(command).toLowerCase() === 'copy') {
110
+ const selected = String(window.getSelection() || '');
111
+ if (selected.length > 0) {
112
+ remember(selected);
113
+ return true;
114
+ }
115
+ }
116
+ return realExec(command, ...rest);
117
+ };
118
+ } catch (e) { /* as above */ }
119
119
  })();`;
120
120
  export class CopilotTransport {
121
121
  opts;
@@ -271,7 +271,7 @@ export class CopilotTransport {
271
271
  'sign-in': 'The profile is signed out. Run "cop login".',
272
272
  unknown: 'The page is not the Microsoft 365 Copilot chat.',
273
273
  };
274
- throw new Error(`${explain[where] ?? explain.unknown}
274
+ throw new Error(`${explain[where] ?? explain.unknown}
275
275
  Current URL: ${url}`);
276
276
  }
277
277
  /**
@@ -680,14 +680,77 @@ Current URL: ${url}`);
680
680
  }
681
681
  await row.click({ timeout: 8_000 }).catch(() => undefined);
682
682
  }
683
- /** Closes the picker, submenu included. */
683
+ /**
684
+ * Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
685
+ * Both, because either alone has been wrong: rows can be mid-animation after the menu closed, and a
686
+ * button re-rendered by the page can lose the attribute while the menu is still up.
687
+ */
688
+ async modelMenuOpen() {
689
+ const button = await this.resolveModelButton(1_000);
690
+ const expanded = button ? await button.getAttribute('aria-expanded').catch(() => null) : null;
691
+ if (expanded === 'true')
692
+ return true;
693
+ return (await this.readMenuRows()).length > 0;
694
+ }
695
+ /**
696
+ * Closes the picker, submenu included, and makes sure it is closed.
697
+ *
698
+ * Escape pressed on the page was all this did, three times, and then it gave up without a word.
699
+ * Live, that left the model list open over the chat after a check of the model: Escape goes to
700
+ * whatever has focus, and after a click dispatched on a row, or no click at all because the model
701
+ * was already chosen, focus was not in the menu, so nothing heard it. Each way a person would close
702
+ * it is tried in turn, and the menu is looked at after each: Escape; Escape sent to the menu itself;
703
+ * the picker button again (it toggles); a click outside the menu, in the composer, which a Fluent
704
+ * popup takes as "close". A menu still open after all of them is reported, not passed over.
705
+ */
684
706
  async closeMenu() {
685
- for (let i = 0; i < 3; i += 1) {
686
- await this.p.keyboard.press('Escape').catch(() => undefined);
687
- await this.p.waitForTimeout(200);
688
- if ((await this.readMenuRows()).length === 0)
707
+ const ways = [
708
+ ['escape', async () => await this.p.keyboard.press('Escape')],
709
+ [
710
+ 'escape in the menu',
711
+ async () => {
712
+ for (const selector of Model.popupSelectors.slice(0, 2)) {
713
+ const menu = this.p.locator(`${selector}:visible`).last();
714
+ if ((await menu.count()) > 0) {
715
+ await menu.press('Escape', { timeout: 2_000 });
716
+ return;
717
+ }
718
+ }
719
+ },
720
+ ],
721
+ [
722
+ 'the picker button',
723
+ async () => {
724
+ const button = await this.resolveModelButton(1_000);
725
+ // Only while the button itself says the menu is open: clicking it otherwise would open it.
726
+ if (button && (await button.getAttribute('aria-expanded').catch(() => null)) === 'true')
727
+ await button.click({ timeout: 3_000 });
728
+ },
729
+ ],
730
+ [
731
+ 'a click outside',
732
+ async () => {
733
+ const composer = this.composer();
734
+ if ((await composer.count().catch(() => 0)) > 0 && (await composer.isVisible().catch(() => false)))
735
+ await composer.click({ timeout: 3_000 });
736
+ else
737
+ await this.p.mouse.click(4, 4);
738
+ },
739
+ ],
740
+ ];
741
+ for (const [how, act] of ways) {
742
+ if (!(await this.modelMenuOpen()))
743
+ return;
744
+ await act().catch(() => undefined);
745
+ await this.p.waitForTimeout(250);
746
+ if (!(await this.modelMenuOpen())) {
747
+ if (how !== 'escape')
748
+ this.emit('model-menu-closed', { how });
689
749
  return;
750
+ }
690
751
  }
752
+ if (await this.modelMenuOpen())
753
+ this.emit('model-menu-stuck', { tried: ways.map(([how]) => how) });
691
754
  }
692
755
  /** The first popup container that becomes visible after the picker is clicked. */
693
756
  async waitForPopup(timeoutMs = 8_000) {
@@ -772,8 +835,7 @@ Current URL: ${url}`);
772
835
  await this.p.waitForTimeout(1_000);
773
836
  // Whatever the click did, the menu is not left open over the chat: a build that keeps it open
774
837
  // after a choice, or a click that did not land, would otherwise leave it for the next message.
775
- if ((await this.readMenuRows()).length > 0)
776
- await this.closeMenu();
838
+ await this.closeMenu();
777
839
  const after = await this.currentModel();
778
840
  // The button shows the choice, so it is the check. It also **shortens** it: picking
779
841
  // "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The command that brings a project's current branch back to the remote's main branch, handed to
3
+ * the operator rather than run.
4
+ *
5
+ * Asked for after a day of runs: a project left on a task branch, with files a step created and
6
+ * nobody committed, wants to go back to where the team's code is. That is `fetch`, `reset --hard`
7
+ * to the remote's main, and `clean -fd` for the new files git does not ignore — three commands that
8
+ * destroy work if pointed at the wrong folder or run a minute too early. So the bot never runs them.
9
+ * It reads the repository, says exactly what would be lost — commits on this branch that the
10
+ * remote's main does not have, changed tracked files, new files — and gives the command, ready to
11
+ * paste into PowerShell, with a read-only preview beside it. Ignored files (`node_modules`, `.env`,
12
+ * build output) are left alone: `clean` without `-x` does not touch them.
13
+ *
14
+ * Nothing here writes to the repository or the network. `git fetch` is in the command, not here:
15
+ * what "would be lost" is computed against the remote branch as last fetched, and the page says so.
16
+ */
17
+ import { statSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { git, porcelainPaths } from './git.js';
20
+ /** A string as a single-quoted PowerShell literal: nothing inside it is expanded. */
21
+ export function psQuote(text) {
22
+ return `'${text.replace(/'/g, "''")}'`;
23
+ }
24
+ /**
25
+ * The two commands, for Windows PowerShell 5.1 and PowerShell 7 alike: `;` and `$LASTEXITCODE`
26
+ * rather than `&&`, which 5.1 does not have, so a failed fetch stops everything after it instead
27
+ * of resetting to a remote branch that is out of date.
28
+ */
29
+ export function syncCommands(dir, remote, target) {
30
+ const g = `git -C ${psQuote(dir)}`;
31
+ return {
32
+ preview: [
33
+ `${g} fetch ${psQuote(remote)}`,
34
+ `${g} log --oneline ${psQuote(`${target}..HEAD`)}`,
35
+ `${g} status --short`,
36
+ `${g} clean -nd`,
37
+ ].join('; '),
38
+ command: `${g} fetch ${psQuote(remote)}; if ($LASTEXITCODE -eq 0) { ${g} reset --hard ${psQuote(target)}; ` +
39
+ `if ($LASTEXITCODE -eq 0) { ${g} clean -fd } }`,
40
+ };
41
+ }
42
+ const empty = (repoDir, problem) => ({
43
+ ok: false,
44
+ problem,
45
+ repoDir,
46
+ willLose: { commits: [], changed: [], untracked: [] },
47
+ preview: '',
48
+ command: '',
49
+ });
50
+ export async function planSync(repoDir) {
51
+ const inside = await git(repoDir, ['rev-parse', '--is-inside-work-tree']);
52
+ if (!inside.ok || inside.stdout !== 'true')
53
+ return empty(repoDir, `${repoDir} is not a git repository.`);
54
+ const branch = await git(repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
55
+ if (!branch.ok || !branch.stdout) {
56
+ return empty(repoDir, 'The repository is not on a branch (a detached HEAD), so there is no "current branch" to bring up to date. Check out a branch first.');
57
+ }
58
+ const remotes = (await git(repoDir, ['remote'])).stdout.split('\n').map((r) => r.trim()).filter(Boolean);
59
+ if (remotes.length === 0)
60
+ return empty(repoDir, 'The repository has no remote, so there is no remote main branch to go back to.');
61
+ const remote = remotes.includes('origin') ? 'origin' : remotes[0];
62
+ // The remote's own idea of its main branch first, then the two usual names.
63
+ let target = '';
64
+ const head = await git(repoDir, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`]);
65
+ if (head.ok && head.stdout)
66
+ target = head.stdout;
67
+ for (const name of ['main', 'master']) {
68
+ if (target)
69
+ break;
70
+ const found = await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`]);
71
+ if (found.ok)
72
+ target = `${remote}/${name}`;
73
+ }
74
+ if (!target) {
75
+ return empty(repoDir, `No main branch of ${remote} is known here yet. Run ${syncCommands(repoDir, remote, `${remote}/main`).preview.split('; ')[0]} once, then look again.`);
76
+ }
77
+ const commits = await git(repoDir, ['log', '--format=%h %s', `${target}..HEAD`]);
78
+ const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
79
+ const clean = await git(repoDir, ['clean', '-nd']);
80
+ let lastFetched;
81
+ try {
82
+ const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
83
+ lastFetched = statSync(join(gitDir, 'FETCH_HEAD')).mtime.toISOString();
84
+ }
85
+ catch {
86
+ /* never fetched: the page says so */
87
+ }
88
+ return {
89
+ ok: true,
90
+ repoDir,
91
+ branch: branch.stdout,
92
+ remote,
93
+ target,
94
+ ...(lastFetched ? { lastFetched } : {}),
95
+ willLose: {
96
+ commits: commits.ok && commits.stdout ? commits.stdout.split('\n') : [],
97
+ changed: porcelainPaths(status.stdout),
98
+ untracked: clean.stdout
99
+ .split('\n')
100
+ .map((l) => l.replace(/^Would remove\s+/, '').trim())
101
+ .filter(Boolean),
102
+ },
103
+ ...syncCommands(repoDir, remote, target),
104
+ };
105
+ }