copilot-operator 0.1.11 → 0.1.13
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.
- package/dist/src/api/operator.controller.js +1 -1
- package/dist/src/api/operator.service.js +98 -8
- package/dist/src/config/schema.js +14 -3
- package/dist/src/context/desktopMirror.js +73 -13
- package/dist/src/context/projectMirror.js +69 -1
- package/dist/src/orchestrator/contract.js +56 -1
- package/dist/src/orchestrator/taskRunner.js +140 -16
- package/dist/src/plan/brief.js +39 -8
- package/dist/src/plan/importPlan.js +3 -0
- package/dist/src/plan/schema.js +26 -2
- package/dist/src/plan/script.js +79 -75
- package/dist/src/plan/systemGuide.js +79 -15
- package/dist/src/session/compose.js +16 -1
- package/dist/src/session/exportRecord.js +8 -1
- package/dist/src/session/exports.js +3 -0
- package/dist/src/session/handoff.js +41 -3
- package/dist/src/session/model.js +9 -0
- package/dist/src/session/store.js +11 -7
- package/dist/src/session/story.js +74 -18
- package/dist/src/transport/chatTransport.js +1 -0
- package/dist/src/transport/copilotTransport.js +80 -61
- package/dist/src/vcs/git.js +92 -2
- package/dist/src/vcs/taskVcs.js +100 -19
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
- package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +1 -0
- package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +6 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
- package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
- package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +0 -1
- /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_ssgManifest.js +0 -0
|
@@ -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,
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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-${
|
|
116
|
-
entries.push({ kind: 'sent', iteration:
|
|
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
|
-
|
|
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) {
|
|
@@ -57,65 +76,65 @@ export class SendRejectedError extends Error {
|
|
|
57
76
|
* impossible — the runner notes it before the click and waits for it to move, so a copy that
|
|
58
77
|
* did not happen reads as a failure rather than as the previous answer.
|
|
59
78
|
*/
|
|
60
|
-
export const CLIPBOARD_GUARD = `(() => {
|
|
61
|
-
const state = { text: '', seq: 0 };
|
|
62
|
-
try {
|
|
63
|
-
Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
|
|
64
|
-
} catch (e) {
|
|
65
|
-
window.__copClipboard = state;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const remember = (text) => {
|
|
69
|
-
if (typeof text !== 'string' || text.length === 0) return;
|
|
70
|
-
state.text = text;
|
|
71
|
-
state.seq += 1;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const clip = navigator.clipboard;
|
|
75
|
-
if (clip) {
|
|
76
|
-
try {
|
|
77
|
-
Object.defineProperty(clip, 'writeText', {
|
|
78
|
-
configurable: true,
|
|
79
|
-
writable: true,
|
|
80
|
-
value: (text) => {
|
|
81
|
-
remember(String(text));
|
|
82
|
-
return Promise.resolve();
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
} catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
Object.defineProperty(clip, 'write', {
|
|
89
|
-
configurable: true,
|
|
90
|
-
writable: true,
|
|
91
|
-
value: async (items) => {
|
|
92
|
-
for (const item of items || []) {
|
|
93
|
-
try {
|
|
94
|
-
if (item && item.types && item.types.indexOf('text/plain') >= 0) {
|
|
95
|
-
remember(await (await item.getType('text/plain')).text());
|
|
96
|
-
}
|
|
97
|
-
} catch (e) { /* one unreadable item is not a reason to drop the rest */ }
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
} catch (e) { /* as above */ }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// The old way: select text in a hidden field, then execCommand('copy'). The selection is
|
|
105
|
-
// exactly the text being copied, so it is taken and the command is never run.
|
|
106
|
-
try {
|
|
107
|
-
const realExec = document.execCommand.bind(document);
|
|
108
|
-
document.execCommand = (command, ...rest) => {
|
|
109
|
-
if (String(command).toLowerCase() === 'copy') {
|
|
110
|
-
const selected = String(window.getSelection() || '');
|
|
111
|
-
if (selected.length > 0) {
|
|
112
|
-
remember(selected);
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return realExec(command, ...rest);
|
|
117
|
-
};
|
|
118
|
-
} catch (e) { /* as above */ }
|
|
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 */ }
|
|
119
138
|
})();`;
|
|
120
139
|
export class CopilotTransport {
|
|
121
140
|
opts;
|
|
@@ -271,7 +290,7 @@ export class CopilotTransport {
|
|
|
271
290
|
'sign-in': 'The profile is signed out. Run "cop login".',
|
|
272
291
|
unknown: 'The page is not the Microsoft 365 Copilot chat.',
|
|
273
292
|
};
|
|
274
|
-
throw new Error(`${explain[where] ?? explain.unknown}
|
|
293
|
+
throw new Error(`${explain[where] ?? explain.unknown}
|
|
275
294
|
Current URL: ${url}`);
|
|
276
295
|
}
|
|
277
296
|
/**
|
|
@@ -1119,7 +1138,7 @@ Current URL: ${url}`);
|
|
|
1119
1138
|
}
|
|
1120
1139
|
if (!sawNewTurn || !last.finished) {
|
|
1121
1140
|
await this.dumpFailure(join(this.opts.transportDir, '..', 'failures'), 'reply-timeout').catch(() => undefined);
|
|
1122
|
-
throw new
|
|
1141
|
+
throw new ReplyTimeoutError(Math.round(this.opts.replyTimeoutMs / 1000), `Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
|
|
1123
1142
|
`(new turn seen: ${sawNewTurn}, still streaming: ${last.streaming}). ` +
|
|
1124
1143
|
'A screenshot and an HTML dump are in the run folder.');
|
|
1125
1144
|
}
|
package/dist/src/vcs/git.js
CHANGED
|
@@ -44,9 +44,9 @@ 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) {
|
|
47
|
+
export function git(cwd, args, timeoutMs = 60_000, env) {
|
|
48
48
|
return new Promise((resolve) => {
|
|
49
|
-
execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
49
|
+
execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024, ...(env ? { env } : {}) }, (error, stdout, stderr) => {
|
|
50
50
|
const code = error?.code;
|
|
51
51
|
resolve({
|
|
52
52
|
ok: !error,
|
|
@@ -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);
|
|
@@ -163,6 +178,81 @@ export async function isValidBranchName(dir, name) {
|
|
|
163
178
|
const r = await git(dir, ['check-ref-format', '--branch', name]);
|
|
164
179
|
return r.ok;
|
|
165
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Brings a local branch up to date with its remote: `git fetch`, then a fast-forward and nothing else.
|
|
183
|
+
*
|
|
184
|
+
* Asked for on 2026-09-30, so a session's branch is cut from the code as it is on the server and not
|
|
185
|
+
* from whatever this checkout last pulled. Never a reset, a merge commit or a rewrite: a branch with
|
|
186
|
+
* commits of its own (ahead) or one that has gone its own way (diverged) is left as it is, and the
|
|
187
|
+
* outcome says so. The remote is the branch's upstream, or `origin` when it has none; a repository
|
|
188
|
+
* with neither is left alone. git is not allowed to ask for credentials — a prompt would hang the run,
|
|
189
|
+
* and a sign-in window on a machine nobody is watching is worse — so a remote that needs a password
|
|
190
|
+
* git does not already have fails the fetch, which is reported, not fatal.
|
|
191
|
+
*/
|
|
192
|
+
export async function updateFromRemote(dir, branch) {
|
|
193
|
+
const local = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
|
|
194
|
+
if (!local.ok || !local.stdout)
|
|
195
|
+
return { branch, outcome: 'failed', detail: `there is no local branch ${branch}` };
|
|
196
|
+
const upstream = await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`]);
|
|
197
|
+
let remote = '';
|
|
198
|
+
let remoteBranch = branch;
|
|
199
|
+
if (upstream.ok && upstream.stdout.includes('/')) {
|
|
200
|
+
remote = upstream.stdout.slice(0, upstream.stdout.indexOf('/'));
|
|
201
|
+
remoteBranch = upstream.stdout.slice(remote.length + 1);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const remotes = await git(dir, ['remote']);
|
|
205
|
+
const names = remotes.ok ? remotes.stdout.split(/\r?\n/).map((r) => r.trim()).filter(Boolean) : [];
|
|
206
|
+
if (names.includes('origin'))
|
|
207
|
+
remote = 'origin';
|
|
208
|
+
}
|
|
209
|
+
if (!remote)
|
|
210
|
+
return { branch, outcome: 'no-remote', detail: 'the repository has no remote to update from' };
|
|
211
|
+
const tracking = `${remote}/${remoteBranch}`;
|
|
212
|
+
const quiet = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '' };
|
|
213
|
+
const fetched = await git(dir, ['-c', 'credential.interactive=false', 'fetch', '--quiet', '--no-tags', remote, `+refs/heads/${remoteBranch}:refs/remotes/${tracking}`], 90_000, quiet);
|
|
214
|
+
if (!fetched.ok) {
|
|
215
|
+
return { branch, remote: tracking, outcome: 'fetch-failed', detail: (fetched.stderr || fetched.stdout || 'git fetch failed').split(/\r?\n/)[0] };
|
|
216
|
+
}
|
|
217
|
+
const theirs = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${tracking}^{commit}`]);
|
|
218
|
+
if (!theirs.ok || !theirs.stdout)
|
|
219
|
+
return { branch, remote: tracking, outcome: 'no-remote', detail: `${tracking} does not exist on the remote` };
|
|
220
|
+
const from = local.stdout;
|
|
221
|
+
const to = theirs.stdout;
|
|
222
|
+
if (from === to)
|
|
223
|
+
return { branch, remote: tracking, outcome: 'up-to-date', from, to };
|
|
224
|
+
if (await isAncestor(dir, to, from))
|
|
225
|
+
return { branch, remote: tracking, outcome: 'ahead', from, to, detail: `${branch} has commits ${tracking} does not; it was left as it is` };
|
|
226
|
+
if (!(await isAncestor(dir, from, to))) {
|
|
227
|
+
return { branch, remote: tracking, outcome: 'diverged', from, to, detail: `${branch} and ${tracking} have each gone their own way; ${branch} was left as it is` };
|
|
228
|
+
}
|
|
229
|
+
// Forward only. A branch that is checked out moves with its tree (clean, or the caller would not be
|
|
230
|
+
// here); one that is not is moved by its ref, and only if it still is where it was read.
|
|
231
|
+
const current = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
232
|
+
const moved = current.ok && current.stdout === branch
|
|
233
|
+
? await git(dir, ['merge', '--ff-only', '--quiet', to])
|
|
234
|
+
: await git(dir, ['update-ref', `refs/heads/${branch}`, to, from]);
|
|
235
|
+
if (!moved.ok)
|
|
236
|
+
return { branch, remote: tracking, outcome: 'failed', from, to, detail: (moved.stderr || moved.stdout).split(/\r?\n/)[0] };
|
|
237
|
+
return { branch, remote: tracking, outcome: 'updated', from, to };
|
|
238
|
+
}
|
|
239
|
+
/** One line for the log and the session page. */
|
|
240
|
+
export function describeUpdate(u) {
|
|
241
|
+
const short = (c) => (c ? c.slice(0, 8) : '?');
|
|
242
|
+
switch (u.outcome) {
|
|
243
|
+
case 'updated':
|
|
244
|
+
return `${u.branch} brought up to date with ${u.remote}: ${short(u.from)} → ${short(u.to)}`;
|
|
245
|
+
case 'up-to-date':
|
|
246
|
+
return `${u.branch} was already up to date with ${u.remote}`;
|
|
247
|
+
default:
|
|
248
|
+
return `${u.branch} not updated from the remote: ${u.detail ?? u.outcome}`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** The local branches of a repository, by name; empty when it cannot be read. */
|
|
252
|
+
export async function localBranches(dir) {
|
|
253
|
+
const r = await git(dir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
|
|
254
|
+
return r.ok ? r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) : [];
|
|
255
|
+
}
|
|
166
256
|
export async function branchExists(dir, name) {
|
|
167
257
|
const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]);
|
|
168
258
|
return r.ok && r.stdout.length > 0;
|