copilot-operator 0.1.10 → 0.1.12

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 (58) hide show
  1. package/dist/src/api/operator.controller.js +12 -1
  2. package/dist/src/api/operator.service.js +92 -7
  3. package/dist/src/config/schema.js +20 -3
  4. package/dist/src/context/desktopMirror.js +34 -12
  5. package/dist/src/context/projectMirror.js +69 -1
  6. package/dist/src/orchestrator/contract.js +56 -1
  7. package/dist/src/orchestrator/taskRunner.js +160 -13
  8. package/dist/src/plan/brief.js +20 -8
  9. package/dist/src/plan/systemGuide.js +74 -10
  10. package/dist/src/session/compose.js +17 -2
  11. package/dist/src/session/exportRecord.js +8 -1
  12. package/dist/src/session/handoff.js +41 -3
  13. package/dist/src/session/model.js +9 -0
  14. package/dist/src/session/store.js +11 -7
  15. package/dist/src/session/story.js +74 -18
  16. package/dist/src/transport/chatTransport.js +1 -0
  17. package/dist/src/transport/copilotTransport.js +89 -8
  18. package/dist/src/vcs/git.js +15 -0
  19. package/dist/src/vcs/syncCommand.js +105 -0
  20. package/dist/src/vcs/taskVcs.js +29 -6
  21. package/dist/web/404.html +2 -2
  22. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
  23. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
  24. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
  25. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +1 -0
  26. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
  27. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +1 -0
  28. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
  29. package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +1 -0
  30. package/dist/web/_next/static/css/{20c6b36996df5e4c.css → a0642fdb8adf9edb.css} +1 -1
  31. package/dist/web/appearance.html +2 -2
  32. package/dist/web/appearance.txt +12 -12
  33. package/dist/web/defaults.html +2 -2
  34. package/dist/web/defaults.txt +12 -12
  35. package/dist/web/history.html +2 -2
  36. package/dist/web/history.txt +12 -12
  37. package/dist/web/import.html +2 -2
  38. package/dist/web/import.txt +12 -12
  39. package/dist/web/index.html +2 -2
  40. package/dist/web/index.txt +12 -12
  41. package/dist/web/level1.html +2 -2
  42. package/dist/web/level1.txt +12 -12
  43. package/dist/web/presets.html +2 -2
  44. package/dist/web/presets.txt +12 -12
  45. package/dist/web/sessions/view.html +2 -2
  46. package/dist/web/sessions/view.txt +12 -12
  47. package/dist/web/system.html +2 -2
  48. package/dist/web/system.txt +12 -12
  49. package/package.json +7 -3
  50. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
  51. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
  52. package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +0 -1
  53. package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +0 -1
  54. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
  55. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
  56. package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +0 -1
  57. /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → bcBE4tY_aNh6kzLzN1luP}/_buildManifest.js +0 -0
  58. /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → bcBE4tY_aNh6kzLzN1luP}/_ssgManifest.js +0 -0
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The end of a task in one fixed shape, put together by the runner from what it recorded.
3
+ *
4
+ * The chat's summary is the model's account, in its words and in whatever order it chose. The
5
+ * operator reading a finished run needs the same eight answers every time — what came of it, which
6
+ * files, what was checked, what is still wrong, where the evidence is, where the code is, what is
7
+ * left for a person to do, and what was never run — and until now had to piece them together from
8
+ * the summary, the check list, the review panel, the branch line and the iteration reports. This
9
+ * reads them off the task record instead, so every entry is a fact the runner saw rather than a
10
+ * claim, and nothing here is written by the model.
11
+ */
12
+ import { isContinuable } from './model.js';
1
13
  export function composeHandoff(task, notRun = []) {
2
14
  const failedChecks = (task.checkResults ?? []).filter((c) => !c.passed);
3
15
  const openFindings = task.review?.verdict === 'fail' ? (task.review.findings ?? []).filter((f) => f.about !== 'task') : [];
@@ -18,10 +30,10 @@ export function composeHandoff(task, notRun = []) {
18
30
  manual.push(`version control: ${vcs.problem}`);
19
31
  if (vcs.commit && vcs.branch)
20
32
  manual.push(`push ${vcs.branch} when you are ready — the runner never pushes`);
33
+ if (isContinuable(task))
34
+ manual.push('continue it in the same chat from the register, or run it again');
21
35
  if (task.status === 'blocked')
22
36
  manual.push(`unblock it: ${task.reason ?? 'see the reason'}`);
23
- if (task.status === 'limit-reached' || task.status === 'aborted')
24
- manual.push('continue it in the same chat from the register, or run it again');
25
37
  if (task.status === 'failed')
26
38
  manual.push('read why it failed, fix the prompt or the checks, and queue it again');
27
39
  if (openFindings.length > 0)
@@ -32,7 +44,33 @@ export function composeHandoff(task, notRun = []) {
32
44
  outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
33
45
  changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
34
46
  validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
35
- ...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
47
+ ...(task.review
48
+ ? { review: { verdict: task.review.verdict, open: openFindings.length, ...(task.review.skippedBecause ? { skippedBecause: task.review.skippedBecause } : {}) } }
49
+ : {}),
50
+ ...(vcs.branch || vcs.problem
51
+ ? {
52
+ lifecycle: {
53
+ ...(vcs.beforeCommit ? { preCommitState: { changed: vcs.beforeCommit.changed } } : {}),
54
+ checksResult: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
55
+ commitResult: {
56
+ ...(vcs.branch ? { branch: vcs.branch } : {}),
57
+ ...(vcs.commit ? { commit: vcs.commit } : {}),
58
+ files: vcs.files?.length ?? 0,
59
+ ...(vcs.problem ? { problem: vcs.problem } : {}),
60
+ },
61
+ ...(vcs.afterCommit
62
+ ? {
63
+ postCommitState: {
64
+ ...(vcs.afterCommit.branch ? { branch: vcs.afterCommit.branch } : {}),
65
+ ...(vcs.afterCommit.head ? { head: vcs.afterCommit.head } : {}),
66
+ clean: vcs.afterCommit.clean,
67
+ uncommitted: vcs.afterCommit.changed,
68
+ },
69
+ }
70
+ : {}),
71
+ },
72
+ }
73
+ : {}),
36
74
  knownIssues,
37
75
  evidence: { ...(task.runId ? { runId: task.runId } : {}), checks: task.checkResults?.length ?? 0, reviewRounds: task.review?.rounds ?? 0 },
38
76
  vcs: { ...(vcs.branch ? { branch: vcs.branch } : {}), ...(vcs.commit ? { commit: vcs.commit } : {}), pushed: false, ...(vcs.problem ? { problem: vcs.problem } : {}) },
@@ -12,6 +12,15 @@ export function tidyVcsPlan(plan) {
12
12
  return undefined;
13
13
  return { ...(branch ? { branch } : {}), ...(commitMessage ? { commitMessage } : {}) };
14
14
  }
15
+ /**
16
+ * Whether "Continue" may carry a task on where it stopped, in its chat and on its branch.
17
+ *
18
+ * A task that stopped before it finished qualifies (`limit-reached`, `aborted`), and so does one
19
+ * whose ending came from a limit in the settings whatever its status says. See `TaskLimit`.
20
+ */
21
+ export function isContinuable(t) {
22
+ return t.status === 'limit-reached' || t.status === 'aborted' || (!!t.limit && (t.status === 'failed' || t.status === 'blocked'));
23
+ }
15
24
  export function newId(prefix = '') {
16
25
  const now = new Date();
17
26
  const p = (n) => String(n).padStart(2, '0');
@@ -9,7 +9,7 @@
9
9
  import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises';
10
10
  import { existsSync } from 'node:fs';
11
11
  import { join, resolve } from 'node:path';
12
- import { newId, tidyVcsPlan } from './model.js';
12
+ import { isContinuable, newId, tidyVcsPlan } from './model.js';
13
13
  /** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
14
14
  const ACTIVE_STATUSES = ['running', 'waiting-approval'];
15
15
  /**
@@ -341,22 +341,24 @@ export class SessionStore {
341
341
  *
342
342
  * Two kinds of stop qualify. The runner's limit (`limit-reached`), and a stop that was not the
343
343
  * task's doing (`aborted`): the bot itself stopping under it — power, a shutdown, Ctrl+C, a crash,
344
- * recorded in `interruption` at the next start — or the operator stopping it. A task that failed,
345
- * blocked or was not done has a verdict on its work, and carrying that work on would carry the
346
- * verdict's cause on with it; those are run again.
344
+ * recorded in `interruption` at the next start — or the operator stopping it. A task that failed
345
+ * or blocked has a verdict on its work, and carrying that work on would carry the verdict's cause
346
+ * on with it; those are run again — unless what ended them was a limit from the settings (out of
347
+ * rounds of fixing checks or review findings), which is a counter, not the verdict. See `TaskLimit`.
347
348
  */
348
349
  async continueTask(sessionId, taskId) {
349
350
  const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
350
351
  if (!current)
351
352
  throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
352
- if (current.status !== 'limit-reached' && current.status !== 'aborted') {
353
- throw new Error('Only a task that stopped before it finished can be continued; run this one again instead.');
353
+ if (!isContinuable(current)) {
354
+ throw new Error('Only a task that stopped before it finished, or at a limit from the settings, can be continued; run this one again instead.');
354
355
  }
355
- const how = current.status === 'limit-reached' ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
356
+ const how = current.status === 'limit-reached' || current.limit ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
356
357
  return await this.rerunTask(sessionId, taskId, {}, {
357
358
  fromAttempt: current.attempt ?? 1,
358
359
  stoppedBecause: current.reason,
359
360
  how,
361
+ ...(current.limit ? { limit: current.limit } : {}),
360
362
  ...(current.interruption ? { interruption: current.interruption } : {}),
361
363
  });
362
364
  }
@@ -400,6 +402,7 @@ export class SessionStore {
400
402
  buildsOn: t.buildsOn,
401
403
  freshRetry: t.freshRetry,
402
404
  stopCode: t.stopCode,
405
+ limit: t.limit,
403
406
  scope: t.scope,
404
407
  scopeReverted: t.scopeReverted,
405
408
  vcs: t.vcs,
@@ -438,6 +441,7 @@ export class SessionStore {
438
441
  t.scopeReverted = undefined;
439
442
  t.freshRetry = undefined;
440
443
  t.stopCode = undefined;
444
+ t.limit = undefined;
441
445
  // The branch of the finished attempt stays in the repository and stays on the record
442
446
  // above; the next attempt gets its own, cut from the same commit this one started at.
443
447
  t.vcs = undefined;
@@ -12,9 +12,42 @@
12
12
  * Nothing here is inferred. An entry is a file, or a section of the task log, or a field of
13
13
  * the task record; the reader decides what it means.
14
14
  */
15
- import { readdir, readFile } from 'node:fs/promises';
15
+ import { readFile, readdir, stat } from 'node:fs/promises';
16
16
  import { join } from 'node:path';
17
17
  const MAX_TEXT = 60_000;
18
+ /*
19
+ * When each thing happened.
20
+ *
21
+ * Every entry of the story is a file the runner wrote at the moment it happened: a reply is saved
22
+ * as it arrives, a step's log is opened as the step starts, a results file is written just before
23
+ * it is sent. So a file's creation time is the entry's time, for runs recorded long before anyone
24
+ * asked for times as much as for new ones — nothing had to be recorded differently. The modification
25
+ * time is the fallback on a file system that keeps no creation time. The opening message has no file
26
+ * of its own and takes its time from the transcript's first "message-sent".
27
+ */
28
+ async function createdAt(path) {
29
+ const info = await stat(path).catch(() => null);
30
+ if (!info)
31
+ return undefined;
32
+ const ms = info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs;
33
+ return new Date(ms).toISOString();
34
+ }
35
+ async function firstSentAt(dir) {
36
+ const text = await readFile(join(dir, 'transcript.jsonl'), 'utf8').catch(() => '');
37
+ for (const line of text.split('\n')) {
38
+ if (!line.includes('"message-sent"'))
39
+ continue;
40
+ try {
41
+ const e = JSON.parse(line);
42
+ if (e.type === 'message-sent' && e.at)
43
+ return e.at;
44
+ }
45
+ catch {
46
+ /* a line cut off by a crash */
47
+ }
48
+ }
49
+ return undefined;
50
+ }
18
51
  function clip(text) {
19
52
  return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (${text.length - MAX_TEXT} more characters in the run folder)` : text;
20
53
  }
@@ -73,6 +106,7 @@ async function stepsIn(dir) {
73
106
  const iteration = Number(m[1]);
74
107
  const parsed = parseStepLog(await readFile(join(dir, name), 'utf8').catch(() => ''));
75
108
  const entry = {
109
+ at: await createdAt(join(dir, name)),
76
110
  kind: 'step',
77
111
  iteration,
78
112
  id: Number(m[2]),
@@ -103,21 +137,41 @@ async function reviewRound(dir, round) {
103
137
  const read = async (name) => clip(await readFile(join(dir, name), 'utf8').catch(() => ''));
104
138
  const names = (await readdir(dir).catch(() => [])).sort();
105
139
  if (names.includes('00-brief.md'))
106
- entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md') });
140
+ entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md'), at: await createdAt(join(dir, '00-brief.md')) });
107
141
  const steps = await stepsIn(join(dir, 'steps'));
108
- const replies = names.filter((n) => /^\d+-review\.md$/.test(n)).sort();
109
- for (const [i, name] of replies.entries()) {
110
- const text = await read(name);
111
- const parsed = parseReply(text);
112
- entries.push({ kind: 'reply', iteration: i + 1, label: `review ${round}, reply ${i + 1}`, text, ...parsed });
113
- for (const step of steps.get(i + 1) ?? [])
142
+ /*
143
+ * The replies, by what they answer. `00-opening.md` is the answer to the brief — the one that
144
+ * proposes the first steps — and `NN-review.md` the answer to the results of iteration NN (the
145
+ * review saves it after sending them). So after reply k come the steps of iteration k + 1 and
146
+ * their results, then reply k + 1. The story used to read only the `NN-review` files and put each
147
+ * before the steps it came after; the times on the entries are what showed it.
148
+ */
149
+ const replyFor = new Map();
150
+ if (names.includes('00-opening.md'))
151
+ replyFor.set(0, '00-opening.md');
152
+ for (const n of names) {
153
+ const m = n.match(/^(\d+)-review\.md$/);
154
+ if (m)
155
+ replyFor.set(Number(m[1]), n);
156
+ }
157
+ const last = Math.max(0, ...replyFor.keys(), ...steps.keys());
158
+ for (let k = 0; k <= last; k += 1) {
159
+ const name = replyFor.get(k);
160
+ if (name) {
161
+ const text = await read(name);
162
+ entries.push({ kind: 'reply', iteration: k, label: `review ${round}, reply ${k + 1}`, text, ...parseReply(text), at: await createdAt(join(dir, name)) });
163
+ }
164
+ for (const step of steps.get(k + 1) ?? [])
114
165
  entries.push(step);
115
- if (names.includes(`iteration-${i + 1}.txt`))
116
- entries.push({ kind: 'sent', iteration: i + 1, label: `results ${i + 1}`, text: await read(`iteration-${i + 1}.txt`) });
166
+ if (names.includes(`iteration-${k + 1}.txt`)) {
167
+ entries.push({ kind: 'sent', iteration: k + 1, label: `results ${k + 1}`, text: await read(`iteration-${k + 1}.txt`), at: await createdAt(join(dir, `iteration-${k + 1}.txt`)) });
168
+ }
169
+ }
170
+ const replies = [...replyFor.values()];
171
+ if (names.includes('findings-sent.md')) {
172
+ entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md'), at: await createdAt(join(dir, 'findings-sent.md')) });
117
173
  }
118
- if (names.includes('findings-sent.md'))
119
- entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md') });
120
- return { kind: 'review', round, entries };
174
+ return { kind: 'review', round, entries, at: entries.find((e) => e.at)?.at };
121
175
  }
122
176
  export async function buildStory(runsDir, runId, task, live) {
123
177
  const dir = join(runsDir, runId);
@@ -126,7 +180,9 @@ export async function buildStory(runsDir, runId, task, live) {
126
180
  const entries = [];
127
181
  const opening = sections.find((s) => s.name === 'OPENING MESSAGE');
128
182
  if (opening)
129
- entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text) });
183
+ entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text), at: (await firstSentAt(dir)) ?? task.startedAt });
184
+ /** The results file of iteration k, as the report writer names it by default; its creation is when it was sent. */
185
+ const resultsAt = async (k) => await createdAt(join(dir, 'reports', `iteration-${k}.txt`));
130
186
  const steps = await stepsIn(join(dir, 'steps'));
131
187
  const reports = new Map();
132
188
  for (const s of sections) {
@@ -161,7 +217,7 @@ export async function buildStory(runsDir, runId, task, live) {
161
217
  for (const step of steps.get(k) ?? [])
162
218
  entries.push(step);
163
219
  if (reports.has(k))
164
- entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
220
+ entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)), at: await resultsAt(k) });
165
221
  lastIteration = k;
166
222
  }
167
223
  else if (rv) {
@@ -170,14 +226,14 @@ export async function buildStory(runsDir, runId, task, live) {
170
226
  for (const step of steps.get(lastIteration + 1) ?? [])
171
227
  entries.push(step);
172
228
  if (reports.has(lastIteration + 1))
173
- entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)) });
229
+ entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)), at: await resultsAt(lastIteration + 1) });
174
230
  lastIteration += reports.has(lastIteration + 1) ? 1 : 0;
175
231
  if (!placedReviews.has(round)) {
176
232
  entries.push(await reviewRound(join(dir, 'review', String(round)), round));
177
233
  placedReviews.add(round);
178
234
  }
179
235
  }
180
- entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed });
236
+ entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed, at: await createdAt(join(dir, 'replies', name)) });
181
237
  }
182
238
  // Steps and results after the last reply (a task that ended without another reply), and
183
239
  // review rounds nobody replied to yet (one still going, or the last one that passed).
@@ -186,7 +242,7 @@ export async function buildStory(runsDir, runId, task, live) {
186
242
  for (const step of list)
187
243
  entries.push(step);
188
244
  if (reports.has(k))
189
- entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
245
+ entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)), at: await resultsAt(k) });
190
246
  }
191
247
  }
192
248
  for (const round of reviewDirs) {
@@ -20,6 +20,7 @@
20
20
  * running in this process — a check script — can call `setTransportFactory`.
21
21
  */
22
22
  import { CopilotTransport } from './copilotTransport.js';
23
+ export { ReplyTimeoutError, isReplyTimeout } from './copilotTransport.js';
23
24
  const realChat = (opts) => new CopilotTransport(opts);
24
25
  let factory = realChat;
25
26
  /** A chat for these options: the real Copilot window, unless a check has put a scripted one in. */
@@ -18,6 +18,25 @@ import { landed } from './acceptance.js';
18
18
  import { Blocker, Css, Label, Model, Rename, Sidebar, Signal, Surface, TestId, Upload, Url } from './locators.js';
19
19
  import { acquireProfileLock } from './profileLock.js';
20
20
  import { parseChatId } from './chatSession.js';
21
+ /**
22
+ * The chat did not finish a reply within `copilot.replyTimeoutSec`.
23
+ *
24
+ * Its own class because it is a limit from the settings, not a fault: the runner ends the task
25
+ * `limit-reached` on it, so "Continue" carries the task on in the same chat, where the reply may
26
+ * well have finished by then. Any other error from the transport still fails the task.
27
+ */
28
+ export class ReplyTimeoutError extends Error {
29
+ seconds;
30
+ constructor(seconds, message) {
31
+ super(message);
32
+ this.seconds = seconds;
33
+ this.name = 'ReplyTimeoutError';
34
+ }
35
+ }
36
+ /** Recognised by name as well: a copy of this module loaded twice makes `instanceof` miss its own class. */
37
+ export function isReplyTimeout(e) {
38
+ return e instanceof ReplyTimeoutError || (e instanceof Error && e.name === 'ReplyTimeoutError' && typeof e.seconds === 'number');
39
+ }
21
40
  const ORIGIN = 'https://m365.cloud.microsoft';
22
41
  /** Escapes a model name so it can be matched literally inside a regular expression. */
23
42
  function escapeForRegExp(text) {
@@ -680,14 +699,77 @@ Current URL: ${url}`);
680
699
  }
681
700
  await row.click({ timeout: 8_000 }).catch(() => undefined);
682
701
  }
683
- /** Closes the picker, submenu included. */
702
+ /**
703
+ * Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
704
+ * Both, because either alone has been wrong: rows can be mid-animation after the menu closed, and a
705
+ * button re-rendered by the page can lose the attribute while the menu is still up.
706
+ */
707
+ async modelMenuOpen() {
708
+ const button = await this.resolveModelButton(1_000);
709
+ const expanded = button ? await button.getAttribute('aria-expanded').catch(() => null) : null;
710
+ if (expanded === 'true')
711
+ return true;
712
+ return (await this.readMenuRows()).length > 0;
713
+ }
714
+ /**
715
+ * Closes the picker, submenu included, and makes sure it is closed.
716
+ *
717
+ * Escape pressed on the page was all this did, three times, and then it gave up without a word.
718
+ * Live, that left the model list open over the chat after a check of the model: Escape goes to
719
+ * whatever has focus, and after a click dispatched on a row, or no click at all because the model
720
+ * was already chosen, focus was not in the menu, so nothing heard it. Each way a person would close
721
+ * it is tried in turn, and the menu is looked at after each: Escape; Escape sent to the menu itself;
722
+ * the picker button again (it toggles); a click outside the menu, in the composer, which a Fluent
723
+ * popup takes as "close". A menu still open after all of them is reported, not passed over.
724
+ */
684
725
  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)
726
+ const ways = [
727
+ ['escape', async () => await this.p.keyboard.press('Escape')],
728
+ [
729
+ 'escape in the menu',
730
+ async () => {
731
+ for (const selector of Model.popupSelectors.slice(0, 2)) {
732
+ const menu = this.p.locator(`${selector}:visible`).last();
733
+ if ((await menu.count()) > 0) {
734
+ await menu.press('Escape', { timeout: 2_000 });
735
+ return;
736
+ }
737
+ }
738
+ },
739
+ ],
740
+ [
741
+ 'the picker button',
742
+ async () => {
743
+ const button = await this.resolveModelButton(1_000);
744
+ // Only while the button itself says the menu is open: clicking it otherwise would open it.
745
+ if (button && (await button.getAttribute('aria-expanded').catch(() => null)) === 'true')
746
+ await button.click({ timeout: 3_000 });
747
+ },
748
+ ],
749
+ [
750
+ 'a click outside',
751
+ async () => {
752
+ const composer = this.composer();
753
+ if ((await composer.count().catch(() => 0)) > 0 && (await composer.isVisible().catch(() => false)))
754
+ await composer.click({ timeout: 3_000 });
755
+ else
756
+ await this.p.mouse.click(4, 4);
757
+ },
758
+ ],
759
+ ];
760
+ for (const [how, act] of ways) {
761
+ if (!(await this.modelMenuOpen()))
689
762
  return;
763
+ await act().catch(() => undefined);
764
+ await this.p.waitForTimeout(250);
765
+ if (!(await this.modelMenuOpen())) {
766
+ if (how !== 'escape')
767
+ this.emit('model-menu-closed', { how });
768
+ return;
769
+ }
690
770
  }
771
+ if (await this.modelMenuOpen())
772
+ this.emit('model-menu-stuck', { tried: ways.map(([how]) => how) });
691
773
  }
692
774
  /** The first popup container that becomes visible after the picker is clicked. */
693
775
  async waitForPopup(timeoutMs = 8_000) {
@@ -772,8 +854,7 @@ Current URL: ${url}`);
772
854
  await this.p.waitForTimeout(1_000);
773
855
  // Whatever the click did, the menu is not left open over the chat: a build that keeps it open
774
856
  // 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();
857
+ await this.closeMenu();
777
858
  const after = await this.currentModel();
778
859
  // The button shows the choice, so it is the check. It also **shortens** it: picking
779
860
  // "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
@@ -1057,7 +1138,7 @@ Current URL: ${url}`);
1057
1138
  }
1058
1139
  if (!sawNewTurn || !last.finished) {
1059
1140
  await this.dumpFailure(join(this.opts.transportDir, '..', 'failures'), 'reply-timeout').catch(() => undefined);
1060
- throw new Error(`Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
1141
+ throw new ReplyTimeoutError(Math.round(this.opts.replyTimeoutMs / 1000), `Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
1061
1142
  `(new turn seen: ${sawNewTurn}, still streaming: ${last.streaming}). ` +
1062
1143
  'A screenshot and an HTML dump are in the run folder.');
1063
1144
  }
@@ -155,6 +155,21 @@ export function branchNameFrom(parts, prefix = 'cop/') {
155
155
  */
156
156
  export function plannedBranchName(raw, prefix = 'cop/', attempt = 1) {
157
157
  const trimmed = raw.trim();
158
+ /*
159
+ * A name with its own namespace — `recovery/apz-migration`, `feature/JIRA-123-schemas` — is the
160
+ * team's convention, and it is used as written: the prefix is only for names that have none.
161
+ * Flattening it to `cop/recovery-apz-migration` made a different branch from the one asked for,
162
+ * so a plan continuing a recovery branch worked beside it instead of on it (2026-09-30). Each
163
+ * segment is still made safe, keeping its case; git itself has the last word in `switchTo`.
164
+ */
165
+ if (trimmed.includes('/')) {
166
+ const segments = trimmed
167
+ .split('/')
168
+ .map((seg) => seg.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[-.]+|[-.]+$/g, ''))
169
+ .filter(Boolean);
170
+ if (segments.length > 1)
171
+ return `${segments.join('/')}${attempt > 1 ? `-a${attempt}` : ''}`.replace(/\.\.+/g, '.').slice(0, 200);
172
+ }
158
173
  const p = prefix.trim();
159
174
  const body = p && trimmed.toLowerCase().startsWith(p.toLowerCase()) ? trimmed.slice(p.length) : trimmed;
160
175
  return branchNameFrom([body, attempt > 1 ? `a${attempt}` : undefined], prefix);
@@ -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
+ }
@@ -282,6 +282,15 @@ export function noteFor(repoDir, vcs, ctx = { mode: 'per-task', earlier: [] }) {
282
282
  `The runner has already put ${repoDir} on the branch \`${vcs.branch}\`, created for this task from ${base},`,
283
283
  'and it will commit whatever you change when the task finishes.',
284
284
  '',
285
+ /*
286
+ * Said outright because a plan's text may still name the branch the work came from: a task that
287
+ * was told to continue a recovery branch went on asking for it, on a branch the runner had made
288
+ * (2026-09-30). The branch above is where the work goes; what it must contain is a commit.
289
+ */
290
+ `\`${vcs.branch}\` is where this work goes, whatever branch the task or the project instructions name: do not ask for`,
291
+ 'another branch, switch to it or check that you are on it. When earlier work must be included, check its',
292
+ 'commit instead: `git merge-base --is-ancestor <commit> HEAD` exits 0 when it is.',
293
+ '',
285
294
  ...(began ? [began, ''] : []),
286
295
  standing,
287
296
  '',
@@ -320,19 +329,33 @@ export async function commitTaskResult(session, task, outcome, bus) {
320
329
  * (they are already there; refusing would not remove them) and named on the task.
321
330
  */
322
331
  const state = await repoState(dir);
332
+ /*
333
+ * The repository before the commit and after it, kept apart on the record. A task's own claims
334
+ * ("nothing committed, the tree is dirty") were written before this runs; without both states
335
+ * the export read as contradicting itself. See `TaskVcs.beforeCommit`.
336
+ */
337
+ const beforeCommit = { changed: state.changed.slice(0, 200) };
338
+ const withStates = async (v) => {
339
+ const after = await repoState(dir).catch(() => null);
340
+ return {
341
+ ...v,
342
+ beforeCommit,
343
+ ...(after ? { afterCommit: { branch: after.branch ?? undefined, head: after.head ?? undefined, clean: !after.dirty, changed: after.changed.slice(0, 200) } } : {}),
344
+ };
345
+ };
323
346
  if (state.branch !== current.branch) {
324
347
  const problem = `the repository is on ${state.branch ?? 'a detached HEAD'}, not on the task's branch ${current.branch}: it was moved while the task ran. ` +
325
348
  'Nothing was committed; the changes are left in the working tree for you to look at.';
326
349
  bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-provenance', level: 'error', message: problem,
327
350
  data: { expected: current.branch, actual: state.branch } });
328
- return { ...current, problem };
351
+ return await withStates({ ...current, problem });
329
352
  }
330
353
  if (current.baseCommit && state.head && !(await isAncestor(dir, current.baseCommit))) {
331
354
  const problem = `the task's branch ${current.branch} no longer contains the commit it started from (${current.baseCommit.slice(0, 8)}): ` +
332
355
  'it was reset or rewritten while the task ran. Nothing was committed; the changes are left in the working tree.';
333
356
  bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-provenance', level: 'error', message: problem,
334
357
  data: { base: current.baseCommit, head: state.head } });
335
- return { ...current, problem };
358
+ return await withStates({ ...current, problem });
336
359
  }
