copilot-operator 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/operator.controller.js +11 -0
- package/dist/src/api/operator.service.js +32 -13
- package/dist/src/config/schema.js +6 -0
- package/dist/src/orchestrator/contract.js +58 -0
- package/dist/src/orchestrator/taskRunner.js +104 -6
- package/dist/src/plan/schema.js +21 -1
- package/dist/src/plan/systemGuide.js +48 -0
- package/dist/src/protocol/parser.js +98 -5
- package/dist/src/session/compose.js +1 -1
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/handoff.js +1 -1
- package/dist/src/session/store.js +44 -10
- package/dist/src/transport/copilotTransport.js +145 -65
- package/dist/src/vcs/scope.js +10 -7
- package/dist/src/vcs/syncCommand.js +105 -0
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
- package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +1 -0
- package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/{page-be366b35f4cc97ce.js → page-dd3ebf37d29dce4f.js} +1 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/{page-cabc3876a8111edd.js → page-93ab518db67e52a2.js} +1 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +1 -0
- package/dist/web/_next/static/css/{20c6b36996df5e4c.css → 2ad2a74b7e07dcc9.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 +3 -3
- 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 -2
- package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +0 -1
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
- package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +0 -1
- package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +0 -1
- /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_ssgManifest.js +0 -0
|
@@ -60,8 +60,83 @@ export function stripCitations(text) {
|
|
|
60
60
|
return undefined;
|
|
61
61
|
return text.replace(/\s*【[^】]*】/g, '').replace(/[ ]+$/gm, '').trim();
|
|
62
62
|
}
|
|
63
|
+
/*
|
|
64
|
+
* Prose that arrived in another shape.
|
|
65
|
+
*
|
|
66
|
+
* `tried`, `needed`, `notes` and `summary` are sentences for a person to read; nothing in them is
|
|
67
|
+
* run. A chat that writes `tried` as a list of objects — `{"approach": "…", "result": "…"}` — has
|
|
68
|
+
* said exactly what was asked, in a shape the schema did not expect, and refusing the whole reply
|
|
69
|
+
* for it cost a format round each time and, three in a row, the task: it ended "failed" with the
|
|
70
|
+
* work done and its results already recorded. So these fields are read as text whatever shape they
|
|
71
|
+
* come in, and the reading is reported. The fields that are acted on — `status`, `steps` and
|
|
72
|
+
* everything in them — are never coerced: a step the runner would have to guess at is not run.
|
|
73
|
+
*/
|
|
74
|
+
const PROSE_KEYS = ['approach', 'what', 'tried', 'attempt', 'action', 'description', 'text', 'summary', 'detail', 'result', 'outcome', 'why', 'reason', 'needed'];
|
|
75
|
+
function proseOf(value) {
|
|
76
|
+
if (typeof value === 'string')
|
|
77
|
+
return value;
|
|
78
|
+
if (value === null || value === undefined)
|
|
79
|
+
return '';
|
|
80
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
81
|
+
return String(value);
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value.map(proseOf).filter((x) => x.trim()).join('\n');
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
const record = value;
|
|
86
|
+
const named = PROSE_KEYS.filter((k) => typeof record[k] === 'string' && record[k].trim()).map((k) => record[k]);
|
|
87
|
+
if (named.length > 0)
|
|
88
|
+
return named.join(' — ');
|
|
89
|
+
const strings = Object.values(record).filter((v) => typeof v === 'string' && v.trim() !== '');
|
|
90
|
+
return strings.length > 0 ? strings.join(' — ') : JSON.stringify(value);
|
|
91
|
+
}
|
|
92
|
+
return String(value);
|
|
93
|
+
}
|
|
94
|
+
export function normaliseProse(value) {
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
96
|
+
return { value, coerced: [] };
|
|
97
|
+
const out = { ...value };
|
|
98
|
+
const coerced = [];
|
|
99
|
+
for (const key of ['notes', 'summary', 'needed']) {
|
|
100
|
+
if (key in out && out[key] !== undefined && typeof out[key] !== 'string') {
|
|
101
|
+
out[key] = proseOf(out[key]);
|
|
102
|
+
coerced.push(key);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if ('tried' in out && out.tried !== undefined) {
|
|
106
|
+
const tried = out.tried;
|
|
107
|
+
if (typeof tried === 'string') {
|
|
108
|
+
out.tried = tried.trim() ? [tried] : [];
|
|
109
|
+
coerced.push('tried');
|
|
110
|
+
}
|
|
111
|
+
else if (Array.isArray(tried) && tried.some((t) => typeof t !== 'string')) {
|
|
112
|
+
out.tried = tried.map(proseOf).filter((t) => t.trim() !== '');
|
|
113
|
+
tried.forEach((t, i) => {
|
|
114
|
+
if (typeof t !== 'string')
|
|
115
|
+
coerced.push(`tried.${i}`);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { value: out, coerced };
|
|
120
|
+
}
|
|
121
|
+
/** What each field must look like, said in the message that asks for a reformatted reply. */
|
|
122
|
+
const FIELD_SHAPES = [
|
|
123
|
+
[/^status$/, '"status" is one of "continue", "done", "blocked"'],
|
|
124
|
+
[/^steps(\.|$)/, '"steps" is a list of {"id": 1, "type": "command", "cmd": "the command"} ("shell", "expect", "timeoutSec" optional)'],
|
|
125
|
+
[/^tried(\.|$)/, '"tried" is a list of plain strings, one sentence per approach'],
|
|
126
|
+
[/^deviations(\.|$)/, '"deviations" is a list of {"instruction": "…", "did": "…", "why": "…"} with plain strings'],
|
|
127
|
+
[/^disputed(\.|$)/, '"disputed" is a list of {"finding": "…", "why": "…", "evidence": "…"} with plain strings'],
|
|
128
|
+
[/^(summary|notes|needed)$/, '"summary", "notes" and "needed" are plain strings'],
|
|
129
|
+
];
|
|
130
|
+
const FORMAT_EXAMPLE = [
|
|
131
|
+
'```json',
|
|
132
|
+
'{"status": "continue", "notes": "what this round does", "steps": [{"id": 1, "type": "command", "cmd": "npm test"}]}',
|
|
133
|
+
'```',
|
|
134
|
+
'or, to end: {"status": "done", "summary": "several sentences on what was done and what it shows"}',
|
|
135
|
+
'or {"status": "blocked", "summary": "…", "tried": ["first approach", "second approach"], "needed": "what would unblock it"}',
|
|
136
|
+
].join('\n');
|
|
63
137
|
export function parseReply(markdown, opts) {
|
|
64
138
|
const candidates = findJsonCandidates(markdown);
|
|
139
|
+
const paths = new Set();
|
|
65
140
|
if (candidates.length === 0) {
|
|
66
141
|
return {
|
|
67
142
|
ok: false,
|
|
@@ -80,9 +155,12 @@ export function parseReply(markdown, opts) {
|
|
|
80
155
|
errors.push(`JSON.parse failed: ${e.message}`);
|
|
81
156
|
continue;
|
|
82
157
|
}
|
|
83
|
-
const
|
|
158
|
+
const prose = normaliseProse(value);
|
|
159
|
+
const result = ReplySchema.safeParse(prose.value);
|
|
84
160
|
if (!result.success) {
|
|
85
161
|
errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
|
|
162
|
+
for (const i of result.error.issues)
|
|
163
|
+
paths.add(i.path.join('.') || '(root)');
|
|
86
164
|
continue;
|
|
87
165
|
}
|
|
88
166
|
const reply = result.data;
|
|
@@ -106,6 +184,7 @@ export function parseReply(markdown, opts) {
|
|
|
106
184
|
done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
|
|
107
185
|
blocked,
|
|
108
186
|
json: text.trim(),
|
|
187
|
+
...(prose.coerced.length > 0 ? { coerced: prose.coerced } : {}),
|
|
109
188
|
};
|
|
110
189
|
}
|
|
111
190
|
}
|
|
@@ -113,6 +192,7 @@ export function parseReply(markdown, opts) {
|
|
|
113
192
|
ok: false,
|
|
114
193
|
reason: 'invalid-json',
|
|
115
194
|
detail: errors.slice(0, 4).join(' | '),
|
|
195
|
+
...(paths.size > 0 ? { paths: [...paths] } : {}),
|
|
116
196
|
};
|
|
117
197
|
}
|
|
118
198
|
/**
|
|
@@ -162,15 +242,28 @@ export function parseReview(markdown, opts) {
|
|
|
162
242
|
return { ok: false, reason: 'invalid-json', detail: errors.slice(0, 4).join(' | ') };
|
|
163
243
|
}
|
|
164
244
|
/** The message sent back when a reply did not validate. Names the problem, nothing else. */
|
|
245
|
+
/**
|
|
246
|
+
* The message that asks for the same answer again, in the format.
|
|
247
|
+
*
|
|
248
|
+
* It names each rejected field with the shape it must have, gives a valid example, and says what
|
|
249
|
+
* did not happen: nothing from the rejected reply was run, so the results of the steps before it
|
|
250
|
+
* stand and the answer only needs reformatting. Without that last part a chat told "invalid" tends
|
|
251
|
+
* to start the round again, or to keep the same shape and change the wording, and the rounds run out.
|
|
252
|
+
*/
|
|
165
253
|
export function formatErrorMessage(fail, attempt, maxAttempts) {
|
|
254
|
+
const shapes = [...new Set((fail.paths ?? []).flatMap((p) => FIELD_SHAPES.filter(([re]) => re.test(p)).map(([, s]) => s)))];
|
|
166
255
|
return [
|
|
167
|
-
`Your last reply did not match the required format (
|
|
256
|
+
`Your last reply did not match the required format (format retry ${attempt} of ${maxAttempts}).`,
|
|
168
257
|
fail.reason === 'no-json-block'
|
|
169
258
|
? 'There was no fenced json code block in it.'
|
|
170
259
|
: `The json block did not validate: ${fail.detail}`,
|
|
171
|
-
|
|
172
|
-
'
|
|
173
|
-
|
|
260
|
+
shapes.length > 0 ? `What those fields must be: ${shapes.join('; ')}.` : '',
|
|
261
|
+
'Only the format is wrong. Nothing from that reply was run, and the results of the steps before it stand.',
|
|
262
|
+
'Send the same answer again, reformatted, as exactly one fenced code block tagged json — do not start over and do not change what it asks for. For example:',
|
|
263
|
+
FORMAT_EXAMPLE,
|
|
264
|
+
]
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.join('\n');
|
|
174
267
|
}
|
|
175
268
|
/**
|
|
176
269
|
* Spots a command that arrived damaged rather than badly written.
|
|
@@ -11,7 +11,7 @@ const TASK_HEADER = '## Task';
|
|
|
11
11
|
* and a note that is empty simply leaves nothing behind.
|
|
12
12
|
*/
|
|
13
13
|
function runnerBlock(input) {
|
|
14
|
-
const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
|
|
14
|
+
const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.approachesNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
|
|
15
15
|
return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
@@ -399,6 +399,7 @@ async function domainTask(session, task, runsDir) {
|
|
|
399
399
|
scope: task.scope ?? [],
|
|
400
400
|
scopeReverted: task.scopeReverted ?? [],
|
|
401
401
|
handoff: task.handoff ?? null,
|
|
402
|
+
stopCode: task.stopCode ?? null,
|
|
402
403
|
prompt: p.prompt,
|
|
403
404
|
expected: p.expected,
|
|
404
405
|
level2: task.level2,
|
|
@@ -29,7 +29,7 @@ export function composeHandoff(task, notRun = []) {
|
|
|
29
29
|
if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
|
|
30
30
|
manual.push('look at the committed files and commits listed above');
|
|
31
31
|
return {
|
|
32
|
-
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}) },
|
|
32
|
+
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
|
|
33
33
|
changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
|
|
34
34
|
validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
|
|
35
35
|
...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
|
|
@@ -42,6 +42,47 @@ export const DEFAULT_MIRROR = {
|
|
|
42
42
|
respectGitignore: true,
|
|
43
43
|
includeEnvFiles: false,
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Applies an edit to a task: the one place the rule about a new intent lives.
|
|
47
|
+
*
|
|
48
|
+
* A task given a new prompt is a new question. The checks the reviews of its earlier attempts
|
|
49
|
+
* added were findings about the old question, and inheriting them gated the new one on work it
|
|
50
|
+
* no longer asks for: a maintenance task rewritten as an audit still had to pass "the build uses
|
|
51
|
+
* the new config", and went blocked on an artificial finding. So a changed prompt drops them, with
|
|
52
|
+
* the reason kept beside each, and they stay on the record. The plan's own checks are not guessed
|
|
53
|
+
* at: whoever writes the new prompt says which still apply (the edit form and the new-prompt
|
|
54
|
+
* dialog both show them), and `checks` in the patch is that answer. A re-run with the same prompt
|
|
55
|
+
* keeps everything, because it is the same question asked again.
|
|
56
|
+
*/
|
|
57
|
+
export function applyTaskPatch(t, patch) {
|
|
58
|
+
const newPrompt = patch.prompt !== undefined && patch.prompt.trim() !== '' && patch.prompt.trim() !== t.prompt.trim();
|
|
59
|
+
if (patch.title !== undefined)
|
|
60
|
+
t.title = patch.title.trim() || t.title;
|
|
61
|
+
if (patch.prompt !== undefined && patch.prompt.trim())
|
|
62
|
+
t.prompt = patch.prompt;
|
|
63
|
+
if (patch.level2 !== undefined)
|
|
64
|
+
t.level2 = patch.level2;
|
|
65
|
+
if (patch.vcsPlan !== undefined)
|
|
66
|
+
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
67
|
+
if (patch.checks !== undefined)
|
|
68
|
+
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
69
|
+
if (patch.readOnly !== undefined) {
|
|
70
|
+
if (patch.readOnly)
|
|
71
|
+
t.readOnly = true;
|
|
72
|
+
else
|
|
73
|
+
delete t.readOnly;
|
|
74
|
+
}
|
|
75
|
+
if (patch.scope !== undefined) {
|
|
76
|
+
const scope = patch.scope.map((p) => p.trim()).filter(Boolean);
|
|
77
|
+
if (scope.length > 0)
|
|
78
|
+
t.scope = scope;
|
|
79
|
+
else
|
|
80
|
+
delete t.scope;
|
|
81
|
+
}
|
|
82
|
+
if (newPrompt && t.reviewChecks?.length) {
|
|
83
|
+
t.reviewChecks = t.reviewChecks.map((rc) => rc.state === 'dropped' ? rc : { ...rc, state: 'dropped', droppedBecause: 'the task was given a new prompt; this was a finding about the old one' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
45
86
|
export class SessionStore {
|
|
46
87
|
defaultLevel1Path;
|
|
47
88
|
dir;
|
|
@@ -358,22 +399,14 @@ export class SessionStore {
|
|
|
358
399
|
continuing: t.continuing,
|
|
359
400
|
buildsOn: t.buildsOn,
|
|
360
401
|
freshRetry: t.freshRetry,
|
|
402
|
+
stopCode: t.stopCode,
|
|
361
403
|
scope: t.scope,
|
|
362
404
|
scopeReverted: t.scopeReverted,
|
|
363
405
|
vcs: t.vcs,
|
|
364
406
|
},
|
|
365
407
|
];
|
|
366
408
|
// An edit is applied only after the old attempt has been put beyond its reach.
|
|
367
|
-
|
|
368
|
-
t.title = patch.title.trim() || t.title;
|
|
369
|
-
if (patch.prompt !== undefined && patch.prompt.trim())
|
|
370
|
-
t.prompt = patch.prompt;
|
|
371
|
-
if (patch.level2 !== undefined)
|
|
372
|
-
t.level2 = patch.level2;
|
|
373
|
-
if (patch.vcsPlan !== undefined)
|
|
374
|
-
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
375
|
-
if (patch.checks !== undefined)
|
|
376
|
-
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
409
|
+
applyTaskPatch(t, patch);
|
|
377
410
|
t.attempt = (t.attempt ?? 1) + 1;
|
|
378
411
|
t.status = 'queued';
|
|
379
412
|
t.iterations = 0;
|
|
@@ -404,6 +437,7 @@ export class SessionStore {
|
|
|
404
437
|
t.handoff = undefined;
|
|
405
438
|
t.scopeReverted = undefined;
|
|
406
439
|
t.freshRetry = undefined;
|
|
440
|
+
t.stopCode = undefined;
|
|
407
441
|
// The branch of the finished attempt stays in the repository and stays on the record
|
|
408
442
|
// above; the next attempt gets its own, cut from the same commit this one started at.
|
|
409
443
|
t.vcs = undefined;
|
|
@@ -57,65 +57,65 @@ export class SendRejectedError extends Error {
|
|
|
57
57
|
* impossible — the runner notes it before the click and waits for it to move, so a copy that
|
|
58
58
|
* did not happen reads as a failure rather than as the previous answer.
|
|
59
59
|
*/
|
|
60
|
-
export const CLIPBOARD_GUARD = `(() => {
|
|
61
|
-
const state = { text: '', seq: 0 };
|
|
62
|
-
try {
|
|
63
|
-
Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
|
|
64
|
-
} catch (e) {
|
|
65
|
-
window.__copClipboard = state;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const remember = (text) => {
|
|
69
|
-
if (typeof text !== 'string' || text.length === 0) return;
|
|
70
|
-
state.text = text;
|
|
71
|
-
state.seq += 1;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const clip = navigator.clipboard;
|
|
75
|
-
if (clip) {
|
|
76
|
-
try {
|
|
77
|
-
Object.defineProperty(clip, 'writeText', {
|
|
78
|
-
configurable: true,
|
|
79
|
-
writable: true,
|
|
80
|
-
value: (text) => {
|
|
81
|
-
remember(String(text));
|
|
82
|
-
return Promise.resolve();
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
} catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
Object.defineProperty(clip, 'write', {
|
|
89
|
-
configurable: true,
|
|
90
|
-
writable: true,
|
|
91
|
-
value: async (items) => {
|
|
92
|
-
for (const item of items || []) {
|
|
93
|
-
try {
|
|
94
|
-
if (item && item.types && item.types.indexOf('text/plain') >= 0) {
|
|
95
|
-
remember(await (await item.getType('text/plain')).text());
|
|
96
|
-
}
|
|
97
|
-
} catch (e) { /* one unreadable item is not a reason to drop the rest */ }
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
} catch (e) { /* as above */ }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// The old way: select text in a hidden field, then execCommand('copy'). The selection is
|
|
105
|
-
// exactly the text being copied, so it is taken and the command is never run.
|
|
106
|
-
try {
|
|
107
|
-
const realExec = document.execCommand.bind(document);
|
|
108
|
-
document.execCommand = (command, ...rest) => {
|
|
109
|
-
if (String(command).toLowerCase() === 'copy') {
|
|
110
|
-
const selected = String(window.getSelection() || '');
|
|
111
|
-
if (selected.length > 0) {
|
|
112
|
-
remember(selected);
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return realExec(command, ...rest);
|
|
117
|
-
};
|
|
118
|
-
} catch (e) { /* as above */ }
|
|
60
|
+
export const CLIPBOARD_GUARD = `(() => {
|
|
61
|
+
const state = { text: '', seq: 0 };
|
|
62
|
+
try {
|
|
63
|
+
Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
window.__copClipboard = state;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const remember = (text) => {
|
|
69
|
+
if (typeof text !== 'string' || text.length === 0) return;
|
|
70
|
+
state.text = text;
|
|
71
|
+
state.seq += 1;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const clip = navigator.clipboard;
|
|
75
|
+
if (clip) {
|
|
76
|
+
try {
|
|
77
|
+
Object.defineProperty(clip, 'writeText', {
|
|
78
|
+
configurable: true,
|
|
79
|
+
writable: true,
|
|
80
|
+
value: (text) => {
|
|
81
|
+
remember(String(text));
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
} catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
Object.defineProperty(clip, 'write', {
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: async (items) => {
|
|
92
|
+
for (const item of items || []) {
|
|
93
|
+
try {
|
|
94
|
+
if (item && item.types && item.types.indexOf('text/plain') >= 0) {
|
|
95
|
+
remember(await (await item.getType('text/plain')).text());
|
|
96
|
+
}
|
|
97
|
+
} catch (e) { /* one unreadable item is not a reason to drop the rest */ }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
} catch (e) { /* as above */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The old way: select text in a hidden field, then execCommand('copy'). The selection is
|
|
105
|
+
// exactly the text being copied, so it is taken and the command is never run.
|
|
106
|
+
try {
|
|
107
|
+
const realExec = document.execCommand.bind(document);
|
|
108
|
+
document.execCommand = (command, ...rest) => {
|
|
109
|
+
if (String(command).toLowerCase() === 'copy') {
|
|
110
|
+
const selected = String(window.getSelection() || '');
|
|
111
|
+
if (selected.length > 0) {
|
|
112
|
+
remember(selected);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return realExec(command, ...rest);
|
|
117
|
+
};
|
|
118
|
+
} catch (e) { /* as above */ }
|
|
119
119
|
})();`;
|
|
120
120
|
export class CopilotTransport {
|
|
121
121
|
opts;
|
|
@@ -271,7 +271,7 @@ export class CopilotTransport {
|
|
|
271
271
|
'sign-in': 'The profile is signed out. Run "cop login".',
|
|
272
272
|
unknown: 'The page is not the Microsoft 365 Copilot chat.',
|
|
273
273
|
};
|
|
274
|
-
throw new Error(`${explain[where] ?? explain.unknown}
|
|
274
|
+
throw new Error(`${explain[where] ?? explain.unknown}
|
|
275
275
|
Current URL: ${url}`);
|
|
276
276
|
}
|
|
277
277
|
/**
|
|
@@ -680,14 +680,77 @@ Current URL: ${url}`);
|
|
|
680
680
|
}
|
|
681
681
|
await row.click({ timeout: 8_000 }).catch(() => undefined);
|
|
682
682
|
}
|
|
683
|
-
/**
|
|
683
|
+
/**
|
|
684
|
+
* Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
|
|
685
|
+
* Both, because either alone has been wrong: rows can be mid-animation after the menu closed, and a
|
|
686
|
+
* button re-rendered by the page can lose the attribute while the menu is still up.
|
|
687
|
+
*/
|
|
688
|
+
async modelMenuOpen() {
|
|
689
|
+
const button = await this.resolveModelButton(1_000);
|
|
690
|
+
const expanded = button ? await button.getAttribute('aria-expanded').catch(() => null) : null;
|
|
691
|
+
if (expanded === 'true')
|
|
692
|
+
return true;
|
|
693
|
+
return (await this.readMenuRows()).length > 0;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Closes the picker, submenu included, and makes sure it is closed.
|
|
697
|
+
*
|
|
698
|
+
* Escape pressed on the page was all this did, three times, and then it gave up without a word.
|
|
699
|
+
* Live, that left the model list open over the chat after a check of the model: Escape goes to
|
|
700
|
+
* whatever has focus, and after a click dispatched on a row, or no click at all because the model
|
|
701
|
+
* was already chosen, focus was not in the menu, so nothing heard it. Each way a person would close
|
|
702
|
+
* it is tried in turn, and the menu is looked at after each: Escape; Escape sent to the menu itself;
|
|
703
|
+
* the picker button again (it toggles); a click outside the menu, in the composer, which a Fluent
|
|
704
|
+
* popup takes as "close". A menu still open after all of them is reported, not passed over.
|
|
705
|
+
*/
|
|
684
706
|
async closeMenu() {
|
|
685
|
-
|
|
686
|
-
await this.p.keyboard.press('Escape')
|
|
687
|
-
|
|
688
|
-
|
|
707
|
+
const ways = [
|
|
708
|
+
['escape', async () => await this.p.keyboard.press('Escape')],
|
|
709
|
+
[
|
|
710
|
+
'escape in the menu',
|
|
711
|
+
async () => {
|
|
712
|
+
for (const selector of Model.popupSelectors.slice(0, 2)) {
|
|
713
|
+
const menu = this.p.locator(`${selector}:visible`).last();
|
|
714
|
+
if ((await menu.count()) > 0) {
|
|
715
|
+
await menu.press('Escape', { timeout: 2_000 });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
[
|
|
722
|
+
'the picker button',
|
|
723
|
+
async () => {
|
|
724
|
+
const button = await this.resolveModelButton(1_000);
|
|
725
|
+
// Only while the button itself says the menu is open: clicking it otherwise would open it.
|
|
726
|
+
if (button && (await button.getAttribute('aria-expanded').catch(() => null)) === 'true')
|
|
727
|
+
await button.click({ timeout: 3_000 });
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
[
|
|
731
|
+
'a click outside',
|
|
732
|
+
async () => {
|
|
733
|
+
const composer = this.composer();
|
|
734
|
+
if ((await composer.count().catch(() => 0)) > 0 && (await composer.isVisible().catch(() => false)))
|
|
735
|
+
await composer.click({ timeout: 3_000 });
|
|
736
|
+
else
|
|
737
|
+
await this.p.mouse.click(4, 4);
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
];
|
|
741
|
+
for (const [how, act] of ways) {
|
|
742
|
+
if (!(await this.modelMenuOpen()))
|
|
743
|
+
return;
|
|
744
|
+
await act().catch(() => undefined);
|
|
745
|
+
await this.p.waitForTimeout(250);
|
|
746
|
+
if (!(await this.modelMenuOpen())) {
|
|
747
|
+
if (how !== 'escape')
|
|
748
|
+
this.emit('model-menu-closed', { how });
|
|
689
749
|
return;
|
|
750
|
+
}
|
|
690
751
|
}
|
|
752
|
+
if (await this.modelMenuOpen())
|
|
753
|
+
this.emit('model-menu-stuck', { tried: ways.map(([how]) => how) });
|
|
691
754
|
}
|
|
692
755
|
/** The first popup container that becomes visible after the picker is clicked. */
|
|
693
756
|
async waitForPopup(timeoutMs = 8_000) {
|
|
@@ -750,12 +813,29 @@ Current URL: ${url}`);
|
|
|
750
813
|
await this.closeMenu();
|
|
751
814
|
return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
|
|
752
815
|
}
|
|
816
|
+
/*
|
|
817
|
+
* Already the one in force: close the menu and say so, without clicking.
|
|
818
|
+
*
|
|
819
|
+
* The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
|
|
820
|
+
* reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
|
|
821
|
+
* model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
|
|
822
|
+
* click it used to get did nothing in Copilot: the row already in force ignores it, the menu
|
|
823
|
+
* stayed open over the composer, and the run went on with the model list hanging on screen.
|
|
824
|
+
*/
|
|
825
|
+
if (target.selected) {
|
|
826
|
+
await this.closeMenu();
|
|
827
|
+
this.emit('model-already-selected', { model: target.name, buttonShows: before });
|
|
828
|
+
return { ok: true, current: target.name };
|
|
829
|
+
}
|
|
753
830
|
if (target.disabled) {
|
|
754
831
|
await this.closeMenu();
|
|
755
832
|
return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
|
|
756
833
|
}
|
|
757
834
|
await this.clickMenuRow(target.name, inSubmenu);
|
|
758
835
|
await this.p.waitForTimeout(1_000);
|
|
836
|
+
// Whatever the click did, the menu is not left open over the chat: a build that keeps it open
|
|
837
|
+
// after a choice, or a click that did not land, would otherwise leave it for the next message.
|
|
838
|
+
await this.closeMenu();
|
|
759
839
|
const after = await this.currentModel();
|
|
760
840
|
// The button shows the choice, so it is the check. It also **shortens** it: picking
|
|
761
841
|
// "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
|
package/dist/src/vcs/scope.js
CHANGED
|
@@ -65,12 +65,13 @@ export function inScope(path, scope) {
|
|
|
65
65
|
* Puts back every change outside `scope`. Only for a repository whose tree was clean when the task
|
|
66
66
|
* started, which is why the caller passes the repository only when version control is active.
|
|
67
67
|
*/
|
|
68
|
-
export async function enforceScope(dir, scope) {
|
|
68
|
+
export async function enforceScope(dir, scope, nothing = false) {
|
|
69
69
|
const out = { outside: [], reverted: [], failed: [] };
|
|
70
|
-
|
|
70
|
+
// An empty scope allows everything; `nothing` is the read-only task, which allows no change at all.
|
|
71
|
+
if (scope.length === 0 && !nothing)
|
|
71
72
|
return out;
|
|
72
73
|
const changed = await workingTreePaths(dir);
|
|
73
|
-
out.outside = changed.filter((p) => !inScope(p, scope));
|
|
74
|
+
out.outside = nothing ? changed : changed.filter((p) => !inScope(p, scope));
|
|
74
75
|
const root = resolve(dir);
|
|
75
76
|
for (const rel of out.outside) {
|
|
76
77
|
const abs = resolve(join(dir, rel));
|
|
@@ -107,13 +108,15 @@ export async function enforceScope(dir, scope) {
|
|
|
107
108
|
return out;
|
|
108
109
|
}
|
|
109
110
|
/** What the chat is told, in the message that carries the step results. */
|
|
110
|
-
export function scopeMessage(check, scope) {
|
|
111
|
+
export function scopeMessage(check, scope, readOnly = false) {
|
|
111
112
|
const list = (paths) => paths.slice(0, 15).join(', ') + (paths.length > 15 ? `, and ${paths.length - 15} more` : '');
|
|
112
113
|
const parts = [
|
|
113
|
-
|
|
114
|
+
`${readOnly ? 'Changed by a read-only task' : "Outside this task's scope"}, so put back by the runner: ${list(check.reverted)}.`,
|
|
114
115
|
check.failed.length > 0 ? `Could not be put back: ${check.failed.map((f) => `${f.path} (${f.why})`).join('; ')}.` : '',
|
|
115
|
-
|
|
116
|
-
'
|
|
116
|
+
readOnly
|
|
117
|
+
? 'This task is read-only: it may change no file at all. Read, run and report; put what you found in your summary.'
|
|
118
|
+
: `This task may change only: ${scope.join(', ')}. If the work truly needs a file outside that, do not work around it: ` +
|
|
119
|
+
'end with status "blocked", name the file in "needed" and say why.',
|
|
117
120
|
];
|
|
118
121
|
return parts.filter(Boolean).join(' ');
|
|
119
122
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command that brings a project's current branch back to the remote's main branch, handed to
|
|
3
|
+
* the operator rather than run.
|
|
4
|
+
*
|
|
5
|
+
* Asked for after a day of runs: a project left on a task branch, with files a step created and
|
|
6
|
+
* nobody committed, wants to go back to where the team's code is. That is `fetch`, `reset --hard`
|
|
7
|
+
* to the remote's main, and `clean -fd` for the new files git does not ignore — three commands that
|
|
8
|
+
* destroy work if pointed at the wrong folder or run a minute too early. So the bot never runs them.
|
|
9
|
+
* It reads the repository, says exactly what would be lost — commits on this branch that the
|
|
10
|
+
* remote's main does not have, changed tracked files, new files — and gives the command, ready to
|
|
11
|
+
* paste into PowerShell, with a read-only preview beside it. Ignored files (`node_modules`, `.env`,
|
|
12
|
+
* build output) are left alone: `clean` without `-x` does not touch them.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here writes to the repository or the network. `git fetch` is in the command, not here:
|
|
15
|
+
* what "would be lost" is computed against the remote branch as last fetched, and the page says so.
|
|
16
|
+
*/
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { git, porcelainPaths } from './git.js';
|
|
20
|
+
/** A string as a single-quoted PowerShell literal: nothing inside it is expanded. */
|
|
21
|
+
export function psQuote(text) {
|
|
22
|
+
return `'${text.replace(/'/g, "''")}'`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The two commands, for Windows PowerShell 5.1 and PowerShell 7 alike: `;` and `$LASTEXITCODE`
|
|
26
|
+
* rather than `&&`, which 5.1 does not have, so a failed fetch stops everything after it instead
|
|
27
|
+
* of resetting to a remote branch that is out of date.
|
|
28
|
+
*/
|
|
29
|
+
export function syncCommands(dir, remote, target) {
|
|
30
|
+
const g = `git -C ${psQuote(dir)}`;
|
|
31
|
+
return {
|
|
32
|
+
preview: [
|
|
33
|
+
`${g} fetch ${psQuote(remote)}`,
|
|
34
|
+
`${g} log --oneline ${psQuote(`${target}..HEAD`)}`,
|
|
35
|
+
`${g} status --short`,
|
|
36
|
+
`${g} clean -nd`,
|
|
37
|
+
].join('; '),
|
|
38
|
+
command: `${g} fetch ${psQuote(remote)}; if ($LASTEXITCODE -eq 0) { ${g} reset --hard ${psQuote(target)}; ` +
|
|
39
|
+
`if ($LASTEXITCODE -eq 0) { ${g} clean -fd } }`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const empty = (repoDir, problem) => ({
|
|
43
|
+
ok: false,
|
|
44
|
+
problem,
|
|
45
|
+
repoDir,
|
|
46
|
+
willLose: { commits: [], changed: [], untracked: [] },
|
|
47
|
+
preview: '',
|
|
48
|
+
command: '',
|
|
49
|
+
});
|
|
50
|
+
export async function planSync(repoDir) {
|
|
51
|
+
const inside = await git(repoDir, ['rev-parse', '--is-inside-work-tree']);
|
|
52
|
+
if (!inside.ok || inside.stdout !== 'true')
|
|
53
|
+
return empty(repoDir, `${repoDir} is not a git repository.`);
|
|
54
|
+
const branch = await git(repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
55
|
+
if (!branch.ok || !branch.stdout) {
|
|
56
|
+
return empty(repoDir, 'The repository is not on a branch (a detached HEAD), so there is no "current branch" to bring up to date. Check out a branch first.');
|
|
57
|
+
}
|
|
58
|
+
const remotes = (await git(repoDir, ['remote'])).stdout.split('\n').map((r) => r.trim()).filter(Boolean);
|
|
59
|
+
if (remotes.length === 0)
|
|
60
|
+
return empty(repoDir, 'The repository has no remote, so there is no remote main branch to go back to.');
|
|
61
|
+
const remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
62
|
+
// The remote's own idea of its main branch first, then the two usual names.
|
|
63
|
+
let target = '';
|
|
64
|
+
const head = await git(repoDir, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`]);
|
|
65
|
+
if (head.ok && head.stdout)
|
|
66
|
+
target = head.stdout;
|
|
67
|
+
for (const name of ['main', 'master']) {
|
|
68
|
+
if (target)
|
|
69
|
+
break;
|
|
70
|
+
const found = await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`]);
|
|
71
|
+
if (found.ok)
|
|
72
|
+
target = `${remote}/${name}`;
|
|
73
|
+
}
|
|
74
|
+
if (!target) {
|
|
75
|
+
return empty(repoDir, `No main branch of ${remote} is known here yet. Run ${syncCommands(repoDir, remote, `${remote}/main`).preview.split('; ')[0]} once, then look again.`);
|
|
76
|
+
}
|
|
77
|
+
const commits = await git(repoDir, ['log', '--format=%h %s', `${target}..HEAD`]);
|
|
78
|
+
const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
79
|
+
const clean = await git(repoDir, ['clean', '-nd']);
|
|
80
|
+
let lastFetched;
|
|
81
|
+
try {
|
|
82
|
+
const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
|
|
83
|
+
lastFetched = statSync(join(gitDir, 'FETCH_HEAD')).mtime.toISOString();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* never fetched: the page says so */
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
repoDir,
|
|
91
|
+
branch: branch.stdout,
|
|
92
|
+
remote,
|
|
93
|
+
target,
|
|
94
|
+
...(lastFetched ? { lastFetched } : {}),
|
|
95
|
+
willLose: {
|
|
96
|
+
commits: commits.ok && commits.stdout ? commits.stdout.split('\n') : [],
|
|
97
|
+
changed: porcelainPaths(status.stdout),
|
|
98
|
+
untracked: clean.stdout
|
|
99
|
+
.split('\n')
|
|
100
|
+
.map((l) => l.replace(/^Would remove\s+/, '').trim())
|
|
101
|
+
.filter(Boolean),
|
|
102
|
+
},
|
|
103
|
+
...syncCommands(repoDir, remote, target),
|
|
104
|
+
};
|
|
105
|
+
}
|