copilot-operator 0.1.23 → 0.1.25

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 (66) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +326 -33
  3. package/dist/src/exec/confinement.js +85 -7
  4. package/dist/src/exec/programs.js +12 -3
  5. package/dist/src/exec/reportFile.js +5 -3
  6. package/dist/src/exec/shells.js +13 -2
  7. package/dist/src/log/runLog.js +6 -1
  8. package/dist/src/orchestrator/contract.js +29 -7
  9. package/dist/src/orchestrator/review.js +16 -3
  10. package/dist/src/orchestrator/taskRunner.js +391 -54
  11. package/dist/src/plan/schema.js +11 -1
  12. package/dist/src/plan/systemGuide.js +12 -4
  13. package/dist/src/protocol/parser.js +33 -5
  14. package/dist/src/protocol/reporter.js +2 -0
  15. package/dist/src/session/compose.js +18 -9
  16. package/dist/src/session/exportRecord.js +12 -3
  17. package/dist/src/session/exports.js +46 -9
  18. package/dist/src/session/handoff.js +5 -2
  19. package/dist/src/session/interruption.js +3 -1
  20. package/dist/src/session/runLog.js +42 -1
  21. package/dist/src/session/runName.js +3 -1
  22. package/dist/src/session/store.js +7 -2
  23. package/dist/src/transport/copilotTransport.js +89 -62
  24. package/dist/src/transport/modelMatch.js +37 -0
  25. package/dist/src/vcs/artifacts.js +20 -7
  26. package/dist/src/vcs/commitHygiene.js +7 -2
  27. package/dist/src/vcs/contentIntegrity.js +65 -6
  28. package/dist/src/vcs/git.js +42 -7
  29. package/dist/src/vcs/prepareFromRemote.js +21 -14
  30. package/dist/src/vcs/remoteAuth.js +31 -0
  31. package/dist/src/vcs/runPreflight.js +100 -31
  32. package/dist/src/vcs/scope.js +18 -2
  33. package/dist/src/vcs/snapshot.js +61 -16
  34. package/dist/src/vcs/syncCommand.js +3 -11
  35. package/dist/src/vcs/taskVcs.js +173 -24
  36. package/dist/web/404.html +2 -2
  37. package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
  38. package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
  39. package/dist/web/_next/static/chunks/app/defaults/{page-0e778dafc69afbd3.js → page-62c8fbe84baa436a.js} +1 -1
  40. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
  41. package/dist/web/_next/static/chunks/app/sessions/view/{page-9b66b729748595bb.js → page-2d3bbc68b27fed2c.js} +1 -1
  42. package/dist/web/appearance.html +2 -2
  43. package/dist/web/appearance.txt +10 -10
  44. package/dist/web/defaults.html +2 -2
  45. package/dist/web/defaults.txt +10 -10
  46. package/dist/web/history.html +2 -2
  47. package/dist/web/history.txt +10 -10
  48. package/dist/web/import.html +2 -2
  49. package/dist/web/import.txt +10 -10
  50. package/dist/web/index.html +2 -2
  51. package/dist/web/index.txt +10 -10
  52. package/dist/web/level1.html +2 -2
  53. package/dist/web/level1.txt +10 -10
  54. package/dist/web/presets.html +2 -2
  55. package/dist/web/presets.txt +10 -10
  56. package/dist/web/sessions/view.html +2 -2
  57. package/dist/web/sessions/view.txt +10 -10
  58. package/dist/web/system.html +2 -2
  59. package/dist/web/system.txt +10 -10
  60. package/package.json +3 -2
  61. package/prompts/level1.md +10 -3
  62. package/dist/web/_next/static/chunks/549-df7a4876917615da.js +0 -1
  63. package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +0 -1
  64. package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
  65. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_buildManifest.js +0 -0
  66. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_ssgManifest.js +0 -0
@@ -10,6 +10,7 @@
10
10
  * 2. Enter does not submit the composer. The Send button has to be clicked.
11
11
  * 3. A message cannot consist of an attachment alone; Send stays disabled without text.
12
12
  */
13
+ import { pageModelFor } from './modelMatch.js';
13
14
  import { chromium } from 'playwright';
14
15
  import { mkdir, writeFile } from 'node:fs/promises';
15
16
  import { join } from 'node:path';
