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.
- package/dist/src/api/operator.controller.js +159 -6
- package/dist/src/api/operator.service.js +417 -10
- package/dist/src/api/server.js +16 -0
- package/dist/src/cli.js +12 -0
- package/dist/src/config/schema.js +17 -0
- package/dist/src/orchestrator/planMode.js +102 -0
- package/dist/src/orchestrator/review.js +8 -4
- package/dist/src/orchestrator/taskRunner.js +246 -40
- package/dist/src/plan/brief.js +69 -20
- package/dist/src/plan/changes.js +217 -0
- package/dist/src/plan/importPlan.js +2 -0
- package/dist/src/plan/schema.js +11 -1
- package/dist/src/plan/systemGuide.js +790 -14
- package/dist/src/protocol/parser.js +26 -2
- package/dist/src/protocol/replySchema.js +28 -4
- package/dist/src/session/compose.js +76 -7
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/model.js +38 -0
- package/dist/src/session/store.js +90 -3
- package/dist/src/vcs/startChoice.js +80 -8
- package/dist/src/vcs/taskVcs.js +126 -8
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/WmzBz4QN36pcz4PL_cfEW/_buildManifest.js +1 -0
- package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
- package/dist/web/_next/static/chunks/549-2415dd26f4e53d07.js +1 -0
- package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
- package/dist/web/_next/static/chunks/867-aea30f560bc18eaf.js +1 -0
- package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-0bb537407fed5944.js +1 -0
- package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
- package/dist/web/_next/static/chunks/app/layout-dc602b727480a653.js +1 -0
- package/dist/web/_next/static/chunks/app/page-d73a8ff300862cd8.js +1 -0
- package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
- package/dist/web/_next/static/css/{cea7913ba416dbb7.css → 72c85e4322c21223.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +25 -28
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +25 -28
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +25 -28
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +25 -28
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +25 -28
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +25 -28
- package/dist/web/presets.html +3 -3
- package/dist/web/presets.txt +25 -28
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +25 -28
- package/dist/web/sessions.html +16 -0
- package/dist/web/sessions.txt +26 -0
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +25 -28
- package/package.json +161 -158
- package/dist/web/_next/static/-htN5GTSrd3W3f5j_QC2y/_buildManifest.js +0 -1
- package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +0 -1
- package/dist/web/_next/static/chunks/549-f295c8429e83b25b.js +0 -1
- package/dist/web/_next/static/chunks/584-578be94a0786334b.js +0 -1
- package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-879158e9e550eea7.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +0 -1
- /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
|
-
|
|
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);
|
package/dist/src/api/server.js
CHANGED
|
@@ -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"
|