copilot-operator 0.1.9 → 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 (57) hide show
  1. package/dist/src/api/operator.controller.js +11 -0
  2. package/dist/src/api/operator.service.js +32 -13
  3. package/dist/src/config/schema.js +6 -0
  4. package/dist/src/orchestrator/contract.js +58 -0
  5. package/dist/src/orchestrator/taskRunner.js +104 -6
  6. package/dist/src/plan/schema.js +21 -1
  7. package/dist/src/plan/systemGuide.js +48 -0
  8. package/dist/src/protocol/parser.js +98 -5
  9. package/dist/src/session/compose.js +1 -1
  10. package/dist/src/session/exports.js +1 -0
  11. package/dist/src/session/handoff.js +1 -1
  12. package/dist/src/session/store.js +44 -10
  13. package/dist/src/transport/copilotTransport.js +145 -65
  14. package/dist/src/vcs/scope.js +10 -7
  15. package/dist/src/vcs/syncCommand.js +105 -0
  16. package/dist/web/404.html +2 -2
  17. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
  18. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
  19. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +1 -0
  20. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +1 -0
  21. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
  23. package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
  24. package/dist/web/_next/static/chunks/app/level1/{page-be366b35f4cc97ce.js → page-dd3ebf37d29dce4f.js} +1 -1
  25. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
  26. package/dist/web/_next/static/chunks/app/presets/{page-cabc3876a8111edd.js → page-93ab518db67e52a2.js} +1 -1
  27. package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +1 -0
  28. package/dist/web/_next/static/css/{20c6b36996df5e4c.css → 2ad2a74b7e07dcc9.css} +1 -1
  29. package/dist/web/appearance.html +2 -2
  30. package/dist/web/appearance.txt +12 -12
  31. package/dist/web/defaults.html +2 -2
  32. package/dist/web/defaults.txt +12 -12
  33. package/dist/web/history.html +2 -2
  34. package/dist/web/history.txt +12 -12
  35. package/dist/web/import.html +2 -2
  36. package/dist/web/import.txt +12 -12
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/index.txt +12 -12
  39. package/dist/web/level1.html +2 -2
  40. package/dist/web/level1.txt +12 -12
  41. package/dist/web/presets.html +3 -3
  42. package/dist/web/presets.txt +12 -12
  43. package/dist/web/sessions/view.html +2 -2
  44. package/dist/web/sessions/view.txt +12 -12
  45. package/dist/web/system.html +2 -2
  46. package/dist/web/system.txt +12 -12
  47. package/package.json +6 -2
  48. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +0 -1
  49. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
  50. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +0 -1
  51. package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +0 -1
  52. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +0 -1
  53. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +0 -1
  54. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +0 -1
  55. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +0 -1
  56. /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_buildManifest.js +0 -0
  57. /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_ssgManifest.js +0 -0
@@ -60,8 +60,83 @@ export function stripCitations(text) {
60
60
  return undefined;
61
61
  return text.replace(/\s*【[^】]*】/g, '').replace(/[ ]+$/gm, '').trim();
62
62
  }
