copilot-operator 0.1.0 → 0.1.2
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/README.md +13 -0
- package/dist/src/api/operator.controller.js +44 -0
- package/dist/src/api/operator.service.js +48 -13
- package/dist/src/config/layout.js +15 -4
- package/dist/src/config/schema.js +8 -2
- package/dist/src/orchestrator/taskRunner.js +26 -13
- package/dist/src/plan/importPlan.js +3 -2
- package/dist/src/plan/systemGuide.js +56 -0
- package/dist/src/session/compose.js +21 -2
- package/dist/src/session/store.js +51 -1
- package/dist/src/transport/chatSession.js +16 -4
- package/dist/src/vcs/taskVcs.js +5 -0
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-2086c05135bc9753.js +1 -0
- package/dist/web/_next/static/chunks/867-5b104743e24a4e2f.js +1 -0
- package/dist/web/_next/static/chunks/955-cf291b8a60315c58.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-559af121c43127a1.js +1 -0
- package/dist/web/_next/static/chunks/app/history/{page-bad4f3e89b485719.js → page-0df90ab9998815db.js} +1 -1
- package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-92fa876afe619cf3.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +1 -0
- package/dist/web/_next/static/chunks/app/page-050cf6f71187e8af.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-4d31690c22c8b331.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +1 -0
- package/dist/web/_next/static/css/{d77735cdc9020a86.css → 5a10c60adf9c5336.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +26 -25
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +26 -25
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +26 -25
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +26 -25
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +26 -25
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +26 -25
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +26 -25
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +26 -25
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +26 -25
- package/package.json +5 -3
- package/prompts/level1.md +17 -7
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +0 -1
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +0 -1
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +0 -1
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +0 -1
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +0 -1
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +0 -1
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +0 -1
- /package/dist/web/_next/static/{u8rZNwh4d3n-HGJnfZ72Y → eWzbORCbQ1h_2IeLH_njb}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{u8rZNwh4d3n-HGJnfZ72Y → eWzbORCbQ1h_2IeLH_njb}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -345,6 +345,19 @@ clean tree. The interface ships prebuilt and is served by the same process on on
|
|
|
345
345
|
second server and nothing is compiled on the machine. `npx cop start --port 4100` picks another
|
|
346
346
|
port. `npm update copilot-operator` updates it; `npm run update` is for a clone.
|
|
347
347
|
|
|
348
|
+
**Keeping it out of the project's repository.** A dev dependency is recorded in `package.json`
|
|
349
|
+
and the lock file, which are committed. To leave the project untouched, install it globally and
|
|
350
|
+
start it from the project's folder — the folder it is started from is the project:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
npm install --global copilot-operator
|
|
354
|
+
cd C:\path\to\your-project
|
|
355
|
+
cop start --open
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Nothing in the project changes except `.copilot-operator/`, which git does not see. The bot's own
|
|
359
|
+
code is then outside the folders its steps may write, which is the safer of the two.
|
|
360
|
+
|
|
348
361
|
The bot is then inside the folders its own steps may write, so a step that reaches for
|
|
349
362
|
`node_modules/copilot-operator`, for `.copilot-operator`, or that installs, updates or removes the
|
|
350
363
|
bot as a package is refused. What no rule can see is a step that rewrites the project's
|
|
@@ -80,6 +80,17 @@ let OperatorController = class OperatorController {
|
|
|
80
80
|
await this.ops.deletePreset(name);
|
|
81
81
|
return { ok: true };
|
|
82
82
|
}
|
|
83
|
+
// --- named personas (the import page's second box, saved under a name) -----------------
|
|
84
|
+
personas() {
|
|
85
|
+
return this.ops.listPersonas();
|
|
86
|
+
}
|
|
87
|
+
savePersona(name, body) {
|
|
88
|
+
return this.ops.savePersona(name, body?.content ?? '').catch(fail);
|
|
89
|
+
}
|
|
90
|
+
async deletePersona(name) {
|
|
91
|
+
await this.ops.deletePersona(name).catch(fail);
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
83
94
|
// --- sessions ---------------------------------------------------------------------------
|
|
84
95
|
sessions() {
|
|
85
96
|
return this.ops.listSessions();
|
|
@@ -117,6 +128,10 @@ let OperatorController = class OperatorController {
|
|
|
117
128
|
* A body with `title`, `prompt` or `level2` edits the task on the way: the attempt that
|
|
118
129
|
* already ran keeps the text it ran with, and the next one uses the new text.
|
|
119
130
|
*/
|
|
131
|
+
/** A task that stopped at the runner's limit carries on in its own conversation and branch. */
|
|
132
|
+
continueTask(id, taskId) {
|
|
133
|
+
return this.ops.continueTask(id, taskId).catch(fail);
|
|
134
|
+
}
|
|
120
135
|
rerunTask(id, taskId, body) {
|
|
121
136
|
return this.ops.rerunTask(id, taskId, body ?? {}).catch(fail);
|
|
122
137
|
}
|
|
@@ -547,6 +562,27 @@ __decorate([
|
|
|
547
562
|
__metadata("design:paramtypes", [String]),
|
|
548
563
|
__metadata("design:returntype", Promise)
|
|
549
564
|
], OperatorController.prototype, "deletePreset", null);
|
|
565
|
+
__decorate([
|
|
566
|
+
Get('personas'),
|
|
567
|
+
__metadata("design:type", Function),
|
|
568
|
+
__metadata("design:paramtypes", []),
|
|
569
|
+
__metadata("design:returntype", Promise)
|
|
570
|
+
], OperatorController.prototype, "personas", null);
|
|
571
|
+
__decorate([
|
|
572
|
+
Put('personas/:name'),
|
|
573
|
+
__param(0, Param('name')),
|
|
574
|
+
__param(1, Body()),
|
|
575
|
+
__metadata("design:type", Function),
|
|
576
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
577
|
+
__metadata("design:returntype", Promise)
|
|
578
|
+
], OperatorController.prototype, "savePersona", null);
|
|
579
|
+
__decorate([
|
|
580
|
+
Delete('personas/:name'),
|
|
581
|
+
__param(0, Param('name')),
|
|
582
|
+
__metadata("design:type", Function),
|
|
583
|
+
__metadata("design:paramtypes", [String]),
|
|
584
|
+
__metadata("design:returntype", Promise)
|
|
585
|
+
], OperatorController.prototype, "deletePersona", null);
|
|
550
586
|
__decorate([
|
|
551
587
|
Get('sessions'),
|
|
552
588
|
__metadata("design:type", Function),
|
|
@@ -607,6 +643,14 @@ __decorate([
|
|
|
607
643
|
__metadata("design:paramtypes", [String, String]),
|
|
608
644
|
__metadata("design:returntype", Promise)
|
|
609
645
|
], OperatorController.prototype, "deleteTask", null);
|
|
646
|
+
__decorate([
|
|
647
|
+
Post('sessions/:id/tasks/:taskId/continue'),
|
|
648
|
+
__param(0, Param('id')),
|
|
649
|
+
__param(1, Param('taskId')),
|
|
650
|
+
__metadata("design:type", Function),
|
|
651
|
+
__metadata("design:paramtypes", [String, String]),
|
|
652
|
+
__metadata("design:returntype", Promise)
|
|
653
|
+
], OperatorController.prototype, "continueTask", null);
|
|
610
654
|
__decorate([
|
|
611
655
|
Post('sessions/:id/tasks/:taskId/rerun'),
|
|
612
656
|
__param(0, Param('id')),
|
|
@@ -133,6 +133,19 @@ let OperatorService = class OperatorService {
|
|
|
133
133
|
await this.init();
|
|
134
134
|
await this.store.deletePreset(name);
|
|
135
135
|
}
|
|
136
|
+
// --- named personas for the import page -----------------------------------------------
|
|
137
|
+
async listPersonas() {
|
|
138
|
+
await this.init();
|
|
139
|
+
return await this.store.listPersonas();
|
|
140
|
+
}
|
|
141
|
+
async savePersona(name, content) {
|
|
142
|
+
await this.init();
|
|
143
|
+
return await this.store.savePersona(name, content);
|
|
144
|
+
}
|
|
145
|
+
async deletePersona(name) {
|
|
146
|
+
await this.init();
|
|
147
|
+
await this.store.deletePersona(name);
|
|
148
|
+
}
|
|
136
149
|
// --- sessions and tasks ---------------------------------------------------------------
|
|
137
150
|
async listSessions() {
|
|
138
151
|
await this.init();
|
|
@@ -152,20 +165,17 @@ let OperatorService = class OperatorService {
|
|
|
152
165
|
if (mirror)
|
|
153
166
|
assertMirrorIsCoherent(mirror);
|
|
154
167
|
const session = await this.store.createSession(name, mirror);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
/*
|
|
169
|
+
* The models are not written onto a new session. A session with no model of its own uses the
|
|
170
|
+
* one chosen in Settings at the time it runs (see `effectiveModels` in taskRunner.ts), so
|
|
171
|
+
* choosing a model there reaches every such session, the ones created before it included.
|
|
172
|
+
* It used to be copied here, which meant a model chosen after an import reached nothing.
|
|
173
|
+
*/
|
|
158
174
|
const cfg = await this.settings.load();
|
|
159
|
-
const defaultModel = (cfg.copilot.defaultModel ?? '').trim();
|
|
160
|
-
const defaultReviewModel = (cfg.copilot.defaultReviewModel ?? '').trim();
|
|
161
175
|
const projectDir = (cfg.project?.rootDir ?? '').trim();
|
|
162
|
-
if (!
|
|
176
|
+
if (!projectDir)
|
|
163
177
|
return session;
|
|
164
178
|
return await this.store.updateSession(session.id, (s) => {
|
|
165
|
-
if (defaultModel)
|
|
166
|
-
s.model = defaultModel;
|
|
167
|
-
if (defaultReviewModel)
|
|
168
|
-
s.review = { enabled: s.review?.enabled !== false, model: defaultReviewModel };
|
|
169
179
|
if (projectDir) {
|
|
170
180
|
// Both fields, because they answer different questions about the same folder: where the
|
|
171
181
|
// files are and where the branches go. Neither is switched on by being filled in.
|
|
@@ -262,6 +272,20 @@ let OperatorService = class OperatorService {
|
|
|
262
272
|
* the text it ran with, and only then is the new text applied. Editing in place would leave
|
|
263
273
|
* the old attempt's summary sitting under a question that was never asked.
|
|
264
274
|
*/
|
|
275
|
+
/** "Continue" on a task that stopped at the runner's limit: same chat, same branch, fresh count. */
|
|
276
|
+
async continueTask(sessionId, taskId) {
|
|
277
|
+
await this.init();
|
|
278
|
+
const task = await this.store.continueTask(sessionId, taskId);
|
|
279
|
+
this.bus.publish({
|
|
280
|
+
sessionId,
|
|
281
|
+
taskId,
|
|
282
|
+
type: 'task-requeued',
|
|
283
|
+
level: 'info',
|
|
284
|
+
message: `"${task.title}" is queued to continue where it stopped (attempt ${task.attempt ?? 1})`,
|
|
285
|
+
data: { attempt: task.attempt, continuing: true },
|
|
286
|
+
});
|
|
287
|
+
return task;
|
|
288
|
+
}
|
|
265
289
|
async rerunTask(sessionId, taskId, patch = {}) {
|
|
266
290
|
await this.init();
|
|
267
291
|
const task = await this.store.rerunTask(sessionId, taskId, patch);
|
|
@@ -798,17 +822,27 @@ let OperatorService = class OperatorService {
|
|
|
798
822
|
const wantedModel = model?.trim();
|
|
799
823
|
const wantedReviewModel = reviewModel?.trim();
|
|
800
824
|
if (wantedModel || wantedReviewModel) {
|
|
825
|
+
/*
|
|
826
|
+
* The panel starts on the models chosen in Settings. A session that follows Settings and is
|
|
827
|
+
* run on exactly that model is left following it, so choosing another model in Settings
|
|
828
|
+
* later still reaches it; only a different choice, or a session with a model of its own,
|
|
829
|
+
* is written.
|
|
830
|
+
*/
|
|
831
|
+
const settingsNow = await this.settings.load();
|
|
832
|
+
const followModel = (settingsNow.copilot.defaultModel ?? '').trim();
|
|
833
|
+
const followReview = (settingsNow.copilot.defaultReviewModel ?? '').trim();
|
|
801
834
|
for (const entry of sessions) {
|
|
802
835
|
if (entry.state !== 'waiting')
|
|
803
836
|
continue;
|
|
804
837
|
await this.store.updateSession(entry.sessionId, (s) => {
|
|
805
|
-
if (wantedModel)
|
|
838
|
+
if (wantedModel && (s.model?.trim() || wantedModel !== followModel))
|
|
806
839
|
s.model = wantedModel;
|
|
807
840
|
// Only the model is set here, never whether the review happens: a run panel is about
|
|
808
841
|
// this run, and silently switching a session's review on or off from it would be a
|
|
809
842
|
// change to the session that outlives the run.
|
|
810
|
-
if (wantedReviewModel)
|
|
843
|
+
if (wantedReviewModel && (s.review?.model?.trim() || wantedReviewModel !== followReview)) {
|
|
811
844
|
s.review = { ...DEFAULT_REVIEW, ...s.review, model: wantedReviewModel };
|
|
845
|
+
}
|
|
812
846
|
});
|
|
813
847
|
}
|
|
814
848
|
}
|
|
@@ -1206,7 +1240,8 @@ let OperatorService = class OperatorService {
|
|
|
1206
1240
|
* the language asked for; only the shipped example does.
|
|
1207
1241
|
*/
|
|
1208
1242
|
const persona = (await this.getContext('persona', 'en')).content;
|
|
1209
|
-
|
|
1243
|
+
// No default models passed: a session the plan gives no model follows Settings at run time.
|
|
1244
|
+
const result = await importPlan(this.store, check.plan, '', '', persona);
|
|
1210
1245
|
return {
|
|
1211
1246
|
ok: true,
|
|
1212
1247
|
result: { ...result, warnings: [...check.warnings, ...result.warnings] },
|
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
* and the UI is the prebuilt one shipped in the package, served by the API itself.
|
|
11
11
|
*
|
|
12
12
|
* Which one is decided by where the code is: inside a `node_modules` folder is a package. The
|
|
13
|
-
* project a package
|
|
14
|
-
*
|
|
13
|
+
* project a package serves is the folder that holds that `node_modules` when it is started from in
|
|
14
|
+
* there — a dev dependency — and otherwise the folder it is started from: a global install
|
|
15
|
+
* (`npm i -g copilot-operator`, then `cop start` in the project) or an `npx` run keep the code in a
|
|
16
|
+
* `node_modules` that belongs to no project, and leave the project's package.json untouched.
|
|
17
|
+
* `COP_PROJECT_ROOT` overrides both.
|
|
15
18
|
*
|
|
16
19
|
* Everything that used to reach for `process.cwd()` or the clone's root to find a prompt, the
|
|
17
20
|
* data folder or the runs folder asks here instead, so the two installs cannot disagree about
|
|
18
21
|
* where a thing is.
|
|
19
22
|
*/
|
|
20
23
|
import { existsSync } from 'node:fs';
|
|
21
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
24
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
22
25
|
import { botRootDir } from '../exec/workDir.js';
|
|
23
26
|
/** The name of the records folder a package keeps inside the project. */
|
|
24
27
|
export const HOME_FOLDER = '.copilot-operator';
|
|
@@ -33,13 +36,21 @@ function projectOfPackage(botRoot) {
|
|
|
33
36
|
return dirname(dirname(parent));
|
|
34
37
|
return null;
|
|
35
38
|
}
|
|
39
|
+
function isInside(dir, root) {
|
|
40
|
+
const a = resolve(dir).toLowerCase();
|
|
41
|
+
const b = resolve(root).toLowerCase();
|
|
42
|
+
return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
|
|
43
|
+
}
|
|
36
44
|
export function installLayout(env = process.env, cwd = process.cwd(), botRoot = botRootDir()) {
|
|
37
45
|
const promptsDir = join(botRoot, 'prompts');
|
|
38
46
|
const shippedWeb = join(botRoot, 'dist', 'web');
|
|
39
47
|
const webDir = existsSync(join(shippedWeb, 'index.html')) ? shippedWeb : null;
|
|
40
48
|
const packaged = projectOfPackage(botRoot);
|
|
41
49
|
if (packaged) {
|
|
42
|
-
|
|
50
|
+
// A dev dependency serves the project around it; a global or npx install serves wherever it
|
|
51
|
+
// was started. Checked by folder, so a start from a subfolder of the project still finds it.
|
|
52
|
+
const owner = isInside(cwd, packaged) ? packaged : cwd;
|
|
53
|
+
const projectRoot = resolve(env.COP_PROJECT_ROOT ?? owner);
|
|
43
54
|
const homeDir = join(projectRoot, HOME_FOLDER);
|
|
44
55
|
return {
|
|
45
56
|
mode: 'package',
|
|
@@ -306,8 +306,14 @@ export const RunConfigSchema = z.object({
|
|
|
306
306
|
.prefault({}),
|
|
307
307
|
limits: z
|
|
308
308
|
.object({
|
|
309
|
-
|
|
310
|
-
|
|
309
|
+
/*
|
|
310
|
+
* Per task, and a guard against a task that loops, not against message volume: that is
|
|
311
|
+
* `pacing.maxMessagesPerHour`, which waits rather than stops. Raised from 30 and 120 on
|
|
312
|
+
* 2026-09-28 at the operator's decision — Copilot keeps a long conversation in view, and a
|
|
313
|
+
* task cut off at 30 had to start over. Both are set in Settings → Execution.
|
|
314
|
+
*/
|
|
315
|
+
maxIterations: z.number().int().positive().default(60),
|
|
316
|
+
maxRunMinutes: z.number().int().positive().default(240),
|
|
311
317
|
maxFormatRetries: z.number().int().nonnegative().default(2),
|
|
312
318
|
maxMessageChars: z.number().int().positive().default(100_000),
|
|
313
319
|
/**
|
|
@@ -14,7 +14,7 @@ import { createHash } from 'node:crypto';
|
|
|
14
14
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { CopilotTransport } from '../transport/copilotTransport.js';
|
|
17
|
-
import { buildChatName, savePointer } from '../transport/chatSession.js';
|
|
17
|
+
import { buildChatName, chatCode, savePointer } from '../transport/chatSession.js';
|
|
18
18
|
import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
|
|
19
19
|
import { resolveDeviations, describeDeviations, mergeDisputes, describeDisputes } from '../protocol/replySchema.js';
|
|
20
20
|
import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
|
|
@@ -263,10 +263,22 @@ export async function openSessionTransport(cfg, session, bus, runsDir) {
|
|
|
263
263
|
* set to, and the session records that, so the register shows which model did the work rather
|
|
264
264
|
* than which one was asked for.
|
|
265
265
|
*/
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
/**
|
|
267
|
+
* The models a session runs on: its own, or else the ones chosen in Settings at this moment.
|
|
268
|
+
* A session gets a model of its own from the plan or from its own picker; one that has none
|
|
269
|
+
* follows Settings, so a model chosen there after the session was made still reaches it.
|
|
270
|
+
*/
|
|
271
|
+
export function effectiveModels(session, cfg) {
|
|
272
|
+
return {
|
|
273
|
+
model: (session.model ?? '').trim() || (cfg.copilot.defaultModel ?? '').trim(),
|
|
274
|
+
reviewModel: (session.review?.model ?? '').trim() || (cfg.copilot.defaultReviewModel ?? '').trim(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async function applySessionModel(transport, session, bus, cfg) {
|
|
278
|
+
const wanted = effectiveModels(session, cfg).model;
|
|
279
|
+
if (!wanted)
|
|
268
280
|
return undefined;
|
|
269
|
-
const result = await transport.selectModel(
|
|
281
|
+
const result = await transport.selectModel(wanted).catch((e) => ({
|
|
270
282
|
ok: false,
|
|
271
283
|
current: null,
|
|
272
284
|
reason: e.message,
|
|
@@ -276,9 +288,9 @@ async function applySessionModel(transport, session, bus) {
|
|
|
276
288
|
type: result.ok ? 'model-selected' : 'model-not-selected',
|
|
277
289
|
level: result.ok ? 'info' : 'warn',
|
|
278
290
|
message: result.ok
|
|
279
|
-
? `model: ${result.current ?? session.model}`
|
|
280
|
-
: `could not switch to "${
|
|
281
|
-
data: { asked: session.model, current: result.current, ok: result.ok },
|
|
291
|
+
? `model: ${result.current ?? wanted}${session.model?.trim() ? '' : ' (from Settings)'}`
|
|
292
|
+
: `could not switch to "${wanted}": ${result.reason ?? 'unknown reason'}. Continuing on ${result.current ?? 'the chat default'}.`,
|
|
293
|
+
data: { asked: wanted, fromSettings: !session.model?.trim(), current: result.current, ok: result.ok },
|
|
282
294
|
});
|
|
283
295
|
return result.current ?? undefined;
|
|
284
296
|
}
|
|
@@ -587,6 +599,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
587
599
|
taskTitle: task.title,
|
|
588
600
|
taskNumber,
|
|
589
601
|
contractAlreadySent: session.contractSent,
|
|
602
|
+
continuing: task.continuing,
|
|
590
603
|
workDirNote: workingDirNote(work),
|
|
591
604
|
vcsNote: prepared.note,
|
|
592
605
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
@@ -613,7 +626,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
613
626
|
if (!session.chat) {
|
|
614
627
|
const chatId = await transport.currentChatId();
|
|
615
628
|
if (chatId) {
|
|
616
|
-
const name = buildChatName(session.id
|
|
629
|
+
const name = buildChatName(chatCode(session.id), session.name);
|
|
617
630
|
await transport.nameChat(chatId, name).catch(() => false);
|
|
618
631
|
const chat = {
|
|
619
632
|
chatId,
|
|
@@ -810,7 +823,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
810
823
|
return 'accept';
|
|
811
824
|
}
|
|
812
825
|
reviewRounds += 1;
|
|
813
|
-
const model = (session
|
|
826
|
+
const model = effectiveModels(session, cfg).reviewModel;
|
|
814
827
|
const repoDir = session.vcs?.enabled ? (session.vcs.repoDir ?? '').trim() : '';
|
|
815
828
|
/*
|
|
816
829
|
* What changed, read from git rather than from anybody's account of it.
|
|
@@ -1462,8 +1475,8 @@ export async function runSession(sessionId, deps) {
|
|
|
1462
1475
|
const chat = transport;
|
|
1463
1476
|
// The picker belongs to the conversation, so the session's choice is applied once, here,
|
|
1464
1477
|
// before the first task goes out. What the chat ended up on is recorded either way.
|
|
1465
|
-
const modelInUse = await applySessionModel(chat, session, bus);
|
|
1466
|
-
if (session.model
|
|
1478
|
+
const modelInUse = await applySessionModel(chat, session, bus, cfg);
|
|
1479
|
+
if (effectiveModels(session, cfg).model) {
|
|
1467
1480
|
await store.updateSession(sessionId, (s) => {
|
|
1468
1481
|
s.modelInUse = modelInUse;
|
|
1469
1482
|
});
|
|
@@ -1527,8 +1540,8 @@ export async function runSession(sessionId, deps) {
|
|
|
1527
1540
|
* it was the model the session asked for. The review has always done this for its own
|
|
1528
1541
|
* fresh conversations a few hundred lines up; this path simply never did.
|
|
1529
1542
|
*/
|
|
1530
|
-
const freshModel = await applySessionModel(chat, session, bus);
|
|
1531
|
-
if (session.model
|
|
1543
|
+
const freshModel = await applySessionModel(chat, session, bus, cfg);
|
|
1544
|
+
if (effectiveModels(session, cfg).model) {
|
|
1532
1545
|
session = await store.updateSession(sessionId, (s) => {
|
|
1533
1546
|
s.modelInUse = freshModel;
|
|
1534
1547
|
});
|
|
@@ -139,8 +139,9 @@ function unavailableShellWarnings(plan) {
|
|
|
139
139
|
/**
|
|
140
140
|
* Creates everything the plan describes.
|
|
141
141
|
*
|
|
142
|
-
* `defaultModel` is
|
|
143
|
-
*
|
|
142
|
+
* `defaultModel` is written onto a session the plan names no model for. The service passes
|
|
143
|
+
* nothing: such a session follows the model chosen in Settings at the time it runs, like one made
|
|
144
|
+
* by hand in the UI. Kept as a parameter for a caller that wants the old, copied behaviour.
|
|
144
145
|
*/
|
|
145
146
|
export async function importPlan(store, plan, defaultModel = '', defaultReviewModel = '',
|
|
146
147
|
/** The operator's persona at the moment of import, written into every task's level 2. */
|
|
@@ -225,6 +225,38 @@ export const SYSTEM_GUIDE = [
|
|
|
225
225
|
en: 'How the tasks are carried out: the agent\'s persona',
|
|
226
226
|
bg: 'Как се изпълняват задачите: персоната на агента',
|
|
227
227
|
controls: [
|
|
228
|
+
{
|
|
229
|
+
key: 'plan.personaSaved',
|
|
230
|
+
kind: 'select',
|
|
231
|
+
en: 'Saved personas',
|
|
232
|
+
bg: 'Запазени персони',
|
|
233
|
+
doesEn: 'Chooses a saved persona and puts it in the box below, which is saved at once; the choice shows which saved persona the box matches.',
|
|
234
|
+
doesBg: 'Избира запазена персона и я поставя в полето отдолу, което се запазва веднага; изборът показва на коя запазена персона отговаря полето.',
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
key: 'plan.personaNamePlaceholder',
|
|
238
|
+
kind: 'field',
|
|
239
|
+
en: 'a name for the persona in the box, e.g. playwright-tests',
|
|
240
|
+
bg: 'име за персоната в полето, напр. playwright-tests',
|
|
241
|
+
doesEn: 'The name to keep the persona in the box under.',
|
|
242
|
+
doesBg: 'Името, под което да се запази персоната от полето.',
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
key: 'plan.personaSaveAs',
|
|
246
|
+
kind: 'button',
|
|
247
|
+
en: 'Save under this name',
|
|
248
|
+
bg: 'Запази под това име',
|
|
249
|
+
doesEn: 'Keeps a copy of the persona in the box under that name, replacing one of the same name after asking.',
|
|
250
|
+
doesBg: 'Запазва копие на персоната от полето под това име, като пита, преди да замени персона със същото име.',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
key: 'plan.personaDelete',
|
|
254
|
+
kind: 'button',
|
|
255
|
+
en: 'Delete the chosen one',
|
|
256
|
+
bg: 'Изтрий избраната',
|
|
257
|
+
doesEn: 'Deletes the saved persona the box matches; the box itself is not changed.',
|
|
258
|
+
doesBg: 'Изтрива запазената персона, на която отговаря полето; самото поле не се променя.',
|
|
259
|
+
},
|
|
228
260
|
{
|
|
229
261
|
key: 'plan.ctxClear',
|
|
230
262
|
kind: 'button',
|
|
@@ -913,6 +945,14 @@ export const SYSTEM_GUIDE = [
|
|
|
913
945
|
doesEn: "Opens the form for that task's title, level 2, task text, git names and checks.",
|
|
914
946
|
doesBg: 'Отваря формата за заглавието, ниво 2, текста, git имената и проверките на задачата.',
|
|
915
947
|
},
|
|
948
|
+
{
|
|
949
|
+
key: 'task.continue',
|
|
950
|
+
kind: 'button',
|
|
951
|
+
en: 'Continue in the same chat',
|
|
952
|
+
bg: 'Продължи в същия чат',
|
|
953
|
+
doesEn: 'Only on a task that stopped as limit reached: queues it to carry on in the same chat and on the same branch, with a fresh count of messages.',
|
|
954
|
+
doesBg: 'Само на задача, спряла като достигнат лимит: връща я в опашката да продължи в същия чат и клон, с нов брояч на съобщенията.',
|
|
955
|
+
},
|
|
916
956
|
{
|
|
917
957
|
key: 'task.rerun',
|
|
918
958
|
kind: 'button',
|
|
@@ -1628,6 +1668,22 @@ export const SYSTEM_GUIDE = [
|
|
|
1628
1668
|
en: 'Execution',
|
|
1629
1669
|
bg: 'Изпълнение',
|
|
1630
1670
|
controls: [
|
|
1671
|
+
{
|
|
1672
|
+
key: 'exec.maxIterations',
|
|
1673
|
+
kind: 'field',
|
|
1674
|
+
en: 'Most messages to the chat in one task',
|
|
1675
|
+
bg: 'Най-много съобщения към чата в една задача',
|
|
1676
|
+
doesEn: 'How many rounds one task may take (default 60) before it stops as limit reached; saved as you type.',
|
|
1677
|
+
doesBg: 'Колко кръга може да отнеме една задача (по подразбиране 60), преди да спре като достигнат лимит; запазва се, докато пишете.',
|
|
1678
|
+
},
|
|
1679
|
+
{
|
|
1680
|
+
key: 'exec.maxRunMinutes',
|
|
1681
|
+
kind: 'field',
|
|
1682
|
+
en: 'Longest a task may run',
|
|
1683
|
+
bg: 'Най-дълго време за една задача',
|
|
1684
|
+
doesEn: 'How many minutes one task may run (default 240) before it stops as limit reached; saved as you type.',
|
|
1685
|
+
doesBg: 'Колко минути може да върви една задача (по подразбиране 240), преди да спре като достигнат лимит; запазва се, докато пишете.',
|
|
1686
|
+
},
|
|
1631
1687
|
{
|
|
1632
1688
|
key: 'exec.retryBlocked',
|
|
1633
1689
|
kind: 'field',
|
|
@@ -52,8 +52,27 @@ function level2Block(level2) {
|
|
|
52
52
|
*/
|
|
53
53
|
export function composeOpening(input) {
|
|
54
54
|
const taskBlock = `${TASK_HEADER} ${input.taskNumber}: ${input.taskTitle.trim() || 'untitled'}\n\n${input.prompt.trim()}`;
|
|
55
|
+
/*
|
|
56
|
+
* Carrying on in the same conversation: the assignment is already in it, so it is not sent
|
|
57
|
+
* again — sending it would read as a new task and start the work over. Only when the
|
|
58
|
+
* conversation was lost does the task go out in full, with a line saying it is a continuation.
|
|
59
|
+
*/
|
|
60
|
+
if (input.continuing && input.contractAlreadySent) {
|
|
61
|
+
const why = input.continuing.stoppedBecause ? ` (${input.continuing.stoppedBecause})` : '';
|
|
62
|
+
const message = `Continue task ${input.taskNumber}: ${input.taskTitle.trim() || 'untitled'}, in this same conversation. ` +
|
|
63
|
+
`It stopped because the runner's limit was reached${why} — not because anything failed. ` +
|
|
64
|
+
`The files are as you left them. Do not start over and do not repeat work that is done: start from ` +
|
|
65
|
+
`your plan, say in \`notes\` which stages are done and which one you are on, and carry on. ` +
|
|
66
|
+
`The assignment is the one you were given above; it has not changed. Step numbering restarts at 1.\n\n` +
|
|
67
|
+
`${runnerBlock(input)}`.trimEnd();
|
|
68
|
+
return { messages: [message], firstMessage: message };
|
|
69
|
+
}
|
|
70
|
+
const continuationLine = input.continuing
|
|
71
|
+
? `\n\nThis continues an earlier attempt at this task that stopped at the runner's limit. The files are as ` +
|
|
72
|
+
`that attempt left them: look at what is already done before changing anything, and carry on from there.`
|
|
73
|
+
: '';
|
|
55
74
|
if (!input.contractAlreadySent) {
|
|
56
|
-
const taskMessage = `${runnerBlock(input)}${level2Block(input.level2)}\n\n${taskBlock}`;
|
|
75
|
+
const taskMessage = `${runnerBlock(input)}${level2Block(input.level2)}\n\n${taskBlock}${continuationLine}`;
|
|
57
76
|
return {
|
|
58
77
|
messages: [input.level1.trim(), taskMessage],
|
|
59
78
|
firstMessage: `${input.level1.trim()}\n\n---\n\n${taskMessage}`,
|
|
@@ -62,6 +81,6 @@ export function composeOpening(input) {
|
|
|
62
81
|
const reminder = `New task in this same conversation. The level 1 contract you received at the start of ` +
|
|
63
82
|
`this conversation still applies unchanged: same format, same rules, same stop word, ` +
|
|
64
83
|
`and a full "summary" when you finish. Step numbering restarts at 1.`;
|
|
65
|
-
const taskMessage = `${reminder}\n\n${runnerBlock(input)}${level2Block(input.level2)}\n\n${taskBlock}`;
|
|
84
|
+
const taskMessage = `${reminder}\n\n${runnerBlock(input)}${level2Block(input.level2)}\n\n${taskBlock}${continuationLine}`;
|
|
66
85
|
return { messages: [taskMessage], firstMessage: taskMessage };
|
|
67
86
|
}
|
|
@@ -47,6 +47,8 @@ export class SessionStore {
|
|
|
47
47
|
dir;
|
|
48
48
|
sessionsDir;
|
|
49
49
|
presetsDir;
|
|
50
|
+
/** Named personas the operator keeps for the import page, one file each. */
|
|
51
|
+
personasDir;
|
|
50
52
|
level1Path;
|
|
51
53
|
modelsPath;
|
|
52
54
|
constructor(dataDir, defaultLevel1Path) {
|
|
@@ -54,6 +56,7 @@ export class SessionStore {
|
|
|
54
56
|
this.dir = resolve(dataDir);
|
|
55
57
|
this.sessionsDir = join(this.dir, 'sessions');
|
|
56
58
|
this.presetsDir = join(this.dir, 'level2');
|
|
59
|
+
this.personasDir = join(this.dir, 'personas');
|
|
57
60
|
this.level1Path = join(this.dir, 'level1.md');
|
|
58
61
|
this.modelsPath = join(this.dir, 'models.json');
|
|
59
62
|
}
|
|
@@ -134,6 +137,36 @@ export class SessionStore {
|
|
|
134
137
|
async deletePreset(name) {
|
|
135
138
|
await rm(join(this.presetsDir, `${safePresetName(name)}.md`), { force: true });
|
|
136
139
|
}
|
|
140
|
+
// --- named personas -------------------------------------------------------------------
|
|
141
|
+
//
|
|
142
|
+
// "How the tasks are carried out" is one box on the import page, and the persona in it depends
|
|
143
|
+
// on the kind of work: a Playwright suite wants a different agent from an API service. These
|
|
144
|
+
// are saved copies of that box under a name, so switching is one choice instead of pasting a
|
|
145
|
+
// JSON back in. The box itself (`context-persona.md`) stays the one that is in force.
|
|
146
|
+
async listPersonas() {
|
|
147
|
+
const names = (await readdir(this.personasDir).catch(() => []))
|
|
148
|
+
.filter((n) => n.endsWith('.md'))
|
|
149
|
+
.sort((a, b) => a.localeCompare(b));
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const n of names) {
|
|
152
|
+
const path = join(this.personasDir, n);
|
|
153
|
+
const content = await readFile(path, 'utf8');
|
|
154
|
+
const { mtime } = await import('node:fs').then((fs) => fs.statSync(path));
|
|
155
|
+
out.push({ name: n.slice(0, -3), content, updatedAt: mtime.toISOString() });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
async savePersona(name, content) {
|
|
160
|
+
const safe = name.replace(/[^\p{L}\p{N}._ -]/gu, '').trim();
|
|
161
|
+
if (!safe)
|
|
162
|
+
throw new Error('The persona name is empty after removing unsafe characters.');
|
|
163
|
+
await mkdir(this.personasDir, { recursive: true });
|
|
164
|
+
await this.atomicWrite(join(this.personasDir, `${safePresetName(safe)}.md`), content);
|
|
165
|
+
return { name: safe, content, updatedAt: new Date().toISOString() };
|
|
166
|
+
}
|
|
167
|
+
async deletePersona(name) {
|
|
168
|
+
await rm(join(this.personasDir, `${safePresetName(name)}.md`), { force: true });
|
|
169
|
+
}
|
|
137
170
|
// --- sessions -------------------------------------------------------------------------
|
|
138
171
|
/**
|
|
139
172
|
* Every session, newest first — by when it was made, not by what it is called.
|
|
@@ -258,7 +291,23 @@ export class SessionStore {
|
|
|
258
291
|
* the retry destroys exactly the thing that made the retry interesting. The run folder of
|
|
259
292
|
* the old attempt is untouched too, because the next attempt gets a folder of its own.
|
|
260
293
|
*/
|
|
261
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Queues a task that stopped at the runner's limit to carry on where it stopped: the same
|
|
296
|
+
* conversation, the same branch, a fresh count of messages. The attempt that stopped is kept on
|
|
297
|
+
* the record like any other.
|
|
298
|
+
*/
|
|
299
|
+
async continueTask(sessionId, taskId) {
|
|
300
|
+
const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
|
|
301
|
+
if (!current)
|
|
302
|
+
throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
|
|
303
|
+
if (current.status !== 'limit-reached') {
|
|
304
|
+
throw new Error('Only a task that stopped at the runner\'s limit can be continued; run this one again instead.');
|
|
305
|
+
}
|
|
306
|
+
return await this.rerunTask(sessionId, taskId, {}, { fromAttempt: current.attempt ?? 1, stoppedBecause: current.reason });
|
|
307
|
+
}
|
|
308
|
+
async rerunTask(sessionId, taskId, patch = {},
|
|
309
|
+
/** Present when the new attempt carries on from the last one (see `continueTask`). */
|
|
310
|
+
continuing) {
|
|
262
311
|
let result;
|
|
263
312
|
await this.updateSession(sessionId, (s) => {
|
|
264
313
|
const t = s.tasks.find((x) => x.id === taskId);
|
|
@@ -304,6 +353,7 @@ export class SessionStore {
|
|
|
304
353
|
t.attempt = (t.attempt ?? 1) + 1;
|
|
305
354
|
t.status = 'queued';
|
|
306
355
|
t.iterations = 0;
|
|
356
|
+
t.continuing = continuing;
|
|
307
357
|
// Cleared so the next run starts from nothing and gets its own run folder.
|
|
308
358
|
t.runId = undefined;
|
|
309
359
|
t.runGroup = undefined;
|
|
@@ -19,16 +19,28 @@ import { dirname } from 'node:path';
|
|
|
19
19
|
/** The UI rejects anything longer. Verified live: "Your Copilot chat name can't be longer than 50 characters". */
|
|
20
20
|
export const MAX_CHAT_NAME = 50;
|
|
21
21
|
export const CHAT_URL_RE = /\/chat\/conversation\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
22
|
+
/**
|
|
23
|
+
* The short code a session's chats are named with: the random tail of its id
|
|
24
|
+
* (`20260928-073140-ujdt` gives `ujdt`), which the session page's address shows too.
|
|
25
|
+
*
|
|
26
|
+
* It replaced the date and time (`20260928-073140`), which took 15 of the 50 characters Copilot
|
|
27
|
+
* allows and left the session's own name 31; with the code it keeps 42 (asked for 2026-09-28).
|
|
28
|
+
* An id without such a tail gives its last six letters and digits.
|
|
29
|
+
*/
|
|
30
|
+
export function chatCode(sessionId) {
|
|
31
|
+
const tail = /-([a-z0-9]{3,8})$/i.exec(sessionId)?.[1];
|
|
32
|
+
return (tail ?? sessionId.replace(/[^a-z0-9]/gi, '').slice(-6)).toLowerCase();
|
|
33
|
+
}
|
|
22
34
|
/**
|
|
23
35
|
* Builds the chat name. Shape:
|
|
24
36
|
*
|
|
25
|
-
* op/<
|
|
37
|
+
* op/<code>/<label>
|
|
26
38
|
*
|
|
27
39
|
* The `op/` prefix makes every bot chat greppable in the sidebar and separates them from
|
|
28
|
-
* the user's own chats. The
|
|
29
|
-
*
|
|
40
|
+
* the user's own chats. The code ties the chat to its session (see `chatCode`). The label is
|
|
41
|
+
* the session's name.
|
|
30
42
|
*
|
|
31
|
-
* The whole thing is squeezed into 50 characters: the prefix and
|
|
43
|
+
* The whole thing is squeezed into 50 characters: the prefix and the code are kept intact
|
|
32
44
|
* because they are what makes the chat findable, and the label absorbs the truncation.
|
|
33
45
|
*/
|
|
34
46
|
export function buildChatName(runId, label) {
|
package/dist/src/vcs/taskVcs.js
CHANGED
|
@@ -57,6 +57,11 @@ export async function prepareForTask(session, task, bus, saveSession) {
|
|
|
57
57
|
const wanted = plannedBranch
|
|
58
58
|
? plannedBranchName(plannedBranch, prefix, attempt)
|
|
59
59
|
: branchNameFrom([session.name, task.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
|
|
60
|
+
// A continuation carries on where the previous attempt stopped, on that attempt's own branch:
|
|
61
|
+
// its work is what is being continued. See `continueTask` in the store.
|
|
62
|
+
const previousBranch = task.continuing ? task.attempts?.at(-1)?.vcs?.branch : undefined;
|
|
63
|
+
if (previousBranch)
|
|
64
|
+
return await switchTo(session, task, dir, previousBranch, base, bus, { reuseExisting: true });
|
|
60
65
|
// A re-run starts from where that task started the first time, not from where the previous
|
|
61
66
|
// attempt ended. That is the whole point of recording the base commit.
|
|
62
67
|
const from = firstAttemptBase(task) ?? base;
|