copilot-operator 0.1.42 → 0.1.44

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 (60) hide show
  1. package/dist/src/api/operator.controller.js +185 -0
  2. package/dist/src/api/operator.service.js +421 -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 +733 -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/session/workspace.js +71 -0
  15. package/dist/src/vcs/taskVcs.js +110 -1
  16. package/dist/web/404.html +2 -2
  17. package/dist/web/_next/static/BX8Nw248DTY9xAfUu2pOH/_buildManifest.js +1 -0
  18. package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
  19. package/dist/web/_next/static/chunks/549-1fe42544e4b72942.js +1 -0
  20. package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
  21. package/dist/web/_next/static/chunks/867-0f0f8c3bd37be31e.js +1 -0
  22. package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
  23. package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
  24. package/dist/web/_next/static/chunks/app/history/{page-47973539ceff81d6.js → page-0bb537407fed5944.js} +1 -1
  25. package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
  26. package/dist/web/_next/static/chunks/app/layout-12f138ee04d33c1e.js +1 -0
  27. package/dist/web/_next/static/chunks/app/page-09c6e2d42098f8d4.js +1 -0
  28. package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
  29. package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
  30. package/dist/web/_next/static/css/{0e55721108c300f2.css → eaec9cc6fe8aceb7.css} +1 -1
  31. package/dist/web/appearance.html +2 -2
  32. package/dist/web/appearance.txt +25 -28
  33. package/dist/web/defaults.html +2 -2
  34. package/dist/web/defaults.txt +25 -28
  35. package/dist/web/history.html +2 -2
  36. package/dist/web/history.txt +25 -28
  37. package/dist/web/import.html +2 -2
  38. package/dist/web/import.txt +25 -28
  39. package/dist/web/index.html +2 -2
  40. package/dist/web/index.txt +25 -28
  41. package/dist/web/level1.html +2 -2
  42. package/dist/web/level1.txt +25 -28
  43. package/dist/web/presets.html +3 -3
  44. package/dist/web/presets.txt +25 -28
  45. package/dist/web/sessions/view.html +2 -2
  46. package/dist/web/sessions/view.txt +25 -28
  47. package/dist/web/sessions.html +16 -0
  48. package/dist/web/sessions.txt +26 -0
  49. package/dist/web/system.html +2 -2
  50. package/dist/web/system.txt +25 -28
  51. package/package.json +5 -2
  52. package/dist/web/_next/static/chunks/204-db82ca47ff211e21.js +0 -1
  53. package/dist/web/_next/static/chunks/499-39b2c00bda3656f0.js +0 -1
  54. package/dist/web/_next/static/chunks/549-348b0a1404407e5a.js +0 -1
  55. package/dist/web/_next/static/chunks/867-7d86a692260006f1.js +0 -1
  56. package/dist/web/_next/static/chunks/app/defaults/page-a25c40436fc312db.js +0 -1
  57. package/dist/web/_next/static/chunks/app/layout-6ee612d25e78b246.js +0 -1
  58. package/dist/web/_next/static/chunks/app/sessions/view/page-872d8b9fe8eedbbf.js +0 -1
  59. package/dist/web/_next/static/lDmqS7pWU_B_Ac5idPvdS/_buildManifest.js +0 -1
  60. /package/dist/web/_next/static/{lDmqS7pWU_B_Ac5idPvdS → BX8Nw248DTY9xAfUu2pOH}/_ssgManifest.js +0 -0
@@ -45,10 +45,12 @@ 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';
50
+ import { workspaceFile, workspaceFiles, workspaceInfo } from '../session/workspace.js';
49
51
  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';
