copilot-operator 0.1.11 → 0.1.13

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 (60) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +98 -8
  3. package/dist/src/config/schema.js +14 -3
  4. package/dist/src/context/desktopMirror.js +73 -13
  5. package/dist/src/context/projectMirror.js +69 -1
  6. package/dist/src/orchestrator/contract.js +56 -1
  7. package/dist/src/orchestrator/taskRunner.js +140 -16
  8. package/dist/src/plan/brief.js +39 -8
  9. package/dist/src/plan/importPlan.js +3 -0
  10. package/dist/src/plan/schema.js +26 -2
  11. package/dist/src/plan/script.js +79 -75
  12. package/dist/src/plan/systemGuide.js +79 -15
  13. package/dist/src/session/compose.js +16 -1
  14. package/dist/src/session/exportRecord.js +8 -1
  15. package/dist/src/session/exports.js +3 -0
  16. package/dist/src/session/handoff.js +41 -3
  17. package/dist/src/session/model.js +9 -0
  18. package/dist/src/session/store.js +11 -7
  19. package/dist/src/session/story.js +74 -18
  20. package/dist/src/transport/chatTransport.js +1 -0
  21. package/dist/src/transport/copilotTransport.js +80 -61
  22. package/dist/src/vcs/git.js +92 -2
  23. package/dist/src/vcs/taskVcs.js +100 -19
  24. package/dist/web/404.html +2 -2
  25. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
  26. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
  27. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +1 -0
  28. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
  29. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
  30. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
  31. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +1 -0
  32. package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
  33. package/dist/web/appearance.html +2 -2
  34. package/dist/web/appearance.txt +12 -12
  35. package/dist/web/defaults.html +2 -2
  36. package/dist/web/defaults.txt +12 -12
  37. package/dist/web/history.html +2 -2
  38. package/dist/web/history.txt +12 -12
  39. package/dist/web/import.html +2 -2
  40. package/dist/web/import.txt +12 -12
  41. package/dist/web/index.html +2 -2
  42. package/dist/web/index.txt +12 -12
  43. package/dist/web/level1.html +2 -2
  44. package/dist/web/level1.txt +12 -12
  45. package/dist/web/presets.html +2 -2
  46. package/dist/web/presets.txt +12 -12
  47. package/dist/web/sessions/view.html +2 -2
  48. package/dist/web/sessions/view.txt +12 -12
  49. package/dist/web/system.html +2 -2
  50. package/dist/web/system.txt +12 -12
  51. package/package.json +6 -3
  52. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
  53. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
  54. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
  55. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
  56. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
  57. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
  58. package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +0 -1
  59. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_buildManifest.js +0 -0
  60. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_ssgManifest.js +0 -0