@@ -76,65 +77,65 @@ export class SendRejectedError extends Error {
76
77
  * impossible — the runner notes it before the click and waits for it to move, so a copy that
77
78
  * did not happen reads as a failure rather than as the previous answer.
78
79
  */
79
- export const CLIPBOARD_GUARD = `(() => {
80
- const state = { text: '', seq: 0 };
81
- try {
82
- Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
83
- } catch (e) {
84
- window.__copClipboard = state;
85
- }
86
-
87
- const remember = (text) => {
88
- if (typeof text !== 'string' || text.length === 0) return;
89
- state.text = text;
90
- state.seq += 1;
91
- };
92
-
93
- const clip = navigator.clipboard;
94
- if (clip) {
95
- try {
96
- Object.defineProperty(clip, 'writeText', {
97
- configurable: true,
98
- writable: true,
99
- value: (text) => {
100
- remember(String(text));
101
- return Promise.resolve();
102
- },
103
- });
104
- } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
105
-
106
- try {
107
- Object.defineProperty(clip, 'write', {
108
- configurable: true,
109
- writable: true,
110
- value: async (items) => {
111
- for (const item of items || []) {
112
- try {
113
- if (item && item.types && item.types.indexOf('text/plain') >= 0) {
114
- remember(await (await item.getType('text/plain')).text());
115
- }
116
- } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
117
- }
118
- },
119
- });
120
- } catch (e) { /* as above */ }
121
- }
122
-
123
- // The old way: select text in a hidden field, then execCommand('copy'). The selection is
124
- // exactly the text being copied, so it is taken and the command is never run.
125
- try {
126
- const realExec = document.execCommand.bind(document);
127
- document.execCommand = (command, ...rest) => {
128
- if (String(command).toLowerCase() === 'copy') {
129
- const selected = String(window.getSelection() || '');
130
- if (selected.length > 0) {
131
- remember(selected);
132
- return true;
133
- }
134
- }
135
- return realExec(command, ...rest);
136
- };
137
- } catch (e) { /* as above */ }
80
+ export const CLIPBOARD_GUARD = `(() => {
81
+ const state = { text: '', seq: 0 };
82
+ try {
83
+ Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
84
+ } catch (e) {
85
+ window.__copClipboard = state;
86
+ }
87
+
88
+ const remember = (text) => {
89
+ if (typeof text !== 'string' || text.length === 0) return;
90
+ state.text = text;
91
+ state.seq += 1;
92
+ };
93
+
94
+ const clip = navigator.clipboard;
95
+ if (clip) {
96
+ try {
97
+ Object.defineProperty(clip, 'writeText', {
98
+ configurable: true,
99
+ writable: true,
100
+ value: (text) => {
101
+ remember(String(text));
102
+ return Promise.resolve();
103
+ },
104
+ });
105
+ } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
106
+
107
+ try {
108
+ Object.defineProperty(clip, 'write', {
109
+ configurable: true,
110
+ writable: true,
111
+ value: async (items) => {
112
+ for (const item of items || []) {
113
+ try {
114
+ if (item && item.types && item.types.indexOf('text/plain') >= 0) {
115
+ remember(await (await item.getType('text/plain')).text());
116
+ }
117
+ } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
118
+ }
119
+ },
120
+ });
121
+ } catch (e) { /* as above */ }
122
+ }
123
+
124
+ // The old way: select text in a hidden field, then execCommand('copy'). The selection is
125
+ // exactly the text being copied, so it is taken and the command is never run.
126
+ try {
127
+ const realExec = document.execCommand.bind(document);
128
+ document.execCommand = (command, ...rest) => {
129
+ if (String(command).toLowerCase() === 'copy') {
130
+ const selected = String(window.getSelection() || '');
131
+ if (selected.length > 0) {
132
+ remember(selected);
133
+ return true;
134
+ }
135
+ }
136
+ return realExec(command, ...rest);
137
+ };
138
+ } catch (e) { /* as above */ }
138
139
  })();`;
139
140
  export class CopilotTransport {
140
141
  opts;
@@ -290,7 +291,7 @@ export class CopilotTransport {
290
291
  'sign-in': 'The profile is signed out. Run "cop login".',
291
292
  unknown: 'The page is not the Microsoft 365 Copilot chat.',
292
293
  };
293
- throw new Error(`${explain[where] ?? explain.unknown}
294
+ throw new Error(`${explain[where] ?? explain.unknown}
294
295
  Current URL: ${url}`);
295
296
  }
296
297
  /**
@@ -446,8 +447,15 @@ Current URL: ${url}`);
446
447
  */
