copilot-operator 0.1.1 → 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.
Files changed (57) hide show
  1. package/dist/src/api/operator.controller.js +44 -0
  2. package/dist/src/api/operator.service.js +48 -13
  3. package/dist/src/config/schema.js +8 -2
  4. package/dist/src/orchestrator/taskRunner.js +26 -13
  5. package/dist/src/plan/importPlan.js +3 -2
  6. package/dist/src/plan/systemGuide.js +56 -0
  7. package/dist/src/session/compose.js +21 -2
  8. package/dist/src/session/store.js +51 -1
  9. package/dist/src/transport/chatSession.js +16 -4
  10. package/dist/src/vcs/taskVcs.js +5 -0
  11. package/dist/web/404.html +2 -2
  12. package/dist/web/_next/static/chunks/549-2086c05135bc9753.js +1 -0
  13. package/dist/web/_next/static/chunks/867-5b104743e24a4e2f.js +1 -0
  14. package/dist/web/_next/static/chunks/955-cf291b8a60315c58.js +1 -0
  15. package/dist/web/_next/static/chunks/app/defaults/page-559af121c43127a1.js +1 -0
  16. package/dist/web/_next/static/chunks/app/history/{page-bad4f3e89b485719.js → page-0df90ab9998815db.js} +1 -1
  17. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +1 -0
  18. package/dist/web/_next/static/chunks/app/layout-92fa876afe619cf3.js +1 -0
  19. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +1 -0
  20. package/dist/web/_next/static/chunks/app/page-050cf6f71187e8af.js +1 -0
  21. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +1 -0
  22. package/dist/web/_next/static/chunks/app/sessions/view/page-4d31690c22c8b331.js +1 -0
  23. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +1 -0
  24. package/dist/web/_next/static/css/{d77735cdc9020a86.css → 5a10c60adf9c5336.css} +1 -1
  25. package/dist/web/appearance.html +2 -2
  26. package/dist/web/appearance.txt +26 -25
  27. package/dist/web/defaults.html +2 -2
  28. package/dist/web/defaults.txt +26 -25
  29. package/dist/web/history.html +2 -2
  30. package/dist/web/history.txt +26 -25
  31. package/dist/web/import.html +2 -2
  32. package/dist/web/import.txt +26 -25
  33. package/dist/web/index.html +2 -2
  34. package/dist/web/index.txt +26 -25
  35. package/dist/web/level1.html +2 -2
  36. package/dist/web/level1.txt +26 -25
  37. package/dist/web/presets.html +2 -2
  38. package/dist/web/presets.txt +26 -25
  39. package/dist/web/sessions/view.html +2 -2
  40. package/dist/web/sessions/view.txt +26 -25
  41. package/dist/web/system.html +2 -2
  42. package/dist/web/system.txt +26 -25
  43. package/package.json +5 -3
  44. package/prompts/level1.md +17 -7
  45. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +0 -1
  46. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +0 -1
  47. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +0 -1
  48. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +0 -1
  49. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +0 -1
  50. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +0 -1
  51. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +0 -1
  52. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +0 -1
  53. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +0 -1
  54. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +0 -1
  55. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +0 -1
  56. /package/dist/web/_next/static/{tMxWYVV0hUR9GoQZ96wFL → eWzbORCbQ1h_2IeLH_njb}/_buildManifest.js +0 -0
  57. /package/dist/web/_next/static/{tMxWYVV0hUR9GoQZ96wFL → eWzbORCbQ1h_2IeLH_njb}/_ssgManifest.js +0 -0
@@ -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);
@@ -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
- const result = await importPlan(this.store, check.plan, (cfg.copilot.defaultModel ?? '').trim(), (cfg.copilot.defaultReviewModel ?? '').trim(), persona);
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] },
@@ -306,8 +306,14 @@ export const RunConfigSchema = z.object({
306
306
  .prefault({}),
307
307
  limits: z
308
308
  .object({
309
- maxIterations: z.number().int().positive().default(30),
310
- maxRunMinutes: z.number().int().positive().default(120),
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
- 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
  }
@@ -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.slice(0, 15), session.name);
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.review?.model ?? '').trim();
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?.trim()) {
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?.trim()) {
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 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',
@@ -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
- async rerunTask(sessionId, taskId, patch = {}) {
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/<runId>/<label>
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 run id ties the chat to the transcript on disk. The label is
29
- * whatever the run config calls the task.
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 run id are kept intact
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) {
@@ -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;