copilot-operator 0.1.12 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +31 -42
  3. package/dist/src/api/operator.service.js +692 -364
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +482 -150
  26. package/dist/src/plan/brief.js +56 -44
  27. package/dist/src/plan/importPlan.js +18 -7
  28. package/dist/src/plan/schema.js +90 -31
  29. package/dist/src/plan/script.js +79 -90
  30. package/dist/src/plan/systemGuide.js +39 -182
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +52 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/git.js +77 -2
  37. package/dist/src/vcs/scope.js +29 -6
  38. package/dist/src/vcs/taskVcs.js +149 -23
  39. package/dist/web/404.html +2 -2
  40. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  41. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  42. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  43. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  44. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
  45. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  46. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  47. package/dist/web/appearance.html +2 -2
  48. package/dist/web/appearance.txt +12 -12
  49. package/dist/web/defaults.html +2 -2
  50. package/dist/web/defaults.txt +12 -12
  51. package/dist/web/history.html +2 -2
  52. package/dist/web/history.txt +12 -12
  53. package/dist/web/import.html +2 -2
  54. package/dist/web/import.txt +12 -12
  55. package/dist/web/index.html +2 -2
  56. package/dist/web/index.txt +12 -12
  57. package/dist/web/level1.html +2 -2
  58. package/dist/web/level1.txt +12 -12
  59. package/dist/web/presets.html +2 -2
  60. package/dist/web/presets.txt +12 -12
  61. package/dist/web/sessions/view.html +2 -2
  62. package/dist/web/sessions/view.txt +12 -12
  63. package/dist/web/system.html +2 -2
  64. package/dist/web/system.txt +12 -12
  65. package/package.json +18 -10
  66. package/prompts/level1.md +2 -1
  67. package/prompts/organisation.bg.md +0 -1
  68. package/prompts/organisation.en.md +0 -1
  69. package/dist/src/context/contextFiles.js +0 -295
  70. package/dist/src/context/desktopMirror.js +0 -135
  71. package/dist/src/context/projectMirror.js +0 -432
  72. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  73. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  74. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  75. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +0 -1
  76. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  77. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +0 -1
  78. package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +0 -1
  79. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  80. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
@@ -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 { changedFilesBetween, fileAt, gitAvailable, 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,128 @@ 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
+ if (held.kind === 'batch') {
60
+ if (ask.kind === 'models')
61
+ return modelsWhileRunning;
62
+ return ask.kind === 'batch' ? 'a batch is already running' : 'a batch of sessions is running';
63
+ }
64
+ if (held.kind === 'models') {
65
+ return ask.kind === 'models'
66
+ ? 'The model list is already being read.'
67
+ : 'the model list is being read from the chat, which has the browser; start this once it has finished';
68
+ }
69
+ // A session running on its own.
70
+ if (ask.kind === 'models')
71
+ return modelsWhileRunning;
72
+ if (ask.kind === 'run') {
73
+ return ask.sessionId === held.sessionId
74
+ ? 'already running'
75
+ : 'another session is running, and the browser takes one run at a time; start this one once it has finished';
76
+ }
77
+ return ask.sessionIds.includes(held.sessionId)
78
+ ? 'one of the selected sessions is already running on its own'
79
+ : 'a session is running on its own, and the browser takes one run at a time; start the batch once it has finished';
80
+ }
55
81
  /** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
56
82
  function normaliseDir(dir) {
57
83
  if (!dir.trim())
58
84
  return '';
59
85
  return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
60
86
  }
87
+ /** The repository a planned session will work in, read the way `repoDirOf` reads an imported one. */
88
+ function plannedRepoDir(session) {
89
+ return session.vcs?.repoDir?.trim() || session.projectDir?.trim() || session.mirror?.rootDir?.trim() || '';
90
+ }
91
+ /**
92
+ * The branches a planned session will make by name when it runs, as `prepareForTask` names them.
93
+ *
94
+ * None with version control off, and none when it carries on an existing branch, which makes
95
+ * nothing. In per-session mode the one `branchName`; in per-task mode the branch each task names,
96
+ * since `branchName` is ignored there. A branch named for the session's id is not known before the
97
+ * import, and is not one a plan can ask to carry on.
98
+ */
99
+ function branchesMadeBy(session) {
100
+ const vcs = session.vcs;
101
+ if (!vcs || vcs.enabled === false || vcs.startFrom === 'existing-branch' || vcs.existingBranch?.trim())
102
+ return [];
103
+ const prefix = vcs.branchPrefix?.trim() || 'cop/';
104
+ if (vcs.branchMode === 'per-session')
105
+ return vcs.branchName?.trim() ? [plannedBranchName(vcs.branchName, prefix)] : [];
106
+ return session.tasks.flatMap((t) => (t.vcs?.branch?.trim() ? [plannedBranchName(t.vcs.branch, prefix)] : []));
107
+ }
108
+ /**
109
+ * The one restore each repository gets when a run starts again from a task: the rule, in one place,
110
+ * for the preview and for the restore itself, which used to keep a copy each and drifted apart.
111
+ *
112
+ * One per repository, keyed on the folder as compared rather than on the path as written or on the
113
+ * session: three sessions working in one repository must not take it back three times to three
114
+ * different commits. The earliest affected task that ran is the one that decides, because that is
115
+ * the state the whole re-run starts from.
116
+ *
117
+ * A repository whose affected tasks never ran has nothing to go back to: the run never touched it.
118
+ * Taking "no base commit recorded" as a refusal blocked "run again from here" for every plan whose
119
+ * later sessions had not started yet — which is the usual shape of a run that stopped in the middle.
120
+ *
121
+ * A session that carries on an existing branch cannot be taken back: its run checks that branch out
122
+ * again before the first task, so a restore branch would be left the moment it was made, and going
123
+ * back on the branch itself would take a reset, which this tool never does. That holds for every
124
+ * session of the repository the run reached, not only the one that decides where it goes back to:
125
+ * a later one carrying on a branch would run its tasks again on top of what they did the first
126
+ * time, which is not "the code goes back first" either. So the repository's restore carries the
127
+ * problem of the first such session, and the preview and the restart refuse before anything moves.
128
+ */
129
+ function restoreTargets(tasks) {
130
+ const byRepo = new Map();
131
+ for (const { session, task } of tasks) {
132
+ if (!session.vcs?.enabled || !task.startedAt)
133
+ continue;
134
+ const dir = repoDirOf(session);
135
+ const key = normaliseDir(dir);
136
+ if (!key)
137
+ continue;
138
+ const target = byRepo.get(key) ?? { dir, session, task };
139
+ byRepo.set(key, target);
140
+ const existing = session.vcs.startFrom === 'existing-branch' ? (session.vcs.existingBranch ?? '').trim() : '';
141
+ if (existing && !target.problem) {
142
+ target.problem =
143
+ `"${session.name}" carries on the existing branch ${existing}, and its run checks that branch out again, so the code ` +
144
+ `would not stay back; going back on ${existing} itself would take a reset, which this tool never does. ` +
145
+ `Queue its tasks again with "Run again" on each, which carries on ${existing} as it is, or move that branch yourself first.`;
146
+ }
147
+ }
148
+ return [...byRepo.values()];
149
+ }
150
+ /** Whether two folders, as `normaliseDir` gives them, are one folder or one is inside the other. */
151
+ function overlaps(a, b) {
152
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
153
+ }
154
+ /**
155
+ * The reason a restart stopped, with what it had already done by then.
156
+ *
157
+ * A repository taken back and tasks queued again stay so when a later step refuses — nothing here
158
+ * undoes them — and the reason is the one thing the page shows. Said only in the result, they were
159
+ * left for the operator to find in git and in the queue.
160
+ */
161
+ function afterMoves(reason, restored, requeued) {
162
+ const done = [...restored.map((r) => `taken back ${r}`), ...(requeued > 0 ? [`${requeued} task(s) queued again`] : [])];
163
+ return done.length > 0 ? `${reason} Already done, and left as it is: ${done.join('; ')}.` : reason;
164
+ }
61
165
  let OperatorService = class OperatorService {
62
166
  /** Where this install keeps its prompts and its records. See `config/layout.ts`. */
63
167
  layout = installLayout();
64
168
  projectRoot = this.layout.projectRoot;
65
169
  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') } : {});
170
+ settings = settingsOf(this.layout);
67
171
  store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
68
172
  bus = new EventBus();
69
173
  running = new Map();
@@ -71,8 +175,21 @@ let OperatorService = class OperatorService {
71
175
  /** The batch in progress, or the last one that finished. At most one ever exists. */
72
176
  batch = null;
73
177
  ready = null;
74
- /** One model read at a time: it launches a browser and holds the profile lock. */
75
- readingModels = false;
178
+ /**
179
+ * What has the browser now: one session's run, a batch, or a read of the model list.
180
+ *
181
+ * The Edge profile takes one writer, and every way into a chat window goes through here: a
182
+ * start, a batch, and reading the models. Each used to look only at its own kind — a start
183
+ * refused only while a batch ran, a batch only a second batch — so while one session ran on its
184
+ * own, starting another or a batch of others opened a second window on the same profile, which
185
+ * fails minutes later with a message about a closed browser. The profile lock is no help inside
186
+ * one process: it waves through a lock this process already holds.
187
+ *
188
+ * Taken at the entrance before its first wait, so two presses that arrive together cannot both
189
+ * find it free, and given back when the window it was taken for has closed or the entrance has
190
+ * refused. See `claimBrowser`.
191
+ */
192
+ browser = null;
76
193
  /**
77
194
  * One-time startup work, run lazily on the first request that needs the store.
78
195
  *
@@ -207,30 +324,32 @@ let OperatorService = class OperatorService {
207
324
  const pending = [...this.waiting.values()].filter((w) => w.approval.sessionId === id).map((w) => w.approval);
208
325
  return { ...s, running: this.running.has(id), pending, runMode: this.running.get(id)?.mode };
209
326
  }
210
- async createSession(name, mirror) {
327
+ async createSession(name, folder) {
211
328
  await this.init();
212
- if (mirror)
213
- assertMirrorIsCoherent(mirror);
214
- const session = await this.store.createSession(name, mirror);
329
+ // Read before the session is made: a settings file that cannot be read refuses the request,
330
+ // and a refusal must leave nothing behind, or each retry adds a session with no project.
331
+ const cfg = await this.settings.load();
332
+ const session = await this.store.createSession(name, folder ?? '');
215
333
  /*
216
334
  * The models are not written onto a new session. A session with no model of its own uses the
217
335
  * one chosen in Settings at the time it runs (see `effectiveModels` in taskRunner.ts), so
218
336
  * choosing a model there reaches every such session, the ones created before it included.
219
337
  * It used to be copied here, which meant a model chosen after an import reached nothing.
220
338
  */
221
- const cfg = await this.settings.load();
222
339
  const projectDir = (cfg.project?.rootDir ?? '').trim();
223
- if (!projectDir)
224
- return session;
225
340
  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
- }
341
+ /*
342
+ * A folder given here is the session's repository too, in the shape a session made on the
343
+ * Project page's folder has: both fields, one folder. Left empty, every reader that follows
344
+ * `repoDirOf` found the folder, but the review read `vcs.repoDir` itself, and was told there
345
+ * was no repository and no changed files for work committed in that folder. The review asks
346
+ * `trackedRepoOf` now, so a session saved with `repoDir` empty is read right as well; this
347
+ * keeps the record saying what the session works in.
348
+ */
349
+ if (s.projectDir.trim() && s.vcs && !s.vcs.repoDir.trim())
350
+ s.vcs.repoDir = s.projectDir;
351
+ // The same rule an import applies, for a session given no folder; see `applyDefaultProject`.
352
+ applyDefaultProject(s, projectDir);
234
353
  });