@@ -457,7 +457,7 @@ let OperatorController = class OperatorController {
457
457
  /** Stores it. A field left out is kept; an empty `rootDir` clears the default. */
458
458
  setProject(body) {
459
459
  return this.ops
460
- .setProject({ rootDir: body?.rootDir, name: body?.name, others: body?.others, mirrorToDesktop: body?.mirrorToDesktop, mirror: body?.mirror })
460
+ .setProject({ rootDir: body?.rootDir, name: body?.name, others: body?.others, mirrorToDesktop: body?.mirrorToDesktop, mirror: body?.mirror, desktop: body?.desktop })
461
461
  .catch(fail);
462
462
  }
463
463
  /**
@@ -19,7 +19,7 @@ import { join, resolve } from 'node:path';
19
19
  import { installLayout } from '../config/layout.js';
20
20
  import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
21
21
  import { EventBus } from '../session/events.js';
22
- import { newId } from '../session/model.js';
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';
@@ -38,7 +38,7 @@ function sameFolder(a, b) {
38
38
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
39
39
  import { readInterruption } from '../session/interruption.js';
40
40
  import { commitInterrupted, repoDirOf, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
41
- import { changedFilesBetween, fileAt, gitAvailable, repoUnusableReason } from '../vcs/git.js';
41
+ import { branchExists, changedFilesBetween, fileAt, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
42
42
  import { makeAuthorizer } from '../exec/authorizer.js';
43
43
  import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
44
44
  import { pickFolder } from './folderPicker.js';
@@ -235,9 +235,16 @@ let OperatorService = class OperatorService {
235
235
  }
236
236
  async updateSession(id, patch) {
237
237
  await this.init();
238
+ if (patch.active !== undefined && this.running.has(id)) {
239
+ throw new Error('This session is running; its status can be changed once it has stopped.');
240
+ }
238
241
  return await this.store.updateSession(id, (s) => {
239
242
  if (patch.name !== undefined)
240
243
  s.name = patch.name.trim() || s.name;
244
+ if (patch.active === true)
245
+ delete s.active;
246
+ else if (patch.active === false)
247
+ s.active = false;
241
248
  if (patch.onFailure === 'stop' || patch.onFailure === 'continue')
242
249
  s.onFailure = patch.onFailure;
243
250
  // An empty string is a real choice here: it means "leave the chat on whatever it is".
@@ -270,6 +277,8 @@ let OperatorService = class OperatorService {
270
277
  }
271
278
  if (merged.baseBranch !== undefined)
272
279
  merged.baseBranch = merged.baseBranch.trim();
280
+ if (merged.existingBranch !== undefined)
281
+ merged.existingBranch = merged.existingBranch.trim();
273
282
  /*
274
283
  * Where the session starts is fixed at its first run. A new choice clears that record, so
275
284
  * the next task to run is cut from what was chosen now; the tasks that already ran keep
@@ -277,6 +286,8 @@ let OperatorService = class OperatorService {
277
286
  */
278
287
  const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
279
288
  (merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
289
+ (merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
290
+ (merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
280
291
  merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
281
292
  if (startChanged) {
282
293
  s.vcsBaseCommit = undefined;
@@ -324,7 +335,7 @@ let OperatorService = class OperatorService {
324
335
  * the text it ran with, and only then is the new text applied. Editing in place would leave
325
336
  * the old attempt's summary sitting under a question that was never asked.
326
337
  */
327
- /** "Continue" on a task that stopped before it finished — its limit, the bot stopping, the operator: same chat, same branch, fresh count. */
338
+ /** "Continue" on a task that stopped before it finished — a limit from the settings, the bot stopping, the operator: same chat, same branch, fresh count. */
328
339
  async continueTask(sessionId, taskId) {
329
340
  await this.init();
330
341
  const task = await this.store.continueTask(sessionId, taskId);
@@ -749,6 +760,10 @@ let OperatorService = class OperatorService {
749
760
  const session = await this.store.getSession(sessionId);
750
761
  if (!session)
751
762
  return { started: false, reason: 'no such session', done: Promise.resolve(idle) };
763
+ // Set aside by the operator on the Sessions page: not started by anything until made active.
764
+ if (session.active === false) {
765
+ return { started: false, reason: 'the session is inactive; make it active on the Sessions page to run it', done: Promise.resolve(idle) };
766
+ }
752
767
  const queuedIds = queuedToRun(session, onlyTasks).map((t) => t.id);
753
768
  if (queuedIds.length === 0)
754
769
  return { started: false, reason: 'no queued tasks', done: Promise.resolve(idle) };
@@ -804,6 +819,8 @@ let OperatorService = class OperatorService {
804
819
  // The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
805
820
  // and the policy.json of a later task must say so.
806
821
  currentMode: () => this.running.get(sessionId)?.mode ?? mode,
822
+ // The Desktop copies follow the work while it happens, not only before the run.
823
+ onWorkChanged: () => this.requestDesktopRefresh(),
807
824
  // The session decides whether its tasks are one chain or a set of independent checks.
808
825
  continueOnFailure: session.onFailure === 'continue',
809
826
  transport,
@@ -1348,6 +1365,31 @@ let OperatorService = class OperatorService {
1348
1365
  });
1349
1366
  }
1350
1367
  });
1368
+ if (repoIssues.length > 0)
1369
+ return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1370
+ /*
1371
+ * 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.
1374
+ */
1375
+ for (const [i, session] of check.plan.sessions.entries()) {
1376
+ const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
1377
+ if (!wanted)
1378
+ continue;
1379
+ const dir = session.vcs?.repoDir?.trim() || session.mirror?.rootDir?.trim() || '';
1380
+ const madeEarlier = check.plan.sessions
1381
+ .slice(0, i)
1382
+ .some((e) => !!e.vcs?.branchName?.trim() && plannedBranchName(e.vcs.branchName, e.vcs.branchPrefix?.trim() || 'cop/') === wanted);
1383
+ if (madeEarlier || (await branchExists(dir, wanted)))
1384
+ continue;
1385
+ const have = await localBranches(dir);
1386
+ repoIssues.push({
1387
+ path: `sessions[${i}].vcs.existingBranch`,
1388
+ message: `${dir} has no local branch "${wanted}" to carry on. ` +
1389
+ (have.length > 0 ? `Its branches: ${have.slice(0, 20).join(', ')}.` : 'It has no branches yet.') +
1390
+ ' Give the exact name, or use startFrom "branch" to start a new one.',
1391
+ });
1392
+ }
1351
1393
  if (repoIssues.length > 0)
1352
1394
  return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1353
1395
  return { ...check, duplicates: await this.findDuplicates(check.plan) };
@@ -1540,7 +1582,10 @@ let OperatorService = class OperatorService {
1540
1582
  return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
1541
1583
  // `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
1542
1584
  // those differ, and the second is the one the operator is asking about.
1543
- return { ...(await vcsPreflight(session)), git, work: sessionBranches(session) };
1585
+ const preflight = await vcsPreflight(session);
1586
+ // The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
1587
+ const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
1588
+ return { ...preflight, git, work: sessionBranches(session), branches };
1544
1589
  }
1545
1590
  /**
1546
1591
  * What going back to before a task would do, and then doing it.
@@ -1814,7 +1859,7 @@ let OperatorService = class OperatorService {
1814
1859
  const problem = repoUnusableReason(rootDir);
1815
1860
  const others = (cfg.project?.others ?? []).map((o) => {
1816
1861
  const p = repoUnusableReason(o.rootDir);
1817
- return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror };
1862
+ return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror, desktop: o.desktop !== false };
1818
1863
  });
1819
1864
  return {
1820
1865
  rootDir,
@@ -1823,10 +1868,42 @@ let OperatorService = class OperatorService {
1823
1868
  ...(problem ? { repoProblem: problem } : {}),
1824
1869
  others,
1825
1870
  mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
1871
+ desktop: cfg.project?.desktop !== false,
1826
1872
  mirror: cfg.project?.mirror,
1827
1873
  contextRoot: contextRoot(cfg),
1828
1874
  };
1829
1875
  }
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
+ }
1830
1907
  /**
1831
1908
  * Refreshes every project's Desktop folder, when the switch is on. Called before a run
1832
1909
  * starts, so what OneDrive holds when the chat opens is the code as it is now, for every
@@ -1841,8 +1918,8 @@ let OperatorService = class OperatorService {
1841
1918
  this.bus.publish({
1842
1919
  sessionId: '*',
1843
1920
  type: 'desktop-mirror',
1844
- level: 'info',
1845
- message: outcomes.map((o) => `${o.name}: ${describeMirror(o.result)}`).join('; ') || 'no projects to 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',
1846
1923
  });
1847
1924
  }
1848
1925
  catch (e) {
@@ -1871,6 +1948,8 @@ let OperatorService = class OperatorService {
1871
1948
  name: (o.name ?? '').trim(),
1872
1949
  rootDir: (o.rootDir ?? '').trim(),
1873
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 } : {}),
1874
1953
  }));
1875
1954
  const seen = new Set();
1876
1955
  for (const o of others) {
@@ -1889,7 +1968,12 @@ let OperatorService = class OperatorService {
1889
1968
  }
1890
1969
  const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
1891
1970
  const mirror = patch.mirror ?? current.mirror;
1892
- const cfg = await this.settings.save({ ...raw, project: { ...current, rootDir, name, others, mirrorToDesktop, ...(mirror ? { 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 } : {}) },
1976
+ });
1893
1977
  /*
1894
1978
  * The switch acts at once, both ways. On: every project's folder appears on the Desktop
1895
1979
  * now, not at the next run, because "I turned it on and nothing happened" is a support
@@ -2054,6 +2138,12 @@ let OperatorService = class OperatorService {
2054
2138
  readOnly: t.readOnly || undefined,
2055
2139
  scope: t.scope?.length ? t.scope : undefined,
2056
2140
  stopCode: t.stopCode,
2141
+ limit: t.limit,
2142
+ continuable: isContinuable(t) || undefined,
2143
+ branch: t.vcs?.branch,
2144
+ commit: t.vcs?.commit,
2145
+ vcsProblem: t.vcs?.problem,
2146
+ sessionInactive: s.active === false || undefined,
2057
2147
  autoRetries: t.autoRetries || undefined,
2058
2148
  attempt: t.attempt,
2059
2149
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
@@ -47,10 +47,17 @@ const TextOrFile = z.union([z.string(), z.object({ file: z.string().min(1) })]);
47
47
  /** One of git's own options, before the subcommand: `-C <path>`, `--no-pager`, `-c k=v`. */
48
48
  const GIT_GLOBAL_OPTION = '(?:--no-pager|--paginate|--bare|--literal-pathspecs|--exec-path=\\S*|-c\\s+\\S+|' +
49
49
  '-C\\s+(?:"[^"]*"|\'[^\']*\'|\\S+)|--git-dir[= ]\\S+|--work-tree[= ]\\S+)';
50
- /** Subcommands that have no read-only form at all. */
50
+ /**
51
+ * Subcommands that have no read-only form at all.
52
+ *
53
+ * Matched as a whole subcommand, followed by neither a letter nor a hyphen: `merge` must not catch
54
+ * `merge-base`, which only reads (refused live on 2026-09-30, while a task checked that its branch
55
+ * held a checkpoint). The hyphenated ones that do write are therefore listed by their full name.
56
+ */
51
57
  const GIT_ALWAYS_WRITES = 'commit|push|reset|rebase|merge|cherry-pick|revert|clean|checkout|switch|restore|stash|' +
52
58
  'am|apply|init|clone|filter-branch|filter-repo|update-ref|update-index|gc|prune|repack|' +
53
- 'submodule|worktree|mv|rm|add|pull';
59
+ 'submodule|worktree|mv|rm|add|pull|' +
60
+ 'checkout-index|commit-tree|merge-file|merge-index|merge-one-file|read-tree|prune-packed|update-server-info';
54
61
  /** Subcommands that read by default and write with these arguments. */
55
62
  const GIT_SOMETIMES_WRITES = 'remote\\s+(?:add|remove|rm|rename|set-url|set-head|set-branches|prune)\\b|' +
56
63
  'branch\\s+(?:-[dDmMfu]\\b|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))|' +
@@ -238,7 +245,7 @@ export const RunConfigSchema = z.object({
238
245
  'Disable-WindowsOptionalFeature',
239
246
  'net\\s+user\\s+\\w+\\s+/add',
240
247
  // git that changes something. See GIT_WRITE_NOTE below.
241
- `\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})\\b`,
248
+ `\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})(?![\\w-])`,
242
249
  `\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_SOMETIMES_WRITES})`,
243
250
  ]),
244
251
  })
@@ -285,6 +292,8 @@ export const RunConfigSchema = z.object({
285
292
  rootDir: z.string().trim().min(1),
286
293
  /** Which of this project's directories go to the Desktop. See `mirror` below. */
287
294
  mirror: ProjectMirrorSelection.optional(),
295
+ /** `false` keeps this project off the Desktop while the switch below is on. Absent means on. */
296
+ desktop: z.boolean().optional(),
288
297
  }))
289
298
  .default([]),
290
299
  /**
@@ -299,6 +308,8 @@ export const RunConfigSchema = z.object({
299
308
  mirrorToDesktop: z.boolean().default(false),
300
309
  /** The default project's own selection. */
301
310
  mirror: ProjectMirrorSelection.optional(),
311
+ /** `false` keeps the default project off the Desktop while the switch is on. Absent means on. */
312
+ desktop: z.boolean().optional(),
302
313
  })
