copilot-operator 0.1.7 → 0.1.9

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 (72) hide show
  1. package/dist/src/api/main.js +2 -104
  2. package/dist/src/api/operator.controller.js +18 -1
  3. package/dist/src/api/operator.service.js +30 -11
  4. package/dist/src/api/server.js +114 -0
  5. package/dist/src/config/schema.js +5 -0
  6. package/dist/src/exec/checks.js +17 -0
  7. package/dist/src/orchestrator/progress.js +106 -0
  8. package/dist/src/orchestrator/taskRunner.js +110 -10
  9. package/dist/src/plan/brief.js +6 -4
  10. package/dist/src/plan/importPlan.js +1 -0
  11. package/dist/src/plan/schema.js +7 -0
  12. package/dist/src/plan/systemGuide.js +67 -12
  13. package/dist/src/session/compose.js +25 -20
  14. package/dist/src/session/exports.js +5 -0
  15. package/dist/src/session/handoff.js +42 -0
  16. package/dist/src/session/interruption.js +104 -0
  17. package/dist/src/session/metrics.js +95 -0
  18. package/dist/src/session/store.js +35 -5
  19. package/dist/src/session/story.js +1 -1
  20. package/dist/src/transport/chatTransport.js +32 -0
  21. package/dist/src/vcs/contentIntegrity.js +174 -0
  22. package/dist/src/vcs/git.js +42 -0
  23. package/dist/src/vcs/scope.js +129 -0
  24. package/dist/src/vcs/taskVcs.js +43 -2
  25. package/dist/web/404.html +4 -3
  26. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +1 -0
  27. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
  28. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +1 -0
  29. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
  30. package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +1 -0
  31. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +1 -0
  32. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +1 -0
  33. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
  34. package/dist/web/_next/static/chunks/app/level1/page-be366b35f4cc97ce.js +1 -0
  35. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +1 -0
  36. package/dist/web/_next/static/chunks/app/presets/page-cabc3876a8111edd.js +1 -0
  37. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +1 -0
  38. package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
  39. package/dist/web/appearance.html +4 -3
  40. package/dist/web/appearance.txt +12 -12
  41. package/dist/web/defaults.html +4 -3
  42. package/dist/web/defaults.txt +12 -12
  43. package/dist/web/history.html +4 -3
  44. package/dist/web/history.txt +12 -12
  45. package/dist/web/import.html +4 -3
  46. package/dist/web/import.txt +12 -12
  47. package/dist/web/index.html +4 -3
  48. package/dist/web/index.txt +12 -12
  49. package/dist/web/level1.html +4 -3
  50. package/dist/web/level1.txt +12 -12
  51. package/dist/web/presets.html +4 -3
  52. package/dist/web/presets.txt +12 -12
  53. package/dist/web/sessions/view.html +4 -3
  54. package/dist/web/sessions/view.txt +12 -12
  55. package/dist/web/system.html +4 -3
  56. package/dist/web/system.txt +12 -12
  57. package/package.json +13 -4
  58. package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
  59. package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
  60. package/dist/web/_next/static/chunks/867-4aa80f5a32311bce.js +0 -1
  61. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
  62. package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +0 -1
  63. package/dist/web/_next/static/chunks/app/history/page-2251664c0abe3f11.js +0 -1
  64. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
  65. package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
  66. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
  67. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
  68. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
  69. package/dist/web/_next/static/chunks/app/sessions/view/page-9517dd74c6d00f85.js +0 -1
  70. package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
  71. /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_buildManifest.js +0 -0
  72. /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_ssgManifest.js +0 -0
@@ -7,109 +7,7 @@
7
7
  * commands on this machine. Exposing it on a network interface would hand that to anyone who
8
8
  * could reach the port.
9
9
  */
