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
@@ -17,16 +17,14 @@ import { readFile, readdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join, resolve } from 'node:path';
19
19
  import { installLayout } from '../config/layout.js';
20
- import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
20
+ import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyDefaultProject, applyTaskPatch } from '../session/store.js';
21
21
  import { EventBus } from '../session/events.js';
22
22
  import { isContinuable, newId } from '../session/model.js';
23
23
  import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
24
24
  import { buildExport } from '../session/exportRecord.js';
25
25
  import { buildDebugExport } from '../session/debugExport.js';
26
- import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName, exportMachine, taskAtAttempt, withTask, writeAttemptRecord } from '../session/exports.js';
26
+ import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName, exportMachine, runScope, taskAtAttempt, taskInRun, withTask, writeAttemptRecord } from '../session/exports.js';
27
27
  import { suggestRunName } from '../session/runName.js';
28
- import { mirrorAllProjects, removeProjectMirrors, contextRoot } from '../context/desktopMirror.js';
29
- import { describeMirror } from '../context/projectMirror.js';
30
28
  import { buildStory } from '../session/story.js';
31
29
  import { checkPlan } from '../plan/schema.js';
32
30
  import { planBrief } from '../plan/brief.js';
@@ -37,10 +35,9 @@ function sameFolder(a, b) {
37
35
  }
38
36
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
39
37
  import { readInterruption } from '../session/interruption.js';