303
314
  .prefault({}),
304
315
  /**
@@ -14,7 +14,7 @@
14
14
  * `project.mirrorToDesktop`; a session's own mirror (the files attached to its first message)
15
15
  * uses the same folder for its project whether or not the switch is on.
16
16
  */
17
- import { basename, join, resolve } from 'node:path';
17
+ import { basename, join, resolve, relative, isAbsolute } from 'node:path';
18
18
  import { readdir, rm, stat } from 'node:fs/promises';
19
19
  import { mirrorProject } from './projectMirror.js';
20
20
  import { defaultExportDir } from './contextFiles.js';
@@ -31,12 +31,51 @@ export function safeProjectName(name) {
31
31
  export function knownProjects(cfg) {
32
32
  const out = [];
33
33
  const root = (cfg.project?.rootDir ?? '').trim();
34
- if (root)
35
- out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION });
34
+ if (root) {
35
+ out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION, desktop: cfg.project?.desktop !== false });
36
+ }
36
37
  for (const o of cfg.project?.others ?? [])
37
- out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION });
38
+ out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION, desktop: o.desktop !== false });
38
39
  return out;
39
40
  }
41
+ /**
42
+ * Whether the Desktop copy of the project at `rootDir` is kept by "Keep the projects on the Desktop".
43
+ *
44
+ * Then its Desktop folder has one owner, the Desktop mirror, and a task's attachments are copied
45
+ * elsewhere. The two used to share it with different selections — the whole project there, the
46
+ * task's folders here — so every run deleted most of the folder and every round of steps put it
47
+ * back: thousands of deletions and uploads in a OneDrive-synced Desktop, while the chat was
48
+ * uploading the task's own attachments to the same OneDrive (2026-09-30, a first message timing out).
49
+ */
50
+ export function keptOnDesktop(cfg, rootDir) {
51
+ return !!cfg.project?.mirrorToDesktop && knownProjects(cfg).some((p) => p.desktop && sameFolder(p.rootDir, rootDir));
52
+ }
53
+ /**
54
+ * The project folder to copy from, for a folder a session names as its project files' root.
55
+ *
56
+ * A folder in the bot's own Desktop area (`contextRoot`) is not a project: it is the bot's copy of
57
+ * one, flattened, and the place the copies are written to. Named as a root — seen live on 2026-09-30,
58
+ * `...\Desktop\copilot-operator-context\Automation` — the mirror read the folder it was writing: with
59
+ * "Keep the projects on the Desktop" on it copied its own copies over the originals while the Desktop
60
+ * mirror put them back, and the first message timed out; with it off the folder was not there and
61
+ * the task was refused. Such a folder is taken to mean the project it is the copy of; one that is the
62
+ * copy of no known project is refused, with what to choose instead.
63
+ */
64
+ export function mirrorSourceRoot(cfg, rootDir) {
65
+ const area = resolve(contextRoot(cfg));
66
+ const dir = resolve(rootDir);
67
+ const rel = relative(area, dir);
68
+ if (rel === '') {
69
+ return { problem: `${dir} is the folder where the bot keeps its Desktop copies, not a project. Choose the project's own folder as the root of the files to attach.` };
70
+ }
71
+ if (rel.startsWith('..') || isAbsolute(rel))
72
+ return { rootDir };
73
+ const copyOf = knownProjects(cfg).find((p) => sameFolder(projectTargetDir(cfg, p.name), join(area, rel.split(/[\\/]/)[0])));
74
+ if (!copyOf) {
75
+ return { problem: `${dir} is inside the folder where the bot keeps its Desktop copies (${area}), not a project, and it is not the copy of any project on the Project page. Choose the project's own folder as the root of the files to attach.` };
76
+ }
77
+ return { rootDir: copyOf.rootDir, fromCopy: dir };
78
+ }
40
79
  /** What to call the folder for a session's project: its Settings name, else the folder's own. */
