copilot-operator 0.1.13 → 0.1.16

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 (78) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +42 -42
  3. package/dist/src/api/operator.service.js +743 -367
  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 +476 -163
  26. package/dist/src/plan/brief.js +37 -44
  27. package/dist/src/plan/importPlan.js +15 -7
  28. package/dist/src/plan/schema.js +64 -29
  29. package/dist/src/plan/script.js +0 -15
  30. package/dist/src/plan/systemGuide.js +33 -177
  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 +49 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/transport/copilotTransport.js +39 -1
  37. package/dist/src/vcs/scope.js +29 -6
  38. package/dist/src/vcs/taskVcs.js +78 -10
  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-7fd4fe96682299f5.js +1 -0
  42. package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +1 -0
  43. package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.js +1 -0
  44. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  45. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  46. package/dist/web/appearance.html +2 -2
  47. package/dist/web/appearance.txt +12 -12
  48. package/dist/web/defaults.html +2 -2
  49. package/dist/web/defaults.txt +12 -12
  50. package/dist/web/history.html +2 -2
  51. package/dist/web/history.txt +12 -12
  52. package/dist/web/import.html +2 -2
  53. package/dist/web/import.txt +12 -12
  54. package/dist/web/index.html +2 -2
  55. package/dist/web/index.txt +12 -12
  56. package/dist/web/level1.html +2 -2
  57. package/dist/web/level1.txt +12 -12
  58. package/dist/web/presets.html +2 -2
  59. package/dist/web/presets.txt +12 -12
  60. package/dist/web/sessions/view.html +2 -2
  61. package/dist/web/sessions/view.txt +12 -12
  62. package/dist/web/system.html +2 -2
  63. package/dist/web/system.txt +12 -12
  64. package/package.json +19 -10
  65. package/prompts/level1.md +2 -1
  66. package/prompts/organisation.bg.md +0 -1
  67. package/prompts/organisation.en.md +0 -1
  68. package/dist/src/context/contextFiles.js +0 -295
  69. package/dist/src/context/desktopMirror.js +0 -173
  70. package/dist/src/context/projectMirror.js +0 -432
  71. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  72. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  73. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  74. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
  75. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  76. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
  77. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_buildManifest.js +0 -0
  78. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_ssgManifest.js +0 -0
@@ -169,7 +169,11 @@ export function parseReply(markdown, opts) {
169
169
  reply.notes = stripCitations(reply.notes);
170
170
  reply.summary = stripCitations(reply.summary);
171
171
  const steps = reply.steps.map((s) => ({ ...s, shell: s.shell ?? opts.defaultShell }));
172
- const markerHit = markdown.includes(opts.stopMarker);
172
+ // A blank stop word is no stop word. The setting is free text, and every reply contains the
173
+ // empty string and nearly every one a space, so searching for either would read each
174
+ // `continue` that carries a summary as done and close the task after one round.
175
+ const marker = opts.stopMarker.trim();
176
+ const markerHit = marker !== '' && markdown.includes(marker);
173
177
  const hasSummary = (reply.summary ?? '').trim().length > 0;
174
178
  // The stop word alone is not enough to end a task any more: the summary is the
175
179
  // deliverable, and a marker without one would let a task close with nothing to show.
@@ -145,7 +145,7 @@ export async function buildDebugExport(input) {
145
145
  chat: session.chat ? { name: session.chat.name, url: session.chat.url } : undefined,
146
146
  vcs: session.vcs,
147
147
  vcsBaseCommit: session.vcsBaseCommit,
148
- mirror: session.mirror,
148
+ projectDir: session.projectDir,
149
149
  tasks,
150
150
  });
151
151
  }
