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,440 @@
1
+ /**
2
+ * Where sessions, level-2 presets and the level-1 contract live on disk.
3
+ *
4
+ * JSON files, not a database. A single operator on one machine gets nothing from a
5
+ * database except a dependency, and files can be read, diffed and backed up with nothing
6
+ * installed. Writes go through a temp file and a rename, so a crash mid-write cannot leave a
7
+ * half-written session behind.
8
+ */
9
+ import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+ import { newId, tidyVcsPlan } from './model.js';
13
+ /** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
14
+ const ACTIVE_STATUSES = ['running', 'waiting-approval'];
15
+ /**
16
+ * Version control is on unless the operator turns it off.
17
+ *
18
+ * The recommended state, and the safe one: a bot that edits files with no branch and no commit
19
+ * leaves the operator with no way back except their own memory of what the tree looked like.
20
+ */
21
+ /**
22
+ * Work is reviewed unless somebody says otherwise.
23
+ *
24
+ * The same argument as version control being on by default: the case for it is strongest
25
+ * exactly when nobody is thinking about it. The model is left empty, meaning the session's own
26
+ * — a different one makes the review better and is worth setting, but requiring it before the
27
+ * feature works at all would mean most runs go unreviewed.
28
+ */
29
+ export const DEFAULT_REVIEW = { enabled: true, model: '' };
30
+ export const DEFAULT_VCS = {
31
+ enabled: true,
32
+ repoDir: '',
33
+ branchMode: 'per-task',
34
+ commitOnFinish: true,
35
+ branchPrefix: 'cop/',
36
+ };
37
+ export const DEFAULT_MIRROR = {
38
+ enabled: false,
39
+ rootDir: '',
40
+ includeDirs: [],
41
+ excludeDirs: [],
42
+ respectGitignore: true,
43
+ includeEnvFiles: false,
44
+ };
45
+ export class SessionStore {
46
+ defaultLevel1Path;
47
+ dir;
48
+ sessionsDir;
49
+ presetsDir;
50
+ level1Path;
51
+ modelsPath;
52
+ constructor(dataDir, defaultLevel1Path) {
53
+ this.defaultLevel1Path = defaultLevel1Path;
54
+ this.dir = resolve(dataDir);
55
+ this.sessionsDir = join(this.dir, 'sessions');
56
+ this.presetsDir = join(this.dir, 'level2');
57
+ this.level1Path = join(this.dir, 'level1.md');
58
+ this.modelsPath = join(this.dir, 'models.json');
59
+ }
60
+ async init() {
61
+ await mkdir(this.sessionsDir, { recursive: true });
62
+ await mkdir(this.presetsDir, { recursive: true });
63
+ }
64
+ // --- level 1 --------------------------------------------------------------------------
65
+ /** The contract in force: the user's edited copy if there is one, else the shipped one. */
66
+ async getLevel1() {
67
+ if (existsSync(this.level1Path)) {
68
+ return { content: await readFile(this.level1Path, 'utf8'), customised: true };
69
+ }
70
+ return { content: await readFile(this.defaultLevel1Path, 'utf8'), customised: false };
71
+ }
72
+ async setLevel1(content) {
73
+ await this.atomicWrite(this.level1Path, content);
74
+ }
75
+ async resetLevel1() {
76
+ await rm(this.level1Path, { force: true });
77
+ }
78
+ // --- the organisation's part of the plan persona -------------------------------------
79
+ /**
80
+ * The operator's own part of the plan brief: where tickets come from, what to search, how
81
+ * the machine is laid out. One customised copy in `data/organisation.md`, whatever language
82
+ * it is written in; the shipped default comes per language from the prompts folder.
83
+ */
84
+ async getContext(kind, lang) {
85
+ const custom = join(this.dir, `context-${kind}.md`);
86
+ const shipped = join(this.defaultLevel1Path, '..', `${kind}.${lang}.md`);
87
+ const example = await readFile(shipped, 'utf8').catch(() => '');
88
+ if (existsSync(custom))
89
+ return { content: await readFile(custom, 'utf8'), customised: true, example };
90
+ // Nothing until the operator writes it: an empty field is what makes the persona ask.
91
+ // The shipped text is the example it is shown, not the answer.
92
+ return { content: '', customised: false, example };
93
+ }
94
+ async setContext(kind, content) {
95
+ await this.atomicWrite(join(this.dir, `context-${kind}.md`), content);
96
+ }
97
+ async resetContext(kind) {
98
+ await rm(join(this.dir, `context-${kind}.md`), { force: true });
99
+ }
100
+ // --- the model catalogue ----------------------------------------------------------------
101
+ /** What the chat's picker offered when it was last read, or null if it never was. */
102
+ async getModels() {
103
+ try {
104
+ return JSON.parse(await readFile(this.modelsPath, 'utf8'));
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ async saveModels(catalogue) {
111
+ await this.atomicWrite(this.modelsPath, JSON.stringify(catalogue, null, 2));
112
+ }
113
+ // --- level 2 presets ------------------------------------------------------------------
114
+ async listPresets() {
115
+ const names = (await readdir(this.presetsDir).catch(() => []))
116
+ .filter((n) => n.endsWith('.md'))
117
+ .sort();
118
+ const out = [];
119
+ for (const n of names) {
120
+ const path = join(this.presetsDir, n);
121
+ const content = await readFile(path, 'utf8');
122
+ const { mtime } = await import('node:fs').then((fs) => fs.statSync(path));
123
+ out.push({ name: n.slice(0, -3), content, updatedAt: mtime.toISOString() });
124
+ }
125
+ return out;
126
+ }
127
+ async savePreset(name, content) {
128
+ const safe = name.replace(/[^\p{L}\p{N}._ -]/gu, '').trim();
129
+ if (!safe)
130
+ throw new Error('Preset name is empty after removing unsafe characters.');
131
+ await this.atomicWrite(join(this.presetsDir, `${safe}.md`), content);
132
+ return { name: safe, content, updatedAt: new Date().toISOString() };
133
+ }
134
+ async deletePreset(name) {
135
+ await rm(join(this.presetsDir, `${safePresetName(name)}.md`), { force: true });
136
+ }
137
+ // --- sessions -------------------------------------------------------------------------
138
+ /**
139
+ * Every session, newest first — by when it was made, not by what it is called.
140
+ *
141
+ * This used to sort the file names, which looks equivalent because an id starts with a
142
+ * timestamp. It is not: the timestamp is only accurate to the second, and the four characters
143
+ * after it are random. Sessions made in the same second therefore came back in an order
144
+ * decided by a coin toss — and an import makes all of its sessions in the same second, so a
145
+ * plan of three sessions ran in a random order, which is exactly the thing a plan is for.
146
+ * It happened: an audit session ran before the sessions whose work it was auditing.
147
+ *
148
+ * `createdAt` is accurate to the millisecond and is written by the same call that makes the
149
+ * id, so it says what the name only approximates. The id breaks a tie, so the order is at
150
+ * least stable when even that is not enough to separate two.
151
+ */
152
+ async listSessions() {
153
+ const files = (await readdir(this.sessionsDir).catch(() => [])).filter((n) => n.endsWith('.json'));
154
+ const out = [];
155
+ for (const f of files) {
156
+ const s = await this.readSession(join(this.sessionsDir, f));
157
+ if (s)
158
+ out.push(s);
159
+ }
160
+ return out.sort((a, b) => {
161
+ const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
162
+ if (byTime !== 0 && !Number.isNaN(byTime))
163
+ return byTime;
164
+ return b.id.localeCompare(a.id);
165
+ });
166
+ }
167
+ async getSession(id) {
168
+ return await this.readSession(join(this.sessionsDir, `${safeName(id)}.json`));
169
+ }
170
+ async createSession(name, mirror = {}) {
171
+ const session = {
172
+ id: newId(),
173
+ name: name.trim() || 'untitled',
174
+ createdAt: new Date().toISOString(),
175
+ status: 'idle',
176
+ contractSent: false,
177
+ // A queue is a chain until the operator says otherwise, which is how it has always
178
+ // behaved and the safer of the two.
179
+ onFailure: 'stop',
180
+ vcs: { ...DEFAULT_VCS },
181
+ mirror: { ...DEFAULT_MIRROR, ...mirror },
182
+ tasks: [],
183
+ };
184
+ await this.saveSession(session);
185
+ return session;
186
+ }
187
+ async saveSession(session) {
188
+ await this.atomicWrite(join(this.sessionsDir, `${safeName(session.id)}.json`), JSON.stringify(session, null, 2));
189
+ }
190
+ async deleteSession(id) {
191
+ await rm(join(this.sessionsDir, `${safeName(id)}.json`), { force: true });
192
+ }
193
+ /** Applies a change under a fresh read, so two writers cannot clobber each other. */
194
+ async updateSession(id, mutate) {
195
+ const s = await this.getSession(id);
196
+ if (!s)
197
+ throw new Error(`Session ${id} does not exist.`);
198
+ mutate(s);
199
+ await this.saveSession(s);
200
+ return s;
201
+ }
202
+ async addTask(sessionId, input) {
203
+ const vcsPlan = tidyVcsPlan(input.vcsPlan);
204
+ const checks = (input.checks ?? []).filter((c) => c.name.trim() !== '');
205
+ const task = {
206
+ id: newId('t-'),
207
+ title: input.title.trim() || input.prompt.trim().slice(0, 60) || 'untitled task',
208
+ level2: input.level2,
209
+ prompt: input.prompt,
210
+ status: 'queued',
211
+ createdAt: new Date().toISOString(),
212
+ iterations: 0,
213
+ // Stored only when there is something to store, so a task made in the UI stays the
214
+ // shape it has always been on disk.
215
+ ...(vcsPlan ? { vcsPlan } : {}),
216
+ ...(checks.length > 0 ? { checks } : {}),
217
+ // Only `false` is worth storing: absent means "whatever the session says", which is on.
218
+ ...(input.reviewEnabled === false ? { reviewEnabled: false } : {}),
219
+ // Only `true` is worth storing: a task may change files unless a plan said otherwise.
220
+ ...(input.readOnly === true ? { readOnly: true } : {}),
221
+ };
222
+ await this.updateSession(sessionId, (s) => {
223
+ s.tasks.push(task);
224
+ });
225
+ return task;
226
+ }
227
+ async updateTask(sessionId, taskId, mutate) {
228
+ let found;
229
+ await this.updateSession(sessionId, (s) => {
230
+ found = s.tasks.find((t) => t.id === taskId);
231
+ if (!found)
232
+ throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
233
+ mutate(found);
234
+ });
235
+ return found;
236
+ }
237
+ /**
238
+ * Removes a task from its session. Anything that is not in flight can go: a queued task the
239
+ * user changed their mind about, and equally a finished one they no longer want in the
240
+ * register. Only a task the runner is inside of is protected, because deleting that one
241
+ * would leave the run writing to a task that no longer exists. The run folder under `runs/`
242
+ * is untouched either way, so the record of what was executed survives the deletion.
243
+ */
244
+ async deleteTask(sessionId, taskId) {
245
+ await this.updateSession(sessionId, (s) => {
246
+ const t = s.tasks.find((x) => x.id === taskId);
247
+ if (t && ACTIVE_STATUSES.includes(t.status)) {
248
+ throw new Error('This task is running. Stop the session first.');
249
+ }
250
+ s.tasks = s.tasks.filter((x) => x.id !== taskId);
251
+ });
252
+ }
253
+ /**
254
+ * Sends a finished task back to the queue, keeping what the last attempt did.
255
+ *
256
+ * The attempt is archived rather than overwritten: a task that failed is usually re-run
257
+ * because someone wants to compare, and losing the summary or the reason at the moment of
258
+ * the retry destroys exactly the thing that made the retry interesting. The run folder of
259
+ * the old attempt is untouched too, because the next attempt gets a folder of its own.
260
+ */
261
+ async rerunTask(sessionId, taskId, patch = {}) {
262
+ let result;
263
+ await this.updateSession(sessionId, (s) => {
264
+ const t = s.tasks.find((x) => x.id === taskId);
265
+ if (!t)
266
+ throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
267
+ if (t.status === 'queued')
268
+ throw new Error('This task is already queued.');
269
+ if (ACTIVE_STATUSES.includes(t.status))
270
+ throw new Error('This task is running. Stop the session first.');
271
+ t.attempts = [
272
+ ...(t.attempts ?? []),
273
+ {
274
+ runId: t.runId,
275
+ runGroup: t.runGroup,
276
+ status: t.status,
277
+ startedAt: t.startedAt,
278
+ finishedAt: t.finishedAt,
279
+ iterations: t.iterations,
280
+ summary: t.summary,
281
+ reason: t.reason,
282
+ deviations: t.deviations,
283
+ disputes: t.disputes,
284
+ // The text as it was when this attempt ran, before any edit below.
285
+ title: t.title,
286
+ prompt: t.prompt,
287
+ level2: t.level2,
288
+ checkResults: t.checkResults,
289
+ review: t.review,
290
+ vcs: t.vcs,
291
+ },
292
+ ];
293
+ // An edit is applied only after the old attempt has been put beyond its reach.
294
+ if (patch.title !== undefined)
295
+ t.title = patch.title.trim() || t.title;
296
+ if (patch.prompt !== undefined && patch.prompt.trim())
297
+ t.prompt = patch.prompt;
298
+ if (patch.level2 !== undefined)
299
+ t.level2 = patch.level2;
300
+ if (patch.vcsPlan !== undefined)
301
+ t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
302
+ if (patch.checks !== undefined)
303
+ t.checks = patch.checks.filter((c) => c.name.trim() !== '');
304
+ t.attempt = (t.attempt ?? 1) + 1;
305
+ t.status = 'queued';
306
+ t.iterations = 0;
307
+ // Cleared so the next run starts from nothing and gets its own run folder.
308
+ t.runId = undefined;
309
+ t.runGroup = undefined;
310
+ t.startedAt = undefined;
311
+ t.finishedAt = undefined;
312
+ t.summary = undefined;
313
+ t.reason = undefined;
314
+ t.finalReply = undefined;
315
+ t.firstMessage = undefined;
316
+ t.logFile = undefined;
317
+ // The results belong to the attempt that produced them, which is now on the record above.
318
+ t.checkResults = undefined;
319
+ t.review = undefined;
320
+ // Declared by the attempt above, and kept there. The checks reviews gave stay: they are
321
+ // the one thing a new attempt should inherit.
322
+ t.deviations = undefined;
323
+ t.disputes = undefined;
324
+ // The branch of the finished attempt stays in the repository and stays on the record
325
+ // above; the next attempt gets its own, cut from the same commit this one started at.
326
+ t.vcs = undefined;
327
+ result = t;
328
+ });
329
+ return result;
330
+ }
331
+ /**
332
+ * Closes tasks that the previous process left open, and is called once at startup.
333
+ *
334
+ * A task's status lives on disk; the approval it is waiting for lives in the memory of the
335
+ * process that asked for it. When that process ends — a restart, a Ctrl+C, a crash — the
336
+ * approval disappears and the task is left saying "waiting for approval" forever: nothing
337
+ * will ever answer it, it is not queued so no run will pick it up, and the session looks
338
+ * busy when it is not. Each one is marked aborted with the reason, which puts it back in
339
+ * reach: it can be deleted, and its prompt can be copied into a new task.
340
+ *
341
+ * Aborted, not re-queued. The task ran commands on this machine and we do not know how far
342
+ * it got, so re-running it silently could repeat whatever it already did. That is the
343
+ * operator's decision, not ours.
344
+ */
345
+ async recoverInterrupted() {
346
+ const recovered = [];
347
+ for (const session of await this.listSessions()) {
348
+ const stuck = session.tasks.filter((t) => ACTIVE_STATUSES.includes(t.status));
349
+ const busySession = session.status !== 'idle';
350
+ if (stuck.length === 0 && !busySession)
351
+ continue;
352
+ for (const t of stuck) {
353
+ t.status = 'aborted';
354
+ t.finishedAt = t.finishedAt ?? new Date().toISOString();
355
+ t.reason = 'The operator stopped while this task was in progress, so it never finished. Nothing was resumed.';
356
+ recovered.push({ sessionId: session.id, taskId: t.id, title: t.title });
357
+ }
358
+ session.status = 'idle';
359
+ await this.saveSession(session);
360
+ }
361
+ return recovered;
362
+ }
363
+ // --- helpers --------------------------------------------------------------------------
364
+ /**
365
+ * Reads one session file, filling in mirror fields added after it was written. Sessions are
366
+ * long-lived JSON on disk, so a new option must never come back as `undefined`.
367
+ */
368
+ async readSession(path) {
369
+ try {
370
+ const s = JSON.parse(await readFile(path, 'utf8'));
371
+ s.mirror = { ...DEFAULT_MIRROR, ...(s.mirror ?? {}) };
372
+ s.vcs = { ...DEFAULT_VCS, ...(s.vcs ?? {}) };
373
+ // Sessions written before the queue could be told what to do on a failure behave the
374
+ // way they always did: the chain stops.
375
+ s.onFailure ??= 'stop';
376
+ return s;
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /**
383
+ * Write through a temp file and a rename, and keep trying when Windows says no.
384
+ *
385
+ * The rename is what makes the write atomic: a crash halfway through leaves the temp file
386
+ * behind and the real one untouched. On Windows it is also the step that fails, because
387
+ * renaming onto a destination that any other process has open is refused outright — and this
388
+ * file is read constantly, by the register polling every few seconds, by the sessions list,
389
+ * and by whatever virus scanner has decided to look inside a JSON file that just changed.
390
+ *
391
+ * The failure is EPERM and it is transient: the handle closes microseconds later. Letting it
392
+ * through unretried meant a task dying mid-run with a message about a temp file, which is
393
+ * what happened — a scaffold that had finished three tasks was killed by a file lock on the
394
+ * fourth. So it is retried, briefly and with a growing pause, and only then given up on.
395
+ */
396
+ async atomicWrite(path, content) {
397
+ const tmp = `${path}.${process.pid}.tmp`;
398
+ await writeFile(tmp, content, 'utf8');
399
+ const backoffMs = [10, 25, 60, 120, 250, 500];
400
+ for (let attempt = 0;; attempt += 1) {
401
+ try {
402
+ await rename(tmp, path);
403
+ return;
404
+ }
405
+ catch (e) {
406
+ const code = e.code ?? '';
407
+ const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
408
+ if (!transient || attempt >= backoffMs.length) {
409
+ // The temp file is no use to anyone now, and leaving one per failed write behind
410
+ // would slowly fill the folder with them.
411
+ await rm(tmp, { force: true }).catch(() => undefined);
412
+ throw new Error(`Could not save ${path}: ${code || e.message}. ` +
413
+ 'Something else is holding the file open — an editor, a sync client or a virus scanner.');
414
+ }
415
+ await new Promise((r) => setTimeout(r, backoffMs[attempt]));
416
+ }
417
+ }
418
+ }
419
+ }
420
+ /**
421
+ * A session id as it may appear in a file name, or an error.
422
+ *
423
+ * Ids, and preset names (see `safePresetName`), arrive from URL parameters. Found on 2026-09-27: `DELETE /sessions/:id` and `DELETE
424
+ * /presets/:name` joined them straight into a path, and Express decodes `%5C` and `%2F`, so
425
+ * `..\..\<anything>` reached a `.json` or `.md` file anywhere the process could write. The ids this
426
+ * store makes are letters, digits, `-`, `_` and `.` (not leading); anything else is not one of them.
427
+ */
428
+ export function safeName(value) {
429
+ if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,199}$/.test(value) || value.includes('..')) {
430
+ throw new Error(`Not a valid name: ${JSON.stringify(value).slice(0, 80)}`);
431
+ }
432
+ return value;
433
+ }
434
+ /** A preset name as `savePreset` allows it — letters in any script, digits, space, `.`, `_`, `-` — never a path. */
435
+ export function safePresetName(value) {
436
+ if (!/^[\p{L}\p{N}_ -][\p{L}\p{N}._ -]{0,199}$/u.test(value) || value.includes('..')) {
437
+ throw new Error(`Not a valid preset name: ${JSON.stringify(value).slice(0, 80)}`);
438
+ }
439
+ return value;
440
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The story of one task attempt: what was asked, what was said, what ran, how it ended —
3
+ * assembled from the run folder for a person to read while it happens and afterwards.
4
+ *
5
+ * The run folder already has every piece: the task log holds what the runner sent (the
6
+ * opening message, every results file, the review briefs and findings); `replies/` holds
7
+ * what the chat answered, in order; `steps/` holds every command with its output and exit
8
+ * code; `review/N/` holds the same for each review round. This puts them in the order they
9
+ * happened, because a folder listing is not a story and the task log alone has no output
10
+ * and no replies.
11
+ *
12
+ * Nothing here is inferred. An entry is a file, or a section of the task log, or a field of
13
+ * the task record; the reader decides what it means.
14
+ */
15
+ import { readdir, readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ const MAX_TEXT = 60_000;
18
+ function clip(text) {
19
+ return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (${text.length - MAX_TEXT} more characters in the run folder)` : text;
20
+ }
21
+ /** The task log's sections: a name between two rules, then the text until the next rule. */
22
+ function sectionsOf(log) {
23
+ const rule = /^=+\s*$/;
24
+ const lines = log.split('\n');
25
+ const out = [];
26
+ let i = 0;
27
+ while (i < lines.length) {
28
+ if (rule.test(lines[i]) && i + 2 < lines.length && rule.test(lines[i + 2])) {
29
+ const name = lines[i + 1].trim();
30
+ i += 3;
31
+ const body = [];
32
+ while (i < lines.length && !(rule.test(lines[i]) && i + 2 < lines.length && rule.test(lines[i + 2]))) {
33
+ body.push(lines[i]);
34
+ i += 1;
35
+ }
36
+ out.push({ name, text: body.join('\n').trim() });
37
+ }
38
+ else {
39
+ i += 1;
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /** A step log: two header lines, the output, one footer line. */
45
+ function parseStepLog(text) {
46
+ const lines = text.split('\n');
47
+ const command = lines[1]?.replace(/^# /, '') ?? '';
48
+ const footer = lines.map((l) => l.match(/^# outcome=(\S+) exit=(-?\d+) durationMs=(\d+)/)).filter(Boolean).pop();
49
+ const body = lines.slice(2).filter((l) => !/^# outcome=/.test(l)).join('\n').trim();
50
+ return {
51
+ command,
52
+ output: body,
53
+ outcome: footer?.[1],
54
+ exitCode: footer ? Number(footer[2]) : undefined,
55
+ durationMs: footer ? Number(footer[3]) : undefined,
56
+ };
57
+ }
58
+ async function stepsIn(dir) {
59
+ const byIteration = new Map();
60
+ for (const name of (await readdir(dir).catch(() => [])).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))) {
61
+ const m = name.match(/^(\d+)-(\d+)\.log$/);
62
+ if (!m)
63
+ continue;
64
+ const iteration = Number(m[1]);
65
+ const parsed = parseStepLog(await readFile(join(dir, name), 'utf8').catch(() => ''));
66
+ const entry = {
67
+ kind: 'step',
68
+ iteration,
69
+ id: Number(m[2]),
70
+ command: parsed.command,
71
+ output: clip(parsed.output),
72
+ outcome: parsed.outcome,
73
+ exitCode: parsed.exitCode,
74
+ durationMs: parsed.durationMs,
75
+ failed: parsed.outcome !== undefined && (parsed.outcome !== 'completed' || parsed.exitCode !== 0),
76
+ };
77
+ byIteration.set(iteration, [...(byIteration.get(iteration) ?? []), entry]);
78
+ }
79
+ return byIteration;
80
+ }
81
+ function parseReply(text) {
82
+ const m = text.match(/```json\s*([\s\S]*?)```/) ?? [null, text];
83
+ try {
84
+ const j = JSON.parse((m[1] ?? '').trim());
85
+ return { status: typeof j.status === 'string' ? j.status : undefined, notes: typeof j.notes === 'string' ? j.notes : undefined };
86
+ }
87
+ catch {
88
+ return {};
89
+ }
90
+ }
91
+ /** One review round's folder as a story of its own: brief, replies, steps, reports, findings. */
92
+ async function reviewRound(dir, round) {
93
+ const entries = [];
94
+ const read = async (name) => clip(await readFile(join(dir, name), 'utf8').catch(() => ''));
95
+ const names = (await readdir(dir).catch(() => [])).sort();
96
+ if (names.includes('00-brief.md'))
97
+ entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md') });
98
+ const steps = await stepsIn(join(dir, 'steps'));
99
+ const replies = names.filter((n) => /^\d+-review\.md$/.test(n)).sort();
100
+ for (const [i, name] of replies.entries()) {
101
+ const text = await read(name);
102
+ const parsed = parseReply(text);
103
+ entries.push({ kind: 'reply', iteration: i + 1, label: `review ${round}, reply ${i + 1}`, text, ...parsed });
104
+ for (const step of steps.get(i + 1) ?? [])
105
+ entries.push(step);
106
+ if (names.includes(`iteration-${i + 1}.txt`))
107
+ entries.push({ kind: 'sent', iteration: i + 1, label: `results ${i + 1}`, text: await read(`iteration-${i + 1}.txt`) });
108
+ }
109
+ if (names.includes('findings-sent.md'))
110
+ entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md') });
111
+ return { kind: 'review', round, entries };
112
+ }
113
+ export async function buildStory(runsDir, runId, task, live) {
114
+ const dir = join(runsDir, runId);
115
+ const log = await readFile(join(dir, 'task-log.txt'), 'utf8').catch(() => '');
116
+ const sections = sectionsOf(log);
117
+ const entries = [];
118
+ const opening = sections.find((s) => s.name === 'OPENING MESSAGE');
119
+ if (opening)
120
+ entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text) });
121
+ const steps = await stepsIn(join(dir, 'steps'));
122
+ const reports = new Map();
123
+ for (const s of sections) {
124
+ const m = s.name.match(/^ITERATION (\d+)$/);
125
+ if (m)
126
+ reports.set(Number(m[1]), s.text);
127
+ }
128
+ const reviewsSent = new Map();
129
+ for (const s of sections) {
130
+ const m = s.name.match(/^REVIEW (\d+) /);
131
+ if (m)
132
+ reviewsSent.set(Number(m[1]), [...(reviewsSent.get(Number(m[1])) ?? []), s]);
133
+ }
134
+ /*
135
+ * The replies are the spine: they are numbered in the order they arrived. What ran and what
136
+ * was sent back sit between them — the steps a reply proposed, then the results file that
137
+ * answered them, then the next reply. A reply to a review's findings is preceded by that
138
+ * review round in full.
139
+ */
140
+ const replyNames = (await readdir(join(dir, 'replies')).catch(() => [])).filter((n) => n.endsWith('.md')).sort();
141
+ const reviewDirs = (await readdir(join(dir, 'review')).catch(() => [])).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b);
142
+ const placedReviews = new Set();
143
+ let lastIteration = 0;
144
+ for (const name of replyNames) {
145
+ const text = clip(await readFile(join(dir, 'replies', name), 'utf8').catch(() => ''));
146
+ const parsed = parseReply(text);
147
+ const label = name.replace(/^\d+-/, '').replace(/\.md$/, '');
148
+ const it = label.match(/iteration-(\d+)/);
149
+ const rv = label.match(/review-(\d+)/);
150
+ if (it) {
151
+ const k = Number(it[1]);
152
+ for (const step of steps.get(k) ?? [])
153
+ entries.push(step);
154
+ if (reports.has(k))
155
+ entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
156
+ lastIteration = k;
157
+ }
158
+ else if (rv) {
159
+ const round = Number(rv[1]);
160
+ // The steps the last reply proposed ran before the review judged the work.
161
+ for (const step of steps.get(lastIteration + 1) ?? [])
162
+ entries.push(step);
163
+ if (reports.has(lastIteration + 1))
164
+ entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)) });
165
+ lastIteration += reports.has(lastIteration + 1) ? 1 : 0;
166
+ if (!placedReviews.has(round)) {
167
+ entries.push(await reviewRound(join(dir, 'review', String(round)), round));
168
+ placedReviews.add(round);
169
+ }
170
+ }
171
+ entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed });
172
+ }
173
+ // Steps and results after the last reply (a task that ended without another reply), and
174
+ // review rounds nobody replied to yet (one still going, or the last one that passed).
175
+ for (const [k, list] of [...steps.entries()].sort((a, b) => a[0] - b[0])) {
176
+ if (k > lastIteration) {
177
+ for (const step of list)
178
+ entries.push(step);
179
+ if (reports.has(k))
180
+ entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
181
+ }
182
+ }
183
+ for (const round of reviewDirs) {
184
+ if (!placedReviews.has(round))
185
+ entries.push(await reviewRound(join(dir, 'review', String(round)), round));
186
+ }
187
+ const ended = task.status !== 'queued' && task.status !== 'running' && task.status !== 'waiting-approval';
188
+ return {
189
+ runId,
190
+ title: task.title,
191
+ status: task.status,
192
+ prompt: task.prompt,
193
+ level2: task.level2,
194
+ entries,
195
+ close: ended ? { status: task.status, summary: task.summary, reason: task.reason, checks: task.checkResults } : undefined,
196
+ live,
197
+ };
198
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Whether a message the runner sent is the one the chat now shows as its newest.
3
+ *
4
+ * The second of two acceptance signals. The first is the conversation's size as the app
5
+ * reports it; this one is what a person would check — the newest user bubble says what was
6
+ * just typed — and it is what keeps a retry from sending a message that already landed. A
7
+ * message is judged to have landed when the newest user text changed since before the send
8
+ * and now begins with what was sent, compared on collapsed whitespace and the first stretch
9
+ * only, because the page renders attachments and links inside the same bubble.
10
+ */
11
+ const HEAD_CHARS = 120;
12
+ function collapse(text) {
13
+ return text.replace(/\s+/g, ' ').trim();
14
+ }
15
+ /** The part of a sent message that the newest user bubble is expected to begin with. */
16
+ export function sentTextHead(text) {
17
+ return collapse(text).slice(0, HEAD_CHARS);
18
+ }
19
+ /**
20
+ * True when the newest user message is the one that was sent.
21
+ *
22
+ * `shownBefore` is what the newest user message said before sending; requiring it to have
23
+ * changed is what stops an earlier message that happens to start the same way — two review
24
+ * rounds' findings messages do — from counting as this one.
25
+ */
26
+ export function landed(sentText, shownNow, shownBefore) {
27
+ const head = sentTextHead(sentText);
28
+ if (head.length === 0)
29
+ return false;
30
+ const now = collapse(shownNow);
31
+ if (now.length === 0 || now === collapse(shownBefore))
32
+ return false;
33
+ return now.startsWith(head);
34
+ }