copilot-operator 0.1.23 → 0.1.26

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 (66) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +337 -33
  3. package/dist/src/exec/confinement.js +85 -7
  4. package/dist/src/exec/programs.js +12 -3
  5. package/dist/src/exec/reportFile.js +5 -3
  6. package/dist/src/exec/shells.js +13 -2
  7. package/dist/src/log/runLog.js +6 -1
  8. package/dist/src/orchestrator/contract.js +29 -7
  9. package/dist/src/orchestrator/review.js +16 -3
  10. package/dist/src/orchestrator/taskRunner.js +391 -54
  11. package/dist/src/plan/schema.js +11 -1
  12. package/dist/src/plan/systemGuide.js +12 -4
  13. package/dist/src/protocol/parser.js +33 -5
  14. package/dist/src/protocol/reporter.js +2 -0
  15. package/dist/src/session/compose.js +18 -9
  16. package/dist/src/session/exportRecord.js +12 -3
  17. package/dist/src/session/exports.js +46 -9
  18. package/dist/src/session/handoff.js +5 -2
  19. package/dist/src/session/interruption.js +3 -1
  20. package/dist/src/session/runLog.js +42 -1
  21. package/dist/src/session/runName.js +3 -1
  22. package/dist/src/session/store.js +7 -2
  23. package/dist/src/transport/copilotTransport.js +100 -74
  24. package/dist/src/transport/modelMatch.js +60 -0
  25. package/dist/src/vcs/artifacts.js +20 -7
  26. package/dist/src/vcs/commitHygiene.js +7 -2
  27. package/dist/src/vcs/contentIntegrity.js +65 -6
  28. package/dist/src/vcs/git.js +42 -7
  29. package/dist/src/vcs/prepareFromRemote.js +21 -14
  30. package/dist/src/vcs/remoteAuth.js +31 -0
  31. package/dist/src/vcs/runPreflight.js +100 -31
  32. package/dist/src/vcs/scope.js +18 -2
  33. package/dist/src/vcs/snapshot.js +61 -16
  34. package/dist/src/vcs/syncCommand.js +3 -11
  35. package/dist/src/vcs/taskVcs.js +173 -24
  36. package/dist/web/404.html +2 -2
  37. package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
  38. package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
  39. package/dist/web/_next/static/chunks/app/defaults/{page-0e778dafc69afbd3.js → page-17dd586d1d6bd068.js} +1 -1
  40. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
  41. package/dist/web/_next/static/chunks/app/sessions/view/{page-9b66b729748595bb.js → page-54ab3cb89b84679d.js} +1 -1
  42. package/dist/web/appearance.html +2 -2
  43. package/dist/web/appearance.txt +10 -10
  44. package/dist/web/defaults.html +2 -2
  45. package/dist/web/defaults.txt +10 -10
  46. package/dist/web/history.html +2 -2
  47. package/dist/web/history.txt +10 -10
  48. package/dist/web/import.html +2 -2
  49. package/dist/web/import.txt +10 -10
  50. package/dist/web/index.html +2 -2
  51. package/dist/web/index.txt +10 -10
  52. package/dist/web/level1.html +2 -2
  53. package/dist/web/level1.txt +10 -10
  54. package/dist/web/presets.html +2 -2
  55. package/dist/web/presets.txt +10 -10
  56. package/dist/web/sessions/view.html +2 -2
  57. package/dist/web/sessions/view.txt +10 -10
  58. package/dist/web/system.html +2 -2
  59. package/dist/web/system.txt +10 -10
  60. package/package.json +3 -2
  61. package/prompts/level1.md +10 -3
  62. package/dist/web/_next/static/chunks/549-df7a4876917615da.js +0 -1
  63. package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +0 -1
  64. package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
  65. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → jYvSWuPPywbxckIuz6cNs}/_buildManifest.js +0 -0
  66. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → jYvSWuPPywbxckIuz6cNs}/_ssgManifest.js +0 -0
@@ -502,7 +502,7 @@ let OperatorController = class OperatorController {
502
502
  }
503
503
  /** …and doing it, if the folder is still what the preview showed. */
504
504
  repoPrepare(body) {
505
- return this.ops.prepareProject(body?.dir ?? '', body?.fingerprint ?? '').catch(fail);
505
+ return this.ops.prepareProject(body?.dir ?? '', body?.fingerprint ?? '', { savedBranch: body?.savedBranch, savedMainBranch: body?.savedMainBranch }).catch(fail);
506
506
  }
507
507
  repo(dir) {
508
508
  const problem = this.ops.repoProblem(dir ?? '');
@@ -12,6 +12,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
12
12
  * the UI resolves), where events go (the bus, streamed as SSE), and how a run is stopped
13
13
  * (an AbortController per session).
14
14
  */
15
+ import { pageModelFor, sameModel } from '../transport/modelMatch.js';
15
16
  import { Injectable } from '@nestjs/common';
16
17
  import { readFile, readdir } from 'node:fs/promises';
17
18
  import { existsSync } from 'node:fs';
@@ -35,8 +36,8 @@ function sameFolder(a, b) {
35
36
  }
36
37
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
37
38
  import { readInterruption } from '../session/interruption.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';