@@ -245,7 +245,7 @@ export async function buildExport(input) {
245
245
  `chat : ${session.chat?.url ?? '(no conversation was opened)'}`,
246
246
  `model : ${session.model || 'whatever the chat was set to'}${session.modelInUse ? ` (ran on ${session.modelInUse})` : ''}`,
247
247
  `repository : ${session.vcs?.enabled
248
- ? `${session.vcs.repoDir || session.mirror.rootDir || '(none set)'} — ${session.vcs.branchMode === 'per-session' ? 'one branch for the whole session' : 'a branch of its own for every task'}, prefix ${session.vcs.branchPrefix}${session.vcs.commitOnFinish ? '' : ', commits off'}`
248
+ ? `${session.vcs.repoDir || session.projectDir || '(none set)'} — ${session.vcs.branchMode === 'per-session' ? 'one branch for the whole session' : 'a branch of its own for every task'}, prefix ${session.vcs.branchPrefix}${session.vcs.commitOnFinish ? '' : ', commits off'}`
249
249
  : 'version control is off for this session'}`,
250
250
  `tasks : ${tasks.length} of ${session.tasks.length}`,
251
251
  `exported : ${when(new Date().toISOString())}`,
@@ -149,14 +149,7 @@ export function buildPlanExport(scope) {
149
149
  ...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
150
150
  },
151
151
  review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
152
- mirror: {
153
- enabled: s.mirror.enabled,
154
- rootDir: s.mirror.rootDir,
155
- includeDirs: s.mirror.includeDirs,
156
- excludeDirs: s.mirror.excludeDirs,
157
- respectGitignore: s.mirror.respectGitignore ?? true,
158
- includeEnvFiles: s.mirror.includeEnvFiles ?? false,
159
- },
152
+ ...(s.projectDir ? { projectDir: s.projectDir } : {}),
160
153
  tasks: tasks.map((t) => {
161
154
  const p = splitPrompt(t.prompt);
162
155
  const task = { title: t.title, prompt: p.prompt, expected: p.expected };
@@ -269,6 +262,54 @@ export function taskAtAttempt(task, attempt) {
269
262
  export function withTask(session, task) {
270
263
  return { ...session, tasks: session.tasks.map((t) => (t.id === task.id ? task : t)) };
271
264
  }
265
+ /**
266
+ * The task as it ended in one run: the last of its attempts that ran in it, live or archived, or
267
+ * null when none did. Several attempts can share a run (a retry in a fresh chat is one); the last
268
+ * is how the run left it, with the ones before it as its history.
269
+ */
270
+ export function taskInRun(task, runId) {
271
+ if (task.runGroup?.id === runId)
272
+ return task;
273
+ const attempts = task.attempts ?? [];
274
+ for (let i = attempts.length - 1; i >= 0; i -= 1) {
275
+ if (attempts[i]?.runGroup?.id === runId)
276
+ return taskAtAttempt(task, i + 1);
277
+ }
278
+ return null;
279
+ }
280
+ /**
281
+ * One run's sessions, with each task as it ended in that run, and only the tasks that were part of it.
282
+ *
283
+ * A run is a record of what happened, and the task on the record moves on after it: "run again"
284
+ * queues it as a new attempt, and another run may finish it. Taken as it is now, a task that
285
+ * failed in the run read as queued, its failure only among the earlier attempts, and the run's
286
+ * counts said nothing failed in it; once a later run had stamped it, the task was not in the
287
+ * first run's export at all. So each task is taken at its attempt in the run (`taskInRun`), and
288
+ * the three views describe the run whichever is asked for. A task the run was asked to do and
289
+ * never reached has no such attempt; it is still the run's, as it stands, through the run's own
290
+ * record on the session.
291
+ */
292
+ export function runScope(sessions, runId, label) {
293
+ const chosen = new Map();
294
+ const asInRun = sessions.map((s) => {
295
+ let session = s;
296
+ const ids = new Set();
297
+ for (const t of s.tasks) {
298
+ const ran = taskInRun(t, runId);
299
+ if (ran) {
300
+ ids.add(t.id);
301
+ if (ran !== t)
302
+ session = withTask(session, ran);
303
+ }
304
+ else if (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)) {
305
+ ids.add(t.id);
306
+ }
307
+ }
308
+ chosen.set(s.id, ids);
309
+ return session;
310
+ });
311
+ return { sessions: asInRun, taskFilter: (s, t) => !!chosen.get(s.id)?.has(t.id), label };
312
+ }
272
313
  /** What the runner export says about the machine, from the configuration in force. */
273
314
  export function exportMachine(cfg) {
274
315
  return {
@@ -540,7 +581,6 @@ async function botTask(session, task, runsDir) {
540
581
  fromReviews: task.reviewChecks,
541
582
  },
542
583
  processes: { leftovers: task.leftovers, reaped: events.filter((e) => e.type === 'processes-reaped').map(trimmed), notOurs: events.filter((e) => e.type === 'processes-not-ours').map(trimmed) },
543
- mirror: events.filter((e) => String(e.type).startsWith('mirror')).map(trimmed),
544
584
  review: {
545
585
  verdict: task.review?.verdict,
546
586
  rounds: task.review?.rounds,
@@ -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
  *
@@ -11,7 +11,7 @@
11
11
  * 3. A message cannot consist of an attachment alone; Send stays disabled without text.
12
12
  */
13
13
  import { chromium } from 'playwright';
14
- import { mkdir } from 'node:fs/promises';
14
+ import { mkdir, writeFile } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
16
  import { findRecentCrash, describeCrash } from './edgeCrash.js';
17
17
  import { landed } from './acceptance.js';
@@ -575,6 +575,7 @@ Current URL: ${url}`);
575
575
  return { options: [], current, note: 'The model picker opened nothing that could be read.' };
576
576
  }
577
577
  const top = await this.readMenuRows();
578
+ await this.captureMenu('top', top);
578
579
  await this.closeMenu();
579
580
  const options = top.filter((r) => !r.opensSubmenu).map(({ opensSubmenu, ...o }) => o);
580
581
  const groups = top.filter((r) => r.opensSubmenu);
@@ -589,8 +590,44 @@ Current URL: ${url}`);
589
590
  }
590
591
  }
591
592
  this.emit('models-read', { count: options.length, groups: groups.length, current });
593
+ await this.captureMenu('result', top, { current, options }).catch(() => undefined);
592
594
  return { options, current };
593
595
  }
596
+ /**
597
+ * What the picker showed, kept beside the reading: the menu as HTML, a picture of it, and the rows
598
+ * that were read from it.
599
+ *
600
+ * The picker is Microsoft's markup and it changes without notice; when "Read the list from
601
+ * Copilot" comes back with the wrong models, the only way to see why is the menu as it was at that
602
+ * moment. Written to this transport's folder (runs/_models for that button), overwritten by the
603
+ * next reading, and never a reason for the reading to fail. Nothing personal is in a model menu.
604
+ */
605
+ async captureMenu(label, rows, extra) {
606
+ try {
607
+ await mkdir(this.opts.transportDir, { recursive: true });
608
+ const base = join(this.opts.transportDir, `models-${label}`);
609
+ if (label !== 'result') {
610
+ const html = await this.p
611
+ .evaluate((selectors) => {
612
+ const parts = [];
613
+ for (const sel of selectors) {
614
+ for (const el of Array.from(document.querySelectorAll(sel))) {
615
+ if (el.getClientRects().length > 0)
616
+ parts.push(`<!-- ${sel} -->\n${el.outerHTML}`);
617
+ }
618
+ }
619
+ return parts.join('\n\n');
620
+ }, [...Model.popupSelectors])
621
+ .catch((e) => `<!-- could not read the menu: ${String(e)} -->`);
622
+ await writeFile(`${base}.html`, html, 'utf8');
623
+ await this.p.screenshot({ path: `${base}.png` }).catch(() => undefined);
624
+ }
625
+ await writeFile(`${base}.json`, JSON.stringify({ at: new Date().toISOString(), rows, ...(extra ?? {}) }, null, 2), 'utf8');
626
+ }
627
+ catch {
628
+ /* a diagnostic, never the reason a reading fails */
629
+ }
630
+ }
594
631
  /** Every visible menu row right now, across the menu and whatever submenu is open. */
595
632
  async readMenuRows() {
596
633
  return await this.p.evaluate(({ roles, selectedAttrs, submenuAttrs }) => {
@@ -652,6 +689,7 @@ Current URL: ${url}`);
652
689
  await trigger.click().catch(() => undefined);
653
690
  children = await this.waitForNewRows(topNames);
654
691
  }
692
+ await this.captureMenu(`group-${groupName.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
655
693
  await this.closeMenu();
656
694
  return children;
657
695
  }