copilot-operator 0.1.39 → 0.1.40

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 (53) hide show
  1. package/dist/src/api/operator.controller.js +101 -2
  2. package/dist/src/api/operator.service.js +292 -25
  3. package/dist/src/api/server.js +7 -0
  4. package/dist/src/log/terminal.js +145 -0
  5. package/dist/src/plan/brief.js +1375 -1369
  6. package/dist/src/plan/schema.js +5 -0
  7. package/dist/src/plan/systemGuide.js +158 -16
  8. package/dist/src/session/exports.js +1 -0
  9. package/dist/src/session/startFailure.js +91 -0
  10. package/dist/src/vcs/prepareFromRemote.js +7 -20
  11. package/dist/src/vcs/remoteAuth.js +26 -1
  12. package/dist/src/vcs/runPreflight.js +31 -14
  13. package/dist/src/vcs/snapshot.js +3 -1
  14. package/dist/src/vcs/startChoice.js +295 -0
  15. package/dist/src/vcs/taskVcs.js +40 -5
  16. package/dist/web/404.html +2 -2
  17. package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
  18. package/dist/web/_next/static/chunks/549-f295c8429e83b25b.js +1 -0
  19. package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
  20. package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
  21. package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
  22. package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
  23. package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
  24. package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
  25. package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
  26. package/dist/web/appearance.html +2 -2
  27. package/dist/web/appearance.txt +27 -25
  28. package/dist/web/defaults.html +2 -2
  29. package/dist/web/defaults.txt +27 -25
  30. package/dist/web/history.html +2 -2
  31. package/dist/web/history.txt +27 -25
  32. package/dist/web/import.html +2 -2
  33. package/dist/web/import.txt +27 -25
  34. package/dist/web/index.html +2 -2
  35. package/dist/web/index.txt +27 -25
  36. package/dist/web/level1.html +2 -2
  37. package/dist/web/level1.txt +27 -25
  38. package/dist/web/presets.html +2 -2
  39. package/dist/web/presets.txt +27 -25
  40. package/dist/web/sessions/view.html +2 -2
  41. package/dist/web/sessions/view.txt +27 -25
  42. package/dist/web/system.html +2 -2
  43. package/dist/web/system.txt +27 -25
  44. package/package.json +158 -157
  45. package/dist/web/_next/static/chunks/549-bfde82e4a70ed1a0.js +0 -1
  46. package/dist/web/_next/static/chunks/584-eba7ffe26cf1be83.js +0 -1
  47. package/dist/web/_next/static/chunks/867-7fccf68b0840c0de.js +0 -1
  48. package/dist/web/_next/static/chunks/app/history/page-d3461384566aa332.js +0 -1
  49. package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +0 -1
  50. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +0 -1
  51. package/dist/web/_next/static/chunks/app/sessions/view/page-945be57e397ae162.js +0 -1
  52. /package/dist/web/_next/static/{KYi1jz7Awse_66Fg8c7Ur → Y9tuV5LV-O0XyCREkeGc5}/_buildManifest.js +0 -0
  53. /package/dist/web/_next/static/{KYi1jz7Awse_66Fg8c7Ur → Y9tuV5LV-O0XyCREkeGc5}/_ssgManifest.js +0 -0
@@ -55,6 +55,29 @@ function asContextKind(kind) {
55
55
  }
56
56
  return kind;
57
57
  }