52
+ import { baseRefOf, branchAt, commitInterrupted, nextAttemptBranch, repoDirOf, trackedRepoOf, vcsPreflight, restorePoint, restorePreview, restoreToBase, revertTaskWork, undoThisPreview, sessionBranches, sessionBranchName } from '../vcs/taskVcs.js';
53
+ import { branchExists, changedFilesBetween, commitsBetween, fileAt, freeBranchName, git, gitAvailable, localBranches, looksLikeRepo, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
52
54
  import { makeAuthorizer } from '../exec/authorizer.js';
53
55
  import { pickFolder } from './folderPicker.js';
54
56
  import { findEdgeUsingProfile } from '../transport/profileLock.js';
@@ -80,6 +82,22 @@ import { saveAndReveal } from './saveToDesktop.js';
80
82
  import { settingsOf, SettingsUnusableError } from './settings.js';
81
83
  import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
82
84
  /** Why a session set aside on the Sessions page does not run: one sentence for every start that refuses it. */
85
+ /** The id of the task a change names by id, number or title; see `plan/changes.ts`. */
86
+ function checkTaskRef(session, ref) {
87
+ if (typeof ref === 'number' || /^\d+$/.test(String(ref)))
88
+ return session.tasks[Number(ref) - 1]?.id;
89
+ 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;
90
+ }
91
+ /** Why this task cannot be undone now, or null: only a task that ran and is not running or queued. */
92
+ function undoRefusalOf(task) {
93
+ if (task.status === 'queued')
94
+ return 'This task is queued and has not run since; there is nothing of it to undo.';
95
+ if (task.status === 'running' || task.status === 'waiting-approval')
96
+ return 'This task is running. Stop it first.';
97
+ if (!task.startedAt)
98
+ return 'This task has not run; there is nothing of it to undo.';
99
+ return null;
100
+ }
83
101
  const INACTIVE_REASON = 'the session is inactive; make it active on the Sessions page to run it';
84
102
  /**
85
103
  * Why the browser cannot be had now, said in terms of what was pressed. The sentences a start, a
@@ -572,6 +590,34 @@ let OperatorService = class OperatorService {
572
590
  await this.init();
573
591
  await this.store.deleteTask(sessionId, taskId);
574
592
  }
593
+ /**
594
+ * A note to the chat about a task (2026-10-14): queued, running or waiting, it goes out with the task's
595
+ * next message — at once, in effect, for a task that is running. See `Task.notes`.
596
+ */
597
+ async addTaskNote(sessionId, taskId, text) {
598
+ await this.init();
599
+ const task = await this.store.addTaskNote(sessionId, taskId, text);
600
+ const running = this.running.has(sessionId) && ['running', 'waiting-approval'].includes(task.status);
601
+ this.bus.publish({ sessionId, taskId, type: 'task-note-added', level: 'info',
602
+ 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',
603
+ data: { length: text.trim().length, running } });
604
+ return task;
605
+ }
606
+ /** The operator accepts a task as done that did not end so (2026-10-14). */
607
+ async acceptTask(sessionId, taskId, note) {
608
+ await this.init();
609
+ if (this.running.has(sessionId)) {
610
+ const now = (await this.store.getSession(sessionId))?.tasks.find((t) => t.id === taskId);
611
+ if (now && ['running', 'waiting-approval'].includes(now.status))
612
+ throw new Error('This task is running. Stop it first.');
613
+ }
614
+ const task = await this.store.acceptTask(sessionId, taskId, note);
615
+ const message = `"${task.title}" accepted as done by the operator (it had ended ${task.accepted?.was})${task.accepted?.note ? `: ${task.accepted.note}` : ''}`;
616
+ this.bus.publish({ sessionId, taskId, type: 'task-accepted', level: 'info', message, data: { was: task.accepted?.was } });
617
+ const cfg = await this.settings.load();
618
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'accept', message, data: { taskId, was: task.accepted?.was } }).catch(() => undefined);
619
+ return task;
620
+ }
575
621
  /**
576
622
  * Puts a finished task back in the queue, keeping the record of what it already did.
577
623
  *
@@ -2194,6 +2240,279 @@ let OperatorService = class OperatorService {
2194
2240
  duplicates: check.duplicates,
2195
2241
  };
2196
2242
  }
2243
+ // --- the workspace and its chat (2026-10-17, "like Windsurf") ------------------------------
2244
+ /** The project folder the workbench has open: Settings → the default project. */
2245
+ async workspace() {
2246
+ await this.init();
2247
+ const cfg = await this.settings.load();
2248
+ return await workspaceInfo(cfg.project?.rootDir ?? '', cfg.project?.name ?? '');
2249
+ }
2250
+ async workspaceFiles() {
2251
+ const ws = await this.workspace();
2252
+ if (!ws.ok)
2253
+ throw new Error(ws.problem ?? 'No project folder is open.');
2254
+ return { dir: ws.dir, ...(await workspaceFiles(ws.dir)) };
2255
+ }
2256
+ async workspaceFile(path) {
2257
+ const ws = await this.workspace();
2258
+ if (!ws.ok)
2259
+ throw new Error(ws.problem ?? 'No project folder is open.');
2260
+ return await workspaceFile(ws.dir, path);
2261
+ }
2262
+ /** The chat's conversations, newest first. */
2263
+ async chatThreads() {
2264
+ await this.init();
2265
+ return (await this.store.listSessions())
2266
+ .filter((s) => s.kind === 'chat')
2267
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
2268
+ .map((s) => ({ id: s.id, name: s.name, createdAt: s.createdAt, status: s.status, turns: s.tasks.length, running: this.running.has(s.id), repoDir: s.vcs?.repoDir || s.projectDir }));
2269
+ }
2270
+ /**
2271
+ * A message written in the chat. With no conversation yet, one is made in the open project folder: a session of
2272
+ * kind `chat`, one branch for all of it, no independent review, plan mode off, so each message is answered as
2273
+ * it would be in an IDE's chat. The message becomes a task of it, queued; the caller starts it (the start window
2274
+ * the first time, where the work starts being asked once). While the conversation is working, the message is a
2275
+ * note to the task in flight, which the chat gets with its next report.
2276
+ */
2277
+ async chatSend(input) {
2278
+ await this.init();
2279
+ const text = (input.text ?? '').trim();
2280
+ if (!text)
2281
+ throw new Error('The message is empty.');
2282
+ if (text.length > MAX_INSTRUCTION_CHARS)
2283
+ throw new Error(`The message is ${text.length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.`);
2284
+ let session = input.threadId ? await this.store.getSession(input.threadId) : null;
2285
+ if (input.threadId && (!session || session.kind !== 'chat'))
2286
+ throw new Error('That conversation is not there any more. Start a new one.');
2287
+ if (session) {
2288
+ const inFlight = session.tasks.find((t) => t.status === 'running' || t.status === 'waiting-approval');
2289
+ if (inFlight && this.running.has(session.id)) {
2290
+ await this.addTaskNote(session.id, inFlight.id, text);
2291
+ return { threadId: session.id, taskId: inFlight.id, noted: true, needsStart: false };
2292
+ }
2293
+ }
2294
+ else {
2295
+ const ws = await this.workspace();
2296
+ if (!ws.ok)
2297
+ throw new Error(ws.problem ?? 'No project folder is open.');
2298
+ const title = text.split(/\r?\n/)[0].trim().slice(0, 60);
2299
+ const created = await this.store.createSession(`chat: ${title}`, ws.dir);
2300
+ session = await this.store.updateSession(created.id, (s) => {
2301
+ s.kind = 'chat';
2302
+ s.onFailure = 'continue';
2303
+ s.review = { enabled: false, model: '' };
2304
+ s.planMode = 'off';
2305
+ s.vcs = ws.isRepo
2306
+ ? {
2307
+ ...(s.vcs ?? DEFAULT_VCS),
2308
+ enabled: true,
2309
+ repoDir: ws.dir,
2310
+ branchMode: 'per-session',
2311
+ // The operator's own uncommitted work is normal in an open folder: taken along as the conversation's
2312
+ // starting snapshot, once they approve it, rather than refusing the first message.
2313
+ dirtyWorktree: { policy: 'snapshot' },
2314
+ }
2315
+ : { ...(s.vcs ?? DEFAULT_VCS), enabled: false, repoDir: '' };
2316
+ s.projectDir = ws.dir;
2317
+ });
2318
+ this.bus.publish({ sessionId: session.id, type: 'chat-started', level: 'info', message: `a conversation in ${ws.dir}` });
2319
+ }
2320
+ const task = await this.store.addTask(session.id, {
2321
+ title: text.split(/\r?\n/)[0].trim().slice(0, 80) || 'message',
2322
+ level2: '',
2323
+ prompt: text,
2324
+ });
2325
+ const fresh = (await this.store.getSession(session.id));
2326
+ return { threadId: fresh.id, taskId: task.id, needsStart: !fresh.vcsStart && !fresh.tasks.some((t) => t.startedAt) };
2327
+ }
2328
+ // --- the repository, read-only, for the workbench (2026-10-14) ------------------------------
2329
+ /**
2330
+ * Where a session's work is in its repository: the session's branch in per-session mode, the task's own
2331
+ * branch when one is named, else what is checked out. Read-only; nothing here moves anything.
2332
+ */
2333
+ async repoRefOf(sessionId, taskId) {
2334
+ await this.init();
2335
+ const session = await this.store.getSession(sessionId);
2336
+ if (!session)
2337
+ throw new Error('No such session.');
2338
+ const dir = repoDirOf(session);
2339
+ if (!dir || !looksLikeRepo(dir))
2340
+ throw new Error('This session has no repository to show.');
2341
+ const task = taskId ? session.tasks.find((t) => t.id === taskId) : undefined;
2342
+ const candidates = [
2343
+ ...(task?.vcs?.commit ? [task.vcs.commit] : []),
2344
+ ...(session.vcs?.enabled && session.vcs.branchMode === 'per-session' ? [sessionBranchName(session)] : []),
2345
+ 'HEAD',
2346
+ ];
2347
+ for (const ref of candidates) {
2348
+ const r = await git(dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
2349
+ if (r.ok && r.stdout)
2350
+ return { dir, ref: r.stdout, label: ref === 'HEAD' ? 'HEAD' : ref.length >= 40 ? ref.slice(0, 8) : ref };
2351
+ }
2352
+ throw new Error('The repository has no commit to show yet.');
2353
+ }
2354
+ /** One folder of the repository at that point: its folders and files, folders first. */
2355
+ async repoTree(sessionId, path = '', taskId) {
2356
+ const clean = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
2357
+ if (clean.split('/').some((p) => p === '..'))
2358
+ throw new Error('That path is not inside the repository.');
2359
+ const { dir, ref, label } = await this.repoRefOf(sessionId, taskId);
2360
+ const r = await git(dir, ['ls-tree', '-l', clean ? `${ref}:${clean}` : ref]);
2361
+ if (!r.ok)
2362
+ throw new Error(clean ? `There is no folder ${clean} there.` : 'The repository could not be read.');
2363
+ const entries = r.stdout
2364
+ .split('\n')
2365
+ .filter(Boolean)
2366
+ .map((line) => {
2367
+ const [meta, name] = line.split('\t');
2368
+ const [, type, , size] = (meta ?? '').split(/\s+/);
2369
+ return { name: name ?? '', path: clean ? `${clean}/${name}` : (name ?? ''), type: type === 'tree' ? 'dir' : 'file', ...(type === 'blob' && size && size !== '-' ? { size: Number(size) } : {}) };
2370
+ })
2371
+ .filter((e) => e.name)
2372
+ .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
2373
+ return { ref, label, path: clean, entries };
2374
+ }
2375
+ /** One file of the repository at that point, as text; a binary or very large file is said to be one. */
2376
+ async repoFile(sessionId, path, taskId) {
2377
+ const clean = path.replace(/\\/g, '/').replace(/^\/+/, '');
2378
+ if (!clean || clean.split('/').some((p) => p === '..'))
2379
+ throw new Error('That path is not inside the repository.');
2380
+ const { dir, ref, label } = await this.repoRefOf(sessionId, taskId);
2381
+ const got = await fileAt(dir, ref, clean, 2 * 1024 * 1024);
2382
+ if (!got.bytes && !got.tooLarge)
2383
+ throw new Error(`There is no file ${clean} there.`);
2384
+ const binary = !!got.bytes && got.bytes.subarray(0, 8000).includes(0);
2385
+ return { ref, label, path: clean, text: got.bytes && !binary ? got.bytes.toString('utf8').replace(/^/, '') : null, binary, tooLarge: got.tooLarge };
2386
+ }
2387
+ /** The commits on the session's line, newest first, each with the task that made it when one did. */
2388
+ async repoLog(sessionId, limit = 60) {
2389
+ const { dir, ref, label } = await this.repoRefOf(sessionId);
2390
+ const session = (await this.store.getSession(sessionId));
2391
+ const r = await git(dir, ['log', `-${Math.max(1, Math.min(limit, 300))}`, '--format=%H%x09%an%x09%aI%x09%s', ref]);
2392
+ const byCommit = new Map();
2393
+ for (const t of session.tasks) {
2394
+ for (const v of [t.vcs, ...(t.attempts ?? []).map((a) => a.vcs)])
2395
+ if (v?.commit)
2396
+ byCommit.set(v.commit, t);
2397
+ }
2398
+ const commits = (r.ok ? r.stdout.split('\n').filter(Boolean) : []).map((line) => {
2399
+ const [sha = '', author = '', at = '', subject = ''] = line.split('\t');
2400
+ const task = byCommit.get(sha);
2401
+ return { sha, subject, author, at, ...(task ? { taskId: task.id, taskTitle: task.title } : {}) };
2402
+ });
2403
+ return { ref, label, commits };
2404
+ }
2405
+ // --- Kerrigan: what happened, and her changes to existing work (2026-10-14) ---------------------
2406
+ /** What happened in these sessions (all, when none are named), for Kerrigan, with the operator's note. */
2407
+ async kerriganReport(opts = {}) {
2408
+ await this.init();
2409
+ const all = await this.store.listSessions();
2410
+ const wanted = (opts.sessionIds ?? []).filter(Boolean);
2411
+ const taskIds = (opts.taskIds ?? []).filter(Boolean);
2412
+ const sessions = all.filter((s) => (wanted.length > 0 ? wanted.includes(s.id) : taskIds.length > 0 ? s.tasks.some((t) => taskIds.includes(t.id)) : true));
2413
+ if (sessions.length === 0)
2414
+ throw new Error('None of these sessions exists.');
2415
+ if ((opts.note ?? '').length > MAX_INSTRUCTION_CHARS)
2416
+ throw new Error(`The note is ${(opts.note ?? '').length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.`);
2417
+ return { text: kerriganReport(sessions, { note: opts.note, taskIds }), sessions: sessions.length };
2418
+ }
2419
+ /** Kerrigan's answer read against the sessions as they are now; nothing is changed. */
2420
+ async checkChanges(text) {
2421
+ await this.init();
2422
+ return checkChanges(text ?? '', await this.store.listSessions());
2423
+ }
2424
+ /**
2425
+ * Applies Kerrigan's changes one by one, through the paths the buttons use, and stops at the first that is
2426
+ * refused, saying which were done. Nothing is started.
2427
+ */
2428
+ async applyChanges(text) {
2429
+ const check = await this.checkChanges(text);
2430
+ if (!check.ok)
2431
+ return { ok: false, check, applied: [] };
2432
+ const cfg = await this.settings.load();
2433
+ const persona = (await this.getContext('persona', 'en')).content;
2434
+ const applied = [];
2435
+ for (const r of check.changes) {
2436
+ const c = r.change;
2437
+ try {
2438
+ switch (c.op) {
2439
+ case 'addTask': {
2440
+ const session = await this.store.getSession(r.sessionId);
2441
+ if (!session)
2442
+ throw new Error('The session is gone.');
2443
+ const afterId = c.after !== undefined ? checkTaskRef(session, c.after) : undefined;
2444
+ const reference = afterId ? session.tasks.find((t) => t.id === afterId) : session.tasks.at(-1);
2445
+ const level2 = c.task.level2.trim()
2446
+ ? composeLevel2({ level2: '', goal: '' }, c.task, persona)
2447
+ : (reference?.level2 ?? personaBlock(persona));
2448
+ const added = await this.store.addTask(r.sessionId, {
2449
+ title: c.task.title,
2450
+ level2,
2451
+ prompt: composePrompt(c.task),
2452
+ vcsPlan: c.task.vcs,
2453
+ checks: c.task.checks,
2454
+ reviewEnabled: c.task.review,
2455
+ readOnly: c.task.readOnly || undefined,
2456
+ scope: c.task.scope.length > 0 ? c.task.scope : undefined,
2457
+ outputs: c.task.outputs.length > 0 ? c.task.outputs : undefined,
2458
+ });
2459
+ if (afterId) {
2460
+ await this.store.updateSession(r.sessionId, (s) => {
2461
+ const from = s.tasks.findIndex((t) => t.id === added.id);
2462
+ const [moved] = s.tasks.splice(from, 1);
2463
+ s.tasks.splice(s.tasks.findIndex((t) => t.id === afterId) + 1, 0, moved);
2464
+ });
2465
+ }
2466
+ break;
2467
+ }
2468
+ case 'editTask':
2469
+ await this.updateTask(r.sessionId, r.taskId, {
2470
+ ...(c.title ? { title: c.title } : {}),
2471
+ ...(c.prompt ? { prompt: c.prompt } : {}),
2472
+ ...(c.checks ? { checks: c.checks } : {}),
2473
+ ...(c.scope ? { scope: c.scope } : {}),
2474
+ });
2475
+ break;
2476
+ case 'removeTask':
2477
+ await this.deleteTask(r.sessionId, r.taskId);
2478
+ break;
2479
+ case 'continue':
2480
+ await this.continueTask(r.sessionId, r.taskId, c.instruction);
2481
+ break;
2482
+ case 'newPrompt':
2483
+ await this.rerunTask(r.sessionId, r.taskId, { prompt: c.prompt }, { buildOnFinished: true });
2484
+ break;
2485
+ case 'runAgain':
2486
+ await this.rerunTask(r.sessionId, r.taskId, c.prompt ? { prompt: c.prompt } : {});
2487
+ break;
2488
+ case 'undo': {
2489
+ const done = await this.undoTask(r.sessionId, r.taskId, c.how);
2490
+ if (!done.ok)
2491
+ throw new Error(done.problem ?? 'it could not be undone');
2492
+ break;
2493
+ }
2494
+ case 'accept':
2495
+ await this.acceptTask(r.sessionId, r.taskId, c.note);
2496
+ break;
2497
+ case 'note':
2498
+ await this.addTaskNote(r.sessionId, r.taskId, c.text);
2499
+ break;
2500
+ }
2501
+ applied.push({ index: r.index, says: r.says });
2502
+ await appendSessionLog(cfg.resolved.runsDir, r.sessionId, { type: 'kerrigan-change', message: r.says, data: { op: c.op, ...(r.taskId ? { taskId: r.taskId } : {}) } }).catch(() => undefined);
2503
+ this.bus.publish({ sessionId: r.sessionId, ...(r.taskId ? { taskId: r.taskId } : {}), type: 'kerrigan-change', level: 'info', message: `from Kerrigan's answer: ${r.says}` });
2504
+ }
2505
+ catch (e) {
2506
+ return {
2507
+ ok: false,
2508
+ applied,
2509
+ failedAt: r.index,
2510
+ problem: `Change ${r.index + 1} (${r.says}) was refused: ${e.message}${applied.length > 0 ? ` The ${applied.length} before it were made.` : ' Nothing was changed.'}`,
2511
+ };
2512
+ }
2513
+ }
2514
+ return { ok: true, applied };
2515
+ }
2197
2516
  // --- approvals ------------------------------------------------------------------------
2198
2517
  /**
2199
2518
  * Asks the operator to choose the model in the Copilot window, when the runner could not (operator's
@@ -2802,6 +3121,105 @@ let OperatorService = class OperatorService {
2802
3121
  const refused = await this.restoreRefusal(session);
2803
3122
  return refused ? { ...preview, ok: false, problem: refused } : preview;
2804
3123
  }
3124
+ /**
3125
+ * What undoing this task would do, both ways (operator's request, 2026-10-14): "Undo only this task" — its
3126
+ * work taken out of the session's line, what came after it kept — and "Go back to before this task" — the
3127
+ * line put back at its start, it and the tasks after it queued again, nothing started. See `undoTask`.
3128
+ */
3129
+ async undoPreview(sessionId, taskId) {
3130
+ await this.init();
3131
+ const session = await this.store.getSession(sessionId);
3132
+ if (!session)
3133
+ throw new Error('No such session.');
3134
+ const task = session.tasks.find((t) => t.id === taskId);
3135
+ if (!task)
3136
+ throw new Error('No such task.');
3137
+ const notYet = undoRefusalOf(task);
3138
+ const refused = notYet ?? (await this.restoreRefusal(session));
3139
+ const own = await undoThisPreview(session, task);
3140
+ const back = await this.restartPlan(sessionId, taskId);
3141
+ return {
3142
+ this: refused ? { ...own, ok: false, problem: refused } : own,
3143
+ back: refused ? { ...back, ok: false, problem: refused } : back,
3144
+ ...(refused ? { problem: refused } : {}),
3145
+ };
3146
+ }
3147
+ /**
3148
+ * Undoes a task that ran. `this`: its work is taken out of the session's line by a revert (per-session),
3149
+ * or the folder is put back at its start (per-task); the task goes back to the queue. `back`: "Run again
3150
+ * from here" without the run — the line goes back to the task's start and it and the tasks after it are
3151
+ * queued. Nothing is started and nothing in git is deleted: the old branch or the reverted commits stay.
3152
+ */
3153
+ async undoTask(sessionId, taskId, how) {
3154
+ await this.init();
3155
+ const session = await this.store.getSession(sessionId);
3156
+ if (!session)
3157
+ throw new Error('No such session.');
3158
+ const task = session.tasks.find((t) => t.id === taskId);
3159
+ if (!task)
3160
+ throw new Error('No such task.');
3161
+ const notYet = undoRefusalOf(task);
3162
+ if (notYet)
3163
+ return { ok: false, problem: notYet };
3164
+ const refused = await this.restoreRefusal(session);
3165
+ if (refused)
3166
+ return { ok: false, problem: refused };
3167
+ const cfg = await this.settings.load();
3168
+ const at = new Date().toISOString();
3169
+ if (how === 'back') {
3170
+ const plan = await this.restartPlan(sessionId, taskId);
3171
+ const r = await this.restartFrom(sessionId, taskId, { restore: true, start: false });
3172
+ if (r.reason && r.requeued === 0)
3173
+ return { ok: false, problem: r.reason };
3174
+ for (const t of plan.tasks.filter((x) => !x.alreadyQueued)) {
3175
+ await this.store
3176
+ .updateSession(t.sessionId, (s) => {
3177
+ const last = s.tasks.find((x) => x.id === t.taskId)?.attempts?.at(-1);
3178
+ if (last)
3179
+ last.undone = { how: 'back', at, ...(r.restored[0] ? { branch: r.restored[0] } : {}), ...(t.taskId !== taskId ? { byTask: taskId } : {}) };
3180
+ })
3181
+ .catch(() => undefined);
3182
+ }
3183
+ 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`;
3184
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'undo', message, data: { taskId, how, requeued: r.requeued, restored: r.restored } }).catch(() => undefined);
3185
+ this.bus.publish({ sessionId, taskId, type: 'task-undone', level: 'info', message, data: { how, requeued: r.requeued } });
3186
+ return { ok: true, message, requeued: r.requeued };
3187
+ }
3188
+ let done = {};
3189
+ if (session.vcs?.enabled && session.vcs.branchMode === 'per-task') {
3190
+ const preview = await undoThisPreview(session, task);
3191
+ if (!preview.ok)
3192
+ return { ok: false, problem: preview.problem };
3193
+ if (!preview.nothing) {
3194
+ const back = await restoreToBase(session, task, this.bus);
3195
+ if (!back.ok)
3196
+ return { ok: false, problem: back.problem };
3197
+ done = { branch: back.branch };
3198
+ }
3199
+ else
3200
+ done = { nothing: preview.nothing };
3201
+ }
3202
+ else {
3203
+ const r = await revertTaskWork(session, task, this.bus);
3204
+ if (!r.ok)
3205
+ return { ok: false, problem: r.problem };
3206
+ done = { commit: r.commit, branch: r.branch, nothing: r.nothing };
3207
+ }
3208
+ await this.store.rerunTask(sessionId, taskId, {});
3209
+ await this.store.updateSession(sessionId, (s) => {
3210
+ const last = s.tasks.find((x) => x.id === taskId)?.attempts?.at(-1);
3211
+ if (last)
3212
+ last.undone = { how: 'this', at, ...(done.commit ? { commit: done.commit } : {}), ...(done.branch ? { branch: done.branch } : {}) };
3213
+ });
3214
+ const message = done.commit
3215
+ ? `took the work of "${task.title}" out of ${done.branch} in ${done.commit.slice(0, 8)}; the task is queued again, nothing was started`
3216
+ : done.branch
3217
+ ? `put the folder back as it was before "${task.title}", on ${done.branch}; its own branch is kept, the task is queued again`
3218
+ : `${done.nothing ?? 'nothing in git to take out'} The task is queued again.`;
3219
+ await appendSessionLog(cfg.resolved.runsDir, sessionId, { type: 'undo', message, data: { taskId, how, ...done } }).catch(() => undefined);
3220
+ this.bus.publish({ sessionId, taskId, type: 'task-undone', level: 'info', message, data: { how, ...done } });
3221
+ return { ok: true, message, requeued: 1 };
3222
+ }
2805
3223
  async restore(sessionId, taskId) {
2806
3224
  await this.init();
2807
3225
  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"