copilot-operator 0.1.12 → 0.1.15

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 (80) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +31 -42
  3. package/dist/src/api/operator.service.js +692 -364
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +482 -150
  26. package/dist/src/plan/brief.js +56 -44
  27. package/dist/src/plan/importPlan.js +18 -7
  28. package/dist/src/plan/schema.js +90 -31
  29. package/dist/src/plan/script.js +79 -90
  30. package/dist/src/plan/systemGuide.js +39 -182
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +52 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/git.js +77 -2
  37. package/dist/src/vcs/scope.js +29 -6
  38. package/dist/src/vcs/taskVcs.js +149 -23
  39. package/dist/web/404.html +2 -2
  40. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  41. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  42. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  43. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  44. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
  45. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  46. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  47. package/dist/web/appearance.html +2 -2
  48. package/dist/web/appearance.txt +12 -12
  49. package/dist/web/defaults.html +2 -2
  50. package/dist/web/defaults.txt +12 -12
  51. package/dist/web/history.html +2 -2
  52. package/dist/web/history.txt +12 -12
  53. package/dist/web/import.html +2 -2
  54. package/dist/web/import.txt +12 -12
  55. package/dist/web/index.html +2 -2
  56. package/dist/web/index.txt +12 -12
  57. package/dist/web/level1.html +2 -2
  58. package/dist/web/level1.txt +12 -12
  59. package/dist/web/presets.html +2 -2
  60. package/dist/web/presets.txt +12 -12
  61. package/dist/web/sessions/view.html +2 -2
  62. package/dist/web/sessions/view.txt +12 -12
  63. package/dist/web/system.html +2 -2
  64. package/dist/web/system.txt +12 -12
  65. package/package.json +18 -10
  66. package/prompts/level1.md +2 -1
  67. package/prompts/organisation.bg.md +0 -1
  68. package/prompts/organisation.en.md +0 -1
  69. package/dist/src/context/contextFiles.js +0 -295
  70. package/dist/src/context/desktopMirror.js +0 -135
  71. package/dist/src/context/projectMirror.js +0 -432
  72. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  73. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  74. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  75. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +0 -1
  76. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  77. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +0 -1
  78. package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +0 -1
  79. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  80. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
@@ -4,10 +4,12 @@
4
4
  * JSON files, not a database. A single operator on one machine gets nothing from a
5
5
  * database except a dependency, and files can be read, diffed and backed up with nothing
6
6
  * installed. Writes go through a temp file and a rename, so a crash mid-write cannot leave a
7
- * half-written session behind.
7
+ * half-written session behind, and the writes of one file wait their turn (see `inTurn`), so two
8
+ * at once cannot fail each other or undo each other's change.
8
9
  */
9
10
  import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises';
10
11
  import { existsSync } from 'node:fs';
12
+ import { randomUUID } from 'node:crypto';
11
13
  import { join, resolve } from 'node:path';
12
14
  import { isContinuable, newId, tidyVcsPlan } from './model.js';
