copilot-operator 0.1.42 → 0.1.43

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 (59) hide show
  1. package/dist/src/api/operator.controller.js +131 -0
  2. package/dist/src/api/operator.service.js +335 -3
  3. package/dist/src/api/server.js +16 -0
  4. package/dist/src/cli.js +12 -0
  5. package/dist/src/config/schema.js +10 -0
  6. package/dist/src/orchestrator/taskRunner.js +109 -38
  7. package/dist/src/plan/brief.js +57 -20
  8. package/dist/src/plan/changes.js +217 -0
  9. package/dist/src/plan/schema.js +1 -1
  10. package/dist/src/plan/systemGuide.js +597 -12
  11. package/dist/src/session/compose.js +15 -1
  12. package/dist/src/session/model.js +1 -1
  13. package/dist/src/session/store.js +60 -0
  14. package/dist/src/vcs/taskVcs.js +110 -1
  15. package/dist/web/404.html +2 -2
  16. package/dist/web/_next/static/WmzBz4QN36pcz4PL_cfEW/_buildManifest.js +1 -0
  17. package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
  18. package/dist/web/_next/static/chunks/549-2415dd26f4e53d07.js +1 -0
  19. package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
  20. package/dist/web/_next/static/chunks/867-aea30f560bc18eaf.js +1 -0
  21. package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
  22. package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
  23. package/dist/web/_next/static/chunks/app/history/{page-47973539ceff81d6.js → page-0bb537407fed5944.js} +1 -1
  24. package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
  25. package/dist/web/_next/static/chunks/app/layout-dc602b727480a653.js +1 -0
  26. package/dist/web/_next/static/chunks/app/page-d73a8ff300862cd8.js +1 -0
  27. package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
  28. package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
  29. package/dist/web/_next/static/css/{0e55721108c300f2.css → 72c85e4322c21223.css} +1 -1
  30. package/dist/web/appearance.html +2 -2
  31. package/dist/web/appearance.txt +25 -28
  32. package/dist/web/defaults.html +2 -2
  33. package/dist/web/defaults.txt +25 -28
  34. package/dist/web/history.html +2 -2
  35. package/dist/web/history.txt +25 -28
  36. package/dist/web/import.html +2 -2
  37. package/dist/web/import.txt +25 -28
  38. package/dist/web/index.html +2 -2
  39. package/dist/web/index.txt +25 -28
  40. package/dist/web/level1.html +2 -2
  41. package/dist/web/level1.txt +25 -28
  42. package/dist/web/presets.html +3 -3
  43. package/dist/web/presets.txt +25 -28
  44. package/dist/web/sessions/view.html +2 -2
  45. package/dist/web/sessions/view.txt +25 -28
  46. package/dist/web/sessions.html +16 -0
  47. package/dist/web/sessions.txt +26 -0
  48. package/dist/web/system.html +2 -2
  49. package/dist/web/system.txt +25 -28
  50. package/package.json +4 -2
  51. package/dist/web/_next/static/chunks/204-db82ca47ff211e21.js +0 -1
  52. package/dist/web/_next/static/chunks/499-39b2c00bda3656f0.js +0 -1
  53. package/dist/web/_next/static/chunks/549-348b0a1404407e5a.js +0 -1
  54. package/dist/web/_next/static/chunks/867-7d86a692260006f1.js +0 -1
  55. package/dist/web/_next/static/chunks/app/defaults/page-a25c40436fc312db.js +0 -1
  56. package/dist/web/_next/static/chunks/app/layout-6ee612d25e78b246.js +0 -1
  57. package/dist/web/_next/static/chunks/app/sessions/view/page-872d8b9fe8eedbbf.js +0 -1
  58. package/dist/web/_next/static/lDmqS7pWU_B_Ac5idPvdS/_buildManifest.js +0 -1
  59. /package/dist/web/_next/static/{lDmqS7pWU_B_Ac5idPvdS → WmzBz4QN36pcz4PL_cfEW}/_ssgManifest.js +0 -0
