copilot-operator 0.1.38 → 0.1.40

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 (55) hide show
  1. package/dist/src/api/operator.controller.js +128 -2
  2. package/dist/src/api/operator.service.js +473 -39
  3. package/dist/src/api/server.js +7 -0
  4. package/dist/src/log/terminal.js +145 -0
  5. package/dist/src/orchestrator/contract.js +77 -6
  6. package/dist/src/plan/brief.js +1375 -1369
  7. package/dist/src/plan/schema.js +5 -0
  8. package/dist/src/plan/systemGuide.js +218 -20
  9. package/dist/src/session/exports.js +1 -0
  10. package/dist/src/session/startFailure.js +91 -0
  11. package/dist/src/vcs/prepareFromRemote.js +7 -20
  12. package/dist/src/vcs/remoteAuth.js +26 -1
  13. package/dist/src/vcs/runPreflight.js +31 -14
  14. package/dist/src/vcs/snapshot.js +3 -1
  15. package/dist/src/vcs/startChoice.js +295 -0
  16. package/dist/src/vcs/taskVcs.js +61 -5
  17. package/dist/web/404.html +2 -2
  18. package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
  19. package/dist/web/_next/static/chunks/{549-7e35e4fdefadb05d.js → 549-f295c8429e83b25b.js} +1 -1
  20. package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
  21. package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
  23. package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
  24. package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
  25. package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
  26. package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
  27. package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
  28. package/dist/web/appearance.html +2 -2
  29. package/dist/web/appearance.txt +28 -26
  30. package/dist/web/defaults.html +2 -2
  31. package/dist/web/defaults.txt +28 -26
  32. package/dist/web/history.html +2 -2
  33. package/dist/web/history.txt +28 -26
  34. package/dist/web/import.html +2 -2
  35. package/dist/web/import.txt +28 -26
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/index.txt +28 -26
  38. package/dist/web/level1.html +2 -2
  39. package/dist/web/level1.txt +28 -26
  40. package/dist/web/presets.html +2 -2
  41. package/dist/web/presets.txt +28 -26
  42. package/dist/web/sessions/view.html +2 -2
  43. package/dist/web/sessions/view.txt +28 -26
  44. package/dist/web/system.html +2 -2
  45. package/dist/web/system.txt +28 -26
  46. package/package.json +158 -156
  47. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +0 -1
  48. package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +0 -1
  49. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +0 -1
  50. package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +0 -1
  51. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +0 -1
  52. package/dist/web/_next/static/chunks/app/sessions/view/page-ce99494b1997ee25.js +0 -1
  53. package/dist/web/_next/static/css/c37978b44194c37b.css +0 -1
  54. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_buildManifest.js +0 -0
  55. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_ssgManifest.js +0 -0
@@ -22,6 +22,7 @@ import { pruneRuns } from '../session/retention.js';
22
22
  import { mkdir, writeFile } from 'node:fs/promises';
23
23
  import { existsSync } from 'node:fs';
24
24
  import { installLayout } from '../config/layout.js';
25
+ import { captureTerminal } from '../log/terminal.js';
25
26
  /**
26
27
  * A settings file, or a policy lock, that cannot be used, answered the same way on every route: 409,
27
28
  * with the message that names the file and says what to do (see `SettingsUnusableError` and
@@ -50,6 +51,11 @@ export async function startApi(opts = {}) {
50
51
  const PORT = opts.port ?? Number(process.env.COP_API_PORT ?? 4000);
51
52
  const WEB_ORIGIN = opts.webOrigin ?? process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
52
53
  const log = opts.quiet ? () => undefined : (line) => console.log(line);
54
+ /*
55
+ * What the console shows from here on is copied for the page to offer as a file (see
56
+ * `log/terminal.ts`): kept in memory now, written to the data folder once its permissions are set.
57
+ */
58
+ captureTerminal();
53
59
  const app = await NestFactory.create(AppModule, { logger: opts.quiet ? ['error', 'warn'] : ['error', 'warn', 'log'] });
54
60
  // A plan pasted from a chat carries every task's text at once, which the 100 KB default