63
+ /*
64
+ * Prose that arrived in another shape.
65
+ *
66
+ * `tried`, `needed`, `notes` and `summary` are sentences for a person to read; nothing in them is
67
+ * run. A chat that writes `tried` as a list of objects — `{"approach": "…", "result": "…"}` — has
68
+ * said exactly what was asked, in a shape the schema did not expect, and refusing the whole reply
69
+ * for it cost a format round each time and, three in a row, the task: it ended "failed" with the
70
+ * work done and its results already recorded. So these fields are read as text whatever shape they
71
+ * come in, and the reading is reported. The fields that are acted on — `status`, `steps` and
72
+ * everything in them — are never coerced: a step the runner would have to guess at is not run.
73
+ */
74
+ const PROSE_KEYS = ['approach', 'what', 'tried', 'attempt', 'action', 'description', 'text', 'summary', 'detail', 'result', 'outcome', 'why', 'reason', 'needed'];
75
+ function proseOf(value) {
76
+ if (typeof value === 'string')
77
+ return value;
78
+ if (value === null || value === undefined)
79
+ return '';
80
+ if (typeof value === 'number' || typeof value === 'boolean')
81
+ return String(value);
82
+ if (Array.isArray(value))
83
+ return value.map(proseOf).filter((x) => x.trim()).join('\n');
84
+ if (typeof value === 'object') {
85
+ const record = value;
86
+ const named = PROSE_KEYS.filter((k) => typeof record[k] === 'string' && record[k].trim()).map((k) => record[k]);
87
+ if (named.length > 0)
88
+ return named.join(' — ');
89
+ const strings = Object.values(record).filter((v) => typeof v === 'string' && v.trim() !== '');
90
+ return strings.length > 0 ? strings.join(' — ') : JSON.stringify(value);
91
+ }
92
+ return String(value);
93
+ }
94
+ export function normaliseProse(value) {
95
+ if (!value || typeof value !== 'object' || Array.isArray(value))
96
+ return { value, coerced: [] };
97
+ const out = { ...value };
98
+ const coerced = [];
99
+ for (const key of ['notes', 'summary', 'needed']) {
100
+ if (key in out && out[key] !== undefined && typeof out[key] !== 'string') {
101
+ out[key] = proseOf(out[key]);
102
+ coerced.push(key);
103
+ }
104
+ }
105
+ if ('tried' in out && out.tried !== undefined) {
106
+ const tried = out.tried;
107
+ if (typeof tried === 'string') {
108
+ out.tried = tried.trim() ? [tried] : [];
109
+ coerced.push('tried');
110
+ }
111
+ else if (Array.isArray(tried) && tried.some((t) => typeof t !== 'string')) {
112
+ out.tried = tried.map(proseOf).filter((t) => t.trim() !== '');
113
+ tried.forEach((t, i) => {
114
+ if (typeof t !== 'string')
115
+ coerced.push(`tried.${i}`);
116
+ });
117
+ }
118
+ }
119
+ return { value: out, coerced };
120
+ }
121
+ /** What each field must look like, said in the message that asks for a reformatted reply. */
122
+ const FIELD_SHAPES = [
123
+ [/^status$/, '"status" is one of "continue", "done", "blocked"'],
124
+ [/^steps(\.|$)/, '"steps" is a list of {"id": 1, "type": "command", "cmd": "the command"} ("shell", "expect", "timeoutSec" optional)'],
125
+ [/^tried(\.|$)/, '"tried" is a list of plain strings, one sentence per approach'],
126
+ [/^deviations(\.|$)/, '"deviations" is a list of {"instruction": "…", "did": "…", "why": "…"} with plain strings'],
127
+ [/^disputed(\.|$)/, '"disputed" is a list of {"finding": "…", "why": "…", "evidence": "…"} with plain strings'],
128
+ [/^(summary|notes|needed)$/, '"summary", "notes" and "needed" are plain strings'],
129
+ ];
130
+ const FORMAT_EXAMPLE = [
131
+ '```json',
132
+ '{"status": "continue", "notes": "what this round does", "steps": [{"id": 1, "type": "command", "cmd": "npm test"}]}',
133
+ '```',
134
+ 'or, to end: {"status": "done", "summary": "several sentences on what was done and what it shows"}',
135
+ 'or {"status": "blocked", "summary": "…", "tried": ["first approach", "second approach"], "needed": "what would unblock it"}',
136
+ ].join('\n');
63
137
  export function parseReply(markdown, opts) {
64
138
  const candidates = findJsonCandidates(markdown);
139
+ const paths = new Set();
65
140
  if (candidates.length === 0) {
66
141
  return {
67
142
  ok: false,
@@ -80,9 +155,12 @@ export function parseReply(markdown, opts) {
80
155
  errors.push(`JSON.parse failed: ${e.message}`);
81
156
  continue;
82
157
  }
83
- const result = ReplySchema.safeParse(value);
158
+ const prose = normaliseProse(value);
159
+ const result = ReplySchema.safeParse(prose.value);
84
160
  if (!result.success) {
85
161
  errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
162
+ for (const i of result.error.issues)
163
+ paths.add(i.path.join('.') || '(root)');
86
164
  continue;
87
165
  }
88
166
  const reply = result.data;
@@ -106,6 +184,7 @@ export function parseReply(markdown, opts) {
106
184
  done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
107
185
  blocked,
108
186
  json: text.trim(),
187
+ ...(prose.coerced.length > 0 ? { coerced: prose.coerced } : {}),
109
188
  };
110
189
  }
111
190
  }
@@ -113,6 +192,7 @@ export function parseReply(markdown, opts) {
113
192
  ok: false,
114
193
  reason: 'invalid-json',
115
194
  detail: errors.slice(0, 4).join(' | '),
195
+ ...(paths.size > 0 ? { paths: [...paths] } : {}),
116
196
  };
117
197
  }
