copilot-operator 0.1.20 → 0.1.23

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 (44) hide show
  1. package/dist/src/api/operator.controller.js +49 -0
  2. package/dist/src/api/operator.service.js +181 -7
  3. package/dist/src/orchestrator/taskRunner.js +14 -2
  4. package/dist/src/plan/systemGuide.js +143 -15
  5. package/dist/src/session/exports.js +70 -1
  6. package/dist/src/session/runLog.js +60 -0
  7. package/dist/src/vcs/git.js +15 -3
  8. package/dist/src/vcs/inputs.js +26 -2
  9. package/dist/src/vcs/prepareFromRemote.js +293 -0
  10. package/dist/src/vcs/remoteAuth.js +61 -0
  11. package/dist/src/vcs/runPreflight.js +286 -0
  12. package/dist/src/vcs/taskVcs.js +42 -6
  13. package/dist/web/404.html +2 -2
  14. package/dist/web/_next/static/chunks/549-df7a4876917615da.js +1 -0
  15. package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +1 -0
  16. package/dist/web/_next/static/chunks/app/defaults/page-0e778dafc69afbd3.js +1 -0
  17. package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +1 -0
  18. package/dist/web/_next/static/chunks/app/sessions/view/page-9b66b729748595bb.js +1 -0
  19. package/dist/web/appearance.html +2 -2
  20. package/dist/web/appearance.txt +10 -10
  21. package/dist/web/defaults.html +2 -2
  22. package/dist/web/defaults.txt +10 -10
  23. package/dist/web/history.html +2 -2
  24. package/dist/web/history.txt +10 -10
  25. package/dist/web/import.html +2 -2
  26. package/dist/web/import.txt +10 -10
  27. package/dist/web/index.html +2 -2
  28. package/dist/web/index.txt +10 -10
  29. package/dist/web/level1.html +2 -2
  30. package/dist/web/level1.txt +10 -10
  31. package/dist/web/presets.html +2 -2
  32. package/dist/web/presets.txt +10 -10
  33. package/dist/web/sessions/view.html +2 -2
  34. package/dist/web/sessions/view.txt +10 -10
  35. package/dist/web/system.html +2 -2
  36. package/dist/web/system.txt +10 -10
  37. package/package.json +150 -148
  38. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +0 -1
  39. package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +0 -1
  40. package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.js +0 -1
  41. package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +0 -1
  42. package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +0 -1
  43. /package/dist/web/_next/static/{P0lpNIJvdSO3yuWRpIPQf → -dZtu-yqxc-HXzYTmqYSc}/_buildManifest.js +0 -0
  44. /package/dist/web/_next/static/{P0lpNIJvdSO3yuWRpIPQf → -dZtu-yqxc-HXzYTmqYSc}/_ssgManifest.js +0 -0
