copilot-operator 0.1.41 → 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 (67) hide show
  1. package/dist/src/api/operator.controller.js +159 -6
  2. package/dist/src/api/operator.service.js +417 -10
  3. package/dist/src/api/server.js +16 -0
  4. package/dist/src/cli.js +12 -0
  5. package/dist/src/config/schema.js +17 -0
  6. package/dist/src/orchestrator/planMode.js +102 -0
  7. package/dist/src/orchestrator/review.js +8 -4
  8. package/dist/src/orchestrator/taskRunner.js +246 -40
  9. package/dist/src/plan/brief.js +69 -20
  10. package/dist/src/plan/changes.js +217 -0
  11. package/dist/src/plan/importPlan.js +2 -0
  12. package/dist/src/plan/schema.js +11 -1
  13. package/dist/src/plan/systemGuide.js +790 -14
  14. package/dist/src/protocol/parser.js +26 -2
  15. package/dist/src/protocol/replySchema.js +28 -4
  16. package/dist/src/session/compose.js +76 -7
  17. package/dist/src/session/exports.js +1 -0
  18. package/dist/src/session/model.js +38 -0
  19. package/dist/src/session/store.js +90 -3
  20. package/dist/src/vcs/startChoice.js +80 -8
  21. package/dist/src/vcs/taskVcs.js +126 -8
  22. package/dist/web/404.html +2 -2
  23. package/dist/web/_next/static/WmzBz4QN36pcz4PL_cfEW/_buildManifest.js +1 -0
  24. package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
  25. package/dist/web/_next/static/chunks/549-2415dd26f4e53d07.js +1 -0
  26. package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
  27. package/dist/web/_next/static/chunks/867-aea30f560bc18eaf.js +1 -0
  28. package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
  29. package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
  30. package/dist/web/_next/static/chunks/app/history/page-0bb537407fed5944.js +1 -0
  31. package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
  32. package/dist/web/_next/static/chunks/app/layout-dc602b727480a653.js +1 -0
  33. package/dist/web/_next/static/chunks/app/page-d73a8ff300862cd8.js +1 -0
  34. package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
  35. package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
  36. package/dist/web/_next/static/css/{cea7913ba416dbb7.css → 72c85e4322c21223.css} +1 -1
  37. package/dist/web/appearance.html +2 -2
  38. package/dist/web/appearance.txt +25 -28
  39. package/dist/web/defaults.html +2 -2
  40. package/dist/web/defaults.txt +25 -28
  41. package/dist/web/history.html +2 -2
  42. package/dist/web/history.txt +25 -28
  43. package/dist/web/import.html +2 -2
  44. package/dist/web/import.txt +25 -28
  45. package/dist/web/index.html +2 -2
  46. package/dist/web/index.txt +25 -28
  47. package/dist/web/level1.html +2 -2
  48. package/dist/web/level1.txt +25 -28
  49. package/dist/web/presets.html +3 -3
  50. package/dist/web/presets.txt +25 -28
  51. package/dist/web/sessions/view.html +2 -2
  52. package/dist/web/sessions/view.txt +25 -28
  53. package/dist/web/sessions.html +16 -0
  54. package/dist/web/sessions.txt +26 -0
  55. package/dist/web/system.html +2 -2
  56. package/dist/web/system.txt +25 -28
  57. package/package.json +161 -158
  58. package/dist/web/_next/static/-htN5GTSrd3W3f5j_QC2y/_buildManifest.js +0 -1
  59. package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +0 -1
  60. package/dist/web/_next/static/chunks/549-f295c8429e83b25b.js +0 -1
  61. package/dist/web/_next/static/chunks/584-578be94a0786334b.js +0 -1
  62. package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +0 -1
  63. package/dist/web/_next/static/chunks/app/defaults/page-879158e9e550eea7.js +0 -1
  64. package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +0 -1
  65. package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +0 -1
  66. package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +0 -1
  67. /package/dist/web/_next/static/{-htN5GTSrd3W3f5j_QC2y → WmzBz4QN36pcz4PL_cfEW}/_ssgManifest.js +0 -0