58
+ /**
59
+ * The start window's choices as the service takes them: one per session id, each of a known shape, and
60
+ * nothing else. A choice of any other shape is dropped, which leaves that session on its settings.
61
+ */
62
+ function startsFrom(raw) {
63
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
64
+ return undefined;
65
+ const out = {};
66
+ for (const [id, c] of Object.entries(raw)) {
67
+ if (!c || typeof c !== 'object')
68
+ continue;
69
+ const x = c;
70
+ const branch = typeof x.branch === 'string' ? x.branch : '';
71
+ const onBranch = x.onBranch === true ? { onBranch: true } : {};
72
+ if (x.from === 'previous-session')
73
+ out[id] = { from: 'previous-session' };
74
+ else if (x.from === 'local' && branch)
75
+ out[id] = { from: 'local', branch, ...onBranch };
76
+ else if (x.from === 'remote' && branch && typeof x.remote === 'string' && x.remote)
77
+ out[id] = { from: 'remote', remote: x.remote, branch, ...onBranch };
78
+ }
79
+ return Object.keys(out).length > 0 ? out : undefined;
80
+ }
58
81
  function contentDisposition(fileName) {
59
82
  const ascii = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
60
83
  return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
@@ -349,7 +372,47 @@ let OperatorController = class OperatorController {
349
372
  }
350
373
  // --- run control ------------------------------------------------------------------------
351
374
  start(id, body) {
352
- return this.ops.start(id, body?.mode === 'unattended' ? 'unattended' : 'confirm', body?.name);
375
+ return this.ops.start(id, body?.mode === 'unattended' ? 'unattended' : 'confirm', body?.name, startsFrom(body?.starts));
376
+ }
377
+ /**
378
+ * The start window's question: where each of these sessions starts, and the branches of their
379
+ * repositories as they are here (nothing fetched). See `vcs/startChoice.ts`.
380
+ */
381
+ startOptions(sessions) {
382
+ return this.ops.startChoices((sessions ?? '').split(',').map((x) => x.trim()).filter(Boolean)).catch(fail);
383
+ }
384
+ /** Fetches branches of a remote for the start window — through Git's password window when it asks. */
385
+ startFetch(body) {
386
+ const branches = Array.isArray(body?.branches) ? body.branches.filter((b) => typeof b === 'string') : null;
387
+ return this.ops.startFetch(body?.dir ?? '', body?.remote ?? '', branches).catch(fail);
388
+ }
389
+ /** Starts that did not get as far as a first task, newest first; `unanswered=1` only those no press was answered with. */
390
+ startFailures(unanswered) {
391
+ return this.ops.recentStartFailures(unanswered === '1' || unanswered === 'true');
392
+ }
393
+ /** One of them as a text file: the error, where it stopped, the run's record, the events and the console. */
394
+ async startFailureReport(id, res) {
395
+ try {
396
+ const { fileName, content } = await this.ops.startFailureReport(id);
397
+ res.type('text/plain; charset=utf-8');
398
+ res.setHeader('content-disposition', contentDisposition(fileName));
399
+ res.send(content);
400
+ }
401
+ catch (e) {
402
+ failText(res, e);
403
+ }
404
+ }
405
+ /** What the bot's console showed, the latest lines, as a text file. See `log/terminal.ts`. */
406
+ async terminal(res) {
407
+ try {
408
+ const { fileName, content } = await this.ops.terminalReport();
409
+ res.type('text/plain; charset=utf-8');
410
+ res.setHeader('content-disposition', contentDisposition(fileName));
411
+ res.send(content);
412
+ }
413
+ catch (e) {
414
+ failText(res, e);
415
+ }
353
416
  }
354
417
  stop(id) {
355
418
  return this.ops.stop(id);
@@ -387,7 +450,7 @@ let OperatorController = class OperatorController {
387
450
  // same as "no model": an empty string on a session means "leave the chat alone".
388
451
  body?.model,
389
452
  // The same for the review: absent means each session keeps whatever it is set to.
390
- body?.reviewModel, body?.name, body?.taskIds)
453
+ body?.reviewModel, body?.name, body?.taskIds, startsFrom(body?.starts))
391
454
  .catch(fail);
392
455
  }