118
198
  /**
@@ -162,15 +242,28 @@ export function parseReview(markdown, opts) {
162
242
  return { ok: false, reason: 'invalid-json', detail: errors.slice(0, 4).join(' | ') };
163
243
  }
164
244
  /** The message sent back when a reply did not validate. Names the problem, nothing else. */
245
+ /**
246
+ * The message that asks for the same answer again, in the format.
247
+ *
248
+ * It names each rejected field with the shape it must have, gives a valid example, and says what
249
+ * did not happen: nothing from the rejected reply was run, so the results of the steps before it
250
+ * stand and the answer only needs reformatting. Without that last part a chat told "invalid" tends
251
+ * to start the round again, or to keep the same shape and change the wording, and the rounds run out.
252
+ */
165
253
  export function formatErrorMessage(fail, attempt, maxAttempts) {
254
+ const shapes = [...new Set((fail.paths ?? []).flatMap((p) => FIELD_SHAPES.filter(([re]) => re.test(p)).map(([, s]) => s)))];
166
255
  return [
167
- `Your last reply did not match the required format (attempt ${attempt} of ${maxAttempts}).`,
256
+ `Your last reply did not match the required format (format retry ${attempt} of ${maxAttempts}).`,
168
257
  fail.reason === 'no-json-block'
169
258
  ? 'There was no fenced json code block in it.'
170
259
  : `The json block did not validate: ${fail.detail}`,
171
- 'Resend the same answer as exactly one fenced code block tagged json, with the fields',
172
- 'status, steps and notes, and nothing else tagged json in the reply.',
173
- ].join(' ');
260
+ shapes.length > 0 ? `What those fields must be: ${shapes.join('; ')}.` : '',
261
+ 'Only the format is wrong. Nothing from that reply was run, and the results of the steps before it stand.',
262
+ 'Send the same answer again, reformatted, as exactly one fenced code block tagged json — do not start over and do not change what it asks for. For example:',
263
+ FORMAT_EXAMPLE,
264
+ ]
265
+ .filter(Boolean)
266
+ .join('\n');
174
267
  }