41
80
  export function projectNameFor(rootDir, cfg) {
42
81
  const known = knownProjects(cfg).find((p) => sameFolder(p.rootDir, rootDir));
@@ -88,26 +127,47 @@ export async function mirrorKnownProject(cfg, project) {
88
127
  });
89
128
  return { name, rootDir: project.rootDir, targetDir, result };
90
129
  }
91
- /** Every known project, when the switch is on. Nothing when it is off. */
130
+ /**
131
+ * Every known project that is kept on the Desktop, when the switch is on; nothing when it is off.
132
+ *
133
+ * The switch is the master control and each project has its own tick under it. A project whose
134
+ * tick is off has its Desktop folder removed here, for the same reason the switch going off
135
+ * removes them all: a stale copy of a code base in the cloud is worse than none.
136
+ */
92
137
  export async function mirrorAllProjects(cfg) {
93
138
  if (!cfg.project?.mirrorToDesktop)
94
139
  return [];
95
140
  await removeLegacyFlatMirror(cfg);
96
141
  const out = [];
97
- for (const p of knownProjects(cfg))
98
- out.push(await mirrorKnownProject(cfg, p));
142
+ for (const p of knownProjects(cfg)) {
143
+ // One project whose selection is wrong keeps its old copies and says so; the others are still refreshed.
144
+ if (p.desktop) {
145
+ out.push(await mirrorKnownProject(cfg, p).catch((e) => ({
146
+ name: safeProjectName(p.name),
147
+ rootDir: p.rootDir,
148
+ targetDir: projectTargetDir(cfg, p.name),
149
+ problem: e.message,
150
+ })));
151
+ }
152
+ else
153
+ await removeProjectFolder(cfg, p);
154
+ }
99
155
  return out;
100
156
  }