393
456
  stopBatch() {
@@ -922,6 +985,42 @@ __decorate([
922
985
  __metadata("design:paramtypes", [String, Object]),
923
986
  __metadata("design:returntype", Promise)
924
987
  ], OperatorController.prototype, "start", null);
988
+ __decorate([
989
+ Get('start/options'),
990
+ __param(0, Query('sessions')),
991
+ __metadata("design:type", Function),
992
+ __metadata("design:paramtypes", [String]),
993
+ __metadata("design:returntype", Promise)
994
+ ], OperatorController.prototype, "startOptions", null);
995
+ __decorate([
996
+ Post('start/fetch'),
997
+ __param(0, Body()),
998
+ __metadata("design:type", Function),
999
+ __metadata("design:paramtypes", [Object]),
1000
+ __metadata("design:returntype", Promise)
1001
+ ], OperatorController.prototype, "startFetch", null);
1002
+ __decorate([
1003
+ Get('start/failures'),
1004
+ __param(0, Query('unanswered')),
1005
+ __metadata("design:type", Function),
1006
+ __metadata("design:paramtypes", [String]),
1007
+ __metadata("design:returntype", Object)
1008
+ ], OperatorController.prototype, "startFailures", null);
1009
+ __decorate([
1010
+ Get('start/failures/:id/report'),
1011
+ __param(0, Param('id')),
1012
+ __param(1, Res()),
1013
+ __metadata("design:type", Function),
1014
+ __metadata("design:paramtypes", [String, Object]),
1015
+ __metadata("design:returntype", Promise)
1016
+ ], OperatorController.prototype, "startFailureReport", null);
1017
+ __decorate([
1018
+ Get('terminal'),
1019
+ __param(0, Res()),
1020
+ __metadata("design:type", Function),
1021
+ __metadata("design:paramtypes", [Object]),
1022
+ __metadata("design:returntype", Promise)
1023
+ ], OperatorController.prototype, "terminal", null);
925
1024
  __decorate([
926
1025
  Post('sessions/:id/stop'),
927
1026
  __param(0, Param('id')),
@@ -29,6 +29,17 @@ import { suggestRunName } from '../session/runName.js';
29
29
  import { buildStory } from '../session/story.js';
30
30
  import { checkPlan } from '../plan/schema.js';
31
31
  import { planBrief } from '../plan/brief.js';
32
+ /**
33
+ * A settings file or a policy lock that cannot be used: answered 409 with its own message on every route
34
+ * (see `server.ts`), so a start passes it on rather than answering it as a start that did not happen.
35
+ */
36
+ function unusableConfig(e) {
37
+ return e instanceof SettingsUnusableError || e instanceof PolicyLockUnusableError;
38
+ }
39
+ /** `20261013-102231` from an ISO time, in this machine's time as the file's contents are, for a file name. */
40
+ function fileStamp(iso) {
41
+ return localTime(iso).replace(/[-:]/g, '').replace(/\..*$/, '').replace(' ', '-');
42
+ }
32
43
  /** Windows paths: case and the slash direction do not make two folders. */
33
44
  function sameFolder(a, b) {
34
45
  const norm = (p) => p.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
@@ -36,7 +47,7 @@ function sameFolder(a, b) {
36
47
  }
37
48
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
38
49
  import { readInterruption } from '../session/interruption.js';
39
- import { branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
50
+ import { baseRefOf, branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
40
51
  import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
41
52
  import { makeAuthorizer } from '../exec/authorizer.js';
42
53
  import { pickFolder } from './folderPicker.js';
@@ -48,7 +59,11 @@ import { composeHandoff } from '../session/handoff.js';
48
59
  import { planSync } from '../vcs/syncCommand.js';
49
60
  import { planPrepare, prepareFromRemote } from '../vcs/prepareFromRemote.js';
50
61
  import { askpassProgram } from '../vcs/remoteAuth.js';
51
- import { appendRunLog, appendSessionLog } from '../session/runLog.js';
62
+ import { appendRunLog, appendSessionLog, readRunLog } from '../session/runLog.js';
63
+ import { applyStartChoice, fetchForStart, startOptions, withChoice } from '../vcs/startChoice.js';
64
+ import { StartFailures, failureReport, refOf } from '../session/startFailure.js';
65
+ import { formatTerminal, localTime, terminalLines, terminalLogFile } from '../log/terminal.js';
66
+ import { redactSecrets } from '../exec/redaction.js';
52
67
  import { workingDirFor, isWorkingDirProblem } from '../exec/workDir.js';
53
68
  import { contractConflictsWithFixes, contractFixPatch, describeContractChange } from '../orchestrator/contract.js';
54
69
  import { checkRefusalForOperator, lineRefusal } from '../orchestrator/taskRunner.js';
@@ -62,7 +77,8 @@ import { botVersion } from '../config/version.js';
62
77
  import { computeMetrics } from '../session/metrics.js';
63
78
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
64
79
  import { saveAndReveal } from './saveToDesktop.js';
65
- import { settingsOf } from './settings.js';
80
+ import { settingsOf, SettingsUnusableError } from './settings.js';
81
+ import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
66
82
  /** Why a session set aside on the Sessions page does not run: one sentence for every start that refuses it. */
67
83
  const INACTIVE_REASON = 'the session is inactive; make it active on the Sessions page to run it';
68
84
  /**
@@ -195,6 +211,8 @@ let OperatorService = class OperatorService {
195
211
  settings = settingsOf(this.layout);
196
212
  store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
197
213
  bus = new EventBus();
214
+ /** Starts that did not get as far as a first task, for the page to show and offer as a file. */
215
+ startFailures = new StartFailures();
198
216
  running = new Map();
199
217
  waiting = new Map();
200
218
  /** The batch in progress, or the last one that finished. At most one ever exists. */
@@ -433,6 +451,17 @@ let OperatorService = class OperatorService {
433
451
  merged.baseBranch = merged.baseBranch.trim();
434
452
  if (merged.existingBranch !== undefined)
435
453
  merged.existingBranch = merged.existingBranch.trim();
454
+ /*
455
+ * The session page names local branches: a start or a branch changed there is a local one, and
456
+ * the remote the start window chose it from last time no longer applies (see `baseRemote`).
457
+ */
458
+ const pointMoved = (patch.vcs.startFrom !== undefined && patch.vcs.startFrom !== s.vcs?.startFrom) ||
459
+ (patch.vcs.baseBranch !== undefined && patch.vcs.baseBranch.trim() !== (s.vcs?.baseBranch ?? '').trim()) ||
460
+ (patch.vcs.existingBranch !== undefined && patch.vcs.existingBranch.trim() !== (s.vcs?.existingBranch ?? '').trim());
461
+ if (pointMoved && patch.vcs.baseRemote === undefined)
462
+ delete merged.baseRemote;
463
+ if (merged.baseRemote !== undefined && !merged.baseRemote.trim())
464
+ delete merged.baseRemote;
436
465
  // Only the two known fields, and absent when it says what absent says: reject, asking first.
437
466
  const dirty = dirtyPolicy(merged);
438
467
  if (dirty.policy === 'reject')
@@ -479,6 +508,7 @@ let OperatorService = class OperatorService {
479
508
  const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
480
509
  (merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
481
510
  (merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
511
+ (merged.baseRemote ?? '') !== (s.vcs?.baseRemote ?? '') ||
482
512
  (merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
483
513
  merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
484
514
  if (startChanged) {
@@ -957,24 +987,47 @@ let OperatorService = class OperatorService {
957
987
  * Starts the session's queued tasks in the background. Returns immediately; progress
958
988
  * arrives on the event stream. One run at a time, of this session or any other.
959
989
  */
960
- async start(sessionId, mode = 'confirm', name) {
990
+ async start(sessionId, mode = 'confirm', name,
991
+ /** Where the session starts, as chosen in the start window: see `vcs/startChoice.ts`. Absent keeps its settings. */
992
+ starts) {
993
+ const pressedAt = new Date().toISOString();
961
994
  // One conversation at a time is not a policy, it is the browser profile: a second run
962
995
  // does not get a second browser, it gets an error about a closed one. Whatever has the
963
996
  // browser now — a batch, another session, a read of the models — the start is refused where
964
997
  // the reason can still be read, rather than three minutes later in a stack trace.
965
998
  const claim = this.claimBrowser({ kind: 'run', sessionId });
966
999
  if ('refused' in claim)
967
- return { started: false, reason: claim.refused };
1000
+ return await this.notStarted({ stage: 'refused', message: claim.refused, sessionIds: [sessionId], pressedAt });
1001
+ let runId;
968
1002
  try {
969
1003
  // A run nobody named is named after what it is a run of, rather than left to show up in the
970
1004
  // register as "Unnamed run" beside five others of the same description.
971
1005
  // Set aside on the Sessions page: said first, before the preflight judges a session that will not run.
972
1006
  if ((await this.store.getSession(sessionId))?.active === false) {
973
1007
  this.releaseBrowser(claim.holder);
974
- return { started: false, reason: INACTIVE_REASON };
1008
+ return await this.notStarted({ stage: 'refused', message: INACTIVE_REASON, sessionIds: [sessionId], pressedAt });
975
1009
  }
976
1010
  const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
977
1011
  const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
1012
+ runId = runGroup.id;
1013
+ /*
1014
+ * What refuses this start whatever branch is chosen, asked before the choice moves anything: a local
1015
+ * branch made or brought up to its remote, and the session's settings, stayed changed after a start
1016
+ * refused for an unattended run this machine does not allow (review of 2026-10-13).
1017
+ */
1018
+ const before = await this.store.getSession(sessionId);
1019
+ const earlyRefusal = !before ? 'no such session' : queuedToRun(before).length === 0 ? 'no queued tasks' : await this.unattendedRefusal(mode);
1020
+ if (earlyRefusal) {
1021
+ this.releaseBrowser(claim.holder);
1022
+ // No run log for a session that does not exist: the id is the caller's, and may be anything.
1023
+ return await this.notStarted({ stage: 'refused', message: earlyRefusal, sessionIds: [sessionId], pressedAt, ...(before ? { runId } : {}) });
1024
+ }
1025
+ // The branch the session starts from, as the start window chose it, before the preflight judges it.
1026
+ const unusable = await this.applyStartChoices(runGroup.id, [sessionId], starts);
1027
+ if (unusable) {
1028
+ this.releaseBrowser(claim.holder);
1029
+ return await this.notStarted({ stage: 'branch', message: unusable, sessionIds: [sessionId], pressedAt, runId });
1030
+ }
978
1031
  // A run of one is still a run, and it records the same thing a batch does, so that going
979
1032
  // back and starting again from a task works the same whether one session was started or six.
980
1033
  // Recorded by `beginRun` once the start is accepted, not here: see `recordOnSession`.
@@ -982,12 +1035,12 @@ let OperatorService = class OperatorService {
982
1035
  const notReady = await this.runPreflight(runGroup.id, [sessionId]);
983
1036
  if (notReady) {
984
1037
  this.releaseBrowser(claim.holder);
985
- return { started: false, reason: notReady };
1038
+ return await this.notStarted({ stage: 'preflight', message: notReady, sessionIds: [sessionId], pressedAt, runId });
986
1039
  }
987
- const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true });
1040
+ const begun = await this.beginRun(sessionId, mode, undefined, runGroup, undefined, { recordOnSession: true, pressedAt });
988
1041
  if (!begun.started) {
989
1042
  this.releaseBrowser(claim.holder);
990
- return { started: false, reason: begun.reason };
1043
+ return await this.notStarted({ stage: 'refused', message: begun.reason ?? 'not started', sessionIds: [sessionId], pressedAt, runId });
991
1044
  }
992
1045
  // The window closes before the run's promise settles (see `runSession`), so the browser is
993
1046
  // free again exactly when it is.
@@ -996,8 +1049,156 @@ let OperatorService = class OperatorService {
996
1049
  }
997
1050
  catch (e) {
998
1051
  this.releaseBrowser(claim.holder);
999
- throw e;
1052
+ if (unusableConfig(e))
1053
+ throw e;
1054
+ // Said with the error itself, and on record with the console of those moments, rather than as a bare 500.
1055
+ console.error('[start] the session could not be started:', e.stack ?? e.message);
1056
+ return await this.notStarted({ stage: 'error', message: e.message, sessionIds: [sessionId], pressedAt, runId });
1057
+ }
1058
+ }
1059
+ /**
1060
+ * A start that did not happen, put on record (`session/startFailure.ts`) and in the run's log when it
1061
+ * had a run, and said on the console, so the terminal shows it as well. `answered`: the reply to the
1062
+ * press carries it; false for one found after the start was accepted, which every page then shows.
1063
+ */
1064
+ async notStarted(f) {
1065
+ const sessions = [];
1066
+ for (const id of f.sessionIds) {
1067
+ const s = await this.store.getSession(id).catch(() => null);
1068
+ sessions.push({ id, name: s?.name ?? id });
1069
+ }
1070
+ const recorded = this.startFailures.record({
1071
+ pressedAt: f.pressedAt,
1072
+ stage: f.stage,
1073
+ message: f.message,
1074
+ ...(f.runId ? { runId: f.runId } : {}),
1075
+ sessions,
1076
+ answered: f.answered !== false,
1077
+ });
1078
+ console.warn(`[start] the work did not start (${f.stage}${f.runId ? `, run ${f.runId}` : ''}): ${f.message}`);
1079
+ if (f.runId) {
1080
+ const cfg = await this.settings.load().catch(() => null);
1081
+ if (cfg) {
1082
+ await appendRunLog(cfg.resolved.runsDir, f.runId, [
1083
+ { at: recorded.at, type: 'start-failed', message: f.message, data: { stage: f.stage, failure: recorded.id, sessions: f.sessionIds } },
1084
+ ]).catch(() => undefined);
1085
+ }
1000
1086
  }
1087
+ return { started: false, reason: f.message, failure: refOf(recorded) };
1088
+ }
1089
+ /**
1090
+ * Writes the start window's choices onto the sessions of a run, or says why one cannot be used.
1091
+ *
1092
+ * Every choice is checked first, with nothing moved (`dry`), so a choice that cannot be used for one
1093
+ * session leaves every other session and every branch as it was; then each is written, and a local
1094
+ * branch made from, or brought up to, its remote one where the choice was to work on it. A session
1095
+ * whose start is fixed keeps it. Recorded in the run's log and the session's.
1096
+ */
1097
+ async applyStartChoices(runId, sessionIds, starts) {
1098
+ if (!starts || typeof starts !== 'object')
1099
+ return null;
1100
+ const wanted = sessionIds.filter((id) => starts[id]);
1101
+ if (wanted.length === 0)
1102
+ return null;
1103
+ for (const id of wanted) {
1104
+ const s = await this.store.getSession(id);
1105
+ if (!s)
1106
+ continue;
1107
+ if (this.running.has(id))
1108
+ return `"${s.name}" is running; its start cannot be changed now.`;
1109
+ const checked = await applyStartChoice(s, starts[id], { dry: true });
1110
+ if (!checked.ok)
1111
+ return `"${s.name}": ${checked.problem}`;
1112
+ }
1113
+ const cfg = await this.settings.load();
1114
+ const entries = [];
1115
+ for (const id of wanted) {
1116
+ const s = await this.store.getSession(id);
1117
+ if (!s)
1118
+ continue;
1119
+ const done = await applyStartChoice(s, starts[id]);
1120
+ if (!done.ok)
1121
+ return `"${s.name}": ${done.problem}`;
1122
+ if (!done.applied)
1123
+ continue;
1124
+ const applied = done.applied;
1125
+ await this.store.updateSession(id, (x) => {
1126
+ if (!x.vcs)
1127
+ return;
1128
+ x.vcs = withChoice(x.vcs, applied.vcs);
1129
+ if (applied.clearStart) {
1130
+ x.vcsBaseCommit = undefined;
1131
+ x.vcsStart = undefined;
1132
+ }
1133
+ });
1134
+ const message = `${applied.said}${applied.note ? `; ${applied.note}` : ''}${applied.clearStart ? '; the start prepared before (a starting snapshot) no longer applies' : ''}`;
1135
+ entries.push({ at: new Date().toISOString(), type: 'start-chosen', message, data: { session: id, choice: starts[id], ...(applied.note ? { note: applied.note } : {}) } });
1136
+ this.bus.publish({ sessionId: id, type: 'start-chosen', level: 'info', message, data: { runId, choice: starts[id] } });
1137
+ await appendSessionLog(cfg.resolved.runsDir, id, { type: 'start-chosen', message, data: { runId, choice: starts[id] } }).catch(() => undefined);
1138
+ }
1139
+ await appendRunLog(cfg.resolved.runsDir, runId, entries).catch(() => undefined);
1140
+ return null;
1141
+ }
1142
+ /** The start window's question for these sessions: their starts and the branches of their repositories. */
1143
+ async startChoices(sessionIds) {
1144
+ await this.init();
1145
+ const sessions = [];
1146
+ for (const id of [...new Set(sessionIds.map((x) => x.trim()).filter(Boolean))]) {
1147
+ const s = await this.store.getSession(id);
1148
+ if (s)
1149
+ sessions.push(s);
1150
+ }
1151
+ return await startOptions(sessions);
1152
+ }
1153
+ /**
1154
+ * Fetches branches of a remote for the start window, where the operator is looking — so through Git's
1155
+ * own password window when the remote asks (see `vcs/remoteAuth.ts`). Only for a folder this bot works
1156
+ * in; only remote-tracking branches move.
1157
+ */
1158
+ async startFetch(dir, remote, branches) {
1159
+ await this.init();
1160
+ await this.knownProjectFolder(dir, 'it is not fetched from here');
1161
+ return await fetchForStart(resolve(dir.trim()), remote.trim(), branches, await askpassProgram());
1162
+ }
1163
+ /** The starts that did not get as far as a first task, newest first: see `session/startFailure.ts`. */
1164
+ recentStartFailures(unansweredOnly) {
1165
+ return this.startFailures.recent({ unansweredOnly }).slice(0, 10).map(refOf);
1166
+ }
1167
+ /**
1168
+ * One of them as a file: what failed and where, the run's record, the sessions' events from the press,
1169
+ * and the console lines from the press to a few seconds after — secret-shaped text taken out.
1170
+ */
1171
+ async startFailureReport(id) {
1172
+ await this.init();
1173
+ const f = this.startFailures.get(id);
1174
+ if (!f)
1175
+ throw new Error('That failed start is not known to this process (was the bot restarted since?). Its console is still in the terminal log: System → "Download what the terminal showed".');
1176
+ const cfg = await this.settings.load();
1177
+ const from = new Date(new Date(f.pressedAt).getTime() - 2_000).toISOString();
1178
+ const to = new Date(new Date(f.at).getTime() + 5_000).toISOString();
1179
+ const events = f.sessions.flatMap((s) => this.bus.recent(s.id)).filter((e) => e.at >= from).sort((a, b) => a.at.localeCompare(b.at));
1180
+ const text = failureReport(f, {
1181
+ runLog: await readRunLog(cfg.resolved.runsDir, f.runId),
1182
+ events,
1183
+ terminal: terminalLines({ from, to, max: 2_000 }),
1184
+ terminalFile: terminalLogFile(),
1185
+ version: botVersion(),
1186
+ });
1187
+ return { fileName: `copilot-operator-not-started-${fileStamp(f.at)}.txt`, content: redactSecrets(text, cfg.report.redactPatterns) };
1188
+ }
1189
+ /** What the bot's console showed: the latest lines kept in memory, as a file. See `log/terminal.ts`. */
1190
+ async terminalReport() {
1191
+ const cfg = await this.settings.load();
1192
+ const lines = terminalLines({ max: 5_000 });
1193
+ const file = terminalLogFile();
1194
+ const head = [
1195
+ "copilot-operator — what the terminal showed (the bot's console)",
1196
+ `Version: ${botVersion()}`,
1197
+ `${lines.length} line(s), the latest kept in memory${file ? `; everything since the bot started is in ${file}` : ''}.`,
1198
+ '',
1199
+ '',
1200
+ ].join('\n');
1201
+ return { fileName: `copilot-operator-terminal-${fileStamp(new Date().toISOString())}.txt`, content: redactSecrets(`${head}${formatTerminal(lines)}\n`, cfg.report.redactPatterns) };
1001
1202
  }
1002
1203
  /**
1003
1204
  * The same thing `start` does, but it also hands back a promise that settles when the run
@@ -1063,7 +1264,8 @@ let OperatorService = class OperatorService {
1063
1264
  * nothing else.
1064
1265
  */
1065
1266
  const authorizer = this.webAuthorizer(policy, controller.signal);
1066
- this.running.set(sessionId, { controller, startedAt: new Date().toISOString(), mode, policy });
1267
+ const startedAt = new Date().toISOString();
1268
+ this.running.set(sessionId, { controller, startedAt, mode, policy });
1067
1269
  this.bus.publish({ sessionId, type: 'run-requested', level: 'info', message: `starting in ${mode} mode` });
1068
1270
  // Said at the start of every run, in both modes, because it is the one fact about this tool a
1069
1271
  // data-protection team needs and the one an operator forgets: the reports leave the machine.
@@ -1122,10 +1324,27 @@ let OperatorService = class OperatorService {
1122
1324
  },
1123
1325
  },
1124
1326
  })
1125
- .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) }))
1327
+ .then(async (outcome) => {
1328
+ const tally = { ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) };
1329
+ // Refused at its turn in a run that was going: nothing of it began, and nobody was answered with it.
1330
+ if (outcome.refused && tally.ran === 0) {
1331
+ await this.notStarted({ stage: 'at-turn', message: outcome.refused, sessionIds: [sessionId], pressedAt: how.pressedAt ?? startedAt, ...(runGroup ? { runId: runGroup.id } : {}), answered: false });
1332
+ }
1333
+ return tally;
1334
+ })
1126
1335
  .catch(async (e) => {
1127
1336
  this.bus.publish({ sessionId, type: 'run-failed', level: 'error', message: e.message });
1128
- return { ...(await this.tally(sessionId, queuedIds)), error: e.message };
1337
+ const tally = { ...(await this.tally(sessionId, queuedIds)), error: e.message };
1338
+ /*
1339
+ * Failed before any task of it began — the browser, the profile, the sign-in, the model: the start
1340
+ * was answered "started" already, so the failure is shown on every page, with the console (2026-10-13).
1341
+ */
1342
+ // Not when the operator stopped it: that is an answer, not a failure to show.
1343
+ if (tally.ran === 0 && !controller.signal.aborted) {
1344
+ console.error('[start] the run ended before its first task:', e.stack ?? e.message);
1345
+ await this.notStarted({ stage: how.laterInRun ? 'at-turn' : 'run', message: e.message, sessionIds: [sessionId], pressedAt: how.pressedAt ?? startedAt, ...(runGroup ? { runId: runGroup.id } : {}), answered: false });
1346
+ }
1347
+ return tally;
1129
1348
  })
1130
1349
  .finally(() => {
1131
1350
  this.running.delete(sessionId);
@@ -1239,14 +1458,17 @@ let OperatorService = class OperatorService {
1239
1458
  * ticked in the register. A session none of whose chosen tasks is queued is skipped; the
1240
1459
  * tasks nobody chose stay queued exactly as they were. Absent means every queued task.
1241
1460
  */
1242
- taskIds) {
1461
+ taskIds,
1462
+ /** Where each session starts, as chosen in the start window: see `vcs/startChoice.ts`. Absent keeps their settings. */
1463
+ starts) {
1464
+ const pressedAt = new Date().toISOString();
1243
1465
  const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
1244
1466
  // Before anything is waited for (see `browser`): a second batch, a session running on its own
1245
1467
  // — one of these or any other — and a read of the models all have the browser already.
1246
1468
  const claim = this.claimBrowser({ kind: 'batch', sessionIds: wanted });
1247
1469
  if ('refused' in claim)
1248
- return { started: false, reason: claim.refused };
1249
- return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds);
1470
+ return await this.notStarted({ stage: 'refused', message: claim.refused, sessionIds: wanted, pressedAt });
1471
+ return await this.startBatchHolding(claim.holder, wanted, mode, onFailure, model, reviewModel, name, taskIds, { starts, pressedAt });
1250
1472
  }
1251
1473
  /**
1252
1474
  * `startBatch` for a caller that has claimed the browser already, and the claim with it: handed on
@@ -1254,16 +1476,18 @@ let OperatorService = class OperatorService {
1254
1476
  * refusal. "Run again from here" claims it before it moves anything and starts through this, so
1255
1477
  * one claim covers the restore, the requeue and the run.
1256
1478
  */
1257
- async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds) {
1479
+ async startBatchHolding(holder, wanted, mode, onFailure, model, reviewModel, name, taskIds, how = {}) {
1258
1480
  const onlyTasks = taskIds && taskIds.length > 0 ? new Set(taskIds.map((t) => t.trim()).filter(Boolean)) : undefined;
1481
+ const pressedAt = how.pressedAt ?? new Date().toISOString();
1259
1482
  let handedOn = false;
1483
+ // The batch's id first, so the preflight is recorded under the run it decides about.
1484
+ const batchId = newId('b-');
1485
+ const refuse = (stage, message, runId) => this.notStarted({ stage, message, sessionIds: wanted, pressedAt, ...(runId ? { runId } : {}) });
1260
1486
  try {
1261
1487
  await this.init();
1262
1488
  if (wanted.length === 0)
1263
- return { started: false, reason: 'no sessions were selected' };
1489
+ return await refuse('refused', 'no sessions were selected');
1264
1490
  // Version control first, before anything opens the browser: see `vcs/runPreflight.ts`.
1265
- // The batch's id first, so the preflight is recorded under the run it decides about.
1266
- const batchId = newId('b-');
1267
1491
  /*
1268
1492
  * Sessions set aside on the Sessions page are left out before the preflight: judged there, an inactive
1269
1493
  * session's contradiction or version-control problem was the answer to the press — and could refuse
@@ -1280,18 +1504,45 @@ let OperatorService = class OperatorService {
1280
1504
  active.push(id);
1281
1505
  }
1282
1506
  if (active.length === 0)
1283
- return { started: false, reason: `${inactive.map((n) => `"${n}"`).join(', ')}: ${INACTIVE_REASON}` };
1507
+ return await refuse('refused', `${inactive.map((n) => `"${n}"`).join(', ')}: ${INACTIVE_REASON}`);
1508
+ /*
1509
+ * What refuses the batch whatever branch is chosen, asked before the choices move anything (review of
1510
+ * 2026-10-13): nothing queued among the chosen tasks, an unattended run this machine does not allow.
1511
+ */
1512
+ let anyQueued = false;
1513
+ for (const id of active) {
1514
+ const s = await this.store.getSession(id);
1515
+ if (s && queuedToRun(s, onlyTasks).length > 0)
1516
+ anyQueued = true;
1517
+ }
1518
+ if (!anyQueued)
1519
+ return await refuse('refused', 'none of the selected sessions has a queued task', batchId);
1520
+ const blockedEarly = await this.unattendedRefusal(mode);
1521
+ if (blockedEarly)
1522
+ return await refuse('refused', blockedEarly, batchId);
1523
+ // The branch each session starts from, as the start window chose it, before the preflight judges it.
1524
+ const unusable = await this.applyStartChoices(batchId, active, how.starts);
1525
+ if (unusable)
1526
+ return await refuse('branch', unusable, batchId);
1284
1527
  const notReady = await this.runPreflight(batchId, active, onlyTasks);
1285
1528
  if (notReady)
1286
- return { started: false, reason: notReady };
1529
+ return await refuse('preflight', notReady, batchId);
1287
1530
  const begun = await this.prepareBatch(wanted, onlyTasks, mode, onFailure, model, reviewModel, name, batchId);
1288
1531
  if (!begun.batch)
1289
- return { started: false, reason: begun.reason };
1532
+ return await refuse('refused', begun.reason, batchId);
1533
+ begun.batch.pressedAt = pressedAt;
1290
1534
  this.batch = begun.batch;
1291
1535
  handedOn = true;
1292
1536
  void this.runBatch(begun.batch, holder);
1293
1537
  return { started: true, batch: this.batchState() };
1294
1538
  }
1539
+ catch (e) {
1540
+ if (unusableConfig(e))
1541
+ throw e;
1542
+ // Said with the error itself, and on record with the console of those moments, rather than as a bare 400.
1543
+ console.error('[start] the run could not be started:', e.stack ?? e.message);
1544
+ return await refuse('error', e.message, batchId);
1545
+ }
1295
1546
  finally {
1296
1547
  if (!handedOn)
1297
1548
  this.releaseBrowser(holder);
@@ -1527,6 +1778,19 @@ let OperatorService = class OperatorService {
1527
1778
  level: 'error',
1528
1779
  message: `the browser could not be opened: ${e.message}`,
1529
1780
  });
1781
+ /*
1782
+ * The run had been accepted, so the press was answered "started": this is shown on every page
1783
+ * until it is closed, with what the console printed (2026-10-13). The stack is printed for it.
1784
+ */
1785
+ console.error('[start] the browser could not be opened:', e.stack ?? e.message);
1786
+ await this.notStarted({
1787
+ stage: 'browser',
1788
+ message: `the browser could not be opened: ${e.message}`,
1789
+ sessionIds: batch.sessions.map((x) => x.sessionId),
1790
+ pressedAt: batch.pressedAt ?? batch.startedAt,
1791
+ runId: batch.id,
1792
+ answered: false,
1793
+ });
1530
1794
  return;
1531
1795
  }
1532
1796
  try {
@@ -1597,6 +1861,7 @@ let OperatorService = class OperatorService {
1597
1861
  // switched the batch with it (see `setRunMode`). The hold is this batch's own.
1598
1862
  const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup, batch.onlyTasks ? new Set(batch.onlyTasks) : undefined, {
1599
1863
  shouldPause: () => batch.pausing,
1864
+ laterInRun: batch.sessions.some((x) => x.ran > 0),
1600
1865
  });
1601
1866
  if (!begun.started) {
1602
1867
  entry.state = 'skipped';
@@ -2346,13 +2611,15 @@ let OperatorService = class OperatorService {
2346
2611
  const branch = how === 'existing-branch' ? session.vcs?.existingBranch?.trim() : session.vcs?.baseBranch?.trim() || 'main';
2347
2612
  if (!branch)
2348
2613
  return 'unknown';
2614
+ // A remote branch chosen in the start window: its remote-tracking branch, as that window fetched it.
2615
+ const ref = how === 'branch' ? baseRefOf(session.vcs).ref : `refs/heads/${branch}`;
2349
2616
  // Brought up to its remote first: what that brings is known only then.
2350
- if (session.vcs?.updateFromRemote !== false) {
2617
+ if (session.vcs?.updateFromRemote !== false && !(how === 'branch' && session.vcs?.baseRemote)) {
2351
2618
  const remotes = await git(repo, ['remote']);
2352
2619
  if (!remotes.ok || remotes.stdout.trim() !== '')
2353
2620
  return 'unknown';
2354
2621
  }
2355
- const tip = await git(repo, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
2622
+ const tip = await git(repo, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
2356
2623
  return tip.ok && tip.stdout ? { ref: tip.stdout } : 'unknown';
2357
2624
  }
2358
2625
  /**
@@ -22,6 +22,7 @@ import { pruneRuns } from '../session/retention.js';
22
22
  import { mkdir, writeFile } from 'node:fs/promises';
23
23
  import { existsSync } from 'node:fs';
24
24
  import { installLayout } from '../config/layout.js';
25
+ import { captureTerminal } from '../log/terminal.js';
25
26
  /**
26
27
  * A settings file, or a policy lock, that cannot be used, answered the same way on every route: 409,
27
28
  * with the message that names the file and says what to do (see `SettingsUnusableError` and
@@ -50,6 +51,11 @@ export async function startApi(opts = {}) {
50
51
  const PORT = opts.port ?? Number(process.env.COP_API_PORT ?? 4000);
51
52
  const WEB_ORIGIN = opts.webOrigin ?? process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
52
53
  const log = opts.quiet ? () => undefined : (line) => console.log(line);
54
+ /*
55
+ * What the console shows from here on is copied for the page to offer as a file (see
56
+ * `log/terminal.ts`): kept in memory now, written to the data folder once its permissions are set.
57
+ */
58
+ captureTerminal();
53
59
  const app = await NestFactory.create(AppModule, { logger: opts.quiet ? ['error', 'warn'] : ['error', 'warn', 'log'] });
54
60
  // A plan pasted from a chat carries every task's text at once, which the 100 KB default
55
61
  // refuses with a message about entity size that says nothing about plans.
@@ -96,6 +102,7 @@ export async function startApi(opts = {}) {
96
102
  const acl = secureDataDir(dataDir);
97
103
  if (!acl.ok)
98
104
  throw new Error(`copilot-operator api will not start: ${acl.reason}`);
105
+ captureTerminal(join(dataDir, 'logs'));
99
106
  const token = await ensureApiToken(dataDir);
100
107
  // The dev UI's origin, and this process's own for the interface it serves itself.
101
108
  const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];