@@ -20,7 +20,7 @@ import { join, resolve } from 'node:path';
20
20
  import { installLayout } from '../config/layout.js';
21
21
  import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyDefaultProject, applyTaskPatch } from '../session/store.js';
22
22
  import { EventBus } from '../session/events.js';
23
- import { freshRetriesOfLatestRun, isContinuable, newId } from '../session/model.js';
23
+ import { freshRetriesOfLatestRun, isContinuable, MAX_INSTRUCTION_CHARS, newId } from '../session/model.js';
24
24
  import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
25
25
  import { buildExport } from '../session/exportRecord.js';
26
26
  import { buildDebugExport } from '../session/debugExport.js';
@@ -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
@@ -436,6 +453,12 @@ let OperatorService = class OperatorService {
436
453
  }
437
454
  if (patch.projectDir !== undefined)
438
455
  s.projectDir = patch.projectDir.trim();
456
+ if (patch.planMode !== undefined) {
457
+ if (patch.planMode === 'off' || patch.planMode === 'auto' || patch.planMode === 'approve')
458
+ s.planMode = patch.planMode;
459
+ else
460
+ delete s.planMode;
461
+ }
439
462
  if (patch.vcs) {
440
463
  const merged = { ...DEFAULT_VCS, ...s.vcs, ...patch.vcs };
441
464
  // A prefix is what makes the bot's branches recognisable in `git branch`, so an empty
@@ -566,6 +589,34 @@ let OperatorService = class OperatorService {
566
589
  await this.init();
567
590
  await this.store.deleteTask(sessionId, taskId);
568
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
+ }
569
620
  /**
570
621
  * Puts a finished task back in the queue, keeping the record of what it already did.
571
622
  *
@@ -574,7 +625,12 @@ let OperatorService = class OperatorService {
574
625
  * the old attempt's summary sitting under a question that was never asked.
575
626
  */
576
627
  /** "Continue" on a task that stopped before it finished — a limit from the settings, the bot stopping, the operator: same chat, same branch, fresh count. */
577
- async continueTask(sessionId, taskId) {
628
+ async continueTask(sessionId, taskId,
629
+ /**
630
+ * The operator's new prompt: what carries a task that failed on from where it got to, in its chat and
631
+ * on its branch (2026-10-13); optional with any other Continue. See `TaskContinuation.instruction`.
632
+ */
633
+ instruction) {
578
634
  await this.init();
579
635
  /*
580
636
  * An attempt the operator stopped: which of its last steps ran, and what they printed, read from its
@@ -584,15 +640,21 @@ let OperatorService = class OperatorService {
584
640
  const before = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
585
641
  const cfg = await this.settings.load();
586
642
  const stoppedAt = before?.status === 'aborted' && !before.interruption && before.runId ? await readInterruption(join(cfg.resolved.runsDir, before.runId)).catch(() => null) : null;
587
- const task = await this.store.continueTask(sessionId, taskId, stoppedAt);
588
- await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'continue', message: `"${task.title}" queued to continue as attempt ${task.attempt ?? 1}`, data: { taskId, attempt: task.attempt } }).catch(() => undefined);
643
+ const task = await this.store.continueTask(sessionId, taskId, stoppedAt, instruction);
644
+ const after = task.continuing?.how === 'failed' ? ` after it ended ${task.continuing.ended?.status ?? 'failed'}` : '';
645
+ const withPrompt = task.continuing?.instruction ? ', with a new prompt' : '';
646
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, {
647
+ type: 'continue',
648
+ message: `"${task.title}" queued to continue as attempt ${task.attempt ?? 1}${after}${withPrompt}`,
649
+ data: { taskId, attempt: task.attempt, ...(task.continuing?.instruction ? { instruction: task.continuing.instruction } : {}) },
650
+ }).catch(() => undefined);
589
651
  this.bus.publish({
590
652
  sessionId,
591
653
  taskId,
592
654
  type: 'task-requeued',
593
655
  level: 'info',
594
- message: `"${task.title}" is queued to continue where it stopped (attempt ${task.attempt ?? 1})`,
595
- data: { attempt: task.attempt, continuing: true },
656
+ message: `"${task.title}" is queued to continue where it ${after ? 'ended' : 'stopped'}${after}${withPrompt} (attempt ${task.attempt ?? 1})`,
657
+ data: { attempt: task.attempt, continuing: true, ...(task.continuing?.instruction ? { instruction: true } : {}) },
596
658
  });
597
659
  return task;
598
660
  }
@@ -1130,8 +1192,16 @@ let OperatorService = class OperatorService {
1130
1192
  x.vcsBaseCommit = undefined;
1131
1193
  x.vcsStart = undefined;
1132
1194
  }
1195
+ // Chosen again after the session ran: a re-run starts from it too, and a per-session line goes on under a new name.
1196
+ if (applied.restart) {
1197
+ x.startRechosenAt = new Date().toISOString();
1198
+ if (applied.restart.freshBranch) {
1199
+ x.vcs.branchName = applied.restart.freshBranch;
1200
+ x.vcs.branchNameExact = true;
1201
+ }
1202
+ }
1133
1203
  });
1134
- const message = `${applied.said}${applied.note ? `; ${applied.note}` : ''}${applied.clearStart ? '; the start prepared before (a starting snapshot) no longer applies' : ''}`;
1204
+ const message = `${applied.said}${applied.note ? `; ${applied.note}` : ''}${applied.clearStart && !applied.restart ? '; the start prepared before (a starting snapshot) no longer applies' : ''}`;
1135
1205
  entries.push({ at: new Date().toISOString(), type: 'start-chosen', message, data: { session: id, choice: starts[id], ...(applied.note ? { note: applied.note } : {}) } });
1136
1206
  this.bus.publish({ sessionId: id, type: 'start-chosen', level: 'info', message, data: { runId, choice: starts[id] } });
1137
1207
  await appendSessionLog(cfg.resolved.runsDir, id, { type: 'start-chosen', message, data: { runId, choice: starts[id] } }).catch(() => undefined);
@@ -1323,6 +1393,8 @@ let OperatorService = class OperatorService {
1323
1393
  await this.store.saveModels({ options, current: current ?? undefined, readAt: new Date().toISOString() });
1324
1394
  },
1325
1395
  },
1396
+ // Plan mode `approve`: the plan is put to the operator on the approvals card.
1397
+ approvePlan: (ask) => this.askPlanApproval(ask, controller.signal),
1326
1398
  })
1327
1399
  .then(async (outcome) => {
1328
1400
  const tally = { ...(await this.tally(sessionId, queuedIds)), paused: outcome.paused, ...(outcome.refused ? { refused: outcome.refused } : {}) };
@@ -2167,6 +2239,194 @@ let OperatorService = class OperatorService {
2167
2239
  duplicates: check.duplicates,
2168
2240
  };
2169
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
+ }
2170
2430
  // --- approvals ------------------------------------------------------------------------
2171
2431
  /**
2172
2432
  * Asks the operator to choose the model in the Copilot window, when the runner could not (operator's
@@ -2196,6 +2456,54 @@ let OperatorService = class OperatorService {
2196
2456
  message: `choose "${ask.asked}" in the Copilot window, then answer on the card`, data: { approvalId: approval.id, model: approval.model } });
2197
2457
  });
2198
2458
  }
2459
+ /**
2460
+ * A task's plan, put to the operator (plan mode `approve`, in a run that asks before each command): it
2461
+ * waits on the approvals card, on every page, until it is approved, sent back for another, or the task
2462
+ * is stopped. See `PlanMode`.
2463
+ */
2464
+ askPlanApproval(ask, signal) {
2465
+ return new Promise((resolveAnswer) => {
2466
+ if (signal.aborted) {
2467
+ resolveAnswer({ decision: 'stop' });
2468
+ return;
2469
+ }
2470
+ const approval = {
2471
+ id: newId('a-'),
2472
+ sessionId: ask.sessionId,
2473
+ taskId: ask.taskId,
2474
+ stepId: 0,
2475
+ description: `The plan of "${ask.title}" (version ${ask.plan.version})`,
2476
+ createdAt: new Date().toISOString(),
2477
+ plan: { title: ask.title, plan: ask.plan },
2478
+ };
2479
+ this.waiting.set(approval.id, {
2480
+ approval,
2481
+ resolve: (d) => resolveAnswer(d.action === 'run' ? { decision: 'approve' } : { decision: 'stop' }),
2482
+ resolvePlan: resolveAnswer,
2483
+ });
2484
+ this.bus.publish({ sessionId: ask.sessionId, taskId: ask.taskId, type: 'approval-requested', level: 'warn',
2485
+ message: `the plan of "${ask.title}" (version ${ask.plan.version}) is waiting for you to approve it`, data: { approvalId: approval.id, plan: true } });
2486
+ });
2487
+ }
2488
+ /**
2489
+ * Answers a plan waiting for approval: approve it (with a note the chat is given beside it), ask for
2490
+ * another (saying what to change), or stop the task.
2491
+ */
2492
+ decidePlan(approvalId, action, note) {
2493
+ const w = this.waiting.get(approvalId);
2494
+ if (!w || !w.resolvePlan)
2495
+ return { ok: false, problem: 'That plan is not waiting any more.' };
2496
+ const text = (note ?? '').trim();
2497
+ if (action === 'revise' && !text)
2498
+ return { ok: false, problem: 'Say what to change in the plan, so the chat knows what the next one must do differently.' };
2499
+ if (text.length > MAX_INSTRUCTION_CHARS)
2500
+ return { ok: false, problem: `The note is ${text.length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.` };
2501
+ this.waiting.delete(approvalId);
2502
+ w.resolvePlan({ decision: action, ...(text ? { note: text } : {}) });
2503
+ this.bus.publish({ sessionId: w.approval.sessionId, taskId: w.approval.taskId, type: 'approval-decided', level: 'info',
2504
+ message: `plan: ${action}${text ? ' (with a note)' : ''}`, data: { approvalId, action, plan: true } });
2505
+ return { ok: true };
2506
+ }
2199
2507
  pendingApprovals(sessionId) {
2200
2508
  return [...this.waiting.values()].map((w) => w.approval).filter((a) => !sessionId || a.sessionId === sessionId);
2201
2509
  }
@@ -2727,6 +3035,105 @@ let OperatorService = class OperatorService {
2727
3035
  const refused = await this.restoreRefusal(session);
2728
3036
  return refused ? { ...preview, ok: false, problem: refused } : preview;
2729
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
+ }
2730
3137
  async restore(sessionId, taskId) {
2731
3138
  await this.init();
2732
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));
@@ -149,6 +149,13 @@ export const RunConfigSchema = z.object({
149
149
  * there, a fetch included, is put in front of a person anyway.
150
150
  */
151
151
  networkFetch: z.enum(['ask', 'refuse', 'run']).default('ask'),
152
+ /**
153
+ * Plan mode, for every session that does not set its own (see `PlanMode` in session/model.ts): a
154
+ * task's chat first looks at the project read-only and replies with a plan, and the work begins once
155
+ * the plan is approved — by the runner (`auto`), or by the operator (`approve`, in a run that asks
156
+ * before each command; a run without asking approves it itself). Off by default: it costs messages.
157
+ */
158
+ planMode: z.enum(['off', 'auto', 'approve']).default('off'),
152
159
  /**
153
160
  * The shell a step or a check runs in when it names none of its own.
154
161
  *
@@ -374,6 +381,16 @@ export const RunConfigSchema = z.object({
374
381
  * its record. Zero turns it off.
375
382
  */
376
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),
377
394
  /*
378
395
  * How many genuinely different approaches the chat must have tried before it may end a task
379
396
  * "blocked". The reply format's own floor is 2 (replySchema.ts); this raises it. A "blocked"