157
+ async function removeProjectFolder(cfg, p) {
158
+ const dir = projectTargetDir(cfg, p.name);
159
+ const info = await stat(dir).catch(() => null);
160
+ if (!info?.isDirectory())
161
+ return false;
162
+ await rm(dir, { recursive: true, force: true });
163
+ return true;
164
+ }
101
165
  /** Removes every known project's folder from the Desktop. The switch going off. */
102
166
  export async function removeProjectMirrors(cfg) {
103
167
  const removed = [];
104
168
  for (const p of knownProjects(cfg)) {
105
- const dir = projectTargetDir(cfg, p.name);
106
- const info = await stat(dir).catch(() => null);
107
- if (!info?.isDirectory())
108
- continue;
109
- await rm(dir, { recursive: true, force: true });
110
- removed.push(dir);
169
+ if (await removeProjectFolder(cfg, p))
170
+ removed.push(projectTargetDir(cfg, p.name));
111
171
  }
112
172
  return removed;
113
173
  }
@@ -49,6 +49,55 @@ export const DEFAULT_IGNORE_DIRS = [
49
49
  ];
50
50
  export const DEFAULT_SEPARATOR = '--';
51
51
  export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
52
+ /** A selection that cannot be copied as asked. Thrown before the target folder is touched. */
53
+ export class MirrorSelectionError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = 'MirrorSelectionError';
57
+ }
58
+ }
59
+ /**
60
+ * Each include directory as a path under `rootDir`, looked for in `alsoUnder` when it is not there.
61
+ *
62
+ * `found` are the paths to copy; `missing` are the ones that are nowhere, with why. A directory found
63
+ * in a folder outside `rootDir` is missing too: its files could not be named as the project's.
64
+ */
65
+ export async function resolveIncludeDirs(rootDir, includeDirs, alsoUnder = []) {
66
+ const root = resolve(rootDir);
67
+ const found = [];
68
+ const moved = [];
69
+ const missing = [];
70
+ const isDir = async (p) => !!(await stat(p).catch(() => null))?.isDirectory();
71
+ for (const dir of includeDirs) {
72
+ const rel = normalizeDirPath(dir);
73
+ if (await isDir(join(root, rel))) {
74
+ found.push(rel);
75
+ continue;
76
+ }
77
+ let reason = `not a folder under the project root ${root}`;
78
+ let used = null;
79
+ for (const other of alsoUnder.filter((o) => o.trim() !== '')) {
80
+ const abs = resolve(other, rel);
81
+ if (!(await isDir(abs)))
82
+ continue;
83
+ const underRoot = relative(root, abs);
84
+ if (underRoot.startsWith('..') || resolve(root, underRoot) !== abs) {
85
+ reason = `found under ${resolve(other)}, which is not inside the project root ${root}: make the root that folder`;
86
+ continue;
87
+ }
88
+ used = underRoot.split(osSep).join('/') || '.';
89
+ break;
90
+ }
91
+ if (used) {
92
+ found.push(used);
93
+ moved.push({ asked: rel, used });
94
+ }
95
+ else {
96
+ missing.push({ dir: rel, reason });
97
+ }
98
+ }
99
+ return { found, moved, missing };
100
+ }
52
101
  /** `.\Src\Foo\` and `src/foo` are the same directory. This is the form both lists compare in. */
