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.
Files changed (62) hide show
  1. package/README.md +5 -1
  2. package/dist/src/api/operator.controller.js +44 -0
  3. package/dist/src/api/operator.service.js +50 -13
  4. package/dist/src/cli.js +1 -0
  5. package/dist/src/config/schema.js +33 -2
  6. package/dist/src/exec/authorizer.js +18 -8
  7. package/dist/src/exec/policyManifest.js +16 -2
  8. package/dist/src/orchestrator/taskRunner.js +27 -13
  9. package/dist/src/plan/importPlan.js +3 -2
  10. package/dist/src/plan/systemGuide.js +78 -6
  11. package/dist/src/session/compose.js +21 -2
  12. package/dist/src/session/store.js +51 -1
  13. package/dist/src/transport/chatSession.js +16 -4
  14. package/dist/src/vcs/taskVcs.js +5 -0
  15. package/dist/web/404.html +2 -2
  16. package/dist/web/_next/static/chunks/549-2086c05135bc9753.js +1 -0
  17. package/dist/web/_next/static/chunks/867-55627c5122ff6336.js +1 -0
  18. package/dist/web/_next/static/chunks/89-5585315f51e4c481.js +1 -0
  19. package/dist/web/_next/static/chunks/app/defaults/page-4ff25cafe6de779c.js +1 -0
  20. package/dist/web/_next/static/chunks/app/history/page-2232949a2ff8559e.js +1 -0
  21. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +1 -0
  22. package/dist/web/_next/static/chunks/app/layout-92fa876afe619cf3.js +1 -0
  23. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +1 -0
  24. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +1 -0
  25. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +1 -0
  26. package/dist/web/_next/static/chunks/app/sessions/view/page-9dda275f4b1b0423.js +1 -0
  27. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +1 -0
  28. package/dist/web/_next/static/css/{d77735cdc9020a86.css → 5a10c60adf9c5336.css} +1 -1
  29. package/dist/web/appearance.html +2 -2
  30. package/dist/web/appearance.txt +26 -25
  31. package/dist/web/defaults.html +2 -2
  32. package/dist/web/defaults.txt +26 -25
  33. package/dist/web/history.html +2 -2
  34. package/dist/web/history.txt +26 -25
  35. package/dist/web/import.html +2 -2
  36. package/dist/web/import.txt +26 -25
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/index.txt +26 -25
  39. package/dist/web/level1.html +2 -2
  40. package/dist/web/level1.txt +26 -25
  41. package/dist/web/presets.html +2 -2
  42. package/dist/web/presets.txt +26 -25
  43. package/dist/web/sessions/view.html +2 -2
  44. package/dist/web/sessions/view.txt +26 -25
  45. package/dist/web/system.html +2 -2
  46. package/dist/web/system.txt +26 -25
  47. package/package.json +5 -3
  48. package/prompts/level1.md +17 -7
  49. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +0 -1
  50. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +0 -1
  51. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +0 -1
  52. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +0 -1
  53. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +0 -1
  54. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +0 -1
  55. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +0 -1
  56. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +0 -1
  57. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +0 -1
  58. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +0 -1
  59. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +0 -1
  60. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +0 -1
  61. /package/dist/web/_next/static/{tMxWYVV0hUR9GoQZ96wFL → QwjyyBjkO5xlKFU5O6Wux}/_buildManifest.js +0 -0
  62. /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. A check that
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
- // A new session starts on the standing choice, if there is one. It is copied onto the
156
- // session rather than read at run time, so changing the default later cannot silently
157
- // change what an existing session does.
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 (!defaultModel && !defaultReviewModel && !projectDir)
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
- const result = await importPlan(this.store, check.plan, (cfg.copilot.defaultModel ?? '').trim(), (cfg.copilot.defaultReviewModel ?? '').trim(), persona);
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
- maxIterations: z.number().int().positive().default(30),
310
- maxRunMinutes: z.number().int().positive().default(120),
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 asked about
10
- * in every mode, with `held` saying why, because the runs nobody is watching are exactly the ones
11
- * in which a download followed by a second step that runs it would otherwise go unseen. `cfg` is
12
- * read on every call, not captured, so a run switched to unattended half-way is judged as one from
13
- * its next step on.
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. The web UI does not use it: its unattended runs keep the approval screen for
57
- * exactly those steps.
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
- export const NETWORK_FETCH = "a command that fetches from the network (Invoke-WebRequest, Invoke-RestMethod, curl, wget, Start-BitsTransfer, .NET web clients, ftp/scp) is held for the operator in every mode; with nobody to ask it is refused. Requests to localhost and the project's package managers are not held";
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: NETWORK_FETCH,
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
- async function applySessionModel(transport, session, bus) {
267
- if (!session.model?.trim())
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(session.model.trim()).catch((e) => ({
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 "${session.model}": ${result.reason ?? 'unknown reason'}. Continuing on ${result.current ?? 'the chat default'}.`,
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.slice(0, 15), session.name);
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.review?.model ?? '').trim();
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?.trim()) {
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?.trim()) {
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 what a session starts on when the plan does not name one, which keeps an
143
- * imported session behaving like one made by hand in the UI.
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. The bot never does that on its own, even in a run that does not ask, because a later step could run what arrived. Run it only if you know what it fetches and why.',
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 do not.',
561
- doesBg: 'Показва се на стъпка, която тегли от мрежата. Такава стъпка чака тук при всяко пускане, включително без питане; заявки към localhost и пакети през npm или dotnet не чакат.',
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
  }