337
360
  const foreign = current.baseCommit ? await foreignCommits(dir, current.baseCommit) : [];
338
361
  if (foreign.length > 0) {
@@ -345,12 +368,12 @@ export async function commitTaskResult(session, task, outcome, bus) {
345
368
  if (result.problem) {
346
369
  bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-problem', level: 'warn',
347
370
  message: `nothing was committed: ${result.problem}` });
348
- return { ...current, problem: result.problem };
371
+ return await withStates({ ...current, problem: result.problem });
349
372
  }
350
373
  if (!result.committed) {
351
374
  bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-nothing', level: 'info',
352
375
  message: `nothing to commit on ${current.branch}: the task changed no files` });
353
- return current;
376
+ return await withStates(current);
354
377
  }
355
378
  const commits = current.baseCommit ? await commitsBetween(dir, current.baseCommit) : [];
356
379
  const files = result.commit ? await commitFiles(dir, result.commit) : [];
@@ -367,14 +390,14 @@ export async function commitTaskResult(session, task, outcome, bus) {
367
390
  suspicious.map((s) => `${s.path} (${s.reason})`).join('; '),
368
391
  data: { commit: result.commit, suspicious } });
369
392
  }
370
- return {
393
+ return await withStates({
371
394
  ...current,
372
395
  commit: result.commit,
373
396
  commits,
374
397
  files,
375
398
  ...(suspicious.length > 0 ? { suspicious } : {}),
376
399
  ...(foreign.length > 0 ? { foreignCommits: foreign } : {}),
377
- };
400
+ });
378
401
  }
379
402
  /**
380
403
  * Commits what a task left behind when the process running it ended before it could.