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,1582 @@
1
+ /**
2
+ * Runs one task inside a session, and a whole session's queue of tasks in turn.
3
+ *
4
+ * A session is one Copilot conversation. The first task opens it: level 1 goes out on its own
5
+ * and is acknowledged, then level 2 plus the task. Later tasks reuse the conversation with a
6
+ * short reminder that the contract still applies. Every task ends with Copilot's `summary`,
7
+ * which is the deliverable the UI shows.
8
+ *
9
+ * Nothing here knows whether it was started from the terminal or from the web: the
10
+ * authorizer decides who approves steps, the sink decides where events go, and the store
11
+ * persists the task as it moves.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { CopilotTransport } from '../transport/copilotTransport.js';
17
+ import { buildChatName, savePointer } from '../transport/chatSession.js';
18
+ import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
19
+ import { resolveDeviations, describeDeviations, mergeDisputes, describeDisputes } from '../protocol/replySchema.js';
20
+ import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
21
+ import { runStep } from '../exec/runner.js';
22
+ import { availableShells, detectShells, effectiveShell, preferredShell, refusalForChat, resolveShell, shellNote } from '../exec/shells.js';
23
+ import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK } from '../exec/checks.js';
24
+ import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing } from './derivedChecks.js';
25
+ import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
26
+ import { redactSecrets } from '../exec/redaction.js';
27
+ import { snapshotProcesses, reapLeftovers, describeLeftovers, ProcessTracker } from '../exec/processes.js';
28
+ import { collectEnvironment, describeEnvironment } from '../exec/environment.js';
29
+ import { describeCrash } from '../transport/edgeCrash.js';
30
+ import { runReview, findingsMessage, deliverableFor } from './review.js';
31
+ import { allAboutTheTask, isRepeat, findingId } from '../protocol/reviewSchema.js';
32
+ import { repoState, workingTreePaths } from '../vcs/git.js';
33
+ import { describeStep, checkCommandRefusal } from '../exec/policy.js';
34
+ import { collectPolicyManifest, describePolicyManifest } from '../exec/policyManifest.js';
35
+ import { assessIsolation, readIsolationSignals } from '../exec/isolation.js';
36
+ import { projectRoots } from '../exec/confinement.js';
37
+ import { writeReport } from '../exec/reportFile.js';
38
+ import { Pacer } from '../util/pacing.js';
39
+ import { RunLog } from '../log/runLog.js';
40
+ import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
41
+ import { mirrorProject, describeMirror } from '../context/projectMirror.js';
42
+ import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
43
+ import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
44
+ /** Why a task that was reported as done is being closed as failed. */
45
+ function checksFailedReason(outcomes) {
46
+ const failed = outcomes.filter((o) => !o.passed);
47
+ return failed.length === 0
48
+ ? 'the checks the operator set for this task did not pass'
49
+ : `the task reported itself as done, but these checks did not pass: ${failed
50
+ .map((o) => `${o.check.name} (${o.detail})`)
51
+ .join('; ')}`;
52
+ }
53
+ /**
54
+ * Why a task ended because of the machine rather than because of the work.
55
+ *
56
+ * Said in the task's own reason, in full, because this is the one failure whose fix is not in
57
+ * the repository: somebody has to install an interpreter or change a setting, and a reason that
58
+ * only said "the checks did not pass" would send them looking in the wrong place entirely.
59
+ */
60
+ function shellProblemReason(problem) {
61
+ return problem
62
+ ? `this machine could not run it: ${problem.message}`
63
+ : 'this machine could not run the commands this task needed, and no usable shell was found';
64
+ }
65
+ /**
66
+ * What a command that has already been run is told, when it is sent again.
67
+ *
68
+ * Addressed to the model, because the model is the only thing that can act on it, and phrased
69
+ * as an instruction rather than a complaint: the useful half of a refusal is what to do next.
70
+ */
71
+ function repeatRefusal(count, limit) {
72
+ return (`this exact command has already run ${count} time(s) in a row in this task and returned the same ` +
73
+ `result each time, which is the limit (maxCommandRepeats ${limit}). Running it again cannot ` +
74
+ 'tell you anything new, so it was not run. Do something different in kind: a different ' +
75
+ 'command, a different tool, a different way round the problem, or read something you have ' +
76
+ 'not read yet. If you have genuinely run out of approaches, end the task with status ' +
77
+ '"blocked" and list in "tried" the different things you attempted.');
78
+ }
79
+ /**
80
+ * The earlier tasks a reviewer is shown, when tasks build on each other.
81
+ *
82
+ * Only in per-session mode, where the tree carries their work, and only the ones that ran:
83
+ * the last three before this one, prompts capped, because the brief is read by a model with
84
+ * a context to spend and the point is what they defined, not their every word.
85
+ */
86
+ export function earlierTasksForReview(session, task, limit = 3, maxChars = 2500) {
87
+ if (session.vcs?.branchMode !== 'per-session')
88
+ return [];
89
+ const index = session.tasks.findIndex((t) => t.id === task.id);
90
+ const before = index < 0 ? session.tasks : session.tasks.slice(0, index);
91
+ return before
92
+ .filter((t) => t.status !== 'queued')
93
+ .slice(-limit)
94
+ .map((t) => ({ title: t.title, prompt: t.prompt.length > maxChars ? `${t.prompt.slice(0, maxChars)}\n[… ${t.prompt.length - maxChars} more characters]` : t.prompt }));
95
+ }
96
+ /**
97
+ * The reason line for a task the model gave up on, built from what it says it tried.
98
+ *
99
+ * The approaches are kept, not summarised away. "Blocked" on its own is no more useful than
100
+ * "failed"; the list of what was attempted is the part somebody reads to decide whether the
101
+ * task was wrong, the environment was wrong, or the model simply missed something obvious.
102
+ */
103
+ function blockedReason(tried, needed) {
104
+ const attempts = tried.map((t, i) => `(${i + 1}) ${t}`).join(' ');
105
+ const needs = needed?.trim();
106
+ return [
107
+ `stopped as blocked after ${tried.length} different approach(es): ${attempts}`,
108
+ needs ? `To unblock it: ${needs}` : '',
109
+ ]
110
+ .filter(Boolean)
111
+ .join(' — ');
112
+ }
113
+ /** A step that was refused never reaches the shell, but Copilot still has to hear about it. */
114
+ function refusedResult(step, reason) {
115
+ return {
116
+ id: step.id,
117
+ shell: effectiveShell(step.shell),
118
+ command: describeStep(step),
119
+ exitCode: -4,
120
+ outcome: 'aborted',
121
+ durationMs: 0,
122
+ stdout: '',
123
+ stderr: `[policy] step not executed: ${reason}\n`,
124
+ truncated: false,
125
+ logPath: '',
126
+ lastOutputAgoMs: 0,
127
+ };
128
+ }
129
+ /** One place that writes to the transcript, the console and the live stream at once. */
130
+ class Sink {
131
+ log;
132
+ bus;
133
+ sessionId;
134
+ taskId;
135
+ constructor(log, bus, sessionId, taskId) {
136
+ this.log = log;
137
+ this.bus = bus;
138
+ this.sessionId = sessionId;
139
+ this.taskId = taskId;
140
+ }
141
+ event(type, data = {}, human, level = 'info') {
142
+ this.log.event(type, data, human, level);
143
+ this.bus.publish({ sessionId: this.sessionId, taskId: this.taskId, type, level, message: human, data });
144
+ }
145
+ say(text) {
146
+ this.log.say(text);
147
+ this.bus.publish({ sessionId: this.sessionId, taskId: this.taskId, type: 'console', level: 'info', message: text.trim() });
148
+ }
149
+ }
150
+ /**
151
+ * Opens the browser and signs in. Nothing about any particular conversation.
152
+ *
153
+ * Split from entering a conversation so that a run of several sessions can open one window and
154
+ * keep it. Closing the browser between sessions meant a fresh launch, a fresh sign-in check and
155
+ * a fresh profile lock for every one of them — minutes of nothing, repeated, and every one of
156
+ * those launches another chance to hit the failure where a leftover Edge process is still
157
+ * holding the profile.
158
+ */
159
+ export async function openBrowser(cfg, bus, transportDir, sessionId) {
160
+ const transport = new CopilotTransport({
161
+ profileDir: cfg.resolved.profileDir,
162
+ transportDir,
163
+ chatUrl: cfg.copilot.url,
164
+ channel: cfg.copilot.channel,
165
+ headless: cfg.copilot.headless,
166
+ replyTimeoutMs: cfg.copilot.replyTimeoutSec * 1000,
167
+ signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
168
+ humanWaitMs: cfg.copilot.humanWaitSec * 1000,
169
+ keepFailurePage: cfg.copilot.keepFailurePage,
170
+ onEvent: (event, detail) => {
171
+ const spoken = {
172
+ 'sign-in-required': 'The chat is asking you to sign in. Do it in the open Edge window; the run is waiting.',
173
+ 'verification-required': 'The chat is showing a human-verification challenge. Complete it in the open Edge window. ' +
174
+ 'The bot will not touch it and is waiting for you.',
175
+ 'verification-cleared': 'Verification cleared, continuing.',
176
+ 'error-banner': 'The chat reported a transient error; reloading the page.',
177
+ 'reply-files-ignored': 'The reply came with a file attached. It was not downloaded and will not be run: the runner takes only the text of a reply.',
178
+ };
179
+ bus.publish({
180
+ sessionId,
181
+ type: `browser:${event}`,
182
+ level: event === 'verification-required' || event === 'reply-files-ignored' ? 'warn' : 'info',
183
+ message: spoken[event],
184
+ data: detail,
185
+ });
186
+ },
187
+ });
188
+ await transport.open();
189
+ await transport.ensureSignedIn();
190
+ return transport;
191
+ }
192
+ /**
193
+ * Puts an already-open browser on this session's conversation, or starts one for it.
194
+ *
195
+ * `closeOnFailure` is false when the browser is shared: a conversation that cannot be reopened
196
+ * is this session's problem, and taking the window down with it would end the sessions after it
197
+ * too, for a reason that has nothing to do with them.
198
+ */
199
+ export async function enterSessionConversation(transport, session, opts) {
200
+ if (session.chat) {
201
+ const ok = await transport.openConversation(session.chat.chatId);
202
+ if (!ok) {
203
+ const byName = await transport.openConversationByName(session.chat.name);
204
+ if (!byName) {
205
+ if (opts.closeOnFailure)
206
+ await transport.close();
207
+ throw new Error(`The session's conversation "${session.chat.name}" could not be reopened. ` +
208
+ 'It may have been deleted in Copilot. Start a new session for a fresh conversation.');
209
+ }
210
+ }
211
+ }
212
+ else {
213
+ await transport.newChat();
214
+ }
215
+ }
216
+ /**
217
+ * Joins the conversation of another session in the same group, when there is one.
218
+ *
219
+ * Several sessions can be told to share a chat: useful when they are one piece of work split
220
+ * into parts that need to see each other's history, and wasteful to refuse when the tasks were
221
+ * written that way. The first session of a group to run opens the conversation in the ordinary
222
+ * way; every later one adopts the pointer and, with it, the fact that the level-1 contract has
223
+ * already been sent there — sending it twice into the same chat would be both noise and a
224
+ * contradiction, since the contract says it is sent once.
225
+ *
226
+ * A session that already has its own conversation is never moved. Its history is in that chat.
227
+ */
228
+ async function joinGroupConversation(store, session, bus) {
229
+ const group = session.conversationGroup?.trim().toLowerCase();
230
+ if (!group || session.chat)
231
+ return session;
232
+ const others = (await store.listSessions()).filter((s) => s.id !== session.id && s.chat && (s.conversationGroup?.trim().toLowerCase() ?? '') === group);
233
+ if (others.length === 0)
234
+ return session;
235
+ // The newest conversation in the group, so a group that was restarted carries on in the chat
236
+ // it is actually using rather than in the one it began with months ago.
237
+ const host = others.sort((a, b) => (b.chat?.createdAt ?? '').localeCompare(a.chat?.createdAt ?? ''))[0];
238
+ const updated = await store.updateSession(session.id, (s) => {
239
+ s.chat = host.chat;
240
+ s.contractSent = true;
241
+ });
242
+ bus.publish({
243
+ sessionId: session.id,
244
+ type: 'chat-joined',
245
+ level: 'info',
246
+ message: `sharing the conversation "${host.chat?.name}" with "${host.name}", as both are in the group "${session.conversationGroup}"`,
247
+ data: { group: session.conversationGroup, hostSession: host.id, chatId: host.chat?.chatId },
248
+ });
249
+ return updated;
250
+ }
251
+ /** The old shape, kept for the terminal path: open a browser and enter the conversation. */
252
+ export async function openSessionTransport(cfg, session, bus, runsDir) {
253
+ const transport = await openBrowser(cfg, bus, join(runsDir, '_browser'), session.id);
254
+ await enterSessionConversation(transport, session, { closeOnFailure: true });
255
+ return transport;
256
+ }
257
+ /**
258
+ * Puts the conversation on the model the session asks for, once per run.
259
+ *
260
+ * Applied here rather than per task because the picker belongs to the conversation, and a
261
+ * failure is reported rather than raised: a model that is out of quota or has been withdrawn
262
+ * should not throw away a queue of tasks. The run continues on whatever the chat is actually
263
+ * set to, and the session records that, so the register shows which model did the work rather
264
+ * than which one was asked for.
265
+ */
266
+ async function applySessionModel(transport, session, bus) {
267
+ if (!session.model?.trim())
268
+ return undefined;
269
+ const result = await transport.selectModel(session.model.trim()).catch((e) => ({
270
+ ok: false,
271
+ current: null,
272
+ reason: e.message,
273
+ }));
274
+ bus.publish({
275
+ sessionId: session.id,
276
+ type: result.ok ? 'model-selected' : 'model-not-selected',
277
+ level: result.ok ? 'info' : 'warn',
278
+ message: result.ok
279
+ ? `model: ${result.current ?? session.model}`
280
+ : `could not switch to "${session.model}": ${result.reason ?? 'unknown reason'}. Continuing on ${result.current ?? 'the chat default'}.`,
281
+ data: { asked: session.model, current: result.current, ok: result.ok },
282
+ });
283
+ return result.current ?? undefined;
284
+ }
285
+ /** Runs one task to completion inside an already-open transport. */
286
+ export async function runTask(transport, session, task, deps) {
287
+ const { cfg, store, bus, authorizer, signal } = deps;
288
+ // Every attempt gets its own folder. The first keeps the original name, so nothing that
289
+ // already exists on disk moves; a re-run adds its attempt number.
290
+ const attempt = task.attempt ?? 1;
291
+ const runId = task.runId ?? `${session.id}-${task.id}${attempt > 1 ? `-a${attempt}` : ''}`;
292
+ const log = new RunLog(runId, cfg.resolved.runsDir);
293
+ const sink = new Sink(log, bus, session.id, task.id);
294
+ const pacer = new Pacer({
295
+ enabled: cfg.pacing.enabled,
296
+ settleMs: cfg.pacing.settleMs,
297
+ maxMessagesPerHour: cfg.pacing.maxMessagesPerHour,
298
+ });
299
+ const artifactsDir = log.path('artifacts');
300
+ const reportsDir = log.path('reports');
301
+ const repliesDir = log.path('replies');
302
+ const taskLogPath = log.path('task-log.txt');
303
+ await mkdir(artifactsDir, { recursive: true });
304
+ await mkdir(repliesDir, { recursive: true });
305
+ /** The consolidated, human-readable record of the whole task, appended as it happens. */
306
+ const record = async (heading, body) => {
307
+ await appendFile(taskLogPath, `\n${'='.repeat(78)}\n${heading}\n${'='.repeat(78)}\n${body.trimEnd()}\n`, 'utf8');
308
+ };
309
+ let replySeq = 0;
310
+ const saveReply = async (label, reply) => {
311
+ replySeq += 1;
312
+ const base = join(repliesDir, `${String(replySeq).padStart(2, '0')}-${label}`);
313
+ await writeFile(`${base}.md`, reply.markdown, 'utf8').catch(() => undefined);
314
+ if (reply.codeBlocksDom.length > 0) {
315
+ const rendered = reply.codeBlocksDom
316
+ .map((b, i) => ['--- code block ' + (i + 1) + ' as rendered ---', b].join('\n'))
317
+ .join('\n\n');
318
+ await writeFile(`${base}.onscreen.txt`, rendered, 'utf8').catch(() => undefined);
319
+ }
320
+ };
321
+ const setTask = async (mutate) => {
322
+ await store.updateTask(session.id, task.id, mutate);
323
+ };
324
+ const startedAt = Date.now();
325
+ const deadline = startedAt + cfg.limits.maxRunMinutes * 60_000;
326
+ let iterations = 0;
327
+ /** What the model has declared it could not do as written, merged across every reply. */
328
+ let deviations = [];
329
+ /** Review findings the model has disputed, by id, merged across every reply. */
330
+ let disputes = [];
331
+ /** What the runner tells the model in its next message about the reply just processed. */
332
+ let runnerNotes = [];
333
+ /**
334
+ * Checks earlier reviews gave with their findings — carried over from every attempt before
335
+ * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
336
+ */
337
+ let reviewChecks = task.reviewChecks ?? [];
338
+ /** Derived checks that still failed when the rounds ran out; the reviewer is told. */
339
+ let derivedStillFailing = [];
340
+ /**
341
+ * What was running before the task, so that what it and its reviews leave running can be
342
+ * told apart and stopped. Taken once the working directory is known; null means "do not".
343
+ */
344
+ let processesBefore = null;
345
+ /**
346
+ * Every shell this task starts — its steps, its checks, its reviews' steps and checks — so that
347
+ * what is stopped afterwards is what the bot started, and nothing the operator started by hand in
348
+ * the same folder. See `processes.ts`.
349
+ */
350
+ const tracker = new ProcessTracker();
351
+ /** Stops what appeared since a snapshot, tied to the project, writes it on the task, and says what it was. */
352
+ const reap = async (since, by) => {
353
+ if (!since)
354
+ return [];
355
+ const result = await reapLeftovers(work.cwd, since, tracker).catch(() => null);
356
+ if (!result)
357
+ return [];
358
+ if (result.notOurs.length > 0) {
359
+ sink.event('processes-not-ours', { by, count: result.notOurs.length, pids: result.notOurs.map((l) => l.pid) }, `${result.notOurs.length} new process(es) in the project folder were not started by the bot, so they were left running: ` +
360
+ result.notOurs.map((l) => `${l.name} pid ${l.pid}${l.ports.length > 0 ? ` (port ${l.ports.join(', ')})` : ''}`).join('; '), 'info');
361
+ }
362
+ if (result.killed.length === 0 && result.failed.length === 0)
363
+ return [];
364
+ const all = [...result.killed, ...result.failed].map((l) => ({ pid: l.pid, name: l.name, command: l.command.slice(0, 300), ports: l.ports, by }));
365
+ await setTask((t) => {
366
+ t.leftovers = [...(t.leftovers ?? []), ...all];
367
+ });
368
+ sink.event('processes-reaped', { by, killed: result.killed.length, failed: result.failed.length, leftovers: all }, `${by} left ${all.length} process(es) running; stopped ${result.killed.length}` +
369
+ `${result.failed.length > 0 ? `, could not stop ${result.failed.length}` : ''}: ` +
370
+ all.map((l) => `${l.name} pid ${l.pid}${l.ports.length > 0 ? ` (port ${l.ports.join(', ')})` : ''}`).join('; '), 'warn');
371
+ await record(`LEFT RUNNING BY ${by.toUpperCase()}`, describeLeftovers([...result.killed, ...result.failed]));
372
+ return all.map((l) => ({ name: l.name, ports: l.ports }));
373
+ };
374
+ const finish = async (status, reason, summary, finalReply) => {
375
+ await record(`TASK ${status.toUpperCase()}`, [summary ?? '', reason ? `Reason: ${reason}` : ''].filter(Boolean).join('\n\n') || '(no details)');
376
+ // The net under the plan's own checks: whatever the task left running is stopped and named.
377
+ await reap(processesBefore, 'the task');
378
+ /*
379
+ * A read-only task that changed files has failed, whatever it reported.
380
+ *
381
+ * Decided from the working tree, not from the summary, and before the commit: the change
382
+ * is still committed on the task's branch — so it is not lost and the next task starts
383
+ * from a clean tree — but the task ends `failed` with the files named. Only a task that
384
+ * claims `done` is overturned; one that already ended badly keeps its own reason.
385
+ */
386
+ if (task.readOnly && status === 'done' && repoDirOf(session)) {
387
+ const changed = await workingTreePaths(repoDirOf(session)).catch(() => []);
388
+ if (changed.length > 0) {
389
+ status = 'failed';
390
+ reason =
391
+ `this task is read-only and it changed ${changed.length} file(s): ${changed.slice(0, 8).join(', ')}` +
392
+ `${changed.length > 8 ? `, and ${changed.length - 8} more` : ''}. The change is committed on the task's branch; nothing is lost.`;
393
+ sink.event('readonly-violated', { files: changed.slice(0, 20) }, reason, 'error');
394
+ }
395
+ }
396
+ // Whatever the outcome, what the task changed goes onto its branch. A failed task that
397
+ // left files behind is exactly when having them committed somewhere is worth the most.
398
+ // The task is re-read first, because the branch was recorded on it after this closure
399
+ // was created.
400
+ const fresh = (await store.getSession(session.id))?.tasks.find((x) => x.id === task.id);
401
+ const vcsAfter = await commitTaskResult(session, { ...task, vcs: fresh?.vcs }, { status, summary, reason, deviations }, bus).catch((e) => {
402
+ sink.event('vcs-error', { error: String(e) }, `version control failed after the task: ${e.message}`, 'warn');
403
+ return undefined;
404
+ });
405
+ if (vcsAfter?.branch) {
406
+ await record('VERSION CONTROL', [
407
+ `branch : ${vcsAfter.branch}`,
408
+ `commit : ${vcsAfter.commit ?? '(nothing was committed)'}`,
409
+ vcsAfter.problem ? `problem: ${vcsAfter.problem}` : '',
410
+ (vcsAfter.suspicious?.length ?? 0) > 0
411
+ ? `suspicious: ${vcsAfter.suspicious?.map((s) => `${s.path} (${s.reason})`).join('; ')}`
412
+ : '',
413
+ ]
414
+ .filter(Boolean)
415
+ .join('\n'));
416
+ }
417
+ await setTask((t) => {
418
+ if (vcsAfter)
419
+ t.vcs = vcsAfter;
420
+ t.status = status;
421
+ t.iterations = iterations;
422
+ t.finishedAt = new Date().toISOString();
423
+ t.summary = summary;
424
+ t.reason = reason;
425
+ t.finalReply = finalReply;
426
+ if (deviations.length > 0)
427
+ t.deviations = deviations;
428
+ if (disputes.length > 0)
429
+ t.disputes = disputes;
430
+ if (reviewChecks.length > 0)
431
+ t.reviewChecks = reviewChecks;
432
+ t.logFile = 'task-log.txt';
433
+ });
434
+ sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
435
+ await log.close();
436
+ return { status, iterations, summary, reason };
437
+ };
438
+ await setTask((t) => {
439
+ t.status = 'running';
440
+ t.runId = runId;
441
+ t.runGroup = deps.runGroup;
442
+ t.startedAt = new Date().toISOString();
443
+ });
444
+ sink.event('task-started', { runId, title: task.title }, `task "${task.title}" starting (run ${runId})`);
445
+ await writeFile(taskLogPath, `TASK: ${task.title}\nSESSION: ${session.name} (${session.id})\nRUN: ${runId}\nSTARTED: ${new Date().toISOString()}\n`, 'utf8');
446
+ /*
447
+ * Which shell a step or a check gets when it names none, decided here and once.
448
+ *
449
+ * The configured default is a preference and not an instruction — nothing about this task
450
+ * chose it — so on a machine that has not got it the run falls through the order rather than
451
+ * spending the task on spawn errors. It is said out loud when it happens: a run whose commands
452
+ * were written for PowerShell and were read by `cmd` is a run somebody will want to know about
453
+ * before they start reading the output.
454
+ */
455
+ const shells = detectShells();
456
+ const defaultShell = preferredShell(cfg.execution.defaultShell, shells);
457
+ if (defaultShell !== cfg.execution.defaultShell) {
458
+ sink.event('shell-fallback', { configured: cfg.execution.defaultShell, using: defaultShell, available: availableShells(shells) }, `${cfg.execution.defaultShell} is not installed on this machine, so anything that names no shell runs in ${defaultShell} ` +
459
+ `(found: ${availableShells(shells).join(', ') || 'nothing'})`, 'warn');
460
+ }
461
+ // Which world this run got: the machine's tools, so a difference between two runs of one
462
+ // plan has somewhere to be read from. Once per process; the probes are child processes.
463
+ const environment = collectEnvironment();
464
+ await writeFile(log.path('environment.json'), JSON.stringify(environment, null, 2), 'utf8').catch(() => undefined);
465
+ await record('ENVIRONMENT', describeEnvironment(environment, defaultShell));
466
+ await setTask((t) => {
467
+ // The shell this run resolved to, kept with the rest of the machine, so the export and the
468
+ // manifest read one fact rather than each deciding it again from a config neither has.
469
+ t.environment = { ...environment, defaultShell };
470
+ });
471
+ /** A shell that could not be started, kept so the task can end on it rather than retry it. */
472
+ let environmentProblem = null;
473
+ /*
474
+ * Where this session's commands run, decided once and before anything is sent.
475
+ *
476
+ * The session's project, else the configured cwd — and never, by default, this runner's own
477
+ * checkout. A session that would land there has no working directory, and a task with no
478
+ * working directory does not start: refusing each step one by one would spend a whole
479
+ * conversation saying the same thing.
480
+ */
481
+ const work = workingDirFor(session, cfg.resolved.cwd);
482
+ if (isWorkingDirProblem(work)) {
483
+ sink.event('workdir-refused', { cwd: work.cwd }, work.problem, 'error');
484
+ return await finish('failed', work.problem);
485
+ }
486
+ sink.event('workdir', { cwd: work.cwd, source: work.source, ownCheckout: work.ownCheckout }, `commands run in ${work.cwd} (${work.source})${work.ownCheckout ? " — this runner's own checkout, as the session was set" : ''}`, work.ownCheckout ? 'warn' : 'info');
487
+ /*
488
+ * What was allowed, recorded beside what was run.
489
+ *
490
+ * The run folder has always held the commands and their exit codes, and never the rules they
491
+ * were judged against, so answering "what was this permitted to do at the time" meant reading a
492
+ * config that had been edited since. Written here rather than with the environment above because
493
+ * the working directory is part of the answer and is only settled now. See `policyManifest.ts`.
494
+ */
495
+ /*
496
+ * The project folders, decided once for the whole task: this session's own, and every folder
497
+ * registered in Settings, because work across a front end, a back end and its tests is one piece
498
+ * of work and a test suite legitimately starts the application next door. Every command this task
499
+ * runs — the implementer's steps and checks, the reviewer's steps and derived checks — is held to
500
+ * them. Built here and handed down rather than rebuilt at each gate, so no gate can have a
501
+ * different idea of where the project ends. See `confinement.ts`.
502
+ */
503
+ const confinement = {
504
+ roots: projectRoots([work.cwd, cfg.project.rootDir, ...cfg.project.others.map((o) => o.rootDir)]),
505
+ cwd: work.cwd,
506
+ };
507
+ sink.event('confinement', { roots: confinement.roots }, `commands are confined to ${confinement.roots.join(', ')}`);
508
+ const isolation = assessIsolation(cfg.execution.isolation, readIsolationSignals());
509
+ for (const concern of isolation.warnings) {
510
+ sink.event('isolation', { claim: isolation.claim, elevated: isolation.signals.elevated }, concern, 'warn');
511
+ }
512
+ const manifest = collectPolicyManifest({
513
+ isolation,
514
+ confinedTo: confinement.roots,
515
+ mode: deps.currentMode?.() ?? cfg.execution.mode,
516
+ allowedPrograms: cfg.execution.allowedPrograms,
517
+ denyPatterns: cfg.execution.denyPatterns,
518
+ cwd: work.cwd,
519
+ lock: cfg.policyLock,
520
+ });
521
+ await writeFile(log.path('policy.json'), JSON.stringify(manifest, null, 2), 'utf8').catch(() => undefined);
522
+ await record('POLICY', describePolicyManifest(manifest));
523
+ sink.event('policy', { mode: manifest.mode, allowlist: manifest.allowlist.enforced, allowlistDigest: manifest.allowlist.digest }, `policy: ${manifest.mode}, allowlist ${manifest.allowlist.enforced ? `${manifest.allowlist.count} programs (${manifest.allowlist.digest})` : 'NOT ENFORCED'}, ` +
524
+ 'the chat cannot supply files', manifest.mode === 'unattended' || !manifest.allowlist.enforced ? 'warn' : 'info');
525
+ // Not when the project is this runner's own checkout: every process of the runner would
526
+ // then look like a leftover.
527
+ processesBefore = work.ownCheckout ? null : await snapshotProcesses(work.cwd).catch(() => null);
528
+ try {
529
+ // --- version control: a branch of this task's own, before anything is touched -------
530
+ const prepared = await prepareForTask(session, task, bus, async (mutate) => {
531
+ await store.updateSession(session.id, mutate);
532
+ });
533
+ if (prepared.vcs.branch || prepared.vcs.problem) {
534
+ await setTask((t) => {
535
+ t.vcs = prepared.vcs;
536
+ });
537
+ await record('VERSION CONTROL', prepared.vcs.problem
538
+ ? `not active: ${prepared.vcs.problem}`
539
+ : `branch ${prepared.vcs.branch}, from ${prepared.vcs.baseCommit ?? '(no commits yet)'}`);
540
+ }
541
+ // --- project mirror, attached to the first message of the task ---------------------
542
+ let mirrorFiles = [];
543
+ if (session.mirror.enabled && session.mirror.rootDir) {
544
+ // One folder per project, and the project's name in front of every file name, so three
545
+ // repositories mirrored in turn do not delete each other's copies or collide in the chat.
546
+ const projectName = projectNameFor(session.mirror.rootDir, cfg);
547
+ const targetDir = projectTargetDir(cfg, projectName);
548
+ await removeLegacyFlatMirror(cfg).catch(() => undefined);
549
+ const result = await mirrorProject({
550
+ rootDir: session.mirror.rootDir,
551
+ includeDirs: session.mirror.includeDirs.length ? session.mirror.includeDirs : ['.'],
552
+ excludeDirs: session.mirror.excludeDirs,
553
+ targetDir,
554
+ namePrefix: projectName,
555
+ separator: cfg.projectMirror.separator,
556
+ txtMode: cfg.projectMirror.txtMode,
557
+ // The session's own switches win: they are what the operator ticked for this project.
558
+ // Settings only supply the fallback for a session saved before they existed.
559
+ respectGitignore: session.mirror.respectGitignore ?? cfg.projectMirror.respectGitignore,
560
+ ignoreDirs: cfg.projectMirror.ignoreDirs,
561
+ includeEnvFiles: session.mirror.includeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
562
+ maxFileBytes: cfg.projectMirror.maxFileBytes,
563
+ });
564
+ sink.event('mirror', { targetDir, ...result }, `project mirror: ${describeMirror(result)}`);
565
+ if (session.mirror.includeEnvFiles) {
566
+ const envCount = Object.keys(result.mapping).filter((p) => /(^|\/)\.env(\.|$)/i.test(p)).length;
567
+ if (envCount > 0) {
568
+ sink.event('mirror-env', { envCount }, `${envCount} .env file(s) are being attached, as configured`, 'warn');
569
+ }
570
+ }
571
+ for (const s of result.skipped.slice(0, 20)) {
572
+ sink.event('mirror-skipped', { ...s }, `not copied: ${s.relPath} (${s.reason})`);
573
+ }
574
+ const all = Object.values(result.mapping).sort();
575
+ mirrorFiles = all.slice(0, cfg.projectMirror.maxAttachedFiles).map((n) => join(targetDir, n));
576
+ if (all.length > mirrorFiles.length) {
577
+ sink.event('mirror-truncated', { total: all.length, attached: mirrorFiles.length }, `only the first ${mirrorFiles.length} of ${all.length} mirrored files will be attached`, 'warn');
578
+ }
579
+ }
580
+ // --- opening messages -------------------------------------------------------------
581
+ const { content: level1 } = await store.getLevel1();
582
+ const taskNumber = session.tasks.findIndex((t) => t.id === task.id) + 1;
583
+ const opening = composeOpening({
584
+ level1,
585
+ level2: task.level2,
586
+ prompt: task.prompt,
587
+ taskTitle: task.title,
588
+ taskNumber,
589
+ contractAlreadySent: session.contractSent,
590
+ workDirNote: workingDirNote(work),
591
+ vcsNote: prepared.note,
592
+ readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
593
+ shellNote: shellNote(shells, defaultShell),
594
+ });
595
+ await setTask((t) => {
596
+ t.firstMessage = opening.firstMessage;
597
+ });
598
+ await record('OPENING MESSAGE', opening.firstMessage);
599
+ let lastMarkdown = '';
600
+ for (const [index, message] of opening.messages.entries()) {
601
+ if (signal?.aborted)
602
+ return await finish('aborted', 'stopped before the task was sent');
603
+ const isFirstOfTask = index === 0;
604
+ const attach = isFirstOfTask && mirrorFiles.length ? mirrorFiles : [];
605
+ await pacer.throttleSend();
606
+ const before = await transport.sendAndConfirm(message, attach);
607
+ sink.event('message-sent', { index, chars: message.length, attached: attach.length }, `message ${index + 1}/${opening.messages.length} sent`);
608
+ const reply = await transport.waitForReply(before);
609
+ lastMarkdown = reply.markdown;
610
+ await saveReply(`opening-${index + 1}`, reply);
611
+ await pacer.settle();
612
+ // The conversation exists now: register it and name it, once per session.
613
+ if (!session.chat) {
614
+ const chatId = await transport.currentChatId();
615
+ if (chatId) {
616
+ const name = buildChatName(session.id.slice(0, 15), session.name);
617
+ await transport.nameChat(chatId, name).catch(() => false);
618
+ const chat = {
619
+ chatId,
620
+ url: `${cfg.copilot.url.replace(/\/chat.*$/, '')}/chat/conversation/${chatId}?es=SSR`,
621
+ name,
622
+ runId,
623
+ createdAt: new Date().toISOString(),
624
+ };
625
+ session.chat = chat;
626
+ await store.updateSession(session.id, (s) => {
627
+ s.chat = chat;
628
+ });
629
+ await savePointer(log.path('chat.json'), chat);
630
+ sink.event('chat-registered', { ...chat }, `chat: ${name}`);
631
+ }
632
+ }
633
+ }
634
+ if (!session.contractSent) {
635
+ session.contractSent = true;
636
+ await store.updateSession(session.id, (s) => {
637
+ s.contractSent = true;
638
+ });
639
+ }
640
+ // --- the loop ---------------------------------------------------------------------
641
+ // How many times the checks have been run for this task, and how many times they may be.
642
+ let checkRounds = 0;
643
+ // Kept here rather than read back off the task: `setTask` writes to the store, it does not
644
+ // refresh the object this function is holding, so reading it back would give the state
645
+ // before the checks ran and the reason would come out empty.
646
+ let lastOutcomes = [];
647
+ const maxCheckRounds = Math.max(1, cfg.limits.maxCheckRounds ?? 3);
648
+ /*
649
+ * The runner's own check, when there is going to be a commit.
650
+ *
651
+ * Whatever the task's checks say, a commit should not carry what is installed, built,
652
+ * logged or secret. Pointed out once and then let through: the second time round the
653
+ * files are committed and marked, because refusing would leave the tree dirty and the next
654
+ * task refusing to start over it.
655
+ */
656
+ const willCommit = !!(session.vcs?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
657
+ let generatedPointedOut = false;
658
+ /**
659
+ * Whether "done" is accepted, decided by the operator's checks rather than by the reply.
660
+ *
661
+ * Returns `accept` when there is nothing to check or everything passed, `retry` when the
662
+ * failures have been sent back for Copilot to fix, `give-up` when it has had its rounds,
663
+ * and `environment` when the machine could not run the checks at all. The checks run here,
664
+ * at the end, and not as steps: they are not work, they are the question of whether the work
665
+ * happened, and a task cannot be trusted to answer that about itself.
666
+ */
667
+ const gateOnChecks = async () => {
668
+ const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK] : [])];
669
+ if (checks.length === 0)
670
+ return 'accept';
671
+ // Counted only once the round turns out to have been a real attempt. A round that died of
672
+ // a missing interpreter asked nothing of the work and must not cost the task one.
673
+ const round = checkRounds + 1;
674
+ sink.event('checks-started', { round, count: checks.length }, `checking the task against ${checks.length} condition(s)`);
675
+ const outcomes = (lastOutcomes = await runChecks(checks, {
676
+ cwd: work.cwd,
677
+ tracker,
678
+ passEnv: cfg.execution.passEnv,
679
+ logDir: log.path('checks'),
680
+ signal: deps.signal,
681
+ /*
682
+ * A check runs at the gate with nobody asked, so a download in one — a plan's own check or a
683
+ * reviewer's — is refused rather than held: there is no approval screen here. Requests to
684
+ * this machine are not downloads and still run. See `network.ts`.
685
+ */
686
+ deny: (command, shell, cwd) => checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, cwd }),
687
+ roots: confinement.roots,
688
+ repoDir: willCommit ? repoDirOf(session) : undefined,
689
+ // The same shell a step that named none is given, so the gate and the work it judges
690
+ // cannot have been read by different interpreters.
691
+ defaultShell,
692
+ }));
693
+ for (const o of outcomes) {
694
+ if (o.check.expect !== 'commit-clean' || o.passed)
695
+ continue;
696
+ if (generatedPointedOut) {
697
+ o.passed = true;
698
+ o.detail = `still there after being pointed out once; committed and marked as suspicious on the task — ${o.detail}`;
699
+ }
700
+ else {
701
+ generatedPointedOut = true;
702
+ }
703
+ }
704
+ await setTask((t) => {
705
+ t.checkResults = outcomes.map((o) => ({ name: o.check.name, passed: o.passed, detail: o.detail }));
706
+ });
707
+ /*
708
+ * The machine, not the work.
709
+ *
710
+ * Sending this back to the chat would be asking a language model to install an
711
+ * interpreter, and it would do what it always does with an instruction it cannot carry
712
+ * out: try something else, fail the same way, and use up the rounds. So the round is not
713
+ * counted, nothing is sent, and the task ends on a reason that names what is missing. It
714
+ * is reported once even when every check hit the same wall, because one sentence about
715
+ * one missing interpreter is the whole of what there is to say.
716
+ */
717
+ const problem = environmentProblemIn(outcomes);
718
+ if (problem) {
719
+ environmentProblem = problem;
720
+ sink.event('checks-environment', { round, requested: problem.requested, available: problem.available, checks: outcomes.filter((o) => o.environmentProblem).map((o) => o.check.name) }, problem.message, 'error');
721
+ return 'environment';
722
+ }
723
+ checkRounds = round;
724
+ for (const o of outcomes) {
725
+ sink.event(o.passed ? 'check-passed' : 'check-failed', { name: o.check.name, detail: o.detail }, `${o.passed ? 'passed' : 'FAILED'}: ${o.check.name} — ${o.detail}`, o.passed ? 'info' : 'warn');
726
+ }
727
+ const failed = outcomes.filter((o) => !o.passed);
728
+ if (failed.length === 0) {
729
+ sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
730
+ return 'accept';
731
+ }
732
+ if (deps.signal?.aborted)
733
+ return 'give-up';
734
+ if (checkRounds > maxCheckRounds) {
735
+ /*
736
+ * Only checks from earlier reviews are failing. A reviewer's check is outranked by the
737
+ * next reviewer's judgement, not by a counter: they are suspended, the work goes to
738
+ * review with them named, and the verdict decides whether they come back or go.
739
+ */
740
+ if (onlyDerivedFailing(outcomes)) {
741
+ const failingNames = new Set(failed.map((o) => o.check.name));
742
+ reviewChecks = reviewChecks.map((rc) => (rc.state === 'active' && failingNames.has(rc.check.name) ? { ...rc, state: 'suspended' } : rc));
743
+ await setTask((t) => {
744
+ t.reviewChecks = reviewChecks;
745
+ });
746
+ derivedStillFailing = failed.map((o) => ({ name: o.check.name, detail: o.detail }));
747
+ sink.event('checks-derived-deferred', { rounds: checkRounds - 1, checks: failed.map((o) => o.check.name) }, `${failed.length} check(s) from earlier reviews still fail after ${maxCheckRounds} attempt(s); the work goes to the reviewer with them named`, 'warn');
748
+ return 'accept';
749
+ }
750
+ sink.event('checks-exhausted', { rounds: checkRounds - 1 }, `${failed.length} check(s) still failing after ${maxCheckRounds} attempt(s); the task is closed as failed`, 'warn');
751
+ return 'give-up';
752
+ }
753
+ // The failures go back exactly the way step output does: a message with a file attached,
754
+ // because a compiler's opinion belongs in a file and not in a chat bubble.
755
+ const path = join(reportsDir, `checks-${checkRounds}.txt`);
756
+ // Check output is uploaded too, so it is redacted the same way a step report is.
757
+ const checkReport = redactSecrets(failureReport(outcomes), cfg.report.redactPatterns);
758
+ await writeFile(path, checkReport, 'utf8');
759
+ await record(`CHECKS ${checkRounds}`, checkReport);
760
+ const message = failureMessage(outcomes, checkRounds, maxCheckRounds);
761
+ await record(`CHECKS ${checkRounds} MESSAGE SENT`, message);
762
+ await pacer.throttleSend();
763
+ const before = await transport.sendAndConfirm(message, [path]);
764
+ const next = await transport.waitForReply(before);
765
+ await saveReply(`checks-${checkRounds}`, next);
766
+ lastMarkdown = next.markdown;
767
+ await pacer.settle();
768
+ return 'retry';
769
+ };
770
+ /*
771
+ * The second opinion.
772
+ *
773
+ * It runs after the checks and only when they have passed, which is the right order for two
774
+ * reasons: the checks are mechanical and free, so spending a whole conversation to discover
775
+ * what a string comparison would have told us is waste; and a reviewer shown work that does
776
+ * not even compile spends its round on that instead of on the things only a reader finds.
777
+ *
778
+ * The reviewer gets its own conversation, in the same browser, and the implementer's is
779
+ * returned to afterwards. What it is told is deliberately narrow — the task, the project
780
+ * instructions and the files that changed — and what it is not told is the implementer's
781
+ * summary, because a reviewer that reads an account of the work starts by trusting the thing
782
+ * it is meant to be checking.
783
+ */
784
+ let reviewRounds = 0;
785
+ let lastReview = null;
786
+ /**
787
+ * What the previous round found, as it was sent back. The next reviewer is told, and a
788
+ * finding that comes back is recognised against this — see `isRepeat`.
789
+ */
790
+ let previousFindings = [];
791
+ /** What the previous review's own steps left running, told to both sides. */
792
+ let previousLeftovers = [];
793
+ const reviewWanted = task.reviewEnabled ?? session.review?.enabled ?? true;
794
+ const maxReviewRounds = Math.max(1, cfg.limits.maxReviewRounds ?? 2);
795
+ const saveReview = async (review) => {
796
+ await setTask((t) => {
797
+ t.review = review;
798
+ });
799
+ };
800
+ /**
801
+ * Whether a task that passed its checks is actually finished.
802
+ *
803
+ * `accept` — reviewed and passed, or not reviewed at all.
804
+ * `retry` — the reviewer found problems and they have been sent back to the implementer.
805
+ * `give-up` — the rounds are spent and the findings are still standing.
806
+ */
807
+ const gateOnReview = async (closing) => {
808
+ if (!reviewWanted) {
809
+ await saveReview({ verdict: 'skipped', rounds: 0, stepsRun: 0 });
810
+ return 'accept';
811
+ }
812
+ reviewRounds += 1;
813
+ const model = (session.review?.model ?? '').trim();
814
+ const repoDir = session.vcs?.enabled ? (session.vcs.repoDir ?? '').trim() : '';
815
+ /*
816
+ * What changed, read from git rather than from anybody's account of it.
817
+ *
818
+ * The commit happens when the task closes, which is after this, so the task's changes are
819
+ * still sitting in the working tree. That is exactly the list wanted: the branch was cut
820
+ * before this task started, so everything dirty now is this task's doing.
821
+ */
822
+ const changedFiles = repoDir ? (await repoState(repoDir).catch(() => null))?.changed ?? [] : [];
823
+ // When nothing changed, or nothing was allowed to, the closing account is the product and
824
+ // the reviewer is given it — as claims to test. See `Deliverable` in review.ts.
825
+ const deliverable = deliverableFor(task, closing, repoDir !== '', changedFiles);
826
+ sink.event('review-started', { round: reviewRounds, model: model || '(the session model)', files: changedFiles.length, deliverable: deliverable?.why }, `an independent review is opening a fresh conversation (round ${reviewRounds} of ${maxReviewRounds})` +
827
+ (deliverable ? `; the closing summary goes with it as the product (${deliverable.why})` : ''));
828
+ // What is running before the review, so that what the review leaves is its own.
829
+ const beforeReview = processesBefore ? await snapshotProcesses(work.cwd, processesBefore.since).catch(() => null) : null;
830
+ let roundLeftovers = [];
831
+ let outcome;
832
+ try {
833
+ await transport.newChat();
834
+ if (model) {
835
+ const picked = await transport.selectModel(model).catch((e) => ({ ok: false, current: null, reason: e.message }));
836
+ sink.event(picked.ok ? 'review-model-selected' : 'review-model-not-selected', { asked: model, current: picked.current }, picked.ok ? `the review runs on ${picked.current ?? model}` : `the review could not switch to "${model}": ${picked.reason ?? 'unknown reason'}`, picked.ok ? 'info' : 'warn');
837
+ }
838
+ outcome = await runReview(transport, session, task, {
839
+ cfg,
840
+ authorizer,
841
+ signal,
842
+ pacer,
843
+ dir: log.path('review', String(reviewRounds)),
844
+ round: reviewRounds,
845
+ cwd: work.cwd,
846
+ roots: confinement.roots,
847
+ tracker,
848
+ changedFiles,
849
+ deliverable,
850
+ deviations,
851
+ disputes,
852
+ previous: reviewRounds > 1 ? { round: reviewRounds - 1, findings: previousFindings, leftovers: previousLeftovers } : undefined,
853
+ repoDir: willCommit ? repoDirOf(session) : undefined,
854
+ derivedFailing: derivedStillFailing,
855
+ // Before the verdict is judged: a check given with a finding must fail on the work,
856
+ // not on a server the reviewer forgot to stop.
857
+ beforeVerdict: async () => {
858
+ roundLeftovers = [...roundLeftovers, ...(await reap(beforeReview, `review round ${reviewRounds}`))];
859
+ },
860
+ earlier: earlierTasksForReview(session, task),
861
+ event: (type, data, human, level) => sink.event(type, data, human, level),
862
+ record,
863
+ });
864
+ }
865
+ catch (e) {
866
+ outcome = { verdict: 'error', findings: [], stepsRun: 0, iterations: 0, problem: e.message };
867
+ }
868
+ finally {
869
+ // Back to the conversation that did the work, whatever happened in the other one. A
870
+ // task whose implementer chat is lost cannot be fixed, reported on, or closed properly.
871
+ await enterSessionConversation(transport, session, { closeOnFailure: false }).catch((e) => {
872
+ sink.event('review-return-failed', { error: String(e) }, `could not return to the task's own conversation after the review: ${e.message}`, 'error');
873
+ });
874
+ }
875
+ // A reviewer that left its own server listening once failed the work for it.
876
+ roundLeftovers = [...roundLeftovers, ...(await reap(beforeReview, `review round ${reviewRounds}`))];
877
+ /*
878
+ * Which findings an earlier round already raised.
879
+ *
880
+ * Decided before anything else, because it is the fact everything below turns on: a
881
+ * finding that survives a reported fix is the strongest evidence available that the
882
+ * task, not the work, is what cannot be satisfied — and the one fact the runner used to
883
+ * have and throw away.
884
+ */
885
+ const repeated = outcome.findings.filter((f) => isRepeat(previousFindings, f));
886
+ if (repeated.length > 0) {
887
+ sink.event('review-finding-repeated', { round: reviewRounds, count: repeated.length }, `${repeated.length} finding(s) came back after a reported fix: ${repeated.map((f) => f.where ?? f.what).join('; ')}`, 'warn');
888
+ }
889
+ // Named by the runner — round and position — so a dispute can point at one and the
890
+ // record can show which came back. The same objects are used everywhere below, so
891
+ // `repeated` still identifies them.
892
+ const named = outcome.findings.map((f, i) => ({
893
+ ...f,
894
+ id: f.id ?? findingId(reviewRounds, i),
895
+ ...(repeated.includes(f) ? { repeated: true } : {}),
896
+ }));
897
+ outcome = { ...outcome, findings: named };
898
+ const repeatedNamed = named.filter((f) => f.repeated);
899
+ /*
900
+ * What this verdict does to the derived checks: the ones this reviewer gave join the
901
+ * task; the ones suspended by a dispute or by spent rounds come back if the finding was
902
+ * raised again, and go if it was not.
903
+ */
904
+ if (outcome.verdict === 'pass' || outcome.verdict === 'fail') {
905
+ for (const d of outcome.derivedChecks ?? []) {
906
+ reviewChecks = [...reviewChecks, { check: d.check, findingId: d.findingId, what: d.what, where: d.where, round: reviewRounds, attempt, state: 'active' }];
907
+ }
908
+ const settled = settleAfterReview(reviewChecks, outcome.verdict, named);
909
+ reviewChecks = settled.checks;
910
+ /*
911
+ * A read-only task never carries an active derived check, whatever a review says.
912
+ *
913
+ * New ones are not kept (see review.ts), and one kept before this rule — or before the
914
+ * task was marked read-only — must not be revived by a later round either: it is a
915
+ * command against a repository the task may not touch, so raising the finding again
916
+ * does not give the task a lever, it only puts the same immovable condition back in
917
+ * the gate. The finding itself still travels and still fails the work.
918
+ */
919
+ if (task.readOnly) {
920
+ const revived = reviewChecks.filter((rc) => rc.state === 'active').map((rc) => rc.findingId);
921
+ if (revived.length > 0) {
922
+ sink.event('review-check-not-kept-readonly', { findings: revived }, `this task may not change files, so the check(s) from ${revived.join(', ')} stay out of the gate; the finding(s) stand`, 'warn');
923
+ }
924
+ reviewChecks = reviewChecks.map((rc) => (rc.state === 'active' ? { ...rc, state: 'suspended' } : rc));
925
+ }
926
+ if (settled.reactivated.length > 0 && !task.readOnly) {
927
+ sink.event('review-check-reactivated', { findings: settled.reactivated }, `the review raised the disputed finding(s) again; their checks are back: ${settled.reactivated.join(', ')}`, 'warn');
928
+ }
929
+ if (settled.dropped.length > 0) {
930
+ sink.event('review-check-dropped', { findings: settled.dropped }, `the review did not raise the finding(s) again; their checks are dropped: ${settled.dropped.join(', ')}`);
931
+ }
932
+ derivedStillFailing = [];
933
+ await setTask((t) => {
934
+ t.reviewChecks = reviewChecks;
935
+ });
936
+ }
937
+ lastReview = outcome;
938
+ await saveReview({
939
+ verdict: outcome.verdict,
940
+ rounds: reviewRounds,
941
+ stepsRun: outcome.stepsRun,
942
+ summary: outcome.summary,
943
+ findings: named,
944
+ problem: outcome.problem,
945
+ model: model || undefined,
946
+ });
947
+ if (outcome.verdict === 'pass') {
948
+ sink.event('review-passed', { round: reviewRounds, stepsRun: outcome.stepsRun }, `the review passed the work after running ${outcome.stepsRun} command(s)`);
949
+ // The work is right and the task is wrong. The task is done; the findings stay on its
950
+ // record for whoever wrote it, and the plan behind it is not stopped over a sentence.
951
+ if (outcome.findings.length > 0) {
952
+ sink.event('review-task-notes', { round: reviewRounds, findings: outcome.findings.length }, `the review passed the work and noted ${outcome.findings.length} problem(s) with the task itself: ` +
953
+ outcome.findings.map((f) => f.what).join(' '), 'warn');
954
+ }
955
+ return 'accept';
956
+ }
957
+ /*
958
+ * A review that could not be carried out does not fail the work.
959
+ *
960
+ * The browser closing, the chat refusing a message, the reviewer losing its format — none
961
+ * of that is evidence about the task, and turning good work into a failed task because
962
+ * the machinery stumbled would make the whole mechanism something to switch off. It is
963
+ * said loudly and recorded on the task instead, so "this went unreviewed" is visible.
964
+ */
965
+ if (outcome.verdict === 'error') {
966
+ sink.event('review-error', { round: reviewRounds, problem: outcome.problem }, `the review could not be carried out: ${outcome.problem ?? 'unknown reason'}. The work is accepted unreviewed.`, 'warn');
967
+ return 'accept';
968
+ }
969
+ /*
970
+ * Nothing here is the work's fault.
971
+ *
972
+ * When every finding is about the task — it contradicts itself, it asks for something the
973
+ * project instructions forbid, or it expects something untrue of this machine — sending it
974
+ * back is asking somebody to fix a sentence they are not allowed to change. The task stops
975
+ * here instead, on the first round, and says which part of its own description is wrong.
976
+ * That is a result for the person who wrote the task; another lap is not.
977
+ */
978
+ if (allAboutTheTask(outcome.findings)) {
979
+ sink.event('review-task-wrong', { round: reviewRounds, findings: outcome.findings.length }, `the review found ${outcome.findings.length} problem(s) with the task itself, not with the work`, 'warn');
980
+ return 'give-up';
981
+ }
982
+ /*
983
+ * Out of rounds, and only now.
984
+ *
985
+ * The budget counts times the findings are *sent back*, not reviews — so the last fix is
986
+ * always checked before the task is judged. Counting reviews instead cost a task: round
987
+ * two's finding was sent back, the implementer fixed it, the checks passed, and the task
988
+ * was then blocked quoting that finding as "still there" without anybody having looked
989
+ * at the fix. It had been fixed.
990
+ */
991
+ if (reviewRounds > maxReviewRounds) {
992
+ sink.event('review-exhausted', { rounds: maxReviewRounds, findings: outcome.findings.length }, `the review still has findings after ${maxReviewRounds} round(s) of fixing; the task is closed as blocked`, 'warn');
993
+ return 'give-up';
994
+ }
995
+ sink.event('review-failed', { round: reviewRounds, findings: outcome.findings.length }, `the review found ${outcome.findings.length} problem(s); sending them back to be fixed`, 'warn');
996
+ await pacer.throttleSend();
997
+ // The reviewer quotes output in its evidence, so the message gets the same treatment.
998
+ // Recorded as sent, because what reached the chat is what the next question is about.
999
+ const findingsSent = redactSecrets(findingsMessage(outcome, reviewRounds, maxReviewRounds, repeatedNamed, roundLeftovers), cfg.report.redactPatterns);
1000
+ await writeFile(log.path('review', String(reviewRounds), 'findings-sent.md'), findingsSent, 'utf8').catch(() => undefined);
1001
+ await record(`REVIEW ${reviewRounds} FINDINGS SENT`, findingsSent);
1002
+ const before = await transport.sendAndConfirm(findingsSent);
1003
+ const next = await transport.waitForReply(before);
1004
+ await saveReply(`review-${reviewRounds}-findings`, next);
1005
+ lastMarkdown = next.markdown;
1006
+ await pacer.settle();
1007
+ previousFindings = named;
1008
+ previousLeftovers = roundLeftovers;
1009
+ return 'retry';
1010
+ };
1011
+ /** Why a task that was reviewed and found wanting is being closed without being done. */
1012
+ const reviewBlockedReason = () => {
1013
+ const findings = lastReview?.findings ?? [];
1014
+ const listed = findings.map((f, i) => `(${i + 1}) ${f.what}`).join(' ');
1015
+ // The two endings read differently because they ask different things of the reader: one
1016
+ // is "the work is not finished", the other is "the task is wrong and needs a decision".
1017
+ return allAboutTheTask(findings)
1018
+ ? `an independent review found ${findings.length} problem(s) with the task itself rather than with the work, ` +
1019
+ `so there was nothing to send back for fixing: ${listed}`
1020
+ : `an independent review found ${findings.length} problem(s) that were still there after ` +
1021
+ `${maxReviewRounds} round(s) of fixing: ${listed}`;
1022
+ };
1023
+ let formatRetries = 0;
1024
+ /*
1025
+ * The anti-spin guards.
1026
+ *
1027
+ * `seen` counts how often each command has actually run, normalised only for whitespace so
1028
+ * that two genuinely different commands never collide. `stalled` counts iterations in which
1029
+ * every single step was refused as a repeat — which is the signature of a model going round
1030
+ * in a circle, and the thing that turns into a task that ends rather than a task that times
1031
+ * out.
1032
+ */
1033
+ const maxCommandRepeats = Math.max(1, cfg.limits.maxCommandRepeats ?? 3);
1034
+ const maxStalledIterations = Math.max(1, cfg.limits.maxStalledIterations ?? 2);
1035
+ /**
1036
+ * Per command: how many times in a row it has returned exactly the same thing.
1037
+ *
1038
+ * The count is of *identical results*, not of runs, and the difference matters. A long task
1039
+ * legitimately runs `npx tsc --noEmit` five or six times — after each fix, and again after
1040
+ * each round of review findings — and every one of those runs is a verification of something
1041
+ * that just changed. Counting runs refused the sixth one as a repeat and pushed the model
1042
+ * into inventing ways around its own type-checker. Counting identical results refuses only
1043
+ * what it was meant to: the same command, returning the same answer, again.
1044
+ */
1045
+ const seen = new Map();
1046
+ const fingerprint = (step) => step.cmd.replace(/\s+/g, ' ').trim();
1047
+ /** What "the same answer" means: the exit code and the output, hashed. */
1048
+ const resultSignature = (r) => createHash('sha256').update(`${r.exitCode}\u0000${r.outcome}\u0000${r.stdout}\u0000${r.stderr}`).digest('hex');
1049
+ let stalled = 0;
1050
+ for (;;) {
1051
+ if (signal?.aborted)
1052
+ return await finish('aborted', 'stopped by the operator');
1053
+ if (Date.now() > deadline)
1054
+ return await finish('limit-reached', `maxRunMinutes (${cfg.limits.maxRunMinutes}) reached`);
1055
+ if (iterations >= cfg.limits.maxIterations)
1056
+ return await finish('limit-reached', `maxIterations (${cfg.limits.maxIterations}) reached`);
1057
+ const parsed = parseReply(lastMarkdown, {
1058
+ stopMarker: cfg.copilot.stopMarker,
1059
+ defaultShell,
1060
+ });
1061
+ if (!parsed.ok) {
1062
+ formatRetries += 1;
1063
+ sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
1064
+ if (formatRetries > cfg.limits.maxFormatRetries) {
1065
+ await transport.dumpFailure(log.path('failures'), 'format-error');
1066
+ return await finish('failed', `Copilot did not keep the output contract: ${parsed.detail}`, undefined, lastMarkdown);
1067
+ }
1068
+ await pacer.throttleSend();
1069
+ const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
1070
+ const again = await transport.waitForReply(before);
1071
+ await saveReply(`format-retry-${formatRetries}`, again);
1072
+ lastMarkdown = again.markdown;
1073
+ continue;
1074
+ }
1075
+ formatRetries = 0;
1076
+ iterations += 1;
1077
+ await setTask((t) => {
1078
+ t.iterations = iterations;
1079
+ });
1080
+ const { reply, done, blocked } = parsed;
1081
+ sink.event('reply-parsed', { iteration: iterations, status: reply.status, steps: reply.steps.length, notes: reply.notes }, `iteration ${iterations}: ${reply.steps.length} step(s)${reply.notes ? ` — ${reply.notes}` : ''}`);
1082
+ /*
1083
+ * A deviation is recorded the moment it is declared, not when the task ends.
1084
+ *
1085
+ * Merged rather than appended while the task goes on, because the same one tends to be
1086
+ * declared twice; replaced by the closing reply's list when it carries one, because that
1087
+ * is the final account and a deviation undone since would otherwise stand in the commit.
1088
+ * Written to the task at once, because a task that ends `failed` or `aborted` still
1089
+ * deviated, and that is still worth knowing about.
1090
+ */
1091
+ if (reply.deviations.length > 0) {
1092
+ deviations = resolveDeviations(deviations, reply.status, reply.deviations);
1093
+ await setTask((t) => {
1094
+ t.deviations = deviations;
1095
+ });
1096
+ sink.event('deviation-declared', { count: reply.deviations.length, total: deviations.length }, `the model says ${reply.deviations.length} instruction(s) could not be followed as written: ` +
1097
+ reply.deviations.map((d) => d.instruction).join('; '), 'warn');
1098
+ await record('DEVIATIONS DECLARED', describeDeviations(reply.deviations));
1099
+ }
1100
+ /*
1101
+ * A disputed finding goes on the record and to the next reviewer, not into the summary.
1102
+ *
1103
+ * The alternative was the one the message used to recommend — "say so in your summary"
1104
+ * — and the summary is the one thing the next reviewer is never shown.
1105
+ */
1106
+ if (reply.disputed.length > 0) {
1107
+ disputes = mergeDisputes(disputes, reply.disputed);
1108
+ await setTask((t) => {
1109
+ t.disputes = disputes;
1110
+ });
1111
+ sink.event('finding-disputed', { count: reply.disputed.length, ids: reply.disputed.map((d) => d.finding) }, `the model disputes ${reply.disputed.length} review finding(s): ${reply.disputed.map((d) => d.finding).join(', ')}`, 'warn');
1112
+ await record('FINDINGS DISPUTED', describeDisputes(reply.disputed));
1113
+ // A disputed finding's check does not run again until the next review rules on it.
1114
+ const paused = suspendDisputed(reviewChecks, reply.disputed.map((d) => d.finding));
1115
+ if (paused.suspended.length > 0) {
1116
+ reviewChecks = paused.checks;
1117
+ await setTask((t) => {
1118
+ t.reviewChecks = reviewChecks;
1119
+ });
1120
+ sink.event('review-check-suspended', { findings: paused.suspended }, `check(s) from disputed finding(s) suspended until the next review rules: ${paused.suspended.join(', ')}`);
1121
+ }
1122
+ /*
1123
+ * Said back at once, in the next message. The implementer that disputed a check it
1124
+ * could not satisfy, and heard nothing, ended the task `blocked` over that check — it
1125
+ * had no way to know the dispute had taken the check out of the gate.
1126
+ */
1127
+ const ids = reply.disputed.map((d) => d.finding).join(', ');
1128
+ runnerNotes.push(paused.suspended.length > 0
1129
+ ? `Noted: you disputed ${ids}. The check(s) tied to ${paused.suspended.join(', ')} are suspended and will not run ` +
1130
+ 'until the next review rules on your dispute. When the work is verified, report done again; the next reviewer is told.'
1131
+ : `Noted: you disputed ${ids}; no check was tied to those findings. The next reviewer is told. When the work is verified, report done again.`);
1132
+ }
1133
+ /*
1134
+ * The task ends here, and it ends without the checks.
1135
+ *
1136
+ * Running them would only produce a list of things that are not true, which is already
1137
+ * what the reply said, at greater length and one message later. The reason carries the
1138
+ * approaches that were tried, and that is what the register shows.
1139
+ */
1140
+ if (blocked) {
1141
+ sink.event('task-blocked', { tried: reply.tried, needed: reply.needed }, `the task was given up as blocked after ${reply.tried.length} approach(es)`, 'warn');
1142
+ return await finish('blocked', blockedReason(reply.tried, reply.needed), reply.summary, lastMarkdown);
1143
+ }
1144
+ if (done && reply.steps.length === 0) {
1145
+ const verdict = await gateOnChecks();
1146
+ if (verdict === 'environment') {
1147
+ return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1148
+ }
1149
+ if (verdict === 'give-up') {
1150
+ return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1151
+ }
1152
+ if (verdict === 'accept') {
1153
+ const reviewed = await gateOnReview(reply.summary);
1154
+ if (reviewed === 'accept')
1155
+ return await finish('done', undefined, reply.summary, lastMarkdown);
1156
+ if (reviewed === 'give-up')
1157
+ return await finish('blocked', reviewBlockedReason(), reply.summary, lastMarkdown);
1158
+ }
1159
+ continue;
1160
+ }
1161
+ // --- execute -----------------------------------------------------------------
1162
+ const results = [];
1163
+ let aborted = false;
1164
+ /** How many of this iteration's steps were turned away for being repeats. */
1165
+ let repeatsRefused = 0;
1166
+ /** Steps refused because they named a shell this machine has not got. */
1167
+ let shellRefused = 0;
1168
+ for (const step of reply.steps) {
1169
+ if (signal?.aborted) {
1170
+ results.push(refusedResult(step, 'stopped by the operator'));
1171
+ aborted = true;
1172
+ break;
1173
+ }
1174
+ {
1175
+ const damage = findLikelyDamage(step.cmd);
1176
+ if (damage) {
1177
+ sink.event('step-damaged', { id: step.id, cmd: step.cmd, damage }, `step ${step.id} arrived damaged: ${damage}`, 'warn');
1178
+ results.push(refusedResult(step, `${damage}. ${damageGuidance()}`));
1179
+ continue;
1180
+ }
1181
+ }
1182
+ /*
1183
+ * The step named a shell this machine has not got.
1184
+ *
1185
+ * Refused, never substituted — a command written for PowerShell and quietly handed to
1186
+ * `cmd` does not fail, it does something else — but refused the way a deny pattern is
1187
+ * refused, as one step, and not the way a missing shell under a *check* is: as the end of
1188
+ * the task. The difference is who chose the shell. A check's shell is the operator's, set
1189
+ * in a plan the chat cannot edit, so nothing the chat does next can help and the task
1190
+ * stops on it. A step's shell is the chat's own, and the contract it works to shows
1191
+ * `"shell": "pwsh"` in its one example, so on a machine without PowerShell 7 the chat will
1192
+ * write that on its first try almost every time. Telling it what the machine actually has
1193
+ * and letting it write the step again is the whole mechanism of this bot; killing the task
1194
+ * instead would make the machine this change was written for worse off than before it.
1195
+ */
1196
+ const named = resolveShell(step.shell);
1197
+ if (!named.ok) {
1198
+ shellRefused += 1;
1199
+ sink.event('step-shell-missing', { id: step.id, requested: named.problem.requested, available: named.problem.available }, `step ${step.id} asked for a shell this machine has not got: ${named.problem.message}`, 'warn');
1200
+ results.push(refusedResult(step, refusalForChat(named.problem, defaultShell)));
1201
+ continue;
1202
+ }
1203
+ // Refused before the operator is asked to approve it, because a step that cannot teach
1204
+ // anyone anything is not worth a person's attention either.
1205
+ const key = fingerprint(step);
1206
+ const ran = seen.get(key)?.sameInARow ?? 0;
1207
+ if (ran >= maxCommandRepeats) {
1208
+ repeatsRefused += 1;
1209
+ sink.event('step-repeated', { id: step.id, count: ran, limit: maxCommandRepeats }, `step ${step.id} refused: already run ${ran} time(s) in this task with the same result`, 'warn');
1210
+ results.push(refusedResult(step, repeatRefusal(ran, maxCommandRepeats)));
1211
+ continue;
1212
+ }
1213
+ await setTask((t) => {
1214
+ t.status = 'waiting-approval';
1215
+ });
1216
+ sink.event('step-proposed', { id: step.id, description: describeStep(step) }, `step ${step.id}: ${describeStep(step)}`);
1217
+ const decision = await authorizer.authorize(step, { sessionId: session.id, taskId: task.id, iteration: iterations, confinement });
1218
+ await setTask((t) => {
1219
+ t.status = 'running';
1220
+ });
1221
+ if (decision.action === 'abort') {
1222
+ results.push(refusedResult(step, decision.reason));
1223
+ aborted = true;
1224
+ break;
1225
+ }
1226
+ if (decision.action === 'skip') {
1227
+ sink.event('step-skipped', { id: step.id, reason: decision.reason }, `step ${step.id} skipped: ${decision.reason}`, 'warn');
1228
+ results.push(refusedResult(step, decision.reason));
1229
+ continue;
1230
+ }
1231
+ const long = step.expect === 'long';
1232
+ const cap = cfg.execution.maxStepTimeoutSec;
1233
+ const hard = Math.min(step.timeoutSec ?? (long ? cfg.execution.longCommandTimeoutSec : cfg.execution.commandTimeoutSec), cap);
1234
+ const idle = Math.min(step.idleTimeoutSec ?? (long ? cfg.execution.longIdleTimeoutSec : cfg.execution.idleTimeoutSec), cap);
1235
+ sink.event('step-started', { id: step.id, shell: step.shell ?? defaultShell }, `running step ${step.id}: ${describeStep(step)}`);
1236
+ const result = await runStep({
1237
+ id: step.id,
1238
+ shell: step.shell ?? defaultShell,
1239
+ command: step.cmd,
1240
+ cwd: work.cwd,
1241
+ hardTimeoutMs: hard * 1000,
1242
+ idleTimeoutMs: idle * 1000,
1243
+ logPath: log.path('steps', `${iterations}-${step.id}.log`),
1244
+ passEnv: cfg.execution.passEnv,
1245
+ }, {
1246
+ signal,
1247
+ tracker,
1248
+ onHeartbeat: ({ elapsedMs, idleMs, lastLine }) => sink.event('step-heartbeat', { id: step.id, elapsedMs, idleMs, lastLine }, `step ${step.id} still running: ${Math.round(elapsedMs / 1000)}s elapsed, ${Math.round(idleMs / 1000)}s since output${lastLine ? ` — ${lastLine.slice(0, 60)}` : ''}`),
1249
+ });
1250
+ results.push(result);
1251
+ // The run counts toward the limit only if it changed nothing about the answer.
1252
+ const signature = resultSignature(result);
1253
+ const previous = seen.get(key);
1254
+ seen.set(key, {
1255
+ signature,
1256
+ sameInARow: previous && previous.signature === signature ? previous.sameInARow + 1 : 1,
1257
+ });
1258
+ sink.event('step-finished', { id: step.id, outcome: result.outcome, exitCode: result.exitCode, durationMs: result.durationMs, shell: result.shell, requestedShell: result.requestedShell ?? null, shellPath: result.shellPath ?? '' }, `step ${step.id}: ${result.outcome}, exit ${result.exitCode}, ${(result.durationMs / 1000).toFixed(1)}s`);
1259
+ /*
1260
+ * The shell itself would not start. The same rule as in the check gate applies, for the
1261
+ * same reason: nothing was learned about the work, and the next message would be asking
1262
+ * the model to fix an interpreter. The iteration stops here and the task ends on it.
1263
+ */
1264
+ if (result.shellProblem) {
1265
+ environmentProblem = result.shellProblem;
1266
+ sink.event('step-environment', { id: step.id, requested: result.requestedShell ?? null, available: result.shellProblem.available }, result.shellProblem.message, 'error');
1267
+ break;
1268
+ }
1269
+ if (cfg.execution.stopOnFailure && result.exitCode !== 0) {
1270
+ sink.event('stop-on-failure', { id: step.id }, 'stopping the iteration: stopOnFailure is set', 'warn');
1271
+ break;
1272
+ }
1273
+ await pacer.settle();
1274
+ }
1275
+ // Ended before the report is written and sent: there is nobody to send it to who could
1276
+ // do anything with it, and the reason on the task says what to install or set instead.
1277
+ if (environmentProblem) {
1278
+ return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1279
+ }
1280
+ // --- report back ---------------------------------------------------------------
1281
+ const report = await writeReport(results, {
1282
+ runId,
1283
+ task: task.title,
1284
+ taskText: task.prompt,
1285
+ iteration: iterations,
1286
+ dir: reportsDir,
1287
+ fileNameTemplate: cfg.report.fileName,
1288
+ maxReportBytes: cfg.report.maxReportBytes,
1289
+ maxOutputChars: cfg.report.maxOutputChars,
1290
+ redactPatterns: cfg.report.redactPatterns,
1291
+ });
1292
+ await record(`ITERATION ${iterations}`, await readFile(report.paths[0], 'utf8'));
1293
+ sink.event('report-written', { iteration: iterations, files: report.names, bytes: report.bytes }, `report: ${report.names.join(', ')} (${report.bytes < 1024 ? `${report.bytes} bytes` : `${(report.bytes / 1024).toFixed(1)} KB`})`);
1294
+ if (report.redactions.length > 0) {
1295
+ sink.event('report-redacted', { iteration: iterations, redactions: report.redactions }, `redacted before upload: ${report.redactions.map((r) => `${r.count}× ${r.name}`).join(', ')}`, 'warn');
1296
+ }
1297
+ if (aborted)
1298
+ return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
1299
+ /*
1300
+ * Nothing this iteration did anything.
1301
+ *
1302
+ * Every step was a repeat of something already run, which means the previous refusal was
1303
+ * read and ignored. One of those is the model reacting to the refusal; two in a row is a
1304
+ * loop, and the task is ended here rather than left to burn through its iterations and
1305
+ * die with a message about a limit that explains nothing.
1306
+ */
1307
+ // A whole iteration of steps naming an interpreter that is not here is the same signature
1308
+ // as a whole iteration of repeats: the refusal was read and written past. Counted with
1309
+ // them rather than given a mechanism of its own, because the answer is the same one.
1310
+ const refusedEverything = repeatsRefused + shellRefused;
1311
+ /*
1312
+ * Which of the two it was, when it has to be said in a sentence.
1313
+ *
1314
+ * An iteration can be all repeats, all missing shells, or one of each — and the third case
1315
+ * is the one that lies if it is not named. Reported as "every step was a repeat" it also
1316
+ * printed an empty list of repeated commands, because the shell-refused step never reached
1317
+ * the fingerprint and there was nothing to list.
1318
+ */
1319
+ const allRepeats = shellRefused === 0;
1320
+ const allNamedAMissingShell = repeatsRefused === 0;
1321
+ if (reply.steps.length > 0 && refusedEverything === reply.steps.length) {
1322
+ stalled += 1;
1323
+ sink.event('iteration-stalled', { stalled, limit: maxStalledIterations }, `every step this iteration ${allRepeats ? 'was a repeat' : allNamedAMissingShell ? 'named a shell this machine has not got' : 'was a repeat or named a shell this machine has not got'} (${stalled}/${maxStalledIterations})`, 'warn');
1324
+ if (stalled >= maxStalledIterations) {
1325
+ const repeated = [...seen.entries()]
1326
+ .filter(([, v]) => v.sameInARow >= maxCommandRepeats)
1327
+ .map(([cmd]) => cmd.slice(0, 120));
1328
+ return await finish('blocked', allRepeats
1329
+ ? `the same command(s) were sent again after being refused for repetition, ${stalled} iteration(s) running, ` +
1330
+ `so the task was ended rather than left to run out of iterations. Repeated: ${repeated.join(' | ')}`
1331
+ : allNamedAMissingShell
1332
+ ? `every step named a shell this machine has not got, ${stalled} iteration(s) running, after the chat was ` +
1333
+ 'told which shells are here. The task was ended rather than left to run out of iterations.'
1334
+ : `every step was either a repeat or named a shell this machine has not got, ${stalled} iteration(s) ` +
1335
+ `running, so the task was ended rather than left to run out of iterations.` +
1336
+ (repeated.length > 0 ? ` Repeated: ${repeated.join(' | ')}` : ''), reply.summary, lastMarkdown);
1337
+ }
1338
+ }
1339
+ else {
1340
+ stalled = 0;
1341
+ }
1342
+ let covering = buildCoveringMessage({ task: task.title, iteration: iterations, results, attachments: report.names, parts: report.parts, notes: runnerNotes });
1343
+ runnerNotes = [];
1344
+ // Said in the message as well as in the step's own output, because a refusal buried in an
1345
+ // attached file is a refusal that gets read after the next command has been written.
1346
+ if (shellRefused > 0) {
1347
+ const here = availableShells(shells);
1348
+ covering +=
1349
+ here.length === 0
1350
+ ? `\n\n${shellRefused} of the ${reply.steps.length} step(s) were not run: this machine has no Windows ` +
1351
+ 'shell the runner can find, so nothing can be run on it. End the task with status "blocked", and fill ' +
1352
+ '"tried" — the format wants two entries there — with what you attempted and this refusal.'
1353
+ : `\n\n${shellRefused} of the ${reply.steps.length} step(s) were not run: they named a shell this ` +
1354
+ `machine has not got. This machine has ${here.join(' and ')}. Send the same work again with ` +
1355
+ `"shell": "${defaultShell}", or leave "shell" out — both give you ${defaultShell} — and write the ` +
1356
+ 'commands for that shell.';
1357
+ }
1358
+ if (repeatsRefused > 0) {
1359
+ covering +=
1360
+ `\n\n${repeatsRefused} of the ${reply.steps.length} step(s) were not run: they repeat a command ` +
1361
+ `that has already run ${maxCommandRepeats} time(s) in this task with the same result. ` +
1362
+ 'Change the approach rather than the wording. If nothing else is left to try, end with ' +
1363
+ 'status "blocked" and say in "tried" what you attempted.';
1364
+ }
1365
+ assertSendable(covering, report.names);
1366
+ let sent = false;
1367
+ for (let attempt = 0; attempt <= cfg.report.uploadRetries && !sent; attempt += 1) {
1368
+ try {
1369
+ await pacer.throttleSend();
1370
+ const before = await transport.sendAndConfirm(covering, report.paths);
1371
+ const next = await transport.waitForReply(before);
1372
+ await saveReply(`iteration-${iterations}`, next);
1373
+ lastMarkdown = next.markdown;
1374
+ sent = true;
1375
+ }
1376
+ catch (e) {
1377
+ sink.event('report-send-failed', { attempt, error: String(e) }, `sending the report failed (attempt ${attempt + 1}): ${e.message}`, 'warn');
1378
+ if (attempt === cfg.report.uploadRetries) {
1379
+ const body = await readFile(report.paths[0], 'utf8');
1380
+ const text = `${covering}\n\nThe upload failed, so here is the output as text, truncated:\n\n` +
1381
+ body.slice(0, cfg.limits.maxMessageChars - covering.length - 200);
1382
+ await pacer.throttleSend();
1383
+ const before = await transport.sendAndConfirm(text);
1384
+ const next = await transport.waitForReply(before);
1385
+ lastMarkdown = next.markdown;
1386
+ sent = true;
1387
+ }
1388
+ else {
1389
+ await new Promise((r) => setTimeout(r, pacer.backoffFor(attempt)));
1390
+ }
1391
+ }
1392
+ }
1393
+ if (done) {
1394
+ const verdict = await gateOnChecks();
1395
+ if (verdict === 'environment') {
1396
+ return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1397
+ }
1398
+ if (verdict === 'give-up') {
1399
+ return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1400
+ }
1401
+ if (verdict === 'accept') {
1402
+ const reviewed = await gateOnReview(reply.summary);
1403
+ if (reviewed === 'accept')
1404
+ return await finish('done', undefined, reply.summary, lastMarkdown);
1405
+ if (reviewed === 'give-up')
1406
+ return await finish('blocked', reviewBlockedReason(), reply.summary, lastMarkdown);
1407
+ }
1408
+ continue;
1409
+ }
1410
+ await pacer.settle();
1411
+ }
1412
+ }
1413
+ catch (e) {
1414
+ const message = e.message;
1415
+ sink.event('task-error', { error: message, stack: e.stack }, message, 'error');
1416
+ await transport.dumpFailure(log.path('failures'), 'crash').catch(() => undefined);
1417
+ /*
1418
+ * "Target page, context or browser has been closed" is what every call says once the
1419
+ * browser is gone, and it explains nothing. When Edge left a crash report, the reason
1420
+ * says so, with the process that died and where the report is.
1421
+ */
1422
+ const crash = /has been closed/i.test(message) ? await transport.recentCrash().catch(() => null) : null;
1423
+ return await finish('failed', crash ? `${describeCrash(crash)}. Then: ${message}` : message);
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Runs every queued task of a session, in order, in one conversation. Stops at the first
1428
+ * task that does not end with `done` unless `continueOnFailure` is set, because a failed
1429
+ * task usually leaves the machine in a state the next task did not expect.
1430
+ */
1431
+ export async function runSession(sessionId, deps) {
1432
+ const { cfg, store, bus } = deps;
1433
+ let session = await store.getSession(sessionId);
1434
+ if (!session)
1435
+ throw new Error(`Session ${sessionId} does not exist.`);
1436
+ const queued = session.tasks.filter((t) => t.status === 'queued');
1437
+ if (queued.length === 0) {
1438
+ bus.publish({ sessionId, type: 'session-idle', level: 'info', message: 'no queued tasks' });
1439
+ return { ran: 0, paused: false };
1440
+ }
1441
+ await store.updateSession(sessionId, (s) => {
1442
+ s.status = 'running';
1443
+ });
1444
+ bus.publish({ sessionId, type: 'session-started', level: 'info', message: `${queued.length} task(s) queued` });
1445
+ const sessionRunsDir = join(cfg.resolved.runsDir, session.id);
1446
+ await mkdir(sessionRunsDir, { recursive: true });
1447
+ const borrowed = deps.transport ?? null;
1448
+ let transport = borrowed;
1449
+ let ran = 0;
1450
+ let lastStatus;
1451
+ /** Whether the queue stopped because the operator asked it to hold, rather than because it ended. */
1452
+ let paused = false;
1453
+ try {
1454
+ session = await joinGroupConversation(store, session, bus);
1455
+ if (borrowed) {
1456
+ bus.publish({ sessionId, type: 'browser-reused', level: 'info', message: 'using the browser window that is already open' });
1457
+ await enterSessionConversation(borrowed, session, { closeOnFailure: false });
1458
+ }
1459
+ else {
1460
+ transport = await openSessionTransport(cfg, session, bus, sessionRunsDir);
1461
+ }
1462
+ const chat = transport;
1463
+ // The picker belongs to the conversation, so the session's choice is applied once, here,
1464
+ // before the first task goes out. What the chat ended up on is recorded either way.
1465
+ const modelInUse = await applySessionModel(chat, session, bus);
1466
+ if (session.model?.trim()) {
1467
+ await store.updateSession(sessionId, (s) => {
1468
+ s.modelInUse = modelInUse;
1469
+ });
1470
+ }
1471
+ for (const queuedTask of queued) {
1472
+ if (deps.signal?.aborted)
1473
+ break;
1474
+ if (deps.shouldPause?.()) {
1475
+ paused = true;
1476
+ bus.publish({ sessionId, type: 'session-paused', level: 'info',
1477
+ message: `paused by the operator after ${ran} task(s); the rest stay queued and the conversation is kept` });
1478
+ break;
1479
+ }
1480
+ session = (await store.getSession(sessionId));
1481
+ const task = session.tasks.find((t) => t.id === queuedTask.id);
1482
+ if (!task || task.status !== 'queued')
1483
+ continue;
1484
+ let outcome = await runTask(chat, session, task, deps);
1485
+ ran += 1;
1486
+ /*
1487
+ * A blocked task is run again in a fresh conversation, up to the configured number of
1488
+ * times, before the verdict stands.
1489
+ *
1490
+ * No judgement is made about why it blocked; none can be. The move is the one thing that
1491
+ * separates the causes: a new chat, with the contract sent again and nothing else in it,
1492
+ * cures a cause that lived in the conversation (Copilot no longer seeing the early turns;
1493
+ * a history it argued itself into) and cures nothing else. So a task that comes back done
1494
+ * was blocked by the chat, and a task that blocks again — in nearly the same words, with
1495
+ * both attempts on its record — was not. The operator reads which from the register.
1496
+ */
1497
+ const retriesAllowed = cfg.limits.retryBlockedInFreshChat ?? 0;
1498
+ let retried = 0;
1499
+ while (outcome.status === 'blocked' && retried < retriesAllowed && !deps.signal?.aborted) {
1500
+ retried += 1;
1501
+ bus.publish({
1502
+ sessionId,
1503
+ taskId: task.id,
1504
+ type: 'task-retry-fresh-chat',
1505
+ level: 'warn',
1506
+ message: `"${task.title}" ended blocked; running it again in a fresh conversation (${retried} of ${retriesAllowed})`,
1507
+ data: { attempt: retried, of: retriesAllowed, reason: outcome.reason },
1508
+ });
1509
+ await store.rerunTask(sessionId, task.id, {});
1510
+ // The conversation pointer is dropped, so the next message opens a new chat, registers
1511
+ // it and sends the contract again. The old chat stays where it is, readable.
1512
+ session = await store.updateSession(sessionId, (s) => {
1513
+ s.chat = undefined;
1514
+ s.contractSent = false;
1515
+ const again = s.tasks.find((x) => x.id === task.id);
1516
+ if (again)
1517
+ again.autoRetries = (again.autoRetries ?? 0) + 1;
1518
+ });
1519
+ await chat.newChat();
1520
+ /*
1521
+ * The picker belongs to the conversation, so a new conversation has lost the choice.
1522
+ *
1523
+ * The model is applied once, before the first task, because that is where the
1524
+ * conversation is opened — and this is the other place one is opened, so it has to be
1525
+ * applied again here. Without it a session that asked for a particular model ran its
1526
+ * retries on whatever the chat opens on, which is "Auto", and the record went on saying
1527
+ * it was the model the session asked for. The review has always done this for its own
1528
+ * fresh conversations a few hundred lines up; this path simply never did.
1529
+ */
1530
+ const freshModel = await applySessionModel(chat, session, bus);
1531
+ if (session.model?.trim()) {
1532
+ session = await store.updateSession(sessionId, (s) => {
1533
+ s.modelInUse = freshModel;
1534
+ });
1535
+ }
1536
+ const fresh = session.tasks.find((x) => x.id === task.id);
1537
+ if (!fresh)
1538
+ break;
1539
+ outcome = await runTask(chat, session, fresh, deps);
1540
+ ran += 1;
1541
+ }
1542
+ if (retried > 0) {
1543
+ bus.publish({
1544
+ sessionId,
1545
+ taskId: task.id,
1546
+ type: outcome.status === 'blocked' ? 'task-retry-exhausted' : 'task-retry-recovered',
1547
+ level: outcome.status === 'blocked' ? 'error' : 'info',
1548
+ message: outcome.status === 'blocked'
1549
+ ? `"${task.title}" blocked again after ${retried} fresh conversation(s): the cause is not the chat — read the task text, the checks and the machine`
1550
+ : `"${task.title}" ended ${outcome.status} in a fresh conversation after blocking ${retried} time(s): the earlier block was the chat's`,
1551
+ data: { retried, status: outcome.status },
1552
+ });
1553
+ }
1554
+ lastStatus = outcome.status;
1555
+ if (outcome.status !== 'done') {
1556
+ if (!deps.continueOnFailure) {
1557
+ bus.publish({ sessionId, type: 'session-stopped-early', level: 'warn',
1558
+ message: `task "${task.title}" ended ${outcome.status}; the remaining tasks stay queued` });
1559
+ break;
1560
+ }
1561
+ // Saying this out loud matters: carrying on past a failure is a choice the operator
1562
+ // made earlier, and the log is where they find out it was taken.
1563
+ bus.publish({ sessionId, type: 'session-continuing', level: 'warn',
1564
+ message: `task "${task.title}" ended ${outcome.status}; continuing with the next one, as this session is set to` });
1565
+ }
1566
+ }
1567
+ }
1568
+ catch (e) {
1569
+ bus.publish({ sessionId, type: 'session-error', level: 'error', message: e.message });
1570
+ throw e;
1571
+ }
1572
+ finally {
1573
+ // A borrowed window belongs to whoever opened it and stays open for the next session.
1574
+ if (!borrowed)
1575
+ await transport?.close();
1576
+ await store.updateSession(sessionId, (s) => {
1577
+ s.status = 'idle';
1578
+ });
1579
+ bus.publish({ sessionId, type: 'session-finished', level: 'info', message: `${ran} task(s) ran` });
1580
+ }
1581
+ return { ran, lastStatus, paused };
1582
+ }