copilot-operator 0.1.22 → 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 +2 -2
  2. package/dist/src/api/operator.service.js +330 -35
  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 +36 -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 +44 -7
  29. package/dist/src/vcs/prepareFromRemote.js +46 -9
  30. package/dist/src/vcs/remoteAuth.js +92 -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-cdc730adea753446.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-185755ada66e2930.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-5f90780046c1c24f.js +0 -1
  63. package/dist/web/_next/static/chunks/867-79f07bebe3084811.js +0 -1
  64. package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
  65. /package/dist/web/_next/static/{9Ze8aagkvT-WLfD4KCKKS → beP1HgHJZvBVRTsoU1sZv}/_buildManifest.js +0 -0
  66. /package/dist/web/_next/static/{9Ze8aagkvT-WLfD4KCKKS → 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';
@@ -44,19 +44,56 @@ function noHooksDir() {
44
44
  return process.env.SystemRoot ? join(process.env.SystemRoot, 'System32', 'copilot-operator-no-hooks') : '/nonexistent';
45
45
  }
46
46
  /** Runs one git command in a directory. Never throws: the caller decides what a failure means. */
47
- export function git(cwd, args, timeoutMs = 60_000, env) {
47
+ export function git(cwd, args, timeoutMs = 60_000, env,
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
+ */
54
+ opts = {}) {
48
55
  return new Promise((resolve) => {
49
- execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, 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);
50
61
  const code = error?.code;
51
62
  resolve({
52
- ok: !error,
63
+ ok: !error && !timedOut,
53
64
  stdout: (stdout ?? '').trim(),
54
- stderr: (stderr ?? '').trim(),
55
- 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,
56
67
  });
57
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
+ }
58
79
  });
59
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
+ }
60
97
  /** Everything the runner needs to know about a repository before it touches it. */
61
98
  export async function repoState(dir) {
62
99
  const empty = { isRepo: false, branch: null, head: null, dirty: false, changed: [] };
@@ -241,7 +278,7 @@ export async function updateFromRemote(dir, branch) {
241
278
  const current = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
242
279
  const moved = current.ok && current.stdout === branch
243
280
  ? await git(dir, ['merge', '--ff-only', '--quiet', to])
244
- : 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]);
245
282
  if (!moved.ok)
246
283
  return { branch, remote: tracking, outcome: 'failed', from, to, detail: (moved.stderr || moved.stdout).split(/\r?\n/)[0] };
247
284
  return { branch, remote: tracking, outcome: 'updated', from, to };
@@ -29,8 +29,8 @@ 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
- const QUIET = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '' };
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()) {
@@ -62,7 +62,9 @@ const empty = (repoDir, problem) => ({
62
62
  });
63
63
  /**
64
64
  * What preparing the folder would do. Fetches the remote when `fetch` is true (the preview); reads
65
- * only when it is false (the preparation, which must compare against what the preview fetched).
65
+ * only when it is false (the preparation, which must compare against what the preview fetched, or a
66
+ * preview after the operator fetched in their own terminal). With `askpass`, the fetch may ask for a
67
+ * password through that program (see `remoteAuth.ts`); without it, it may not ask at all.
66
68
  */
67
69
  export async function planPrepare(folder, opts) {
68
70
  const inside = await git(folder, ['rev-parse', '--is-inside-work-tree']);
@@ -84,13 +86,30 @@ export async function planPrepare(folder, opts) {
84
86
  if (remotes.length === 0)
85
87
  return empty(repoDir, 'The repository has no remote, so there is no remote main branch to prepare it from.');
86
88
  const remote = remotes.includes('origin') ? 'origin' : remotes[0];
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;
87
92
  let fetched;
88
93
  if (opts.fetch) {
89
- const f = await git(repoDir, ['-c', 'credential.interactive=false', 'fetch', '--quiet', '--no-tags', '--prune', remote], 120_000, QUIET);
90
- fetched = f.ok ? { ok: true } : { ok: false, detail: (f.stderr || f.stdout || 'git fetch failed').split(/\r?\n/)[0] };
91
- // The remote's own idea of its main branch, asked of it now; a remote that will not say keeps the old answer.
94
+ /*
95
+ * With the password window: five minutes for the operator to type, and the window not hidden. One
96
+ * connection only — `remote set-head --auto` would open a second and ask for the password again —
97
+ * so the remote's main is read from what was fetched, below.
98
+ */
99
+ const asked = !!opts.askpass;
100
+ const env = asked ? operatorFetchEnv(opts.askpass) : QUIET_FETCH_ENV;
101
+ const args = [...(asked ? [] : ['-c', 'credential.interactive=false']), 'fetch', '--quiet', '--no-tags', '--prune', remote];
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);
92
107
  if (f.ok)
93
- await git(repoDir, ['remote', 'set-head', remote, '--auto'], 60_000, QUIET);
108
+ fetched = { ok: true, asked };
109
+ else {
110
+ const why = describeFetchFailure(f.stderr || f.stdout);
111
+ fetched = { ok: false, detail: why.detail, auth: why.auth, asked };
112
+ }
94
113
  }
95
114
  let target = '';
96
115
  const remoteHead = await git(repoDir, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`]);
@@ -102,14 +121,21 @@ export async function planPrepare(folder, opts) {
102
121
  if ((await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`])).ok)
103
122
  target = `${remote}/${name}`;
104
123
  }
124
+ const last = fetched && !fetched.ok ? lastBefore : gitDir ? lastFetchedAt(gitDir) : undefined;
105
125
  if (!target) {
106
126
  return {
107
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.`),
108
128
  ...(fetched ? { fetched } : {}),
129
+ fetchCommand,
109
130
  };
110
131
  }
111
132
  if (fetched && !fetched.ok) {
112
- return { ...empty(repoDir, `The remote could not be fetched, so the folder would be set to an old copy of ${target}: ${fetched.detail}. Nothing was changed.`), fetched };
133
+ return {
134
+ ...empty(repoDir, `The remote could not be fetched, so the folder would be set to an old copy of ${target}: ${fetched.detail}. Nothing was changed.`),
135
+ fetched,
136
+ fetchCommand,
137
+ ...(last ? { lastFetched: last } : {}),
138
+ };
113
139
  }
114
140
  const targetCommit = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${target}^{commit}`])).stdout;
115
141
  if (!targetCommit)
@@ -144,6 +170,8 @@ export async function planPrepare(folder, opts) {
144
170
  localBranch,
145
171
  localHead,
146
172
  ...(fetched ? { fetched } : {}),
173
+ ...(last ? { lastFetched: last } : {}),
174
+ fetchCommand,
147
175
  changed,
148
176
  untracked,
149
177
  mainOnlyCommits,
@@ -191,8 +219,17 @@ async function keepUncommitted(dir, head, branch, message) {
191
219
  }
192
220
  }
193
221
  /** Does it, after checking the folder is still what the preview showed. */
194
- 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 = {}) {
195
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;
196
233
  if (!plan.ok)
197
234
  return { ok: false, problem: plan.problem ?? 'the folder could not be read.' };
198
235
  const repoDir = plan.repoDir;