@@ -188,6 +188,16 @@ let OperatorController = class OperatorController {
188
188
  * A body with `title`, `prompt` or `level2` edits the task on the way: the attempt that
189
189
  * already ran keeps the text it ran with, and the next one uses the new text.
190
190
  */
191
+ /** A note to the chat about this task: sent with its next message, at once while it runs. */
192
+ addTaskNote(id, taskId, body = {}) {
193
+ if (typeof body?.text !== 'string' || !body.text.trim())
194
+ throw new BadRequestException('text is required');
195
+ return this.ops.addTaskNote(id, taskId, body.text).catch(fail);
196
+ }
197
+ /** The operator accepts a task as done that did not end so. */
198
+ acceptTask(id, taskId, body = {}) {
199
+ return this.ops.acceptTask(id, taskId, typeof body?.note === 'string' ? body.note : undefined).catch(fail);
200
+ }
191
201
  /** A task that stopped at the runner's limit carries on in its own conversation and branch. */
192
202
  continueTask(id, taskId, body = {}) {
193
203
  // The operator's new prompt, when one is given: see `TaskContinuation.instruction`.
@@ -216,6 +226,19 @@ let OperatorController = class OperatorController {
216
226
  restore(id, taskId) {
217
227
  return this.ops.restore(id, taskId).catch(fail);
218
228
  }
229
+ /** What undoing this task would do, both ways: only this task, or back to before it. */
230
+ undoPreview(id, taskId) {
231
+ return this.ops.undoPreview(id, taskId).catch(fail);
232
+ }
233
+ /** Does it: `this` takes the task's work out (queued again); `back` goes back to before it (it and the later ones queued). */
234
+ async undoTask(id, taskId, body) {
235
+ if (body?.how !== 'this' && body?.how !== 'back')
236
+ throw new BadRequestException('how must be "this" or "back"');
237
+ const r = await this.ops.undoTask(id, taskId, body.how).catch(fail);
238
+ if (!r.ok)
239
+ throw new BadRequestException(r.problem);
240
+ return r;
241
+ }
219
242
  /** What starting the run again from this task would re-queue, and do to the code. */
220
243
  restartPlan(id, taskId) {
221
244
  return this.ops.restartPlan(id, taskId).catch(fail);
@@ -501,6 +524,33 @@ let OperatorController = class OperatorController {
501
524
  resetContext(kind, lang) {
502
525
  return this.ops.resetContext(asContextKind(kind), lang ?? 'en');
503
526
  }
527
+ /** One folder of the session's repository at its branch (or the task's commit): read-only, for the workbench. */
528
+ repoTree(id, path, task) {
529
+ return this.ops.repoTree(id, path ?? '', task?.trim() || undefined).catch(fail);
530
+ }
531
+ /** One file of it, as text. */
532
+ repoFile(id, path, task) {
533
+ if (!path)
534
+ throw new BadRequestException('path is required');
535
+ return this.ops.repoFile(id, path, task?.trim() || undefined).catch(fail);
536
+ }
537
+ /** The commits on the session's line, each with the task that made it. */
538
+ repoLog(id) {
539
+ return this.ops.repoLog(id).catch(fail);
540
+ }
541
+ /** What happened in these sessions, for Kerrigan, with the operator's note and the form of her answer. */
542
+ kerriganReport(body = {}) {
543
+ const ids = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
544
+ return this.ops.kerriganReport({ sessionIds: ids(body?.sessionIds), taskIds: ids(body?.taskIds), note: typeof body?.note === 'string' ? body.note : undefined }).catch(fail);
545
+ }
546
+ /** Kerrigan's changes to existing work, read against it as it is now; nothing is changed. */
547
+ checkChanges(body = {}) {
548
+ return this.ops.checkChanges(typeof body?.text === 'string' ? body.text : '').catch(fail);
549
+ }
550
+ /** Applies them one by one, stopping at the first refused; nothing is started. */
551
+ applyChanges(body = {}) {
552
+ return this.ops.applyChanges(typeof body?.text === 'string' ? body.text : '').catch(fail);
553
+ }
504
554
  /** Checks a pasted plan without importing anything, and says what it would duplicate. */
505
555
  checkPlan(body) {
506
556
  return this.ops.checkPlan(body?.text ?? '').catch(fail);
@@ -807,6 +857,24 @@ __decorate([
807
857
  __metadata("design:paramtypes", [String, String]),
808
858
  __metadata("design:returntype", Promise)
809
859
  ], OperatorController.prototype, "deleteTask", null);
860
+ __decorate([
861
+ Post('sessions/:id/tasks/:taskId/notes'),
862
+ __param(0, Param('id')),
863
+ __param(1, Param('taskId')),
864
+ __param(2, Body()),
865
+ __metadata("design:type", Function),
866
+ __metadata("design:paramtypes", [String, String, Object]),
867
+ __metadata("design:returntype", Promise)
868
+ ], OperatorController.prototype, "addTaskNote", null);
869
+ __decorate([
870
+ Post('sessions/:id/tasks/:taskId/accept'),
871
+ __param(0, Param('id')),
872
+ __param(1, Param('taskId')),
873
+ __param(2, Body()),
874
+ __metadata("design:type", Function),
875
+ __metadata("design:paramtypes", [String, String, Object]),
876
+ __metadata("design:returntype", Promise)
877
+ ], OperatorController.prototype, "acceptTask", null);
810
878
  __decorate([
811
879
  Post('sessions/:id/tasks/:taskId/continue'),
812
880
  __param(0, Param('id')),
@@ -858,6 +926,23 @@ __decorate([
858
926
  __metadata("design:paramtypes", [String, String]),
859
927
  __metadata("design:returntype", Promise)
860
928
  ], OperatorController.prototype, "restore", null);
929
+ __decorate([
930
+ Get('sessions/:id/tasks/:taskId/undo'),
931
+ __param(0, Param('id')),
932
+ __param(1, Param('taskId')),
933
+ __metadata("design:type", Function),
934
+ __metadata("design:paramtypes", [String, String]),
935
+ __metadata("design:returntype", Promise)
936
+ ], OperatorController.prototype, "undoPreview", null);
937
+ __decorate([
938
+ Post('sessions/:id/tasks/:taskId/undo'),
939
+ __param(0, Param('id')),
940
+ __param(1, Param('taskId')),
941
+ __param(2, Body()),
942
+ __metadata("design:type", Function),
943
+ __metadata("design:paramtypes", [String, String, Object]),
944
+ __metadata("design:returntype", Promise)
945
+ ], OperatorController.prototype, "undoTask", null);
861
946
  __decorate([
862
947
  Get('sessions/:id/tasks/:taskId/restart'),
863
948
  __param(0, Param('id')),
@@ -1111,6 +1196,52 @@ __decorate([
1111
1196
  __metadata("design:paramtypes", [String, String]),
1112
1197
  __metadata("design:returntype", Promise)
1113
1198
  ], OperatorController.prototype, "resetContext", null);
1199
+ __decorate([
1200
+ Get('sessions/:id/repo/tree'),
1201
+ __param(0, Param('id')),
1202
+ __param(1, Query('path')),
1203
+ __param(2, Query('task')),
1204
+ __metadata("design:type", Function),
1205
+ __metadata("design:paramtypes", [String, String, String]),
1206
+ __metadata("design:returntype", Promise)
1207
+ ], OperatorController.prototype, "repoTree", null);
1208
+ __decorate([
1209
+ Get('sessions/:id/repo/file'),
1210
+ __param(0, Param('id')),
1211
+ __param(1, Query('path')),
1212
+ __param(2, Query('task')),
1213
+ __metadata("design:type", Function),
1214
+ __metadata("design:paramtypes", [String, String, String]),
1215
+ __metadata("design:returntype", Promise)
1216
+ ], OperatorController.prototype, "repoFile", null);
1217
+ __decorate([
1218
+ Get('sessions/:id/repo/log'),
1219
+ __param(0, Param('id')),
1220
+ __metadata("design:type", Function),
1221
+ __metadata("design:paramtypes", [String]),
1222
+ __metadata("design:returntype", Promise)
1223
+ ], OperatorController.prototype, "repoLog", null);
1224
+ __decorate([
1225
+ Post('kerrigan/report'),
1226
+ __param(0, Body()),
1227
+ __metadata("design:type", Function),
1228
+ __metadata("design:paramtypes", [Object]),
1229
+ __metadata("design:returntype", Promise)
1230
+ ], OperatorController.prototype, "kerriganReport", null);
1231
+ __decorate([
1232
+ Post('changes/check'),
1233
+ __param(0, Body()),
1234
+ __metadata("design:type", Function),
1235
+ __metadata("design:paramtypes", [Object]),
1236
+ __metadata("design:returntype", Promise)
1237
+ ], OperatorController.prototype, "checkChanges", null);
1238
+ __decorate([
1239
+ Post('changes/apply'),
1240
+ __param(0, Body()),
1241
+ __metadata("design:type", Function),
1242
+ __metadata("design:paramtypes", [Object]),
1243
+ __metadata("design:returntype", Promise)
1244
+ ], OperatorController.prototype, "applyChanges", null);
1114
1245
  __decorate([
1115
1246
  Post('plan/check'),
1116
1247
  __param(0, Body()),
@@ -45,10 +45,11 @@ function sameFolder(a, b) {
45
45
  const norm = (p) => p.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
46
46
  return a.trim() !== '' && norm(a) === norm(b);
47
47
  }
48
- import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
48
+ import { composeLevel2, composePrompt, importPlan, personaBlock, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
49
+ import { checkChanges, kerriganReport } from '../plan/changes.js';
49
50
  import { readInterruption } from '../session/interruption.js';
50
- import { baseRefOf, branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
51
- import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
51
+ import { baseRefOf, branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, revertTaskWork, undoThisPreview, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
52
+ import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, looksLikeRepo, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
52
53
  import { makeAuthorizer } from '../exec/authorizer.js';
53
54
  import { pickFolder } from './folderPicker.js';
54
55
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
@@ -80,6 +81,22 @@ import { saveAndReveal } from './saveToDesktop.js';
80
81
  import { settingsOf, SettingsUnusableError } from './settings.js';
81
82
  import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
82
83
  /** Why a session set aside on the Sessions page does not run: one sentence for every start that refuses it. */
84
+ /** The id of the task a change names by id, number or title; see `plan/changes.ts`. */
85
+ function checkTaskRef(session, ref) {
86
+ if (typeof ref === 'number' || /^\d+$/.test(String(ref)))
87
+ return session.tasks[Number(ref) - 1]?.id;
88
+ return (session.tasks.find((t) => t.id === ref) ?? session.tasks.find((t) => t.title === ref) ?? session.tasks.find((t) => t.title.toLowerCase() === String(ref).toLowerCase()))?.id;
89
+ }
90
+ /** Why this task cannot be undone now, or null: only a task that ran and is not running or queued. */
91
+ function undoRefusalOf(task) {
92
+ if (task.status === 'queued')
93
+ return 'This task is queued and has not run since; there is nothing of it to undo.';
94
+ if (task.status === 'running' || task.status === 'waiting-approval')
95
+ return 'This task is running. Stop it first.';
96
+ if (!task.startedAt)
97
+ return 'This task has not run; there is nothing of it to undo.';
98
+ return null;
99
+ }
83
100
  const INACTIVE_REASON = 'the session is inactive; make it active on the Sessions page to run it';
84
101
  /**
85
102
  * Why the browser cannot be had now, said in terms of what was pressed. The sentences a start, a
@@ -572,6 +589,34 @@ let OperatorService = class OperatorService {
572
589
  await this.init();
573
590
  await this.store.deleteTask(sessionId, taskId);
574
591
  }
592
+ /**
593
+ * A note to the chat about a task (2026-10-14): queued, running or waiting, it goes out with the task's
594
+ * next message — at once, in effect, for a task that is running. See `Task.notes`.
595
+ */
596
+ async addTaskNote(sessionId, taskId, text) {
597
+ await this.init();
598
+ const task = await this.store.addTaskNote(sessionId, taskId, text);
599
+ const running = this.running.has(sessionId) && ['running', 'waiting-approval'].includes(task.status);
600
+ this.bus.publish({ sessionId, taskId, type: 'task-note-added', level: 'info',
601
+ message: running ? 'a note from the operator; it goes to the chat with the next report' : 'a note from the operator; it goes to the chat when the task runs next',
602
+ data: { length: text.trim().length, running } });
603
+ return task;
604
+ }
605
+ /** The operator accepts a task as done that did not end so (2026-10-14). */
606
+ async acceptTask(sessionId, taskId, note) {
607
+ await this.init();
608
+ if (this.running.has(sessionId)) {
609
+ const now = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
610
+ if (now && ['running', 'waiting-approval'].includes(now.status))
611
+ throw new Error('This task is running. Stop it first.');
612
+ }
613
+ const task = await this.store.acceptTask(sessionId, taskId, note);
614
+ const message = `"${task.title}" accepted as done by the operator (it had ended ${task.accepted?.was})${task.accepted?.note ? `: ${task.accepted.note}` : ''}`;
615
+ this.bus.publish({ sessionId, taskId, type: 'task-accepted', level: 'info', message, data: { was: task.accepted?.was } });
616
+ const cfg = await this.settings.load();
617
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'accept', message, data: { taskId, was: task.accepted?.was } }).catch(() => undefined);
618
+ return task;
619
+ }
575
620
  /**
576
621
  * Puts a finished task back in the queue, keeping the record of what it already did.
577
622
  *
@@ -2194,6 +2239,194 @@ let OperatorService = class OperatorService {
2194
2239
  duplicates: check.duplicates,
2195
2240
  };
2196
2241
  }
2242
+ // --- the repository, read-only, for the workbench (2026-10-14) ------------------------------
2243
+ /**
2244
+ * Where a session's work is in its repository: the session's branch in per-session mode, the task's own
2245
+ * branch when one is named, else what is checked out. Read-only; nothing here moves anything.
2246
+ */
2247
+ async repoRefOf(sessionId, taskId) {
2248
+ await this.init();
2249
+ const session = await this.store.getSession(sessionId);
2250
+ if (!session)
2251
+ throw new Error('No such session.');
2252
+ const dir = repoDirOf(session);
2253
+ if (!dir || !looksLikeRepo(dir))
2254
+ throw new Error('This session has no repository to show.');
2255
+ const task = taskId ? session.tasks.find((t) => t.id === taskId) : undefined;
2256
+ const candidates = [
2257
+ ...(task?.vcs?.commit ? [task.vcs.commit] : []),
2258
+ ...(session.vcs?.enabled && session.vcs.branchMode === 'per-session' ? [sessionBranchName(session)] : []),
2259
+ 'HEAD',
2260
+ ];
2261
+ for (const ref of candidates) {
2262
+ const r = await git(dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
2263
+ if (r.ok && r.stdout)
2264
+ return { dir, ref: r.stdout, label: ref === 'HEAD' ? 'HEAD' : ref.length >= 40 ? ref.slice(0, 8) : ref };
2265
+ }
2266
+ throw new Error('The repository has no commit to show yet.');
2267
+ }
2268
+ /** One folder of the repository at that point: its folders and files, folders first. */
2269
+ async repoTree(sessionId, path = '', taskId) {
2270
+ const clean = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
2271
+ if (clean.split('/').some((p) => p === '..'))
2272
+ throw new Error('That path is not inside the repository.');
2273
+ const { dir, ref, label } = await this.repoRefOf(sessionId, taskId);
2274
+ const r = await git(dir, ['ls-tree', '-l', clean ? `${ref}:${clean}` : ref]);
2275
+ if (!r.ok)
2276
+ throw new Error(clean ? `There is no folder ${clean} there.` : 'The repository could not be read.');
2277
+ const entries = r.stdout
2278
+ .split('\n')
2279
+ .filter(Boolean)
2280
+ .map((line) => {
2281
+ const [meta, name] = line.split('\t');
2282
+ const [, type, , size] = (meta ?? '').split(/\s+/);
2283
+ return { name: name ?? '', path: clean ? `${clean}/${name}` : (name ?? ''), type: type === 'tree' ? 'dir' : 'file', ...(type === 'blob' && size && size !== '-' ? { size: Number(size) } : {}) };
2284
+ })
2285
+ .filter((e) => e.name)
2286
+ .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
2287
+ return { ref, label, path: clean, entries };
2288
+ }
2289
+ /** One file of the repository at that point, as text; a binary or very large file is said to be one. */
2290
+ async repoFile(sessionId, path, taskId) {
2291
+ const clean = path.replace(/\\/g, '/').replace(/^\/+/, '');
2292
+ if (!clean || clean.split('/').some((p) => p === '..'))
2293
+ throw new Error('That path is not inside the repository.');
2294
+ const { dir, ref, label } = await this.repoRefOf(sessionId, taskId);
2295
+ const got = await fileAt(dir, ref, clean, 2 * 1024 * 1024);
2296
+ if (!got.bytes && !got.tooLarge)
2297
+ throw new Error(`There is no file ${clean} there.`);
2298
+ const binary = !!got.bytes && got.bytes.subarray(0, 8000).includes(0);
2299
+ return { ref, label, path: clean, text: got.bytes && !binary ? got.bytes.toString('utf8').replace(/^/, '') : null, binary, tooLarge: got.tooLarge };
2300
+ }
2301
+ /** The commits on the session's line, newest first, each with the task that made it when one did. */
2302
+ async repoLog(sessionId, limit = 60) {
2303
+ const { dir, ref, label } = await this.repoRefOf(sessionId);
2304
+ const session = (await this.store.getSession(sessionId));
2305
+ const r = await git(dir, ['log', `-${Math.max(1, Math.min(limit, 300))}`, '--format=%H%x09%an%x09%aI%x09%s', ref]);
2306
+ const byCommit = new Map();
2307
+ for (const t of session.tasks) {
2308
+ for (const v of [t.vcs, ...(t.attempts ?? []).map((a) => a.vcs)])
2309
+ if (v?.commit)
2310
+ byCommit.set(v.commit, t);
2311
+ }
2312
+ const commits = (r.ok ? r.stdout.split('\n').filter(Boolean) : []).map((line) => {
2313
+ const [sha = '', author = '', at = '', subject = ''] = line.split('\t');
2314
+ const task = byCommit.get(sha);
2315
+ return { sha, subject, author, at, ...(task ? { taskId: task.id, taskTitle: task.title } : {}) };
2316
+ });
2317
+ return { ref, label, commits };
2318
+ }
2319
+ // --- Kerrigan: what happened, and her changes to existing work (2026-10-14) ---------------------
2320
+ /** What happened in these sessions (all, when none are named), for Kerrigan, with the operator's note. */
2321
+ async kerriganReport(opts = {}) {
2322
+ await this.init();
2323
+ const all = await this.store.listSessions();
2324
+ const wanted = (opts.sessionIds ?? []).filter(Boolean);
2325
+ const taskIds = (opts.taskIds ?? []).filter(Boolean);
2326
+ const sessions = all.filter((s) => (wanted.length > 0 ? wanted.includes(s.id) : taskIds.length > 0 ? s.tasks.some((t) => taskIds.includes(t.id)) : true));
2327
+ if (sessions.length === 0)
2328
+ throw new Error('None of these sessions exists.');
2329
+ if ((opts.note ?? '').length > MAX_INSTRUCTION_CHARS)
2330
+ throw new Error(`The note is ${(opts.note ?? '').length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.`);
2331
+ return { text: kerriganReport(sessions, { note: opts.note, taskIds }), sessions: sessions.length };
2332
+ }
2333
+ /** Kerrigan's answer read against the sessions as they are now; nothing is changed. */
2334
+ async checkChanges(text) {
2335
+ await this.init();
2336
+ return checkChanges(text ?? '', await this.store.listSessions());
2337
+ }
2338
+ /**
2339
+ * Applies Kerrigan's changes one by one, through the paths the buttons use, and stops at the first that is
2340
+ * refused, saying which were done. Nothing is started.
2341
+ */
2342
+ async applyChanges(text) {
2343
+ const check = await this.checkChanges(text);
2344
+ if (!check.ok)
2345
+ return { ok: false, check, applied: [] };
2346
+ const cfg = await this.settings.load();
2347
+ const persona = (await this.getContext('persona', 'en')).content;
2348
+ const applied = [];
2349
+ for (const r of check.changes) {
2350
+ const c = r.change;
2351
+ try {
2352
+ switch (c.op) {
2353
+ case 'addTask': {
2354
+ const session = await this.store.getSession(r.sessionId);
2355
+ if (!session)
2356
+ throw new Error('The session is gone.');
2357
+ const afterId = c.after !== undefined ? checkTaskRef(session, c.after) : undefined;
2358
+ const reference = afterId ? session.tasks.find((t) => t.id === afterId) : session.tasks.at(-1);
2359
+ const level2 = c.task.level2.trim()
2360
+ ? composeLevel2({ level2: '', goal: '' }, c.task, persona)
2361
+ : (reference?.level2 ?? personaBlock(persona));
2362
+ const added = await this.store.addTask(r.sessionId, {
2363
+ title: c.task.title,
2364
+ level2,
2365
+ prompt: composePrompt(c.task),
2366
+ vcsPlan: c.task.vcs,
2367
+ checks: c.task.checks,
2368
+ reviewEnabled: c.task.review,
2369
+ readOnly: c.task.readOnly || undefined,
2370
+ scope: c.task.scope.length > 0 ? c.task.scope : undefined,
2371
+ outputs: c.task.outputs.length > 0 ? c.task.outputs : undefined,
2372
+ });
2373
+ if (afterId) {
2374
+ await this.store.updateSession(r.sessionId, (s) => {
2375
+ const from = s.tasks.findIndex((t) => t.id === added.id);
2376
+ const [moved] = s.tasks.splice(from, 1);
2377
+ s.tasks.splice(s.tasks.findIndex((t) => t.id === afterId) + 1, 0, moved);
2378
+ });
2379
+ }
2380
+ break;
2381
+ }
2382
+ case 'editTask':
2383
+ await this.updateTask(r.sessionId, r.taskId, {
2384
+ ...(c.title ? { title: c.title } : {}),
2385
+ ...(c.prompt ? { prompt: c.prompt } : {}),
2386
+ ...(c.checks ? { checks: c.checks } : {}),
2387
+ ...(c.scope ? { scope: c.scope } : {}),
2388
+ });
2389
+ break;
2390
+ case 'removeTask':
2391
+ await this.deleteTask(r.sessionId, r.taskId);
2392
+ break;
2393
+ case 'continue':
2394
+ await this.continueTask(r.sessionId, r.taskId, c.instruction);
2395
+ break;
2396
+ case 'newPrompt':
2397
+ await this.rerunTask(r.sessionId, r.taskId, { prompt: c.prompt }, { buildOnFinished: true });
2398
+ break;
2399
+ case 'runAgain':
2400
+ await this.rerunTask(r.sessionId, r.taskId, c.prompt ? { prompt: c.prompt } : {});
2401
+ break;
2402
+ case 'undo': {
2403
+ const done = await this.undoTask(r.sessionId, r.taskId, c.how);
2404
+ if (!done.ok)
2405
+ throw new Error(done.problem ?? 'it could not be undone');
2406
+ break;
2407
+ }
2408
+ case 'accept':
2409
+ await this.acceptTask(r.sessionId, r.taskId, c.note);
2410
+ break;
2411
+ case 'note':
2412
+ await this.addTaskNote(r.sessionId, r.taskId, c.text);
2413
+ break;
2414
+ }
2415
+ applied.push({ index: r.index, says: r.says });
2416
+ await appendSessionLog(cfg.resolved.runsDir, r.sessionId, { type: 'kerrigan-change', message: r.says, data: { op: c.op, ...(r.taskId ? { taskId: r.taskId } : {}) } }).catch(() => undefined);
2417
+ this.bus.publish({ sessionId: r.sessionId, ...(r.taskId ? { taskId: r.taskId } : {}), type: 'kerrigan-change', level: 'info', message: `from Kerrigan's answer: ${r.says}` });
2418
+ }
2419
+ catch (e) {
2420
+ return {
2421
+ ok: false,
2422
+ applied,
2423
+ failedAt: r.index,
2424
+ problem: `Change ${r.index + 1} (${r.says}) was refused: ${e.message}${applied.length > 0 ? ` The ${applied.length} before it were made.` : ' Nothing was changed.'}`,
2425
+ };
2426
+ }
2427
+ }
2428
+ return { ok: true, applied };
2429
+ }
2197
2430
  // --- approvals ------------------------------------------------------------------------
2198
2431
  /**
2199
2432
  * Asks the operator to choose the model in the Copilot window, when the runner could not (operator's
@@ -2802,6 +3035,105 @@ let OperatorService = class OperatorService {
2802
3035
  const refused = await this.restoreRefusal(session);
2803
3036
  return refused ? { ...preview, ok: false, problem: refused } : preview;
2804
3037
  }
3038
+ /**
3039
+ * What undoing this task would do, both ways (operator's request, 2026-10-14): "Undo only this task" — its
3040
+ * work taken out of the session's line, what came after it kept — and "Go back to before this task" — the
3041
+ * line put back at its start, it and the tasks after it queued again, nothing started. See `undoTask`.
3042
+ */
3043
+ async undoPreview(sessionId, taskId) {
3044
+ await this.init();
3045
+ const session = await this.store.getSession(sessionId);
3046
+ if (!session)
3047
+ throw new Error('No such session.');
3048
+ const task = session.tasks.find((t) => t.id === taskId);
3049
+ if (!task)
3050
+ throw new Error('No such task.');
3051
+ const notYet = undoRefusalOf(task);
3052
+ const refused = notYet ?? (await this.restoreRefusal(session));
3053
+ const own = await undoThisPreview(session, task);
3054
+ const back = await this.restartPlan(sessionId, taskId);
3055
+ return {
3056
+ this: refused ? { ...own, ok: false, problem: refused } : own,
3057
+ back: refused ? { ...back, ok: false, problem: refused } : back,
3058
+ ...(refused ? { problem: refused } : {}),
3059
+ };
3060
+ }
3061
+ /**
3062
+ * Undoes a task that ran. `this`: its work is taken out of the session's line by a revert (per-session),
3063
+ * or the folder is put back at its start (per-task); the task goes back to the queue. `back`: "Run again
3064
+ * from here" without the run — the line goes back to the task's start and it and the tasks after it are
3065
+ * queued. Nothing is started and nothing in git is deleted: the old branch or the reverted commits stay.
3066
+ */
3067
+ async undoTask(sessionId, taskId, how) {
3068
+ await this.init();
3069
+ const session = await this.store.getSession(sessionId);
3070
+ if (!session)
3071
+ throw new Error('No such session.');
3072
+ const task = session.tasks.find((t) => t.id === taskId);
3073
+ if (!task)
3074
+ throw new Error('No such task.');
3075
+ const notYet = undoRefusalOf(task);
3076
+ if (notYet)
3077
+ return { ok: false, problem: notYet };
3078
+ const refused = await this.restoreRefusal(session);
3079
+ if (refused)
3080
+ return { ok: false, problem: refused };
3081
+ const cfg = await this.settings.load();
3082
+ const at = new Date().toISOString();
3083
+ if (how === 'back') {
3084
+ const plan = await this.restartPlan(sessionId, taskId);
3085
+ const r = await this.restartFrom(sessionId, taskId, { restore: true, start: false });
3086
+ if (r.reason && r.requeued === 0)
3087
+ return { ok: false, problem: r.reason };
3088
+ for (const t of plan.tasks.filter((x) => !x.alreadyQueued)) {
3089
+ await this.store
3090
+ .updateSession(t.sessionId, (s) => {
3091
+ const last = s.tasks.find((x) => x.id === t.taskId)?.attempts?.at(-1);
3092
+ if (last)
3093
+ last.undone = { how: 'back', at, ...(r.restored[0] ? { branch: r.restored[0] } : {}), ...(t.taskId !== taskId ? { byTask: taskId } : {}) };
3094
+ })
3095
+ .catch(() => undefined);
3096
+ }
3097
+ const message = `went back to before "${task.title}": ${r.requeued} task(s) queued again${r.restored.length > 0 ? `, the code on ${r.restored.join(', ')}` : ''}; nothing was started`;
3098
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'undo', message, data: { taskId, how, requeued: r.requeued, restored: r.restored } }).catch(() => undefined);
3099
+ this.bus.publish({ sessionId, taskId, type: 'task-undone', level: 'info', message, data: { how, requeued: r.requeued } });
3100
+ return { ok: true, message, requeued: r.requeued };
3101
+ }
3102
+ let done = {};
3103
+ if (session.vcs?.enabled && session.vcs.branchMode === 'per-task') {
3104
+ const preview = await undoThisPreview(session, task);
3105
+ if (!preview.ok)
3106
+ return { ok: false, problem: preview.problem };
3107
+ if (!preview.nothing) {
3108
+ const back = await restoreToBase(session, task, this.bus);
3109
+ if (!back.ok)
3110
+ return { ok: false, problem: back.problem };
3111
+ done = { branch: back.branch };
3112
+ }
3113
+ else
3114
+ done = { nothing: preview.nothing };
3115
+ }
3116
+ else {
3117
+ const r = await revertTaskWork(session, task, this.bus);
3118
+ if (!r.ok)
3119
+ return { ok: false, problem: r.problem };
3120
+ done = { commit: r.commit, branch: r.branch, nothing: r.nothing };
3121
+ }
3122
+ await this.store.rerunTask(sessionId, taskId, {});
3123
+ await this.store.updateSession(sessionId, (s) => {
3124
+ const last = s.tasks.find((x) => x.id === taskId)?.attempts?.at(-1);
3125
+ if (last)
3126
+ last.undone = { how: 'this', at, ...(done.commit ? { commit: done.commit } : {}), ...(done.branch ? { branch: done.branch } : {}) };
3127
+ });
3128
+ const message = done.commit
3129
+ ? `took the work of "${task.title}" out of ${done.branch} in ${done.commit.slice(0, 8)}; the task is queued again, nothing was started`
3130
+ : done.branch
3131
+ ? `put the folder back as it was before "${task.title}", on ${done.branch}; its own branch is kept, the task is queued again`
3132
+ : `${done.nothing ?? 'nothing in git to take out'} The task is queued again.`;
3133
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'undo', message, data: { taskId, how, ...done } }).catch(() => undefined);
3134
+ this.bus.publish({ sessionId, taskId, type: 'task-undone', level: 'info', message, data: { how, ...done } });
3135
+ return { ok: true, message, requeued: 1 };
3136
+ }
2805
3137
  async restore(sessionId, taskId) {
2806
3138
  await this.init();
2807
3139
  const session = await this.store.getSession(sessionId);
@@ -108,6 +108,22 @@ export async function startApi(opts = {}) {
108
108
  const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
109
109
  app.use(localApiGuard({ token, port: PORT, allowedOrigins, servesInterface: webDir !== null }));
110
110
  if (webDir) {
111
+ /*
112
+ * A page whose name is also a folder of pages: `/sessions` is `sessions.html`, and `sessions/` holds
113
+ * `view.html` (2026-10-14, when the list moved there). The static server finds the folder first and
114
+ * redirects to it, where there is no page; the page is served instead.
115
+ */
116
+ const root = webDir;
117
+ app.use((req, res, next) => {
118
+ if ((req.method === 'GET' || req.method === 'HEAD') && /^\/[a-z0-9-]+$/i.test(req.path)) {
119
+ const page = join(root, `${req.path.slice(1)}.html`);
120
+ if (existsSync(page) && existsSync(join(root, req.path.slice(1)))) {
121
+ res.sendFile(page);
122
+ return;
123
+ }
124
+ }
125
+ next();
126
+ });
111
127
  // The prebuilt interface, from the package: one process, one port, no dev server.
112
128
  app.useStaticAssets(webDir, { extensions: ['html'], index: 'index.html' });
113
129
  }
package/dist/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { createInterface } from 'node:readline/promises';
17
17
  import { stdin, stdout } from 'node:process';
18
18
  import { loadConfig, expandPath } from './config/schema.js';
19
19
  import { CopilotTransport } from './transport/copilotTransport.js';
20
+ import { parseChatId } from './transport/chatSession.js';
20
21
  import { runSession } from './orchestrator/taskRunner.js';
21
22
  import { SessionStore } from './session/store.js';
22
23
  import { settingsOf, SettingsUnusableError } from './api/settings.js';
@@ -384,6 +385,7 @@ program
384
385
  .option('--json', 'print the raw list, for pasting into an issue')
385
386
  .option('--set <name>', 'also switch the chat to this model, by its exact name')
386
387
  .option('--steps', 'with --set: print each step of the choice with its time, and keep a picture of each in runs/_models')
388
+ .option('--chat <id-or-address>', 'first open this existing conversation (its id, or its address from the browser), as a task carried on does')
387
389
  .action(async (opts) => {
388
390
  const transport = new CopilotTransport({
389
391
  profileDir: opts.profile,
@@ -400,6 +402,16 @@ program
400
402
  try {
401
403
  await transport.open();
402
404
  await transport.ensureSignedIn();
405
+ /*
406
+ * Back in a conversation that already ran, as a failed task carried on is: the case the operator saw
407
+ * the model check go wrong in (2026-10-14). What the picker button reads there is printed first.
408
+ */
409
+ if (opts.chat) {
410
+ const id = parseChatId(opts.chat) ?? opts.chat.trim();
411
+ const opened = await transport.openConversation(id);
412
+ console.log(opened ? `opened the conversation ${id}` : `the conversation ${id} could not be opened; going on in the page as it is`);
413
+ console.log(`the picker button reads: ${(await transport.currentModel()) ?? '(nothing)'}`);
414
+ }
403
415
  const { options, current, note } = await transport.listModels();
404
416
  if (opts.json) {
405
417
  console.log(JSON.stringify({ current, note, options }, null, 2));
@@ -381,6 +381,16 @@ export const RunConfigSchema = z.object({
381
381
  * its record. Zero turns it off.
382
382
  */
383
383
  retryBlockedInFreshChat: z.number().int().nonnegative().default(2),
384
+ /**
385
+ * How many times a task a limit stopped is carried on at once, in its own chat and from where it got
386
+ * to, before it waits for the operator's "Continue" (operator's request, 2026-10-14: keep going until
387
+ * the task is done, like Claude Code, with the limits as the bound). Every limit above counts —
388
+ * messages, minutes, the reply wait, replies in the wrong shape, rounds of fixing checks or review
389
+ * findings — so a task gets at most (autoContinue + 1) times each of them. The loop guards (repeats,
390
+ * no progress) do not carry on: they end a task as blocked, which a fresh chat may cure, then a person.
391
+ * Zero turns it off.
392
+ */
393
+ autoContinue: z.number().int().min(0).max(20).default(3),
384
394
  /*
385
395
  * How many genuinely different approaches the chat must have tried before it may end a task
386
396
  * "blocked". The reply format's own floor is 2 (replySchema.ts); this raises it. A "blocked"