447
448
  async nameChat(chatId, name) {
448
449
  const row = this.p.locator(`${Sidebar.conversationLink}[href*="${chatId}"]`).first();
449
- if ((await row.count()) === 0)
450
+ // Waited for, like everything below: a new chat's row appears in the sidebar a moment after the
451
+ // reply, and `count()` read it as missing and returned without a word (live run 2026-10-03).
452
+ try {
453
+ await row.waitFor({ state: 'attached', timeout: 15_000 });
454
+ }
455
+ catch {
456
+ this.emit('chat-name-failed', { chatId, name, error: 'the conversation did not appear in the sidebar within 15 s' });
450
457
  return false;
458
+ }
451
459
  /*
452
460
  * Every step here waits for what it is about to click.
453
461
  *
@@ -868,7 +876,26 @@ Current URL: ${url}`);
868
876
  }
869
877
  if (!target) {
870
878
  await this.closeMenu();
871
- return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
879
+ /*
880
+ * Not under that name: the line-up is read from the page, and the same model under its new name is
881
+ * chosen when exactly one fits (see `modelMatch.ts`). The list read goes back to the caller as well,
882
+ * so the saved catalogue follows the page (live run 2026-10-04: "GPT 5.6 Think deeper" had become
883
+ * "GPT 5.6 Sol Think deeper", and every run went on the default).
884
+ */
885
+ const read = await this.listModels().catch(() => null);
886
+ const options = read?.options ?? [];
887
+ const match = pageModelFor(name, options);
888
+ if (match && match.name.toLowerCase() !== name.toLowerCase()) {
889
+ const again = await this.selectModel(match.name);
890
+ return { ...again, matched: match.name, options };
891
+ }
892
+ const offered = options.filter((o) => !o.disabled).map((o) => o.name);
893
+ return {
894
+ ok: false,
895
+ current: before,
896
+ reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}.`,
897
+ ...(options.length > 0 ? { options } : {}),
898
+ };
872
899
  }
873
900
  /*
874
901
  * Already the one in force: close the menu and say so, without clicking.
@@ -0,0 +1,37 @@
1
+ const words = (name) => name.toLowerCase().split(/\s+/).filter(Boolean);
2
+ const isVersion = (w) => /^v?\d+(?:\.\d+)*$/.test(w);
3
+ const versionOf = (ws) => (ws.find(isVersion) ?? '').replace(/^v/, '').split('.').map(Number).filter((n) => !Number.isNaN(n));
4
+ function newer(a, b) {
5
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
6
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
7
+ if (d !== 0)
8
+ return d;
9
+ }
10
+ return 0;
11
+ }
12
+ export function pageModelFor(wanted, options) {
13
+ const offered = options.filter((o) => !o.disabled && o.name.trim());
14
+ const exact = offered.find((o) => o.name.trim().toLowerCase() === wanted.trim().toLowerCase());
15
+ if (exact)
16
+ return exact;
17
+ const want = words(wanted);
18
+ if (want.length === 0)
19
+ return null;
20
+ /** The options whose words hold all of `need`, same first word, fewest words added; null when two tie. */
21
+ const closest = (need, strip) => {
22
+ const fits = offered
23
+ .map((o) => ({ o, ws: strip(words(o.name)) }))
24
+ .filter(({ ws }) => ws[0] === need[0] && need.every((w) => ws.includes(w)))
25
+ .map(({ o, ws }) => ({ o, extra: ws.length - need.length }));
26
+ if (fits.length === 0)
27
+ return null;
28
+ const least = Math.min(...fits.map((f) => f.extra));
29
+ const best = fits.filter((f) => f.extra === least);
30
+ if (best.length === 1)
31
+ return best[0].o;
32
+ // The same name in several versions: the newest, which is what the line-up moved on to.
33
+ const byVersion = [...best].sort((a, b) => newer(versionOf(words(b.o.name)), versionOf(words(a.o.name))));
34
+ return newer(versionOf(words(byVersion[0].o.name)), versionOf(words(byVersion[1].o.name))) > 0 ? byVersion[0].o : null;
35
+ };
36
+ return closest(want, (ws) => ws) ?? closest(want.filter((w) => !isVersion(w)), (ws) => ws.filter((w) => !isVersion(w)));
37
+ }
@@ -46,13 +46,19 @@ function excludeLine(pattern) {
46
46
  return `/${pattern.replace(/^\/+/, '')}`;
47
47
  }
48
48
  /**
49
- * Writes the session's artifact patterns into `.git/info/exclude`, once each. Returns the files
50
- * matching them that git already tracks, which an exclude does not stop from being committed.
49
+ * The runner's scratch folder in the project: helper scripts and temporary copies the chat needs while
50
+ * it works, never committed. Found live on 2026-10-03: the write-then-run pattern the contract teaches
51
+ * left `rewrite-report.ps1` and `tmp-report.txt` in the project root, and the task's commit swept them
52
+ * in with the work. The contract now sends such files here, and the folder is excluded from git.
53
+ */
54
+ export const SCRATCH_DIR = '.cop-tmp';
55
+ /**
56
+ * Writes the session's artifact patterns, and the runner's scratch folder, into `.git/info/exclude`,
57
+ * once each. Returns the files matching the patterns that git already tracks, which an exclude does
58
+ * not stop from being committed.
51
59
  */
52
60
  export async function excludeArtifacts(dir, session) {
53
61
  const patterns = artifactPatterns(session.vcs);
54
- if (patterns.length === 0)
55
- return { tracked: [] };
56
62
  const where = await git(dir, ['rev-parse', '--git-path', 'info/exclude']);
57
63
  if (!where.ok || !where.stdout)
58
64
  return { problem: "the repository's exclude file could not be found", tracked: [] };
@@ -61,16 +67,23 @@ export async function excludeArtifacts(dir, session) {
61
67
  const before = await readFile(file, 'utf8').catch(() => '');
62
68
  const have = new Set(before.split(/\r?\n/).map((l) => l.trim()));
63
69
  const add = patterns.map(excludeLine).filter((l) => !have.has(l));
64
- if (add.length > 0) {
70
+ const scratch = `/${SCRATCH_DIR}/`;
71
+ const blocks = [];
72
+ if (!have.has(scratch))
73
+ blocks.push(`# copilot-operator: the runner's scratch folder — helper scripts and temporary files, never committed`, scratch);
74
+ if (add.length > 0)
75
+ blocks.push(`# copilot-operator: artifacts of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}) — kept with the run, never committed`, ...add);
76
+ if (blocks.length > 0) {
65
77
  try {
66
78
  await mkdir(dirname(file), { recursive: true });
67
- const block = [`# copilot-operator: artifacts of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}) — kept with the run, never committed`, ...add, ''].join('\n');
68
- await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
79
+ await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${[...blocks, ''].join('\n')}`, 'utf8');
69
80
  }
70
81
  catch (e) {
71
82
  return { problem: `the exclude file could not be written: ${e.message}`, tracked: [] };
72
83
  }
73
84
  }
85
+ if (patterns.length === 0)
86
+ return { tracked: [] };
74
87
  const tracked = (await git(resolve(top), ['ls-files'])).stdout.split('\n').filter((p) => p && inScope(p, patterns));
75
88
  return { tracked };
76
89
  }
@@ -35,6 +35,8 @@ const GENERATED_DIRS = {
35
35
  '.mypy_cache': 'a type-checker cache',
36
36
  '.venv': 'a Python virtual environment',
37
37
  venv: 'a Python virtual environment',
38
+ tmp: 'a scratch folder (temporary files go in .cop-tmp, which is never committed)',
39
+ temp: 'a scratch folder (temporary files go in .cop-tmp, which is never committed)',
38
40
  };
39
41
  /** File names, or endings, that a tool writes and nobody reads. */
40
42
  const GENERATED_FILES = [
@@ -42,6 +44,8 @@ const GENERATED_FILES = [
42
44
  [(n) => n.endsWith('.log'), 'a log file'],
43
45
  [(n) => n === '.DS_Store' || n === 'Thumbs.db' || n === 'desktop.ini', "the operating system's folder metadata"],
44
46
  [(n) => n.endsWith('.pyc'), 'compiled Python'],
47
+ // The chat's own scratch copies, seen committed in a live run: tmp-report.txt, a .bak of a file it edited.
48
+ [(n) => /^(?:tmp|temp|scratch)(?:[-_.]|$)/i.test(n) || /\.(?:tmp|bak|orig|swp)$/i.test(n), 'a temporary copy (temporary files go in .cop-tmp, which is never committed)'],
45
49
  ];
46
50
  /** `.env` and its variants, except the ones that exist to be committed. */
47
51
  function isSecretsFile(name) {
@@ -96,7 +100,8 @@ export function findSuspicious(paths) {
96
100
  export function suspiciousDetail(found) {
97
101
  const list = found.map((s) => `${s.path} (${s.reason})`).join('; ');
98
102
  return (`${found.length} file(s) that look like tool output or secrets are in the working tree and would be committed ` +
99
- `with this task: ${list}. Add them to .gitignore — they are installed, built or generated, not written — and ` +
100
- 'take them out of the index if git already tracks them. If one of them truly belongs in the repository, leave ' +
103
+ `with this task: ${list}. Add what is installed, built or generated to .gitignore, and take it out of the index if git ` +
104
+ 'already tracks it; delete a temporary copy or scratch folder, or move it into .cop-tmp, which is never committed. ' +
105
+ 'If one of them truly belongs in the repository, leave ' +
101
106
  'it and say why in your summary: it will be committed and marked.');
102
107
  }
@@ -22,6 +22,11 @@ import { readFile, stat } from 'node:fs/promises';
22
22
  import { join } from 'node:path';
23
23
  import { gitBytes } from './git.js';
24
24
  import { looksGenerated } from './commitHygiene.js';
25
+ const endingsOf = (bytes) => {
26
+ const text = bytes.toString('latin1');
27
+ const crlf = (text.match(/\r\n/g) ?? []).length;
28
+ return { crlf, lf: (text.match(/\n/g) ?? []).length - crlf };
29
+ };
25
30
  /** Written files larger than this are almost always generated output. */
26
31
  export const OVERSIZED_BYTES = 1024 * 1024;
27
32
  /** Files that are binary by nature: their bytes are not text and are not looked into. */
@@ -98,8 +103,13 @@ export function traitsOf(path, bytes) {
98
103
  t['control-chars'] = `${control} control character(s)${text.includes('\u001B[') ? ', including terminal colour codes' : ''}`;
99
104
  const crlf = (text.match(/\r\n/g) ?? []).length;
100
105
  const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
101
- if (crlf > 0 && lf > 0)
102
- t['mixed-line-endings'] = `${crlf} CRLF and ${lf} LF line ending(s) in one file`;
106
+ if (crlf > 0 && lf > 0) {
107
+ // The usual cause, said where it is the case: a here-string written with Set-Content/Out-File, which add a CRLF of their own at the end (live run 2026-10-04).
108
+ const lastOnly = crlf === 1 && text.endsWith('\r\n');
109
+ t['mixed-line-endings'] =
110
+ `${crlf} CRLF and ${lf} LF line ending(s) in one file` +
111
+ (lastOnly ? ' — only the last line ends in CRLF, as Set-Content and Out-File add one: write with -NoNewline and end the text with its own newline' : '');
112
+ }
103
113
  for (const [re, what] of SECRETS) {
104
114
  if (re.test(text)) {
105
115
  t.secret = `what looks like ${what}`;
@@ -114,7 +124,7 @@ export function traitsOf(path, bytes) {
114
124
  * A BOM in a new file counts only for formats it breaks; in a changed file, whenever the file had
115
125
  * none. Size counts only when the file crossed the line in this task.
116
126
  */
117
- export function newProblems(path, now, before) {
127
+ export function newProblems(path, now, before, endings = { normalized: false }) {
118
128
  const ext = extensionOf(path);
119
129
  if (BINARY_EXTENSIONS.has(ext))
120
130
  return [];
@@ -126,13 +136,61 @@ export function newProblems(path, now, before) {
126
136
  continue;
127
137
  if (kind === 'bom' && !before && !BOM_BREAKS.has(ext))
128
138
  continue;
139
+ if (kind === 'mixed-line-endings' && endings.normalized)
140
+ continue;
129
141
  out.push({ path, kind, detail });
130
142
  }
143
+ /*
144
+ * A whole file turned from one ending to the other is a change on every line, and a mixed-endings
145
+ * test cannot see it: each version is consistent on its own. Found live on 2026-10-03, a task told to
146
+ * keep a JSON file's formatting rewrote it from LF to CRLF and it was committed that way. Not where git
147
+ * converts endings on commit.
148
+ */
149
+ if (!endings.normalized && !has['mixed-line-endings']) {
150
+ const n = endingsOf(now);
151
+ const only = n.crlf > 0 && n.lf === 0 ? 'crlf' : n.lf > 0 && n.crlf === 0 ? 'lf' : null;
152
+ if (only && before) {
153
+ const b = endingsOf(before);
154
+ const was = b.crlf > 0 && b.lf === 0 ? 'crlf' : b.lf > 0 && b.crlf === 0 ? 'lf' : null;
155
+ if (was && was !== only)
156
+ out.push({ path, kind: 'line-endings-changed', detail: `every line ending changed from ${was.toUpperCase()} to ${only.toUpperCase()}` });
157
+ }
158
+ /*
159
+ * A new file is not held to the repository's style: every file PowerShell's Set-Content writes ends
160
+ * its lines in CRLF, so on a repository without line-ending conversion that cost nearly every task a
161
+ * round. A file changed from one ending to the other is the change that misleads a diff.
162
+ */
163
+ }
131
164
  return out;
132
165
  }
166
+ /** Whether git converts line endings in this repository on commit, and the style its files have. */
167
+ export async function lineEndingRule(dir) {
168
+ const auto = (await gitBytes(dir, ['config', '--get', 'core.autocrlf'])).stdout.toString('utf8').trim().toLowerCase();
169
+ if (auto === 'true' || auto === 'input')
170
+ return { normalized: true };
171
+ const eol = await gitBytes(dir, ['ls-files', '--eol']);
172
+ let lf = 0;
173
+ let crlf = 0;
174
+ for (const line of eol.stdout.toString('utf8').split('\n')) {
175
+ // i/lf w/lf attr/text=auto path
176
+ const m = /^i\/(\S*)\s+w\/\S*\s+attr\/(\S*)/.exec(line);
177
+ if (!m)
178
+ continue;
179
+ if (/(^|\s)(text|eol=)/.test(m[2] ?? '') && !/-text/.test(m[2] ?? ''))
180
+ return { normalized: true };
181
+ if (m[1] === 'lf')
182
+ lf += 1;
183
+ else if (m[1] === 'crlf')
184
+ crlf += 1;
185
+ }
186
+ const total = lf + crlf;
187
+ const style = total >= 3 && lf / total >= 0.8 ? 'lf' : total >= 3 && crlf / total >= 0.8 ? 'crlf' : undefined;
188
+ return { normalized: false, ...(style ? { style } : {}) };
189
+ }
133
190
  /** The problems the task added to these working-tree paths, compared with `baseCommit`. */
134
191
  export async function scanChanges(dir, paths, baseCommit, limit = 400) {
135
192
  const found = [];
193
+ const endings = await lineEndingRule(dir);
136
194
  for (const rel of paths.slice(0, limit)) {
137
195
  if (rel.endsWith('/') || looksGenerated(rel))
138
196
  continue;
@@ -159,7 +217,7 @@ export async function scanChanges(dir, paths, baseCommit, limit = 400) {
159
217
  const shown = await gitBytes(dir, ['show', `${baseCommit}:${rel.replace(/\\/g, '/')}`]);
160
218
  before = shown.ok ? shown.stdout : null;
161
219
  }
162
- found.push(...newProblems(rel, now, before));
220
+ found.push(...newProblems(rel, now, before, endings));
163
221
  }
164
222
  return found;
165
223
  }
@@ -168,7 +226,8 @@ export function integrityDetail(found) {
168
226
  const list = found.slice(0, 20).map((f) => `${f.path}: ${f.detail}`).join('; ');
169
227
  return (`${found.length} problem(s) in the text of files this task changed, none of them there before it: ${list}` +
170
228
  `${found.length > 20 ? `; and ${found.length - 20} more` : ''}. Write the files again as UTF-8 without a byte-order mark ` +
171
- '(in PowerShell 5.1 use [IO.File]::WriteAllText with New-Object Text.UTF8Encoding($false), not Set-Content -Encoding utf8), ' +
172
- 'one kind of line ending per file, no terminal colour codes, and no credentials — load those from the environment. ' +
229
+ '(in PowerShell 5.1: $f = [IO.File]; $f::WriteAllText($path, $text, (New-Object Text.UTF8Encoding($false))) — not Set-Content -Encoding utf8), ' +
230
+ 'the line endings the file and the repository already use (Set-Content adds a Windows line ending after the last line: use -NoNewline and end the here-string with an empty line), ' +
231
+ 'no terminal colour codes, and no credentials — load those from the environment. ' +
173
232
  'If one of these is intended, leave it and say why in your summary: it will be committed and the finding kept on the task.');
174
233
  }
@@ -16,7 +16,7 @@
16
16
  * commit message cannot turn into another command.
17
17
  */
18
18
  import { execFile } from 'node:child_process';
19
- import { existsSync } from 'node:fs';
19
+ import { existsSync, statSync } from 'node:fs';
20
20
  import { readFile, stat } from 'node:fs/promises';
21
21
  import { createHash } from 'node:crypto';
22
22
  import { join } from 'node:path';
@@ -45,20 +45,55 @@ function noHooksDir() {
45
45
  }
46
46
  /** Runs one git command in a directory. Never throws: the caller decides what a failure means. */
47
47
  export function git(cwd, args, timeoutMs = 60_000, env,
48
- /** `windowsHide: false` only for a fetch the operator is sitting at, whose password window must be seen. */
48
+ /**
49
+ * `windowsHide: false` only for a fetch the operator is sitting at, whose password window must be seen.
50
+ * `killTree`: on the timeout, stop git and everything it started. The plain timeout stops git.exe
51
+ * alone, and a fetch's ssh and its password window outlived it (found 2026-10-03: the window stayed
52
+ * on screen after the fetch had been given up).
53
+ */
49
54
  opts = {}) {
50
55
  return new Promise((resolve) => {
51
- execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, windowsHide: opts.windowsHide ?? true, maxBuffer: 8 * 1024 * 1024, ...(env ? { env } : {}) }, (error, stdout, stderr) => {
56
+ let timedOut = false;
57
+ let timer;
58
+ const child = execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: opts.killTree ? 0 : timeoutMs, windowsHide: opts.windowsHide ?? true, maxBuffer: 8 * 1024 * 1024, ...(env ? { env } : {}) }, (error, stdout, stderr) => {
59
+ if (timer)
60
+ clearTimeout(timer);
52
61
  const code = error?.code;
53
62
  resolve({
54
- ok: !error,
63
+ ok: !error && !timedOut,
55
64
  stdout: (stdout ?? '').trim(),
56
- stderr: (stderr ?? '').trim(),
57
- code: typeof code === 'number' ? code : error ? 1 : 0,
65
+ stderr: `${(stderr ?? '').trim()}${timedOut ? `${stderr ? '\n' : ''}git gave up after ${Math.round(timeoutMs / 1000)} s` : ''}`.trim(),
66
+ code: typeof code === 'number' ? code : error || timedOut ? 1 : 0,
58
67
  });
59
68
  });
69
+ if (opts.killTree && child.pid) {
70
+ const pid = child.pid;
71
+ timer = setTimeout(() => {
72
+ timedOut = true;
73
+ if (process.platform === 'win32')
74
+ execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => undefined);
75
+ else
76
+ child.kill('SIGKILL');
77
+ }, timeoutMs);
78
+ }
60
79
  });
61
80
  }
81
+ /**
82
+ * When this repository last fetched successfully, or undefined when that cannot be told.
83
+ *
84
+ * Read from FETCH_HEAD, but only when it holds something: git empties it at the start of every fetch,
85
+ * so a fetch that failed — a passphrase not typed, a network down — leaves an empty file with a fresh
86
+ * time, and that time read as "last fetched a minute ago" (seen live on 2026-10-03).
87
+ */
88
+ export function lastFetchedAt(gitDir) {
89
+ try {
90
+ const s = statSync(join(gitDir, 'FETCH_HEAD'));
91
+ return s.size > 0 ? s.mtime.toISOString() : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
62
97
  /** Everything the runner needs to know about a repository before it touches it. */
63
98
  export async function repoState(dir) {
64
99
  const empty = { isRepo: false, branch: null, head: null, dirty: false, changed: [] };
@@ -243,7 +278,7 @@ export async function updateFromRemote(dir, branch) {
243
278
  const current = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
244
279
  const moved = current.ok && current.stdout === branch
245
280
  ? await git(dir, ['merge', '--ff-only', '--quiet', to])
246
- : await git(dir, ['update-ref', `refs/heads/${branch}`, to, from]);
281
+ : await git(dir, ['update-ref', '-m', `copilot-operator: brought up to date with ${tracking}`, `refs/heads/${branch}`, to, from]);
247
282
  if (!moved.ok)
248
283
  return { branch, remote: tracking, outcome: 'failed', from, to, detail: (moved.stderr || moved.stdout).split(/\r?\n/)[0] };
249
284
  return { branch, remote: tracking, outcome: 'updated', from, to };
@@ -24,13 +24,13 @@
24
24
  * The preview fetches (so what it shows is against the remote as it is now) and changes nothing else;
25
25
  * the preparation itself refuses when the folder is no longer what the preview showed.
26
26
  */
27
- import { existsSync, statSync } from 'node:fs';
27
+ import { existsSync } from 'node:fs';
28
28
  import { rm } from 'node:fs/promises';
29
29
  import { tmpdir } from 'node:os';
30
30
  import { join, resolve } from 'node:path';
31
31
  import { createHash } from 'node:crypto';
32
- import { branchNameFrom, freeBranchName, git, porcelainPaths, RUNNER_EMAIL } from './git.js';
33
- import { describeFetchFailure, fetchCommandFor, operatorFetchEnv, QUIET_FETCH_ENV } from './remoteAuth.js';
32
+ import { branchNameFrom, freeBranchName, git, lastFetchedAt, porcelainPaths, RUNNER_EMAIL } from './git.js';
33
+ import { closePasswordWindows, describeFetchFailure, fetchCommandFor, operatorFetchEnv, QUIET_FETCH_ENV } from './remoteAuth.js';
34
34
  const short = (c) => (c ?? '').slice(0, 8);
35
35
  const lines = (s) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
36
36
  function stamp(now = new Date()) {
@@ -86,15 +86,9 @@ export async function planPrepare(folder, opts) {
86
86
  if (remotes.length === 0)
87
87
  return empty(repoDir, 'The repository has no remote, so there is no remote main branch to prepare it from.');
88
88
  const remote = remotes.includes('origin') ? 'origin' : remotes[0];
89
- const lastFetchedAt = () => {
90
- try {
91
- return gitDir ? statSync(join(gitDir, 'FETCH_HEAD')).mtime.toISOString() : undefined;
92
- }
93
- catch {
94
- return undefined;
95
- }
96
- };
97
89
  const fetchCommand = fetchCommandFor(repoDir, remote);
90
+ // Read before fetching: a fetch that fails empties FETCH_HEAD, and with it the time of the last one that worked.
91
+ const lastBefore = gitDir ? lastFetchedAt(gitDir) : undefined;
98
92
  let fetched;
99
93
  if (opts.fetch) {
100
94
  /*
@@ -105,7 +99,11 @@ export async function planPrepare(folder, opts) {
105
99
  const asked = !!opts.askpass;
106
100
  const env = asked ? operatorFetchEnv(opts.askpass) : QUIET_FETCH_ENV;
107
101
  const args = [...(asked ? [] : ['-c', 'credential.interactive=false']), 'fetch', '--quiet', '--no-tags', '--prune', remote];
108
- const f = await git(repoDir, args, asked ? 300_000 : 120_000, env, { windowsHide: !asked });
102
+ const started = new Date(Date.now() - 1000);
103
+ const f = await git(repoDir, args, asked ? 300_000 : 120_000, env, { windowsHide: !asked, killTree: true });
104
+ // Given up on with the window still open: closed, so it is not left on screen asking for nothing.
105
+ if (!f.ok && asked && /gave up after/.test(f.stderr))
106
+ await closePasswordWindows(opts.askpass, started);
109
107
  if (f.ok)
110
108
  fetched = { ok: true, asked };
111
109
  else {
@@ -123,7 +121,7 @@ export async function planPrepare(folder, opts) {
123
121
  if ((await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`])).ok)