235
354
  }
236
355
  async updateSession(id, patch) {
@@ -259,11 +378,8 @@ let OperatorService = class OperatorService {
259
378
  const merged = { ...DEFAULT_REVIEW, ...s.review, ...patch.review };
260
379
  s.review = { enabled: merged.enabled !== false, model: (merged.model ?? '').trim() };
261
380
  }
262
- if (patch.mirror) {
263
- const merged = { ...s.mirror, ...patch.mirror };
264
- assertMirrorIsCoherent(merged);
265
- s.mirror = merged;
266
- }
381
+ if (patch.projectDir !== undefined)
382
+ s.projectDir = patch.projectDir.trim();
267
383
  if (patch.vcs) {
268
384
  const merged = { ...DEFAULT_VCS, ...s.vcs, ...patch.vcs };
269
385
  // A prefix is what makes the bot's branches recognisable in `git branch`, so an empty
@@ -271,12 +387,20 @@ let OperatorService = class OperatorService {
271
387
  merged.branchPrefix = merged.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
272
388
  merged.repoDir = merged.repoDir.trim();
273
389
  if (merged.enabled) {
274
- const reason = repoUnusableReason(merged.repoDir || s.mirror.rootDir || '');
390
+ const reason = repoUnusableReason(merged.repoDir || s.projectDir || '');
275
391
  if (reason)
276
392
  throw new Error(reason);
277
393
  }
278
394
  if (merged.baseBranch !== undefined)
279
395
  merged.baseBranch = merged.baseBranch.trim();
396
+ if (merged.existingBranch !== undefined)
397
+ merged.existingBranch = merged.existingBranch.trim();
398
+ // Only the program marks a name as one of its own branches, and a name changed is not that one.
399
+ const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
400
+ if (stillOwn)
401
+ merged.branchNameExact = true;
402
+ else
403
+ delete merged.branchNameExact;
280
404
  /*
281
405
  * Where the session starts is fixed at its first run. A new choice clears that record, so
282
406
  * the next task to run is cut from what was chosen now; the tasks that already ran keep
@@ -284,6 +408,8 @@ let OperatorService = class OperatorService {
284
408
  */
285
409
  const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
286
410
  (merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
411
+ (merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
412
+ (merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
287
413
  merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
288
414
  if (startChanged) {
289
415
  s.vcsBaseCommit = undefined;
@@ -607,15 +733,17 @@ let OperatorService = class OperatorService {
607
733
  .sort((a, b) => (a.runGroup?.id === runId ? (a.runGroup.order ?? 0) : 0) - (b.runGroup?.id === runId ? (b.runGroup.order ?? 0) : 0));
608
734
  if (sessions.length === 0)
609
735
  throw new Error(`No session took part in run ${runId}.`);
610
- const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId);
736
+ /*
737
+ * The run's name and start, from a session's own record of the run — or, once a later run has
738
+ * replaced that record, from a task that ran in it, which carries both. Without the second,
739
+ * the earlier run's file was called "run-" with nothing after it, the kind of name nobody
740
+ * finds again.
741
+ */
742
+ const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId) ??
743
+ sessions.flatMap((s) => s.tasks.map((t) => taskInRun(t, runId)?.runGroup)).find((g) => g?.id === runId);
611
744
  const name = group?.name?.trim() || `run-${(group?.startedAt ?? '').slice(0, 16).replace(/[:T]/g, '-')}`;
612
- scope = {
613
- sessions,
614
- // A task belongs to the run if this attempt ran in it, or if it was queued for it and
615
- // never reached — that second kind is what the run's own record on the session is for.
616
- taskFilter: (s, t) => t.runGroup?.id === runId || (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)),
617
- label: name,
618
- };
745
+ // Each task as it ended in this run, not as it is now; see `runScope`.
746
+ scope = runScope(sessions, runId, name);
619
747
  }
620
748
  else if (where.sessionId) {
621
749
  const session = await this.store.getSession(where.sessionId);
@@ -700,34 +828,56 @@ let OperatorService = class OperatorService {
700
828
  isRunning(sessionId) {
701
829
  return this.running.has(sessionId);
702
830
  }
831
+ /**
832
+ * Takes the browser for `ask`, or says why it cannot have it. Synchronous on purpose (see
833
+ * `browser`). What it hands back is what `releaseBrowser` is given, so a release that comes late
834
+ * cannot free a browser somebody else has taken since.
835
+ */
836
+ claimBrowser(ask) {
837
+ if (this.browser)
838
+ return { refused: browserRefusal(this.browser, ask) };
839
+ const holder = ask.kind === 'run' ? { kind: 'run', sessionId: ask.sessionId } : { kind: ask.kind };
840
+ this.browser = holder;
841
+ return { holder };
842
+ }
843
+ releaseBrowser(holder) {
844
+ if (this.browser === holder)
845
+ this.browser = null;
846
+ }
703
847
  /**
704
848
  * Starts the session's queued tasks in the background. Returns immediately; progress
705
- * arrives on the event stream. One run per session at a time.
849
+ * arrives on the event stream. One run at a time, of this session or any other.
706
850
  */
707
851
  async start(sessionId, mode = 'confirm', name) {
708
852
  // One conversation at a time is not a policy, it is the browser profile: a second run
709
- // does not get a second browser, it gets an error about a closed one. A batch already
710
- // holds that turn, so a single session asking for it now is refused where the reason can
711
- // still be read, rather than three minutes later in a stack trace.
712
- if (this.batch?.running)
713
- return { started: false, reason: 'a batch of sessions is running' };
714
- // A run nobody named is named after what it is a run of, rather than left to show up in the
715
- // register as "Unnamed run" beside five others of the same description.
716
- const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
717
- const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
718
- // A run of one is still a run, and it records the same thing a batch does, so that going
719
- // back and starting again from a task works the same whether one session was started or six.
720
- await this.store.updateSession(sessionId, (s) => {
721
- s.runGroup = {
722
- ...runGroup,
723
- order: 0,
724
- taskIds: s.tasks.filter((t) => t.status === 'queued').map((t) => t.id),
725
- mode,
726
- onFailure: s.onFailure === 'continue' ? 'continue' : 'stop',
727
- };
728
- });
729
- const begun = await this.beginRun(sessionId, mode, undefined, runGroup);
730
- return { started: begun.started, reason: begun.reason };
853
+ // does not get a second browser, it gets an error about a closed one. Whatever has the
854
+ // browser now — a batch, another session, a read of the models — the start is refused where
855
+ // the reason can still be read, rather than three minutes later in a stack trace.
856
+ const claim = this.claimBrowser({ kind: 'run', sessionId });
857
+ if ('refused' in claim)
858
+ return { started: false, reason: claim.refused };
859
+ try {
860
+ // A run nobody named is named after what it is a run of, rather than left to show up in the
861
+ // register as "Unnamed run" beside five others of the same description.
862
+ const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
863
+ const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
864
+ // A run of one is still a run, and it records the same thing a batch does, so that going
865
+ // back and starting again from a task works the same whether one session was started or six.
866
+ // Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
867
+ const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
868
+ if (!begun.started) {
869
+ this.releaseBrowser(claim.holder);
870
+ return { started: false, reason: begun.reason };
871
+ }
872
+ // The window closes before the run's promise settles (see `runSession`), so the browser is
873
+ // free again exactly when it is.
874
+ void begun.done.finally(() => this.releaseBrowser(claim.holder));
875
+ return { started: true };
876
+ }
877
+ catch (e) {
878
+ this.releaseBrowser(claim.holder);
879
+ throw e;
880
+ }
731
881
  }
732
882
  /**
733
883
  * The same thing `start` does, but it also hands back a promise that settles when the run
@@ -744,12 +894,8 @@ let OperatorService = class OperatorService {
744
894
  /** The press of a start button this belongs to, stamped onto every task it reaches. */
745
895
  runGroup,
746
896
  /** Only these queued tasks, when the operator chose some; the rest stay queued. */
747
- onlyTasks) {
897
+ onlyTasks, how = {}) {
748
898
  await this.init();
749
- // Every project's Desktop copy brought up to date before the chat opens, when the operator
750
- // asked for that. Before the run rather than before each task: a task's own mirror already
751
- // refreshes its project, and the other projects do not change while the bot works.
752
- await this.refreshDesktopMirrors();
753
899
  const idle = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
754
900
  if (this.running.has(sessionId))
755
901
  return { started: false, reason: 'already running', done: Promise.resolve(idle) };
@@ -782,6 +928,12 @@ let OperatorService = class OperatorService {
782
928
  const precondition = unattendedPrecondition(policy);
783
929
  if (precondition)
784
930
  return { started: false, reason: precondition, done: Promise.resolve(idle) };
931
+ if (how.recordOnSession && runGroup) {
932
+ // The tasks this run takes, as worked out above, so the record and the run cannot disagree.
933
+ await this.store.updateSession(sessionId, (s) => {
934
+ s.runGroup = { ...runGroup, order: 0, taskIds: queuedIds, mode, onFailure: s.onFailure === 'continue' ? 'continue' : 'stop' };
935
+ });
936
+ }
785
937
  const controller = new AbortController();
786
938
  /*
787
939
  * The same authorizer in both modes. It used to be `unattendedAuthorizer` here for an unattended
@@ -809,14 +961,13 @@ let OperatorService = class OperatorService {
809
961
  bus: this.bus,
810
962
  authorizer,
811
963
  signal: controller.signal,
812
- // Read between tasks, so the one in flight finishes properly first. The batch owns the
813
- // flag, because a pause is about the whole run and not about this session.
814
- shouldPause: () => !!this.batch?.pausing,
964
+ // Read between tasks, so the one in flight finishes properly first. The batch that owns
965
+ // this run answers it, because a pause is about the whole run and not about this session.
966
+ shouldPause: how.shouldPause,
815
967
  // The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
816
968
  // and the policy.json of a later task must say so.
817
969
  currentMode: () => this.running.get(sessionId)?.mode ?? mode,
818
970
  // The Desktop copies follow the work while it happens, not only before the run.
819
- onWorkChanged: () => this.requestDesktopRefresh(),
820
971
  // The session decides whether its tasks are one chain or a set of independent checks.
821
972
  continueOnFailure: session.onFailure === 'continue',
822
973
  transport,
@@ -931,20 +1082,64 @@ let OperatorService = class OperatorService {
931
1082
  * tasks nobody chose stay queued exactly as they were. Absent means every queued task.
932
1083
  */
933
1084
  taskIds) {
934
- await this.init();
935
- const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
936
- if (this.batch?.running)
937
- return { started: false, reason: 'a batch is already running' };
938
1085
  const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
939
- if (wanted.length === 0)
940
- return { started: false, reason: 'no sessions were selected' };
1086
+ // Before anything is waited for (see `browser`): a second batch, a session running on its own
1087
+ // — one of these or any other — and a read of the models all have the browser already.
1088
+ const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
1089
+ if ('refused' in claim)
1090
+ return { started: false, reason: claim.refused };
1091
+ return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
1092
+ }
1093
+ /**
1094
+ * `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
1095
+ * to the batch once it is under way (`runBatch` gives it back), and given back here on every
1096
+ * refusal. "Run again from here" claims it before it moves anything and starts through this, so
1097
+ * one claim covers the restore, the requeue and the run.
1098
+ */
1099
+ async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
1100
+ const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
1101
+ let handedOn = false;
1102
+ try {
1103
+ await this.init();
1104
+ if (wanted.length === 0)
1105
+ return { started: false, reason: 'no sessions were selected' };
1106
+ const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name);
1107
+ if (!begun.batch)
1108
+ return { started: false, reason: begun.reason };
1109
+ this.batch = begun.batch;
1110
+ handedOn = true;
1111
+ void this.runBatch(begun.batch, holder);
1112
+ return { started: true, batch: this.batchState() };
1113
+ }
1114
+ finally {
1115
+ if (!handedOn)
1116
+ this.releaseBrowser(holder);
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Why a batch in this mode may not begin on this machine now, or null when it may: the entrance
1121
+ * rule (`unattendedPrecondition`) asked of the settings as they are at this moment. One place, for
1122
+ * the batch and for "Run again from here", which has to ask it before it moves anything.
1123
+ */
1124
+ async unattendedRefusal(mode) {
1125
+ const cfg = await this.settings.load();
1126
+ return unattendedPrecondition({
1127
+ mode,
1128
+ allowedPrograms: cfg.execution.allowedPrograms,
1129
+ isolation: cfg.execution.isolation,
1130
+ lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
1131
+ });
1132
+ }
1133
+ /**
1134
+ * Everything `startBatch` decides before the batch exists: which sessions it has work in, whether
1135
+ * an unattended run may begin, and the models the run panel chose. A refusal comes back as the
1136
+ * reason; nothing is started here.
1137
+ */
1138
+ async prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name) {
941
1139
  // Worked out here rather than asked of the operator again: every way into this — the run
942
1140
  // panel, continuing after a failure, "Run again from here" — either has a field they may
943
1141
  // have left empty or has no field at all.
944
1142
  const chosenName = name?.trim() || (await this.suggestRunName(wanted));
945
- if (wanted.some((id) => this.running.has(id))) {
946
- return { started: false, reason: 'one of the selected sessions is already running on its own' };
947
- }
948
1143
  const sessions = [];
949
1144
  for (const id of wanted) {
950
1145
  const s = await this.store.getSession(id);
@@ -958,7 +1153,7 @@ let OperatorService = class OperatorService {
958
1153
  : { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
959
1154
  }
960
1155
  if (!sessions.some((s) => s.state === 'waiting')) {
961
- return { started: false, reason: 'none of the selected sessions has a queued task' };
1156
+ return { reason: 'none of the selected sessions has a queued task' };
962
1157
  }
963
1158
  /*
964
1159
  * The same entrance rule `start` has, asked here too and for the same reason: before a browser
@@ -968,17 +1163,9 @@ let OperatorService = class OperatorService {
968
1163
  * session was quietly skipped with the reason held in a batch state nobody was looking at. Asked
969
1164
  * here, the refusal is the answer to the press, shown under the button that caused it.
970
1165
  */
971
- {
972
- const cfg = await this.settings.load();
973
- const blocked = unattendedPrecondition({
974
- mode,
975
- allowedPrograms: cfg.execution.allowedPrograms,
976
- isolation: cfg.execution.isolation,
977
- lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
978
- });
979
- if (blocked)
980
- return { started: false, reason: blocked };
981
- }
1166
+ const blocked = await this.unattendedRefusal(mode);
1167
+ if (blocked)
1168
+ return { reason: blocked };
982
1169
  // One model for the whole run, chosen here rather than opened on every session first. It
983
1170
  // is written onto the sessions instead of being held for the run, so what the session says
984
1171
  // it will use and what it used are the same thing afterwards — including for anyone who
@@ -1010,20 +1197,20 @@ let OperatorService = class OperatorService {
1010
1197
  });
1011
1198
  }
1012
1199
  }
1013
- this.batch = {
1014
- id: newId('b-'),
1015
- startedAt: new Date().toISOString(),
1016
- name: chosenName,
1017
- ...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
1018
- mode,
1019
- onFailure,
1020
- stopping: false,
1021
- pausing: false,
1022
- running: true,
1023
- sessions,
1200
+ return {
1201
+ batch: {
1202
+ id: newId('b-'),
1203
+ startedAt: new Date().toISOString(),
1204
+ name: chosenName,
1205
+ ...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
1206
+ mode,
1207
+ onFailure,
1208
+ stopping: false,
1209
+ pausing: false,
1210
+ running: true,
1211
+ sessions,
1212
+ },
1024
1213
  };
1025
- void this.runBatch();
1026
- return { started: true, batch: this.batchState() };
1027
1214
  }
1028
1215
  /** Stops the session that is running now and leaves the rest of the batch unstarted. */
1029
1216
  /**
@@ -1077,6 +1264,24 @@ let OperatorService = class OperatorService {
1077
1264
  await this.stop(current.sessionId);
1078
1265
  return { stopping: true };
1079
1266
  }
1267
+ /**
1268
+ * Runs a batch that has just been started, and ends what only the batch may end: its hold and
1269
+ * its claim on the browser. Both are let go here, once, however the loop came out — finished,
1270
+ * stopped, failed, or never able to open its window.
1271
+ */
1272
+ async runBatch(batch, holder) {
1273
+ try {
1274
+ await this.batchLoop(batch);
1275
+ }
1276
+ finally {
1277
+ // A pause holds this batch's queue and nothing after it. Left on, it read as "pausing" on a
1278
+ // batch that had ended, and it was what every later run read, so each stopped before its
1279
+ // first task (see `shouldPause` in `beginRun`).
1280
+ batch.pausing = false;
1281
+ // The batch's window is closed by now, in the loop's own ending.
1282
+ this.releaseBrowser(holder);
1283
+ }
1284
+ }
1080
1285
  /**
1081
1286
  * The batch loop. Nothing here runs in parallel, on purpose: see `beginRun`.
1082
1287
  *
@@ -1084,10 +1289,7 @@ let OperatorService = class OperatorService {
1084
1289
  * failure is `done`; one with a failed task is `failed`; one the operator stopped, or one
1085
1290
  * whose chain stopped early with no failure of its own, is `stopped`.
1086
1291
  */
1087
- async runBatch() {
1088
- const batch = this.batch;
1089
- if (!batch)
1090
- return;
1292
+ async batchLoop(batch) {
1091
1293
  /*
1092
1294
  * One browser for the whole batch.
1093
1295
  *
@@ -1192,7 +1394,11 @@ let OperatorService = class OperatorService {
1192
1394
  message: `starting as part of a batch of ${batch.sessions.length} session(s), in ${batch.mode} mode`,
1193
1395
  data: { batchId: batch.id },
1194
1396
  });
1195
- const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined);
1397
+ // In the batch's mode as it is now: "run the rest without asking" in an earlier session
1398
+ // switched the batch with it (see `setRunMode`). The hold is this batch's own.
1399
+ const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
1400
+ shouldPause: () => batch.pausing,
1401
+ });
1196
1402
  if (!begun.started) {
1197
1403
  entry.state = 'skipped';
1198
1404
  entry.reason = begun.reason;
@@ -1297,11 +1503,18 @@ let OperatorService = class OperatorService {
1297
1503
  */
1298
1504
  const cfg = await this.settings.load();
1299
1505
  const unattendedRule = { mode: 'unattended', allowedPrograms: cfg.execution.allowedPrograms, isolation: cfg.execution.isolation, lockedToConfirm: cfg.policyLock?.maxMode === 'confirm' };
1506
+ /*
1507
+ * Which of its refusals it is, asked in the order the rule asks them: the lock, then isolation,
1508
+ * then the allowlist. Anything that was not isolation used to be told as an empty allowlist, so
1509
+ * under a policy lock the brief sent the operator to fill in a list that was already full.
1510
+ */
1300
1511
  const unattendedBlocked = !unattendedPrecondition(unattendedRule)
1301
1512
  ? undefined
1302
- : unattendedIsolationRefusal('unattended', cfg.execution.isolation)
1303
- ? 'isolation'
1304
- : 'allowlist';
1513
+ : unattendedRule.lockedToConfirm
1514
+ ? 'lock'
1515
+ : unattendedIsolationRefusal('unattended', cfg.execution.isolation)
1516
+ ? 'isolation'
1517
+ : 'allowlist';
1305
1518
  return {
1306
1519
  text: planBrief({
1307
1520
  lang,
@@ -1349,7 +1562,7 @@ let OperatorService = class OperatorService {
1349
1562
  check.plan.sessions.forEach((session, i) => {
1350
1563
  if (session.vcs?.enabled === false)
1351
1564
  return;
1352
- const dir = session.vcs?.repoDir?.trim() || session.mirror?.rootDir?.trim() || '';
1565
+ const dir = plannedRepoDir(session);
1353
1566
  const reason = repoUnusableReason(dir);
1354
1567
  if (reason)
1355
1568
  repoIssues.push({ path: `sessions[${i}].vcs.repoDir`, message: reason });
@@ -1361,6 +1574,37 @@ let OperatorService = class OperatorService {
1361
1574
  });
1362
1575
  }
1363
1576
  });
1577
+ if (repoIssues.length > 0)
1578
+ return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1579
+ /*
1580
+ * A branch to carry on must be there to carry on — unless a session before it in this same plan
1581
+ * is the one that makes it, in this same repository. Refused now, with the branches that are
1582
+ * there, rather than at the first run with a task refused.
1583
+ *
1584
+ * The repository is part of the question. A branch is a branch of one repository, and the name
1585
+ * alone let a branch an earlier session makes somewhere else count here: the plan was accepted,
1586
+ * and the session failed at its first task with "no such local branch" — the failure this check
1587
+ * exists to bring forward.
1588
+ */
1589
+ for (const [i, session] of check.plan.sessions.entries()) {
1590
+ const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
1591
+ if (!wanted)
1592
+ continue;
1593
+ const dir = plannedRepoDir(session);
1594
+ const here = normaliseDir(dir);
1595
+ const madeEarlier = check.plan.sessions
1596
+ .slice(0, i)
1597
+ .some((e) => normaliseDir(plannedRepoDir(e)) === here && branchesMadeBy(e).includes(wanted));
1598
+ if (madeEarlier || (await branchExists(dir, wanted)))
1599
+ continue;
1600
+ const have = await localBranches(dir);
1601
+ repoIssues.push({
1602
+ path: `sessions[${i}].vcs.existingBranch`,
1603
+ message: `${dir} has no local branch "${wanted}" to carry on. ` +
1604
+ (have.length > 0 ? `Its branches: ${have.slice(0, 20).join(', ')}.` : 'It has no branches yet.') +
1605
+ ' Give the exact name, or use startFrom "branch" to start a new one.',
1606
+ });
1607
+ }
1364
1608
  if (repoIssues.length > 0)
1365
1609
  return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1366
1610
  return { ...check, duplicates: await this.findDuplicates(check.plan) };
@@ -1407,7 +1651,9 @@ let OperatorService = class OperatorService {
1407
1651
  */
1408
1652
  const persona = (await this.getContext('persona', 'en')).content;
1409
1653
  // No default models passed: a session the plan gives no model follows Settings at run time.
1410
- const result = await importPlan(this.store, check.plan, '', '', persona);
1654
+ // The default project is passed, so a session the plan gives no folder works where one made
1655
+ // on the Sessions page would, and not in execution.cwd.
1656
+ const result = await importPlan(this.store, check.plan, '', '', persona, (cfg.project?.rootDir ?? '').trim());
1411
1657
  return {
1412
1658
  ok: true,
1413
1659
  result: { ...result, warnings: [...check.warnings, ...result.warnings] },
@@ -1478,14 +1724,26 @@ let OperatorService = class OperatorService {
1478
1724
  * one would read.
1479
1725
  */
1480
1726
  run.policy.mode = mode;
1727
+ /*
1728
+ * A batch is one run. The dialog behind "run the rest without asking" says "until this run
1729
+ * ends", and the run the operator started is the batch: the switch used to reach only the
1730
+ * session on screen, and the next session started in the batch's old mode and asked again. So
1731
+ * the sessions the batch has not reached yet start in the mode it is switched to — and back,
1732
+ * when the asking is switched on again. Each still meets the unattended precondition as it
1733
+ * starts (see `beginRun`), under the settings of that moment, and the run recorded on it
1734
+ * (`Session.runGroup`) keeps the mode it was started in.
1735
+ */
1736
+ const batch = this.batch?.running && this.batch.sessions.some((e) => e.sessionId === sessionId && e.state === 'running') ? this.batch : null;
1737
+ if (batch)
1738
+ batch.mode = mode;
1481
1739
  this.bus.publish({
1482
1740
  sessionId,
1483
1741
  type: 'run-mode-changed',
1484
1742
  level: mode === 'unattended' ? 'warn' : 'info',
1485
1743
  message: mode === 'unattended'
1486
- ? 'the rest of this run will execute without asking; denied patterns are still refused'
1487
- : 'every further step will be shown for approval again',
1488
- data: { mode },
1744
+ ? `the rest of this run${batch ? ', the sessions of the batch after this one included,' : ''} will execute without asking; denied patterns are still refused`
1745
+ : `every further step${batch ? ', in this session and the ones of the batch after it,' : ''} will be shown for approval again`,
1746
+ data: { mode, ...(batch ? { batchId: batch.id } : {}) },
1489
1747
  });
1490
1748
  if (mode === 'unattended') {
1491
1749
  for (const [id, w] of this.waiting) {
@@ -1505,6 +1763,12 @@ let OperatorService = class OperatorService {
1505
1763
  }
1506
1764
  webAuthorizer(policy, signal) {
1507
1765
  return makeAuthorizer(policy, (step, ctx, held) => new Promise((resolvePromise) => {
1766
+ // A stopped run answers before anything else, the mode included: "run the rest without
1767
+ // asking" is permission to run the steps of a run that is going, not of one that was stopped.
1768
+ if (signal.aborted) {
1769
+ resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
1770
+ return;
1771
+ }
1508
1772
  // The operator may have pressed "run the rest without asking" on an earlier step.
1509
1773
  // This is checked per step rather than captured once, which is what makes the switch
1510
1774
  // take effect from the very next step instead of the next run. A held step is the
@@ -1522,17 +1786,13 @@ let OperatorService = class OperatorService {
1522
1786
  createdAt: new Date().toISOString(),
1523
1787
  ...(held ? { network: held.network } : {}),
1524
1788
  };
1525
- if (signal.aborted) {
1526
- resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
1527
- return;
1528
- }
1529
1789
  this.waiting.set(approval.id, { approval, resolve: resolvePromise });
1530
1790
  this.bus.publish({ sessionId: approval.sessionId, taskId: approval.taskId, type: 'approval-requested', level: 'warn',
1531
1791
  message: held
1532
1792
  ? `held for the operator — ${held.network}: ${approval.description}`
1533
1793
  : `waiting for approval: ${approval.description}`,
1534
1794
  data: { ...approval } });
1535
- }));
1795
+ }), signal);
1536
1796
  }
1537
1797
  // --- events ---------------------------------------------------------------------------
1538
1798
  recentEvents(sessionId) {
@@ -1553,7 +1813,10 @@ let OperatorService = class OperatorService {
1553
1813
  return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
1554
1814
  // `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
1555
1815
  // those differ, and the second is the one the operator is asking about.
1556
- return { ...(await vcsPreflight(session)), git, work: sessionBranches(session) };
1816
+ const preflight = await vcsPreflight(session);
1817
+ // The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
1818
+ const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
1819
+ return { ...preflight, git, work: sessionBranches(session), branches };
1557
1820
  }
1558
1821
  /**
1559
1822
  * What going back to before a task would do, and then doing it.
@@ -1569,24 +1832,68 @@ let OperatorService = class OperatorService {
1569
1832
  const task = session.tasks.find((t) => t.id === taskId);
1570
1833
  if (!task)
1571
1834
  throw new Error('No such task.');
1572
- return await restorePreview(session, task);
1835
+ const preview = await restorePreview(session, task);
1836
+ // Said in the preview too, so the dialog refuses before the operator agrees to anything,
1837
+ // rather than showing what would happen and refusing when they say yes.
1838
+ const refused = await this.restoreRefusal(session);
1839
+ return refused ? { ...preview, ok: false, problem: refused } : preview;
1573
1840
  }
1574
1841
  async restore(sessionId, taskId) {
1575
1842
  await this.init();
1576
- if (this.running.has(sessionId)) {
1577
- return { ok: false, problem: 'This session is running. Stop it before moving the repository to another branch.' };
1578
- }
1579
- if (this.batch?.running) {
1580
- return { ok: false, problem: 'A batch is running. Stop it before moving the repository to another branch.' };
1581
- }
1582
1843
  const session = await this.store.getSession(sessionId);
1583
1844
  if (!session)
1584
1845
  throw new Error('No such session.');
1585
1846
  const task = session.tasks.find((t) => t.id === taskId);
1586
1847
  if (!task)
1587
1848
  throw new Error('No such task.');
1849
+ const refused = await this.restoreRefusal(session);
1850
+ if (refused)
1851
+ return { ok: false, problem: refused };
1588
1852
  return await restoreToBase(session, task, this.bus);
1589
1853
  }
1854
+ /**
1855
+ * Why the repository of this session cannot be moved to another branch now, or null when it can.
1856
+ *
1857
+ * Anything that may be working in that folder at this moment: the session itself, a batch (which
1858
+ * may reach a session in it next), and any other session running there on its own. Only the first
1859
+ * two used to be asked, so a restore checked out a branch under another session in the middle of
1860
+ * its task — and that session then found its repository moved, and left its work uncommitted.
1861
+ * Version control being off for the other session does not make it safe: its commands still run
1862
+ * in that folder.
1863
+ */
1864
+ async restoreRefusal(session) {
1865
+ if (this.running.has(session.id))
1866
+ return 'This session is running. Stop it before moving the repository to another branch.';
1867
+ if (this.batch?.running)
1868
+ return 'A batch is running. Stop it before moving the repository to another branch.';
1869
+ const other = await this.runningIn(repoDirOf(session));
1870
+ return other
1871
+ ? `Session "${other.name}" is running in this repository. Stop it before moving the repository to another branch.`
1872
+ : null;
1873
+ }
1874
+ /**
1875
+ * A session running right now whose commands run in this folder, inside it, or in a folder around it.
1876
+ *
1877
+ * By where its commands run, the rule `workingDirFor` decides it by: the repository, else the
1878
+ * project folder, else `execution.cwd`. Compared as folders, not as equal paths: an audit with
1879
+ * version control off whose project is the repository's `web` folder works in the tree a checkout
1880
+ * rewrites, and so does a session whose folder holds the repository. Only the same path used to
1881
+ * count, and a restore changed files under such a session in the middle of its task.
1882
+ */
1883
+ async runningIn(dir) {
1884
+ const here = normaliseDir(dir);
1885
+ if (!here)
1886
+ return undefined;
1887
+ for (const id of this.running.keys()) {
1888
+ const s = await this.store.getSession(id);
1889
+ if (!s)
1890
+ continue;
1891
+ const there = normaliseDir(repoDirOf(s) || (await this.settings.load()).resolved.cwd);
1892
+ if (there && overlaps(here, there))
1893
+ return s;
1894
+ }
1895
+ return undefined;
1896
+ }
1590
1897
  /**
1591
1898
  * Everything one run was asked to do, from a given task onward, in the order it would run.
1592
1899
  *
@@ -1673,37 +1980,27 @@ let OperatorService = class OperatorService {
1673
1980
  })),
1674
1981
  sessions: sessions.map((s) => ({ id: s.id, name: s.name, tasks: tasks.filter((x) => x.session.id === s.id).length })),
1675
1982
  restores: [],
1676
- mode: group?.mode ?? 'unattended',
1983
+ /*
1984
+ * Nothing on record says a run with no record of its own ever went unattended, and taken for
1985
+ * one, it was refused on every machine that refuses unattended runs, the default among them:
1986
+ * the dialog has no other mode to offer, so its button could never do anything for such a task.
1987
+ */
1988
+ mode: group?.mode ?? 'confirm',
1677
1989
  onFailure: group?.onFailure ?? session.onFailure ?? 'stop',
1678
1990
  };
1679
- /*
1680
- * One restore per repository, keyed on the folder rather than on the session.
1681
- *
1682
- * Three sessions working in one repository must not take it back three times to three
1683
- * different commits; the earliest affected task is the one that decides, because that is
1684
- * the state the whole re-run starts from.
1685
- */
1686
- const byRepo = new Map();
1687
- for (const entry of tasks) {
1688
- const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
1689
- if (!dir)
1690
- continue;
1691
- // A repository whose affected tasks never ran has nothing to go back to: the run never
1692
- // touched it. Taking "no base commit recorded" as a refusal blocked "run again from
1693
- // here" for every plan whose later sessions had not started yet — which is the usual
1694
- // shape of a run that stopped in the middle.
1695
- if (!entry.task.startedAt)
1696
- continue;
1697
- if (!byRepo.has(dir))
1698
- byRepo.set(dir, entry);
1699
- }
1700
- for (const [dir, entry] of byRepo) {
1701
- const preview = await restorePreview(entry.session, entry.task);
1991
+ // Asked of the machine as it is now, as `restartFrom` will ask it; see `unattendedRefused`.
1992
+ if (plan.mode === 'unattended')
1993
+ plan.unattendedRefused = (await this.unattendedRefusal('unattended')) ?? undefined;
1994
+ for (const target of restoreTargets(tasks)) {
1995
+ const preview = await restorePreview(target.session, target.task);
1996
+ // What would stop the restore itself, asked here as well, so the dialog says it before the
1997
+ // operator agrees rather than after.
1998
+ const refused = target.problem ?? (await this.restoreRefusal(target.session));
1702
1999
  plan.restores.push({
1703
- repoDir: preview.repoDir || dir,
1704
- ok: preview.ok,
1705
- problem: preview.problem,
1706
- forTask: entry.task.title,
2000
+ repoDir: preview.repoDir || target.dir,
2001
+ ok: preview.ok && !refused,
2002
+ problem: refused ?? preview.problem,
2003
+ forTask: target.task.title,
1707
2004
  baseCommit: preview.baseCommit,
1708
2005
  branchName: preview.branchName,
1709
2006
  leftBehind: preview.leftBehind,
@@ -1735,27 +2032,95 @@ let OperatorService = class OperatorService {
1735
2032
  if (plan.sessions.some((s) => this.running.has(s.id))) {
1736
2033
  return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
1737
2034
  }
2035
+ /*
2036
+ * Claimed before anything is moved, and held until the run it ends in has the browser: that run
2037
+ * needs it, and another session running on its own, or a read of the models, may have it. Found
2038
+ * out only at the start, the repositories were already taken back and the tasks queued, with
2039
+ * nothing to run them; and only looked at here, the browser could still be taken by a start that
2040
+ * came in while the repositories were being taken back, to the same end. Given back on every way
2041
+ * out that does not start the run.
2042
+ */
2043
+ const ids = plan.sessions.map((s) => s.id);
2044
+ if (opts.start === false)
2045
+ return await this.restartMoves(sessionId, taskId, plan, opts);
2046
+ /*
2047
+ * The run's entrance rule, asked before anything moves, for the same reason as the browser
2048
+ * below. Asked only when the batch began, a machine that no longer lets this run go unattended
2049
+ * (a policy lock added since, isolation no longer accepted) took the repositories back and
2050
+ * queued the tasks, and only then refused the run they were moved for. The plan says so first
2051
+ * (`unattendedRefused`), so the dialog asks for the run step by step instead.
2052
+ */
2053
+ const blocked = await this.unattendedRefusal(opts.mode ?? plan.mode);
2054
+ if (blocked)
2055
+ return { ...idle, reason: blocked };
2056
+ const claim = this.claimBrowser({ kind: 'batch', sessionIds: ids });
2057
+ if ('refused' in claim)
2058
+ return { ...idle, reason: claim.refused };
2059
+ let handedOn = false;
2060
+ try {
2061
+ const moved = await this.restartMoves(sessionId, taskId, plan, opts);
2062
+ if (moved.reason !== undefined)
2063
+ return moved;
2064
+ // Named after the task it goes back to, which is the one thing this run is about and the
2065
+ // only thing a heading could usefully say. There is no field to type into on a task card,
2066
+ // so leaving it to the operator meant leaving it blank every time.
2067
+ const runName = await this.suggestRunName(ids, plan.from.title);
2068
+ // From here the claim is the batch's to give back, whichever way its start goes.
2069
+ handedOn = true;
2070
+ /*
2071
+ * Exactly the tasks the preview listed, which are the tasks the run was asked to do from this
2072
+ * one on. Started with none named, the batch took every queued task of these sessions, so a
2073
+ * task the run never chose — queued beside it, or added since — was sent to the chat as well.
2074
+ */
2075
+ const taskIds = plan.tasks.map((x) => x.taskId);
2076
+ const started = await this.startBatchHolding(claim.holder, ids, opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined, runName, taskIds);
2077
+ // A start refused now leaves the moves in place, and says so with its reason.
2078
+ const reason = started.started || started.reason === undefined ? started.reason : afterMoves(started.reason, moved.restored, moved.requeued);
2079
+ return { started: started.started, reason, requeued: moved.requeued, restored: moved.restored, batch: started.batch };
2080
+ }
2081
+ finally {
2082
+ if (!handedOn)
2083
+ this.releaseBrowser(claim.holder);
2084
+ }
2085
+ }
2086
+ /**
2087
+ * What `restartFrom` moves before its run starts: the repositories taken back, then the tasks
2088
+ * queued again. A refusal, and "prepared, not started", come back with their reason; with none,
2089
+ * everything is in place for the run.
2090
+ */
2091
+ async restartMoves(sessionId, taskId, plan, opts) {
2092
+ const idle = { started: false, requeued: 0, restored: [] };
1738
2093
  const restored = [];
1739
2094
  if (opts.restore !== false) {
1740
2095
  for (const entry of plan.restores) {
1741
2096
  if (!entry.ok)
1742
2097
  return { ...idle, reason: `${entry.repoDir}: ${entry.problem ?? 'the restore could not be prepared.'}` };
1743
2098
  }
1744
- // Re-read each repository's own pivot and do it for real. The preview above is what the
1745
- // operator agreed to; this is the same call, one step further.
2099
+ /*
2100
+ * Re-read each repository's own pivot and do it for real. The preview above is what the
2101
+ * operator agreed to; this is the same choice (`restoreTargets`), one step further. It was
2102
+ * worked out a second time here, by a copy of the rule that had not been given the preview's
2103
+ * "never started" skip, so a run whose later repository was never reached failed on it after
2104
+ * the first repository had already been moved — and queued nothing.
2105
+ *
2106
+ * A refusal part-way still says what was already moved, in the result and in its reason (the
2107
+ * one thing the page shows): a repository taken back is the operator's to know about, whatever
2108
+ * happened after it.
2109
+ */
1746
2110
  const session = (await this.store.getSession(sessionId));
1747
2111
  const task = session.tasks.find((t) => t.id === taskId);
1748
2112
  const { tasks } = await this.affectedByRestart(session, task);
1749
- const seenRepo = new Set();
1750
- for (const entry of tasks) {
1751
- const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
1752
- if (!dir || seenRepo.has(dir))
1753
- continue;
1754
- seenRepo.add(dir);
1755
- const done = await restoreToBase(entry.session, entry.task, this.bus);
1756
- if (!done.ok)
1757
- return { ...idle, reason: `${dir}: ${done.problem ?? 'the restore failed.'}` };
1758
- restored.push(`${dir} -> ${done.branch ?? ''}`);
2113
+ for (const target of restoreTargets(tasks)) {
2114
+ // Asked again at the moment of moving: a session may have started in this folder since.
2115
+ const refused = target.problem ?? (await this.restoreRefusal(target.session));
2116
+ if (refused)
2117
+ return { ...idle, restored, reason: afterMoves(`${target.dir}: ${refused}`, restored, 0) };
2118
+ const done = await restoreToBase(target.session, target.task, this.bus);
2119
+ if (!done.ok || !done.branch) {
2120
+ return { ...idle, restored, reason: afterMoves(`${target.dir}: ${done.problem ?? 'the restore failed.'}`, restored, 0) };
2121
+ }
2122
+ restored.push(`${target.dir} -> ${done.branch}`);
2123
+ await this.rerunFromRestore(target, done.branch, tasks);
1759
2124
  }
1760
2125
  }
1761
2126
  let requeued = 0;
@@ -1768,8 +2133,8 @@ let OperatorService = class OperatorService {
1768
2133
  }
1769
2134
  catch (e) {
1770
2135
  // One task refusing to be queued again must not leave the rest half-reset with no
1771
- // explanation, so it stops here and says which one and why.
1772
- return { ...idle, restored, reason: `"${t.title}" could not be queued again: ${e.message}` };
2136
+ // explanation, so it stops here and says which one and why — and what was done before it.
2137
+ return { ...idle, restored, requeued, reason: afterMoves(`"${t.title}" could not be queued again: ${e.message}`, restored, requeued) };
1773
2138
  }
1774
2139
  }
1775
2140
  this.bus.publish({
@@ -1783,12 +2148,102 @@ let OperatorService = class OperatorService {
1783
2148
  });
1784
2149
  if (opts.start === false)
1785
2150
  return { started: false, reason: 'prepared, not started', requeued, restored };
1786
- const started = await this.startBatch(plan.sessions.map((x) => x.id), opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined,
1787
- // Named after the task it goes back to, which is the one thing this run is about and the
1788
- // only thing a heading could usefully say. There is no field to type into on a task card,
1789
- // so leaving it to the operator meant leaving it blank every time.
1790
- await this.suggestRunName(plan.sessions.map((x) => x.id), plan.from.title));
1791
- return { started: started.started, reason: started.reason, requeued, restored, batch: started.batch };
2151
+ return { started: false, requeued, restored };
2152
+ }
2153
+ /**
2154
+ * Makes a restore hold for the run that follows it.
2155
+ *
2156
+ * The restore puts the repository on a new branch at the pivot task's starting commit, and the
2157
+ * re-run then puts each session on its own branch. In per-task mode that is right: a re-run cuts
2158
+ * a fresh branch from where the task first started, which is the restore commit. In per-session
2159
+ * mode it undid the restore: the session's one branch was checked out again, with the failed
2160
+ * attempt's commit still on it, so the attempt that was to start from the code as it was began on
2161
+ * the work it was meant to replace, and committed it a second time. So the pivot's session now
2162
+ * works on the restore branch — the code as it was before that task, with the tasks before it —
2163
+ * and its old branch keeps the attempt that failed, as the confirmation says.
2164
+ *
2165
+ * A per-session session after it in the same repository that this run reached has the same
2166
+ * problem whatever it started from: its one branch carries what its tasks did in this run, and
2167
+ * checked out again it would start them over on their own first attempt. So it gets a branch of
2168
+ * its own, and the old one keeps what it did. Where that branch starts depends on where the
2169
+ * session began. From a named branch: where its first task in this run began, as a restore of
2170
+ * that task would go back to, and the session's start stays as recorded. From where the
2171
+ * repository was, or from the session before it: that start was work this run is now redoing, so
2172
+ * it is forgotten, to be worked out again when the session is reached (for a `previous-session`
2173
+ * chain, from the work done again), and the branch is cut from that. A session the run never
2174
+ * reached holds nothing of this run and is left alone.
2175
+ *
2176
+ * A later per-task session is left as it is. From a named branch that is right: each re-run is
2177
+ * cut from where its task first started, that branch's commit. Chained on this run's work it is
2178
+ * not, and is not solved here: its tasks first started on the work being redone, so their re-runs
2179
+ * are cut from it again. Telling that apart from an operator's new "Start from", whose re-runs go
2180
+ * back to where each task first started on purpose (see `updateSession`), needs the restart
2181
+ * written on the session, and the session's record has no field for it.
2182
+ */
2183
+ async rerunFromRestore(target, branch, tasks) {
2184
+ if (target.session.vcs?.branchMode === 'per-session') {
2185
+ const left = sessionBranchName(target.session);
2186
+ // The restore branch exists by this name, so the name is kept as it is (see `branchNameExact`).
2187
+ await this.store.updateSession(target.session.id, (s) => {
2188
+ if (s.vcs) {
2189
+ s.vcs.branchName = branch;
2190
+ s.vcs.branchNameExact = true;
2191
+ }
2192
+ });
2193
+ this.bus.publish({
2194
+ sessionId: target.session.id,
2195
+ taskId: target.task.id,
2196
+ type: 'vcs-session-branch',
2197
+ level: 'info',
2198
+ message: `the session carries on from ${branch}, the code as it was before "${target.task.title}"; ${left} keeps the work that came after`,
2199
+ data: { branch, left },
2200
+ });
2201
+ }
2202
+ const here = normaliseDir(target.dir);
2203
+ const inOrder = [...new Map(tasks.map(({ session }) => [session.id, session])).values()];
2204
+ const later = inOrder.slice(inOrder.findIndex((s) => s.id === target.session.id) + 1);
2205
+ for (const s of later) {
2206
+ // One that carries on an existing branch never gets here: `restoreTargets` refuses the restart.
2207
+ if (!s.vcs?.enabled || s.vcs.branchMode !== 'per-session' || s.vcs.startFrom === 'existing-branch')
2208
+ continue;
2209
+ // No base recorded: version control never got as far as making its branch.
2210
+ if (!s.vcsBaseCommit || normaliseDir(repoDirOf(s)) !== here)
2211
+ continue;
2212
+ const first = tasks.find((x) => x.session.id === s.id && x.task.startedAt)?.task;
2213
+ if (!first)
2214
+ continue;
2215
+ const left = sessionBranchName(s);
2216
+ const fresh = await freeBranchName(target.dir, left);
2217
+ // No record of the start is read as chained, the reading that cannot bring undone work back.
2218
+ const chained = s.vcsStart?.kind !== 'branch';
2219
+ const at = chained ? undefined : restorePoint(s, first);
2220
+ if (at) {
2221
+ const made = await branchAt(target.dir, fresh, at);
2222
+ // Not made: left to the run, which cuts it from the session's start when it is reached.
2223
+ if (!made.ok) {
2224
+ this.bus.publish({ sessionId: s.id, type: 'vcs-problem', level: 'warn', message: `${fresh} could not be made at ${at.slice(0, 8)}: ${made.problem}` });
2225
+ }
2226
+ }
2227
+ await this.store.updateSession(s.id, (x) => {
2228
+ if (chained) {
2229
+ x.vcsBaseCommit = undefined;
2230
+ x.vcsStart = undefined;
2231
+ }
2232
+ if (x.vcs) {
2233
+ x.vcs.branchName = fresh;
2234
+ x.vcs.branchNameExact = true;
2235
+ }
2236
+ });
2237
+ this.bus.publish({
2238
+ sessionId: s.id,
2239
+ type: 'vcs-session-branch',
2240
+ level: 'info',
2241
+ message: chained
2242
+ ? `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`
2243
+ : `the session starts its tasks again on a new branch ${fresh}, from where "${first.title}" began; ${left} keeps what it did before`,
2244
+ data: { branch: fresh, left },
2245
+ });
2246
+ }
1792
2247
  }
1793
2248
  /** Why a folder cannot be used for version control, or null when it can. */
1794
2249
  repoProblem(dir) {
@@ -1806,7 +2261,7 @@ let OperatorService = class OperatorService {
1806
2261
  await this.init();
1807
2262
  const wanted = normaliseDir(dir);
1808
2263
  const cfg = await this.settings.load();
1809
- 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 ?? ''])]
2264
+ 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 ?? ''])]
1810
2265
  .filter((d) => d.trim())
1811
2266
  .map(normaliseDir));
1812
2267
  if (!wanted || !known.has(wanted)) {
@@ -1827,7 +2282,7 @@ let OperatorService = class OperatorService {
1827
2282
  const problem = repoUnusableReason(rootDir);
1828
2283
  const others = (cfg.project?.others ?? []).map((o) => {
1829
2284
  const p = repoUnusableReason(o.rootDir);
1830
- return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror, desktop: o.desktop !== false };
2285
+ return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}) };
1831
2286
  });
1832
2287
  return {
1833
2288
  rootDir,
@@ -1835,65 +2290,8 @@ let OperatorService = class OperatorService {
1835
2290
  repoOk: rootDir !== '' && problem === null,
1836
2291
  ...(problem ? { repoProblem: problem } : {}),
1837
2292
  others,
1838
- mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
1839
- desktop: cfg.project?.desktop !== false,
1840
- mirror: cfg.project?.mirror,
1841
- contextRoot: contextRoot(cfg),
1842
2293
  };
1843
2294
  }
1844
- desktopRefresh = null;
1845
- desktopRefreshAgain = false;
1846
- /**
1847
- * Asks for the Desktop copies to be brought up to date, while a run works.
1848
- *
1849
- * They used to be refreshed once, before a run, so a task that changed files left the Desktop —
1850
- * and OneDrive, and the chat that reads from it — on the code as it was before the task. The
1851
- * runner now calls this after every round of steps and after every task. One refresh runs at a
1852
- * time: a request that arrives while one is running is remembered and served by one more pass
1853
- * after it, so a burst of rounds costs two passes, not one per round, and the last pass always
1854
- * sees the latest files. A failure is reported and never stops the run.
1855
- */
1856
- requestDesktopRefresh() {
1857
- if (this.desktopRefresh) {
1858
- this.desktopRefreshAgain = true;
1859
- return;
1860
- }
1861
- this.desktopRefresh = (async () => {
1862
- do {
1863
- this.desktopRefreshAgain = false;
1864
- await this.refreshDesktopMirrors();
1865
- } while (this.desktopRefreshAgain);
1866
- })().finally(() => {
1867
- this.desktopRefresh = null;
1868
- });
1869
- }
1870
- /** Resolves once no Desktop refresh is running or waiting. For the checks, and for a clean stop. */
1871
- async desktopRefreshSettled() {
1872
- while (this.desktopRefresh)
1873
- await this.desktopRefresh;
1874
- }
1875
- /**
1876
- * Refreshes every project's Desktop folder, when the switch is on. Called before a run
1877
- * starts, so what OneDrive holds when the chat opens is the code as it is now, for every
1878
- * project on the Settings page and not only the one the session is about.
1879
- */
1880
- async refreshDesktopMirrors() {
1881
- const cfg = await this.settings.load();
1882
- if (!cfg.project?.mirrorToDesktop)
1883
- return;
1884
- try {
1885
- const outcomes = await mirrorAllProjects(cfg);
1886
- this.bus.publish({
1887
- sessionId: '*',
1888
- type: 'desktop-mirror',
1889
- level: outcomes.some((o) => o.problem) ? 'warn' : 'info',
1890
- message: outcomes.map((o) => `${o.name}: ${o.result ? describeMirror(o.result) : `not refreshed — ${o.problem}`}`).join('; ') || 'no projects to mirror',
1891
- });
1892
- }
1893
- catch (e) {
1894
- this.bus.publish({ sessionId: '*', type: 'desktop-mirror-failed', level: 'warn', message: e.message });
1895
- }
1896
- }
1897
2295
  /**
1898
2296
  * Stores the project folder and the other folders the operator works in. A field left out
1899
2297
  * of the patch is kept; an empty `rootDir` clears the default, which is a real choice: it
@@ -1906,54 +2304,37 @@ let OperatorService = class OperatorService {
1906
2304
  * — each of those would be a setting that looks filled in and points nowhere.
1907
2305
  */
1908
2306
  async setProject(patch) {
1909
- const raw = await this.settings.raw();
1910
- const current = (raw.project ?? {});
1911
- const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
1912
- const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
1913
- if (rootDir && !existsSync(rootDir))
1914
- throw new Error(`The folder ${rootDir} does not exist on this machine.`);
1915
- const others = (patch.others ?? current.others ?? []).map((o) => ({
1916
- name: (o.name ?? '').trim(),
1917
- rootDir: (o.rootDir ?? '').trim(),
1918
- ...(o.mirror ? { mirror: o.mirror } : {}),
1919
- // Stored only when it is off: absent means the project goes to the Desktop with the rest.
1920
- ...(o.desktop === false ? { desktop: false } : {}),
1921
- }));
1922
- const seen = new Set();
1923
- for (const o of others) {
1924
- if (!o.name)
1925
- throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
1926
- if (!o.rootDir)
1927
- throw new Error(`The project "${o.name}" needs a folder.`);
1928
- if (!existsSync(o.rootDir))
1929
- throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
1930
- if (sameFolder(o.rootDir, rootDir))
1931
- throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
1932
- const key = o.name.toLowerCase();
1933
- if (seen.has(key))
1934
- throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
1935
- seen.add(key);
1936
- }
1937
- const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
1938
- const mirror = patch.mirror ?? current.mirror;
1939
- const desktop = patch.desktop ?? current.desktop ?? true;
1940
- const { desktop: _old, ...rest } = current;
1941
- const cfg = await this.settings.save({
1942
- ...raw,
1943
- project: { ...rest, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}), ...(desktop === false ? { desktop: false } : {}) },
2307
+ // Worked out from the file in its turn (see `Settings.update`), so a model chosen at the same
2308
+ // moment is kept, and a refusal below leaves the file as it was.
2309
+ await this.settings.update((raw) => {
2310
+ const current = (raw.project ?? {});
2311
+ const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
2312
+ const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
2313
+ if (rootDir && !existsSync(rootDir))
2314
+ throw new Error(`The folder ${rootDir} does not exist on this machine.`);
2315
+ const others = (patch.others ?? current.others ?? []).map((o) => ({
2316
+ name: (o.name ?? '').trim(),
2317
+ rootDir: (o.rootDir ?? '').trim(),
2318
+ }));
2319
+ const seen = new Set();
2320
+ for (const o of others) {
2321
+ if (!o.name)
2322
+ throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
2323
+ if (!o.rootDir)
2324
+ throw new Error(`The project "${o.name}" needs a folder.`);
2325
+ if (!existsSync(o.rootDir))
2326
+ throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
2327
+ if (sameFolder(o.rootDir, rootDir))
2328
+ throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
2329
+ const key = o.name.toLowerCase();
2330
+ if (seen.has(key))
2331
+ throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
2332
+ seen.add(key);
2333
+ }
2334
+ // What the removed Desktop copies left in the stored project is dropped with the next save.
2335
+ const { mirror: _m, desktop: _d, mirrorToDesktop: _t, ...rest } = current;
2336
+ return { ...raw, project: { ...rest, rootDir, name, others } };
1944
2337
  });
1945
- /*
1946
- * The switch acts at once, both ways. On: every project's folder appears on the Desktop
1947
- * now, not at the next run, because "I turned it on and nothing happened" is a support
1948
- * question. Off: the folders go, because a copy of a code base left in OneDrive after the
1949
- * operator said no is the one outcome the switch must not produce. A changed selection
1950
- * with the switch on is applied the same way.
1951
- */
1952
- const wasOn = current.mirrorToDesktop ?? false;
1953
- if (mirrorToDesktop)
1954
- await mirrorAllProjects(cfg);
1955
- else if (wasOn)
1956
- await removeProjectMirrors(cfg);
1957
2338
  return await this.project();
1958
2339
  }
1959
2340
  // --- the model catalogue ----------------------------------------------------------------
@@ -1978,9 +2359,7 @@ let OperatorService = class OperatorService {
1978
2359
  */
1979
2360
  async setDefaultReviewModel(name) {
1980
2361
  await this.init();
1981
- const raw = await this.settings.raw();
1982
- const copilot = { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() };
1983
- await this.settings.save({ ...raw, copilot });
2362
+ await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() } }));
1984
2363
  this.bus.publish({
1985
2364
  sessionId: '*',
1986
2365
  type: 'default-review-model-changed',
@@ -1998,9 +2377,7 @@ let OperatorService = class OperatorService {
1998
2377
  */
1999
2378
  async setDefaultModel(name) {
2000
2379
  await this.init();
2001
- const raw = await this.settings.raw();
2002
- const copilot = { ...(raw.copilot ?? {}), defaultModel: name.trim() };
2003
- await this.settings.save({ ...raw, copilot });
2380
+ await this.settings.update((raw) => ({ ...raw, copilot: { ...(raw.copilot ?? {}), defaultModel: name.trim() } }));
2004
2381
  this.bus.publish({
2005
2382
  sessionId: '*',
2006
2383
  type: 'default-model-changed',
@@ -2021,26 +2398,27 @@ let OperatorService = class OperatorService {
2021
2398
  * line-up.
2022
2399
  */
2023
2400
  async refreshModels() {
2024
- await this.init();
2025
- if (this.running.size > 0) {
2026
- throw new Error('A session is running and it is using the browser profile. Stop it first, then read the models.');
2027
- }
2028
- if (this.readingModels)
2029
- throw new Error('The model list is already being read.');
2030
- this.readingModels = true;
2031
- const cfg = await this.settings.load();
2032
- const transport = createTransport({
2033
- profileDir: cfg.resolved.profileDir,
2034
- transportDir: join(cfg.resolved.runsDir, '_models'),
2035
- chatUrl: cfg.copilot.url,
2036
- channel: cfg.copilot.channel,
2037
- headless: cfg.copilot.headless,
2038
- replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
2039
- signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
2040
- humanWaitMs: cfg.copilot.humanWaitSec * 1000,
2041
- keepFailurePage: cfg.copilot.keepFailurePage,
2042
- });
2401
+ // Asked of the one claim every window goes through (see `browser`), before anything is waited
2402
+ // for. It used to count the sessions running, which is none in the moment between two
2403
+ // sessions of a batch, while the batch's window is still open.
2404
+ const claim = this.claimBrowser({ kind: 'models' });
2405
+ if ('refused' in claim)
2406
+ throw new Error(claim.refused);
2407
+ let transport = null;
2043
2408
  try {
2409
+ await this.init();
2410
+ const cfg = await this.settings.load();
2411
+ transport = createTransport({
2412
+ profileDir: cfg.resolved.profileDir,
2413
+ transportDir: join(cfg.resolved.runsDir, '_models'),
2414
+ chatUrl: cfg.copilot.url,
2415
+ channel: cfg.copilot.channel,
2416
+ headless: cfg.copilot.headless,
2417
+ replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
2418
+ signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
2419
+ humanWaitMs: cfg.copilot.humanWaitSec * 1000,
2420
+ keepFailurePage: cfg.copilot.keepFailurePage,
2421
+ });
2044
2422
  await transport.open();
2045
2423
  await transport.ensureSignedIn();
2046
2424
  const { options, current, note } = await transport.listModels();
@@ -2049,8 +2427,8 @@ let OperatorService = class OperatorService {
2049
2427
  return catalogue;
2050
2428
  }
2051
2429
  finally {
2052
- await transport.close().catch(() => undefined);
2053
- this.readingModels = false;
2430
+ await transport?.close().catch(() => undefined);
2431
+ this.releaseBrowser(claim.holder);
2054
2432
  }
2055
2433
  }
2056
2434
  // --- the registry ---------------------------------------------------------------------
@@ -2139,41 +2517,10 @@ let OperatorService = class OperatorService {
2139
2517
  return out;
2140
2518
  }
2141
2519
  // --- environment ----------------------------------------------------------------------
2142
- async dirs(root, respectGitignore = true) {
2143
- return await listSelectableDirs(root, { respectGitignore });
2144
- }
2145
2520
  /** Opens the machine's own folder dialog and reports what was picked. */
2146
2521
  async browseFolder(start) {
2147
2522
  return await pickFolder(start);
2148
2523
  }
2149
- /**
2150
- * What the current selection would copy, without writing anything. This is how the two
2151
- * switches become checkable: turn the env one on and the `.env` files move from the skipped
2152
- * list into the copied one, whatever `.gitignore` says about them.
2153
- */
2154
- async previewMirror(input) {
2155
- const conflicts = findSelectionConflicts(input.includeDirs ?? [], input.excludeDirs ?? []);
2156
- if (conflicts.length > 0)
2157
- throw new Error(describeConflicts(conflicts));
2158
- const cfg = await this.settings.load();
2159
- const { files, skipped } = await collectFiles({
2160
- rootDir: input.rootDir,
2161
- includeDirs: input.includeDirs?.length ? input.includeDirs : ['.'],
2162
- excludeDirs: input.excludeDirs ?? [],
2163
- targetDir: '',
2164
- respectGitignore: input.respectGitignore ?? true,
2165
- includeEnvFiles: input.includeEnvFiles ?? false,
2166
- ignoreDirs: cfg.projectMirror.ignoreDirs,
2167
- maxFileBytes: cfg.projectMirror.maxFileBytes,
2168
- });
2169
- let totalBytes = 0;
2170
- for (const rel of files) {
2171
- const s = await import('node:fs/promises').then((fs) => fs.stat(join(input.rootDir, rel)).catch(() => null));
2172
- if (s)
2173
- totalBytes += s.size;
2174
- }
2175
- return { files, skipped, envFiles: files.filter(isEnvPath), totalBytes, conflicts };
2176
- }
2177
2524
  async doctor() {
2178
2525
  const cfg = await this.settings.load();
2179
2526
  const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
@@ -2204,7 +2551,6 @@ let OperatorService = class OperatorService {
2204
2551
  */
2205
2552
  isolation: assessIsolation(cfg.execution.isolation, readIsolationSignals()),
2206
2553
  folderDialog: process.platform === 'win32',
2207
- alwaysIgnoredDirs: DEFAULT_IGNORE_DIRS,
2208
2554
  };
2209
2555
  }
2210
2556
  };
@@ -2227,21 +2573,3 @@ function resolveRunId(task, requested) {
2227
2573
  const owned = [task.runId, ...(task.attempts ?? []).map((a) => a.runId)].filter(Boolean);
2228
2574
  return owned.includes(requested) ? requested : null;
2229
2575
  }
2230
- function isEnvPath(relPath) {
2231
- const name = relPath.split('/').pop()?.toLowerCase() ?? '';
2232
- return name === '.env' || name.startsWith('.env.');
2233
- }
2234
- /**
2235
- * Refuses a selection that contradicts itself, at the point it is saved rather than at the
2236
- * point it runs. A contradiction is cheap to fix while the form is open and expensive to
2237
- * discover an hour later, when the first task of a run fails on it.
2238
- */
2239
- function assertMirrorIsCoherent(mirror) {
2240
- const conflicts = findSelectionConflicts(mirror.includeDirs ?? [], mirror.excludeDirs ?? []);
2241
- if (conflicts.length > 0) {
2242
- throw new Error(`The include and exclude lists contradict each other. ${describeConflicts(conflicts)}`);
2243
- }
2244
- if (mirror.enabled && mirror.rootDir !== undefined && !mirror.rootDir.trim()) {
2245
- throw new Error('Attaching project files needs a project root.');
2246
- }
2247
- }