copilot-operator 0.1.1 → 0.1.3
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 +5 -1
- package/dist/src/api/operator.controller.js +44 -0
- package/dist/src/api/operator.service.js +50 -13
- package/dist/src/cli.js +1 -0
- package/dist/src/config/schema.js +33 -2
- package/dist/src/exec/authorizer.js +18 -8
- package/dist/src/exec/policyManifest.js +16 -2
- package/dist/src/orchestrator/taskRunner.js +27 -13
- package/dist/src/plan/importPlan.js +3 -2
- package/dist/src/plan/systemGuide.js +78 -6
- 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-55627c5122ff6336.js +1 -0
- package/dist/web/_next/static/chunks/89-5585315f51e4c481.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-4ff25cafe6de779c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-2232949a2ff8559e.js +1 -0
- 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-f04d6e00182ccc76.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-9dda275f4b1b0423.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/history/page-bad4f3e89b485719.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/{tMxWYVV0hUR9GoQZ96wFL → QwjyyBjkO5xlKFU5O6Wux}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{tMxWYVV0hUR9GoQZ96wFL → QwjyyBjkO5xlKFU5O6Wux}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -82,7 +82,11 @@ file run. What `Start-Process` is asked to start is judged like any other progra
|
|
|
82
82
|
`Invoke-WebRequest`, `Invoke-RestMethod`, `curl`, `wget`, `Start-BitsTransfer`, the .NET web
|
|
83
83
|
clients, `ftp`/`scp`, a package named by URL or git address, `npx` of a tool not installed in the
|
|
84
84
|
project, `docker pull` — is neither refused nor run: it waits on the approval screen, in every
|
|
85
|
-
mode, unattended included, and "run the rest without asking" does not answer it.
|
|
85
|
+
mode, unattended included, and "run the rest without asking" does not answer it. That is the
|
|
86
|
+
default, and the one stop an unattended run still makes; `execution.networkFetch` (Settings →
|
|
87
|
+
Execution) may instead **refuse** it back to the chat with the reason, so the run never waits, or
|
|
88
|
+
**run** it unread on a machine where that is acceptable. The choice applies only to unattended
|
|
89
|
+
runs, and each task's policy manifest records which was in force. A check that
|
|
86
90
|
fetches is refused, since a check runs with nobody asked. Requests to this machine (`localhost`,
|
|
87
91
|
`127.0.0.1`) and the project's own package managers are not held. `npm install` runs with
|
|
88
92
|
`ignore-scripts`: a package's install script is code fetched and executed unread, and it does not
|
|
@@ -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);
|
|
@@ -594,6 +618,8 @@ let OperatorService = class OperatorService {
|
|
|
594
618
|
allowedPrograms: cfg.execution.allowedPrograms,
|
|
595
619
|
isolation: cfg.execution.isolation,
|
|
596
620
|
lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
|
|
621
|
+
// Read once per run like the rest of the policy: a change in Settings applies from the next start.
|
|
622
|
+
networkFetch: cfg.execution.networkFetch,
|
|
597
623
|
};
|
|
598
624
|
/*
|
|
599
625
|
* An unattended run that cannot legally execute anything is stopped here, where the operator
|
|
@@ -798,17 +824,27 @@ let OperatorService = class OperatorService {
|
|
|
798
824
|
const wantedModel = model?.trim();
|
|
799
825
|
const wantedReviewModel = reviewModel?.trim();
|
|
800
826
|
if (wantedModel || wantedReviewModel) {
|
|
827
|
+
/*
|
|
828
|
+
* The panel starts on the models chosen in Settings. A session that follows Settings and is
|
|
829
|
+
* run on exactly that model is left following it, so choosing another model in Settings
|
|
830
|
+
* later still reaches it; only a different choice, or a session with a model of its own,
|
|
831
|
+
* is written.
|
|
832
|
+
*/
|
|
833
|
+
const settingsNow = await this.settings.load();
|
|
834
|
+
const followModel = (settingsNow.copilot.defaultModel ?? '').trim();
|
|
835
|
+
const followReview = (settingsNow.copilot.defaultReviewModel ?? '').trim();
|
|
801
836
|
for (const entry of sessions) {
|
|
802
837
|
if (entry.state !== 'waiting')
|
|
803
838
|
continue;
|
|
804
839
|
await this.store.updateSession(entry.sessionId, (s) => {
|
|
805
|
-
if (wantedModel)
|
|
840
|
+
if (wantedModel && (s.model?.trim() || wantedModel !== followModel))
|
|
806
841
|
s.model = wantedModel;
|
|
807
842
|
// Only the model is set here, never whether the review happens: a run panel is about
|
|
808
843
|
// this run, and silently switching a session's review on or off from it would be a
|
|
809
844
|
// change to the session that outlives the run.
|
|
810
|
-
if (wantedReviewModel)
|
|
845
|
+
if (wantedReviewModel && (s.review?.model?.trim() || wantedReviewModel !== followReview)) {
|
|
811
846
|
s.review = { ...DEFAULT_REVIEW, ...s.review, model: wantedReviewModel };
|
|
847
|
+
}
|
|
812
848
|
});
|
|
813
849
|
}
|
|
814
850
|
}
|
|
@@ -1206,7 +1242,8 @@ let OperatorService = class OperatorService {
|
|
|
1206
1242
|
* the language asked for; only the shipped example does.
|
|
1207
1243
|
*/
|
|
1208
1244
|
const persona = (await this.getContext('persona', 'en')).content;
|
|
1209
|
-
|
|
1245
|
+
// No default models passed: a session the plan gives no model follows Settings at run time.
|
|
1246
|
+
const result = await importPlan(this.store, check.plan, '', '', persona);
|
|
1210
1247
|
return {
|
|
1211
1248
|
ok: true,
|
|
1212
1249
|
result: { ...result, warnings: [...check.warnings, ...result.warnings] },
|
package/dist/src/cli.js
CHANGED
|
@@ -484,6 +484,7 @@ program
|
|
|
484
484
|
allowedPrograms: cfg.execution.allowedPrograms,
|
|
485
485
|
isolation: cfg.execution.isolation,
|
|
486
486
|
lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
|
|
487
|
+
networkFetch: cfg.execution.networkFetch,
|
|
487
488
|
};
|
|
488
489
|
const authorizer = cfg.execution.mode === 'unattended'
|
|
489
490
|
? unattendedAuthorizer(policy)
|
|
@@ -117,7 +117,32 @@ export const RunConfigSchema = z.object({
|
|
|
117
117
|
task: TextOrFile.optional(),
|
|
118
118
|
execution: z
|
|
119
119
|
.object({
|
|
120
|
+
/*
|
|
121
|
+
* For the terminal, the mode `cop run` uses without `--unattended`. For the API it is the
|
|
122
|
+
* operator's standing choice from Settings: `unattended` means an unattended start is not
|
|
123
|
+
* questioned again with "are you sure" — they said so once, on the Settings page, and asking
|
|
124
|
+
* at every press is exactly the interruption they switched off. Every start still names its
|
|
125
|
+
* own mode; this never turns a step-by-step press into an unattended run.
|
|
126
|
+
*/
|
|
120
127
|
mode: z.enum(['confirm', 'unattended']).default('confirm'),
|
|
128
|
+
/**
|
|
129
|
+
* What an unattended run does with a step that fetches from the network. See `network.ts`.
|
|
130
|
+
*
|
|
131
|
+
* ask it waits on the approval screen for the operator, as it did before this setting
|
|
132
|
+
* existed — the one question an unattended run still asks.
|
|
133
|
+
* refuse it is not run, and the chat is told why at once and what to do instead, so the
|
|
134
|
+
* run never stops to wait for anybody. The terminal's unattended form has always
|
|
135
|
+
* done this, because it has nobody to ask.
|
|
136
|
+
* run it runs like every other step, with nobody having read it.
|
|
137
|
+
*
|
|
138
|
+
* `ask` is the default because the hold exists for a reason — a security team found
|
|
139
|
+
* model-written steps staging and running code on a work laptop — and a run that can fetch
|
|
140
|
+
* and then execute what arrived, unread, is the thing it was built to prevent. `run` is the
|
|
141
|
+
* operator's to choose on a machine where that is acceptable, and it is recorded in every
|
|
142
|
+
* task's policy manifest as exactly that. A step-by-step run is unaffected: every step
|
|
143
|
+
* there, a fetch included, is put in front of a person anyway.
|
|
144
|
+
*/
|
|
145
|
+
networkFetch: z.enum(['ask', 'refuse', 'run']).default('ask'),
|
|
121
146
|
/**
|
|
122
147
|
* The shell a step or a check runs in when it names none of its own.
|
|
123
148
|
*
|
|
@@ -306,8 +331,14 @@ export const RunConfigSchema = z.object({
|
|
|
306
331
|
.prefault({}),
|
|
307
332
|
limits: z
|
|
308
333
|
.object({
|
|
309
|
-
|
|
310
|
-
|
|
334
|
+
/*
|
|
335
|
+
* Per task, and a guard against a task that loops, not against message volume: that is
|
|
336
|
+
* `pacing.maxMessagesPerHour`, which waits rather than stops. Raised from 30 and 120 on
|
|
337
|
+
* 2026-09-28 at the operator's decision — Copilot keeps a long conversation in view, and a
|
|
338
|
+
* task cut off at 30 had to start over. Both are set in Settings → Execution.
|
|
339
|
+
*/
|
|
340
|
+
maxIterations: z.number().int().positive().default(60),
|
|
341
|
+
maxRunMinutes: z.number().int().positive().default(240),
|
|
311
342
|
maxFormatRetries: z.number().int().nonnegative().default(2),
|
|
312
343
|
maxMessageChars: z.number().int().positive().default(100_000),
|
|
313
344
|
/**
|
|
@@ -6,11 +6,14 @@ import { stdin, stdout } from 'node:process';
|
|
|
6
6
|
/**
|
|
7
7
|
* Runs the static rules, then, unless unattended, asks the human through `ask`.
|
|
8
8
|
*
|
|
9
|
-
* A step that fetches from the network is the exception to "unless unattended": it is
|
|
10
|
-
* in every mode, with `held` saying why, because the runs nobody is watching are
|
|
11
|
-
* in which a download followed by a second step that runs it would otherwise go
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* A step that fetches from the network is the exception to "unless unattended": by default it is
|
|
10
|
+
* asked about in every mode, with `held` saying why, because the runs nobody is watching are
|
|
11
|
+
* exactly the ones in which a download followed by a second step that runs it would otherwise go
|
|
12
|
+
* unseen. The operator may choose otherwise for unattended runs (`cfg.networkFetch`): `refuse`
|
|
13
|
+
* answers the chat at once instead of waiting for anybody, `run` treats the fetch like any other
|
|
14
|
+
* step. Only for unattended runs — a watched run shows the fetch like every other step, and the
|
|
15
|
+
* person reading it is the whole point of watching. `cfg` is read on every call, not captured, so
|
|
16
|
+
* a run switched to unattended half-way is judged as one from its next step on.
|
|
14
17
|
*/
|
|
15
18
|
export function makeAuthorizer(cfg, ask) {
|
|
16
19
|
return {
|
|
@@ -20,8 +23,13 @@ export function makeAuthorizer(cfg, ask) {
|
|
|
20
23
|
return blocked;
|
|
21
24
|
const cwd = ctx.confinement?.cwd;
|
|
22
25
|
const network = networkFetchReason(step.cmd, cwd) ?? (cwd ? scriptNetworkReason(step.cmd, cwd) : null);
|
|
23
|
-
if (network)
|
|
26
|
+
if (network) {
|
|
27
|
+
if (cfg.mode === 'unattended' && cfg.networkFetch === 'refuse')
|
|
28
|
+
return { action: 'skip', reason: networkFetchRefusal(network) };
|
|
29
|
+
if (cfg.mode === 'unattended' && cfg.networkFetch === 'run')
|
|
30
|
+
return { action: 'run' };
|
|
24
31
|
return await ask(step, ctx, { network });
|
|
32
|
+
}
|
|
25
33
|
if (cfg.mode === 'unattended')
|
|
26
34
|
return { action: 'run' };
|
|
27
35
|
return await ask(step, ctx);
|
|
@@ -53,8 +61,10 @@ export function terminalAuthorizer(cfg, print) {
|
|
|
53
61
|
* Everything runs after the static rules. Only for runs the user explicitly marked so.
|
|
54
62
|
*
|
|
55
63
|
* A step held for a person (a network fetch) is refused here, with the reason, because this form
|
|
56
|
-
* has nobody to ask
|
|
57
|
-
*
|
|
64
|
+
* has nobody to ask — `ask` and `refuse` come to the same thing at a terminal nobody is at. Only
|
|
65
|
+
* `networkFetch: run` lets one through, and that is decided in `makeAuthorizer` before this is
|
|
66
|
+
* reached. The web UI does not use it: its unattended runs keep the approval screen for exactly
|
|
67
|
+
* those steps.
|
|
58
68
|
*/
|
|
59
69
|
export function unattendedAuthorizer(cfg) {
|
|
60
70
|
return makeAuthorizer({ ...cfg, mode: 'unattended' }, async (_step, _ctx, held) => held ? { action: 'skip', reason: networkFetchRefusal(held.network) } : { action: 'run' });
|
|
@@ -31,7 +31,21 @@ export function digestOf(values) {
|
|
|
31
31
|
const canonical = JSON.stringify([...values].map((v) => v.trim()).filter(Boolean).sort());
|
|
32
32
|
return createHash('sha256').update(canonical).digest('hex').slice(0, 12);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
const FETCHES = 'a command that fetches from the network (Invoke-WebRequest, Invoke-RestMethod, curl, wget, Start-BitsTransfer, .NET web clients, ftp/scp)';
|
|
35
|
+
const NOT_HELD = "Requests to localhost and the project's package managers are not held";
|
|
36
|
+
/**
|
|
37
|
+
* The manifest's sentence about downloads, for this mode and the operator's choice. A step-by-step
|
|
38
|
+
* run shows every step to a person, so the choice changes nothing there and is not mentioned.
|
|
39
|
+
*/
|
|
40
|
+
export function networkFetchStatement(mode, choice = 'ask') {
|
|
41
|
+
if (mode === 'confirm')
|
|
42
|
+
return `${FETCHES} is shown to the operator with a note that it downloads, like every step of this run. ${NOT_HELD}`;
|
|
43
|
+
if (choice === 'refuse')
|
|
44
|
+
return `${FETCHES} is refused back to the chat without running (execution.networkFetch: refuse). ${NOT_HELD}`;
|
|
45
|
+
if (choice === 'run')
|
|
46
|
+
return `${FETCHES} RUNS WITHOUT ANYONE READING IT — the operator chose this (execution.networkFetch: run). Only the deny list and the built-in refusals applied`;
|
|
47
|
+
return `${FETCHES} is held for the operator even in this unattended run (execution.networkFetch: ask); with nobody to ask it is refused. ${NOT_HELD}`;
|
|
48
|
+
}
|
|
35
49
|
export function collectPolicyManifest(input, env = process.env) {
|
|
36
50
|
const names = DANGEROUS_TECHNIQUES.map((t) => t.name);
|
|
37
51
|
return {
|
|
@@ -46,7 +60,7 @@ export function collectPolicyManifest(input, env = process.env) {
|
|
|
46
60
|
programs: [...input.allowedPrograms],
|
|
47
61
|
},
|
|
48
62
|
chatFiles: 'the chat cannot supply a file: this runner has no file step and executes nothing it did not receive as a command',
|
|
49
|
-
networkFetch:
|
|
63
|
+
networkFetch: networkFetchStatement(input.mode, input.networkFetch),
|
|
50
64
|
denyPatterns: { count: input.denyPatterns.length, digest: digestOf(input.denyPatterns) },
|
|
51
65
|
builtIn: { count: names.length, digest: digestOf(names), names },
|
|
52
66
|
lock: input.lock ?? { applied: false, changes: [] },
|
|
@@ -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
|
}
|
|
@@ -513,6 +525,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
513
525
|
isolation,
|
|
514
526
|
confinedTo: confinement.roots,
|
|
515
527
|
mode: deps.currentMode?.() ?? cfg.execution.mode,
|
|
528
|
+
networkFetch: cfg.execution.networkFetch,
|
|
516
529
|
allowedPrograms: cfg.execution.allowedPrograms,
|
|
517
530
|
denyPatterns: cfg.execution.denyPatterns,
|
|
518
531
|
cwd: work.cwd,
|
|
@@ -587,6 +600,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
587
600
|
taskTitle: task.title,
|
|
588
601
|
taskNumber,
|
|
589
602
|
contractAlreadySent: session.contractSent,
|
|
603
|
+
continuing: task.continuing,
|
|
590
604
|
workDirNote: workingDirNote(work),
|
|
591
605
|
vcsNote: prepared.note,
|
|
592
606
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
@@ -613,7 +627,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
613
627
|
if (!session.chat) {
|
|
614
628
|
const chatId = await transport.currentChatId();
|
|
615
629
|
if (chatId) {
|
|
616
|
-
const name = buildChatName(session.id
|
|
630
|
+
const name = buildChatName(chatCode(session.id), session.name);
|
|
617
631
|
await transport.nameChat(chatId, name).catch(() => false);
|
|
618
632
|
const chat = {
|
|
619
633
|
chatId,
|
|
@@ -810,7 +824,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
810
824
|
return 'accept';
|
|
811
825
|
}
|
|
812
826
|
reviewRounds += 1;
|
|
813
|
-
const model = (session
|
|
827
|
+
const model = effectiveModels(session, cfg).reviewModel;
|
|
814
828
|
const repoDir = session.vcs?.enabled ? (session.vcs.repoDir ?? '').trim() : '';
|
|
815
829
|
/*
|
|
816
830
|
* What changed, read from git rather than from anybody's account of it.
|
|
@@ -1462,8 +1476,8 @@ export async function runSession(sessionId, deps) {
|
|
|
1462
1476
|
const chat = transport;
|
|
1463
1477
|
// The picker belongs to the conversation, so the session's choice is applied once, here,
|
|
1464
1478
|
// 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
|
|
1479
|
+
const modelInUse = await applySessionModel(chat, session, bus, cfg);
|
|
1480
|
+
if (effectiveModels(session, cfg).model) {
|
|
1467
1481
|
await store.updateSession(sessionId, (s) => {
|
|
1468
1482
|
s.modelInUse = modelInUse;
|
|
1469
1483
|
});
|
|
@@ -1527,8 +1541,8 @@ export async function runSession(sessionId, deps) {
|
|
|
1527
1541
|
* it was the model the session asked for. The review has always done this for its own
|
|
1528
1542
|
* fresh conversations a few hundred lines up; this path simply never did.
|
|
1529
1543
|
*/
|
|
1530
|
-
const freshModel = await applySessionModel(chat, session, bus);
|
|
1531
|
-
if (session.model
|
|
1544
|
+
const freshModel = await applySessionModel(chat, session, bus, cfg);
|
|
1545
|
+
if (effectiveModels(session, cfg).model) {
|
|
1532
1546
|
session = await store.updateSession(sessionId, (s) => {
|
|
1533
1547
|
s.modelInUse = freshModel;
|
|
1534
1548
|
});
|
|
@@ -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',
|
|
@@ -549,16 +581,16 @@ export const SYSTEM_GUIDE = [
|
|
|
549
581
|
kind: 'button',
|
|
550
582
|
en: 'Run this and the rest without asking',
|
|
551
583
|
bg: 'Изпълни без да питаш повече',
|
|
552
|
-
doesEn: 'Runs it and stops asking about every command after it until this run ends, except a command that downloads from the internet. Not offered on such a command.',
|
|
553
|
-
doesBg: 'Изпълнява я и спира да пита за следващите команди до края на това пускане, освен за команда, която тегли от
|
|
584
|
+
doesEn: 'Runs it and stops asking about every command after it until this run ends, except a command that downloads from the internet, which follows the choice in Settings → Execution. Not offered on such a command.',
|
|
585
|
+
doesBg: 'Изпълнява я и спира да пита за следващите команди до края на това пускане, освен за команда, която тегли от интернет — за нея важи изборът в Настройки → Изпълнение. Не се предлага на такава команда.',
|
|
554
586
|
},
|
|
555
587
|
{
|
|
556
588
|
key: 'approval.network',
|
|
557
589
|
kind: 'note',
|
|
558
|
-
en: 'This command downloads from the internet.
|
|
559
|
-
bg: 'Тази команда тегли от интернет.
|
|
560
|
-
doesEn: 'Shown on a step that fetches from the network. Such a step waits here in every run, including one that does not ask; requests to localhost and package installs through npm or dotnet
|
|
561
|
-
doesBg: 'Показва се на стъпка, която тегли от мрежата. Такава стъпка чака тук при всяко пускане, включително без
|
|
590
|
+
en: 'This command downloads from the internet. A run that does not ask still stops for this one (unless Settings → Execution says otherwise), because a later step could run what arrived. Run it only if you know what it fetches and why.',
|
|
591
|
+
bg: 'Тази команда тегли от интернет. Пускане без питане също спира за нея (освен ако Настройки → Изпълнение не казва друго), защото следваща стъпка може да пусне изтегленото. Изпълнете я само ако знаете какво тегли и защо.',
|
|
592
|
+
doesEn: 'Shown on a step that fetches from the network. Such a step waits here in every run, including one that does not ask, unless Settings → Execution has chosen to refuse or run such commands in a run without supervision; requests to localhost and package installs through npm or dotnet never wait.',
|
|
593
|
+
doesBg: 'Показва се на стъпка, която тегли от мрежата. Такава стъпка чака тук при всяко пускане, включително без питане, освен ако в Настройки → Изпълнение не е избрано такива команди да се отказват или изпълняват при пускане без надзор; заявки към localhost и пакети през npm или dotnet никога не чакат.',
|
|
562
594
|
},
|
|
563
595
|
{
|
|
564
596
|
key: 'approval.skip',
|
|
@@ -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',
|
|
@@ -1644,6 +1700,22 @@ export const SYSTEM_GUIDE = [
|
|
|
1644
1700
|
doesEn: "The operator's statement about what contains this runner: this account, a separate low-privilege Windows account, Windows Sandbox, or a VM. It gates unattended runs — while it says this account, a run with nobody watching is refused, unless the operator chose \"this account — I accept unattended runs without isolation\", which allows it on their responsibility — and it is recorded with every run beside the account the process actually held.",
|
|
1645
1701
|
doesBg: 'Твърдението на оператора какво огражда този runner: този акаунт, отделен Windows акаунт с малки права, Windows Sandbox или виртуална машина. То управлява пусканията без надзор — докато казва „този акаунт“, пускане без човек се отказва, освен ако операторът не е избрал „в този акаунт — приемам пускане без надзор без изолация“, което го позволява на негова отговорност — и се записва с всяко пускане до акаунта, който процесът наистина е държал.',
|
|
1646
1702
|
},
|
|
1703
|
+
{
|
|
1704
|
+
key: 'exec.startMode',
|
|
1705
|
+
kind: 'select',
|
|
1706
|
+
en: 'Starting a run without supervision',
|
|
1707
|
+
bg: 'Пускане без надзор',
|
|
1708
|
+
doesEn: 'Whether the run buttons that work through the queue on their own ask "are you sure" first ("Ask \"are you sure\" every time", the default) or simply start ("Start it without asking"). Step by step stays available beside them either way.',
|
|
1709
|
+
doesBg: 'Дали бутоните, които минават през опашката сами, питат първо „сигурен ли си“ („Питай „сигурен ли си“ всеки път“, по подразбиране) или просто тръгват („Пускай, без да питаш“). „Стъпка по стъпка“ остава до тях и в двата случая.',
|
|
1710
|
+
},
|
|
1711
|
+
{
|
|
1712
|
+
key: 'exec.networkFetch',
|
|
1713
|
+
kind: 'select',
|
|
1714
|
+
en: 'A command that downloads, in a run without supervision',
|
|
1715
|
+
bg: 'Команда, която тегли от интернет, при пускане без надзор',
|
|
1716
|
+
doesEn: 'What a run without supervision does with Invoke-WebRequest, curl, wget and the like: wait for the operator on the approval screen (the default), refuse it and tell the chat why so the run never waits, or run it without anyone reading it. Recorded in every task. A step-by-step run shows every command whatever is chosen.',
|
|
1717
|
+
doesBg: 'Какво прави пускане без надзор с Invoke-WebRequest, curl, wget и подобни: чака оператора на екрана за одобрение (по подразбиране), отказва я и казва на чата защо, така че пускането никога не чака, или я изпълнява, без никой да я е прочел. Записва се във всяка задача. При „стъпка по стъпка“ всяка команда се показва, каквото и да е избрано.',
|
|
1718
|
+
},
|
|
1647
1719
|
],
|
|
1648
1720
|
},
|
|
1649
1721
|
],
|
|
@@ -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
|
}
|