10
- import 'reflect-metadata';
11
- import { NestFactory } from '@nestjs/core';
12
- import { join } from 'node:path';
13
- import { AppModule } from './app.module.js';
14
- import { OperatorService } from './operator.service.js';
15
- import { ensureApiToken, localApiGuard } from './security.js';
16
- import { secureDataDir } from './dataAcl.js';
17
- import { pruneRuns } from '../session/retention.js';
18
- import { mkdir, writeFile } from 'node:fs/promises';
19
- import { existsSync } from 'node:fs';
20
- import { installLayout } from '../config/layout.js';
21
- const PORT = Number(process.env.COP_API_PORT ?? 4000);
22
- const WEB_ORIGIN = process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
23
- async function main() {
24
- const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] });
25
- // A plan pasted from a chat carries every task's text at once, which the 100 KB default
26
- // refuses with a message about entity size that says nothing about plans.
27
- app.useBodyParser('json', { limit: '5mb' });
28
- app.enableCors({
29
- origin: [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1')],
30
- /*
31
- * The one header the page has to be able to read.
32
- *
33
- * A browser hands script only a handful of response headers across origins, and
34
- * `content-disposition` is not among them unless it is named here. The UI is on
35
- * `localhost:3210` and this is on `127.0.0.1:4000`, so every fetch is cross-origin: without
36
- * this, a download built from a blob cannot find out what the file is called, and the
37
- * carefully composed `copilot-operator-bundle-2-tasks-2-sessions-<stamp>.json` arrives as
38
- * whatever the client guessed. The plain `<a download>` exports never noticed, because the
39
- * browser reads the header itself for those and never shows it to the page.
40
- */
41
- exposedHeaders: ['content-disposition'],
42
- });
43
- app.setGlobalPrefix('api');
44
- /*
45
- * Who may drive this. Mounted before routing so it covers every route including the SSE stream,
46
- * and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
47
- * was never the whole answer — a page in the operator's ordinary browser and any other process
48
- * on the machine were both callers all along.
49
- */
50
- const layout = installLayout();
51
- const dataDir = layout.dataDir;
52
- await mkdir(dataDir, { recursive: true });
53
- if (layout.mode === 'package') {
54
- /*
55
- * The records folder sits inside the project, so it must be invisible to the project's git:
56
- * version control refuses to branch on a tree with untracked files, and the records are not
57
- * the project's work. A `.gitignore` of `*` inside the folder says so without touching the
58
- * project's own .gitignore.
59
- */
60
- const ignore = join(layout.homeDir, '.gitignore');
61
- if (!existsSync(ignore))
62
- await writeFile(ignore, '*\n', 'utf8');
63
- }
64
- // Before the token is read: a token any local account can read guards nothing. See `dataAcl.ts`.
65
- const acl = secureDataDir(dataDir);
66
- if (!acl.ok) {
67
- console.error(`copilot-operator api will not start: ${acl.reason}`);
68
- process.exit(1);
69
- }
70
- const token = await ensureApiToken(dataDir);
71
- // The dev UI's origin, and this process's own for the interface it serves itself.
72
- const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
73
- app.use(localApiGuard({ token, port: PORT, allowedOrigins, servesInterface: layout.webDir !== null }));
74
- if (layout.webDir) {
75
- // The prebuilt interface, from the package: one process, one port, no dev server.
76
- app.useStaticAssets(layout.webDir, { extensions: ['html'], index: 'index.html' });
77
- }
78
- await app.listen(PORT, '127.0.0.1');
79
- // Close whatever the previous process left open before anyone can look at it, rather than
80
- // waiting for the first request to trigger it.
81
- const ops = app.get(OperatorService);
82
- await ops.bootstrap();
83
- /*
84
- * runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
85
- * as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
86
- * parent folder had. Narrowed the same way, and pruned to the configured retention.
87
- */
88
- const cfg = await ops.settings.load();
89
- await mkdir(cfg.resolved.runsDir, { recursive: true });
90
- const runsAcl = secureDataDir(cfg.resolved.runsDir);
91
- if (!runsAcl.ok) {
92
- console.error(`copilot-operator api will not start: ${runsAcl.reason}`);
93
- process.exit(1);
94
- }
95
- for (const [name, dir] of [['data', dataDir], ['runs', cfg.resolved.runsDir]]) {
96
- if (/onedrive/i.test(dir) || /^\\\\/.test(dir)) {
97
- console.warn(` warning: the ${name} folder (${dir}) is under OneDrive or on a network share; keep the bot's records on a local disk (COP_DATA_DIR, runsDir)`);
98
- }
99
- }
100
- const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
101
- if (pruned.length > 0)
102
- console.log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
103
- if (layout.webDir) {
104
- console.log(`copilot-operator is running for ${layout.projectRoot}`);
105
- console.log(` open http://127.0.0.1:${PORT}/ in your browser`);
106
- console.log(` records are kept in ${layout.homeDir}`);
107
- }
108
- else {
109
- console.log(`copilot-operator api listening on http://127.0.0.1:${PORT}/api (web origin ${WEB_ORIGIN})`);
110
- console.log(` requests need the token in ${join(dataDir, 'api-token')}; npm start hands it to the UI`);
111
- }
112
- }
10
+ import { startApi } from './server.js';
113
11
  /*
114
12
  * A stray rejection must not end a run.
115
13
  *
@@ -126,7 +24,7 @@ process.on('unhandledRejection', (reason) => {
126
24
  process.on('uncaughtException', (error) => {
127
25
  console.error('[api] uncaught exception (the run continues):', error.stack ?? error.message);
128
26
  });
129
- main().catch((e) => {
27
+ startApi().catch((e) => {
130
28
  console.error(e.message);
131
29
  process.exit(1);
132
30
  });
@@ -10,7 +10,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  var __param = (this && this.__param) || function (paramIndex, decorator) {
11
11
  return function (target, key) { decorator(target, key, paramIndex); }
12
12
  };
13
- import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, Sse, NotFoundException, BadRequestException } from '@nestjs/common';
13
+ import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Query, Res, Sse, NotFoundException, BadRequestException } from '@nestjs/common';
14
14
  import { Observable } from 'rxjs';
15
15
  import { OperatorService } from './operator.service.js';
16
16
  function fail(e) {
@@ -37,6 +37,13 @@ function contentDisposition(fileName) {
37
37
  }
38
38
  let OperatorController = class OperatorController {
39
39
  ops;
40
+ /*
41
+ * Named explicitly rather than read from the parameter's type. Nest normally learns what to inject
42
+ * from the metadata `tsc` emits for a decorated class, and that metadata exists only when the code
43
+ * was compiled by `tsc`: run through tsx or any other esbuild-based loader — which is how every
44
+ * check in test/ runs, including the ones that start this API — the type is gone and `ops` arrived
45
+ * as undefined, so every route failed with a TypeError. With the token named, both ways work.
46
+ */
40
47
  constructor(ops) {
41
48
  this.ops = ops;
42
49
  }