@@ -496,6 +496,14 @@ let OperatorController = class OperatorController {
496
496
  repoSync(dir) {
497
497
  return this.ops.syncPlan(dir ?? '').catch(fail);
498
498
  }
499
+ /** "Prepare the folder from the remote main branch": what it would do (fetches, changes nothing else). */
500
+ repoPreparePreview(body) {
501
+ return this.ops.preparePreview(body?.dir ?? '', body?.fetch !== false).catch(fail);
502
+ }
503
+ /** …and doing it, if the folder is still what the preview showed. */
504
+ repoPrepare(body) {
505
+ return this.ops.prepareProject(body?.dir ?? '', body?.fingerprint ?? '').catch(fail);
506
+ }
499
507
  repo(dir) {
500
508
  const problem = this.ops.repoProblem(dir ?? '');
501
509
  return problem ? { ok: false, problem } : { ok: true };
@@ -504,6 +512,18 @@ let OperatorController = class OperatorController {
504
512
  vcsStatus(id) {
505
513
  return this.ops.vcsStatus(id).catch(fail);
506
514
  }
515
+ /** "Prepare version control for this run": one group per repository of the sessions given. */
516
+ runVcs(ids, tasks) {
517
+ const only = (tasks ?? '').split(',').map((t) => t.trim()).filter(Boolean);
518
+ return this.ops.runVcs((ids ?? '').split(',').map((s) => s.trim()).filter(Boolean), only.length > 0 ? new Set(only) : undefined).catch(fail);
519
+ }
520
+ /** One action of that panel, for one repository. */
521
+ runVcsPrepare(body) {
522
+ const actions = ['review-inputs', 'snapshot-on-base', 'snapshot-here', 'use-current-branch', 'allow-snapshot'];
523
+ if (!actions.includes(body?.action ?? ''))
524
+ return Promise.resolve({ ok: false, problem: 'unknown action' });
525
+ return this.ops.runVcsPrepare(body?.sessionIds ?? [], body?.repoDir ?? '', body.action, body?.choices ?? {}).catch(fail);
526
+ }
507
527
  /** The operator's approval of the starting snapshot: one choice per file of the list they saw. */
508
528
  vcsSnapshot(id, body) {
509
529
  return this.ops.vcsSnapshot(id, body?.choices ?? {}).catch(fail);
@@ -1028,6 +1048,20 @@ __decorate([
1028
1048
  __metadata("design:paramtypes", [String]),
1029
1049
  __metadata("design:returntype", Promise)
1030
1050
  ], OperatorController.prototype, "repoSync", null);
1051
+ __decorate([
1052
+ Post('repo/prepare/preview'),
1053
+ __param(0, Body()),
1054
+ __metadata("design:type", Function),
1055
+ __metadata("design:paramtypes", [Object]),
1056
+ __metadata("design:returntype", Promise)
1057
+ ], OperatorController.prototype, "repoPreparePreview", null);
1058
+ __decorate([
1059
+ Post('repo/prepare'),
1060
+ __param(0, Body()),
1061
+ __metadata("design:type", Function),
1062
+ __metadata("design:paramtypes", [Object]),
1063
+ __metadata("design:returntype", Promise)
1064
+ ], OperatorController.prototype, "repoPrepare", null);
1031
1065
  __decorate([
1032
1066
  Get('repo'),
1033
1067
  __param(0, Query('dir')),
@@ -1042,6 +1076,21 @@ __decorate([
1042
1076
  __metadata("design:paramtypes", [String]),
1043
1077
  __metadata("design:returntype", Promise)
1044
1078
  ], OperatorController.prototype, "vcsStatus", null);
1079
+ __decorate([
1080
+ Get('batch/vcs'),
1081
+ __param(0, Query('ids')),
1082
+ __param(1, Query('tasks')),
1083
+ __metadata("design:type", Function),
1084
+ __metadata("design:paramtypes", [String, String]),
1085
+ __metadata("design:returntype", Promise)
1086
+ ], OperatorController.prototype, "runVcs", null);
1087
+ __decorate([
1088
+ Post('batch/vcs/prepare'),
1089
+ __param(0, Body()),
1090
+ __metadata("design:type", Function),
1091
+ __metadata("design:paramtypes", [Object]),
1092
+ __metadata("design:returntype", Promise)
1093
+ ], OperatorController.prototype, "runVcsPrepare", null);
1045
1094
  __decorate([
1046
1095
  Post('sessions/:id/vcs/snapshot'),
1047
1096
  __param(0, Param('id')),
@@ -45,7 +45,11 @@ import { unattendedPrecondition } from '../exec/policy.js';
45
45
  import { createTransport } from '../transport/chatTransport.js';
46
46
  import { composeHandoff } from '../session/handoff.js';
47
47
  import { planSync } from '../vcs/syncCommand.js';
48
+ import { planPrepare, prepareFromRemote } from '../vcs/prepareFromRemote.js';
49
+ import { askpassProgram } from '../vcs/remoteAuth.js';
50
+ import { appendRunLog } from '../session/runLog.js';
48
51
  import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
52
+ import { runVcsPreflight, runVcsPrepare } from '../vcs/runPreflight.js';
49
53
  import { normalisePatterns } from '../vcs/inputs.js';
50
54
  import { coveredByArtifacts } from '../vcs/artifacts.js';
51
55
  import { botVersion } from '../config/version.js';
@@ -917,6 +921,12 @@ let OperatorService = class OperatorService {
917
921
  // A run of one is still a run, and it records the same thing a batch does, so that going
918
922
  // back and starting again from a task works the same whether one session was started or six.
919
923
  // Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
924
+ // Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
925
+ const notReady = await this.runPreflight(runGroup.id, [sessionId]);
926
+ if (notReady) {
927
+ this.releaseBrowser(claim.holder);
928
+ return { started: false, reason: notReady };
929
+ }
920
930
  const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
921
931
  if (!begun.started) {
922
932
  this.releaseBrowser(claim.holder);
@@ -1026,8 +1036,10 @@ let OperatorService = class OperatorService {
1026
1036
  transport,
1027
1037
  runGroup,
1028
1038
  onlyTasks,
1039
+ vcsGate: (s) => this.sessionVcsGate(s, onlyTasks),
1040
+ ...(transport || !runGroup ? {} : { beforeBrowser: () => this.browserRequested(runGroup.id, [sessionId]) }),
1029
1041
  })
1030
- .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused }))
1042
+ .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { error: outcome.refused } : {}) }))
1031
1043
  .catch(async (e) => {
1032
1044
  this.bus.publish({ sessionId, type: 'run-failed', level: 'error', message: e.message });
1033
1045
  return { ...(await this.tally(sessionId, queuedIds)), error: e.message };
@@ -1097,7 +1109,12 @@ let OperatorService = class OperatorService {
1097
1109
  * directory walk every few seconds, on every page, to light up a dot would be a bad trade.
1098
1110
  */
1099
1111
  activity() {
1100
- return { running: this.running.size > 0, sessions: this.running.size, batch: this.batch?.running === true };
1112
+ /*
1113
+ * A run being started — its version control checked before the browser opens — is not running yet
1114
+ * and not idle either: `starting` says so, so "nothing is happening" is not read in that moment.
1115
+ */
1116
+ const starting = this.running.size === 0 && !this.batch?.running && (this.browser?.kind === 'run' || this.browser?.kind === 'batch');
1117
+ return { running: this.running.size > 0, sessions: this.running.size, batch: this.batch?.running === true, starting };
1101
1118
  }
1102
1119
  /** The batch in progress, or the last one that ran, or null if none ever has. */
1103
1120
  batchState() {
@@ -1156,7 +1173,13 @@ let OperatorService = class OperatorService {
1156
1173
  await this.init();
1157
1174
  if (wanted.length === 0)
1158
1175
  return { started: false, reason: 'no sessions were selected' };
1159
- const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name);
1176
+ // Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
1177
+ // The batch's id first, so the preflight is recorded under the run it decides about.
1178
+ const batchId = newId('b-');
1179
+ const notReady = await this.runPreflight(batchId, wanted, onlyTasks);
1180
+ if (notReady)
1181
+ return { started: false, reason: notReady };
1182
+ const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name, batchId);
1160
1183
  if (!begun.batch)
1161
1184
  return { started: false, reason: begun.reason };
1162
1185
  this.batch = begun.batch;
@@ -1188,7 +1211,9 @@ let OperatorService = class OperatorService {
1188
1211
  * an unattended run may begin, and the models the run panel chose. A refusal comes back as the
1189
1212
  * reason; nothing is started here.
1190
1213
  */
1191
- async prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name) {
1214
+ async prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name,
1215
+ /** The id its preflight was recorded under; a new one when there was none. */
1216
+ id = newId('b-')) {
1192
1217
  // Worked out here rather than asked of the operator again: every way into this — the run
1193
1218
  // panel, continuing after a failure, "Run again from here" — either has a field they may
1194
1219
  // have left empty or has no field at all.
@@ -1252,7 +1277,7 @@ let OperatorService = class OperatorService {
1252
1277
  }
1253
1278
  return {
1254
1279
  batch: {
1255
- id: newId('b-'),
1280
+ id,
1256
1281
  startedAt: new Date().toISOString(),
1257
1282
  name: chosenName,
1258
1283
  ...(onlyTasks ? { onlyTasks: [...onlyTasks] } : {}),
@@ -1355,6 +1380,7 @@ let OperatorService = class OperatorService {
1355
1380
  let browser = null;
1356
1381
  try {
1357
1382
  const cfg = await this.settings.load();
1383
+ await this.browserRequested(batch.id, batch.sessions.filter((x) => x.state === 'waiting').map((x) => x.sessionId));
1358
1384
  browser = await openBrowser(cfg, this.bus, join(cfg.resolved.runsDir, '_browser'), batch.id);
1359
1385
  this.bus.publish({
1360
1386
  sessionId: batch.sessions[0]?.sessionId ?? batch.id,
@@ -1855,6 +1881,124 @@ let OperatorService = class OperatorService {
1855
1881
  return this.bus.subscribe(sessionId, handler);
1856
1882
  }
1857
1883
  // --- version control ----------------------------------------------------------------------
1884
+ /**
1885
+ * Version control for the sessions of a run, one group per repository, with what can be done about
1886
+ * it from the run screen. See `vcs/runPreflight.ts`. Only sessions that have something queued count.
1887
+ */
1888
+ async runVcs(sessionIds, onlyTasks) {
1889
+ await this.init();
1890
+ const sessions = [];
1891
+ for (const id of sessionIds) {
1892
+ const s = await this.store.getSession(id);
1893
+ if (s && queuedToRun(s, onlyTasks).length > 0)
1894
+ sessions.push(s);
1895
+ }
1896
+ return await runVcsPreflight(sessions, () => this.store.listSessions());
1897
+ }
1898
+ /**
1899
+ * The run's version control preflight: why the run may not start, said before anything opens the
1900
+ * browser, creates a task attempt or emits `task-started`; null when it may.
1901
+ *
1902
+ * Recorded as it happens, in the run's own log (`session/runLog.ts`) and on each session's event
1903
+ * stream: `run-preflight-started`, one `repository-preflight` per repository, `baseline-created` for
1904
+ * a starting snapshot taken on the run screen, `snapshot-approval-required` where the operator has a
1905
+ * fix to press, then `run-preflight-passed` or `run-preflight-refused`. The browser is asked for
1906
+ * only after a pass (`browser-launch-requested`), so the export can show the order.
1907
+ */
1908
+ async runPreflight(runId, sessionIds, onlyTasks) {
1909
+ const cfg = await this.settings.load();
1910
+ const log = [];
1911
+ const note = (type, message, data, ids, level = 'info') => {
1912
+ log.push({ at: new Date().toISOString(), type, message, data });
1913
+ for (const id of ids)
1914
+ this.bus.publish({ sessionId: id, type, level, message, data: { runId, ...data } });
1915
+ };
1916
+ note('run-preflight-started', `version control preflight for ${sessionIds.length} session(s), before the browser opens`, { sessions: sessionIds }, sessionIds);
1917
+ const groups = await this.runVcs(sessionIds, onlyTasks);
1918
+ for (const g of groups) {
1919
+ const ids = g.sessions.map((x) => x.id);
1920
+ const first = ids[0] ? await this.store.getSession(ids[0]) : null;
1921
+ const start = first?.vcsStart;
1922
+ const untouched = !!first && !first.tasks.some((t) => t.startedAt);
1923
+ if (start?.kind === 'snapshot' && start.snapshot?.approved && untouched) {
1924
+ 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
+ repoDir: g.repoDir,
1926
+ commit: start.commit,
1927
+ branch: start.branch,
1928
+ approvedAt: start.snapshot.approvedAt,
1929
+ onBase: !!start.snapshot.onBase,
1930
+ included: start.snapshot.included.length,
1931
+ }, ids);
1932
+ }
1933
+ note('repository-preflight', `${g.repoDir}: ${g.ready ? 'ready' : g.problem ?? 'needs your approval'}`, {
1934
+ repoDir: g.repoDir,
1935
+ ready: g.ready,
1936
+ branch: g.branch,
1937
+ head: g.head,
1938
+ ...(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 })),
1940
+ inputPatterns: g.inputPatterns,
1941
+ inputs: g.inputs.map((e) => e.path),
1942
+ unrelated: g.unrelated.map((u) => u.path),
1943
+ ...(g.problem ? { problem: g.problem } : {}),
1944
+ }, ids, g.ready ? 'info' : 'warn');
1945
+ const fixes = g.actions.filter((a) => a.available && a.id !== 'review-inputs').map((a) => a.id);
1946
+ 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');
1948
+ }
1949
+ }
1950
+ 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.';
1956
+ if (reason)
1957
+ note('run-preflight-refused', reason, { repositories: open.map((g) => g.repoDir) }, sessionIds, 'warn');
1958
+ else
1959
+ note('run-preflight-passed', `version control is ready in ${groups.length} repositor${groups.length === 1 ? 'y' : 'ies'}; the browser may open`, { repositories: groups.map((g) => g.repoDir) }, sessionIds);
1960
+ await appendRunLog(cfg.resolved.runsDir, runId, log).catch(() => undefined);
1961
+ return reason;
1962
+ }
1963
+ /**
1964
+ * The same rule for one session that has not started, when its turn comes: see `RunDeps.vcsGate`.
1965
+ * A session that has started is past its first preparation; its tasks are prepared as they come.
1966
+ */
1967
+ async sessionVcsGate(session, onlyTasks) {
1968
+ if (!session.vcs?.enabled || session.vcsBaseCommit)
1969
+ return null;
1970
+ if (queuedToRun(session, onlyTasks).length === 0)
1971
+ return null;
1972
+ const [group] = await runVcsPreflight([session], () => this.store.listSessions());
1973
+ if (!group || group.ready)
1974
+ 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.`;
1976
+ }
1977
+ /** Recorded in the run's log just before the browser is opened for it: see `runPreflight`. */
1978
+ async browserRequested(runId, sessionIds) {
1979
+ const cfg = await this.settings.load();
1980
+ const entry = { at: new Date().toISOString(), type: 'browser-launch-requested', message: 'the version control preflight passed; opening the browser', data: { sessions: sessionIds } };
1981
+ for (const id of sessionIds)
1982
+ this.bus.publish({ sessionId: id, type: entry.type, level: 'info', message: entry.message, data: { runId, ...entry.data } });
1983
+ await appendRunLog(cfg.resolved.runsDir, runId, [entry]).catch(() => undefined);
1984
+ }
1985
+ /** One action of "Prepare version control for this run". Refused while anything runs in that repository. */
1986
+ async runVcsPrepare(sessionIds, repoDir, action, choices) {
1987
+ await this.init();
1988
+ if (this.batch?.running)
1989
+ return { ok: false, problem: 'A run is going. Stop it before preparing version control.' };
1990
+ const other = await this.runningIn(repoDir);
1991
+ if (other)
1992
+ return { ok: false, problem: `Session "${other.name}" is running in this repository. Stop it first.` };
1993
+ const sessions = [];
1994
+ for (const id of sessionIds) {
1995
+ const s = await this.store.getSession(id);
1996
+ if (s && queuedToRun(s).length > 0)
1997
+ sessions.push(s);
1998
+ }
1999
+ 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());
2000
+ return done.ok ? { ok: true, result: done.result } : { ok: false, problem: done.problem };
2001
+ }
1858
2002
  /** Whether version control can do its job in this session, asked before a run. */
1859
2003
  async vcsStatus(sessionId) {
1860
2004
  await this.init();
@@ -2328,15 +2472,45 @@ let OperatorService = class OperatorService {
2328
2472
  */
2329
2473
  async syncPlan(dir) {
2330
2474
  await this.init();
2475
+ await this.knownProjectFolder(dir, 'no command is offered for it');
2476
+ return await planSync(resolve(dir.trim()));
2477
+ }
2478
+ /** Throws unless the folder is a project in Settings or the repository of a session. */
2479
+ async knownProjectFolder(dir, otherwise) {
2331
2480
  const wanted = normaliseDir(dir);
2332
2481
  const cfg = await this.settings.load();
2333
2482
  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 ?? ''])]
2334
2483
  .filter((d) => d.trim())
2335
2484
  .map(normaliseDir));
2336
2485
  if (!wanted || !known.has(wanted)) {
2337
- throw new Error('That folder is not one of the projects in Settings or the repository of a session, so no command is offered for it.');
2486
+ throw new Error(`That folder is not one of the projects in Settings or the repository of a session, so ${otherwise}.`);
2338
2487
  }
2339
- return await planSync(resolve(dir.trim()));
2488
+ }
2489
+ /**
2490
+ * "Prepare the folder from the remote main branch", first half: fetches and says what would be
2491
+ * kept where and what the folder would be. See `vcs/prepareFromRemote.ts`.
2492
+ */
2493
+ async preparePreview(dir, fetch = true) {
2494
+ await this.init();
2495
+ await this.knownProjectFolder(dir, 'it is not prepared from here');
2496
+ // The operator pressed the button and is looking: the remote may ask for a password through Git's own window.
2497
+ return await planPrepare(resolve(dir.trim()), { fetch, askpass: fetch ? await askpassProgram() : null });
2498
+ }
2499
+ /**
2500
+ * The second half: does it, if the folder is still what the preview showed. Refused while anything
2501
+ * runs, or is being started, in that folder or one around it: it moves the checkout.
2502
+ */
2503
+ async prepareProject(dir, fingerprint) {
2504
+ await this.init();
2505
+ await this.knownProjectFolder(dir, 'it is not prepared from here');
2506
+ if (this.batch?.running)
2507
+ return { ok: false, problem: 'A run is going. Stop it before preparing the folder; nothing was changed.' };
2508
+ if (this.browser?.kind === 'run' || this.browser?.kind === 'batch')
2509
+ return { ok: false, problem: 'A run is being started. Wait for it, or stop it, before preparing the folder; nothing was changed.' };
2510
+ const other = await this.runningIn(dir);
2511
+ if (other)
2512
+ 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 ?? ''));
2340
2514
  }
2341
2515
  // --- the project being worked on ---------------------------------------------------------
2342
2516
  /**
@@ -630,11 +630,16 @@ export async function runTask(transport, session, task, deps) {
630
630
  */
631
631
  const patterns = artifactPatterns(session.vcs);
632
632
  const projectRoot = repoDirOf(session) || session.projectDir?.trim() || '';
633
- if ((patterns.length > 0 || (task.outputs?.length ?? 0) > 0) && projectRoot) {
633
+ // Only after the task started: one refused before it ran made nothing, and what is there is not its own.
634
+ if ((patterns.length > 0 || (task.outputs?.length ?? 0) > 0) && projectRoot && artifactsBefore) {
634
635
  // Only what this task made or changed, inside its scope, or declared as its outputs.
635
636
  const kept = await keepArtifacts(projectRoot, patterns, join(artifactsDir, 'project'), { before: artifactsBefore, scope: task.scope, outputs: task.outputs })
636
637
  .catch((e) => ({ kept: [], skipped: [`(all: ${e.message})`], unchanged: 0, outsideScope: [] }));
637
- sink.event('artifacts-kept', { kept: kept.kept.length, skipped: kept.skipped, unchanged: kept.unchanged, outsideScope: kept.outsideScope.slice(0, 20) }, `artifacts kept with the run: ${kept.kept.length} file(s) this task made or changed` +
638
+ /*
639
+ * `artifacts-kept` only when something was: files that were there before and untouched are not this
640
+ * task's evidence, and an event of that name with nothing in it read as if they had been collected.
641
+ */
642
+ sink.event(kept.kept.length > 0 || kept.skipped.length > 0 ? 'artifacts-kept' : 'artifacts-none', { kept: kept.kept.length, skipped: kept.skipped, unchanged: kept.unchanged, outsideScope: kept.outsideScope.slice(0, 20) }, `artifacts kept with the run: ${kept.kept.length} file(s) this task made or changed` +
638
643
  `${kept.unchanged > 0 ? `; ${kept.unchanged} already there and untouched, not kept` : ''}` +
639
644
  `${kept.outsideScope.length > 0 ? `; ${kept.outsideScope.length} outside the task's scope, not kept` : ''}` +
640
645
  `${kept.skipped.length > 0 ? `; not kept: ${kept.skipped.slice(0, 5).join('; ')}` : ''}`, kept.skipped.length > 0 ? 'warn' : 'info');
@@ -2226,6 +2231,12 @@ export async function runSession(sessionId, deps) {
2226
2231
  bus.publish({ sessionId, type: 'session-idle', level: 'info', message: 'no queued tasks' });
2227
2232
  return { ran: 0, paused: false };
2228
2233
  }
2234
+ // Version control before anything else: a session that may not start is refused with its tasks queued.
2235
+ const refused = deps.vcsGate ? await deps.vcsGate(session) : null;
2236
+ if (refused) {
2237
+ bus.publish({ sessionId, type: 'run-preflight-refused', level: 'warn', message: refused, data: { queued: queued.length } });
2238
+ return { ran: 0, paused: false, refused };
2239
+ }
2229
2240
  await store.updateSession(sessionId, (s) => {
2230
2241
  s.status = 'running';
2231
2242
  });
@@ -2245,6 +2256,7 @@ export async function runSession(sessionId, deps) {
2245
2256
  await enterSessionConversation(borrowed, session, { closeOnFailure: false });
2246
2257
  }
2247
2258
  else {
2259
+ await deps.beforeBrowser?.();
2248
2260
  transport = await openSessionTransport(cfg, session, bus, sessionRunsDir);
2249
2261
  }
2250
2262
  const chat = transport;
@@ -685,6 +685,110 @@ export const SYSTEM_GUIDE = [
685
685
  doesEn: 'The model that reviews each task afterwards, chosen separately from the one doing the work.',
686
686
  doesBg: 'Моделът, който проверява всяка задача после, избран отделно от този, който върши работата.',
687
687
  },
688
+ {
689
+ key: 'runvcs.title',
690
+ kind: 'note',
691
+ en: 'Prepare version control for this run',
692
+ bg: 'Подготви контрола на версиите за това пускане',
693
+ doesEn: 'Shown under the run settings when a repository of the ticked sessions is not ready: one box per repository, with the fixes. The run buttons stay off until every box is ready, and a start is refused before the browser opens.',
694
+ doesBg: 'Показва се под настройките на пускането, когато хранилище на отметнатите сесии не е готово: по една кутия на хранилище, с решенията. Бутоните за пускане остават изключени, докато всяка кутия не е готова, а старт се отказва преди да се отвори браузърът.',
695
+ },
696
+ {
697
+ key: 'runvcs.repo',
698
+ kind: 'note',
699
+ en: 'Repository',
700
+ bg: 'Хранилище',
701
+ doesEn: 'The repository the box is about.',
702
+ doesBg: 'Хранилището, за което е кутията.',
703
+ },
704
+ {
705
+ key: 'runvcs.current',
706
+ kind: 'note',
707
+ en: 'Current branch and HEAD',
708
+ bg: 'Текущ клон и HEAD',
709
+ doesEn: 'Where the repository is now.',
710
+ doesBg: 'Къде е хранилището сега.',
711
+ },
712
+ {
713
+ key: 'runvcs.base',
714
+ kind: 'note',
715
+ en: 'Requested base branch and HEAD',
716
+ bg: 'Поискан базов клон и HEAD',
717
+ doesEn: 'The branch the plan asked the sessions to start from, and its tip.',
718
+ doesBg: 'Клонът, от който планът иска сесиите да тръгнат, и върхът му.',
719
+ },
720
+ {
721
+ key: 'runvcs.sessions',
722
+ kind: 'note',
723
+ en: 'Sessions',
724
+ bg: 'Сесии',
725
+ doesEn: 'The ticked sessions in this repository and how each starts: first, from the same base, or carrying on the one before.',
726
+ doesBg: 'Отметнатите сесии в това хранилище и как тръгва всяка: първа, от същата база или продължава предната.',
727
+ },
728
+ {
729
+ key: 'runvcs.patterns',
730
+ kind: 'note',
731
+ en: 'Input file patterns',
732
+ bg: 'Шаблони за входни файлове',
733
+ doesEn: 'The "Input files" of the first session.',
734
+ doesBg: '„Входни файлове“ на първата сесия.',
735
+ },
736
+ {
737
+ key: 'runvcs.review',
738
+ kind: 'button',
739
+ en: 'Review input files',
740
+ bg: 'Прегледай входните файлове',
741
+ doesEn: 'Lists the matched input files with git status and size. Changes nothing.',
742
+ doesBg: 'Показва съвпадналите входни файлове с git статус и размер. Нищо не променя.',
743
+ },
744
+ {
745
+ key: 'runvcs.snapshotOnBase',
746
+ kind: 'button',
747
+ en: 'Create starting snapshot on {branch}',
748
+ bg: 'Създай начална снимка върху {branch}',
749
+ doesEn: 'Commits the approved input files on top of the base branch, on a new cop/baseline/ branch, without switching branches or touching the checkout; every session of the run starting from that branch starts from it, chained ones carry it on. Recommended when the plan names that branch. Not offered while other files are uncommitted.',
750
+ doesBg: 'Комитва одобрените входни файлове върху базовия клон, на нов клон cop/baseline/, без да сменя клона и без да пипа работното копие; всяка сесия от пускането, тръгваща от този клон, тръгва от снимката, а свързаните във верига я продължават. Препоръчано, когато планът посочва този клон. Не се предлага, докато има други некомитнати файлове.',
751
+ },
752
+ {
753
+ key: 'runvcs.snapshotHere',
754
+ kind: 'button',
755
+ en: 'Create starting snapshot on {current}',
756
+ bg: 'Създай начална снимка върху {current}',
757
+ doesEn: 'Takes the listed files as the starting snapshot where the repository is, as the session page does: one commit on a new branch, the current branch not moved.',
758
+ doesBg: 'Взема изброените файлове като начална снимка там, където е хранилището, както страницата на сесията: един комит на нов клон, текущият клон не се мести.',
759
+ },
760
+ {
761
+ key: 'runvcs.useCurrent',
762
+ kind: 'button',
763
+ en: 'Use the current branch instead of {branch}',
764
+ bg: 'Използвай текущия branch вместо {branch}',
765
+ doesEn: 'The sessions start from the branch the repository is on instead of the base branch: their "Start from" becomes "From wherever the repository is". Nothing is committed.',
766
+ doesBg: 'Сесиите тръгват от клона, на който е хранилището, вместо от базовия: „Откъде тръгва“ им става „Откъдето е хранилището“. Нищо не се комитва.',
767
+ },
768
+ {
769
+ key: 'runvcs.allowSnapshot',
770
+ kind: 'button',
771
+ en: 'Take uncommitted changes as a starting snapshot',
772
+ bg: 'Вземи некомитнатите промени като начална снимка',
773
+ doesEn: 'Sets "Uncommitted changes" of the first session to "Take them as a starting snapshot", so the files are listed to approve. Nothing is committed.',
774
+ doesBg: 'Задава „Некомитнати промени“ на първата сесия на „Запази ги като начална снимка“, за да се изброят файловете за одобрение. Нищо не се комитва.',
775
+ },
776
+ {
777
+ key: 'runvcs.cancel',
778
+ kind: 'button',
779
+ en: 'Cancel',
780
+ bg: 'Отказ',
781
+ doesEn: 'Hides the fixes; nothing is changed and the run stays off.',
782
+ doesBg: 'Скрива решенията; нищо не се променя и пускането остава изключено.',
783
+ },
784
+ {
785
+ key: 'runvcs.showAgain',
786
+ kind: 'button',
787
+ en: 'Show the fixes again',
788
+ bg: 'Покажи решенията отново',
789
+ doesEn: 'Brings the fixes back after Cancel.',
790
+ doesBg: 'Връща решенията след „Отказ“.',
791
+ },
688
792
  {
689
793
  key: 'batch.run',
690
794
  kind: 'button',
@@ -2837,28 +2941,52 @@ export const SYSTEM_GUIDE = [
2837
2941
  doesBg: 'Секунди чакане на отговор от чата (по подразбиране 900); незавършен дотогава отговор проваля задачата с „Copilot did not finish a reply within …“.',
2838
2942
  },
2839
2943
  {
2840
- key: 'sync.show',
2944
+ key: 'prep.show',
2841
2945
  kind: 'button',
2842
- en: 'Command to bring this project back to the remote main branch',
2843
- bg: 'Команда за връщане на проекта към основния клон на отдалеченото хранилище',
2844
- doesEn: 'Under each project in Settings and on a session\u2019s version control: lists what would be lost and gives a PowerShell command to paste; the bot never runs it.',
2845
- doesBg: 'Под всеки проект в Настройки и в контрола на версиите на сесия: изброява какво ще се загуби и дава команда за PowerShell; ботът никога не я пуска.',
2946
+ en: 'Prepare the folder from the remote main branch',
2947
+ bg: 'Подготви папката от remote main',
2948
+ doesEn: 'Under each project in Settings and on a session’s version control: fetches, then shows what would be kept where — uncommitted changes and new files on a saved branch of their own, commits of the local main the remote lacks on another — and where the folder ends up (the remote main, nothing uncommitted). Changes nothing until "Confirm".',
2949
+ doesBg: 'Под всеки проект в Настройки и в контрола на версиите на сесия: изтегля и показва какво къде ще се запази — некомитнатите промени и новите файлове в отделен запазен клон, комитите на локалния main, които ги няма в remote, в друг — и къде ще е папката (основният клон на remote, без некомитнати промени). Нищо не се променя до „Потвърди“.',
2846
2950
  },
2847
2951
  {
2848
- key: 'sync.copyPreview',
2952
+ key: 'prep.confirm',
2849
2953
  kind: 'button',
2850
- en: 'Copy the preview (changes nothing)',
2851
- bg: 'Копирай прегледа (не променя нищо)',
2852
- doesEn: 'Fetches and lists the commits, changes and new files the command would remove.',
2853
- doesBg: 'Изтегля и изброява commit-ите, промените и новите файлове, които командата би махнала.',
2954
+ en: 'Confirm',
2955
+ bg: 'Потвърди',
2956
+ doesEn: 'Keeps everything as shown, then checks out the remote main branch as the local one and removes the new files from the folder (they are on the saved branch); ignored files stay. Refused if the folder changed since the preview, or while a run works in it. Afterwards the operator adds the files a task needs and the run screen offers a starting snapshot of them.',
2957
+ doesBg: 'Запазва всичко, както е показано, после избира основния клон на remote като локален и маха новите файлове от папката (те са в запазения клон); игнорираните остават. Отказва се, ако папката се е променила след прегледа или докато пускане работи в нея. След това операторът добавя файловете за задачата и екранът за пускане предлага начална снимка от тях.',
2854
2958
  },
2855
2959
  {
2856
- key: 'sync.copyCommand',
2960
+ key: 'prep.retry',
2857
2961
  kind: 'button',
2858
- en: 'Copy the command',
2859
- bg: 'Копирай командата',
2860
- doesEn: 'fetch, reset --hard to the remote main, clean -fd — each only if the step before worked.',
2861
- doesBg: 'fetch, reset --hard към основния клон, clean -fd — всяка само ако предишната е успяла.',
2962
+ en: 'Try again',
2963
+ bg: 'Опитай пак',
2964
+ doesEn: 'Shown when the fetch did not get through: fetches again; a remote that wants a password or an SSH key passphrase asks through Git\u2019s own window, which the bot never reads.',
2965
+ doesBg: 'Показва се, когато изтеглянето не е минало: изтегля отново; remote, който иска парола или passphrase за SSH ключ, пита през собствения прозорец на Git, който ботът не чете.',
2966
+ },
2967
+ {
2968
+ key: 'prep.copyFetch',
2969
+ kind: 'button',
2970
+ en: 'Copy the fetch command',
2971
+ bg: 'Копирай командата за изтегляне',
2972
+ doesEn: 'Copies git fetch for this repository, to run in the operator\u2019s own PowerShell, where ssh asks for the passphrase directly.',
2973
+ doesBg: 'Копира git fetch за това хранилище, за собствения PowerShell на оператора, където ssh пита за passphrase директно.',
2974
+ },
2975
+ {
2976
+ key: 'prep.useFetched',
2977
+ kind: 'button',
2978
+ en: 'Continue with what was last fetched',
2979
+ bg: 'Продължи с последно изтегленото',
2980
+ doesEn: 'The preview again without fetching, against the remote as it was last fetched (say, by the command above); it says when that was.',
2981
+ doesBg: 'Прегледът отново без изтегляне, спрямо remote, както е изтеглен последно (например с командата по-горе); казва кога е било.',
2982
+ },
2983
+ {
2984
+ key: 'prep.cancel',
2985
+ kind: 'button',
2986
+ en: 'Cancel',
2987
+ bg: 'Отказ',
2988
+ doesEn: 'Closes the preview; nothing is changed.',
2989
+ doesBg: 'Затваря прегледа; нищо не се променя.',
2862
2990
  },
2863
2991
  {
2864
2992
  key: 'exec.minApproaches',