40
- import { commitInterrupted, repoDirOf, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
41
- import { branchExists, changedFilesBetween, fileAt, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
38
+ import { branchAt, commitInterrupted, repoDirOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
39
+ import { branchExists, changedFilesBetween, fileAt, freeBranchName, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
42
40
  import { makeAuthorizer } from '../exec/authorizer.js';
43
- import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
44
41
  import { pickFolder } from './folderPicker.js';
45
42
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
46
43
  import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } from '../exec/isolation.js';
@@ -49,21 +46,137 @@ import { createTransport } from '../transport/chatTransport.js';
49
46
  import { composeHandoff } from '../session/handoff.js';
50
47
  import { planSync } from '../vcs/syncCommand.js';
51
48
  import { computeMetrics } from '../session/metrics.js';
52
- import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
49
+ import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
53
50
  import { saveAndReveal } from './saveToDesktop.js';
54
- import { Settings } from './settings.js';
51
+ import { settingsOf } from './settings.js';
52
+ /**
53
+ * Why the browser cannot be had now, said in terms of what was pressed. The sentences a start, a
54
+ * batch and the model picker gave before there was one rule for all three are kept where they still
55
+ * apply, so the page reads as it did.
56
+ */
57
+ function browserRefusal(held, ask) {
58
+ const modelsWhileRunning = 'A session is running and it is using the browser profile. Stop it first, then read the models.';
59
+ const loginWhileBusy = 'The browser profile is in use right now. Sign in once the run, or the reading of the models, has finished.';
60
+ // The sign-in window holds the profile until the operator has signed in and it closes.
61
+ if (held.kind === 'login') {
62
+ return ask.kind === 'login'
63
+ ? 'The sign-in window is already open. Finish signing in there.'
64
+ : 'the sign-in window is open and has the browser profile; finish signing in first';
65
+ }
66
+ if (ask.kind === 'login')
67
+ return loginWhileBusy;
68
+ if (held.kind === 'batch') {
69
+ if (ask.kind === 'models')
70
+ return modelsWhileRunning;
71
+ return ask.kind === 'batch' ? 'a batch is already running' : 'a batch of sessions is running';
72
+ }
73
+ if (held.kind === 'models') {
74
+ return ask.kind === 'models'
75
+ ? 'The model list is already being read.'
76
+ : 'the model list is being read from the chat, which has the browser; start this once it has finished';
77
+ }
78
+ // A session running on its own.
79
+ if (ask.kind === 'models')
80
+ return modelsWhileRunning;
81
+ if (ask.kind === 'run') {
82
+ return ask.sessionId === held.sessionId
83
+ ? 'already running'
84
+ : 'another session is running, and the browser takes one run at a time; start this one once it has finished';
85
+ }
86
+ return ask.sessionIds.includes(held.sessionId)
87
+ ? 'one of the selected sessions is already running on its own'
88
+ : 'a session is running on its own, and the browser takes one run at a time; start the batch once it has finished';
89
+ }
55
90
  /** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
56
91
  function normaliseDir(dir) {
57
92
  if (!dir.trim())
58
93
  return '';
59
94
  return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
60
95
  }
96
+ /** The repository a planned session will work in, read the way `repoDirOf` reads an imported one. */
97
+ function plannedRepoDir(session) {
98
+ return session.vcs?.repoDir?.trim() || session.projectDir?.trim() || session.mirror?.rootDir?.trim() || '';
99
+ }
100
+ /**
101
+ * The branches a planned session will make by name when it runs, as `prepareForTask` names them.
102
+ *
103
+ * None with version control off, and none when it carries on an existing branch, which makes
104
+ * nothing. In per-session mode the one `branchName`; in per-task mode the branch each task names,
105
+ * since `branchName` is ignored there. A branch named for the session's id is not known before the
106
+ * import, and is not one a plan can ask to carry on.
107
+ */
108
+ function branchesMadeBy(session) {
109
+ const vcs = session.vcs;
110
+ if (!vcs || vcs.enabled === false || vcs.startFrom === 'existing-branch' || vcs.existingBranch?.trim())
111
+ return [];
112
+ const prefix = vcs.branchPrefix?.trim() || 'cop/';
113
+ if (vcs.branchMode === 'per-session')
114
+ return vcs.branchName?.trim() ? [plannedBranchName(vcs.branchName, prefix)] : [];
115
+ return session.tasks.flatMap((t) => (t.vcs?.branch?.trim() ? [plannedBranchName(t.vcs.branch, prefix)] : []));
116
+ }
117
+ /**
118
+ * The one restore each repository gets when a run starts again from a task: the rule, in one place,
119
+ * for the preview and for the restore itself, which used to keep a copy each and drifted apart.
120
+ *
121
+ * One per repository, keyed on the folder as compared rather than on the path as written or on the
122
+ * session: three sessions working in one repository must not take it back three times to three
123
+ * different commits. The earliest affected task that ran is the one that decides, because that is
124
+ * the state the whole re-run starts from.
125
+ *
126
+ * A repository whose affected tasks never ran has nothing to go back to: the run never touched it.
127
+ * Taking "no base commit recorded" as a refusal blocked "run again from here" for every plan whose
128
+ * later sessions had not started yet — which is the usual shape of a run that stopped in the middle.
129
+ *
130
+ * A session that carries on an existing branch cannot be taken back: its run checks that branch out
131
+ * again before the first task, so a restore branch would be left the moment it was made, and going
132
+ * back on the branch itself would take a reset, which this tool never does. That holds for every
133
+ * session of the repository the run reached, not only the one that decides where it goes back to:
134
+ * a later one carrying on a branch would run its tasks again on top of what they did the first
135
+ * time, which is not "the code goes back first" either. So the repository's restore carries the
136
+ * problem of the first such session, and the preview and the restart refuse before anything moves.
137
+ */
138
+ function restoreTargets(tasks) {
139
+ const byRepo = new Map();
140
+ for (const { session, task } of tasks) {
141
+ if (!session.vcs?.enabled || !task.startedAt)
142
+ continue;
143
+ const dir = repoDirOf(session);
144
+ const key = normaliseDir(dir);
145
+ if (!key)
146
+ continue;
147
+ const target = byRepo.get(key) ?? { dir, session, task };
148
+ byRepo.set(key, target);
149
+ const existing = session.vcs.startFrom === 'existing-branch' ? (session.vcs.existingBranch ?? '').trim() : '';
150
+ if (existing && !target.problem) {
151
+ target.problem =
152
+ `"${session.name}" carries on the existing branch ${existing}, and its run checks that branch out again, so the code ` +
153
+ `would not stay back; going back on ${existing} itself would take a reset, which this tool never does. ` +
154
+ `Queue its tasks again with "Run again" on each, which carries on ${existing} as it is, or move that branch yourself first.`;
155
+ }
156
+ }
157
+ return [...byRepo.values()];
158
+ }
159
+ /** Whether two folders, as `normaliseDir` gives them, are one folder or one is inside the other. */
160
+ function overlaps(a, b) {
161
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
162
+ }
163
+ /**
164
+ * The reason a restart stopped, with what it had already done by then.
165
+ *
166
+ * A repository taken back and tasks queued again stay so when a later step refuses — nothing here
167
+ * undoes them — and the reason is the one thing the page shows. Said only in the result, they were
168
+ * left for the operator to find in git and in the queue.
169
+ */
170
+ function afterMoves(reason, restored, requeued) {
171
+ const done = [...restored.map((r) => `taken back ${r}`), ...(requeued > 0 ? [`${requeued} task(s) queued again`] : [])];
172
+ return done.length > 0 ? `${reason} Already done, and left as it is: ${done.join('; ')}.` : reason;
173
+ }
61
174
  let OperatorService = class OperatorService {
62
175
  /** Where this install keeps its prompts and its records. See `config/layout.ts`. */
63
176
  layout = installLayout();
64
177
  projectRoot = this.layout.projectRoot;
65
178
  dataDir = this.layout.dataDir;
66
- settings = new Settings(this.projectRoot, this.dataDir, this.layout.mode === 'package' ? { runsDir: this.layout.runsDir, level1File: join(this.layout.promptsDir, 'level1.md') } : {});
179
+ settings = settingsOf(this.layout);
67
180
  store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
68
181
  bus = new EventBus();
69
182
  running = new Map();
@@ -71,8 +184,21 @@ let OperatorService = class OperatorService {
71
184
  /** The batch in progress, or the last one that finished. At most one ever exists. */
72
185
  batch = null;
73
186
  ready = null;
74
- /** One model read at a time: it launches a browser and holds the profile lock. */
75
- readingModels = false;
187
+ /**
188
+ * What has the browser now: one session's run, a batch, or a read of the model list.
189
+ *
190
+ * The Edge profile takes one writer, and every way into a chat window goes through here: a
191
+ * start, a batch, and reading the models. Each used to look only at its own kind — a start
192
+ * refused only while a batch ran, a batch only a second batch — so while one session ran on its
193
+ * own, starting another or a batch of others opened a second window on the same profile, which
194
+ * fails minutes later with a message about a closed browser. The profile lock is no help inside
195
+ * one process: it waves through a lock this process already holds.
196
+ *
197
+ * Taken at the entrance before its first wait, so two presses that arrive together cannot both
198
+ * find it free, and given back when the window it was taken for has closed or the entrance has
199
+ * refused. See `claimBrowser`.
200
+ */
201
+ browser = null;
76
202
  /**
77
203
  * One-time startup work, run lazily on the first request that needs the store.
78
204
  *
@@ -207,30 +333,32 @@ let OperatorService = class OperatorService {
207
333
  const pending = [...this.waiting.values()].filter((w) => w.approval.sessionId === id).map((w) => w.approval);
208
334
  return { ...s, running: this.running.has(id), pending, runMode: this.running.get(id)?.mode };
209
335
  }
210
- async createSession(name, mirror) {
336
+ async createSession(name, folder) {
211
337
  await this.init();
212
- if (mirror)
213
- assertMirrorIsCoherent(mirror);
214
- const session = await this.store.createSession(name, mirror);
338
+ // Read before the session is made: a settings file that cannot be read refuses the request,
339
+ // and a refusal must leave nothing behind, or each retry adds a session with no project.
340
+ const cfg = await this.settings.load();
341
+ const session = await this.store.createSession(name, folder ?? '');
215
342
  /*
216
343
  * The models are not written onto a new session. A session with no model of its own uses the
217
344
  * one chosen in Settings at the time it runs (see `effectiveModels` in taskRunner.ts), so
218
345
  * choosing a model there reaches every such session, the ones created before it included.
219
346
  * It used to be copied here, which meant a model chosen after an import reached nothing.
220
347
  */
221
- const cfg = await this.settings.load();
222
348
  const projectDir = (cfg.project?.rootDir ?? '').trim();
223
- if (!projectDir)
224
- return session;
225
349
  return await this.store.updateSession(session.id, (s) => {
226
- if (projectDir) {
227
- // Both fields, because they answer different questions about the same folder: where the
228
- // files are and where the branches go. Neither is switched on by being filled in.
229
- if (!s.mirror.rootDir.trim())
230
- s.mirror.rootDir = projectDir;
231
- if (s.vcs && !s.vcs.repoDir.trim())
232
- s.vcs.repoDir = projectDir;
233
- }
350
+ /*
351
+ * A folder given here is the session's repository too, in the shape a session made on the
352
+ * Project page's folder has: both fields, one folder. Left empty, every reader that follows
353
+ * `repoDirOf` found the folder, but the review read `vcs.repoDir` itself, and was told there
354
+ * was no repository and no changed files for work committed in that folder. The review asks
355
+ * `trackedRepoOf` now, so a session saved with `repoDir` empty is read right as well; this
356
+ * keeps the record saying what the session works in.
357
+ */
358
+ if (s.projectDir.trim() && s.vcs && !s.vcs.repoDir.trim())
359
+ s.vcs.repoDir = s.projectDir;
360
+ // The same rule an import applies, for a session given no folder; see `applyDefaultProject`.
361
+ applyDefaultProject(s, projectDir);
234
362
  });
235
363
  }
236
364
  async updateSession(id, patch) {
@@ -259,11 +387,8 @@ let OperatorService = class OperatorService {
259
387
  const merged = { ...DEFAULT_REVIEW, ...s.review, ...patch.review };
260
388
  s.review = { enabled: merged.enabled !== false, model: (merged.model ?? '').trim() };
261
389
  }
262
- if (patch.mirror) {
263
- const merged = { ...s.mirror, ...patch.mirror };
264
- assertMirrorIsCoherent(merged);
265
- s.mirror = merged;
266
- }
390
+ if (patch.projectDir !== undefined)
391
+ s.projectDir = patch.projectDir.trim();
267
392
  if (patch.vcs) {
268
393
  const merged = { ...DEFAULT_VCS, ...s.vcs, ...patch.vcs };
269
394
  // A prefix is what makes the bot's branches recognisable in `git branch`, so an empty
@@ -271,7 +396,7 @@ let OperatorService = class OperatorService {
271
396
  merged.branchPrefix = merged.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
272
397
  merged.repoDir = merged.repoDir.trim();
273
398
  if (merged.enabled) {
274
- const reason = repoUnusableReason(merged.repoDir || s.mirror.rootDir || '');
399
+ const reason = repoUnusableReason(merged.repoDir || s.projectDir || '');
275
400
  if (reason)
276
401
  throw new Error(reason);
277
402
  }
@@ -279,6 +404,12 @@ let OperatorService = class OperatorService {
279
404
  merged.baseBranch = merged.baseBranch.trim();
280
405
  if (merged.existingBranch !== undefined)
281
406
  merged.existingBranch = merged.existingBranch.trim();
407
+ // Only the program marks a name as one of its own branches, and a name changed is not that one.
408
+ const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
409
+ if (stillOwn)
410
+ merged.branchNameExact = true;
411
+ else
412
+ delete merged.branchNameExact;
282
413
  /*
283
414
  * Where the session starts is fixed at its first run. A new choice clears that record, so
284
415
  * the next task to run is cut from what was chosen now; the tasks that already ran keep
@@ -611,15 +742,17 @@ let OperatorService = class OperatorService {
611
742
  .sort((a, b) => (a.runGroup?.id === runId ? (a.runGroup.order ?? 0) : 0) - (b.runGroup?.id === runId ? (b.runGroup.order ?? 0) : 0));
612
743
  if (sessions.length === 0)
613
744
  throw new Error(`No session took part in run ${runId}.`);
614
- const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId);
745
+ /*
746
+ * The run's name and start, from a session's own record of the run — or, once a later run has
747
+ * replaced that record, from a task that ran in it, which carries both. Without the second,
748
+ * the earlier run's file was called "run-" with nothing after it, the kind of name nobody
749
+ * finds again.
750
+ */
751
+ const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId) ??
752
+ sessions.flatMap((s) => s.tasks.map((t) => taskInRun(t, runId)?.runGroup)).find((g) => g?.id === runId);
615
753
  const name = group?.name?.trim() || `run-${(group?.startedAt ?? '').slice(0, 16).replace(/[:T]/g, '-')}`;
616
- scope = {
617
- sessions,
618
- // A task belongs to the run if this attempt ran in it, or if it was queued for it and
619
- // never reached — that second kind is what the run's own record on the session is for.
620
- taskFilter: (s, t) => t.runGroup?.id === runId || (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)),
621
- label: name,
622
- };
754
+ // Each task as it ended in this run, not as it is now; see `runScope`.
755
+ scope = runScope(sessions, runId, name);
623
756
  }
624
757
  else if (where.sessionId) {
625
758
  const session = await this.store.getSession(where.sessionId);
@@ -704,34 +837,56 @@ let OperatorService = class OperatorService {
704
837
  isRunning(sessionId) {
705
838
  return this.running.has(sessionId);
706
839
  }
840
+ /**
841
+ * Takes the browser for `ask`, or says why it cannot have it. Synchronous on purpose (see
842
+ * `browser`). What it hands back is what `releaseBrowser` is given, so a release that comes late
843
+ * cannot free a browser somebody else has taken since.
844
+ */
845
+ claimBrowser(ask) {
846
+ if (this.browser)
847
+ return { refused: browserRefusal(this.browser, ask) };
848
+ const holder = ask.kind === 'run' ? { kind: 'run', sessionId: ask.sessionId } : { kind: ask.kind };
849
+ this.browser = holder;
850
+ return { holder };
851
+ }
852
+ releaseBrowser(holder) {
853
+ if (this.browser === holder)
854
+ this.browser = null;
855
+ }
707
856
  /**
708
857
  * Starts the session's queued tasks in the background. Returns immediately; progress
709
- * arrives on the event stream. One run per session at a time.
858
+ * arrives on the event stream. One run at a time, of this session or any other.
710
859
  */
711
860
  async start(sessionId, mode = 'confirm', name) {
712
861
  // One conversation at a time is not a policy, it is the browser profile: a second run
713
- // does not get a second browser, it gets an error about a closed one. A batch already
714
- // holds that turn, so a single session asking for it now is refused where the reason can
715
- // still be read, rather than three minutes later in a stack trace.
716
- if (this.batch?.running)
717
- return { started: false, reason: 'a batch of sessions is running' };
718
- // A run nobody named is named after what it is a run of, rather than left to show up in the
719
- // register as "Unnamed run" beside five others of the same description.
720
- const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
721
- const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
722
- // A run of one is still a run, and it records the same thing a batch does, so that going
723
- // back and starting again from a task works the same whether one session was started or six.
724
- await this.store.updateSession(sessionId, (s) => {
725
- s.runGroup = {
726
- ...runGroup,
727
- order: 0,
728
- taskIds: s.tasks.filter((t) => t.status === 'queued').map((t) => t.id),
729
- mode,
730
- onFailure: s.onFailure === 'continue' ? 'continue' : 'stop',
731
- };
732
- });
733
- const begun = await this.beginRun(sessionId, mode, undefined, runGroup);
734
- return { started: begun.started, reason: begun.reason };
862
+ // does not get a second browser, it gets an error about a closed one. Whatever has the
863
+ // browser now — a batch, another session, a read of the models — the start is refused where
864
+ // the reason can still be read, rather than three minutes later in a stack trace.
865
+ const claim = this.claimBrowser({ kind: 'run', sessionId });
866
+ if ('refused' in claim)
867
+ return { started: false, reason: claim.refused };
868
+ try {
869
+ // A run nobody named is named after what it is a run of, rather than left to show up in the
870
+ // register as "Unnamed run" beside five others of the same description.
871
+ const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
872
+ const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
873
+ // A run of one is still a run, and it records the same thing a batch does, so that going
874
+ // back and starting again from a task works the same whether one session was started or six.
875
+ // Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
876
+ const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
877
+ if (!begun.started) {
878
+ this.releaseBrowser(claim.holder);
879
+ return { started: false, reason: begun.reason };
880
+ }
881
+ // The window closes before the run's promise settles (see `runSession`), so the browser is
882
+ // free again exactly when it is.
883
+ void begun.done.finally(() => this.releaseBrowser(claim.holder));
884
+ return { started: true };
885
+ }
886
+ catch (e) {
887
+ this.releaseBrowser(claim.holder);
888
+ throw e;
889
+ }
735
890
  }
736
891
  /**
737
892
  * The same thing `start` does, but it also hands back a promise that settles when the run
@@ -748,12 +903,8 @@ let OperatorService = class OperatorService {
748
903
  /** The press of a start button this belongs to, stamped onto every task it reaches. */
749
904
  runGroup,
750
905
  /** Only these queued tasks, when the operator chose some; the rest stay queued. */
751
- onlyTasks) {
906
+ onlyTasks, how = {}) {
752
907
  await this.init();
753
- // Every project's Desktop copy brought up to date before the chat opens, when the operator
754
- // asked for that. Before the run rather than before each task: a task's own mirror already
755
- // refreshes its project, and the other projects do not change while the bot works.
756
- await this.refreshDesktopMirrors();
757
908
  const idle = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
758
909
  if (this.running.has(sessionId))
759
910
  return { started: false, reason: 'already running', done: Promise.resolve(idle) };
@@ -786,6 +937,12 @@ let OperatorService = class OperatorService {
786
937
  const precondition = unattendedPrecondition(policy);
787
938
  if (precondition)
788
939
  return { started: false, reason: precondition, done: Promise.resolve(idle) };
940
+ if (how.recordOnSession && runGroup) {
941
+ // The tasks this run takes, as worked out above, so the record and the run cannot disagree.
942
+ await this.store.updateSession(sessionId, (s) => {
943
+ s.runGroup = { ...runGroup, order: 0, taskIds: queuedIds, mode, onFailure: s.onFailure === 'continue' ? 'continue' : 'stop' };
944
+ });
945
+ }
789
946
  const controller = new AbortController();
790
947
  /*
791
948
  * The same authorizer in both modes. It used to be `unattendedAuthorizer` here for an unattended
@@ -813,14 +970,13 @@ let OperatorService = class OperatorService {
813
970
  bus: this.bus,
814
971
  authorizer,
815
972
  signal: controller.signal,
816
- // Read between tasks, so the one in flight finishes properly first. The batch owns the
817
- // flag, because a pause is about the whole run and not about this session.
818
- shouldPause: () => !!this.batch?.pausing,
973
+ // Read between tasks, so the one in flight finishes properly first. The batch that owns
974
+ // this run answers it, because a pause is about the whole run and not about this session.
975
+ shouldPause: how.shouldPause,
819
976
  // The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
820
977
  // and the policy.json of a later task must say so.
821
978
  currentMode: () => this.running.get(sessionId)?.mode ?? mode,
822
979
  // The Desktop copies follow the work while it happens, not only before the run.
823
- onWorkChanged: () => this.requestDesktopRefresh(),
824
980
  // The session decides whether its tasks are one chain or a set of independent checks.
825
981
  continueOnFailure: session.onFailure === 'continue',
826
982
  transport,
@@ -935,20 +1091,64 @@ let OperatorService = class OperatorService {
935
1091
  * tasks nobody chose stay queued exactly as they were. Absent means every queued task.
936
1092
  */
937
1093
  taskIds) {
938
- await this.init();
939
- const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
940
- if (this.batch?.running)
941
- return { started: false, reason: 'a batch is already running' };
942
1094
  const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
943
- if (wanted.length === 0)
944
- return { started: false, reason: 'no sessions were selected' };
1095
+ // Before anything is waited for (see `browser`): a second batch, a session running on its own
1096
+ // — one of these or any other — and a read of the models all have the browser already.
1097
+ const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
1098
+ if ('refused' in claim)
1099
+ return { started: false, reason: claim.refused };
1100
+ return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
1101
+ }
1102
+ /**
1103
+ * `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
1104
+ * to the batch once it is under way (`runBatch` gives it back), and given back here on every
1105
+ * refusal. "Run again from here" claims it before it moves anything and starts through this, so
1106
+ * one claim covers the restore, the requeue and the run.
1107
+ */
1108
+ async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
1109
+ const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
1110
+ let handedOn = false;
1111
+ try {
1112
+ await this.init();
1113
+ if (wanted.length === 0)
1114
+ return { started: false, reason: 'no sessions were selected' };
1115
+ const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name);
1116
+ if (!begun.batch)
1117
+ return { started: false, reason: begun.reason };
1118
+ this.batch = begun.batch;
1119
+ handedOn = true;
1120
+ void this.runBatch(begun.batch, holder);
1121
+ return { started: true, batch: this.batchState() };
1122
+ }
1123
+ finally {
1124
+ if (!handedOn)
1125
+ this.releaseBrowser(holder);
1126
+ }
1127
+ }
1128
+ /**
1129
+ * Why a batch in this mode may not begin on this machine now, or null when it may: the entrance
1130
+ * rule (`unattendedPrecondition`) asked of the settings as they are at this moment. One place, for
1131
+ * the batch and for "Run again from here", which has to ask it before it moves anything.
1132
+ */
1133
+ async unattendedRefusal(mode) {
1134
+ const cfg = await this.settings.load();
1135
+ return unattendedPrecondition({
1136
+ mode,
1137
+ allowedPrograms: cfg.execution.allowedPrograms,
1138
+ isolation: cfg.execution.isolation,
1139
+ lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
1140
+ });
1141
+ }
1142
+ /**
1143
+ * Everything `startBatch` decides before the batch exists: which sessions it has work in, whether
1144
+ * an unattended run may begin, and the models the run panel chose. A refusal comes back as the
1145
+ * reason; nothing is started here.
1146
+ */
1147
+ async prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name) {
945
1148
  // Worked out here rather than asked of the operator again: every way into this — the run
946
1149
  // panel, continuing after a failure, "Run again from here" — either has a field they may
947
1150
  // have left empty or has no field at all.
948
1151
  const chosenName = name?.trim() || (await this.suggestRunName(wanted));
949
- if (wanted.some((id) => this.running.has(id))) {
950
- return { started: false, reason: 'one of the selected sessions is already running on its own' };
951
- }
952
1152
  const sessions = [];
953
1153
  for (const id of wanted) {
954
1154
  const s = await this.store.getSession(id);
@@ -962,7 +1162,7 @@ let OperatorService = class OperatorService {
962
1162
  : { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
963
1163
  }
964
1164
  if (!sessions.some((s) => s.state === 'waiting')) {
965
- return { started: false, reason: 'none of the selected sessions has a queued task' };
1165
+ return { reason: 'none of the selected sessions has a queued task' };
966
1166
  }
967
1167
  /*
968
1168
  * The same entrance rule `start` has, asked here too and for the same reason: before a browser
@@ -972,17 +1172,9 @@ let OperatorService = class OperatorService {
972
1172
  * session was quietly skipped with the reason held in a batch state nobody was looking at. Asked
973
1173
  * here, the refusal is the answer to the press, shown under the button that caused it.
974
1174
  */
975
- {
976
- const cfg = await this.settings.load();
977
- const blocked = unattendedPrecondition({
978
- mode,
979
- allowedPrograms: cfg.execution.allowedPrograms,
980
- isolation: cfg.execution.isolation,
981
- lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
982
- });
983
- if (blocked)
984
- return { started: false, reason: blocked };
985
- }
1175
+ const blocked = await this.unattendedRefusal(mode);
1176
+ if (blocked)
1177
+ return { reason: blocked };
986
1178
  // One model for the whole run, chosen here rather than opened on every session first. It
987
1179
  // is written onto the sessions instead of being held for the run, so what the session says
988
1180
  // it will use and what it used are the same thing afterwards — including for anyone who
@@ -1014,20 +1206,20 @@ let OperatorService = class OperatorService {
1014
1206
  });
1015
1207
  }
1016
1208
  }
1017
- this.batch = {
1018
- id: newId('b-'),
1019
- startedAt: new Date().toISOString(),
1020
- name: chosenName,
1021
- ...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
1022
- mode,
1023
- onFailure,
1024
- stopping: false,
1025
- pausing: false,
1026
- running: true,
1027
- sessions,
1209
+ return {
1210
+ batch: {
1211
+ id: newId('b-'),
1212
+ startedAt: new Date().toISOString(),
1213
+ name: chosenName,
1214
+ ...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
1215
+ mode,
1216
+ onFailure,
1217
+ stopping: false,
1218
+ pausing: false,
1219
+ running: true,
1220
+ sessions,
1221
+ },
1028
1222
  };
1029
- void this.runBatch();
1030
- return { started: true, batch: this.batchState() };
1031
1223
  }
1032
1224
  /** Stops the session that is running now and leaves the rest of the batch unstarted. */
1033
1225
  /**
@@ -1081,6 +1273,24 @@ let OperatorService = class OperatorService {
1081
1273
  await this.stop(current.sessionId);
1082
1274
  return { stopping: true };
1083
1275
  }
1276
+ /**
1277
+ * Runs a batch that has just been started, and ends what only the batch may end: its hold and
1278
+ * its claim on the browser. Both are let go here, once, however the loop came out — finished,
1279
+ * stopped, failed, or never able to open its window.
1280
+ */
1281
+ async runBatch(batch, holder) {
1282
+ try {
1283
+ await this.batchLoop(batch);
1284
+ }
1285
+ finally {
1286
+ // A pause holds this batch's queue and nothing after it. Left on, it read as "pausing" on a
1287
+ // batch that had ended, and it was what every later run read, so each stopped before its
1288
+ // first task (see `shouldPause` in `beginRun`).
1289
+ batch.pausing = false;
1290
+ // The batch's window is closed by now, in the loop's own ending.
1291
+ this.releaseBrowser(holder);
1292
+ }
1293
+ }
1084
1294
  /**
1085
1295
  * The batch loop. Nothing here runs in parallel, on purpose: see `beginRun`.
1086
1296
  *
@@ -1088,10 +1298,7 @@ let OperatorService = class OperatorService {
1088
1298
  * failure is `done`; one with a failed task is `failed`; one the operator stopped, or one
1089
1299
  * whose chain stopped early with no failure of its own, is `stopped`.
1090
1300
  */
1091
- async runBatch() {
1092
- const batch = this.batch;
1093
- if (!batch)
1094
- return;
1301
+ async batchLoop(batch) {
1095
1302
  /*
1096
1303
  * One browser for the whole batch.
1097
1304
  *
@@ -1196,7 +1403,11 @@ let OperatorService = class OperatorService {
1196
1403
  message: `starting as part of a batch of ${batch.sessions.length} session(s), in ${batch.mode} mode`,
1197
1404
  data: { batchId: batch.id },
1198
1405
  });
1199
- const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined);
1406
+ // In the batch's mode as it is now: "run the rest without asking" in an earlier session
1407
+ // switched the batch with it (see `setRunMode`). The hold is this batch's own.
1408
+ const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
1409
+ shouldPause: () => batch.pausing,
1410
+ });
1200
1411
  if (!begun.started) {
1201
1412
  entry.state = 'skipped';
1202
1413
  entry.reason = begun.reason;
@@ -1301,11 +1512,18 @@ let OperatorService = class OperatorService {
1301
1512
  */
1302
1513
  const cfg = await this.settings.load();
1303
1514
  const unattendedRule = { mode: 'unattended', allowedPrograms: cfg.execution.allowedPrograms, isolation: cfg.execution.isolation, lockedToConfirm: cfg.policyLock?.maxMode === 'confirm' };
1515
+ /*
1516
+ * Which of its refusals it is, asked in the order the rule asks them: the lock, then isolation,
1517
+ * then the allowlist. Anything that was not isolation used to be told as an empty allowlist, so
1518
+ * under a policy lock the brief sent the operator to fill in a list that was already full.
1519
+ */
1304
1520
  const unattendedBlocked = !unattendedPrecondition(unattendedRule)
1305
1521
  ? undefined
1306
- : unattendedIsolationRefusal('unattended', cfg.execution.isolation)
1307
- ? 'isolation'
1308
- : 'allowlist';
1522
+ : unattendedRule.lockedToConfirm
1523
+ ? 'lock'
1524
+ : unattendedIsolationRefusal('unattended', cfg.execution.isolation)
1525
+ ? 'isolation'
1526
+ : 'allowlist';
1309
1527
  return {
1310
1528
  text: planBrief({
1311
1529
  lang,
@@ -1353,7 +1571,7 @@ let OperatorService = class OperatorService {
1353
1571
  check.plan.sessions.forEach((session, i) => {
1354
1572
  if (session.vcs?.enabled === false)
1355
1573
  return;
1356
- const dir = session.vcs?.repoDir?.trim() || session.mirror?.rootDir?.trim() || '';
1574
+ const dir = plannedRepoDir(session);
1357
1575
  const reason = repoUnusableReason(dir);
1358
1576
  if (reason)
1359
1577
  repoIssues.push({ path: `sessions[${i}].vcs.repoDir`, message: reason });
@@ -1369,17 +1587,23 @@ let OperatorService = class OperatorService {
1369
1587
  return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1370
1588
  /*
1371
1589
  * A branch to carry on must be there to carry on — unless a session before it in this same plan
1372
- * is the one that makes it. Refused now, with the branches that are there, rather than at the
1373
- * first run with a task refused.
1590
+ * is the one that makes it, in this same repository. Refused now, with the branches that are
1591
+ * there, rather than at the first run with a task refused.
1592
+ *
1593
+ * The repository is part of the question. A branch is a branch of one repository, and the name
1594
+ * alone let a branch an earlier session makes somewhere else count here: the plan was accepted,
1595
+ * and the session failed at its first task with "no such local branch" — the failure this check
1596
+ * exists to bring forward.
1374
1597
  */
1375
1598
  for (const [i, session] of check.plan.sessions.entries()) {
1376
1599
  const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
1377
1600
  if (!wanted)
1378
1601
  continue;
1379
- const dir = session.vcs?.repoDir?.trim() || session.mirror?.rootDir?.trim() || '';
1602
+ const dir = plannedRepoDir(session);
1603
+ const here = normaliseDir(dir);
1380
1604
  const madeEarlier = check.plan.sessions
1381
1605
  .slice(0, i)
1382
- .some((e) => !!e.vcs?.branchName?.trim() && plannedBranchName(e.vcs.branchName, e.vcs.branchPrefix?.trim() || 'cop/') === wanted);
1606
+ .some((e) => normaliseDir(plannedRepoDir(e)) === here && branchesMadeBy(e).includes(wanted));
1383
1607
  if (madeEarlier || (await branchExists(dir, wanted)))
1384
1608
  continue;
1385
1609
  const have = await localBranches(dir);
@@ -1436,7 +1660,9 @@ let OperatorService = class OperatorService {
1436
1660
  */
1437
1661
  const persona = (await this.getContext('persona', 'en')).content;
1438
1662
  // No default models passed: a session the plan gives no model follows Settings at run time.
1439
- const result = await importPlan(this.store, check.plan, '', '', persona);
1663
+ // The default project is passed, so a session the plan gives no folder works where one made
1664
+ // on the Sessions page would, and not in execution.cwd.
1665
+ const result = await importPlan(this.store, check.plan, '', '', persona, (cfg.project?.rootDir ?? '').trim());
1440
1666
  return {
1441
1667
  ok: true,
1442
1668
  result: { ...result, warnings: [...check.warnings, ...result.warnings] },
@@ -1507,14 +1733,26 @@ let OperatorService = class OperatorService {
1507
1733
  * one would read.
1508
1734
  */
1509
1735
  run.policy.mode = mode;
1736
+ /*
1737
+ * A batch is one run. The dialog behind "run the rest without asking" says "until this run
1738
+ * ends", and the run the operator started is the batch: the switch used to reach only the
1739
+ * session on screen, and the next session started in the batch's old mode and asked again. So
1740
+ * the sessions the batch has not reached yet start in the mode it is switched to — and back,
1741
+ * when the asking is switched on again. Each still meets the unattended precondition as it
1742
+ * starts (see `beginRun`), under the settings of that moment, and the run recorded on it
1743
+ * (`Session.runGroup`) keeps the mode it was started in.
1744
+ */
1745
+ const batch = this.batch?.running && this.batch.sessions.some((e) => e.sessionId === sessionId && e.state === 'running') ? this.batch : null;
1746
+ if (batch)
1747
+ batch.mode = mode;
1510
1748
  this.bus.publish({
1511
1749
  sessionId,
1512
1750
  type: 'run-mode-changed',
1513
1751
  level: mode === 'unattended' ? 'warn' : 'info',
1514
1752
  message: mode === 'unattended'
1515
- ? 'the rest of this run will execute without asking; denied patterns are still refused'
1516
- : 'every further step will be shown for approval again',
1517
- data: { mode },
1753
+ ? `the rest of this run${batch ? ', the sessions of the batch after this one included,' : ''} will execute without asking; denied patterns are still refused`
1754
+ : `every further step${batch ? ', in this session and the ones of the batch after it,' : ''} will be shown for approval again`,
1755
+ data: { mode, ...(batch ? { batchId: batch.id } : {}) },
1518
1756
  });
1519
1757
  if (mode === 'unattended') {
1520
1758
  for (const [id, w] of this.waiting) {
@@ -1534,6 +1772,12 @@ let OperatorService = class OperatorService {
1534
1772
  }
1535
1773
  webAuthorizer(policy, signal) {
1536
1774
  return makeAuthorizer(policy, (step, ctx, held) => new Promise((resolvePromise) => {
1775
+ // A stopped run answers before anything else, the mode included: "run the rest without
1776
+ // asking" is permission to run the steps of a run that is going, not of one that was stopped.
1777
+ if (signal.aborted) {
1778
+ resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
1779
+ return;
1780
+ }
1537
1781
  // The operator may have pressed "run the rest without asking" on an earlier step.
1538
1782
  // This is checked per step rather than captured once, which is what makes the switch
1539
1783
  // take effect from the very next step instead of the next run. A held step is the
@@ -1551,17 +1795,13 @@ let OperatorService = class OperatorService {
1551
1795
  createdAt: new Date().toISOString(),
1552
1796
  ...(held ? { network: held.network } : {}),
1553
1797
  };
1554
- if (signal.aborted) {
1555
- resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
1556
- return;
1557
- }
1558
1798
  this.waiting.set(approval.id, { approval, resolve: resolvePromise });
1559
1799
  this.bus.publish({ sessionId: approval.sessionId, taskId: approval.taskId, type: 'approval-requested', level: 'warn',
1560
1800
  message: held
1561
1801
  ? `held for the operator — ${held.network}: ${approval.description}`
1562
1802
  : `waiting for approval: ${approval.description}`,
1563
1803
  data: { ...approval } });
1564
- }));
1804
+ }), signal);
1565
1805
  }
1566
1806
  // --- events ---------------------------------------------------------------------------
1567
1807
  recentEvents(sessionId) {
@@ -1601,24 +1841,68 @@ let OperatorService = class OperatorService {
1601
1841
  const task = session.tasks.find((t) => t.id === taskId);
1602
1842
  if (!task)
1603
1843
  throw new Error('No such task.');
1604
- return await restorePreview(session, task);
1844
+ const preview = await restorePreview(session, task);
1845
+ // Said in the preview too, so the dialog refuses before the operator agrees to anything,
1846
+ // rather than showing what would happen and refusing when they say yes.
1847
+ const refused = await this.restoreRefusal(session);
1848
+ return refused ? { ...preview, ok: false, problem: refused } : preview;
1605
1849
  }
1606
1850
  async restore(sessionId, taskId) {
1607
1851
  await this.init();
1608
- if (this.running.has(sessionId)) {
1609
- return { ok: false, problem: 'This session is running. Stop it before moving the repository to another branch.' };
1610
- }
1611
- if (this.batch?.running) {
1612
- return { ok: false, problem: 'A batch is running. Stop it before moving the repository to another branch.' };
1613
- }
1614
1852
  const session = await this.store.getSession(sessionId);
1615
1853
  if (!session)
1616
1854
  throw new Error('No such session.');
1617
1855
  const task = session.tasks.find((t) => t.id === taskId);
1618
1856
  if (!task)
1619
1857
  throw new Error('No such task.');
1858
+ const refused = await this.restoreRefusal(session);
1859
+ if (refused)
1860
+ return { ok: false, problem: refused };
1620
1861
  return await restoreToBase(session, task, this.bus);
1621
1862
  }
1863
+ /**
1864
+ * Why the repository of this session cannot be moved to another branch now, or null when it can.
1865
+ *
1866
+ * Anything that may be working in that folder at this moment: the session itself, a batch (which
1867
+ * may reach a session in it next), and any other session running there on its own. Only the first
1868
+ * two used to be asked, so a restore checked out a branch under another session in the middle of
1869
+ * its task — and that session then found its repository moved, and left its work uncommitted.
1870
+ * Version control being off for the other session does not make it safe: its commands still run
1871
+ * in that folder.
1872
+ */
1873
+ async restoreRefusal(session) {
1874
+ if (this.running.has(session.id))
1875
+ return 'This session is running. Stop it before moving the repository to another branch.';
1876
+ if (this.batch?.running)
1877
+ return 'A batch is running. Stop it before moving the repository to another branch.';
1878
+ const other = await this.runningIn(repoDirOf(session));
1879
+ return other
1880
+ ? `Session "${other.name}" is running in this repository. Stop it before moving the repository to another branch.`
1881
+ : null;
1882
+ }
1883
+ /**
1884
+ * A session running right now whose commands run in this folder, inside it, or in a folder around it.
1885
+ *
1886
+ * By where its commands run, the rule `workingDirFor` decides it by: the repository, else the
1887
+ * project folder, else `execution.cwd`. Compared as folders, not as equal paths: an audit with
1888
+ * version control off whose project is the repository's `web` folder works in the tree a checkout
1889
+ * rewrites, and so does a session whose folder holds the repository. Only the same path used to
1890
+ * count, and a restore changed files under such a session in the middle of its task.
1891
+ */
1892
+ async runningIn(dir) {
1893
+ const here = normaliseDir(dir);
1894
+ if (!here)
1895
+ return undefined;
1896
+ for (const id of this.running.keys()) {
1897
+ const s = await this.store.getSession(id);
1898
+ if (!s)
1899
+ continue;
1900
+ const there = normaliseDir(repoDirOf(s) || (await this.settings.load()).resolved.cwd);
1901
+ if (there && overlaps(here, there))
1902
+ return s;
1903
+ }
1904
+ return undefined;
1905
+ }
1622
1906
  /**
1623
1907
  * Everything one run was asked to do, from a given task onward, in the order it would run.
1624
1908
  *
@@ -1705,37 +1989,27 @@ let OperatorService = class OperatorService {
1705
1989
  })),
1706
1990
  sessions: sessions.map((s) => ({ id: s.id, name: s.name, tasks: tasks.filter((x) => x.session.id === s.id).length })),
1707
1991
  restores: [],
1708
- mode: group?.mode ?? 'unattended',
1992
+ /*
1993
+ * Nothing on record says a run with no record of its own ever went unattended, and taken for
1994
+ * one, it was refused on every machine that refuses unattended runs, the default among them:
1995
+ * the dialog has no other mode to offer, so its button could never do anything for such a task.
1996
+ */
1997
+ mode: group?.mode ?? 'confirm',
1709
1998
  onFailure: group?.onFailure ?? session.onFailure ?? 'stop',
1710
1999
  };
1711
- /*
1712
- * One restore per repository, keyed on the folder rather than on the session.
1713
- *
1714
- * Three sessions working in one repository must not take it back three times to three
1715
- * different commits; the earliest affected task is the one that decides, because that is
1716
- * the state the whole re-run starts from.
1717
- */
1718
- const byRepo = new Map();
1719
- for (const entry of tasks) {
1720
- const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
1721
- if (!dir)
1722
- continue;
1723
- // A repository whose affected tasks never ran has nothing to go back to: the run never
1724
- // touched it. Taking "no base commit recorded" as a refusal blocked "run again from
1725
- // here" for every plan whose later sessions had not started yet — which is the usual
1726
- // shape of a run that stopped in the middle.
1727
- if (!entry.task.startedAt)
1728
- continue;
1729
- if (!byRepo.has(dir))
1730
- byRepo.set(dir, entry);
1731
- }
1732
- for (const [dir, entry] of byRepo) {
1733
- const preview = await restorePreview(entry.session, entry.task);
2000
+ // Asked of the machine as it is now, as `restartFrom` will ask it; see `unattendedRefused`.
2001
+ if (plan.mode === 'unattended')
2002
+ plan.unattendedRefused = (await this.unattendedRefusal('unattended')) ?? undefined;
2003
+ for (const target of restoreTargets(tasks)) {
2004
+ const preview = await restorePreview(target.session, target.task);
2005
+ // What would stop the restore itself, asked here as well, so the dialog says it before the
2006
+ // operator agrees rather than after.
2007
+ const refused = target.problem ?? (await this.restoreRefusal(target.session));
1734
2008
  plan.restores.push({
1735
- repoDir: preview.repoDir || dir,
1736
- ok: preview.ok,
1737
- problem: preview.problem,
1738
- forTask: entry.task.title,
2009
+ repoDir: preview.repoDir || target.dir,
2010
+ ok: preview.ok && !refused,
2011
+ problem: refused ?? preview.problem,
2012
+ forTask: target.task.title,
1739
2013
  baseCommit: preview.baseCommit,
1740
2014
  branchName: preview.branchName,
1741
2015
  leftBehind: preview.leftBehind,
@@ -1767,27 +2041,95 @@ let OperatorService = class OperatorService {
1767
2041
  if (plan.sessions.some((s) => this.running.has(s.id))) {
1768
2042
  return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
1769
2043
  }
2044
+ /*
2045
+ * Claimed before anything is moved, and held until the run it ends in has the browser: that run
2046
+ * needs it, and another session running on its own, or a read of the models, may have it. Found
2047
+ * out only at the start, the repositories were already taken back and the tasks queued, with
2048
+ * nothing to run them; and only looked at here, the browser could still be taken by a start that
2049
+ * came in while the repositories were being taken back, to the same end. Given back on every way
2050
+ * out that does not start the run.
2051
+ */
2052
+ const ids = plan.sessions.map((s) => s.id);
2053
+ if (opts.start === false)
2054
+ return await this.restartMoves(sessionId, taskId, plan, opts);
2055
+ /*
2056
+ * The run's entrance rule, asked before anything moves, for the same reason as the browser
2057
+ * below. Asked only when the batch began, a machine that no longer lets this run go unattended
2058
+ * (a policy lock added since, isolation no longer accepted) took the repositories back and
2059
+ * queued the tasks, and only then refused the run they were moved for. The plan says so first
2060
+ * (`unattendedRefused`), so the dialog asks for the run step by step instead.
2061
+ */
2062
+ const blocked = await this.unattendedRefusal(opts.mode ?? plan.mode);
2063
+ if (blocked)
2064
+ return { ...idle, reason: blocked };
2065
+ const claim = this.claimBrowser({ kind: 'batch', sessionIds: ids });
2066
+ if ('refused' in claim)
2067
+ return { ...idle, reason: claim.refused };
2068
+ let handedOn = false;
2069
+ try {
2070
+ const moved = await this.restartMoves(sessionId, taskId, plan, opts);
2071
+ if (moved.reason !== undefined)
2072
+ return moved;
2073
+ // Named after the task it goes back to, which is the one thing this run is about and the
2074
+ // only thing a heading could usefully say. There is no field to type into on a task card,
2075
+ // so leaving it to the operator meant leaving it blank every time.
2076
+ const runName = await this.suggestRunName(ids, plan.from.title);
2077
+ // From here the claim is the batch's to give back, whichever way its start goes.
2078
+ handedOn = true;
2079
+ /*
2080
+ * Exactly the tasks the preview listed, which are the tasks the run was asked to do from this
2081
+ * one on. Started with none named, the batch took every queued task of these sessions, so a
2082
+ * task the run never chose — queued beside it, or added since — was sent to the chat as well.
2083
+ */
2084
+ const taskIds = plan.tasks.map((x) => x.taskId);
2085
+ const started = await this.startBatchHolding(claim.holder, ids, opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined, runName, taskIds);
2086
+ // A start refused now leaves the moves in place, and says so with its reason.
2087
+ const reason = started.started || started.reason === undefined ? started.reason : afterMoves(started.reason, moved.restored, moved.requeued);
2088
+ return { started: started.started, reason, requeued: moved.requeued, restored: moved.restored, batch: started.batch };
2089
+ }
2090
+ finally {
2091
+ if (!handedOn)
2092
+ this.releaseBrowser(claim.holder);
2093
+ }
2094
+ }
2095
+ /**
2096
+ * What `restartFrom` moves before its run starts: the repositories taken back, then the tasks
2097
+ * queued again. A refusal, and "prepared, not started", come back with their reason; with none,
2098
+ * everything is in place for the run.
2099
+ */
2100
+ async restartMoves(sessionId, taskId, plan, opts) {
2101
+ const idle = { started: false, requeued: 0, restored: [] };
1770
2102
  const restored = [];
1771
2103
  if (opts.restore !== false) {
1772
2104
  for (const entry of plan.restores) {
1773
2105
  if (!entry.ok)
1774
2106
  return { ...idle, reason: `${entry.repoDir}: ${entry.problem ?? 'the restore could not be prepared.'}` };
1775
2107
  }
1776
- // Re-read each repository's own pivot and do it for real. The preview above is what the
1777
- // operator agreed to; this is the same call, one step further.
2108
+ /*
2109
+ * Re-read each repository's own pivot and do it for real. The preview above is what the
2110
+ * operator agreed to; this is the same choice (`restoreTargets`), one step further. It was
2111
+ * worked out a second time here, by a copy of the rule that had not been given the preview's
2112
+ * "never started" skip, so a run whose later repository was never reached failed on it after
2113
+ * the first repository had already been moved — and queued nothing.
2114
+ *
2115
+ * A refusal part-way still says what was already moved, in the result and in its reason (the
2116
+ * one thing the page shows): a repository taken back is the operator's to know about, whatever
2117
+ * happened after it.
2118
+ */
1778
2119
  const session = (await this.store.getSession(sessionId));
1779
2120
  const task = session.tasks.find((t) => t.id === taskId);
1780
2121
  const { tasks } = await this.affectedByRestart(session, task);
1781
- const seenRepo = new Set();
1782
- for (const entry of tasks) {
1783
- const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
1784
- if (!dir || seenRepo.has(dir))
1785
- continue;
1786
- seenRepo.add(dir);
1787
- const done = await restoreToBase(entry.session, entry.task, this.bus);
1788
- if (!done.ok)
1789
- return { ...idle, reason: `${dir}: ${done.problem ?? 'the restore failed.'}` };
1790
- restored.push(`${dir} -> ${done.branch ?? ''}`);
2122
+ for (const target of restoreTargets(tasks)) {
2123
+ // Asked again at the moment of moving: a session may have started in this folder since.
2124
+ const refused = target.problem ?? (await this.restoreRefusal(target.session));
2125
+ if (refused)
2126
+ return { ...idle, restored, reason: afterMoves(`${target.dir}: ${refused}`, restored, 0) };
2127
+ const done = await restoreToBase(target.session, target.task, this.bus);
2128
+ if (!done.ok || !done.branch) {
2129
+ return { ...idle, restored, reason: afterMoves(`${target.dir}: ${done.problem ?? 'the restore failed.'}`, restored, 0) };
2130
+ }
2131
+ restored.push(`${target.dir} -> ${done.branch}`);
2132
+ await this.rerunFromRestore(target, done.branch, tasks);
1791
2133
  }
1792
2134
  }
1793
2135
  let requeued = 0;
@@ -1800,8 +2142,8 @@ let OperatorService = class OperatorService {
1800
2142
  }
1801
2143
  catch (e) {
1802
2144
  // One task refusing to be queued again must not leave the rest half-reset with no
1803
- // explanation, so it stops here and says which one and why.
1804
- return { ...idle, restored, reason: `"${t.title}" could not be queued again: ${e.message}` };
2145
+ // explanation, so it stops here and says which one and why — and what was done before it.
2146
+ return { ...idle, restored, requeued, reason: afterMoves(`"${t.title}" could not be queued again: ${e.message}`, restored, requeued) };
1805
2147
  }
1806
2148
  }
1807
2149
  this.bus.publish({
@@ -1815,12 +2157,102 @@ let OperatorService = class OperatorService {
1815
2157
  });
1816
2158
  if (opts.start === false)
1817
2159
  return { started: false, reason: 'prepared, not started', requeued, restored };
1818
- const started = await this.startBatch(plan.sessions.map((x) => x.id), opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined,
1819
- // Named after the task it goes back to, which is the one thing this run is about and the
1820
- // only thing a heading could usefully say. There is no field to type into on a task card,
1821
- // so leaving it to the operator meant leaving it blank every time.
1822
- await this.suggestRunName(plan.sessions.map((x) => x.id), plan.from.title));
1823
- return { started: started.started, reason: started.reason, requeued, restored, batch: started.batch };
2160
+ return { started: false, requeued, restored };
2161
+ }
2162
+ /**
2163
+ * Makes a restore hold for the run that follows it.
2164
+ *
2165
+ * The restore puts the repository on a new branch at the pivot task's starting commit, and the
2166
+ * re-run then puts each session on its own branch. In per-task mode that is right: a re-run cuts
2167
+ * a fresh branch from where the task first started, which is the restore commit. In per-session
2168
+ * mode it undid the restore: the session's one branch was checked out again, with the failed
2169
+ * attempt's commit still on it, so the attempt that was to start from the code as it was began on
2170
+ * the work it was meant to replace, and committed it a second time. So the pivot's session now
2171
+ * works on the restore branch — the code as it was before that task, with the tasks before it —
2172
+ * and its old branch keeps the attempt that failed, as the confirmation says.
2173
+ *
2174
+ * A per-session session after it in the same repository that this run reached has the same
2175
+ * problem whatever it started from: its one branch carries what its tasks did in this run, and
2176
+ * checked out again it would start them over on their own first attempt. So it gets a branch of
2177
+ * its own, and the old one keeps what it did. Where that branch starts depends on where the
2178
+ * session began. From a named branch: where its first task in this run began, as a restore of
2179
+ * that task would go back to, and the session's start stays as recorded. From where the
2180
+ * repository was, or from the session before it: that start was work this run is now redoing, so
2181
+ * it is forgotten, to be worked out again when the session is reached (for a `previous-session`
2182
+ * chain, from the work done again), and the branch is cut from that. A session the run never
2183
+ * reached holds nothing of this run and is left alone.
2184
+ *
2185
+ * A later per-task session is left as it is. From a named branch that is right: each re-run is
2186
+ * cut from where its task first started, that branch's commit. Chained on this run's work it is
2187
+ * not, and is not solved here: its tasks first started on the work being redone, so their re-runs
2188
+ * are cut from it again. Telling that apart from an operator's new "Start from", whose re-runs go
2189
+ * back to where each task first started on purpose (see `updateSession`), needs the restart
2190
+ * written on the session, and the session's record has no field for it.
2191
+ */
2192
+ async rerunFromRestore(target, branch, tasks) {
2193
+ if (target.session.vcs?.branchMode === 'per-session') {
2194
+ const left = sessionBranchName(target.session);
2195
+ // The restore branch exists by this name, so the name is kept as it is (see `branchNameExact`).
2196
+ await this.store.updateSession(target.session.id, (s) => {
2197
+ if (s.vcs) {
2198
+ s.vcs.branchName = branch;
2199
+ s.vcs.branchNameExact = true;
2200
+ }
2201
+ });
2202
+ this.bus.publish({
2203
+ sessionId: target.session.id,
2204
+ taskId: target.task.id,
2205
+ type: 'vcs-session-branch',
2206
+ level: 'info',
2207
+ message: `the session carries on from ${branch}, the code as it was before "${target.task.title}"; ${left} keeps the work that came after`,
2208
+ data: { branch, left },
2209
+ });
2210
+ }
2211
+ const here = normaliseDir(target.dir);
2212
+ const inOrder = [...new Map(tasks.map(({ session }) => [session.id, session])).values()];
2213
+ const later = inOrder.slice(inOrder.findIndex((s) => s.id === target.session.id) + 1);
2214
+ for (const s of later) {
2215
+ // One that carries on an existing branch never gets here: `restoreTargets` refuses the restart.
2216
+ if (!s.vcs?.enabled || s.vcs.branchMode !== 'per-session' || s.vcs.startFrom === 'existing-branch')
2217
+ continue;
2218
+ // No base recorded: version control never got as far as making its branch.
2219
+ if (!s.vcsBaseCommit || normaliseDir(repoDirOf(s)) !== here)
2220
+ continue;
2221
+ const first = tasks.find((x) => x.session.id === s.id && x.task.startedAt)?.task;
2222
+ if (!first)
2223
+ continue;
2224
+ const left = sessionBranchName(s);
2225
+ const fresh = await freeBranchName(target.dir, left);
2226
+ // No record of the start is read as chained, the reading that cannot bring undone work back.
2227
+ const chained = s.vcsStart?.kind !== 'branch';
2228
+ const at = chained ? undefined : restorePoint(s, first);
2229
+ if (at) {
2230
+ const made = await branchAt(target.dir, fresh, at);
2231
+ // Not made: left to the run, which cuts it from the session's start when it is reached.
2232
+ if (!made.ok) {
2233
+ this.bus.publish({ sessionId: s.id, type: 'vcs-problem', level: 'warn', message: `${fresh} could not be made at ${at.slice(0, 8)}: ${made.problem}` });
2234
+ }
2235
+ }
2236
+ await this.store.updateSession(s.id, (x) => {
2237
+ if (chained) {
2238
+ x.vcsBaseCommit = undefined;
2239
+ x.vcsStart = undefined;
2240
+ }
2241
+ if (x.vcs) {
2242
+ x.vcs.branchName = fresh;
2243
+ x.vcs.branchNameExact = true;
2244
+ }
2245
+ });
2246
+ this.bus.publish({
2247
+ sessionId: s.id,
2248
+ type: 'vcs-session-branch',
2249
+ level: 'info',
2250
+ message: chained
2251
+ ? `the session began from work this run is doing again, so it starts again when it is reached, on a new branch ${fresh}; ${left} keeps what it did before`
2252
+ : `the session starts its tasks again on a new branch ${fresh}, from where "${first.title}" began; ${left} keeps what it did before`,
2253
+ data: { branch: fresh, left },
2254
+ });
2255
+ }
1824
2256
  }
1825
2257
  /** Why a folder cannot be used for version control, or null when it can. */
1826
2258
  repoProblem(dir) {
@@ -1838,7 +2270,7 @@ let OperatorService = class OperatorService {
1838
2270
  await this.init();
1839
2271
  const wanted = normaliseDir(dir);
1840
2272
  const cfg = await this.settings.load();
1841
- const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.mirror?.rootDir ?? ''])]
2273
+ const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.projectDir ?? ''])]
1842
2274
  .filter((d) => d.trim())
1843
2275
  .map(normaliseDir));
1844
2276
  if (!wanted || !known.has(wanted)) {
@@ -1859,7 +2291,7 @@ let OperatorService = class OperatorService {
1859
2291
  const problem = repoUnusableReason(rootDir);
1860
2292
  const others = (cfg.project?.others ?? []).map((o) => {
1861
2293
  const p = repoUnusableReason(o.rootDir);
1862
- return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror, desktop: o.desktop !== false };
2294
+ return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}) };
1863
2295
  });
1864
2296
  return {
1865
2297
  rootDir,
@@ -1867,65 +2299,8 @@ let OperatorService = class OperatorService {
1867
2299
  repoOk: rootDir !== '' && problem === null,
1868
2300
  ...(problem ? { repoProblem: problem } : {}),
1869
2301
  others,
1870
- mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
1871
- desktop: cfg.project?.desktop !== false,
1872
- mirror: cfg.project?.mirror,
1873
- contextRoot: contextRoot(cfg),
1874
2302
  };
1875
2303
  }
1876
- desktopRefresh = null;
1877
- desktopRefreshAgain = false;
1878
- /**
1879
- * Asks for the Desktop copies to be brought up to date, while a run works.
1880
- *
1881
- * They used to be refreshed once, before a run, so a task that changed files left the Desktop —
1882
- * and OneDrive, and the chat that reads from it — on the code as it was before the task. The
1883
- * runner now calls this after every round of steps and after every task. One refresh runs at a
1884
- * time: a request that arrives while one is running is remembered and served by one more pass
1885
- * after it, so a burst of rounds costs two passes, not one per round, and the last pass always
1886
- * sees the latest files. A failure is reported and never stops the run.
1887
- */
1888
- requestDesktopRefresh() {
1889
- if (this.desktopRefresh) {
1890
- this.desktopRefreshAgain = true;
1891
- return;
1892
- }
1893
- this.desktopRefresh = (async () => {
1894
- do {
1895
- this.desktopRefreshAgain = false;
1896
- await this.refreshDesktopMirrors();
1897
- } while (this.desktopRefreshAgain);
1898
- })().finally(() => {
1899
- this.desktopRefresh = null;
1900
- });
1901
- }
1902
- /** Resolves once no Desktop refresh is running or waiting. For the checks, and for a clean stop. */
1903
- async desktopRefreshSettled() {
1904
- while (this.desktopRefresh)
1905
- await this.desktopRefresh;
1906
- }
1907
- /**
1908
- * Refreshes every project's Desktop folder, when the switch is on. Called before a run
1909
- * starts, so what OneDrive holds when the chat opens is the code as it is now, for every
1910
- * project on the Settings page and not only the one the session is about.
1911
- */
1912
- async refreshDesktopMirrors() {
1913
- const cfg = await this.settings.load();
1914
- if (!cfg.project?.mirrorToDesktop)
1915
- return;
1916
- try {
1917
- const outcomes = await mirrorAllProjects(cfg);
1918
- this.bus.publish({
1919
- sessionId: '*',
1920
- type: 'desktop-mirror',
1921
- level: outcomes.some((o) => o.problem) ? 'warn' : 'info',
1922
- message: outcomes.map((o) => `${o.name}: ${o.result ? describeMirror(o.result) : `not refreshed — ${o.problem}`}`).join('; ') || 'no projects to mirror',
1923
- });
1924
- }
1925
- catch (e) {
1926
- this.bus.publish({ sessionId: '*', type: 'desktop-mirror-failed', level: 'warn', message: e.message });
1927
- }
1928
- }
1929
2304
  /**
1930
2305
  * Stores the project folder and the other folders the operator works in. A field left out
1931
2306
  * of the patch is kept; an empty `rootDir` clears the default, which is a real choice: it
@@ -1938,54 +2313,37 @@ let OperatorService = class OperatorService {
1938
2313
  * — each of those would be a setting that looks filled in and points nowhere.
1939
2314
  */
1940
2315
  async setProject(patch) {
1941
- const raw = await this.settings.raw();
1942
- const current = (raw.project ?? {});
1943
- const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
1944
- const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
1945
- if (rootDir && !existsSync(rootDir))
1946
- throw new Error(`The folder ${rootDir} does not exist on this machine.`);
1947
- const others = (patch.others ?? current.others ?? []).map((o) => ({
1948
- name: (o.name ?? '').trim(),
1949
- rootDir: (o.rootDir ?? '').trim(),
1950
- ...(o.mirror ? { mirror: o.mirror } : {}),
1951
- // Stored only when it is off: absent means the project goes to the Desktop with the rest.
1952
- ...(o.desktop === false ? { desktop: false } : {}),
1953
- }));
1954
- const seen = new Set();
1955
- for (const o of others) {
1956
- if (!o.name)
1957
- throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
1958
- if (!o.rootDir)
1959
- throw new Error(`The project "${o.name}" needs a folder.`);
1960
- if (!existsSync(o.rootDir))
1961
- throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
1962
- if (sameFolder(o.rootDir, rootDir))
1963
- throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
1964
- const key = o.name.toLowerCase();
1965
- if (seen.has(key))
1966
- throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
1967
- seen.add(key);
1968
- }
1969
- const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
1970
- const mirror = patch.mirror ?? current.mirror;
1971
- const desktop = patch.desktop ?? current.desktop ?? true;
1972
- const { desktop: _old, ...rest } = current;
1973
- const cfg = await this.settings.save({
1974
- ...raw,
1975
- project: { ...rest, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}), ...(desktop === false ? { desktop: false } : {}) },
2316
+ // Worked out from the file in its turn (see `Settings.update`), so a model chosen at the same
2317
+ // moment is kept, and a refusal below leaves the file as it was.
2318
+ await this.settings.update((raw) => {
2319
+ const current = (raw.project ?? {});
2320
+ const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
2321
+ const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
2322
+ if (rootDir && !existsSync(rootDir))
2323
+ throw new Error(`The folder ${rootDir} does not exist on this machine.`);
2324
+ const others = (patch.others ?? current.others ?? []).map((o) => ({
2325
+ name: (o.name ?? '').trim(),
2326
+ rootDir: (o.rootDir ?? '').trim(),
2327
+ }));
2328
+ const seen = new Set();
2329
+ for (const o of others) {
2330
+ if (!o.name)
2331
+ throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
2332
+ if (!o.rootDir)
2333
+ throw new Error(`The project "${o.name}" needs a folder.`);
2334
+ if (!existsSync(o.rootDir))
2335
+ throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
2336
+ if (sameFolder(o.rootDir, rootDir))
2337
+ throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
2338
+ const key = o.name.toLowerCase();
2339
+ if (seen.has(key))
2340
+ throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
2341
+ seen.add(key);
2342
+ }
2343
+ // What the removed Desktop copies left in the stored project is dropped with the next save.
2344
+ const { mirror: _m, desktop: _d, mirrorToDesktop: _t, ...rest } = current;
2345
+ return { ...raw, project: { ...rest, rootDir, name, others } };
1976
2346
  });
1977
- /*
1978
- * The switch acts at once, both ways. On: every project's folder appears on the Desktop
1979
- * now, not at the next run, because "I turned it on and nothing happened" is a support
1980
- * question. Off: the folders go, because a copy of a code base left in OneDrive after the
1981
- * operator said no is the one outcome the switch must not produce. A changed selection
1982
- * with the switch on is applied the same way.
1983
- */
1984
- const wasOn = current.mirrorToDesktop ?? false;
1985
- if (mirrorToDesktop)
1986
- await mirrorAllProjects(cfg);
1987
- else if (wasOn)
1988
- await removeProjectMirrors(cfg);
1989
2347
  return await this.project();
1990
2348
  }
1991
2349
  // --- the model catalogue ----------------------------------------------------------------
@@ -2010,9 +2368,7 @@ let OperatorService = class OperatorService {
2010
2368
  */
2011
2369
  async setDefaultReviewModel(name) {
2012
2370
  await this.init();
2013
- const raw = await this.settings.raw();
2014
- const copilot = { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() };
2015
- await this.settings.save({ ...raw, copilot });
2371
+ await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() } }));
2016
2372
  this.bus.publish({
2017
2373
  sessionId: '*',
2018
2374
  type: 'default-review-model-changed',
@@ -2030,9 +2386,7 @@ let OperatorService = class OperatorService {
2030
2386
  */
2031
2387
  async setDefaultModel(name) {
2032
2388
  await this.init();
2033
- const raw = await this.settings.raw();
2034
- const copilot = { ...(raw.copilot ?? {}), defaultModel: name.trim() };
2035
- await this.settings.save({ ...raw, copilot });
2389
+ await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultModel: name.trim() } }));
2036
2390
  this.bus.publish({
2037
2391
  sessionId: '*',
2038
2392
  type: 'default-model-changed',
@@ -2052,27 +2406,99 @@ let OperatorService = class OperatorService {
2052
2406
  * gets to choose from, which is the only way this keeps working when Microsoft changes the
2053
2407
  * line-up.
2054
2408
  */
2055
- async refreshModels() {
2056
- await this.init();
2057
- if (this.running.size > 0) {
2058
- throw new Error('A session is running and it is using the browser profile. Stop it first, then read the models.');
2409
+ /**
2410
+ * "Sign in" from Settings: what `cop login` does, from the page.
2411
+ *
2412
+ * Opens Edge with the bot's own profile — the one in Settings, which is the one every run uses —
2413
+ * and waits for the operator to sign in to Microsoft 365 Copilot. With an account, the profile is
2414
+ * signed out first and the chat asked for that account, because Edge on a work machine otherwise
2415
+ * signs the profile in with whatever account Windows knows. The bot never types a credential:
2416
+ * the person signs in, completes any verification, and the window closes once the chat is there.
2417
+ *
2418
+ * The request stays open until then (up to `copilot.signInTimeoutSec`). It takes the browser like
2419
+ * a run does, so it is refused while a run or a reading of the models has it, and they are while
2420
+ * it is open.
2421
+ */
2422
+ async login(account) {
2423
+ const upn = account?.trim() || undefined;
2424
+ if (upn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(upn)) {
2425
+ throw new Error(`"${upn}" is not an account address. Give the work or school address, for example name@company.com, or leave it empty.`);
2059
2426
  }
2060
- if (this.readingModels)
2061
- throw new Error('The model list is already being read.');
2062
- this.readingModels = true;
2063
- const cfg = await this.settings.load();
2064
- const transport = createTransport({
2065
- profileDir: cfg.resolved.profileDir,
2066
- transportDir: join(cfg.resolved.runsDir, '_models'),
2067
- chatUrl: cfg.copilot.url,
2068
- channel: cfg.copilot.channel,
2069
- headless: cfg.copilot.headless,
2070
- replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
2071
- signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
2072
- humanWaitMs: cfg.copilot.humanWaitSec * 1000,
2073
- keepFailurePage: cfg.copilot.keepFailurePage,
2074
- });
2427
+ const claim = this.claimBrowser({ kind: 'login' });
2428
+ if ('refused' in claim)
2429
+ throw new Error(claim.refused);
2430
+ let transport = null;
2075
2431
  try {
2432
+ await this.init();
2433
+ const cfg = await this.settings.load();
2434
+ transport = createTransport({
2435
+ profileDir: cfg.resolved.profileDir,
2436
+ transportDir: join(cfg.resolved.runsDir, '_login'),
2437
+ chatUrl: cfg.copilot.url,
2438
+ channel: cfg.copilot.channel,
2439
+ // Always on screen, whatever Settings say: a person has to see the window to sign in.
2440
+ headless: false,
2441
+ replyTimeoutMs: 60_000,
2442
+ signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
2443
+ humanWaitMs: cfg.copilot.signInTimeoutSec * 1000,
2444
+ keepFailurePage: cfg.copilot.keepFailurePage,
2445
+ });
2446
+ await transport.open();
2447
+ if (upn) {
2448
+ await transport.signOut();
2449
+ await transport.gotoChatAs(upn, cfg.copilot.url);
2450
+ }
2451
+ await transport.ensureSignedIn(upn ? undefined : cfg.copilot.url);
2452
+ const accounts = await transport.findAccountsInPage();
2453
+ if (!upn) {
2454
+ return {
2455
+ ok: true,
2456
+ accounts,
2457
+ message: accounts.length > 0 ? `Signed in. The page shows: ${accounts.join(', ')}.` : 'Signed in.',
2458
+ };
2459
+ }
2460
+ const matched = accounts.some((a) => a.toLowerCase() === upn.toLowerCase());
2461
+ if (matched)
2462
+ return { ok: true, accounts, account: upn, matched, message: `Signed in as ${upn}.` };
2463
+ if (accounts.length > 0) {
2464
+ return {
2465
+ ok: false,
2466
+ accounts,
2467
+ account: upn,
2468
+ matched,
2469
+ message: `Signed in, but not as ${upn}: the page shows ${accounts.join(', ')}. Sign in again and choose "Use another account".`,
2470
+ };
2471
+ }
2472
+ return { ok: true, accounts, account: upn, message: `Signed in. The account could not be read from the page, so ${upn} is not confirmed.` };
2473
+ }
2474
+ finally {
2475
+ // Always closed: a window left open keeps the profile locked, and the next run would fail on it.
2476
+ await transport?.close().catch(() => undefined);
2477
+ this.releaseBrowser(claim.holder);
2478
+ }
2479
+ }
2480
+ async refreshModels() {
2481
+ // Asked of the one claim every window goes through (see `browser`), before anything is waited
2482
+ // for. It used to count the sessions running, which is none in the moment between two
2483
+ // sessions of a batch, while the batch's window is still open.
2484
+ const claim = this.claimBrowser({ kind: 'models' });
2485
+ if ('refused' in claim)
2486
+ throw new Error(claim.refused);
2487
+ let transport = null;
2488
+ try {
2489
+ await this.init();
2490
+ const cfg = await this.settings.load();
2491
+ transport = createTransport({
2492
+ profileDir: cfg.resolved.profileDir,
2493
+ transportDir: join(cfg.resolved.runsDir, '_models'),
2494
+ chatUrl: cfg.copilot.url,
2495
+ channel: cfg.copilot.channel,
2496
+ headless: cfg.copilot.headless,
2497
+ replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
2498
+ signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
2499
+ humanWaitMs: cfg.copilot.humanWaitSec * 1000,
2500
+ keepFailurePage: cfg.copilot.keepFailurePage,
2501
+ });
2076
2502
  await transport.open();
2077
2503
  await transport.ensureSignedIn();
2078
2504
  const { options, current, note } = await transport.listModels();
@@ -2081,8 +2507,8 @@ let OperatorService = class OperatorService {
2081
2507
  return catalogue;
2082
2508
  }
2083
2509
  finally {
2084
- await transport.close().catch(() => undefined);
2085
- this.readingModels = false;
2510
+ await transport?.close().catch(() => undefined);
2511
+ this.releaseBrowser(claim.holder);
2086
2512
  }
2087
2513
  }
2088
2514
  // --- the registry ---------------------------------------------------------------------
@@ -2171,41 +2597,10 @@ let OperatorService = class OperatorService {
2171
2597
  return out;
2172
2598
  }
2173
2599
  // --- environment ----------------------------------------------------------------------
2174
- async dirs(root, respectGitignore = true) {
2175
- return await listSelectableDirs(root, { respectGitignore });
2176
- }
2177
2600
  /** Opens the machine's own folder dialog and reports what was picked. */
2178
2601
  async browseFolder(start) {
2179
2602
  return await pickFolder(start);
2180
2603
  }
2181
- /**
2182
- * What the current selection would copy, without writing anything. This is how the two
2183
- * switches become checkable: turn the env one on and the `.env` files move from the skipped
2184
- * list into the copied one, whatever `.gitignore` says about them.
2185
- */
2186
- async previewMirror(input) {
2187
- const conflicts = findSelectionConflicts(input.includeDirs ?? [], input.excludeDirs ?? []);
2188
- if (conflicts.length > 0)
2189
- throw new Error(describeConflicts(conflicts));
2190
- const cfg = await this.settings.load();
2191
- const { files, skipped } = await collectFiles({
2192
- rootDir: input.rootDir,
2193
- includeDirs: input.includeDirs?.length ? input.includeDirs : ['.'],
2194
- excludeDirs: input.excludeDirs ?? [],
2195
- targetDir: '',
2196
- respectGitignore: input.respectGitignore ?? true,
2197
- includeEnvFiles: input.includeEnvFiles ?? false,
2198
- ignoreDirs: cfg.projectMirror.ignoreDirs,
2199
- maxFileBytes: cfg.projectMirror.maxFileBytes,
2200
- });
2201
- let totalBytes = 0;
2202
- for (const rel of files) {
2203
- const s = await import('node:fs/promises').then((fs) => fs.stat(join(input.rootDir, rel)).catch(() => null));
2204
- if (s)
2205
- totalBytes += s.size;
2206
- }
2207
- return { files, skipped, envFiles: files.filter(isEnvPath), totalBytes, conflicts };
2208
- }
2209
2604
  async doctor() {
2210
2605
  const cfg = await this.settings.load();
2211
2606
  const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
@@ -2236,7 +2631,6 @@ let OperatorService = class OperatorService {
2236
2631
  */
2237
2632
  isolation: assessIsolation(cfg.execution.isolation, readIsolationSignals()),
2238
2633
  folderDialog: process.platform === 'win32',
2239
- alwaysIgnoredDirs: DEFAULT_IGNORE_DIRS,
2240
2634
  };
2241
2635
  }
2242
2636
  };
@@ -2259,21 +2653,3 @@ function resolveRunId(task, requested) {
2259
2653
  const owned = [task.runId, ...(task.attempts ?? []).map((a) => a.runId)].filter(Boolean);
2260
2654
  return owned.includes(requested) ? requested : null;
2261
2655
  }
2262
- function isEnvPath(relPath) {
2263
- const name = relPath.split('/').pop()?.toLowerCase() ?? '';
2264
- return name === '.env' || name.startsWith('.env.');
2265
- }
2266
- /**
2267
- * Refuses a selection that contradicts itself, at the point it is saved rather than at the
2268
- * point it runs. A contradiction is cheap to fix while the form is open and expensive to
2269
- * discover an hour later, when the first task of a run fails on it.
2270
- */
2271
- function assertMirrorIsCoherent(mirror) {
2272
- const conflicts = findSelectionConflicts(mirror.includeDirs ?? [], mirror.excludeDirs ?? []);
2273
- if (conflicts.length > 0) {
2274
- throw new Error(`The include and exclude lists contradict each other. ${describeConflicts(conflicts)}`);
2275
- }
2276
- if (mirror.enabled && mirror.rootDir !== undefined && !mirror.rootDir.trim()) {
2277
- throw new Error('Attaching project files needs a project root.');
2278
- }
2279
- }