55
61
  // refuses with a message about entity size that says nothing about plans.
@@ -96,6 +102,7 @@ export async function startApi(opts = {}) {
96
102
  const acl = secureDataDir(dataDir);
97
103
  if (!acl.ok)
98
104
  throw new Error(`copilot-operator api will not start: ${acl.reason}`);
105
+ captureTerminal(join(dataDir, 'logs'));
99
106
  const token = await ensureApiToken(dataDir);
100
107
  // The dev UI's origin, and this process's own for the interface it serves itself.
101
108
  const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
@@ -0,0 +1,145 @@
1
+ /**
2
+ * What the bot's terminal showed, kept so it can be downloaded from the page.
3
+ *
4
+ * Asked for on 2026-10-13: when a run does not start at all, the operator wants to see the error, and
5
+ * a place to download what happened in the terminal. The terminal is the console the API runs in —
6
+ * `npm start` or `cop start` — and what it printed was on that screen only: a stack trace from
7
+ * Playwright about the browser profile, a refusal from git, scrolled away or closed with the window.
8
+ *
9
+ * Everything this process writes to stdout and stderr is copied, line by line with the time it was
10
+ * written, into a ring of the latest lines in memory and into `<data>/logs/terminal.log` on disk (the
11
+ * data folder is narrowed to this account; see `api/dataAcl.ts`), which rolls over to `terminal.1.log`
12
+ * at 5 MB. Writing to the console is not changed in any way: the copy is taken on the side, and a copy
13
+ * that fails is dropped rather than allowed to break the write it was copying.
14
+ */
15
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ const MAX_LINES = 5_000;
18
+ const MAX_LINE = 4_000;
19
+ const ROLL_AT = 5 * 1024 * 1024;
20
+ const ring = [];
21
+ const partial = { out: '', err: '' };
22
+ let installed = false;
23
+ let file = null;
24
+ let written = 0;
25
+ // eslint-disable-next-line no-control-regex
26
+ const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
27
+ /** The file the lines go to, once `captureTerminal` was given a folder. */
28
+ export function terminalLogFile() {
29
+ return file;
30
+ }
31
+ /**
32
+ * Starts copying this process's console, once; a later call only points the copy at another folder.
33
+ * Lines written before a folder is known are kept in memory and written to the file when it is.
34
+ */
35
+ export function captureTerminal(dir) {
36
+ if (dir)
37
+ setFolder(dir);
38
+ if (installed)
39
+ return;
40
+ installed = true;
41
+ for (const stream of ['out', 'err']) {
42
+ const target = stream === 'out' ? process.stdout : process.stderr;
43
+ const original = target.write.bind(target);
44
+ target.write = ((chunk, ...rest) => {
45
+ try {
46
+ take(stream, typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk instanceof Uint8Array ? Buffer.from(chunk).toString('utf8') : '');
47
+ }
48
+ catch {
49
+ /* the copy is never allowed to break the write */
50
+ }
51
+ return original(chunk, ...rest);
52
+ });
53
+ }
54
+ }
55
+ function setFolder(dir) {
56
+ try {
57
+ mkdirSync(dir, { recursive: true });
58
+ const next = join(dir, 'terminal.log');
59
+ if (next === file)
60
+ return;
61
+ const first = file === null;
62
+ file = next;
63
+ written = sizeOf(next);
64
+ // What was printed before the folder was known — the start-up — goes in first.
65
+ if (first && ring.length > 0)
66
+ append(ring.map(format).join(''));
67
+ }
68
+ catch {
69
+ file = null;
70
+ }
71
+ }
72
+ function take(stream, text) {
73
+ if (!text)
74
+ return;
75
+ const parts = (partial[stream] + text).split(/\r?\n/);
76
+ partial[stream] = parts.pop() ?? '';
77
+ // A line that never ends (a progress bar) is still kept, in pieces.
78
+ if (partial[stream].length > MAX_LINE) {
79
+ parts.push(partial[stream]);
80
+ partial[stream] = '';
81
+ }
82
+ const at = new Date().toISOString();
83
+ const out = [];
84
+ for (const raw of parts) {
85
+ const clean = raw.replace(ANSI, '').replace(/\r/g, '');
86
+ if (!clean.trim())
87
+ continue;
88
+ out.push({ at, stream, text: clean.length > MAX_LINE ? `${clean.slice(0, MAX_LINE)} …` : clean });
89
+ }
90
+ if (out.length === 0)
91
+ return;
92
+ ring.push(...out);
93
+ if (ring.length > MAX_LINES)
94
+ ring.splice(0, ring.length - MAX_LINES);
95
+ if (file)
96
+ append(out.map(format).join(''));
97
+ }
98
+ function format(l) {
99
+ return `${l.at} ${l.stream === 'err' ? 'ERR' : 'OUT'} ${l.text}\n`;
100
+ }
101
+ function append(text) {
102
+ if (!file)
103
+ return;
104
+ try {
105
+ if (written + text.length > ROLL_AT) {
106
+ try {
107
+ renameSync(file, file.replace(/\.log$/, '.1.log'));
108
+ }
109
+ catch {
110
+ /* nothing to roll over yet */
111
+ }
112
+ written = 0;
113
+ }
114
+ appendFileSync(file, text, 'utf8');
115
+ written += Buffer.byteLength(text);
116
+ }
117
+ catch {
118
+ /* a full disk or a locked file costs the copy, not the console */
119
+ }
120
+ }
121
+ function sizeOf(path) {
122
+ try {
123
+ return statSync(path).size;
124
+ }
125
+ catch {
126
+ return 0;
127
+ }
128
+ }
129
+ /** The lines kept in memory from `from` to `to` (both inclusive, ISO times), the latest `max` of them. */
130
+ export function terminalLines(opts = {}) {
131
+ const within = ring.filter((l) => (!opts.from || l.at >= opts.from) && (!opts.to || l.at <= opts.to));
132
+ return opts.max !== undefined && within.length > opts.max ? within.slice(within.length - opts.max) : within;
133
+ }
134
+ /** The lines as the download shows them: local time, the stream, the text. */
135
+ export function formatTerminal(list) {
136
+ return list.map((l) => `${localTime(l.at)} ${l.stream === 'err' ? 'ERR' : ' '} ${l.text}`).join('\n');
137
+ }
138
+ /** `2026-10-13 10:22:31.123` in this machine's time zone. */
139
+ export function localTime(iso) {
140
+ const d = new Date(iso);
141
+ if (Number.isNaN(d.getTime()))
142
+ return iso;
143
+ const p = (n, w = 2) => String(n).padStart(w, '0');
144
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
145
+ }
@@ -79,11 +79,68 @@ function branchCheckPasses(check, branch) {
79
79
  return null;
80
80
  }
81
81
  /** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
82
- export async function contractConflicts(task, cwd, repoDir,
82
+ export async function contractConflicts(task, cwd, repoDir, vcs = {}) {
83
+ return (await contractConflictsWithFixes(task, cwd, repoDir, vcs)).map((c) => c.text);
84
+ }
85
+ /**
86
+ * The contract fixes applied to the task as it is now, as the patch an edit or a re-run takes. In the
87
+ * order given; a fix naming a check the task no longer has changes nothing. Nothing the operator did not
88
+ * choose is changed: a read-only task keeps its paths unless "drop-scope" was chosen, even though the two
89
+ * contradict each other, because leaving that contradiction was the operator's choice.
90
+ */
91
+ export function contractFixPatch(task, fixes) {
92
+ let readOnly = !!task.readOnly;
93
+ let scope = [...(task.scope ?? [])];
94
+ let checks = [...(task.checks ?? [])];
95
+ for (const fix of fixes) {
96
+ if (fix.kind === 'add-to-scope') {
97
+ if (!scope.includes(fix.path))
98
+ scope.push(fix.path);
99
+ }
100
+ else if (fix.kind === 'not-read-only') {
101
+ readOnly = false;
102
+ for (const p of fix.scope)
103
+ if (!scope.includes(p))
104
+ scope.push(p);
105
+ }
106
+ else if (fix.kind === 'drop-scope') {
107
+ scope = [];
108
+ }
109
+ else if (fix.kind === 'drop-check') {
110
+ checks = checks.filter((c) => c.name !== fix.check);
111
+ }
112
+ }
113
+ return { readOnly, scope, checks };
114
+ }
115
+ /**
116
+ * What changed in the contract, read from before and after rather than from the fixes chosen: two fixes
117
+ * can pull different ways ("drop the paths" for one contradiction, "allow only this file" for another),
118
+ * and their descriptions side by side read as a contradiction; the result does not. For a log line or
119
+ * an event.
120
+ */
121
+ export function describeContractChange(before, after) {
122
+ const out = [];
123
+ if (!!before.readOnly !== !!after.readOnly)
124
+ out.push(after.readOnly ? 'now read-only' : 'no longer read-only');
125
+ const was = (before.scope ?? []).join(', ');
126
+ const now = (after.scope ?? []).join(', ');
127
+ if (was !== now)
128
+ out.push(now ? `may change only ${now}${was ? ` (was ${was})` : ''}` : `its list of paths removed (was ${was})`);
129
+ const kept = new Set((after.checks ?? []).map((c) => c.name));
130
+ for (const c of before.checks ?? [])
131
+ if (!kept.has(c.name))
132
+ out.push(`the check "${c.name}" dropped`);
133
+ return out;
134
+ }
135
+ /** Each contradiction in the task, with the ways out of it; empty when it can be satisfied. */
136
+ export async function contractConflictsWithFixes(task, cwd, repoDir,
83
137
  /** The branch version control put the task on, when it is on; where its file checks are judged (see `ContractTree`). */
84
138
  vcs = {}) {
85
139
  const out = [];
86
140
  const scope = task.scope ?? [];
141
+ // Only a check the task itself carries can be dropped from it; a review's check is that review's finding.
142
+ const own = new Set((task.checks ?? []).map((c) => c.name));
143
+ const dropIfOwn = (check) => (own.has(check.name) ? [{ kind: 'drop-check', check: check.name }] : []);
87
144
  /*
88
145
  * A check that the task is on a branch the runner did not choose can never pass: the chat may not
89
146
  * switch branches. Seen on 2026-09-30, a check still expecting the recovery branch the session had
@@ -95,12 +152,19 @@ vcs = {}) {
95
152
  for (const check of all.filter(readsCurrentBranch)) {
96
153
  if (branchCheckPasses(check, vcs.branch) !== false)
97
154
  continue;
98
- out.push(`The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
99
- 'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.');
155
+ out.push({
156
+ text: `The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
157
+ 'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.',
158
+ fixes: dropIfOwn(check),
159
+ });
100
160
  }
101
161
  }
102
162
  if (task.readOnly && scope.length > 0) {
103
- out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
163
+ out.push({
164
+ text: `It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`,
165
+ // Read-only already means "nowhere", so dropping the paths changes nothing the task could do.
166
+ fixes: [{ kind: 'drop-scope' }, { kind: 'not-read-only', scope: [...scope] }],
167
+ });
104
168
  }
105
169
  if (!task.readOnly && scope.length === 0)
106
170
  return out;
@@ -125,10 +189,17 @@ vcs = {}) {
125
189
  if (inside && (vcs.carried?.length ?? 0) > 0 && inScope(rel, vcs.carried))
126
190
  continue;
127
191
  if (task.readOnly) {
128
- out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
192
+ out.push({
193
+ text: `The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`,
194
+ // Allowed to change that one file and nothing else: the least a read-only task can be loosened.
195
+ fixes: [...(inside ? [{ kind: 'not-read-only', scope: [rel] }] : []), ...dropIfOwn(check)],
196
+ });
129
197
  }
130
198
  else if (inside && !inScope(rel, scope)) {
131
- out.push(`The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`);
199
+ out.push({
200
+ text: `The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`,
201
+ fixes: [{ kind: 'add-to-scope', path: rel }, ...dropIfOwn(check)],
202
+ });
132
203
  }
133
204
  }
134
205
  return out;