39
+ import { branchAt, commitInterrupted, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
40
+ import { branchExists, branchNameFrom, changedFilesBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
40
41
  import { makeAuthorizer } from '../exec/authorizer.js';
41
42
  import { pickFolder } from './folderPicker.js';
42
43
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
@@ -47,10 +48,15 @@ import { composeHandoff } from '../session/handoff.js';
47
48
  import { planSync } from '../vcs/syncCommand.js';
48
49
  import { planPrepare, prepareFromRemote } from '../vcs/prepareFromRemote.js';
49
50
  import { askpassProgram } from '../vcs/remoteAuth.js';
50
- import { appendRunLog } from '../session/runLog.js';
51
+ import { appendRunLog, appendSessionLog } from '../session/runLog.js';
52
+ import { workingDirFor, isWorkingDirProblem } from '../exec/workDir.js';
53
+ import { contractConflicts } from '../orchestrator/contract.js';
54
+ import { checkRefusalForOperator, lineRefusal } from '../orchestrator/taskRunner.js';
55
+ import { preferredShell } from '../exec/shells.js';
56
+ import { sessionRoots } from '../exec/confinement.js';
51
57
  import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
52
58
  import { runVcsPreflight, runVcsPrepare } from '../vcs/runPreflight.js';
53
- import { normalisePatterns } from '../vcs/inputs.js';
59
+ import { inputSettings, normalisePatterns } from '../vcs/inputs.js';
54
60
  import { coveredByArtifacts } from '../vcs/artifacts.js';
55
61
  import { botVersion } from '../config/version.js';
56
62
  import { computeMetrics } from '../session/metrics.js';
@@ -497,11 +503,27 @@ let OperatorService = class OperatorService {
497
503
  */
498
504
  async updateTask(sessionId, taskId, patch) {
499
505
  await this.init();
500
- return await this.store.updateTask(sessionId, taskId, (t) => {
506
+ let before;
507
+ const task = await this.store.updateTask(sessionId, taskId, (t) => {
501
508
  if (t.status !== 'queued')
502
509
  throw new Error('Only a queued task can be edited.');
510
+ before = structuredClone(t);
503
511
  applyTaskPatch(t, patch);
504
512
  });
513
+ /*
514
+ * On record with the operator's other actions: a check or the prompt changed after the import is a
515
+ * different contract from the plan's, and the export showed only the new one (live run 2026-10-04).
516
+ */
517
+ const changed = Object.keys(patch).filter((k) => JSON.stringify(before?.[k]) !== JSON.stringify(task[k]));
518
+ if (changed.length > 0) {
519
+ const cfg = await this.settings.load();
520
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, {
521
+ type: 'task-edited',
522
+ message: `"${task.title}" edited while queued: ${changed.join(', ')} changed`,
523
+ data: { taskId, changed, before: Object.fromEntries(changed.map((k) => [k, before?.[k] ?? null])) },
524
+ }).catch(() => undefined);
525
+ }
526
+ return task;
505
527
  }
506
528
  async deleteTask(sessionId, taskId) {
507
529
  await this.init();
@@ -517,7 +539,16 @@ let OperatorService = class OperatorService {
517
539
  /** "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. */
518
540
  async continueTask(sessionId, taskId) {
519
541
  await this.init();
520
- const task = await this.store.continueTask(sessionId, taskId);
542
+ /*
543
+ * An attempt the operator stopped: which of its last steps ran, and what they printed, read from its
544
+ * run folder, so "Continue" can tell the chat. Without it the chat was told only that it had been
545
+ * stopped, and ran its inspection steps again (live run 2026-10-03).
546
+ */
547
+ const before = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
548
+ const cfg = await this.settings.load();
549
+ const stoppedAt = before?.status === 'aborted' && !before.interruption && before.runId ? await readInterruption(join(cfg.resolved.runsDir, before.runId)).catch(() => null) : null;
550
+ const task = await this.store.continueTask(sessionId, taskId, stoppedAt);
551
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'continue', message: `"${task.title}" queued to continue as attempt ${task.attempt ?? 1}`, data: { taskId, attempt: task.attempt } }).catch(() => undefined);
521
552
  this.bus.publish({
522
553
  sessionId,
523
554
  taskId,
@@ -538,7 +569,21 @@ let OperatorService = class OperatorService {
538
569
  await this.init();
539
570
  const before = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
540
571
  const buildsOn = opts.buildOnFinished && before?.status === 'done' ? { fromAttempt: before.attempt ?? 1 } : undefined;
572
+ /*
573
+ * A new prompt is a new change: the plan's commit subject was written for the old one, and two
574
+ * different commits went out under it (live run 2026-10-03). Dropped unless the edit names one.
575
+ */
576
+ // A correction added to the old prompt is not a new change, and keeps the subject (live run 2026-10-04).
577
+ const appended = !!before && !!patch.prompt && patch.prompt.trim().startsWith(before.prompt.trim().split('\n### Expected result')[0].trim());
578
+ if (patch.prompt?.trim() && before && patch.prompt.trim() !== before.prompt.trim() && !appended && before.vcsPlan?.commitMessage && !patch.vcsPlan?.commitMessage) {
579
+ patch = { ...patch, vcsPlan: { ...before.vcsPlan, ...(patch.vcsPlan ?? {}), commitMessage: undefined } };
580
+ }
541
581
  const task = await this.store.rerunTask(sessionId, taskId, patch, undefined, buildsOn);
582
+ {
583
+ const cfg = await this.settings.load();
584
+ const changed = Object.keys(patch).filter((k) => patch[k] !== undefined);
585
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'rerun', message: `"${task.title}" queued again as attempt ${task.attempt ?? 1}${buildsOn ? `, building on attempt ${buildsOn.fromAttempt}` : ''}${changed.length > 0 ? `, with ${changed.join(', ')} changed` : ''}`, data: { taskId, attempt: task.attempt, changed, ...(buildsOn ? { buildsOn } : {}) } }).catch(() => undefined);
586
+ }
542
587
  this.bus.publish({
543
588
  sessionId,
544
589
  taskId,
@@ -1014,9 +1059,11 @@ let OperatorService = class OperatorService {
1014
1059
  sessionId,
1015
1060
  type: 'upload-notice',
1016
1061
  level: 'info',
1017
- message: "every report of this run — each step's terminal output, the check results, the reviewer's transcripts — and any " +
1018
- "mirrored project files are uploaded into the Copilot chat and stored in the signed-in account's OneDrive and " +
1019
- 'Copilot history, inside the tenant; secret-shaped strings are redacted first',
1062
+ message:
1063
+ // Project files are no longer mirrored to the chat (removed in 0.1.14): only the reports go (live run 2026-10-03).
1064
+ "every report of this run — each step's terminal output, the check results, the reviewer's transcripts — is " +
1065
+ "uploaded into the Copilot chat and stored in the signed-in account's OneDrive and Copilot history, inside the " +
1066
+ 'tenant; secret-shaped strings are redacted first',
1020
1067
  });
1021
1068
  const done = runSession(sessionId, {
1022
1069
  cfg: { ...cfg, execution: { ...cfg.execution, mode } },
@@ -1036,10 +1083,32 @@ let OperatorService = class OperatorService {
1036
1083
  transport,
1037
1084
  runGroup,
1038
1085
  onlyTasks,
1039
- vcsGate: (s) => this.sessionVcsGate(s, onlyTasks),
1086
+ vcsGate: async (s) => {
1087
+ const refused = await this.sessionVcsGate(s, onlyTasks);
1088
+ // In the run's own log, where the export finds it: it was only in the live events (live run 2026-10-04).
1089
+ if (refused && runGroup) {
1090
+ await appendRunLog(cfg.resolved.runsDir, runGroup.id, [
1091
+ { at: new Date().toISOString(), type: 'run-preflight-refused', message: `refused at its turn: ${refused}`, data: { sessions: [s.id], atTurn: true } },
1092
+ ]).catch(() => undefined);
1093
+ }
1094
+ return refused;
1095
+ },
1040
1096
  ...(transport || !runGroup ? {} : { beforeBrowser: () => this.browserRequested(runGroup.id, [sessionId]) }),
1097
+ // The page is the source of the model names: Settings and the saved list follow it (see `ModelHooks`).
1098
+ models: {
1099
+ renamedDefault: async (which, from, to) => {
1100
+ const now = (await this.settings.load()).copilot;
1101
+ if (which === 'model' && (now.defaultModel ?? '').trim() === from)
1102
+ await this.setDefaultModel(to);
1103
+ if (which === 'review' && (now.defaultReviewModel ?? '').trim() === from)
1104
+ await this.setDefaultReviewModel(to);
1105
+ },
1106
+ seen: async (options, current) => {
1107
+ await this.store.saveModels({ options, current: current ?? undefined, readAt: new Date().toISOString() });
1108
+ },
1109
+ },
1041
1110
  })
1042
- .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { error: outcome.refused } : {}) }))
1111
+ .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) }))
1043
1112
  .catch(async (e) => {
1044
1113
  this.bus.publish({ sessionId, type: 'run-failed', level: 'error', message: e.message });
1045
1114
  return { ...(await this.tally(sessionId, queuedIds)), error: e.message };
@@ -1074,6 +1143,10 @@ let OperatorService = class OperatorService {
1074
1143
  continue;
1075
1144
  }
1076
1145
  tally.ran += 1;
1146
+ if (task.status === 'aborted') {
1147
+ tally.stopped = (tally.stopped ?? 0) + 1;
1148
+ continue;
1149
+ }
1077
1150
  if (task.status !== 'done') {
1078
1151
  tally.failed += 1;
1079
1152
  tally.failedTitles.push(task.title);
@@ -1096,7 +1169,8 @@ let OperatorService = class OperatorService {
1096
1169
  await this.store.updateSession(sessionId, (s) => {
1097
1170
  s.status = 'stopping';
1098
1171
  });
1099
- this.bus.publish({ sessionId, type: 'stop-requested', level: 'warn', message: 'stopping after the current step' });
1172
+ // The running step is cut off, not waited for: said as it is (live run 2026-10-03).
1173
+ this.bus.publish({ sessionId, type: 'stop-requested', level: 'warn', message: 'stopping now: nothing more is sent, and a step still running is cut off' });
1100
1174
  return { stopping: true };
1101
1175
  }
1102
1176
  // --- running several sessions in turn -------------------------------------------------
@@ -1486,7 +1560,12 @@ let OperatorService = class OperatorService {
1486
1560
  const tally = await begun.done;
1487
1561
  entry.ran = tally.ran;
1488
1562
  entry.failed = tally.failed;
1489
- if (tally.error) {
1563
+ if (tally.refused) {
1564
+ // Refused at its turn, before anything of it ran: not a failure of its tasks, which stay queued.
1565
+ entry.state = 'skipped';
1566
+ entry.reason = tally.refused;
1567
+ }
1568
+ else if (tally.error) {
1490
1569
  entry.state = 'failed';
1491
1570
  entry.reason = tally.error;
1492
1571
  }
@@ -1494,6 +1573,12 @@ let OperatorService = class OperatorService {
1494
1573
  entry.state = 'failed';
1495
1574
  entry.reason = `${tally.failed} task(s) did not finish: ${tally.failedTitles.join(', ')}`;
1496
1575
  }
1576
+ else if ((tally.stopped ?? 0) > 0) {
1577
+ entry.state = 'stopped';
1578
+ // The task cut short is counted as what it is: "after 0 task(s)" beside ran 1 read as if nothing ran (live run 2026-10-04).
1579
+ const finished = tally.ran - (tally.stopped ?? 0);
1580
+ entry.reason = `stopped by the operator: ${tally.stopped} task(s) cut short${finished > 0 ? `, after ${finished} that finished` : ''}; ${tally.leftQueued} still queued`;
1581
+ }
1497
1582
  else if (tally.paused) {
1498
1583
  entry.state = 'stopped';
1499
1584
  entry.reason = `paused after ${tally.ran} task(s); ${tally.leftQueued} still queued`;
@@ -1508,11 +1593,12 @@ let OperatorService = class OperatorService {
1508
1593
  this.bus.publish({
1509
1594
  sessionId: entry.sessionId,
1510
1595
  type: 'batch-session-finished',
1511
- level: entry.state === 'failed' ? 'warn' : 'info',
1512
- message: `this session ended ${entry.state} inside the batch`,
1596
+ level: entry.state === 'failed' || tally.refused ? 'warn' : 'info',
1597
+ message: tally.refused ? 'this session was refused at its turn inside the batch; its tasks stay queued' : `this session ended ${entry.state} inside the batch`,
1513
1598
  data: { batchId: batch.id, ran: entry.ran, failed: entry.failed },
1514
1599
  });
1515
- if (entry.state === 'failed' && batch.onFailure === 'stop') {
1600
+ // A session refused at its turn breaks a chain as a failed one does.
1601
+ if ((entry.state === 'failed' || tally.refused) && batch.onFailure === 'stop') {
1516
1602
  batch.stopping = true;
1517
1603
  this.bus.publish({
1518
1604
  sessionId: entry.sessionId,
@@ -1639,8 +1725,13 @@ let OperatorService = class OperatorService {
1639
1725
  */
1640
1726
  const repoIssues = [];
1641
1727
  check.plan.sessions.forEach((session, i) => {
1642
- if (session.vcs?.enabled === false)
1728
+ if (session.vcs?.enabled === false) {
1729
+ // Without version control the project folder must still be there (live run 2026-10-03: not looked at).
1730
+ const folder = (session.projectDir ?? '').trim();
1731
+ if (folder && !existsSync(folder))
1732
+ repoIssues.push({ path: `sessions[${i}].projectDir`, message: `${folder} does not exist. Name the session's project folder, or create it.` });
1643
1733
  return;
1734
+ }
1644
1735
  const dir = plannedRepoDir(session);
1645
1736
  const reason = repoUnusableReason(dir);
1646
1737
  if (reason)
@@ -1686,7 +1777,30 @@ let OperatorService = class OperatorService {
1686
1777
  }
1687
1778
  if (repoIssues.length > 0)
1688
1779
  return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1689
- return { ...check, duplicates: await this.findDuplicates(check.plan) };
1780
+ /*
1781
+ * A check the runner refuses for its own command line under these Settings: every start of its task
1782
+ * is refused for it, so it is said here, at the check, and not only at the start (live run 2026-10-04:
1783
+ * "check" said ok and the first start was refused). A warning, since the Settings may be changed.
1784
+ */
1785
+ const cfg = await this.settings.load();
1786
+ const shell = preferredShell(cfg.execution.defaultShell);
1787
+ const others = [cfg.project.rootDir, ...cfg.project.others.map((o) => o.rootDir)];
1788
+ const lineWarnings = [];
1789
+ check.plan.sessions.forEach((session, i) => {
1790
+ const cwd = (session.vcs?.enabled === false ? '' : plannedRepoDir(session)) || (session.projectDir ?? '').trim() || cfg.resolved.cwd;
1791
+ const roots = sessionRoots(cwd, others);
1792
+ session.tasks.forEach((task, j) => {
1793
+ for (const c of task.checks ?? []) {
1794
+ const run = c.run?.trim();
1795
+ if (!run)
1796
+ continue;
1797
+ const why = lineRefusal(run, c.shell ?? shell, c.cwd ? resolve(cwd, c.cwd) : cwd, cfg.execution, roots);
1798
+ if (why)
1799
+ lineWarnings.push(`sessions[${i}].tasks[${j}] ("${task.title}"): the check "${c.name}" is refused by the runner for its own command line, so the task cannot start until it is changed: ${checkRefusalForOperator(why)}.`);
1800
+ }
1801
+ });
1802
+ });
1803
+ return { ...check, warnings: [...check.warnings, ...lineWarnings], duplicates: await this.findDuplicates(check.plan) };
1690
1804
  }
1691
1805
  /**
1692
1806
  * Sessions the store already holds that this plan would create again, task for task.
@@ -1919,7 +2033,8 @@ let OperatorService = class OperatorService {
1919
2033
  const ids = g.sessions.map((x) => x.id);
1920
2034
  const first = ids[0] ? await this.store.getSession(ids[0]) : null;
1921
2035
  const start = first?.vcsStart;
1922
- const untouched = !!first && !first.tasks.some((t) => t.startedAt);
2036
+ // Said once, in the first run after the snapshot: a re-run clears `startedAt`, so earlier attempts count too.
2037
+ const untouched = !!first && !first.tasks.some((t) => t.startedAt || (t.attempts ?? []).some((a) => a.startedAt));
1923
2038
  if (start?.kind === 'snapshot' && start.snapshot?.approved && untouched) {
1924
2039
  note('baseline-created', `starting snapshot ${start.commit.slice(0, 8)} on ${start.branch ?? '?'}, approved ${start.snapshot.approvedAt ?? ''}${start.snapshot.onBase ? ` on top of "${start.snapshot.fromBranch}"` : ''}`, {
1925
2040
  repoDir: g.repoDir,
@@ -1936,7 +2051,9 @@ let OperatorService = class OperatorService {
1936
2051
  branch: g.branch,
1937
2052
  head: g.head,
1938
2053
  ...(g.baseBranch ? { baseBranch: g.baseBranch, baseHead: g.baseHead } : {}),
1939
- sessions: g.sessions.map((x) => ({ id: x.id, name: x.name, startFrom: x.startFrom, role: x.role })),
2054
+ ...(g.workBranch ? { workBranch: g.workBranch } : {}),
2055
+ ...(g.carry ? { carry: g.carry } : {}),
2056
+ sessions: g.sessions.map((x) => ({ id: x.id, name: x.name, startFrom: x.startFrom, role: x.role, started: x.started })),
1940
2057
  inputPatterns: g.inputPatterns,
1941
2058
  inputs: g.inputs.map((e) => e.path),
1942
2059
  unrelated: g.unrelated.map((u) => u.path),
@@ -1944,15 +2061,43 @@ let OperatorService = class OperatorService {
1944
2061
  }, ids, g.ready ? 'info' : 'warn');
1945
2062
  const fixes = g.actions.filter((a) => a.available && a.id !== 'review-inputs').map((a) => a.id);
1946
2063
  if (!g.ready && fixes.length > 0) {
1947
- note('snapshot-approval-required', `${g.repoDir}: nothing runs until one of these is pressed on the run screen: ${fixes.join(', ')}`, { repoDir: g.repoDir, actions: fixes }, ids, 'warn');
2064
+ const label = {
2065
+ 'snapshot-on-base': `Create starting snapshot on ${g.baseBranch ?? 'the base branch'}`,
2066
+ 'snapshot-here': `Create starting snapshot on ${g.branch ?? 'HEAD'}`,
2067
+ 'use-current-branch': `Use the current branch instead of ${g.baseBranch ?? 'the base branch'}`,
2068
+ 'allow-snapshot': 'Take uncommitted changes as a starting snapshot',
2069
+ };
2070
+ note('snapshot-approval-required', `${g.repoDir}: nothing runs until one of these is pressed under "Prepare version control for this run" on the Sessions page: ${fixes.map((f) => `"${label[f] ?? f}"`).join(', ')}`, { repoDir: g.repoDir, actions: fixes }, ids, 'warn');
1948
2071
  }
1949
2072
  }
1950
2073
  const open = groups.filter((g) => !g.ready);
1951
- const reason = open.length === 0
1952
- ? null
1953
- : 'Version control is not ready for this run, so nothing was opened or sent. ' +
1954
- open.map((g) => `${g.repoDir}: ${g.problem ?? 'it needs your approval'}`).join(' ') +
1955
- ' Fix it under "Prepare version control for this run" on the Sessions page.';
2074
+ // A task that contradicts itself, said here too: found at its start, it had already cut a branch and used an attempt.
2075
+ const contradictions = [];
2076
+ const reposBefore = new Set();
2077
+ for (const id of sessionIds) {
2078
+ const s = await this.store.getSession(id);
2079
+ const repo = s ? trackedRepoOf(s) : '';
2080
+ const afterOthers = !!repo && reposBefore.has(resolve(repo).toLowerCase());
2081
+ if (repo)
2082
+ reposBefore.add(resolve(repo).toLowerCase());
2083
+ const said = s ? await this.contractRefusal(s, onlyTasks, afterOthers) : null;
2084
+ if (said) {
2085
+ contradictions.push(said);
2086
+ // Said once here as what it is; the refusal below names it again with everything else in the way.
2087
+ note('task-contract-refused', said, { session: id, contract: true }, [id], 'warn');
2088
+ }
2089
+ }
2090
+ const parts = [
2091
+ ...(open.length > 0
2092
+ ? [
2093
+ 'Version control is not ready for this run, so nothing was opened or sent. ' +
2094
+ open.map((g) => `${g.repoDir}: ${g.problem ?? 'it needs your approval'}`).join(' ') +
2095
+ ' Fix it under "Prepare version control for this run" on the Sessions page.',
2096
+ ]
2097
+ : []),
2098
+ ...(contradictions.length > 0 ? [`${open.length > 0 ? '' : 'Nothing was opened or sent. '}${contradictions.join(' ')}`] : []),
2099
+ ];
2100
+ const reason = parts.length === 0 ? null : parts.join(' ');
1956
2101
  if (reason)
1957
2102
  note('run-preflight-refused', reason, { repositories: open.map((g) => g.repoDir) }, sessionIds, 'warn');
1958
2103
  else
@@ -1960,11 +2105,98 @@ let OperatorService = class OperatorService {
1960
2105
  await appendRunLog(cfg.resolved.runsDir, runId, log).catch(() => undefined);
1961
2106
  return reason;
1962
2107
  }
2108
+ /**
2109
+ * Why the session's next task cannot start because it contradicts itself — read-only and scoped, or
2110
+ * a file check that needs a change the task may not make — or null. The part of the contract that
2111
+ * depends on the branch the runner will choose is checked at the task's start, once that is known.
2112
+ */
2113
+ async contractRefusal(session, onlyTasks, afterOthers = false) {
2114
+ const next = queuedToRun(session, onlyTasks)[0];
2115
+ if (!next)
2116
+ return null;
2117
+ const cfg = await this.settings.load();
2118
+ const work = workingDirFor(session, cfg.resolved.cwd);
2119
+ if (isWorkingDirProblem(work))
2120
+ return null;
2121
+ /*
2122
+ * The branch the task will be on, where that is known before the run: so a check that expects another
2123
+ * branch is refused here too, not after the browser opened and a branch was cut (live run 2026-10-04).
2124
+ */
2125
+ const prefix = session.vcs?.branchPrefix || 'cop/';
2126
+ const attempt = next.attempt ?? 1;
2127
+ const branch = !session.vcs?.enabled
2128
+ ? undefined
2129
+ : session.vcs.startFrom === 'existing-branch'
2130
+ ? session.vcs.existingBranch?.trim() || undefined
2131
+ : session.vcs.branchMode === 'per-session'
2132
+ ? sessionBranchName(session)
2133
+ : next.vcsPlan?.branch?.trim()
2134
+ ? plannedBranchName(next.vcsPlan.branch, prefix, attempt)
2135
+ : branchNameFrom([session.name, next.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
2136
+ const files = await this.contractTree(session, afterOthers).catch(() => 'unknown');
2137
+ // Input files are carried onto a first start from their last capture: not judged before it (see `contract.ts`).
2138
+ const carried = !session.vcsBaseCommit ? inputSettings(session.vcs)?.patterns : undefined;
2139
+ const conflicts = await contractConflicts(next, work.cwd, repoDirOf(session) || work.cwd, { branch, files, carried }).catch(() => []);
2140
+ // A plan check the runner refuses for its own command line can never run: the task could never pass.
2141
+ const roots = sessionRoots(work.cwd, [cfg.project.rootDir, ...cfg.project.others.map((o) => o.rootDir)]);
2142
+ const shell = preferredShell(cfg.execution.defaultShell);
2143
+ for (const c of next.checks ?? []) {
2144
+ const run = c.run?.trim();
2145
+ if (!run)
2146
+ continue;
2147
+ const why = lineRefusal(run, c.shell ?? shell, c.cwd ? resolve(work.cwd, c.cwd) : work.cwd, cfg.execution, roots);
2148
+ if (why)
2149
+ conflicts.push(`The check "${c.name}" is refused by the runner for its own command line, so it can never run and the task could never pass: ${checkRefusalForOperator(why)}.`);
2150
+ }
2151
+ return conflicts.length === 0
2152
+ ? null
2153
+ : `The task "${next.title}" of "${session.name}" contradicts itself, so it was not started and stays queued: ${conflicts.join(' ')} Change the prompt, the checks, the scope or read-only on the session's page, and start again.`;
2154
+ }
2155
+ /**
2156
+ * Where the next task's file checks can be judged before the run: see `ContractTree`. The folder holds
2157
+ * the task's tree only when nothing is still to happen to it first — no update from a remote, no
2158
+ * earlier session of the batch in the same repository (`afterOthers`) — and otherwise the commit the
2159
+ * task starts from is read where it is already known, or the checks wait for the task's start.
2160
+ */
2161
+ async contractTree(session, afterOthers) {
2162
+ const repo = trackedRepoOf(session);
2163
+ if (!repo)
2164
+ return 'tree';
2165
+ if (session.vcsBaseCommit) {
2166
+ if (session.vcs?.branchMode !== 'per-session')
2167
+ return { ref: session.vcsBaseCommit };
2168
+ // A started session goes on from where its last task left its branch: the folder, when still on it.
2169
+ const last = [...session.tasks].reverse().find((t) => t.vcs?.branch)?.vcs?.branch;
2170
+ const now = await git(repo, ['branch', '--show-current']);
2171
+ return last && now.ok && now.stdout === last ? 'tree' : 'unknown';
2172
+ }
2173
+ if (session.vcsStart?.commit)
2174
+ return { ref: session.vcsStart.commit };
2175
+ const how = session.vcs?.startFrom ?? 'head';
2176
+ if (how === 'head')
2177
+ return afterOthers ? 'unknown' : 'tree';
2178
+ if (how === 'previous-session')
2179
+ return 'unknown';
2180
+ const branch = how === 'existing-branch' ? session.vcs?.existingBranch?.trim() : session.vcs?.baseBranch?.trim() || 'main';
2181
+ if (!branch)
2182
+ return 'unknown';
2183
+ // Brought up to its remote first: what that brings is known only then.
2184
+ if (session.vcs?.updateFromRemote !== false) {
2185
+ const remotes = await git(repo, ['remote']);
2186
+ if (!remotes.ok || remotes.stdout.trim() !== '')
2187
+ return 'unknown';
2188
+ }
2189
+ const tip = await git(repo, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
2190
+ return tip.ok && tip.stdout ? { ref: tip.stdout } : 'unknown';
2191
+ }
1963
2192
  /**
1964
2193
  * The same rule for one session that has not started, when its turn comes: see `RunDeps.vcsGate`.
1965
2194
  * A session that has started is past its first preparation; its tasks are prepared as they come.
1966
2195
  */
1967
2196
  async sessionVcsGate(session, onlyTasks) {
2197
+ const contradiction = await this.contractRefusal(session, onlyTasks);
2198
+ if (contradiction)
2199
+ return contradiction;
1968
2200
  if (!session.vcs?.enabled || session.vcsBaseCommit)
1969
2201
  return null;
1970
2202
  if (queuedToRun(session, onlyTasks).length === 0)
@@ -1972,7 +2204,9 @@ let OperatorService = class OperatorService {
1972
2204
  const [group] = await runVcsPreflight([session], () => this.store.listSessions());
1973
2205
  if (!group || group.ready)
1974
2206
  return null;
1975
- return `Version control is not ready for "${session.name}", so it did not start and its tasks stay queued: ${group.problem ?? 'it needs your approval'} Fix it under "Prepare version control for this run" on the Sessions page.`;
2207
+ // The panel is named only where it has a fix to offer: for a chain waiting on its predecessor the remedy is that one's Continue (live run 2026-10-04).
2208
+ const panel = group.actions.some((a) => a.available && a.id !== 'review-inputs');
2209
+ return `Version control is not ready for "${session.name}", so it did not start and its tasks stay queued: ${group.problem ?? 'it needs your approval'}${panel ? ' Fix it under "Prepare version control for this run" on the Sessions page.' : ''}`;
1976
2210
  }
1977
2211
  /** Recorded in the run's log just before the browser is opened for it: see `runPreflight`. */
1978
2212
  async browserRequested(runId, sessionIds) {
@@ -1997,6 +2231,15 @@ let OperatorService = class OperatorService {
1997
2231
  sessions.push(s);
1998
2232
  }
1999
2233
  const done = await runVcsPrepare(sessions, repoDir, action, choices && typeof choices === 'object' ? choices : {}, this.bus, async (id, mutate) => void (await this.store.updateSession(id, mutate)), () => this.store.listSessions());
2234
+ // Kept on every session the action was for, with what it did: see `appendSessionLog`.
2235
+ if (done.ok && action !== 'review-inputs') {
2236
+ const cfg = await this.settings.load();
2237
+ for (const s of sessions.filter((x) => normaliseDir(repoDirOf(x)) === normaliseDir(repoDir))) {
2238
+ const message = `run screen: "${action}" in ${repoDir}: ${done.result}`;
2239
+ await appendSessionLog(cfg.resolved.runsDir, s.id, { type: 'run-screen-action', message, data: { action, repoDir, before: { startFrom: s.vcs?.startFrom, baseBranch: s.vcs?.baseBranch } } }).catch(() => undefined);
2240
+ this.bus.publish({ sessionId: s.id, type: 'run-screen-action', level: 'info', message, data: { action, repoDir } });
2241
+ }
2242
+ }
2000
2243
  return done.ok ? { ok: true, result: done.result } : { ok: false, problem: done.problem };
2001
2244
  }
2002
2245
  /** Whether version control can do its job in this session, asked before a run. */
@@ -2062,7 +2305,30 @@ let OperatorService = class OperatorService {
2062
2305
  const refused = await this.restoreRefusal(session);
2063
2306
  if (refused)
2064
2307
  return { ok: false, problem: refused };
2065
- return await restoreToBase(session, task, this.bus);
2308
+ const done = await restoreToBase(session, task, this.bus);
2309
+ // On record with the operator's other actions: it made a branch and moved the checkout (live run 2026-10-04).
2310
+ if (done.ok) {
2311
+ const cfg = await this.settings.load();
2312
+ await appendSessionLog(cfg.resolved.runsDir, session.id, { type: 'restore', message: `restored the code as it was before "${task.title}" on ${done.branch} at ${(done.commit ?? '').slice(0, 8)}`, data: { taskId, branch: done.branch, commit: done.commit, leftBehind: done.leftBehind, keptOn: done.keptOn } }).catch(() => undefined);
2313
+ }
2314
+ /*
2315
+ * In per-session mode the session's one branch is where its next task goes, so a Restore moves the
2316
+ * session onto the restore branch, as "Run again from here" does. Without that the next run checked
2317
+ * the old session branch out again and the restore did not hold (live run 2026-10-03).
2318
+ */
2319
+ if (done.ok && done.branch && session.vcs?.branchMode === 'per-session' && session.vcs.startFrom !== 'existing-branch') {
2320
+ const left = sessionBranchName(session);
2321
+ await this.store.updateSession(session.id, (s) => {
2322
+ if (s.vcs) {
2323
+ s.vcs.branchName = done.branch;
2324
+ s.vcs.branchNameExact = true;
2325
+ }
2326
+ });
2327
+ this.bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-session-branch', level: 'info',
2328
+ message: `the session carries on from ${done.branch}, the code as it was before "${task.title}"; ${left} keeps the work that came after`,
2329
+ data: { branch: done.branch, left } });
2330
+ }
2331
+ return done;
2066
2332
  }
2067
2333
  /**
2068
2334
  * Why the repository of this session cannot be moved to another branch now, or null when it can.
@@ -2220,6 +2486,16 @@ let OperatorService = class OperatorService {
2220
2486
  keptOn: preview.keptOn,
2221
2487
  });
2222
2488
  }
2489
+ /*
2490
+ * The plan is "ok" only when the restart it describes can happen: a refused restore refuses the
2491
+ * restart, as `restartFrom` does and the dialog says, so a caller reading only `ok` is not told
2492
+ * otherwise (live run 2026-10-04: ok true beside its only restore refused).
2493
+ */
2494
+ const refusedRestores = plan.restores.filter((r) => !r.ok);
2495
+ if (plan.ok && refusedRestores.length > 0) {
2496
+ plan.ok = false;
2497
+ plan.problem = refusedRestores.map((r) => `${r.repoDir}: ${r.problem ?? 'the restore cannot be made'}`).join(' ');
2498
+ }
2223
2499
  return plan;
2224
2500
  }
2225
2501
  /**
@@ -2240,7 +2516,9 @@ let OperatorService = class OperatorService {
2240
2516
  if (this.batch?.running)
2241
2517
  return { ...idle, reason: 'A run is in progress. Stop it first.' };
2242
2518
  const plan = await this.restartPlan(sessionId, taskId);
2243
- if (!plan.ok)
2519
+ // A refused restore stops the restart only when the restore is asked for: `restore: false` queues without one.
2520
+ const onlyRestoreRefused = plan.tasks.length > 0 && plan.restores.some((r) => !r.ok);
2521
+ if (!plan.ok && !(opts.restore === false && onlyRestoreRefused))
2244
2522
  return { ...idle, reason: plan.problem };
2245
2523
  if (plan.sessions.some((s) => this.running.has(s.id))) {
2246
2524
  return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
@@ -2355,8 +2633,9 @@ let OperatorService = class OperatorService {
2355
2633
  taskId,
2356
2634
  type: 'run-restarted',
2357
2635
  level: 'info',
2358
- message: `starting again from "${plan.from.title}": ${plan.tasks.length} task(s) in ${plan.sessions.length} session(s)` +
2359
- (restored.length > 0 ? `, after taking ${restored.length} repository(ies) back` : ''),
2636
+ message: `${opts.start === false ? 'prepared to start again' : 'starting again'} from "${plan.from.title}": ${plan.tasks.length} task(s) in ${plan.sessions.length} session(s)` +
2637
+ (restored.length > 0 ? `, after taking ${restored.length} repository(ies) back` : '') +
2638
+ (opts.start === false ? '; press a run button to start' : ''),
2360
2639
  data: { requeued, restored },
2361
2640
  });
2362
2641
  if (opts.start === false)
@@ -2500,7 +2779,7 @@ let OperatorService = class OperatorService {
2500
2779
  * The second half: does it, if the folder is still what the preview showed. Refused while anything
2501
2780
  * runs, or is being started, in that folder or one around it: it moves the checkout.
2502
2781
  */
2503
- async prepareProject(dir, fingerprint) {
2782
+ async prepareProject(dir, fingerprint, names = {}) {
2504
2783
  await this.init();
2505
2784
  await this.knownProjectFolder(dir, 'it is not prepared from here');
2506
2785
  if (this.batch?.running)
@@ -2510,7 +2789,18 @@ let OperatorService = class OperatorService {
2510
2789
  const other = await this.runningIn(dir);
2511
2790
  if (other)
2512
2791
  return { ok: false, problem: `Session "${other.name}" is running in this folder. Stop it before preparing the folder; nothing was changed.` };
2513
- return await prepareFromRemote(resolve(dir.trim()), String(fingerprint ?? ''));
2792
+ const done = await prepareFromRemote(resolve(dir.trim()), String(fingerprint ?? ''), { names });
2793
+ // Kept on every session working in that folder: it moved the checkout and made saved branches.
2794
+ if (done.ok) {
2795
+ const cfg = await this.settings.load();
2796
+ const here = normaliseDir(dir);
2797
+ for (const s of (await this.store.listSessions()).filter((x) => normaliseDir(repoDirOf(x)) === here)) {
2798
+ const message = `the folder was prepared from ${done.target}: ${done.result}`;
2799
+ await appendSessionLog(cfg.resolved.runsDir, s.id, { type: 'folder-prepared', message, data: { target: done.target, commit: done.commit, branch: done.branch, saved: done.saved, removedFromFolder: done.removedFromFolder } }).catch(() => undefined);
2800
+ this.bus.publish({ sessionId: s.id, type: 'folder-prepared', level: 'info', message, data: { target: done.target, commit: done.commit, saved: done.saved } });
2801
+ }
2802
+ }
2803
+ return done;
2514
2804
  }
2515
2805
  // --- the project being worked on ---------------------------------------------------------
2516
2806
  /**
@@ -2738,6 +3028,16 @@ let OperatorService = class OperatorService {
2738
3028
  const { options, current, note } = await transport.listModels();
2739
3029
  const catalogue = { options, current: current ?? undefined, readAt: new Date().toISOString(), note };
2740
3030
  await this.store.saveModels(catalogue);
3031
+ // A saved choice the page now offers under another name follows it here, as a run does (see `modelMatch.ts`).
3032
+ const saved = cfg.copilot;
3033
+ const work = (saved.defaultModel ?? '').trim();
3034
+ const review = (saved.defaultReviewModel ?? '').trim();
3035
+ const workNow = work ? pageModelFor(work, options) : null;
3036
+ const reviewNow = review ? pageModelFor(review, options) : null;
3037
+ if (workNow && !sameModel(workNow.name, work))
3038
+ await this.setDefaultModel(workNow.name);
3039
+ if (reviewNow && !sameModel(reviewNow.name, review))
3040
+ await this.setDefaultReviewModel(reviewNow.name);
2741
3041
  return catalogue;
2742
3042
  }
2743
3043
  finally {
@@ -2822,6 +3122,10 @@ let OperatorService = class OperatorService {
2822
3122
  reason: a.reason,
2823
3123
  runId: a.runId,
2824
3124
  branch: a.vcs?.branch,
3125
+ // Why the runner stopped this attempt, whether it was a fresh-chat retry, and its commit (live run 2026-10-03).
3126
+ stopCode: a.stopCode,
3127
+ freshRetry: a.freshRetry,
3128
+ commit: a.vcs?.commit,
2825
3129
  changedFiles: a.vcs?.commit && a.vcs.baseCommit ? (a.vcs.files?.length ?? 0) : undefined,
2826
3130
  };
2827
3131
  }),