175
268
  /**
176
269
  * Spots a command that arrived damaged rather than badly written.
@@ -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
  /**
@@ -399,6 +399,7 @@ async function domainTask(session, task, runsDir) {
399
399
  scope: task.scope ?? [],
400
400
  scopeReverted: task.scopeReverted ?? [],
401
401
  handoff: task.handoff ?? null,
402
+ stopCode: task.stopCode ?? null,
402
403
  prompt: p.prompt,
403
404
  expected: p.expected,
404
405
  level2: task.level2,
@@ -29,7 +29,7 @@ export function composeHandoff(task, notRun = []) {
29
29
  if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
30
30
  manual.push('look at the committed files and commits listed above');
31
31
  return {
32
- outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}) },
32
+ outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
33
33
  changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
34
34
  validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
35
35
  ...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
@@ -42,6 +42,47 @@ export const DEFAULT_MIRROR = {
42
42
  respectGitignore: true,
43
43
  includeEnvFiles: false,
44
44
  };
45
+ /**
46
+ * Applies an edit to a task: the one place the rule about a new intent lives.
47
+ *
48
+ * A task given a new prompt is a new question. The checks the reviews of its earlier attempts
49
+ * added were findings about the old question, and inheriting them gated the new one on work it
50
+ * no longer asks for: a maintenance task rewritten as an audit still had to pass "the build uses
51
+ * the new config", and went blocked on an artificial finding. So a changed prompt drops them, with
52
+ * the reason kept beside each, and they stay on the record. The plan's own checks are not guessed
53
+ * at: whoever writes the new prompt says which still apply (the edit form and the new-prompt
54
+ * dialog both show them), and `checks` in the patch is that answer. A re-run with the same prompt
55
+ * keeps everything, because it is the same question asked again.
56
+ */
57
+ export function applyTaskPatch(t, patch) {
58
+ const newPrompt = patch.prompt !== undefined && patch.prompt.trim() !== '' && patch.prompt.trim() !== t.prompt.trim();
59
+ if (patch.title !== undefined)
60
+ t.title = patch.title.trim() || t.title;
61
+ if (patch.prompt !== undefined && patch.prompt.trim())
62
+ t.prompt = patch.prompt;
63
+ if (patch.level2 !== undefined)
64
+ t.level2 = patch.level2;
65
+ if (patch.vcsPlan !== undefined)
66
+ t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
67
+ if (patch.checks !== undefined)
68
+ t.checks = patch.checks.filter((c) => c.name.trim() !== '');
69
+ if (patch.readOnly !== undefined) {
70
+ if (patch.readOnly)
71
+ t.readOnly = true;
72
+ else
73
+ delete t.readOnly;
74
+ }
75
+ if (patch.scope !== undefined) {
76
+ const scope = patch.scope.map((p) => p.trim()).filter(Boolean);
77
+ if (scope.length > 0)
78
+ t.scope = scope;
79
+ else
80
+ delete t.scope;
81
+ }
82
+ if (newPrompt && t.reviewChecks?.length) {
83
+ t.reviewChecks = t.reviewChecks.map((rc) => rc.state === 'dropped' ? rc : { ...rc, state: 'dropped', droppedBecause: 'the task was given a new prompt; this was a finding about the old one' });
84
+ }
85
+ }
45
86
  export class SessionStore {
46
87
  defaultLevel1Path;
47
88
  dir;
@@ -358,22 +399,14 @@ export class SessionStore {
358
399
  continuing: t.continuing,
359
400
  buildsOn: t.buildsOn,
360
401
  freshRetry: t.freshRetry,
402
+ stopCode: t.stopCode,
361
403
  scope: t.scope,
362
404
  scopeReverted: t.scopeReverted,
363
405
  vcs: t.vcs,
364
406
  },
365
407
  ];
366
408
  // An edit is applied only after the old attempt has been put beyond its reach.
367
- if (patch.title !== undefined)
368
- t.title = patch.title.trim() || t.title;
369
- if (patch.prompt !== undefined && patch.prompt.trim())
370
- t.prompt = patch.prompt;
371
- if (patch.level2 !== undefined)
372
- t.level2 = patch.level2;
373
- if (patch.vcsPlan !== undefined)
374
- t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
375
- if (patch.checks !== undefined)
376
- t.checks = patch.checks.filter((c) => c.name.trim() !== '');
409
+ applyTaskPatch(t, patch);
377
410
  t.attempt = (t.attempt ?? 1) + 1;
378
411
  t.status = 'queued';
379
412
  t.iterations = 0;