13
15
  /** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
@@ -34,14 +36,27 @@ export const DEFAULT_VCS = {
34
36
  commitOnFinish: true,
35
37
  branchPrefix: 'cop/',
36
38
  };
37
- export const DEFAULT_MIRROR = {
38
- enabled: false,
39
- rootDir: '',
40
- includeDirs: [],
41
- excludeDirs: [],
42
- respectGitignore: true,
43
- includeEnvFiles: false,
44
- };
39
+ /**
40
+ * Points a new session that names no folder at the default project, the one on the Project page.
41
+ *
42
+ * The one rule for every way a session is made: on the Sessions page and by importing a plan. The
43
+ * import used to skip it, so a plan's session with version control off and no `projectDir` — the
44
+ * brief's own example has one — kept an empty folder and ran its commands in `execution.cwd`,
45
+ * somewhere the operator never chose, while the same session made by hand worked in their project.
46
+ *
47
+ * Both fields, because they answer different questions about the same folder: where the work is
48
+ * and where the branches go. Version control is not switched on by being filled in. A session that
49
+ * names either keeps what it named and gets nothing here: a plan's repository with the default
50
+ * project beside it as its folder would say the work is in two places.
51
+ */
52
+ export function applyDefaultProject(session, defaultDir) {
53
+ const dir = defaultDir.trim();
54
+ if (!dir || session.projectDir.trim() || session.vcs?.repoDir.trim())
55
+ return;
56
+ session.projectDir = dir;
57
+ if (session.vcs)
58
+ session.vcs.repoDir = dir;
59
+ }
45
60
  /**
46
61
  * Applies an edit to a task: the one place the rule about a new intent lives.
47
62
  *
@@ -114,10 +129,10 @@ export class SessionStore {
114
129
  return { content: await readFile(this.defaultLevel1Path, 'utf8'), customised: false };
115
130
  }
116
131
  async setLevel1(content) {
117
- await this.atomicWrite(this.level1Path, content);
132
+ await writeFileAtomically(this.level1Path, content);
118
133
  }
119
134
  async resetLevel1() {
120
- await rm(this.level1Path, { force: true });
135
+ await removeFile(this.level1Path);
121
136
  }
122
137
  // --- the organisation's part of the plan persona -------------------------------------
123
138
  /**
@@ -136,10 +151,10 @@ export class SessionStore {
136
151
  return { content: '', customised: false, example };
137
152
  }
138
153
  async setContext(kind, content) {
139
- await this.atomicWrite(join(this.dir, `context-${kind}.md`), content);
154
+ await writeFileAtomically(join(this.dir, `context-${kind}.md`), content);
140
155
  }
141
156
  async resetContext(kind) {
142
- await rm(join(this.dir, `context-${kind}.md`), { force: true });
157
+ await removeFile(join(this.dir, `context-${kind}.md`));
143
158
  }
144
159
  // --- the model catalogue ----------------------------------------------------------------
145
160
  /** What the chat's picker offered when it was last read, or null if it never was. */
@@ -152,7 +167,7 @@ export class SessionStore {
152
167
  }
153
168
  }
154
169
  async saveModels(catalogue) {
155
- await this.atomicWrite(this.modelsPath, JSON.stringify(catalogue, null, 2));
170
+ await writeFileAtomically(this.modelsPath, JSON.stringify(catalogue, null, 2));
156
171
  }
157
172
  // --- level 2 presets ------------------------------------------------------------------
158
173
  async listPresets() {
@@ -168,15 +183,23 @@ export class SessionStore {
168
183
  }
169
184
  return out;
170
185
  }
186
+ /**
187
+ * Saves a preset under the name it can later be deleted by.
188
+ *
189
+ * Stripping the characters a file name cannot hold is not enough on its own: a leading dot and
190
+ * ".." survive it, and `safePresetName` refuses both, so ".env" and "a..b" were saved and then
191
+ * could never be deleted. The name goes through the same rule as the delete, the way a persona's
192
+ * always has, so a name that could not be removed is refused here instead.
193
+ */
171
194
  async savePreset(name, content) {
172
195
  const safe = name.replace(/[^\p{L}\p{N}._ -]/gu, '').trim();
173
196
  if (!safe)
174
197
  throw new Error('Preset name is empty after removing unsafe characters.');
175
- await this.atomicWrite(join(this.presetsDir, `${safe}.md`), content);
198
+ await writeFileAtomically(join(this.presetsDir, `${safePresetName(safe)}.md`), content);
176
199
  return { name: safe, content, updatedAt: new Date().toISOString() };
177
200
  }
178
201
  async deletePreset(name) {
179
- await rm(join(this.presetsDir, `${safePresetName(name)}.md`), { force: true });
202
+ await removeFile(await namedFile(this.presetsDir, name));
180
203
  }