53
102
  export function normalizeDirPath(dir) {
54
103
  const cleaned = dir
@@ -293,7 +342,26 @@ export async function mirrorProject(cfg) {
293
342
  const targetDir = resolve(cfg.targetDir);
294
343
  const separator = cfg.separator ?? DEFAULT_SEPARATOR;
295
344
  const txtMode = cfg.txtMode ?? 'append';
296
- const { files, skipped } = await collectFiles(cfg);
345
+ /*
346
+ * Checked before the target folder is touched.
347
+ *
348
+ * The folder is brought in line with the selection, which deletes whatever the selection does
349
+ * not hold. A directory that could not be found made the selection empty, and every copy the
350
+ * chat had been working from was deleted (seen live on 2026-09-30: `rules-engine` not found,
351
+ * the whole context folder gone, totalBytes 0). A selection that is wrong is now an error that
352
+ * leaves the copies as they were; so is one that finds no file at all.
353
+ */
354
+ const dirs = await resolveIncludeDirs(rootDir, cfg.includeDirs, cfg.alsoUnder);
355
+ if (dirs.missing.length > 0) {
356
+ throw new MirrorSelectionError(`The project files could not be copied: ${dirs.missing.map((m) => `"${m.dir}" is ${m.reason}`).join('; ')}. ` +
357
+ `Nothing was copied, and the copies already in ${targetDir} were left as they are.`);
358
+ }
359
+ const { files, skipped } = await collectFiles({ ...cfg, includeDirs: dirs.found });
360
+ if (files.length === 0) {
361
+ throw new MirrorSelectionError(`The selection (${cfg.includeDirs.join(', ')}) holds no file that can be copied` +
362
+ `${skipped.length > 0 ? ` (${skipped.length} skipped, for example ${skipped[0].relPath}: ${skipped[0].reason})` : ''}. ` +
363
+ `Nothing was copied, and the copies already in ${targetDir} were left as they are.`);
364
+ }
297
365
  await mkdir(targetDir, { recursive: true });
298
366
  const used = new Set();
299
367
  const desired = new Set();
@@ -28,10 +28,65 @@ async function fileCheckPassesNow(check, cwd) {
28
28
  const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
29
29
  return { passes: exists && text.includes(check.value ?? ''), path };
30
30
  }
31
+ /** `git` with the global options that may come before its subcommand. */
32
+ const GIT = String.raw `\bgit(?:\s+(?:-C\s+(?:"[^"]*"|'[^']*'|\S+)|--no-pager|-c\s+\S+))*\s+`;
33
+ /** A check whose command asks which branch is checked out. */
34
+ export function readsCurrentBranch(check) {
35
+ return !!check.run && new RegExp(`${GIT}(?:branch\\s+--show-current|rev-parse\\s+--abbrev-ref\\s+HEAD|symbolic-ref\\s+(?:-q\\s+)?(?:--short\\s+)?HEAD|name-rev\\s+--name-only\\s+HEAD)`, 'i').test(check.run);
36
+ }
37
+ /**
38
+ * A check whose command asks whether the working tree is clean: `git status --porcelain`/`--short`,
39
+ * `git diff --quiet`/`--exit-code`. With version control on, the runner commits the task's changes
40
+ * after its checks — and the chat may not commit — so such a check is decided after that commit.
41
+ */
42
+ export function readsTreeClean(check) {
43
+ return !!check.run && new RegExp(`${GIT}(?:status\\b[^|;&\\n]*(?:--porcelain|--short|\\s-s\\b)|diff\\b[^|;&\\n]*(?:--quiet|--exit-code))`, 'i').test(check.run);
44
+ }
45
+ /**
46
+ * Whether a check about the checked-out branch can pass on `branch`, the one the runner chose.
47
+ * `null` when it cannot be told without running it.
48
+ */
49
+ function branchCheckPasses(check, branch) {
50
+ const value = check.value ?? '';
51
+ if (check.expect === 'output-contains')
52
+ return value === '' || branch.includes(value);
53
+ if (check.expect === 'output-omits')
54
+ return value === '' || !branch.includes(value);
55
+ if (check.expect === 'output-matches') {
56
+ try {
57
+ return new RegExp(value).test(branch);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ // A comparison written into the command: `(git branch --show-current) -eq 'recovery/x'`.
64
+ const named = [...(check.run ?? '').matchAll(/['"]([\w.-]+(?:\/[\w.-]+)+)['"]/g)].map((m) => m[1]);
65
+ if (named.length > 0)
66
+ return named.includes(branch);
67
+ return null;
68
+ }
31
69
  /** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
32
- export async function contractConflicts(task, cwd, repoDir) {
70
+ export async function contractConflicts(task, cwd, repoDir,
71
+ /** The branch version control put the task on, when it is on. */
72
+ vcs = {}) {
33
73
  const out = [];
34
74
  const scope = task.scope ?? [];
75
+ /*
76
+ * A check that the task is on a branch the runner did not choose can never pass: the chat may not
77
+ * switch branches. Seen on 2026-09-30, a check still expecting the recovery branch the session had
78
+ * been started from, while the runner worked on its own. Named before the start, with the check
79
+ * that does what was meant: that the earlier work is in this branch.
80
+ */
81
+ if (vcs.branch) {
82
+ const all = [...(task.checks ?? []), ...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check)];
83
+ for (const check of all.filter(readsCurrentBranch)) {
84
+ if (branchCheckPasses(check, vcs.branch) !== false)
85
+ continue;
86
+ out.push(`The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
87
+ 'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.');
88
+ }
89
+ }
35
90
  if (task.readOnly && scope.length > 0) {
36
91
  out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
37
92
  }