124
122
  target = `${remote}/${name}`;
125
123
  }
126
- const last = lastFetchedAt();
124
+ const last = fetched && !fetched.ok ? lastBefore : gitDir ? lastFetchedAt(gitDir) : undefined;
127
125
  if (!target) {
128
126
  return {
129
127
  ...empty(repoDir, fetched && !fetched.ok ? `The remote could not be fetched (${fetched.detail}), and no main branch of ${remote} is known here.` : `No main branch of ${remote} is known here.`),
@@ -221,8 +219,17 @@ async function keepUncommitted(dir, head, branch, message) {
221
219
  }
222
220
  }
223
221
  /** Does it, after checking the folder is still what the preview showed. */
224
- export async function prepareFromRemote(folder, fingerprint, opts = {}) {
222
+ export async function prepareFromRemote(folder, fingerprint,
223
+ /** `names`: the saved-branch names the preview showed, used when still free, so Confirm makes what was shown. */
224
+ opts = {}) {
225
225
  const plan = await planPrepare(folder, { fetch: false, prefix: opts.prefix });
226
+ // The names the operator saw, not ones stamped a minute later (live run 2026-10-03).
227
+ const prefix = opts.prefix || 'cop/';
228
+ const free = async (name) => !!name && name.startsWith(`${prefix}saved/`) && !(await git(plan.repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])).ok;
229
+ if (plan.savedBranch && (await free(opts.names?.savedBranch)))
230
+ plan.savedBranch = opts.names.savedBranch;
231
+ if (plan.savedMainBranch && (await free(opts.names?.savedMainBranch)))
232
+ plan.savedMainBranch = opts.names.savedMainBranch;
226
233
  if (!plan.ok)
227
234
  return { ok: false, problem: plan.problem ?? 'the folder could not be read.' };
228
235
  const repoDir = plan.repoDir;
@@ -17,6 +17,7 @@
17
17
  * Only for a fetch the operator just pressed a button for: a run's own fetches stay silent, since a
18
18
  * window nobody answers would hold the run until it times out.
19
19
  */
20
+ import { execFile } from 'node:child_process';
20
21
  import { existsSync } from 'node:fs';
21
22
  import { resolve } from 'node:path';
22
23
  import { git } from './git.js';
@@ -55,6 +56,36 @@ export function describeFetchFailure(stderr) {
55
56
  const auth = /permission denied|publickey|passphrase|authentication failed|could not read (username|password)|host key verification failed|terminal prompts disabled/i.test(stderr);
56
57
  return { detail: lines.slice(0, 3).join(' ') || 'git fetch failed', auth };
57
58
  }
59
+ /**
60
+ * Closes the password windows of a fetch that was given up on: every process of exactly this program
61
+ * started since `since`.
62
+ *
63
+ * Needed because stopping git and its process tree does not reach them. Git for Windows starts ssh, and
64
+ * ssh the window, the way its POSIX layer does, and the Windows parent of the window is a process that
65
+ * is already gone — so `taskkill /T` stops git and leaves the window on screen (seen live on
66
+ * 2026-10-03, and in a process listing: the window's parent id named no running process). Once the
67
+ * window is closed the ssh waiting on it ends by itself: git, at the other end of its pipe, is gone.
68
+ * Matched by the program's full path and its start time, so nothing else is touched; a second
69
+ * password window of the operator's own, opened in the same minutes, would at worst be cancelled.
70
+ */
71
+ export function closePasswordWindows(program, since) {
72
+ if (process.platform !== 'win32')
73
+ return Promise.resolve(0);
74
+ const script = [
75
+ `$path = '${program.replace(/'/g, "''")}'`,
76
+ `$since = [datetime]::Parse('${since.toISOString()}').ToUniversalTime()`,
77
+ '$n = 0',
78
+ "Get-CimInstance Win32_Process -Filter \"Name='git-askpass.exe' OR Name='$([IO.Path]::GetFileName($path))'\" |",
79
+ ' Where-Object { $_.ExecutablePath -and $_.ExecutablePath -ieq $path -and $_.CreationDate.ToUniversalTime() -ge $since } |',
80
+ ' ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $n++ }',
81
+ '$n',
82
+ ].join('\n');
83
+ return new Promise((resolve) => {
84
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 30_000 }, (_e, stdout) => {
85
+ resolve(Number.parseInt(String(stdout).trim(), 10) || 0);
86
+ });
87
+ });
88
+ }
58
89
  /** The fetch for the operator to run in their own PowerShell, where ssh can ask them directly. */
59
90
  export function fetchCommandFor(dir, remote) {
60
91
  return `git -C ${psQuote(dir)} fetch --prune ${psQuote(remote)}`;