@@ -404,6 +437,7 @@ export class SessionStore {
404
437
  t.handoff = undefined;
405
438
  t.scopeReverted = undefined;
406
439
  t.freshRetry = undefined;
440
+ t.stopCode = undefined;
407
441
  // The branch of the finished attempt stays in the repository and stays on the record
408
442
  // above; the next attempt gets its own, cut from the same commit this one started at.
409
443
  t.vcs = undefined;
@@ -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) {
@@ -750,12 +813,29 @@ Current URL: ${url}`);
750
813
  await this.closeMenu();
751
814
  return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
752
815
  }
816
+ /*
817
+ * Already the one in force: close the menu and say so, without clicking.
818
+ *
819
+ * The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
820
+ * reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
821
+ * model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
822
+ * click it used to get did nothing in Copilot: the row already in force ignores it, the menu
823
+ * stayed open over the composer, and the run went on with the model list hanging on screen.
824
+ */
825
+ if (target.selected) {
826
+ await this.closeMenu();
827
+ this.emit('model-already-selected', { model: target.name, buttonShows: before });
828
+ return { ok: true, current: target.name };
829
+ }
753
830
  if (target.disabled) {
754
831
  await this.closeMenu();
755
832
  return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
756
833
  }
757
834
  await this.clickMenuRow(target.name, inSubmenu);
758
835
  await this.p.waitForTimeout(1_000);
836
+ // Whatever the click did, the menu is not left open over the chat: a build that keeps it open
837
+ // after a choice, or a click that did not land, would otherwise leave it for the next message.
838
+ await this.closeMenu();
759
839
  const after = await this.currentModel();
760
840
  // The button shows the choice, so it is the check. It also **shortens** it: picking
761
841
  // "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
@@ -65,12 +65,13 @@ export function inScope(path, scope) {
65
65
  * Puts back every change outside `scope`. Only for a repository whose tree was clean when the task
66
66
  * started, which is why the caller passes the repository only when version control is active.
67
67
  */
68
- export async function enforceScope(dir, scope) {
68
+ export async function enforceScope(dir, scope, nothing = false) {
69
69
  const out = { outside: [], reverted: [], failed: [] };
70
- if (scope.length === 0)
70
+ // An empty scope allows everything; `nothing` is the read-only task, which allows no change at all.
71
+ if (scope.length === 0 && !nothing)
71
72
  return out;
72
73
  const changed = await workingTreePaths(dir);
73
- out.outside = changed.filter((p) => !inScope(p, scope));
74
+ out.outside = nothing ? changed : changed.filter((p) => !inScope(p, scope));
74
75
  const root = resolve(dir);
75
76
  for (const rel of out.outside) {
76
77
  const abs = resolve(join(dir, rel));
@@ -107,13 +108,15 @@ export async function enforceScope(dir, scope) {
107
108
  return out;
108
109
  }
109
110
  /** What the chat is told, in the message that carries the step results. */
110
- export function scopeMessage(check, scope) {
111
+ export function scopeMessage(check, scope, readOnly = false) {
111
112
  const list = (paths) => paths.slice(0, 15).join(', ') + (paths.length > 15 ? `, and ${paths.length - 15} more` : '');
112
113
  const parts = [
113
- `Outside this task's scope, so put back by the runner: ${list(check.reverted)}.`,
114
+ `${readOnly ? 'Changed by a read-only task' : "Outside this task's scope"}, so put back by the runner: ${list(check.reverted)}.`,
114
115
  check.failed.length > 0 ? `Could not be put back: ${check.failed.map((f) => `${f.path} (${f.why})`).join('; ')}.` : '',
115
- `This task may change only: ${scope.join(', ')}. If the work truly needs a file outside that, do not work around it: ` +
116
- 'end with status "blocked", name the file in "needed" and say why.',
116
+ readOnly
117
+ ? 'This task is read-only: it may change no file at all. Read, run and report; put what you found in your summary.'
118
+ : `This task may change only: ${scope.join(', ')}. If the work truly needs a file outside that, do not work around it: ` +
119
+ 'end with status "blocked", name the file in "needed" and say why.',
117
120
  ];
118
121
  return parts.filter(Boolean).join(' ');
119
122
  }
@@ -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
+ }