copilot-operator 0.1.0

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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,1971 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ /**
8
+ * Everything the web UI can do, in one service.
9
+ *
10
+ * The runner itself does not know it is being driven from a browser. This service supplies
11
+ * the three things that differ from the terminal: where approvals come from (a pending list
12
+ * the UI resolves), where events go (the bus, streamed as SSE), and how a run is stopped
13
+ * (an AbortController per session).
14
+ */
15
+ import { Injectable } from '@nestjs/common';
16
+ import { readFile, readdir } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { installLayout } from '../config/layout.js';
20
+ import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW } from '../session/store.js';
21
+ import { EventBus } from '../session/events.js';
22
+ import { newId, tidyVcsPlan } from '../session/model.js';
23
+ import { runSession, openBrowser } from '../orchestrator/taskRunner.js';
24
+ import { buildExport } from '../session/exportRecord.js';
25
+ import { buildDebugExport } from '../session/debugExport.js';
26
+ import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName } from '../session/exports.js';
27
+ import { suggestRunName } from '../session/runName.js';
28
+ import { mirrorAllProjects, removeProjectMirrors, contextRoot } from '../context/desktopMirror.js';
29
+ import { describeMirror } from '../context/projectMirror.js';
30
+ import { buildStory } from '../session/story.js';
31
+ import { checkPlan } from '../plan/schema.js';
32
+ import { planBrief } from '../plan/brief.js';
33
+ /** Windows paths: case and the slash direction do not make two folders. */
34
+ function sameFolder(a, b) {
35
+ const norm = (p) => p.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
36
+ return a.trim() !== '' && norm(a) === norm(b);
37
+ }
38
+ import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
39
+ import { vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
40
+ import { gitAvailable, repoUnusableReason } from '../vcs/git.js';
41
+ import { makeAuthorizer } from '../exec/authorizer.js';
42
+ import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
43
+ import { pickFolder } from './folderPicker.js';
44
+ import { findEdgeUsingProfile } from '../transport/profileLock.js';
45
+ import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } from '../exec/isolation.js';
46
+ import { unattendedPrecondition } from '../exec/policy.js';
47
+ import { CopilotTransport } from '../transport/copilotTransport.js';
48
+ import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
49
+ import { saveAndReveal } from './saveToDesktop.js';
50
+ import { Settings } from './settings.js';
51
+ let OperatorService = class OperatorService {
52
+ /** Where this install keeps its prompts and its records. See `config/layout.ts`. */
53
+ layout = installLayout();
54
+ projectRoot = this.layout.projectRoot;
55
+ dataDir = this.layout.dataDir;
56
+ settings = new Settings(this.projectRoot, this.dataDir, this.layout.mode === 'package' ? { runsDir: this.layout.runsDir, level1File: join(this.layout.promptsDir, 'level1.md') } : {});
57
+ store = new SessionStore(this.dataDir, join(this.layout.promptsDir, 'level1.md'));
58
+ bus = new EventBus();
59
+ running = new Map();
60
+ waiting = new Map();
61
+ /** The batch in progress, or the last one that finished. At most one ever exists. */
62
+ batch = null;
63
+ ready = null;
64
+ /** One model read at a time: it launches a browser and holds the profile lock. */
65
+ readingModels = false;
66
+ /**
67
+ * One-time startup work, run lazily on the first request that needs the store.
68
+ *
69
+ * The recovery pass is part of it on purpose: no request should ever see a task still
70
+ * claiming to wait for an approval that died with the previous process.
71
+ */
72
+ init() {
73
+ this.ready ??= (async () => {
74
+ await this.store.init();
75
+ const recovered = await this.store.recoverInterrupted();
76
+ for (const r of recovered) {
77
+ this.bus.publish({
78
+ sessionId: r.sessionId,
79
+ taskId: r.taskId,
80
+ type: 'task-recovered',
81
+ level: 'warn',
82
+ message: `"${r.title}" was left unfinished by an earlier run and has been marked aborted`,
83
+ });
84
+ }
85
+ if (recovered.length > 0) {
86
+ console.log(`[api] closed ${recovered.length} task(s) left unfinished by an earlier run`);
87
+ }
88
+ })();
89
+ return this.ready;
90
+ }
91
+ /** Runs the startup work now, so it does not wait for the first request. */
92
+ bootstrap() {
93
+ return this.init();
94
+ }
95
+ // --- level 1 --------------------------------------------------------------------------
96
+ async getLevel1() {
97
+ await this.init();
98
+ return await this.store.getLevel1();
99
+ }
100
+ async setLevel1(content) {
101
+ await this.init();
102
+ await this.store.setLevel1(content);
103
+ }
104
+ async resetLevel1() {
105
+ await this.init();
106
+ await this.store.resetLevel1();
107
+ return await this.store.getLevel1();
108
+ }
109
+ // --- the organisation's part of the plan persona -------------------------------------
110
+ async getContext(kind, lang) {
111
+ await this.init();
112
+ return await this.store.getContext(kind, lang === 'bg' ? 'bg' : 'en');
113
+ }
114
+ async setContext(kind, content) {
115
+ await this.init();
116
+ await this.store.setContext(kind, content);
117
+ }
118
+ async resetContext(kind, lang) {
119
+ await this.init();
120
+ await this.store.resetContext(kind);
121
+ return await this.store.getContext(kind, lang === 'bg' ? 'bg' : 'en');
122
+ }
123
+ // --- level 2 presets ------------------------------------------------------------------
124
+ async listPresets() {
125
+ await this.init();
126
+ return await this.store.listPresets();
127
+ }
128
+ async savePreset(name, content) {
129
+ await this.init();
130
+ return await this.store.savePreset(name, content);
131
+ }
132
+ async deletePreset(name) {
133
+ await this.init();
134
+ await this.store.deletePreset(name);
135
+ }
136
+ // --- sessions and tasks ---------------------------------------------------------------
137
+ async listSessions() {
138
+ await this.init();
139
+ const all = await this.store.listSessions();
140
+ return all.map((s) => ({ ...s, running: this.running.has(s.id) }));
141
+ }
142
+ async getSession(id) {
143
+ await this.init();
144
+ const s = await this.store.getSession(id);
145
+ if (!s)
146
+ return null;
147
+ const pending = [...this.waiting.values()].filter((w) => w.approval.sessionId === id).map((w) => w.approval);
148
+ return { ...s, running: this.running.has(id), pending, runMode: this.running.get(id)?.mode };
149
+ }
150
+ async createSession(name, mirror) {
151
+ await this.init();
152
+ if (mirror)
153
+ assertMirrorIsCoherent(mirror);
154
+ const session = await this.store.createSession(name, mirror);
155
+ // A new session starts on the standing choice, if there is one. It is copied onto the
156
+ // session rather than read at run time, so changing the default later cannot silently
157
+ // change what an existing session does.
158
+ const cfg = await this.settings.load();
159
+ const defaultModel = (cfg.copilot.defaultModel ?? '').trim();
160
+ const defaultReviewModel = (cfg.copilot.defaultReviewModel ?? '').trim();
161
+ const projectDir = (cfg.project?.rootDir ?? '').trim();
162
+ if (!defaultModel && !defaultReviewModel && !projectDir)
163
+ return session;
164
+ return await this.store.updateSession(session.id, (s) => {
165
+ if (defaultModel)
166
+ s.model = defaultModel;
167
+ if (defaultReviewModel)
168
+ s.review = { enabled: s.review?.enabled !== false, model: defaultReviewModel };
169
+ if (projectDir) {
170
+ // Both fields, because they answer different questions about the same folder: where the
171
+ // files are and where the branches go. Neither is switched on by being filled in.
172
+ if (!s.mirror.rootDir.trim())
173
+ s.mirror.rootDir = projectDir;
174
+ if (s.vcs && !s.vcs.repoDir.trim())
175
+ s.vcs.repoDir = projectDir;
176
+ }
177
+ });
178
+ }
179
+ async updateSession(id, patch) {
180
+ await this.init();
181
+ return await this.store.updateSession(id, (s) => {
182
+ if (patch.name !== undefined)
183
+ s.name = patch.name.trim() || s.name;
184
+ if (patch.onFailure === 'stop' || patch.onFailure === 'continue')
185
+ s.onFailure = patch.onFailure;
186
+ // An empty string is a real choice here: it means "leave the chat on whatever it is".
187
+ if (patch.model !== undefined)
188
+ s.model = patch.model.trim() || undefined;
189
+ // The same for the group: clearing it gives this session its conversation back. It does
190
+ // not move the conversation it is already in; that history is where it is.
191
+ if (patch.conversationGroup !== undefined)
192
+ s.conversationGroup = patch.conversationGroup.trim() || undefined;
193
+ if (patch.review) {
194
+ // Empty model means "the session's own", which is a real choice and not a missing one.
195
+ const merged = { ...DEFAULT_REVIEW, ...s.review, ...patch.review };
196
+ s.review = { enabled: merged.enabled !== false, model: (merged.model ?? '').trim() };
197
+ }
198
+ if (patch.mirror) {
199
+ const merged = { ...s.mirror, ...patch.mirror };
200
+ assertMirrorIsCoherent(merged);
201
+ s.mirror = merged;
202
+ }
203
+ if (patch.vcs) {
204
+ const merged = { ...DEFAULT_VCS, ...s.vcs, ...patch.vcs };
205
+ // A prefix is what makes the bot's branches recognisable in `git branch`, so an empty
206
+ // one is treated as "I did not mean to change this" rather than as a choice.
207
+ merged.branchPrefix = merged.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
208
+ merged.repoDir = merged.repoDir.trim();
209
+ if (merged.enabled) {
210
+ const reason = repoUnusableReason(merged.repoDir || s.mirror.rootDir || '');
211
+ if (reason)
212
+ throw new Error(reason);
213
+ }
214
+ s.vcs = merged;
215
+ }
216
+ });
217
+ }
218
+ async deleteSession(id) {
219
+ await this.init();
220
+ if (this.running.has(id))
221
+ throw new Error('Stop the session before deleting it.');
222
+ await this.store.deleteSession(id);
223
+ }
224
+ async addTask(sessionId, input) {
225
+ await this.init();
226
+ if (!input.prompt?.trim())
227
+ throw new Error('A task needs a prompt.');
228
+ return await this.store.addTask(sessionId, input);
229
+ }
230
+ /**
231
+ * Edits a queued task, including the names it carries into git.
232
+ *
233
+ * Everything an imported plan can set, the form can change: a task that arrived as JSON is
234
+ * the same object as one typed in by hand, and the moment the two diverge is the moment an
235
+ * import becomes a thing you cannot correct without editing a file.
236
+ */
237
+ async updateTask(sessionId, taskId, patch) {
238
+ await this.init();
239
+ return await this.store.updateTask(sessionId, taskId, (t) => {
240
+ if (t.status !== 'queued')
241
+ throw new Error('Only a queued task can be edited.');
242
+ if (patch.title !== undefined)
243
+ t.title = patch.title;
244
+ if (patch.level2 !== undefined)
245
+ t.level2 = patch.level2;
246
+ if (patch.prompt !== undefined)
247
+ t.prompt = patch.prompt;
248
+ if (patch.vcsPlan !== undefined)
249
+ t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
250
+ if (patch.checks !== undefined)
251
+ t.checks = patch.checks.filter((c) => c.name.trim() !== '');
252
+ });
253
+ }
254
+ async deleteTask(sessionId, taskId) {
255
+ await this.init();
256
+ await this.store.deleteTask(sessionId, taskId);
257
+ }
258
+ /**
259
+ * Puts a finished task back in the queue, keeping the record of what it already did.
260
+ *
261
+ * An optional patch is how a finished task is edited: the attempt that ran is archived with
262
+ * the text it ran with, and only then is the new text applied. Editing in place would leave
263
+ * the old attempt's summary sitting under a question that was never asked.
264
+ */
265
+ async rerunTask(sessionId, taskId, patch = {}) {
266
+ await this.init();
267
+ const task = await this.store.rerunTask(sessionId, taskId, patch);
268
+ this.bus.publish({
269
+ sessionId,
270
+ taskId,
271
+ type: 'task-requeued',
272
+ level: 'info',
273
+ message: `"${task.title}" is queued again (attempt ${task.attempt ?? 1})`,
274
+ data: { attempt: task.attempt },
275
+ });
276
+ return task;
277
+ }
278
+ /**
279
+ * The consolidated text log of a task, or null when it has not produced one.
280
+ *
281
+ * `runId` asks for one particular attempt. It is checked against the run ids this task
282
+ * actually owns, so the parameter cannot be used to read another task's folder, or any
283
+ * other folder on the machine.
284
+ */
285
+ async taskLog(sessionId, taskId, runId) {
286
+ const cfg = await this.settings.load();
287
+ const s = await this.store.getSession(sessionId);
288
+ const t = s?.tasks.find((x) => x.id === taskId);
289
+ const chosen = resolveRunId(t, runId);
290
+ if (!chosen)
291
+ return null;
292
+ const path = join(cfg.resolved.runsDir, chosen, 'task-log.txt');
293
+ return existsSync(path) ? await readFile(path, 'utf8') : null;
294
+ }
295
+ /**
296
+ * The story of one attempt, assembled from its run folder: what was sent, what the chat
297
+ * answered, what ran with its output, the review rounds, how it ended. Read while it runs
298
+ * (the page polls) and afterwards; `runId` picks an earlier attempt.
299
+ */
300
+ async taskStory(sessionId, taskId, runId) {
301
+ const cfg = await this.settings.load();
302
+ const s = await this.store.getSession(sessionId);
303
+ const t = s?.tasks.find((x) => x.id === taskId);
304
+ const chosen = resolveRunId(t, runId);
305
+ if (!t || !chosen)
306
+ return null;
307
+ const current = chosen === t.runId;
308
+ // An earlier attempt is read with its own record, so the closing summary is that attempt's.
309
+ const attempt = current ? t : (t.attempts ?? []).find((a) => a.runId === chosen);
310
+ const view = current || !attempt ? t : { ...t, ...attempt, status: attempt.status, attempts: undefined };
311
+ const live = current && this.running.has(sessionId) && (t.status === 'running' || t.status === 'waiting-approval');
312
+ return await buildStory(cfg.resolved.runsDir, chosen, view, live);
313
+ }
314
+ /** Files a task produced, so the UI can list reports and downloaded scripts. */
315
+ async taskFiles(sessionId, taskId, runId) {
316
+ const cfg = await this.settings.load();
317
+ const s = await this.store.getSession(sessionId);
318
+ const t = s?.tasks.find((x) => x.id === taskId);
319
+ const empty = { reports: [], artifacts: [], replies: [] };
320
+ const chosen = resolveRunId(t, runId);
321
+ if (!chosen)
322
+ return empty;
323
+ const base = join(cfg.resolved.runsDir, chosen);
324
+ const ls = async (sub) => (await readdir(join(base, sub)).catch(() => [])).filter((n) => !n.startsWith('_')).sort();
325
+ return { reports: await ls('reports'), artifacts: await ls('artifacts'), replies: await ls('replies') };
326
+ }
327
+ async taskFile(sessionId, taskId, kind, name, runId) {
328
+ if (name.includes('..') || name.includes('/') || name.includes('\\'))
329
+ return null;
330
+ const cfg = await this.settings.load();
331
+ const s = await this.store.getSession(sessionId);
332
+ const t = s?.tasks.find((x) => x.id === taskId);
333
+ const chosen = resolveRunId(t, runId);
334
+ if (!chosen)
335
+ return null;
336
+ const path = join(cfg.resolved.runsDir, chosen, kind, name);
337
+ return existsSync(path) ? await readFile(path, 'utf8') : null;
338
+ }
339
+ /**
340
+ * The same log, written to the Desktop and shown in Explorer instead of to the browser.
341
+ *
342
+ * What the operator does with a log is hand it to the chat that orchestrates the effort, so
343
+ * the useful end of the journey is a file under the cursor, not a page in a tab. The reading
344
+ * is delegated to `taskLog` rather than repeated here: that method already decides which run
345
+ * folder this task is allowed to open, and a second copy of that decision is a second place
346
+ * for it to be got wrong.
347
+ */
348
+ async saveTaskLog(sessionId, taskId, runId) {
349
+ const text = await this.taskLog(sessionId, taskId, runId);
350
+ if (text === null)
351
+ return null;
352
+ return await saveAndReveal(text, await this.namingFor(sessionId, taskId, runId));
353
+ }
354
+ /** One of a task's own files, saved and revealed the same way. They are logs by another name. */
355
+ async saveTaskFile(sessionId, taskId, kind, name, runId) {
356
+ const text = await this.taskFile(sessionId, taskId, kind, name, runId);
357
+ if (text === null)
358
+ return null;
359
+ return await saveAndReveal(text, { ...(await this.namingFor(sessionId, taskId, runId)), file: name });
360
+ }
361
+ /**
362
+ * What a saved file is named after.
363
+ *
364
+ * An earlier attempt is named from its own snapshot rather than from the task as it stands
365
+ * now: a task can be edited between attempts, and a file called after today's title would
366
+ * claim the old log answered a question it was never asked.
367
+ */
368
+ async namingFor(sessionId, taskId, runId) {
369
+ const s = await this.store.getSession(sessionId);
370
+ const t = s?.tasks.find((x) => x.id === taskId);
371
+ const chosen = resolveRunId(t, runId);
372
+ const earlier = chosen && t && chosen !== t.runId ? (t.attempts ?? []).findIndex((a) => a.runId === chosen) : -1;
373
+ const attempt = earlier >= 0 ? earlier + 1 : (t?.attempt ?? 1);
374
+ return {
375
+ session: s?.name ?? sessionId,
376
+ task: (earlier >= 0 ? t?.attempts?.[earlier]?.title : t?.title) ?? taskId,
377
+ // A task that has only ever run once has nothing to be told apart from.
378
+ attempt: attempt > 1 ? attempt : undefined,
379
+ };
380
+ }
381
+ /**
382
+ * The record of selected tasks as one downloadable document.
383
+ *
384
+ * `taskIds` empty means every task of the session that has actually run. A queued task has
385
+ * nothing to say yet, and silently including it would put an empty section in a document
386
+ * someone is about to send to a colleague.
387
+ */
388
+ async exportTasks(sessionId, taskIds, variant) {
389
+ await this.init();
390
+ const session = await this.store.getSession(sessionId);
391
+ if (!session)
392
+ throw new Error('No such session.');
393
+ const wanted = taskIds.length > 0 ? session.tasks.filter((t) => taskIds.includes(t.id)) : session.tasks;
394
+ const tasks = wanted.filter((t) => t.status !== 'queued');
395
+ if (tasks.length === 0) {
396
+ throw new Error('None of the selected tasks has run yet, so there is nothing to export.');
397
+ }
398
+ const cfg = await this.settings.load();
399
+ return await buildExport({ session, tasks, variant, runsDir: cfg.resolved.runsDir });
400
+ }
401
+ /**
402
+ * Everything that happened across a set of sessions, as one JSON document.
403
+ *
404
+ * Given no ids it exports the sessions of the last batch, because the commonest moment for
405
+ * wanting this is right after a run of several went wrong and the operator wants to hand the
406
+ * whole thing to somebody who was not watching.
407
+ */
408
+ async debugExport(sessionIds) {
409
+ await this.init();
410
+ const wanted = sessionIds.map((x) => x.trim()).filter(Boolean);
411
+ const ids = wanted.length > 0 ? wanted : (this.batch?.sessions ?? []).map((s) => s.sessionId);
412
+ if (ids.length === 0) {
413
+ throw new Error('No sessions were named, and no batch has run in this process to fall back on.');
414
+ }
415
+ const sessions = [];
416
+ for (const id of ids) {
417
+ const session = await this.store.getSession(id);
418
+ if (session)
419
+ sessions.push(session);
420
+ }
421
+ if (sessions.length === 0)
422
+ throw new Error('None of those sessions exists any more.');
423
+ const cfg = await this.settings.load();
424
+ return await buildDebugExport({ sessions, runsDir: cfg.resolved.runsDir, cwd: cfg.resolved.cwd });
425
+ }
426
+ /**
427
+ * One of the three JSON views (plan, domain, bot) of one task, one session or one run.
428
+ *
429
+ * A run is everything that one press of a start button set off, across sessions: the tasks
430
+ * stamped with its id, and the sessions that were part of it even where a task never got its
431
+ * turn. The file is named after the run's own name when it has one, because a file called
432
+ * `run-r-abc123` is not something anyone can find again.
433
+ */
434
+ async exportView(kind, where) {
435
+ await this.init();
436
+ let scope;
437
+ if (where.runId) {
438
+ const runId = where.runId;
439
+ const all = await this.store.listSessions();
440
+ const sessions = all
441
+ .filter((s) => s.runGroup?.id === runId || s.tasks.some((t) => t.runGroup?.id === runId || t.attempts?.some((a) => a.runGroup?.id === runId)))
442
+ .sort((a, b) => (a.runGroup?.id === runId ? (a.runGroup.order ?? 0) : 0) - (b.runGroup?.id === runId ? (b.runGroup.order ?? 0) : 0));
443
+ if (sessions.length === 0)
444
+ throw new Error(`No session took part in run ${runId}.`);
445
+ const group = sessions.map((s) => s.runGroup).find((g) => g?.id === runId);
446
+ const name = group?.name?.trim() || `run-${(group?.startedAt ?? '').slice(0, 16).replace(/[:T]/g, '-')}`;
447
+ scope = {
448
+ sessions,
449
+ // A task belongs to the run if this attempt ran in it, or if it was queued for it and
450
+ // never reached — that second kind is what the run's own record on the session is for.
451
+ taskFilter: (s, t) => t.runGroup?.id === runId || (s.runGroup?.id === runId && s.runGroup.taskIds.includes(t.id)),
452
+ label: name,
453
+ };
454
+ }
455
+ else if (where.sessionId) {
456
+ const session = await this.store.getSession(where.sessionId);
457
+ if (!session)
458
+ throw new Error('No such session.');
459
+ if (where.taskId) {
460
+ const task = session.tasks.find((t) => t.id === where.taskId);
461
+ if (!task)
462
+ throw new Error('No such task.');
463
+ scope = { sessions: [session], taskFilter: (_s, t) => t.id === task.id, label: `${session.name}-${task.title}` };
464
+ }
465
+ else {
466
+ scope = { sessions: [session], label: session.name };
467
+ }
468
+ }
469
+ else {
470
+ throw new Error('Name a run, a session or a task.');
471
+ }
472
+ const cfg = await this.settings.load();
473
+ const document = kind === 'plan'
474
+ ? buildPlanExport(scope)
475
+ : kind === 'domain'
476
+ ? await buildDomainExport(scope, cfg.resolved.runsDir)
477
+ : await buildBotExport(scope, cfg.resolved.runsDir, {
478
+ node: process.versions.node,
479
+ platform: process.platform,
480
+ cwd: cfg.resolved.cwd,
481
+ limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, mode: cfg.execution.mode } },
482
+ });
483
+ return { fileName: exportFileName(kind, scope.label), content: JSON.stringify(document, null, 2) };
484
+ }
485
+ /**
486
+ * All three views of a set of tasks the operator picked by hand, in one file.
487
+ *
488
+ * The pairs carry their session because a selection spans runs and sessions — that is the
489
+ * point of it — so a task id on its own would not say which session's task it is. Unknown
490
+ * pairs are dropped rather than refused: a register left open while a session is deleted
491
+ * elsewhere should still hand over the tasks that are still there, and the document says
492
+ * which ones it holds.
493
+ */
494
+ async exportBundle(pairs) {
495
+ await this.init();
496
+ if (pairs.length === 0)
497
+ throw new Error('Choose at least one task.');
498
+ const wanted = new Map();
499
+ for (const { sessionId, taskId } of pairs) {
500
+ const set = wanted.get(sessionId) ?? new Set();
501
+ set.add(taskId);
502
+ wanted.set(sessionId, set);
503
+ }
504
+ const sessions = [];
505
+ for (const sessionId of wanted.keys()) {
506
+ const session = await this.store.getSession(sessionId);
507
+ if (session)
508
+ sessions.push(session);
509
+ }
510
+ const found = sessions.flatMap((s) => s.tasks.filter((t) => wanted.get(s.id)?.has(t.id)));
511
+ if (found.length === 0)
512
+ throw new Error('None of the chosen tasks still exists.');
513
+ // Named after what was chosen rather than after a run, because a selection is not a run: it
514
+ // may be one task of one, or a task from each of three.
515
+ const label = found.length === 1 && sessions.length === 1
516
+ ? `${sessions[0].name}-${found[0].title}`
517
+ : sessions.length === 1
518
+ ? `${sessions[0].name}-${found.length}-tasks`
519
+ : `${found.length}-tasks-${sessions.length}-sessions`;
520
+ const cfg = await this.settings.load();
521
+ const document = await buildBundleExport({ sessions, taskFilter: (s, t) => !!wanted.get(s.id)?.has(t.id), label }, cfg.resolved.runsDir, {
522
+ node: process.versions.node,
523
+ platform: process.platform,
524
+ cwd: cfg.resolved.cwd,
525
+ limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, mode: cfg.execution.mode } },
526
+ });
527
+ return { fileName: exportFileName('bundle', label), content: JSON.stringify(document, null, 2) };
528
+ }
529
+ // --- running --------------------------------------------------------------------------
530
+ isRunning(sessionId) {
531
+ return this.running.has(sessionId);
532
+ }
533
+ /**
534
+ * Starts the session's queued tasks in the background. Returns immediately; progress
535
+ * arrives on the event stream. One run per session at a time.
536
+ */
537
+ async start(sessionId, mode = 'confirm', name) {
538
+ // One conversation at a time is not a policy, it is the browser profile: a second run
539
+ // does not get a second browser, it gets an error about a closed one. A batch already
540
+ // holds that turn, so a single session asking for it now is refused where the reason can
541
+ // still be read, rather than three minutes later in a stack trace.
542
+ if (this.batch?.running)
543
+ return { started: false, reason: 'a batch of sessions is running' };
544
+ // A run nobody named is named after what it is a run of, rather than left to show up in the
545
+ // register as "Unnamed run" beside five others of the same description.
546
+ const chosenName = name?.trim() || (await this.suggestRunName([sessionId]));
547
+ const runGroup = { id: newId('r-'), startedAt: new Date().toISOString(), sessions: 1, name: chosenName };
548
+ // A run of one is still a run, and it records the same thing a batch does, so that going
549
+ // back and starting again from a task works the same whether one session was started or six.
550
+ await this.store.updateSession(sessionId, (s) => {
551
+ s.runGroup = {
552
+ ...runGroup,
553
+ order: 0,
554
+ taskIds: s.tasks.filter((t) => t.status === 'queued').map((t) => t.id),
555
+ mode,
556
+ onFailure: s.onFailure === 'continue' ? 'continue' : 'stop',
557
+ };
558
+ });
559
+ const begun = await this.beginRun(sessionId, mode, undefined, runGroup);
560
+ return { started: begun.started, reason: begun.reason };
561
+ }
562
+ /**
563
+ * The same thing `start` does, but it also hands back a promise that settles when the run
564
+ * is over and says how it went.
565
+ *
566
+ * A batch needs that promise and a single session does not, which is the only difference
567
+ * between the two callers. The sessions of a batch still run one at a time through here,
568
+ * and they have to: the browser profile is single-writer, so two conversations at once is
569
+ * not slower, it is a failure with a message about a closed browser.
570
+ */
571
+ async beginRun(sessionId, mode,
572
+ /** A browser that is already open. A batch passes its own; a single run opens one. */
573
+ transport,
574
+ /** The press of a start button this belongs to, stamped onto every task it reaches. */
575
+ runGroup) {
576
+ await this.init();
577
+ // Every project's Desktop copy brought up to date before the chat opens, when the operator
578
+ // asked for that. Before the run rather than before each task: a task's own mirror already
579
+ // refreshes its project, and the other projects do not change while the bot works.
580
+ await this.refreshDesktopMirrors();
581
+ const idle = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
582
+ if (this.running.has(sessionId))
583
+ return { started: false, reason: 'already running', done: Promise.resolve(idle) };
584
+ const session = await this.store.getSession(sessionId);
585
+ if (!session)
586
+ return { started: false, reason: 'no such session', done: Promise.resolve(idle) };
587
+ const queuedIds = session.tasks.filter((t) => t.status === 'queued').map((t) => t.id);
588
+ if (queuedIds.length === 0)
589
+ return { started: false, reason: 'no queued tasks', done: Promise.resolve(idle) };
590
+ const cfg = await this.settings.load();
591
+ const policy = {
592
+ mode,
593
+ denyPatterns: cfg.execution.denyPatterns,
594
+ allowedPrograms: cfg.execution.allowedPrograms,
595
+ isolation: cfg.execution.isolation,
596
+ lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
597
+ };
598
+ /*
599
+ * An unattended run that cannot legally execute anything is stopped here, where the operator
600
+ * is looking, rather than starting and returning every step refused. The step gate holds the
601
+ * same rule and is what actually enforces it; this is so the answer arrives once, before a
602
+ * browser is opened and a conversation is started, and names the setting to change.
603
+ */
604
+ const precondition = unattendedPrecondition(policy);
605
+ if (precondition)
606
+ return { started: false, reason: precondition, done: Promise.resolve(idle) };
607
+ const controller = new AbortController();
608
+ /*
609
+ * The same authorizer in both modes. It used to be `unattendedAuthorizer` here for an unattended
610
+ * run, which has no way to ask anybody; but a step that fetches from the network is put to the
611
+ * operator whatever the mode (see `network.ts`), and the approval screen is where that happens.
612
+ * `makeAuthorizer` reads `policy.mode` on every step, so an unattended run still asks about
613
+ * nothing else.
614
+ */
615
+ const authorizer = this.webAuthorizer(policy, controller.signal);
616
+ this.running.set(sessionId, { controller, startedAt: new Date().toISOString(), mode, policy });
617
+ this.bus.publish({ sessionId, type: 'run-requested', level: 'info', message: `starting in ${mode} mode` });
618
+ // Said at the start of every run, in both modes, because it is the one fact about this tool a
619
+ // data-protection team needs and the one an operator forgets: the reports leave the machine.
620
+ this.bus.publish({
621
+ sessionId,
622
+ type: 'upload-notice',
623
+ level: 'info',
624
+ message: "every report of this run — each step's terminal output, the check results, the reviewer's transcripts — and any " +
625
+ "mirrored project files are uploaded into the Copilot chat and stored in the signed-in account's OneDrive and " +
626
+ 'Copilot history, inside the tenant; secret-shaped strings are redacted first',
627
+ });
628
+ const done = runSession(sessionId, {
629
+ cfg: { ...cfg, execution: { ...cfg.execution, mode } },
630
+ store: this.store,
631
+ bus: this.bus,
632
+ authorizer,
633
+ signal: controller.signal,
634
+ // Read between tasks, so the one in flight finishes properly first. The batch owns the
635
+ // flag, because a pause is about the whole run and not about this session.
636
+ shouldPause: () => !!this.batch?.pausing,
637
+ // The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
638
+ // and the policy.json of a later task must say so.
639
+ currentMode: () => this.running.get(sessionId)?.mode ?? mode,
640
+ // The session decides whether its tasks are one chain or a set of independent checks.
641
+ continueOnFailure: session.onFailure === 'continue',
642
+ transport,
643
+ runGroup,
644
+ })
645
+ .then(async (outcome) => ({ ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused }))
646
+ .catch(async (e) => {
647
+ this.bus.publish({ sessionId, type: 'run-failed', level: 'error', message: e.message });
648
+ return { ...(await this.tally(sessionId, queuedIds)), error: e.message };
649
+ })
650
+ .finally(() => {
651
+ this.running.delete(sessionId);
652
+ for (const [id, w] of this.waiting) {
653
+ if (w.approval.sessionId === sessionId) {
654
+ w.resolve({ action: 'abort', reason: 'the run ended' });
655
+ this.waiting.delete(id);
656
+ }
657
+ }
658
+ });
659
+ return { started: true, done };
660
+ }
661
+ /**
662
+ * How a finished run went, read from the tasks it was given rather than from its return
663
+ * value.
664
+ *
665
+ * Only the tasks that were queued when the run began are counted, so a session that already
666
+ * held a failure from last week does not make today's run look failed.
667
+ */
668
+ async tally(sessionId, taskIds) {
669
+ const session = await this.store.getSession(sessionId);
670
+ const tally = { ran: 0, failed: 0, leftQueued: 0, failedTitles: [] };
671
+ for (const id of taskIds) {
672
+ const task = session?.tasks.find((t) => t.id === id);
673
+ if (!task)
674
+ continue;
675
+ if (task.status === 'queued') {
676
+ tally.leftQueued += 1;
677
+ continue;
678
+ }
679
+ tally.ran += 1;
680
+ if (task.status !== 'done') {
681
+ tally.failed += 1;
682
+ tally.failedTitles.push(task.title);
683
+ }
684
+ }
685
+ return tally;
686
+ }
687
+ /** Asks the run to stop after the current step. Pending approvals are aborted at once. */
688
+ async stop(sessionId) {
689
+ const r = this.running.get(sessionId);
690
+ if (!r)
691
+ return { stopping: false };
692
+ r.controller.abort();
693
+ for (const [id, w] of this.waiting) {
694
+ if (w.approval.sessionId === sessionId) {
695
+ w.resolve({ action: 'abort', reason: 'stopped by the operator' });
696
+ this.waiting.delete(id);
697
+ }
698
+ }
699
+ await this.store.updateSession(sessionId, (s) => {
700
+ s.status = 'stopping';
701
+ });
702
+ this.bus.publish({ sessionId, type: 'stop-requested', level: 'warn', message: 'stopping after the current step' });
703
+ return { stopping: true };
704
+ }
705
+ // --- running several sessions in turn -------------------------------------------------
706
+ /**
707
+ * Whether the bot is working right now, and nothing else.
708
+ *
709
+ * Every page asks this on a timer so the register's own button can say `live`, which is the
710
+ * only reason it is separate from the registry: the registry reads every session off disk to
711
+ * answer a question this one answers from two fields already in memory. A poll that costs a
712
+ * directory walk every few seconds, on every page, to light up a dot would be a bad trade.
713
+ */
714
+ activity() {
715
+ return { running: this.running.size > 0, sessions: this.running.size, batch: this.batch?.running === true };
716
+ }
717
+ /** The batch in progress, or the last one that ran, or null if none ever has. */
718
+ batchState() {
719
+ return this.batch ? { ...this.batch, sessions: this.batch.sessions.map((s) => ({ ...s })) } : null;
720
+ }
721
+ /**
722
+ * Runs the queued tasks of several sessions, one session after another.
723
+ *
724
+ * The order is the order the ids arrive in, which is the order the operator ticked them.
725
+ * `onFailure` is the same question the task queue already asks, one level up: `stop` treats
726
+ * the sessions as a chain, `continue` treats them as separate pieces of work that happen to
727
+ * have been started together.
728
+ */
729
+ /**
730
+ * What to call a run nobody named. The rule and the reasoning are in `session/runName.ts`;
731
+ * this only fetches the sessions it needs to answer over.
732
+ */
733
+ async suggestRunName(sessionIds, about) {
734
+ return suggestRunName(await this.store.listSessions(), sessionIds, about);
735
+ }
736
+ async startBatch(sessionIds, mode = 'confirm', onFailure = 'stop', model,
737
+ /**
738
+ * The model the independent review runs on, for every session in this run.
739
+ *
740
+ * Separate from `model` because they are separate decisions, and the interesting case is
741
+ * precisely when they differ: a review is worth most when it is not the same mind that did
742
+ * the work, and a run panel that could only set one model made that the hard path.
743
+ */
744
+ reviewModel,
745
+ /** What to call the run. Offered from the plan's name; empty means it goes by its id. */
746
+ name) {
747
+ await this.init();
748
+ if (this.batch?.running)
749
+ return { started: false, reason: 'a batch is already running' };
750
+ const wanted = [...new Set(sessionIds.map((s) => s.trim()).filter(Boolean))];
751
+ if (wanted.length === 0)
752
+ return { started: false, reason: 'no sessions were selected' };
753
+ // Worked out here rather than asked of the operator again: every way into this — the run
754
+ // panel, continuing after a failure, "Run again from here" — either has a field they may
755
+ // have left empty or has no field at all.
756
+ const chosenName = name?.trim() || (await this.suggestRunName(wanted));
757
+ if (wanted.some((id) => this.running.has(id))) {
758
+ return { started: false, reason: 'one of the selected sessions is already running on its own' };
759
+ }
760
+ const sessions = [];
761
+ for (const id of wanted) {
762
+ const s = await this.store.getSession(id);
763
+ if (!s) {
764
+ sessions.push({ sessionId: id, name: id, state: 'skipped', ran: 0, failed: 0, reason: 'no such session' });
765
+ continue;
766
+ }
767
+ const queued = s.tasks.filter((t) => t.status === 'queued').length;
768
+ sessions.push(queued === 0
769
+ ? { sessionId: id, name: s.name, state: 'skipped', ran: 0, failed: 0, reason: 'nothing queued' }
770
+ : { sessionId: id, name: s.name, state: 'waiting', ran: 0, failed: 0 });
771
+ }
772
+ if (!sessions.some((s) => s.state === 'waiting')) {
773
+ return { started: false, reason: 'none of the selected sessions has a queued task' };
774
+ }
775
+ /*
776
+ * The same entrance rule `start` has, asked here too and for the same reason: before a browser
777
+ * is opened. It was missing, and the rule was only met inside the loop, per session, after the
778
+ * window was already up — so on a machine with no isolation the operator pressed the run
779
+ * button, watched Edge open, begin loading the chat and close again, and was told nothing: the
780
+ * session was quietly skipped with the reason held in a batch state nobody was looking at. Asked
781
+ * here, the refusal is the answer to the press, shown under the button that caused it.
782
+ */
783
+ {
784
+ const cfg = await this.settings.load();
785
+ const blocked = unattendedPrecondition({
786
+ mode,
787
+ allowedPrograms: cfg.execution.allowedPrograms,
788
+ isolation: cfg.execution.isolation,
789
+ lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
790
+ });
791
+ if (blocked)
792
+ return { started: false, reason: blocked };
793
+ }
794
+ // One model for the whole run, chosen here rather than opened on every session first. It
795
+ // is written onto the sessions instead of being held for the run, so what the session says
796
+ // it will use and what it used are the same thing afterwards — including for anyone who
797
+ // opens one of them tomorrow and wonders which model produced that summary.
798
+ const wantedModel = model?.trim();
799
+ const wantedReviewModel = reviewModel?.trim();
800
+ if (wantedModel || wantedReviewModel) {
801
+ for (const entry of sessions) {
802
+ if (entry.state !== 'waiting')
803
+ continue;
804
+ await this.store.updateSession(entry.sessionId, (s) => {
805
+ if (wantedModel)
806
+ s.model = wantedModel;
807
+ // Only the model is set here, never whether the review happens: a run panel is about
808
+ // this run, and silently switching a session's review on or off from it would be a
809
+ // change to the session that outlives the run.
810
+ if (wantedReviewModel)
811
+ s.review = { ...DEFAULT_REVIEW, ...s.review, model: wantedReviewModel };
812
+ });
813
+ }
814
+ }
815
+ this.batch = {
816
+ id: newId('b-'),
817
+ startedAt: new Date().toISOString(),
818
+ name: chosenName,
819
+ mode,
820
+ onFailure,
821
+ stopping: false,
822
+ pausing: false,
823
+ running: true,
824
+ sessions,
825
+ };
826
+ void this.runBatch();
827
+ return { started: true, batch: this.batchState() };
828
+ }
829
+ /** Stops the session that is running now and leaves the rest of the batch unstarted. */
830
+ /**
831
+ * Holds the run after the task that is running, and lets that task finish first.
832
+ *
833
+ * Not a stop: the task in flight runs its checks, is reviewed and commits, exactly as it would
834
+ * have. What is held is the queue behind it, and the sessions after this one, both of which
835
+ * stay as they are — so continuing is the same act it always was, and the button for it is the
836
+ * one already on the register.
837
+ *
838
+ * It does not release the browser early either. The run ends when the current task does, and
839
+ * the window closes with it, because the Edge profile is single-writer and a run that sat
840
+ * holding it would stop every other session from doing anything at all.
841
+ */
842
+ async pauseBatch() {
843
+ const batch = this.batch;
844
+ if (!batch?.running || batch.stopping)
845
+ return { pausing: false };
846
+ batch.pausing = true;
847
+ this.bus.publish({
848
+ sessionId: batch.sessions.find((s) => s.state === 'running')?.sessionId ?? batch.id,
849
+ type: 'batch-pausing',
850
+ level: 'info',
851
+ message: 'pausing: the task that is running will finish, and the rest of the queue stays as it is',
852
+ data: { batchId: batch.id },
853
+ });
854
+ return { pausing: true };
855
+ }
856
+ /** Takes the hold off a run that has not ended yet, so the queue carries on where it was. */
857
+ async resumeBatch() {
858
+ const batch = this.batch;
859
+ if (!batch?.running || !batch.pausing)
860
+ return { pausing: false };
861
+ batch.pausing = false;
862
+ this.bus.publish({
863
+ sessionId: batch.sessions.find((s) => s.state === 'running')?.sessionId ?? batch.id,
864
+ type: 'batch-resumed',
865
+ level: 'info',
866
+ message: 'the hold is off; the queue carries on',
867
+ data: { batchId: batch.id },
868
+ });
869
+ return { pausing: false };
870
+ }
871
+ async stopBatch() {
872
+ const batch = this.batch;
873
+ if (!batch?.running)
874
+ return { stopping: false };
875
+ batch.stopping = true;
876
+ const current = batch.sessions.find((s) => s.state === 'running');
877
+ if (current)
878
+ await this.stop(current.sessionId);
879
+ return { stopping: true };
880
+ }
881
+ /**
882
+ * The batch loop. Nothing here runs in parallel, on purpose: see `beginRun`.
883
+ *
884
+ * Each session is judged by the tasks it was given. A session that ran everything without a
885
+ * failure is `done`; one with a failed task is `failed`; one the operator stopped, or one
886
+ * whose chain stopped early with no failure of its own, is `stopped`.
887
+ */
888
+ async runBatch() {
889
+ const batch = this.batch;
890
+ if (!batch)
891
+ return;
892
+ /*
893
+ * One browser for the whole batch.
894
+ *
895
+ * Every session used to open its own window and close it again: a fresh launch, a fresh
896
+ * sign-in check and a fresh grab at the profile lock, several times over, for a machine
897
+ * that can only have one of them open at a time anyway. The window is opened here, handed
898
+ * to each session in turn, and closed when the last one is done — so between sessions the
899
+ * only thing that changes is which conversation is on screen.
900
+ */
901
+ let browser = null;
902
+ try {
903
+ const cfg = await this.settings.load();
904
+ browser = await openBrowser(cfg, this.bus, join(cfg.resolved.runsDir, '_browser'), batch.id);
905
+ this.bus.publish({
906
+ sessionId: batch.sessions[0]?.sessionId ?? batch.id,
907
+ type: 'batch-browser-open',
908
+ level: 'info',
909
+ message: `one browser window for all ${batch.sessions.length} session(s) in this run`,
910
+ data: { batchId: batch.id },
911
+ });
912
+ }
913
+ catch (e) {
914
+ // Without a window nothing can run, and saying so once is better than failing every
915
+ // session in turn with the same message.
916
+ for (const entry of batch.sessions) {
917
+ if (entry.state === 'waiting') {
918
+ entry.state = 'failed';
919
+ entry.reason = `the browser could not be opened: ${e.message}`;
920
+ }
921
+ }
922
+ batch.running = false;
923
+ batch.finishedAt = new Date().toISOString();
924
+ this.bus.publish({
925
+ sessionId: batch.id,
926
+ type: 'batch-error',
927
+ level: 'error',
928
+ message: `the browser could not be opened: ${e.message}`,
929
+ });
930
+ return;
931
+ }
932
+ try {
933
+ /*
934
+ * One group for the whole batch, made before the loop rather than per session.
935
+ *
936
+ * That is the entire point of it: the register can then draw one line around everything
937
+ * this press of the button set off, across sessions, which is the question a list of
938
+ * tasks sorted by time cannot answer.
939
+ */
940
+ const runGroup = {
941
+ id: batch.id,
942
+ startedAt: batch.startedAt,
943
+ sessions: batch.sessions.filter((s) => s.state === 'waiting').length,
944
+ ...(batch.name ? { name: batch.name } : {}),
945
+ };
946
+ /*
947
+ * The same run, recorded on each session, with what it was asked to do.
948
+ *
949
+ * Written before the first session starts and for every session including the ones the
950
+ * run may never reach, because that is precisely the case it exists for: a failure in
951
+ * session one leaves sessions two and three with no tasks stamped and no way to know
952
+ * they were ever part of this. Going back afterwards and running the rest again needs
953
+ * the whole list, and this is the only place it is still true.
954
+ */
955
+ let order = 0;
956
+ for (const entry of batch.sessions) {
957
+ if (entry.state !== 'waiting')
958
+ continue;
959
+ const at = order;
960
+ order += 1;
961
+ await this.store.updateSession(entry.sessionId, (s) => {
962
+ s.runGroup = {
963
+ ...runGroup,
964
+ order: at,
965
+ taskIds: s.tasks.filter((t) => t.status === 'queued').map((t) => t.id),
966
+ mode: batch.mode,
967
+ onFailure: batch.onFailure,
968
+ };
969
+ });
970
+ }
971
+ for (const entry of batch.sessions) {
972
+ if (entry.state !== 'waiting')
973
+ continue;
974
+ if (batch.stopping) {
975
+ entry.state = 'skipped';
976
+ entry.reason = 'the batch was stopped before this session started';
977
+ continue;
978
+ }
979
+ // A hold applies to the sessions that have not started as well as to the queue inside
980
+ // the one that has. They are left exactly as they were, which is what makes continuing
981
+ // afterwards the same act as continuing after anything else.
982
+ if (batch.pausing) {
983
+ entry.state = 'skipped';
984
+ entry.reason = 'the run was paused before this session started; its tasks are still queued';
985
+ continue;
986
+ }
987
+ entry.state = 'running';
988
+ this.bus.publish({
989
+ sessionId: entry.sessionId,
990
+ type: 'batch-session-started',
991
+ level: 'info',
992
+ message: `starting as part of a batch of ${batch.sessions.length} session(s), in ${batch.mode} mode`,
993
+ data: { batchId: batch.id },
994
+ });
995
+ const begun = await this.beginRun(entry.sessionId, batch.mode, browser, runGroup);
996
+ if (!begun.started) {
997
+ entry.state = 'skipped';
998
+ entry.reason = begun.reason;
999
+ continue;
1000
+ }
1001
+ const tally = await begun.done;
1002
+ entry.ran = tally.ran;
1003
+ entry.failed = tally.failed;
1004
+ if (tally.error) {
1005
+ entry.state = 'failed';
1006
+ entry.reason = tally.error;
1007
+ }
1008
+ else if (tally.failed > 0) {
1009
+ entry.state = 'failed';
1010
+ entry.reason = `${tally.failed} task(s) did not finish: ${tally.failedTitles.join(', ')}`;
1011
+ }
1012
+ else if (tally.paused) {
1013
+ entry.state = 'stopped';
1014
+ entry.reason = `paused after ${tally.ran} task(s); ${tally.leftQueued} still queued`;
1015
+ }
1016
+ else if (batch.stopping || tally.leftQueued > 0) {
1017
+ entry.state = 'stopped';
1018
+ entry.reason = `${tally.leftQueued} task(s) never started`;
1019
+ }
1020
+ else {
1021
+ entry.state = 'done';
1022
+ }
1023
+ this.bus.publish({
1024
+ sessionId: entry.sessionId,
1025
+ type: 'batch-session-finished',
1026
+ level: entry.state === 'failed' ? 'warn' : 'info',
1027
+ message: `this session ended ${entry.state} inside the batch`,
1028
+ data: { batchId: batch.id, ran: entry.ran, failed: entry.failed },
1029
+ });
1030
+ if (entry.state === 'failed' && batch.onFailure === 'stop') {
1031
+ batch.stopping = true;
1032
+ this.bus.publish({
1033
+ sessionId: entry.sessionId,
1034
+ type: 'batch-stopped-early',
1035
+ level: 'warn',
1036
+ message: 'the batch is set to stop on a failure, so the sessions after this one stay as they are',
1037
+ data: { batchId: batch.id },
1038
+ });
1039
+ }
1040
+ }
1041
+ }
1042
+ catch (e) {
1043
+ // Nothing above is expected to throw: a failing run resolves rather than rejects. If
1044
+ // something does, the batch has to end here and say so, because this promise is not
1045
+ // awaited anywhere and an escaping rejection would take the whole API process down.
1046
+ const current = batch.sessions.find((s) => s.state === 'running');
1047
+ if (current) {
1048
+ current.state = 'failed';
1049
+ current.reason = e.message;
1050
+ }
1051
+ this.bus.publish({
1052
+ sessionId: current?.sessionId ?? batch.id,
1053
+ type: 'batch-error',
1054
+ level: 'error',
1055
+ message: e.message,
1056
+ });
1057
+ }
1058
+ finally {
1059
+ for (const entry of batch.sessions) {
1060
+ if (entry.state === 'waiting' || entry.state === 'running') {
1061
+ entry.state = 'skipped';
1062
+ entry.reason ??= 'the batch ended before this session ran';
1063
+ }
1064
+ }
1065
+ // The window outlived every session in the batch; it does not outlive the batch.
1066
+ await browser?.close().catch(() => undefined);
1067
+ batch.running = false;
1068
+ batch.finishedAt = new Date().toISOString();
1069
+ }
1070
+ }
1071
+ // --- plans ----------------------------------------------------------------------------
1072
+ /** The brief the operator hands to a chat model, in the language the interface is in. */
1073
+ /**
1074
+ * The persona, assembled: the software part (fixed, this project's), the machine's projects
1075
+ * by path, and the organisation's part (the operator's, edited on the plan page). Returned
1076
+ * whole for copying and in parts for showing, so the page can say which is which.
1077
+ */
1078
+ async planBrief(opts) {
1079
+ // The machine's projects go into the brief by absolute path, so a plan across a front
1080
+ // end, a back end and a test suite is written with the folders that exist rather than
1081
+ // with three paths the chat model had to ask for and the operator typed from memory.
1082
+ const lang = opts.lang === 'bg' ? 'bg' : 'en';
1083
+ const project = await this.project();
1084
+ const projects = [
1085
+ ...(project.rootDir ? [{ name: project.name, rootDir: project.rootDir, repo: project.repoOk, isDefault: true }] : []),
1086
+ ...project.others.map((o) => ({ name: o.name, rootDir: o.rootDir, repo: o.repoOk, isDefault: false })),
1087
+ ];
1088
+ const [organisation, persona, work] = await Promise.all([
1089
+ this.getContext('organisation', lang),
1090
+ this.getContext('persona', lang),
1091
+ this.getContext('work', lang),
1092
+ ]);
1093
+ /*
1094
+ * Whether a run with nobody watching can start here, asked of the same rule the runner applies
1095
+ * at the entrance — so the brief cannot tell Kerrigan the unattended button works while the run
1096
+ * refuses it, which is exactly what the first test of her did.
1097
+ */
1098
+ const cfg = await this.settings.load();
1099
+ const unattendedRule = { mode: 'unattended', allowedPrograms: cfg.execution.allowedPrograms, isolation: cfg.execution.isolation, lockedToConfirm: cfg.policyLock?.maxMode === 'confirm' };
1100
+ const unattendedBlocked = !unattendedPrecondition(unattendedRule)
1101
+ ? undefined
1102
+ : unattendedIsolationRefusal('unattended', cfg.execution.isolation)
1103
+ ? 'isolation'
1104
+ : 'allowlist';
1105
+ return {
1106
+ text: planBrief({
1107
+ lang,
1108
+ projects,
1109
+ organisation: organisation.content,
1110
+ organisationExample: organisation.example,
1111
+ persona: persona.content,
1112
+ personaExample: persona.example,
1113
+ work: work.content,
1114
+ workExample: work.example,
1115
+ unattendedBlocked,
1116
+ }),
1117
+ software: planBrief({ lang, projects }),
1118
+ organisation: organisation.content,
1119
+ customised: organisation.customised,
1120
+ example: organisation.example,
1121
+ persona: persona.content,
1122
+ personaCustomised: persona.customised,
1123
+ personaExample: persona.example,
1124
+ work: work.content,
1125
+ workCustomised: work.customised,
1126
+ workExample: work.example,
1127
+ };
1128
+ }
1129
+ /**
1130
+ * Reads a pasted plan and says what it means, or what is wrong with it.
1131
+ *
1132
+ * A valid plan is also checked against what the store already holds, because the most
1133
+ * likely mistake with this page is not a malformed document: it is pressing the button
1134
+ * twice, or pasting the plan that is already imported instead of the corrected one. That is
1135
+ * reported, not refused — importing the same work twice is a thing people legitimately do.
1136
+ */
1137
+ async checkPlan(text) {
1138
+ await this.init();
1139
+ const check = checkPlan(text ?? '');
1140
+ if (!check.ok)
1141
+ return { ...check, duplicates: [] };
1142
+ /*
1143
+ * A plan can ask for version control in a folder that is not a repository on this machine.
1144
+ * The document is not wrong; this machine is not ready for it. It is refused all the same,
1145
+ * and the message says which of the two it is, because importing it would create sessions
1146
+ * that claim a way back they do not have.
1147
+ */
1148
+ const repoIssues = [];
1149
+ check.plan.sessions.forEach((session, i) => {
1150
+ if (session.vcs?.enabled === false)
1151
+ return;
1152
+ const dir = session.vcs?.repoDir?.trim() || session.mirror?.rootDir?.trim() || '';
1153
+ const reason = repoUnusableReason(dir);
1154
+ if (reason)
1155
+ repoIssues.push({ path: `sessions[${i}].vcs.repoDir`, message: reason });
1156
+ else if (!dir) {
1157
+ repoIssues.push({
1158
+ path: `sessions[${i}].vcs.repoDir`,
1159
+ message: 'This session asks for version control but names no repository. Give it the absolute path of a git ' +
1160
+ 'repository, or set "enabled": false for it.',
1161
+ });
1162
+ }
1163
+ });
1164
+ if (repoIssues.length > 0)
1165
+ return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
1166
+ return { ...check, duplicates: await this.findDuplicates(check.plan) };
1167
+ }
1168
+ /**
1169
+ * Sessions the store already holds that this plan would create again, task for task.
1170
+ *
1171
+ * Matched on the name and on the text that would actually be sent, in order. Not on the
1172
+ * level 2, the model or the branch names: those are edited on a session after an import, and
1173
+ * a session whose instructions were tightened by hand is still the same session this plan
1174
+ * describes.
1175
+ */
1176
+ async findDuplicates(plan) {
1177
+ const existing = await this.store.listSessions();
1178
+ const out = [];
1179
+ for (const planned of plan.sessions) {
1180
+ const wanted = plannedSessionSignature(planned);
1181
+ const match = existing.find((s) => s.name.trim().toLowerCase() === planned.name.trim().toLowerCase() &&
1182
+ s.tasks.length === planned.tasks.length &&
1183
+ s.tasks.map((t) => taskSignature(t)).join('\u0001') === wanted);
1184
+ if (match) {
1185
+ out.push({ name: match.name, sessionId: match.id, createdAt: match.createdAt, tasks: match.tasks.length });
1186
+ }
1187
+ }
1188
+ return out;
1189
+ }
1190
+ /**
1191
+ * Validates a plan and creates everything in it. Nothing is started.
1192
+ *
1193
+ * A plan that does not validate is refused as a whole rather than imported in part: half a
1194
+ * plan in the session list is harder to recognise, and harder to undo, than none of it.
1195
+ */
1196
+ async importPlan(text) {
1197
+ await this.init();
1198
+ const check = await this.checkPlan(text);
1199
+ if (!check.ok)
1200
+ return { ok: false, check };
1201
+ const cfg = await this.settings.load();
1202
+ /*
1203
+ * The persona in force right now is the one this import carries. Read here, once, and written
1204
+ * into every task, so a session keeps the approach it was created with: changing the field
1205
+ * afterwards changes the next import, not work already queued. The content does not depend on
1206
+ * the language asked for; only the shipped example does.
1207
+ */
1208
+ const persona = (await this.getContext('persona', 'en')).content;
1209
+ const result = await importPlan(this.store, check.plan, (cfg.copilot.defaultModel ?? '').trim(), (cfg.copilot.defaultReviewModel ?? '').trim(), persona);
1210
+ return {
1211
+ ok: true,
1212
+ result: { ...result, warnings: [...check.warnings, ...result.warnings] },
1213
+ summary: check.summary,
1214
+ // The sessions this import has just made a second copy of, reported after the fact for
1215
+ // the same reason it is reported before: so nobody has to notice it in a list.
1216
+ duplicates: check.duplicates,
1217
+ };
1218
+ }
1219
+ // --- approvals ------------------------------------------------------------------------
1220
+ pendingApprovals(sessionId) {
1221
+ return [...this.waiting.values()].map((w) => w.approval).filter((a) => !sessionId || a.sessionId === sessionId);
1222
+ }
1223
+ /**
1224
+ * Answers one pending approval.
1225
+ *
1226
+ * `run-all` is `run` plus a decision about everything after it: the run stops asking. It is
1227
+ * here rather than as a separate endpoint because it is the answer to the question on
1228
+ * screen, and because the step in front of the operator is the one that earns their trust
1229
+ * in the rest. The deny list is untouched by it.
1230
+ */
1231
+ decide(approvalId, action) {
1232
+ const w = this.waiting.get(approvalId);
1233
+ if (!w)
1234
+ return { ok: false };
1235
+ this.waiting.delete(approvalId);
1236
+ if (action === 'run-all')
1237
+ this.setRunMode(w.approval.sessionId, 'unattended');
1238
+ const decision = action === 'run' || action === 'run-all'
1239
+ ? { action: 'run' }
1240
+ : action === 'skip'
1241
+ ? { action: 'skip', reason: 'skipped by the operator' }
1242
+ : { action: 'abort', reason: 'aborted by the operator' };
1243
+ w.resolve(decision);
1244
+ this.bus.publish({ sessionId: w.approval.sessionId, taskId: w.approval.taskId, type: 'approval-decided', level: 'info',
1245
+ message: `step ${w.approval.stepId}: ${action}`, data: { approvalId, action } });
1246
+ return { ok: true };
1247
+ }
1248
+ /**
1249
+ * Switches a run between asking and not asking, while it is running.
1250
+ *
1251
+ * Going to `unattended` also releases whatever is already waiting, because leaving a step
1252
+ * on screen asking a question nobody will answer again is the one outcome this must not
1253
+ * produce. Going back to `confirm` only affects steps that have not been proposed yet.
1254
+ */
1255
+ setRunMode(sessionId, mode) {
1256
+ const run = this.running.get(sessionId);
1257
+ if (!run)
1258
+ return { ok: false };
1259
+ if (run.mode === mode)
1260
+ return { ok: true, mode };
1261
+ /*
1262
+ * "Run the rest without asking" is a request to become an unattended run, so it answers to the
1263
+ * same preconditions as one started that way. Refusing here keeps the button honest: it cannot
1264
+ * hand out an autonomy the run would not have been allowed to start with.
1265
+ */
1266
+ if (mode === 'unattended') {
1267
+ const blocked = unattendedPrecondition({ ...run.policy, mode });
1268
+ if (blocked)
1269
+ return { ok: false, reason: blocked };
1270
+ }
1271
+ run.mode = mode;
1272
+ /*
1273
+ * The authorizer's rules read the mode from this same policy object, so it changes with the run.
1274
+ * Without this line "run the rest without asking" stopped the asking but left the rules believing
1275
+ * a person was watching: the extra unattended rule — no allowed interpreter evaluating a string,
1276
+ * no shell inside a shell — was never applied to the steps that followed, exactly the steps no
1277
+ * one would read.
1278
+ */
1279
+ run.policy.mode = mode;
1280
+ this.bus.publish({
1281
+ sessionId,
1282
+ type: 'run-mode-changed',
1283
+ level: mode === 'unattended' ? 'warn' : 'info',
1284
+ message: mode === 'unattended'
1285
+ ? 'the rest of this run will execute without asking; denied patterns are still refused'
1286
+ : 'every further step will be shown for approval again',
1287
+ data: { mode },
1288
+ });
1289
+ if (mode === 'unattended') {
1290
+ for (const [id, w] of this.waiting) {
1291
+ if (w.approval.sessionId !== sessionId)
1292
+ continue;
1293
+ // A fetch waits for its own answer: "run the rest without asking" is not an answer to it.
1294
+ if (w.approval.network)
1295
+ continue;
1296
+ this.waiting.delete(id);
1297
+ w.resolve({ action: 'run' });
1298
+ }
1299
+ }
1300
+ return { ok: true, mode };
1301
+ }
1302
+ runMode(sessionId) {
1303
+ return this.running.get(sessionId)?.mode;
1304
+ }
1305
+ webAuthorizer(policy, signal) {
1306
+ return makeAuthorizer(policy, (step, ctx, held) => new Promise((resolvePromise) => {
1307
+ // The operator may have pressed "run the rest without asking" on an earlier step.
1308
+ // This is checked per step rather than captured once, which is what makes the switch
1309
+ // take effect from the very next step instead of the next run. A held step is the
1310
+ // exception: it is asked about in an unattended run too, which is the point of holding it.
1311
+ if (!held && this.running.get(ctx.sessionId ?? '')?.mode === 'unattended') {
1312
+ resolvePromise({ action: 'run' });
1313
+ return;
1314
+ }
1315
+ const approval = {
1316
+ id: newId('a-'),
1317
+ sessionId: ctx.sessionId ?? '',
1318
+ taskId: ctx.taskId ?? '',
1319
+ stepId: step.id,
1320
+ description: `[${step.shell ?? 'pwsh'}] ${step.cmd}`,
1321
+ createdAt: new Date().toISOString(),
1322
+ ...(held ? { network: held.network } : {}),
1323
+ };
1324
+ if (signal.aborted) {
1325
+ resolvePromise({ action: 'abort', reason: 'stopped by the operator' });
1326
+ return;
1327
+ }
1328
+ this.waiting.set(approval.id, { approval, resolve: resolvePromise });
1329
+ this.bus.publish({ sessionId: approval.sessionId, taskId: approval.taskId, type: 'approval-requested', level: 'warn',
1330
+ message: held
1331
+ ? `held for the operator — ${held.network}: ${approval.description}`
1332
+ : `waiting for approval: ${approval.description}`,
1333
+ data: { ...approval } });
1334
+ }));
1335
+ }
1336
+ // --- events ---------------------------------------------------------------------------
1337
+ recentEvents(sessionId) {
1338
+ return this.bus.recent(sessionId);
1339
+ }
1340
+ subscribe(sessionId, handler) {
1341
+ return this.bus.subscribe(sessionId, handler);
1342
+ }
1343
+ // --- version control ----------------------------------------------------------------------
1344
+ /** Whether version control can do its job in this session, asked before a run. */
1345
+ async vcsStatus(sessionId) {
1346
+ await this.init();
1347
+ const session = await this.store.getSession(sessionId);
1348
+ if (!session)
1349
+ throw new Error('No such session.');
1350
+ const git = await gitAvailable();
1351
+ if (!git)
1352
+ return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
1353
+ // `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
1354
+ // those differ, and the second is the one the operator is asking about.
1355
+ return { ...(await vcsPreflight(session)), git, work: sessionBranches(session) };
1356
+ }
1357
+ /**
1358
+ * What going back to before a task would do, and then doing it.
1359
+ *
1360
+ * Two calls rather than one because the operator has to read the consequence before they
1361
+ * agree to it: which commits the restored branch will not have, and which branch keeps them.
1362
+ */
1363
+ async restorePreview(sessionId, taskId) {
1364
+ await this.init();
1365
+ const session = await this.store.getSession(sessionId);
1366
+ if (!session)
1367
+ throw new Error('No such session.');
1368
+ const task = session.tasks.find((t) => t.id === taskId);
1369
+ if (!task)
1370
+ throw new Error('No such task.');
1371
+ return await restorePreview(session, task);
1372
+ }
1373
+ async restore(sessionId, taskId) {
1374
+ await this.init();
1375
+ if (this.running.has(sessionId)) {
1376
+ return { ok: false, problem: 'This session is running. Stop it before moving the repository to another branch.' };
1377
+ }
1378
+ if (this.batch?.running) {
1379
+ return { ok: false, problem: 'A batch is running. Stop it before moving the repository to another branch.' };
1380
+ }
1381
+ const session = await this.store.getSession(sessionId);
1382
+ if (!session)
1383
+ throw new Error('No such session.');
1384
+ const task = session.tasks.find((t) => t.id === taskId);
1385
+ if (!task)
1386
+ throw new Error('No such task.');
1387
+ return await restoreToBase(session, task, this.bus);
1388
+ }
1389
+ /**
1390
+ * Everything one run was asked to do, from a given task onward, in the order it would run.
1391
+ *
1392
+ * The run's own record on each session is what makes this answerable. Tasks are stamped when
1393
+ * they start, so after a failure the tasks that never ran carry nothing and the sessions that
1394
+ * were never reached carry nothing either — asking the tasks would return the half of the run
1395
+ * that already happened, which is the opposite of what is wanted.
1396
+ */
1397
+ async affectedByRestart(session, task) {
1398
+ const group = session.runGroup;
1399
+ /** The tasks a session was asked to do in that run, in its own order, falling back to all. */
1400
+ const askedOf = (s) => {
1401
+ const ids = s.runGroup?.id === group?.id ? (s.runGroup?.taskIds ?? []) : [];
1402
+ return ids.length > 0 ? s.tasks.filter((t) => ids.includes(t.id)) : s.tasks;
1403
+ };
1404
+ if (!group) {
1405
+ // No run on record — an older session, or one whose tasks were started by hand. Then the
1406
+ // honest scope is this session alone, from this task onward.
1407
+ const from = session.tasks.findIndex((t) => t.id === task.id);
1408
+ return { sessions: [session], tasks: session.tasks.slice(Math.max(0, from)).map((t) => ({ session, task: t })) };
1409
+ }
1410
+ const all = await this.store.listSessions();
1411
+ const inRun = all.filter((s) => s.runGroup?.id === group.id).sort((a, b) => (a.runGroup?.order ?? 0) - (b.runGroup?.order ?? 0));
1412
+ const out = [];
1413
+ const sessions = [];
1414
+ for (const s of inRun) {
1415
+ // Sessions the run had already finished with are none of this restart's business.
1416
+ if ((s.runGroup?.order ?? 0) < (group.order ?? 0))
1417
+ continue;
1418
+ const mine = s.id === session.id;
1419
+ /*
1420
+ * The session the task is in starts from that task; every session after it starts from
1421
+ * the top, because it was either never reached or its work is downstream of this one.
1422
+ *
1423
+ * The pivot is looked for in what the run was asked to do, and then in the session's own
1424
+ * list. The second lookup is not redundant: a task added since the run is not in the
1425
+ * run's list, and skipping its session because of that would quietly drop the very
1426
+ * session the operator pressed the button on.
1427
+ */
1428
+ let scope = askedOf(s);
1429
+ let start = 0;
1430
+ if (mine) {
1431
+ start = scope.findIndex((t) => t.id === task.id);
1432
+ if (start < 0) {
1433
+ scope = s.tasks;
1434
+ start = scope.findIndex((t) => t.id === task.id);
1435
+ if (start < 0)
1436
+ continue;
1437
+ }
1438
+ }
1439
+ const slice = scope.slice(start);
1440
+ if (slice.length === 0)
1441
+ continue;
1442
+ sessions.push(s);
1443
+ for (const t of slice)
1444
+ out.push({ session: s, task: t });
1445
+ }
1446
+ return { sessions, tasks: out, group };
1447
+ }
1448
+ /** What starting again from this task would re-queue, and what it would do to the code. */
1449
+ async restartPlan(sessionId, taskId) {
1450
+ await this.init();
1451
+ const session = await this.store.getSession(sessionId);
1452
+ if (!session)
1453
+ throw new Error('No such session.');
1454
+ const task = session.tasks.find((t) => t.id === taskId);
1455
+ if (!task)
1456
+ throw new Error('No such task.');
1457
+ const { sessions, tasks, group } = await this.affectedByRestart(session, task);
1458
+ const from = { sessionId: session.id, sessionName: session.name, taskId: task.id, title: task.title, status: task.status };
1459
+ const plan = {
1460
+ ok: tasks.length > 0,
1461
+ problem: tasks.length === 0 ? 'There is nothing to run again from this task.' : undefined,
1462
+ runId: group?.id,
1463
+ runStartedAt: group?.startedAt,
1464
+ from,
1465
+ tasks: tasks.map(({ session: s, task: t }) => ({
1466
+ sessionId: s.id,
1467
+ sessionName: s.name,
1468
+ taskId: t.id,
1469
+ title: t.title,
1470
+ status: t.status,
1471
+ alreadyQueued: t.status === 'queued',
1472
+ })),
1473
+ sessions: sessions.map((s) => ({ id: s.id, name: s.name, tasks: tasks.filter((x) => x.session.id === s.id).length })),
1474
+ restores: [],
1475
+ mode: group?.mode ?? 'unattended',
1476
+ onFailure: group?.onFailure ?? session.onFailure ?? 'stop',
1477
+ };
1478
+ /*
1479
+ * One restore per repository, keyed on the folder rather than on the session.
1480
+ *
1481
+ * Three sessions working in one repository must not take it back three times to three
1482
+ * different commits; the earliest affected task is the one that decides, because that is
1483
+ * the state the whole re-run starts from.
1484
+ */
1485
+ const byRepo = new Map();
1486
+ for (const entry of tasks) {
1487
+ const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
1488
+ if (!dir)
1489
+ continue;
1490
+ // A repository whose affected tasks never ran has nothing to go back to: the run never
1491
+ // touched it. Taking "no base commit recorded" as a refusal blocked "run again from
1492
+ // here" for every plan whose later sessions had not started yet — which is the usual
1493
+ // shape of a run that stopped in the middle.
1494
+ if (!entry.task.startedAt)
1495
+ continue;
1496
+ if (!byRepo.has(dir))
1497
+ byRepo.set(dir, entry);
1498
+ }
1499
+ for (const [dir, entry] of byRepo) {
1500
+ const preview = await restorePreview(entry.session, entry.task);
1501
+ plan.restores.push({
1502
+ repoDir: preview.repoDir || dir,
1503
+ ok: preview.ok,
1504
+ problem: preview.problem,
1505
+ forTask: entry.task.title,
1506
+ baseCommit: preview.baseCommit,
1507
+ branchName: preview.branchName,
1508
+ leftBehind: preview.leftBehind,
1509
+ keptOn: preview.keptOn,
1510
+ });
1511
+ }
1512
+ return plan;
1513
+ }
1514
+ /**
1515
+ * Put the code back, queue the task and everything after it, and start the run again.
1516
+ *
1517
+ * The order matters and is not negotiable: restore first, then queue, then start. Queueing
1518
+ * before restoring would leave a window in which a run could pick up a task against a tree
1519
+ * that has not moved yet, and starting before queueing would run an empty batch.
1520
+ *
1521
+ * `restore` can be turned off, for the case where the repository is already where it should
1522
+ * be, or where version control was never on. `start` can be turned off to do the first two
1523
+ * and stop there, leaving a queue and an untouched Start button — the same shape an import
1524
+ * has, and the same reason: the moment before a run is the one worth being able to stop at.
1525
+ */
1526
+ async restartFrom(sessionId, taskId, opts = {}) {
1527
+ await this.init();
1528
+ const idle = { started: false, requeued: 0, restored: [] };
1529
+ if (this.batch?.running)
1530
+ return { ...idle, reason: 'A run is in progress. Stop it first.' };
1531
+ const plan = await this.restartPlan(sessionId, taskId);
1532
+ if (!plan.ok)
1533
+ return { ...idle, reason: plan.problem };
1534
+ if (plan.sessions.some((s) => this.running.has(s.id))) {
1535
+ return { ...idle, reason: 'One of these sessions is running on its own. Stop it first.' };
1536
+ }
1537
+ const restored = [];
1538
+ if (opts.restore !== false) {
1539
+ for (const entry of plan.restores) {
1540
+ if (!entry.ok)
1541
+ return { ...idle, reason: `${entry.repoDir}: ${entry.problem ?? 'the restore could not be prepared.'}` };
1542
+ }
1543
+ // Re-read each repository's own pivot and do it for real. The preview above is what the
1544
+ // operator agreed to; this is the same call, one step further.
1545
+ const session = (await this.store.getSession(sessionId));
1546
+ const task = session.tasks.find((t) => t.id === taskId);
1547
+ const { tasks } = await this.affectedByRestart(session, task);
1548
+ const seenRepo = new Set();
1549
+ for (const entry of tasks) {
1550
+ const dir = (entry.session.vcs?.enabled ? entry.session.vcs.repoDir : '')?.trim();
1551
+ if (!dir || seenRepo.has(dir))
1552
+ continue;
1553
+ seenRepo.add(dir);
1554
+ const done = await restoreToBase(entry.session, entry.task, this.bus);
1555
+ if (!done.ok)
1556
+ return { ...idle, reason: `${dir}: ${done.problem ?? 'the restore failed.'}` };
1557
+ restored.push(`${dir} -> ${done.branch ?? ''}`);
1558
+ }
1559
+ }
1560
+ let requeued = 0;
1561
+ for (const t of plan.tasks) {
1562
+ if (t.alreadyQueued)
1563
+ continue;
1564
+ try {
1565
+ await this.store.rerunTask(t.sessionId, t.taskId);
1566
+ requeued += 1;
1567
+ }
1568
+ catch (e) {
1569
+ // One task refusing to be queued again must not leave the rest half-reset with no
1570
+ // explanation, so it stops here and says which one and why.
1571
+ return { ...idle, restored, reason: `"${t.title}" could not be queued again: ${e.message}` };
1572
+ }
1573
+ }
1574
+ this.bus.publish({
1575
+ sessionId,
1576
+ taskId,
1577
+ type: 'run-restarted',
1578
+ level: 'info',
1579
+ message: `starting again from "${plan.from.title}": ${plan.tasks.length} task(s) in ${plan.sessions.length} session(s)` +
1580
+ (restored.length > 0 ? `, after taking ${restored.length} repository(ies) back` : ''),
1581
+ data: { requeued, restored },
1582
+ });
1583
+ if (opts.start === false)
1584
+ return { started: false, reason: 'prepared, not started', requeued, restored };
1585
+ const started = await this.startBatch(plan.sessions.map((x) => x.id), opts.mode ?? plan.mode, opts.onFailure ?? plan.onFailure, undefined, undefined,
1586
+ // Named after the task it goes back to, which is the one thing this run is about and the
1587
+ // only thing a heading could usefully say. There is no field to type into on a task card,
1588
+ // so leaving it to the operator meant leaving it blank every time.
1589
+ await this.suggestRunName(plan.sessions.map((x) => x.id), plan.from.title));
1590
+ return { started: started.started, reason: started.reason, requeued, restored, batch: started.batch };
1591
+ }
1592
+ /** Why a folder cannot be used for version control, or null when it can. */
1593
+ repoProblem(dir) {
1594
+ return repoUnusableReason(dir);
1595
+ }
1596
+ // --- the project being worked on ---------------------------------------------------------
1597
+ /**
1598
+ * The folder new sessions start pointed at, and whether it can carry version control.
1599
+ *
1600
+ * The repository check travels with it because the two questions are always asked together:
1601
+ * somewhere to work, and whether that somewhere can be branched and committed.
1602
+ */
1603
+ async project() {
1604
+ const cfg = await this.settings.load();
1605
+ const rootDir = (cfg.project?.rootDir ?? '').trim();
1606
+ const problem = repoUnusableReason(rootDir);
1607
+ const others = (cfg.project?.others ?? []).map((o) => {
1608
+ const p = repoUnusableReason(o.rootDir);
1609
+ return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror };
1610
+ });
1611
+ return {
1612
+ rootDir,
1613
+ name: (cfg.project?.name ?? '').trim(),
1614
+ repoOk: rootDir !== '' && problem === null,
1615
+ ...(problem ? { repoProblem: problem } : {}),
1616
+ others,
1617
+ mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
1618
+ mirror: cfg.project?.mirror,
1619
+ contextRoot: contextRoot(cfg),
1620
+ };
1621
+ }
1622
+ /**
1623
+ * Refreshes every project's Desktop folder, when the switch is on. Called before a run
1624
+ * starts, so what OneDrive holds when the chat opens is the code as it is now, for every
1625
+ * project on the Settings page and not only the one the session is about.
1626
+ */
1627
+ async refreshDesktopMirrors() {
1628
+ const cfg = await this.settings.load();
1629
+ if (!cfg.project?.mirrorToDesktop)
1630
+ return;
1631
+ try {
1632
+ const outcomes = await mirrorAllProjects(cfg);
1633
+ this.bus.publish({
1634
+ sessionId: '*',
1635
+ type: 'desktop-mirror',
1636
+ level: 'info',
1637
+ message: outcomes.map((o) => `${o.name}: ${describeMirror(o.result)}`).join('; ') || 'no projects to mirror',
1638
+ });
1639
+ }
1640
+ catch (e) {
1641
+ this.bus.publish({ sessionId: '*', type: 'desktop-mirror-failed', level: 'warn', message: e.message });
1642
+ }
1643
+ }
1644
+ /**
1645
+ * Stores the project folder and the other folders the operator works in. A field left out
1646
+ * of the patch is kept; an empty `rootDir` clears the default, which is a real choice: it
1647
+ * means new sessions go back to starting with nothing chosen.
1648
+ *
1649
+ * A folder that is not a git repository is allowed here, because this setting is about where
1650
+ * the work is, not about version control. What it cannot do is silently promise version
1651
+ * control, so the answer says, for each folder, whether it can carry it. What is refused is a
1652
+ * folder that is not there, a name used twice, and the default listed again among the others
1653
+ * — each of those would be a setting that looks filled in and points nowhere.
1654
+ */
1655
+ async setProject(patch) {
1656
+ const raw = await this.settings.raw();
1657
+ const current = (raw.project ?? {});
1658
+ const rootDir = patch.rootDir !== undefined ? patch.rootDir.trim() : (current.rootDir ?? '').trim();
1659
+ const name = patch.name !== undefined ? patch.name.trim() : (current.name ?? '').trim();
1660
+ if (rootDir && !existsSync(rootDir))
1661
+ throw new Error(`The folder ${rootDir} does not exist on this machine.`);
1662
+ const others = (patch.others ?? current.others ?? []).map((o) => ({
1663
+ name: (o.name ?? '').trim(),
1664
+ rootDir: (o.rootDir ?? '').trim(),
1665
+ ...(o.mirror ? { mirror: o.mirror } : {}),
1666
+ }));
1667
+ const seen = new Set();
1668
+ for (const o of others) {
1669
+ if (!o.name)
1670
+ throw new Error(`Every other project needs a name; the one at ${o.rootDir || '(no folder)'} has none.`);
1671
+ if (!o.rootDir)
1672
+ throw new Error(`The project "${o.name}" needs a folder.`);
1673
+ if (!existsSync(o.rootDir))
1674
+ throw new Error(`The folder ${o.rootDir} for "${o.name}" does not exist on this machine.`);
1675
+ if (sameFolder(o.rootDir, rootDir))
1676
+ throw new Error(`${o.rootDir} is already the default project; it does not need listing again.`);
1677
+ const key = o.name.toLowerCase();
1678
+ if (seen.has(key))
1679
+ throw new Error(`Two projects are named "${o.name}". Names are how the folders are told apart, so each needs its own.`);
1680
+ seen.add(key);
1681
+ }
1682
+ const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
1683
+ const mirror = patch.mirror ?? current.mirror;
1684
+ const cfg = await this.settings.save({ ...raw, project: { ...current, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}) } });
1685
+ /*
1686
+ * The switch acts at once, both ways. On: every project's folder appears on the Desktop
1687
+ * now, not at the next run, because "I turned it on and nothing happened" is a support
1688
+ * question. Off: the folders go, because a copy of a code base left in OneDrive after the
1689
+ * operator said no is the one outcome the switch must not produce. A changed selection
1690
+ * with the switch on is applied the same way.
1691
+ */
1692
+ const wasOn = current.mirrorToDesktop ?? false;
1693
+ if (mirrorToDesktop)
1694
+ await mirrorAllProjects(cfg);
1695
+ else if (wasOn)
1696
+ await removeProjectMirrors(cfg);
1697
+ return await this.project();
1698
+ }
1699
+ // --- the model catalogue ----------------------------------------------------------------
1700
+ /**
1701
+ * What the picker offered when it was last read, plus the model new sessions start on.
1702
+ *
1703
+ * The two travel together because the UI shows them in one place: a list to choose from,
1704
+ * and which of them is the standing choice.
1705
+ */
1706
+ async models() {
1707
+ await this.init();
1708
+ const cfg = await this.settings.load();
1709
+ const cached = await this.store.getModels();
1710
+ const defaultModel = cfg.copilot.defaultModel ?? '';
1711
+ const defaultReviewModel = cfg.copilot.defaultReviewModel ?? '';
1712
+ return cached ? { ...cached, defaultModel, defaultReviewModel } : { defaultModel, defaultReviewModel, options: [], readAt: null };
1713
+ }
1714
+ /**
1715
+ * Sets the model a new session's independent review starts on. An empty name clears it,
1716
+ * which means the review runs on the session's own model — the weaker choice, and the one
1717
+ * every session had before this existed.
1718
+ */
1719
+ async setDefaultReviewModel(name) {
1720
+ await this.init();
1721
+ const raw = await this.settings.raw();
1722
+ const copilot = { ...(raw.copilot ?? {}), defaultReviewModel: name.trim() };
1723
+ await this.settings.save({ ...raw, copilot });
1724
+ this.bus.publish({
1725
+ sessionId: '*',
1726
+ type: 'default-review-model-changed',
1727
+ level: 'info',
1728
+ message: name.trim() ? `new sessions will be reviewed by ${name.trim()}` : 'new sessions will be reviewed by their own model',
1729
+ });
1730
+ return { defaultReviewModel: name.trim() };
1731
+ }
1732
+ /**
1733
+ * Sets the model new sessions will start on. An empty name clears it.
1734
+ *
1735
+ * Stored in `data/settings.json` next to the rest of the configuration rather than in the
1736
+ * model cache, because it is a preference of this operator, not a fact about the chat: a
1737
+ * refresh of the list must not be able to change it, and clearing the cache must not lose it.
1738
+ */
1739
+ async setDefaultModel(name) {
1740
+ await this.init();
1741
+ const raw = await this.settings.raw();
1742
+ const copilot = { ...(raw.copilot ?? {}), defaultModel: name.trim() };
1743
+ await this.settings.save({ ...raw, copilot });
1744
+ this.bus.publish({
1745
+ sessionId: '*',
1746
+ type: 'default-model-changed',
1747
+ level: 'info',
1748
+ message: name.trim() ? `new sessions will start on ${name.trim()}` : 'new sessions will start on the chat default',
1749
+ });
1750
+ return { defaultModel: name.trim() };
1751
+ }
1752
+ /**
1753
+ * Reads the model picker from the live chat and caches what it says.
1754
+ *
1755
+ * Expensive and exclusive: it launches the browser with the bot profile, which is the same
1756
+ * profile a run needs, so it cannot happen while a session is running. That is also why the
1757
+ * UI never does this by itself — the user asks for it, and knows a window will open.
1758
+ *
1759
+ * Nothing here decides what a "valid" model is. Whatever the chat lists is what the user
1760
+ * gets to choose from, which is the only way this keeps working when Microsoft changes the
1761
+ * line-up.
1762
+ */
1763
+ async refreshModels() {
1764
+ await this.init();
1765
+ if (this.running.size > 0) {
1766
+ throw new Error('A session is running and it is using the browser profile. Stop it first, then read the models.');
1767
+ }
1768
+ if (this.readingModels)
1769
+ throw new Error('The model list is already being read.');
1770
+ this.readingModels = true;
1771
+ const cfg = await this.settings.load();
1772
+ const transport = new CopilotTransport({
1773
+ profileDir: cfg.resolved.profileDir,
1774
+ transportDir: join(cfg.resolved.runsDir, '_models'),
1775
+ chatUrl: cfg.copilot.url,
1776
+ channel: cfg.copilot.channel,
1777
+ headless: cfg.copilot.headless,
1778
+ replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
1779
+ signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
1780
+ humanWaitMs: cfg.copilot.humanWaitSec * 1000,
1781
+ keepFailurePage: cfg.copilot.keepFailurePage,
1782
+ });
1783
+ try {
1784
+ await transport.open();
1785
+ await transport.ensureSignedIn();
1786
+ const { options, current, note } = await transport.listModels();
1787
+ const catalogue = { options, current: current ?? undefined, readAt: new Date().toISOString(), note };
1788
+ await this.store.saveModels(catalogue);
1789
+ return catalogue;
1790
+ }
1791
+ finally {
1792
+ await transport.close().catch(() => undefined);
1793
+ this.readingModels = false;
1794
+ }
1795
+ }
1796
+ // --- the registry ---------------------------------------------------------------------
1797
+ /**
1798
+ * Every task of every session in one flat list, newest session first and, inside a session,
1799
+ * in the order the tasks run. The page splits it into what is done, what is running and
1800
+ * what is still ahead; doing the flattening here keeps that split honest, because the
1801
+ * queue position is only knowable from the session's own list.
1802
+ */
1803
+ async taskRegistry() {
1804
+ await this.init();
1805
+ const sessions = await this.store.listSessions();
1806
+ const out = [];
1807
+ for (const s of sessions) {
1808
+ let queueSeen = 0;
1809
+ s.tasks.forEach((t, i) => {
1810
+ if (t.status === 'queued')
1811
+ queueSeen += 1;
1812
+ const started = t.startedAt ? Date.parse(t.startedAt) : undefined;
1813
+ const finished = t.finishedAt ? Date.parse(t.finishedAt) : undefined;
1814
+ out.push({
1815
+ sessionId: s.id,
1816
+ sessionName: s.name,
1817
+ sessionStatus: s.status,
1818
+ sessionOnFailure: s.onFailure === 'continue' ? 'continue' : 'stop',
1819
+ sessionRunOrder: s.runGroup?.order,
1820
+ sessionRunning: this.running.has(s.id),
1821
+ chatUrl: s.chat?.url,
1822
+ taskId: t.id,
1823
+ title: t.title,
1824
+ status: t.status,
1825
+ position: i + 1,
1826
+ queuePosition: t.status === 'queued' ? queueSeen : undefined,
1827
+ createdAt: t.createdAt,
1828
+ startedAt: t.startedAt,
1829
+ finishedAt: t.finishedAt,
1830
+ durationMs: started && finished ? finished - started : undefined,
1831
+ iterations: t.iterations,
1832
+ summary: t.summary,
1833
+ reason: t.reason,
1834
+ runId: t.runId,
1835
+ runGroup: t.runGroup,
1836
+ review: t.review
1837
+ ? { verdict: t.review.verdict, findings: t.review.findings?.length ?? 0, stepsRun: t.review.stepsRun }
1838
+ : undefined,
1839
+ deviations: t.deviations?.length || undefined,
1840
+ disputes: t.disputes?.length || undefined,
1841
+ readOnly: t.readOnly || undefined,
1842
+ autoRetries: t.autoRetries || undefined,
1843
+ attempt: t.attempt,
1844
+ attempts: (t.attempts ?? []).map((a, n) => {
1845
+ const from = a.startedAt ? Date.parse(a.startedAt) : undefined;
1846
+ const to = a.finishedAt ? Date.parse(a.finishedAt) : undefined;
1847
+ return {
1848
+ attempt: n + 1,
1849
+ status: a.status,
1850
+ startedAt: a.startedAt,
1851
+ finishedAt: a.finishedAt,
1852
+ durationMs: from && to ? to - from : undefined,
1853
+ iterations: a.iterations,
1854
+ summary: a.summary,
1855
+ reason: a.reason,
1856
+ runId: a.runId,
1857
+ branch: a.vcs?.branch,
1858
+ };
1859
+ }),
1860
+ });
1861
+ });
1862
+ }
1863
+ return out;
1864
+ }
1865
+ // --- environment ----------------------------------------------------------------------
1866
+ async dirs(root, respectGitignore = true) {
1867
+ return await listSelectableDirs(root, { respectGitignore });
1868
+ }
1869
+ /** Opens the machine's own folder dialog and reports what was picked. */
1870
+ async browseFolder(start) {
1871
+ return await pickFolder(start);
1872
+ }
1873
+ /**
1874
+ * What the current selection would copy, without writing anything. This is how the two
1875
+ * switches become checkable: turn the env one on and the `.env` files move from the skipped
1876
+ * list into the copied one, whatever `.gitignore` says about them.
1877
+ */
1878
+ async previewMirror(input) {
1879
+ const conflicts = findSelectionConflicts(input.includeDirs ?? [], input.excludeDirs ?? []);
1880
+ if (conflicts.length > 0)
1881
+ throw new Error(describeConflicts(conflicts));
1882
+ const cfg = await this.settings.load();
1883
+ const { files, skipped } = await collectFiles({
1884
+ rootDir: input.rootDir,
1885
+ includeDirs: input.includeDirs?.length ? input.includeDirs : ['.'],
1886
+ excludeDirs: input.excludeDirs ?? [],
1887
+ targetDir: '',
1888
+ respectGitignore: input.respectGitignore ?? true,
1889
+ includeEnvFiles: input.includeEnvFiles ?? false,
1890
+ ignoreDirs: cfg.projectMirror.ignoreDirs,
1891
+ maxFileBytes: cfg.projectMirror.maxFileBytes,
1892
+ });
1893
+ let totalBytes = 0;
1894
+ for (const rel of files) {
1895
+ const s = await import('node:fs/promises').then((fs) => fs.stat(join(input.rootDir, rel)).catch(() => null));
1896
+ if (s)
1897
+ totalBytes += s.size;
1898
+ }
1899
+ return { files, skipped, envFiles: files.filter(isEnvPath), totalBytes, conflicts };
1900
+ }
1901
+ async doctor() {
1902
+ const cfg = await this.settings.load();
1903
+ const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
1904
+ const holders = findEdgeUsingProfile(cfg.resolved.profileDir);
1905
+ return {
1906
+ node: process.versions.node,
1907
+ edge: edge ?? null,
1908
+ profileDir: cfg.resolved.profileDir,
1909
+ profileExists: existsSync(cfg.resolved.profileDir),
1910
+ profileHeldBy: Array.isArray(holders) ? holders.map((h) => h.pid) : 'unknown',
1911
+ /*
1912
+ * Whether the bot itself is what holds the profile. While a run is going, Edge is open
1913
+ * on that profile *by the runner*, and the warning "close this Edge window, a run would
1914
+ * fail" was shown in exactly the moment it was false. The page says which it is.
1915
+ */
1916
+ botRunning: this.running.size > 0 || this.batch?.running === true,
1917
+ desktop: resolveDesktopDir(),
1918
+ desktopSynced: desktopIsSynced(),
1919
+ cwd: cfg.resolved.cwd,
1920
+ runsDir: cfg.resolved.runsDir,
1921
+ dataDir: this.dataDir,
1922
+ mode: cfg.execution.mode,
1923
+ /*
1924
+ * Where the bot is running. The only thing that actually contains a command once it runs, and
1925
+ * until now the one thing the System page could not show: a machine that ignored the README's
1926
+ * recommendation looked exactly like one that had followed it. The claim is the operator's and
1927
+ * is shown as a claim; the signals are what can be read. See `exec/isolation.ts`.
1928
+ */
1929
+ isolation: assessIsolation(cfg.execution.isolation, readIsolationSignals()),
1930
+ folderDialog: process.platform === 'win32',
1931
+ alwaysIgnoredDirs: DEFAULT_IGNORE_DIRS,
1932
+ };
1933
+ }
1934
+ };
1935
+ OperatorService = __decorate([
1936
+ Injectable()
1937
+ ], OperatorService);
1938
+ export { OperatorService };
1939
+ /**
1940
+ * Which run folder a request may read.
1941
+ *
1942
+ * Without a `runId` it is the task's current one. With one, it must be a run this task owns:
1943
+ * the current attempt or one of the archived ones. Anything else returns null rather than a
1944
+ * path, so a crafted id cannot walk out of the task's own history.
1945
+ */
1946
+ function resolveRunId(task, requested) {
1947
+ if (!task)
1948
+ return null;
1949
+ if (!requested)
1950
+ return task.runId ?? null;
1951
+ const owned = [task.runId, ...(task.attempts ?? []).map((a) => a.runId)].filter(Boolean);
1952
+ return owned.includes(requested) ? requested : null;
1953
+ }
1954
+ function isEnvPath(relPath) {
1955
+ const name = relPath.split('/').pop()?.toLowerCase() ?? '';
1956
+ return name === '.env' || name.startsWith('.env.');
1957
+ }
1958
+ /**
1959
+ * Refuses a selection that contradicts itself, at the point it is saved rather than at the
1960
+ * point it runs. A contradiction is cheap to fix while the form is open and expensive to
1961
+ * discover an hour later, when the first task of a run fails on it.
1962
+ */
1963
+ function assertMirrorIsCoherent(mirror) {
1964
+ const conflicts = findSelectionConflicts(mirror.includeDirs ?? [], mirror.excludeDirs ?? []);
1965
+ if (conflicts.length > 0) {
1966
+ throw new Error(`The include and exclude lists contradict each other. ${describeConflicts(conflicts)}`);
1967
+ }
1968
+ if (mirror.enabled && mirror.rootDir !== undefined && !mirror.rootDir.trim()) {
1969
+ throw new Error('Attaching project files needs a project root.');
1970
+ }
1971
+ }