@@ -491,6 +498,9 @@ let OperatorController = class OperatorController {
491
498
  tasks() {
492
499
  return this.ops.taskRegistry();
493
500
  }
501
+ metrics() {
502
+ return this.ops.metrics();
503
+ }
494
504
  // --- helpers for the UI -----------------------------------------------------------------
495
505
  dirs(root, gitignore) {
496
506
  if (!root)
@@ -1030,6 +1040,12 @@ __decorate([
1030
1040
  __metadata("design:paramtypes", []),
1031
1041
  __metadata("design:returntype", Promise)
1032
1042
  ], OperatorController.prototype, "tasks", null);
1043
+ __decorate([
1044
+ Get('metrics'),
1045
+ __metadata("design:type", Function),
1046
+ __metadata("design:paramtypes", []),
1047
+ __metadata("design:returntype", Promise)
1048
+ ], OperatorController.prototype, "metrics", null);
1033
1049
  __decorate([
1034
1050
  Get('dirs'),
1035
1051
  __param(0, Query('root')),
@@ -1054,6 +1070,7 @@ __decorate([
1054
1070
  ], OperatorController.prototype, "preview", null);
1055
1071
  OperatorController = __decorate([
1056
1072
  Controller(),
1073
+ __param(0, Inject(OperatorService)),
1057
1074
  __metadata("design:paramtypes", [OperatorService])
1058
1075
  ], OperatorController);
1059
1076
  export { OperatorController };
@@ -36,6 +36,7 @@ function sameFolder(a, b) {
36
36
  return a.trim() !== '' && norm(a) === norm(b);
37
37
  }
38
38
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
39
+ import { readInterruption } from '../session/interruption.js';
39
40
  import { commitInterrupted, repoDirOf, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
40
41
  import { changedFilesBetween, fileAt, gitAvailable, repoUnusableReason } from '../vcs/git.js';
41
42
  import { makeAuthorizer } from '../exec/authorizer.js';
@@ -44,7 +45,9 @@ import { pickFolder } from './folderPicker.js';
44
45
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
45
46
  import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } from '../exec/isolation.js';
46
47
  import { unattendedPrecondition } from '../exec/policy.js';
47
- import { CopilotTransport } from '../transport/copilotTransport.js';
48
+ import { createTransport } from '../transport/chatTransport.js';
49
+ import { composeHandoff } from '../session/handoff.js';
50
+ import { computeMetrics } from '../session/metrics.js';
48
51
  import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
49
52
  import { saveAndReveal } from './saveToDesktop.js';
50
53
  import { Settings } from './settings.js';
@@ -101,19 +104,28 @@ let OperatorService = class OperatorService {
101
104
  if (!session || !task)
102
105
  return;
103
106
  const vcs = await commitInterrupted(session, task, task.reason ?? 'interrupted', this.bus);
104
- const settled = vcs?.commit
105
- ? await this.store.updateSession(sessionId, (s) => {
106
- const t = s.tasks.find((x) => x.id === taskId);
107
- if (!t)
108
- return;
107
+ const cfg = await this.settings.load();
108
+ // Where it had got to, from its run folder, so "Continue" can tell the chat exactly that.
109
+ const interruption = task.runId ? await readInterruption(join(cfg.resolved.runsDir, task.runId)) : null;
110
+ const settled = await this.store.updateSession(sessionId, (s) => {
111
+ const t = s.tasks.find((x) => x.id === taskId);
112
+ if (!t)
113
+ return;
114
+ if (interruption)
115
+ t.interruption = interruption;
116
+ if (vcs?.commit) {
109
117
  t.vcs = vcs;
110
118
  t.reason = `${t.reason ?? ''} What it had changed is committed on ${vcs.branch} as ${vcs.commit?.slice(0, 8)}.`.trim();
111
- })
112
- : session;
119
+ }
120
+ // The same fixed-shape ending a task the runner finished gets; the steps the bot never
121
+ // reached are the interruption's own record of them.
122
+ t.handoff = composeHandoff(t, (interruption?.steps ?? [])
123
+ .filter((x) => x.state !== 'finished')
124
+ .map((x) => ({ command: x.command ?? `step ${x.id}`, why: x.state === 'cut' ? 'cut off when the bot stopped' : 'not reached before the bot stopped' })));
125
+ });
113
126
  const ended = settled.tasks.find((t) => t.id === taskId);
114
127
  if (!ended)
115
128
  return;
116
- const cfg = await this.settings.load();
117
129
  await writeAttemptRecord(settled, ended, cfg.resolved.runsDir, exportMachine(cfg));
118
130
  }
119
131
  /** Runs the startup work now, so it does not wait for the first request. */
@@ -314,7 +326,7 @@ let OperatorService = class OperatorService {
314
326
  * the text it ran with, and only then is the new text applied. Editing in place would leave
315
327
  * the old attempt's summary sitting under a question that was never asked.
316
328
  */
317
- /** "Continue" on a task that stopped at the runner's limit: same chat, same branch, fresh count. */
329
+ /** "Continue" on a task that stopped before it finished — its limit, the bot stopping, the operator: same chat, same branch, fresh count. */
318
330
  async continueTask(sessionId, taskId) {
319
331
  await this.init();
320
332
  const task = await this.store.continueTask(sessionId, taskId);
@@ -1947,7 +1959,7 @@ let OperatorService = class OperatorService {
1947
1959
  throw new Error('The model list is already being read.');
1948
1960
  this.readingModels = true;
1949
1961
  const cfg = await this.settings.load();
1950
- const transport = new CopilotTransport({
1962
+ const transport = createTransport({
1951
1963
  profileDir: cfg.resolved.profileDir,
1952
1964
  transportDir: join(cfg.resolved.runsDir, '_models'),
1953
1965
  chatUrl: cfg.copilot.url,
@@ -1978,6 +1990,11 @@ let OperatorService = class OperatorService {
1978
1990
  * what is still ahead; doing the flattening here keeps that split honest, because the
1979
1991
  * queue position is only knowable from the session's own list.
1980
1992
  */
1993
+ /** How well the bot is doing, added up from every task on record. See `session/metrics.ts`. */
1994
+ async metrics() {
1995
+ await this.init();
1996
+ return computeMetrics(await this.store.listSessions());
1997
+ }
1981
1998
  async taskRegistry() {
1982
1999
  await this.init();
1983
2000
  const sessions = await this.store.listSessions();
@@ -2017,9 +2034,11 @@ let OperatorService = class OperatorService {
2017
2034
  deviations: t.deviations?.length || undefined,
2018
2035
  disputes: t.disputes?.length || undefined,
2019
2036
  readOnly: t.readOnly || undefined,
2037
+ scope: t.scope?.length ? t.scope : undefined,
2020
2038
  autoRetries: t.autoRetries || undefined,
2021
2039
  attempt: t.attempt,
2022
2040
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
2041
+ interrupted: t.status === 'aborted' && !!t.interruption ? true : undefined,
2023
2042
  attempts: (t.attempts ?? []).map((a, n) => {
2024
2043
  const from = a.startedAt ? Date.parse(a.startedAt) : undefined;
2025
2044
  const to = a.finishedAt ? Date.parse(a.finishedAt) : undefined;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The API server: routes, the guard in front of them, the data folder's permissions, recovery of
3
+ * whatever the previous process left running. See `main.ts` for the process around it.
4
+ */
5
+ import 'reflect-metadata';
6
+ import { NestFactory } from '@nestjs/core';
7
+ import { join } from 'node:path';
8
+ import { AppModule } from './app.module.js';
9
+ import { OperatorService } from './operator.service.js';
10
+ import { ensureApiToken, localApiGuard } from './security.js';
11
+ import { secureDataDir } from './dataAcl.js';
12
+ import { pruneRuns } from '../session/retention.js';
13
+ import { mkdir, writeFile } from 'node:fs/promises';
14
+ import { existsSync } from 'node:fs';
15
+ import { installLayout } from '../config/layout.js';
16
+ /**
17
+ * Everything `main` used to do, as a function that returns the running server instead of owning the
18
+ * process. `main.ts` calls it and turns a failure into an exit code; the end-to-end checks call it
19
+ * with a scripted chat in place (see `transport/chatTransport.ts`) and close it when they are done.
20
+ * A refusal to start is thrown rather than ending the process, so a check that trips one is told why.
21
+ */
22
+ export async function startApi(opts = {}) {
23
+ const PORT = opts.port ?? Number(process.env.COP_API_PORT ?? 4000);
24
+ const WEB_ORIGIN = opts.webOrigin ?? process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
25
+ const log = opts.quiet ? () => undefined : (line) => console.log(line);
26
+ const app = await NestFactory.create(AppModule, { logger: opts.quiet ? ['error', 'warn'] : ['error', 'warn', 'log'] });
27
+ // A plan pasted from a chat carries every task's text at once, which the 100 KB default
28
+ // refuses with a message about entity size that says nothing about plans.
29
+ app.useBodyParser('json', { limit: '5mb' });
30
+ app.enableCors({
31
+ origin: [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1')],
32
+ /*
33
+ * The one header the page has to be able to read.
34
+ *
35
+ * A browser hands script only a handful of response headers across origins, and
36
+ * `content-disposition` is not among them unless it is named here. The UI is on
37
+ * `localhost:3210` and this is on `127.0.0.1:4000`, so every fetch is cross-origin: without
38
+ * this, a download built from a blob cannot find out what the file is called, and the
39
+ * carefully composed `copilot-operator-bundle-2-tasks-2-sessions-<stamp>.json` arrives as
40
+ * whatever the client guessed. The plain `<a download>` exports never noticed, because the
41
+ * browser reads the header itself for those and never shows it to the page.
42
+ */
43
+ exposedHeaders: ['content-disposition'],
44
+ });
45
+ app.setGlobalPrefix('api');
46
+ /*
47
+ * Who may drive this. Mounted before routing so it covers every route including the SSE stream,
48
+ * and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
49
+ * was never the whole answer — a page in the operator's ordinary browser and any other process
50
+ * on the machine were both callers all along.
51
+ */
52
+ const layout = installLayout();
53
+ const dataDir = layout.dataDir;
54
+ const webDir = opts.webDir ?? layout.webDir;
55
+ await mkdir(dataDir, { recursive: true });
56
+ if (layout.mode === 'package') {
57
+ /*
58
+ * The records folder sits inside the project, so it must be invisible to the project's git:
59
+ * version control refuses to branch on a tree with untracked files, and the records are not
60
+ * the project's work. A `.gitignore` of `*` inside the folder says so without touching the
61
+ * project's own .gitignore.
62
+ */
63
+ const ignore = join(layout.homeDir, '.gitignore');
64
+ if (!existsSync(ignore))
65
+ await writeFile(ignore, '*\n', 'utf8');
66
+ }
67
+ // Before the token is read: a token any local account can read guards nothing. See `dataAcl.ts`.
68
+ const acl = secureDataDir(dataDir);
69
+ if (!acl.ok)
70
+ throw new Error(`copilot-operator api will not start: ${acl.reason}`);
71
+ const token = await ensureApiToken(dataDir);
72
+ // The dev UI's origin, and this process's own for the interface it serves itself.
73
+ const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
74
+ app.use(localApiGuard({ token, port: PORT, allowedOrigins, servesInterface: webDir !== null }));
75
+ if (webDir) {
76
+ // The prebuilt interface, from the package: one process, one port, no dev server.
77
+ app.useStaticAssets(webDir, { extensions: ['html'], index: 'index.html' });
78
+ }
79
+ await app.listen(PORT, '127.0.0.1');
80
+ // Close whatever the previous process left open before anyone can look at it, rather than
81
+ // waiting for the first request to trigger it.
82
+ const ops = app.get(OperatorService);
83
+ await ops.bootstrap();
84
+ /*
85
+ * runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
86
+ * as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
87
+ * parent folder had. Narrowed the same way, and pruned to the configured retention.
88
+ */
89
+ const cfg = await ops.settings.load();
90
+ await mkdir(cfg.resolved.runsDir, { recursive: true });
91
+ const runsAcl = secureDataDir(cfg.resolved.runsDir);
92
+ if (!runsAcl.ok) {
93
+ await app.close();
94
+ throw new Error(`copilot-operator api will not start: ${runsAcl.reason}`);
95
+ }
96
+ for (const [name, dir] of [['data', dataDir], ['runs', cfg.resolved.runsDir]]) {
97
+ if (/onedrive/i.test(dir) || /^\\\\/.test(dir)) {
98
+ console.warn(` warning: the ${name} folder (${dir}) is under OneDrive or on a network share; keep the bot's records on a local disk (COP_DATA_DIR, runsDir)`);
99
+ }
100
+ }
101
+ const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
102
+ if (pruned.length > 0)
103
+ log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
104
+ if (layout.webDir) {
105
+ log(`copilot-operator is running for ${layout.projectRoot}`);
106
+ log(` open http://127.0.0.1:${PORT}/ in your browser`);
107
+ log(` records are kept in ${layout.homeDir}`);
108
+ }
109
+ else {
110
+ log(`copilot-operator api listening on http://127.0.0.1:${PORT}/api (web origin ${WEB_ORIGIN})`);
111
+ log(` requests need the token in ${join(dataDir, 'api-token')}; npm start hands it to the UI`);
112
+ }
113
+ return { app, port: PORT, token, dataDir, close: () => app.close() };
114
+ }
@@ -370,6 +370,11 @@ export const RunConfigSchema = z.object({
370
370
  * of turns" is not.
371
371
  */
372
372
  maxStalledIterations: z.number().int().positive().default(2),
373
+ /*
374
+ * Rounds in a row that end with the same error while no file changes, before the task is
375
+ * stopped as blocked with that diagnosis (orchestrator/progress.ts). Only with a repository.
376
+ */
377
+ maxNoProgressRounds: z.number().int().min(2).default(3),
373
378
  /**
374
379
  * How many times a task may be sent back because an independent review found problems.
375
380
  *
@@ -21,6 +21,7 @@ import { runStep } from './runner.js';
21
21
  import { resolveShell } from './shells.js';
22
22
  import { repoState, workingTreePaths } from '../vcs/git.js';
23
23
  import { findSuspicious, suspiciousDetail } from '../vcs/commitHygiene.js';
24
+ import { integrityDetail, scanChanges } from '../vcs/contentIntegrity.js';
24
25
  /**
25
26
  * The check the runner adds on its own whenever it is about to commit.
26
27
  *
@@ -28,6 +29,10 @@ import { findSuspicious, suspiciousDetail } from '../vcs/commitHygiene.js';
28
29
  * work: it is what a commit should never carry, whatever the task was.
29
30
  */
30
31
  export const COMMIT_CLEAN_CHECK = { name: 'nothing installed, built, logged or secret is committed', expect: 'commit-clean' };
32
+ /** Its sibling for what is inside the files: encoding damage, stray bytes, credentials. See `contentIntegrity.ts`. */
33
+ export const CONTENT_CLEAN_CHECK = { name: 'the text written is clean: encoding, line endings, no credentials', expect: 'content-clean' };
34
+ /** The checks the runner adds by itself before a commit, rather than ones a plan wrote. */
35
+ export const RUNNER_CHECK_KINDS = new Set(['commit-clean', 'content-clean']);
31
36
  const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
32
37
  /** How much of a command's output travels back to the chat with a failure. */
33
38
  const OUTPUT_KEPT = 4000;
@@ -63,6 +68,18 @@ export async function runCheck(check, index, opts) {
63
68
  ? pass('nothing in the working tree looks like tool output or secrets')
64
69
  : fail(suspiciousDetail(found), { output: found.map((s) => `${s.path}\t${s.reason}`).join('\n') });
65
70
  }
71
+ if (check.expect === 'content-clean') {
72
+ const dir = (opts.repoDir ?? '').trim();
73
+ if (!dir)
74
+ return pass('no repository is set for this session, so nothing is committed');
75
+ const state = await repoState(dir).catch(() => null);
76
+ if (!state?.isRepo)
77
+ return pass(`${dir} is not a git repository, so nothing is committed`);
78
+ const found = await scanChanges(dir, await workingTreePaths(dir), opts.baseCommit);
79
+ return found.length === 0
80
+ ? pass('the changed files are clean UTF-8 text with one kind of line ending and no credentials')
81
+ : fail(integrityDetail(found), { output: found.map((f) => `${f.path}\t${f.kind}\t${f.detail}`).join('\n') });
82
+ }
66
83
  /*
67
84
  * Where this check really runs, decided once. The command runs here and a file check's relative
68
85
  * path is resolved against it — the same folder for both, so the path confined is the path read.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Whether a task is still getting anywhere, judged from what happened rather than from what the
3
+ * chat says about it.
4
+ *
5
+ * The runner already stops two kinds of loop: the same command sent again with the same result
6
+ * (`maxCommandRepeats`), and a whole round of steps it refused (`maxStalledIterations`). What those
7
+ * miss is the loop in which every round looks new: a different command each time that fails the
8
+ * same way while no file changes; changes made in one round and undone in the next, over and over;
9
+ * "done" said again with the same checks failing and nothing touched since they were reported. Each
10
+ * of those ran until the iteration limit and ended "limit reached" with a reason that explained
11
+ * nothing. Here each is recognised and ends the task `blocked` with a diagnosis saying which one it
12
+ * was and what the evidence is, so the operator knows what to change before running it again.
13
+ *
14
+ * "No file changed" is read from the working tree (`treeFingerprint` in git.ts), so these signals
15
+ * are only on when the session works in a git repository. Without one this watches nothing and
16
+ * the older guards still apply.
17
+ */
18
+ /** The failure a round ended with, reduced to what stays the same when only the wording changes. */
19
+ export function failureSignature(results) {
20
+ if (results.length === 0)
21
+ return null;
22
+ // A round in which anything ran and succeeded made progress of some kind.
23
+ if (results.some((r) => r.outcome === 'completed' && r.exitCode === 0))
24
+ return null;
25
+ const failed = results.filter((r) => r.outcome === 'completed' && r.exitCode !== 0);
26
+ if (failed.length === 0)
27
+ return null;
28
+ return failed
29
+ .map((r) => {
30
+ const text = (r.stderr.trim() || r.stdout.trim()).slice(-600);
31
+ // Numbers are masked (times, line numbers, process ids) and space collapsed: a different
32
+ // command that dies of the same error still reads the same.
33
+ return text.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
34
+ })
35
+ .sort()
36
+ .join('\n');
37
+ }
38
+ export class ProgressWatch {
39
+ limits;
40
+ trees = [];
41
+ oscillations = 0;
42
+ lastFailure = null;
43
+ sameFailure = 1;
44
+ lastChecks = null;
45
+ constructor(limits) {
46
+ this.limits = limits;
47
+ }
48
+ /**
49
+ * After a round of steps. `tree` is the working tree's fingerprint after it, or null when there is
50
+ * no repository to read. Returns why the task should stop, or null to go on.
51
+ */
52
+ afterRound(tree, results) {
53
+ if (tree === null)
54
+ return null;
55
+ const previous = this.trees.at(-1);
56
+ const beforeThat = this.trees.at(-2);
57
+ if (beforeThat !== undefined && tree === beforeThat && tree !== previous) {
58
+ this.oscillations += 1;
59
+ if (this.oscillations >= this.limits.oscillations) {
60
+ return (`no progress: the files went back to how they were two rounds earlier ${this.oscillations} times — ` +
61
+ 'changes made in one round and undone in the next. The task was stopped rather than left to run out of iterations. ' +
62
+ 'The two approaches it switches between are in the iteration reports; decide between them in the prompt.');
63
+ }
64
+ }
65
+ this.trees.push(tree);
66
+ const signature = failureSignature(results);
67
+ if (signature === null) {
68
+ this.lastFailure = null;
69
+ this.sameFailure = 1;
70
+ return null;
71
+ }
72
+ if (this.lastFailure && this.lastFailure.signature === signature && this.lastFailure.tree === tree) {
73
+ this.sameFailure += 1;
74
+ }
75
+ else {
76
+ this.sameFailure = 1;
77
+ }
78
+ this.lastFailure = { signature, tree };
79
+ if (this.sameFailure >= this.limits.noProgress) {
80
+ const example = signature.split('\n')[0].slice(-240);
81
+ return (`no progress: ${this.sameFailure} rounds in a row ended with the same error and no file changed, ` +
82
+ `though the commands differed. The error: "${example}". The task was stopped rather than left to run out of iterations; ` +
83
+ 'the cause is probably outside what the chat can change — read the last report.');
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * After "done" was answered with failing checks. Returns why the task should stop when this is the
89
+ * same failure as last time with nothing changed in between.
90
+ */
91
+ afterFailedChecks(tree, failing) {
92
+ if (tree === null)
93
+ return null;
94
+ const key = failing
95
+ .map((f) => `${f.name}\u0000${f.detail.toLowerCase().replace(/\d+/g, '#')}`)
96
+ .sort()
97
+ .join('\n');
98
+ const same = this.lastChecks && this.lastChecks.key === key && this.lastChecks.tree === tree;
99
+ this.lastChecks = { key, tree };
100
+ if (!same)
101
+ return null;
102
+ return (`no progress: "done" was said again with the same ${failing.length} check(s) failing ` +
103
+ `(${failing.map((f) => f.name).join(', ')}) and no file changed since they were reported. ` +
104
+ 'The task was stopped rather than spend its remaining check rounds the same way.');
105
+ }
106
+ }