181
204
  // --- named personas -------------------------------------------------------------------
182
205
  //
@@ -202,11 +225,11 @@ export class SessionStore {
202
225
  if (!safe)
203
226
  throw new Error('The persona name is empty after removing unsafe characters.');
204
227
  await mkdir(this.personasDir, { recursive: true });
205
- await this.atomicWrite(join(this.personasDir, `${safePresetName(safe)}.md`), content);
228
+ await writeFileAtomically(join(this.personasDir, `${safePresetName(safe)}.md`), content);
206
229
  return { name: safe, content, updatedAt: new Date().toISOString() };
207
230
  }
208
231
  async deletePersona(name) {
209
- await rm(join(this.personasDir, `${safePresetName(name)}.md`), { force: true });
232
+ await removeFile(await namedFile(this.personasDir, name));
210
233
  }
211
234
  // --- sessions -------------------------------------------------------------------------
212
235
  /**
@@ -238,10 +261,27 @@ export class SessionStore {
238
261
  return b.id.localeCompare(a.id);
239
262
  });
240
263
  }
264
+ /**
265
+ * The session of this id, or null when there is none.
266
+ *
267
+ * An id that could not be a session's file name — a path, `..`, anything `safeName` refuses —
268
+ * names no session, and is answered as one that does not exist. It used to throw here, so every
269
+ * route that only reads a session (the session itself, a task's log, story or files, a start)
270
+ * answered a path for an id with a 500 rather than the 404 or "no such session" a missing one
271
+ * gets. A write or a delete of such an id is still refused outright: those go through
272
+ * `sessionPath` themselves.
273
+ */
241
274
  async getSession(id) {
242
- return await this.readSession(join(this.sessionsDir, `${safeName(id)}.json`));
275
+ let path;
276
+ try {
277
+ path = this.sessionPath(id);
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ return await this.readSession(path);
243
283
  }
244
- async createSession(name, mirror = {}) {
284
+ async createSession(name, projectDir = '') {
245
285
  const session = {
246
286
  id: newId(),
247
287
  name: name.trim() || 'untitled',
@@ -252,26 +292,38 @@ export class SessionStore {
252
292
  // behaved and the safer of the two.
253
293
  onFailure: 'stop',
254
294
  vcs: { ...DEFAULT_VCS },
255
- mirror: { ...DEFAULT_MIRROR, ...mirror },
295
+ projectDir: projectDir.trim(),
256
296
  tasks: [],
257
297
  };
258
298
  await this.saveSession(session);
259
299
  return session;
260
300
  }
261
301
  async saveSession(session) {
262
- await this.atomicWrite(join(this.sessionsDir, `${safeName(session.id)}.json`), JSON.stringify(session, null, 2));
302
+ await writeFileAtomically(this.sessionPath(session.id), JSON.stringify(session, null, 2));
263
303
  }
264
304
  async deleteSession(id) {
265
- await rm(join(this.sessionsDir, `${safeName(id)}.json`), { force: true });
305
+ await removeFile(this.sessionPath(id));
266
306
  }
267
- /** Applies a change under a fresh read, so two writers cannot clobber each other. */
307
+ /**
308
+ * Applies a change under a fresh read, so two writers cannot clobber each other.
309
+ *
310
+ * The read, the change and the write happen in the file's turn, with nothing else of this
311
+ * process writing the session in between. A fresh read alone did not keep that promise: Stop
312
+ * writes "stopping" at the moment it answers an approval, and the runner it has just woken
313
+ * writes the task at the same moment. Both read the session before either had written, so
314
+ * whichever wrote second put back what the first had changed, or, when both renamed at once,
315
+ * one of them failed and took the stop or the task down with it.
316
+ */
268
317
  async updateSession(id, mutate) {
269
- const s = await this.getSession(id);
270
- if (!s)
271
- throw new Error(`Session ${id} does not exist.`);
272
- mutate(s);
273
- await this.saveSession(s);
274
- return s;
318
+ const path = this.sessionPath(id);
319
+ return await inTurn(path, async () => {
320
+ const s = await this.readSession(path);
321
+ if (!s)
322
+ throw new Error(`Session ${id} does not exist.`);
323
+ mutate(s);
324
+ await replaceFile(path, JSON.stringify(s, null, 2));
325
+ return s;
326
+ });
275
327
  }
276
328
  async addTask(sessionId, input) {
277
329
  const vcsPlan = tidyVcsPlan(input.vcsPlan);
@@ -465,33 +517,41 @@ export class SessionStore {
465
517
  */
466
518
  async recoverInterrupted() {
467
519
  const recovered = [];
468
- for (const session of await this.listSessions()) {
469
- const stuck = session.tasks.filter((t) => ACTIVE_STATUSES.includes(t.status));
470
- const busySession = session.status !== 'idle';
471
- if (stuck.length === 0 && !busySession)
520
+ for (const listed of await this.listSessions()) {
521
+ if (listed.status === 'idle' && !listed.tasks.some((t) => ACTIVE_STATUSES.includes(t.status)))
472
522
  continue;
473
- for (const t of stuck) {
474
- t.status = 'aborted';
475
- t.finishedAt = t.finishedAt ?? new Date().toISOString();
476
- t.reason =
477
- 'The bot stopped while this task was in progress (the program was closed, the machine went off, or it crashed), so it never finished. ' +
478
- 'Its work so far is kept; "Continue in the same chat" carries it on where it stopped.';
479
- recovered.push({ sessionId: session.id, taskId: t.id, title: t.title });
480
- }
481
- session.status = 'idle';
482
- await this.saveSession(session);
523
+ // Read again in the file's turn, like any other change, so a write that landed since the
524
+ // list was read is kept rather than replaced by the copy in the list.
525
+ const path = this.sessionPath(listed.id);
526
+ await inTurn(path, async () => {
527
+ const session = await this.readSession(path);
528
+ if (!session)
529
+ return;
530
+ for (const t of session.tasks.filter((x) => ACTIVE_STATUSES.includes(x.status))) {
531
+ t.status = 'aborted';
532
+ t.finishedAt = t.finishedAt ?? new Date().toISOString();
533
+ t.reason =
534
+ 'The bot stopped while this task was in progress (the program was closed, the machine went off, or it crashed), so it never finished. ' +
535
+ 'Its work so far is kept; "Continue in the same chat" carries it on where it stopped.';
536
+ recovered.push({ sessionId: session.id, taskId: t.id, title: t.title });
537
+ }
538
+ session.status = 'idle';
539
+ await replaceFile(path, JSON.stringify(session, null, 2));
540
+ });
483
541
  }
484
542
  return recovered;
485
543
  }
486
544
  // --- helpers --------------------------------------------------------------------------
487
545
  /**
488
- * Reads one session file, filling in mirror fields added after it was written. Sessions are
546
+ * Reads one session file, filling in fields added after it was written. Sessions are
489
547
  * long-lived JSON on disk, so a new option must never come back as `undefined`.
490
548
  */
491
549
  async readSession(path) {
492
550
  try {
493
551
  const s = JSON.parse(await readFile(path, 'utf8'));
494
- s.mirror = { ...DEFAULT_MIRROR, ...(s.mirror ?? {}) };
552
+ // Written before the project-files feature was removed: its root is the project folder.
553
+ s.projectDir = (s.projectDir ?? s.mirror?.rootDir ?? '').trim();
554
+ delete s.mirror;
495
555
  s.vcs = { ...DEFAULT_VCS, ...(s.vcs ?? {}) };
496
556
  // Sessions written before the queue could be told what to do on a failure behave the
497
557
  // way they always did: the chain stops.
@@ -502,44 +562,119 @@ export class SessionStore {
502
562
  return null;
503
563
  }
504
564
  }
505
- /**
506
- * Write through a temp file and a rename, and keep trying when Windows says no.
507
- *
508
- * The rename is what makes the write atomic: a crash halfway through leaves the temp file
509
- * behind and the real one untouched. On Windows it is also the step that fails, because
510
- * renaming onto a destination that any other process has open is refused outright — and this
511
- * file is read constantly, by the register polling every few seconds, by the sessions list,
512
- * and by whatever virus scanner has decided to look inside a JSON file that just changed.
513
- *
514
- * The failure is EPERM and it is transient: the handle closes microseconds later. Letting it
515
- * through unretried meant a task dying mid-run with a message about a temp file, which is
516
- * what happened — a scaffold that had finished three tasks was killed by a file lock on the
517
- * fourth. So it is retried, briefly and with a growing pause, and only then given up on.
518
- */
519
- async atomicWrite(path, content) {
520
- const tmp = `${path}.${process.pid}.tmp`;
521
- await writeFile(tmp, content, 'utf8');
522
- const backoffMs = [10, 25, 60, 120, 250, 500];
523
- for (let attempt = 0;; attempt += 1) {
524
- try {
525
- await rename(tmp, path);
526
- return;
527
- }
528
- catch (e) {
529
- const code = e.code ?? '';
530
- const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
531
- if (!transient || attempt >= backoffMs.length) {
532
- // The temp file is no use to anyone now, and leaving one per failed write behind
533
- // would slowly fill the folder with them.
534
- await rm(tmp, { force: true }).catch(() => undefined);
535
- throw new Error(`Could not save ${path}: ${code || e.message}. ` +
536
- 'Something else is holding the file open — an editor, a sync client or a virus scanner.');
537
- }
538
- await new Promise((r) => setTimeout(r, backoffMs[attempt]));
565
+ sessionPath(id) {
566
+ return join(this.sessionsDir, `${safeName(id)}.json`);
567
+ }
568
+ }
569
+ // --- writing a file -------------------------------------------------------------------------
570
+ /**
571
+ * The work queued on each file, by the file's full path: the tail of its chain.
572
+ *
573
+ * Kept for the module, not for one store, because every store in a process writes the same
574
+ * files: the API has one, but a check or the terminal may make another over the same folder,
575
+ * and a queue each would let their writes meet again.
576
+ */
577
+ const queued = new Map();
578
+ /**
579
+ * Runs `work` once everything queued earlier on the same file has finished, failed or not.
580
+ *
581
+ * Every write of the store goes through here, and `updateSession` holds the turn across its read
582
+ * as well, as does a write worked out from the file (see `writeFileAtomically`), so a change is
583
+ * always made to what the last write left. `work` must not wait for another turn on the same file,
584
+ * which would wait for itself; nothing here does, and a session's `mutate` is synchronous, so it
585
+ * cannot.
586
+ *
587
+ * In this process only: another process writing the same file at the same time is not waited
588
+ * for. The one that might, a run from the terminal, makes a session of its own and writes only that.
589
+ */
590
+ function inTurn(path, work) {
591
+ const full = resolve(path);
592
+ // Windows treats `A.json` and `a.json` as one file, so they share one queue there.
593
+ const key = process.platform === 'win32' ? full.toLowerCase() : full;
594
+ const run = (queued.get(key) ?? Promise.resolve()).then(work);
595
+ const tail = run.then(() => undefined, () => undefined);
596
+ queued.set(key, tail);
597
+ // Forgotten once nothing is queued behind it, so the map holds only the files being written.
598
+ void tail.then(() => {
599
+ if (queued.get(key) === tail)
600
+ queued.delete(key);
601
+ });
602
+ return run;
603
+ }
604
+ /**
605
+ * Writes a file whole, in its turn: a crash, or another write of the same file, cannot leave it
606
+ * half written or lose either write. For any file the program keeps, not only the store's.
607
+ *
608
+ * `content` may be a function instead, for a write worked out from what the file holds: it is
609
+ * called in the file's turn, so what it reads is what the last write left, and nothing else of this
610
+ * process writes the file until its answer has landed. Read first and written here, a change of one
611
+ * setting put back whatever another had changed in between. If it throws, nothing is written. Like
612
+ * any work in a turn, it must not wait for another turn on the same file (see `inTurn`).
613
+ */
614
+ export async function writeFileAtomically(path, content) {
615
+ await inTurn(path, async () => replaceFile(path, typeof content === 'string' ? content : await content()));
616
+ }
617
+ /** Removes a file in its turn, so a delete and a write of it land in the order they were asked for. */
618
+ async function removeFile(path) {
619
+ await inTurn(path, () => rm(path, { force: true }));
620
+ }
621
+ /**
622
+ * Write through a temp file and a rename, and keep trying when Windows says no. Not queued: the
623
+ * caller holds the file's turn (see `inTurn`).
624
+ *
625
+ * The rename is what makes the write atomic: a crash halfway through leaves the temp file
626
+ * behind and the real one untouched. On Windows it is also the step that fails, because
627
+ * renaming onto a destination that any other process has open is refused outright — and this
628
+ * file is read constantly, by the register polling every few seconds, by the sessions list,
629
+ * and by whatever virus scanner has decided to look inside a JSON file that just changed.
630
+ *
631
+ * The failure is EPERM and it is transient: the handle closes microseconds later. Letting it
632
+ * through unretried meant a task dying mid-run with a message about a temp file, which is
633
+ * what happened — a scaffold that had finished three tasks was killed by a file lock on the
634
+ * fourth. So it is retried, briefly and with a growing pause, and only then given up on.
635
+ *
636
+ * Each write has a temp file of its own. One name per process was shared by every write of the
637
+ * same file, so when two overlapped, the first rename took the temp file the second was about to
638
+ * rename, and the second failed with ENOENT — reported as a lock it was not.
639
+ */
640
+ async function replaceFile(path, content) {
641
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
642
+ await writeFile(tmp, content, 'utf8');
643
+ const backoffMs = [10, 25, 60, 120, 250, 500];
644
+ for (let attempt = 0;; attempt += 1) {
645
+ try {
646
+ await rename(tmp, path);
647
+ return;
648
+ }
649
+ catch (e) {
650
+ const code = e.code ?? '';
651
+ const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
652
+ if (!transient || attempt >= backoffMs.length) {
653
+ // The temp file is no use to anyone now, and leaving one per failed write behind
654
+ // would slowly fill the folder with them.
655
+ await rm(tmp, { force: true }).catch(() => undefined);
656
+ throw new Error(transient
657
+ ? `Could not save ${path}: ${code}. Something else is holding the file open — an editor, a sync client or a virus scanner.`
658
+ : `Could not save ${path}: ${e.message}`);
539
659
  }
660
+ await new Promise((r) => setTimeout(r, backoffMs[attempt]));
540
661
  }
541
662
  }
542
663
  }
664
+ /**
665
+ * The file a preset or persona of this name is kept in, for removing it.
666
+ *
667
+ * A name `safePresetName` accepts, or one that is already a file in the folder. The save did not
668
+ * always apply that rule, so a preset called ".env" or "a..b" may be on disk from before; without
669
+ * the second way it is listed and can never be deleted. A name matched against the folder's own
670
+ * listing is a file name, which cannot hold a separator, so it cannot reach outside the folder.
671
+ */
672
+ async function namedFile(dir, name) {
673
+ const onDisk = await readdir(dir).catch(() => []);
674
+ if (onDisk.includes(`${name}.md`))
675
+ return join(dir, `${name}.md`);
676
+ return join(dir, `${safePresetName(name)}.md`);
677
+ }
543
678
  /**
544
679
  * A session id as it may appear in a file name, or an error.
545
680
  *
@@ -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,
@@ -178,6 +178,81 @@ export async function isValidBranchName(dir, name) {
178
178
  const r = await git(dir, ['check-ref-format', '--branch', name]);
179
179
  return r.ok;
180
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
+ }
181
256
  export async function branchExists(dir, name) {
182
257
  const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]);
183
258
  return r.ok && r.stdout.length > 0;
@@ -15,13 +15,27 @@
15
15
  *
16
16
  * Patterns are repository-relative and use `/`: `tests/e2e/editor.spec.ts` is one file,
17
17
  * `tests/e2e/` or `tests/e2e/**` is everything under that folder, `*` matches within one folder
18
- * and `**` across folders. Matching ignores case, as Windows does.
18
+ * and `**` across folders. A pattern is read the way a path is: `\` is `/`, a `./` or `/` in front
19
+ * and a `//` or `/./` inside change nothing, `src/../docs` is `docs`, and `.`, `./` or `/` alone is
20
+ * the whole repository. Matching ignores case, as Windows does.
19
21
  */
20
22
  import { readdir, rm, rmdir } from 'node:fs/promises';
21
- import { dirname, join, resolve, sep } from 'node:path';
23
+ import { dirname, join, posix, resolve, sep } from 'node:path';
22
24
  import { git, workingTreePaths } from './git.js';
25
+ /**
26
+ * A path or pattern in the one spelling the matching expects: `/` between folders, nothing in front,
27
+ * and no empty, `.` or `..` folder anywhere in it. A plan writes the same folder as `docs`, `./docs`,
28
+ * `/docs`, `.\docs` or `src/../docs`, and they must all cover the same files: each spelling the
29
+ * matching did not expect covered nothing, so every change the task made under it was put back.
30
+ * Cleaning only the front left `src//docs` and `docs/.` the same way, so the whole path is resolved
31
+ * here, as a path would be. The repository itself (`.`, `./`, `/`) comes out as the empty string.
32
+ */
33
+ function normalise(p) {
34
+ const n = posix.normalize(p.replace(/\\/g, '/')).replace(/^\/+/, '');
35
+ return n === '.' || n === './' ? '' : n;
36
+ }
23
37
  function toRegExp(pattern) {
24
- let p = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
38
+ let p = normalise(pattern.trim());
25
39
  // A folder, written with or without `**`, is everything under it.
26
40
  if (p.endsWith('/'))
27
41
  p += '**';
@@ -52,11 +66,20 @@ function toRegExp(pattern) {
52
66
  export function inScope(path, scope) {
53
67
  if (scope.length === 0)
54
68
  return true;
55
- const p = path.replace(/\\/g, '/').replace(/^\.\//, '');
69
+ const p = normalise(path);
56
70
  return scope.some((pattern) => {
57
- const clean = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
71
+ // A blank entry names nothing, so it covers nothing. It must not read as the repository below,
72
+ // which it would, since normalising it gives the same empty string as `.` does.
73
+ if (pattern.trim() === '')
74
+ return false;
75
+ const clean = normalise(pattern.trim()).replace(/\/+$/, '');
76
+ // `.`, `./` or `/` is the repository itself, so the whole project: a plan writes it to mean
77
+ // anywhere, and read as a file named `.`, or as no path at all, it covered nothing and every
78
+ // change the task made was put back.
79
+ if (clean === '')
80
+ return true;
58
81
  // A plain folder name without a slash still means the folder: `pages` covers `pages/Home.ts`.
59
- if (clean && !/[*?]/.test(clean) && p.toLowerCase().startsWith(`${clean.toLowerCase()}/`))
82
+ if (!/[*?]/.test(clean) && p.toLowerCase().startsWith(`${clean.toLowerCase()}/`))
60
83
  return true;
61
84
  return toRegExp(pattern).test(p);
62
85
  });