copilot-operator 0.1.13 → 0.1.15

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 (77) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +31 -42
  3. package/dist/src/api/operator.service.js +663 -367
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +476 -163
  26. package/dist/src/plan/brief.js +37 -44
  27. package/dist/src/plan/importPlan.js +15 -7
  28. package/dist/src/plan/schema.js +64 -29
  29. package/dist/src/plan/script.js +0 -15
  30. package/dist/src/plan/systemGuide.js +2 -177
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +49 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/scope.js +29 -6
  37. package/dist/src/vcs/taskVcs.js +78 -10
  38. package/dist/web/404.html +2 -2
  39. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  40. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  41. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  42. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  43. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  44. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  45. package/dist/web/appearance.html +2 -2
  46. package/dist/web/appearance.txt +12 -12
  47. package/dist/web/defaults.html +2 -2
  48. package/dist/web/defaults.txt +12 -12
  49. package/dist/web/history.html +2 -2
  50. package/dist/web/history.txt +12 -12
  51. package/dist/web/import.html +2 -2
  52. package/dist/web/import.txt +12 -12
  53. package/dist/web/index.html +2 -2
  54. package/dist/web/index.txt +12 -12
  55. package/dist/web/level1.html +2 -2
  56. package/dist/web/level1.txt +12 -12
  57. package/dist/web/presets.html +2 -2
  58. package/dist/web/presets.txt +12 -12
  59. package/dist/web/sessions/view.html +2 -2
  60. package/dist/web/sessions/view.txt +12 -12
  61. package/dist/web/system.html +2 -2
  62. package/dist/web/system.txt +12 -12
  63. package/package.json +18 -10
  64. package/prompts/level1.md +2 -1
  65. package/prompts/organisation.bg.md +0 -1
  66. package/prompts/organisation.en.md +0 -1
  67. package/dist/src/context/contextFiles.js +0 -295
  68. package/dist/src/context/desktopMirror.js +0 -173
  69. package/dist/src/context/projectMirror.js +0 -432
  70. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  71. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  72. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  73. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
  74. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  75. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
  76. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  77. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
@@ -92,20 +92,29 @@ ${lines.join('\n')}
92
92
  * Whether a run with nobody watching can start on this machine — and when it cannot, what to press.
93
93
  *
94
94
  * Kerrigan's phase 2 script offers the unattended button first, because on a machine that allows it
95
- * that is the ordinary way to run. On one that does not — no isolation claimed, or no allowlist —
96
- * the runner refuses it at the entrance, and a persona that recommends it has sent the operator to
97
- * a button that says no. She cannot see the machine; the app can, so it tells her, and only when it
98
- * matters. The labels are the ones on the screens, so check:guide holds them.
95
+ * that is the ordinary way to run. On one that does not — a policy lock, no isolation claimed, or no
96
+ * allowlist — the runner refuses it at the entrance, and a persona that recommends it has sent the
97
+ * operator to a button that says no. She cannot see the machine; the app can, so it tells her, and
98
+ * only when it matters. The labels are the ones on the screens, so check:guide holds them.
99
99
  */
100
100
  function machineSection(unattendedBlocked, lang) {
101
101
  if (!unattendedBlocked)
102
102
  return '';
103
- const whyBg = unattendedBlocked === 'isolation'
104
- ? '**„Къде върви ботът“** в `/defaults` още казва, че ботът върви в акаунта на оператора, без изолация'
105
- : 'списъкът с позволени програми е празен, а без него нищо не ограничава стъпка, която никой не гледа';
106
- const whyEn = unattendedBlocked === 'isolation'
107
- ? '**"Where the bot runs"** on `/defaults` still says the bot runs in the operator\'s own account, with no isolation'
108
- : 'the list of allowed programs is empty, and without it nothing limits a step that nobody is watching';
103
+ /*
104
+ * Each reason named as it is, because each sends the operator somewhere different. A lock blamed
105
+ * on the allowlist sent them to fill in a list that was already full, and the run was refused all
106
+ * the same: the lock is the machine's, set beside the install, and no page changes it.
107
+ */
108
+ const whyBg = {
109
+ lock: 'файлът `policy.lock.json` до инсталацията на тази машина забранява пускания без надзор, и никоя настройка на страниците не го променя',
110
+ isolation: '**„Къде върви ботът“** в `/defaults` още казва, че ботът върви в акаунта на оператора, без изолация',
111
+ allowlist: 'списъкът с позволени програми е празен, а без него нищо не ограничава стъпка, която никой не гледа',
112
+ }[unattendedBlocked];
113
+ const whyEn = {
114
+ lock: "this machine's `policy.lock.json`, beside the install, forbids runs with nobody watching, and no setting on any page changes it",
115
+ isolation: '**"Where the bot runs"** on `/defaults` still says the bot runs in the operator\'s own account, with no isolation',
116
+ allowlist: 'the list of allowed programs is empty, and without it nothing limits a step that nobody is watching',
117
+ }[unattendedBlocked];
109
118
  return lang === 'bg'
110
119
  ? `
111
120
  ## На тази машина пускане без надзор се отказва
@@ -248,14 +257,6 @@ export function planExample() {
248
257
  baseBranch: 'main',
249
258
  },
250
259
  review: { enabled: true, model: '' },
251
- mirror: {
252
- enabled: true,
253
- rootDir: root,
254
- includeDirs: ['src/invoices'],
255
- excludeDirs: [],
256
- respectGitignore: true,
257
- includeEnvFiles: false,
258
- },
259
260
  tasks: firstTasks,
260
261
  },
261
262
  {
@@ -308,6 +309,12 @@ const FIELD_ROWS_EN = [
308
309
  'no',
309
310
  'Overrides the setting above for this one session: give the same name to the sessions that should share a chat, and leave it "" for a session that should have its own.',
310
311
  ],
312
+ [
313
+ 'projectDir',
314
+ 'session',
315
+ 'no',
316
+ 'The project folder of a session with version control off: its commands run there. Leave it out for the default project. With version control on, `vcs.repoDir` is required and the commands run in it.',
317
+ ],
311
318
  [
312
319
  'review',
313
320
  'session',
@@ -317,7 +324,6 @@ const FIELD_ROWS_EN = [
317
324
  ['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
318
325
  ['readOnly', 'task', 'no', '`true` for a task that must not change files — an audit, a smoke test, a report. The runner fails it if the tree changed, and still commits the change on its branch so nothing is lost.'],
319
326
  ['scope', 'task', 'no', 'The paths the task may change: files, folders (`tests/e2e/`) or `*`/`**` patterns. With version control on, the runner puts back any change outside them. Give it when the task is about one suite or module.'],
320
- ['mirror', 'session', 'no, but ask', 'Whether project files are copied and attached to the first message as context, and which: root, directories in and out, gitignore, env files. Off when left out.'],
321
327
  ['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
322
328
  ['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
323
329
  ['prompt', 'task', 'yes', 'The task itself, at least 30 characters. This is what Copilot reads.'],
@@ -373,6 +379,12 @@ const FIELD_ROWS_BG = [
373
379
  'не',
374
380
  'Отменя настройката горе само за тази сесия: дай едно и също име на сесиите, които да споделят чат, и остави "" на онази, която да е сама.',
375
381
  ],
382
+ [
383
+ 'projectDir',
384
+ 'сесия',
385
+ 'не',
386
+ 'Папката на проекта на сесия с изключен контрол на версиите: командите ѝ се изпълняват там. Пропусни го за проекта по подразбиране. С включен контрол на версиите `vcs.repoDir` е задължително и командите се изпълняват в него.',
387
+ ],
376
388
  [
377
389
  'review',
378
390
  'сесия',
@@ -382,7 +394,6 @@ const FIELD_ROWS_BG = [
382
394
  ['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
383
395
  ['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
384
396
  ['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
385
- ['mirror', 'сесия', 'не, но питай', 'Дали файлове от проекта се копират и прикачат към първото съобщение като контекст, и кои: корен, директории вътре и вън, gitignore, env файлове. Изключено, когато липсва.'],
386
397
  ['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
387
398
  ['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
388
399
  ['prompt', 'задача', 'да', 'Самата задача, поне 30 знака. Това чете Copilot.'],
@@ -465,7 +476,9 @@ commit is written: imperative, short, about the change rather than about the tas
465
476
  \`"vcs": { "enabled": false, "repoDir": "", "branchMode": "per-task", "commitOnFinish": false, "branchPrefix": "cop/" }\`.
466
477
  Then give its tasks no \`vcs\` at all — no branch, no commit message — because there is nothing
467
478
  to name. Read-only work, audits and reports are the usual reason. Say in \`notes\` why you turned
468
- it off, so the operator can disagree with you.
479
+ it off, so the operator can disagree with you. Its commands run in the default project; when its
480
+ work is in another one, give that project's path as the session's \`projectDir\`, since the empty
481
+ \`repoDir\` cannot say it.
469
482
 
470
483
  **Do not leave \`vcs\` out of a session.** The system refuses the whole document and hands the
471
484
  user a line saying so, and you will have to ask the question then anyway — after wasting their
@@ -521,7 +534,9 @@ reset или merge. \`"updateFromRemote": false\` го изключва — са
521
534
  \`"vcs": { "enabled": false, "repoDir": "", "branchMode": "per-task", "commitOnFinish": false, "branchPrefix": "cop/" }\`.
522
535
  На нейните задачи не давай \`vcs\` изобщо — нито клон, нито текст на комит — защото няма какво да
523
536
  се именува. Обичайната причина е работа само за четене, одит или отчет. Напиши в \`notes\` защо си
524
- го изключил, за да може операторът да не се съгласи с теб.
537
+ го изключил, за да може операторът да не се съгласи с теб. Командите ѝ се изпълняват в проекта по
538
+ подразбиране; когато работата ѝ е в друг, дай пътя на този проект като \`projectDir\` на сесията,
539
+ защото празното \`repoDir\` не може да го каже.
525
540
 
526
541
  **Не пропускай \`vcs\` в сесия.** Системата отказва целия документ и връща на потребителя ред,
527
542
  който го казва, а ти пак ще трябва да зададеш въпроса — след като си му загубил времето. Задай
@@ -625,16 +640,6 @@ const CHECKS_BG = `
625
640
  трите минават без свършена работа.
626
641
  Всеки критерий от заданието е покрит поне от една проверка, която пада, докато работата не е свършена.
627
642
  `.trim();
628
- const MIRROR_EN = `
629
- **mirror**: \`enabled\`, \`rootDir\` (absolute), \`includeDirs\` and \`excludeDirs\` (paths relative to
630
- the root, forward slashes), \`respectGitignore\` (leave it true), \`includeEnvFiles\` (leave it false
631
- unless the user insists — it copies secrets into a chat).
632
- `.trim();
633
- const MIRROR_BG = `
634
- **mirror**: \`enabled\`, \`rootDir\` (абсолютен), \`includeDirs\` и \`excludeDirs\` (пътища спрямо корена,
635
- с наклонени черти напред), \`respectGitignore\` (остави го true), \`includeEnvFiles\` (остави го false,
636
- освен ако потребителят не настоява — копира тайни в чат).
637
- `.trim();
638
643
  const VCS_RULE_EN = '- **Version control is yours to ask about, and there is no default.** With it on, the runner makes a branch\n' +
639
644
  ' before each task and a commit after it, using the names you give, and it never pushes; Copilot must not touch\n' +
640
645
  ' git itself. With it off, files are changed where they lie and there is no way back. Ask the user which it is,\n' +
@@ -1523,10 +1528,6 @@ asking for both — the first is what makes the next plan quicker to write than
1523
1528
  judges it. Ask whether it stays on and on which model; a model different from the working
1524
1529
  one catches more. \`model\` for the work itself only if the user names one from the chat's
1525
1530
  own picker.
1526
- - \`mirror\` (optional, off by default): whether project files are copied and attached to the
1527
- first message as context — from which root, which directories in and out, whether
1528
- \`.gitignore\` is respected (yes), whether \`.env\` files go in (no: they are secrets). Ask;
1529
- never assume.
1530
1531
  3. **The tasks.** For each: \`title\` (required), \`prompt\` (required — what Copilot reads:
1531
1532
  precise, with paths, ports and commands), \`expected\` (the bar, one sentence), \`checks\`
1532
1533
  (the same bar written so the runner can decide it; write one wherever a command or a file can
@@ -1611,8 +1612,6 @@ ${table(['Field', 'Where', 'Required', 'What it is'], rows)}
1611
1612
 
1612
1613
  ${VCS_DETAIL_EN}
1613
1614
 
1614
- ${MIRROR_EN}
1615
-
1616
1615
  ${CHECKS_EN}
1617
1616
 
1618
1617
  ### A filled-in example
@@ -1746,10 +1745,6 @@ ${systemGuideSection('bg')}
1746
1745
  работата и я оценява. Питай дали остава включен и на кой модел; модел, различен от
1747
1746
  работния, хваща повече. \`model\` за самата работа — само ако потребителят назове такъв
1748
1747
  от менюто на чата.
1749
- - \`mirror\` (незадължително, изключено по подразбиране): дали файлове от проекта се копират
1750
- и прикачат към първото съобщение като контекст — от кой корен, кои директории вътре и вън,
1751
- дали се спазва \`.gitignore\` (да), дали влизат \`.env\` файлове (не: те са тайни). Питай;
1752
- никога не предполагай.
1753
1748
  3. **Задачите.** За всяка: \`title\` (задължително), \`prompt\` (задължително — това чете
1754
1749
  Copilot: точно, с пътища, портове и команди), \`expected\` (летвата, едно изречение),
1755
1750
  \`checks\` (същата летва, написана така, че runner-ът да я решава сам; пиши по една навсякъде,
@@ -1835,8 +1830,6 @@ ${table(['Поле', 'Къде', 'Задължително', 'Какво е'], r
1835
1830
 
1836
1831
  ${VCS_DETAIL_BG}
1837
1832
 
1838
- ${MIRROR_BG}
1839
-
1840
1833
  ${CHECKS_BG}
1841
1834
 
1842
1835
  ### Попълнен пример
@@ -1,4 +1,4 @@
1
- import { DEFAULT_MIRROR, DEFAULT_VCS } from '../session/store.js';
1
+ import { DEFAULT_VCS, applyDefaultProject } from '../session/store.js';
2
2
  import { availableShells, detectShells } from '../exec/shells.js';
3
3
  /** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
4
4
  const EXPECTED_HEADER = '### Expected result';
@@ -145,7 +145,13 @@ function unavailableShellWarnings(plan) {
145
145
  */
146
146
  export async function importPlan(store, plan, defaultModel = '', defaultReviewModel = '',
147
147
  /** The operator's persona at the moment of import, written into every task's level 2. */
148
- persona = '') {
148
+ persona = '',
149
+ /**
150
+ * The default project on the Project page, for a session that names no folder of its own. The
151
+ * service passes it, so an imported session works where one made by hand would; see
152
+ * `applyDefaultProject`. Empty leaves such a session without a folder.
153
+ */
154
+ defaultProjectDir = '') {
149
155
  const sessions = [];
150
156
  const warnings = [...unavailableShellWarnings(plan)];
151
157
  /*
@@ -160,12 +166,12 @@ persona = '') {
160
166
  ? (plan.plan.trim() || `plan-${new Date().toISOString().slice(0, 16).replace(/[:T-]/g, '')}`).slice(0, 60)
161
167
  : '';
162
168
  for (const planned of plan.sessions) {
163
- const mirror = { ...DEFAULT_MIRROR, ...(planned.mirror ?? {}) };
164
- if (mirror.enabled && !mirror.rootDir.trim()) {
165
- mirror.enabled = false;
166
- warnings.push(`Session "${planned.name}" asked for project files without a root folder, so file attachment was left off.`);
169
+ // Attaching project files to the chat was removed; an old plan's root is still its project folder.
170
+ if (planned.mirror?.enabled) {
171
+ warnings.push(`Session "${planned.name}" asks for project files to be attached to the chat. That no longer exists: ` +
172
+ 'the chat reads the project by running commands. "mirror" was ignored.');
167
173
  }
168
- const created = await store.createSession(planned.name, mirror);
174
+ const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
169
175
  const vcs = { ...DEFAULT_VCS, ...(planned.vcs ?? {}) };
170
176
  vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
171
177
  vcs.repoDir = vcs.repoDir.trim();
@@ -183,6 +189,8 @@ persona = '') {
183
189
  s.model = (planned.model || defaultModel).trim() || undefined;
184
190
  s.planName = plan.plan.trim() || undefined;
185
191
  s.vcs = vcs;
192
+ // After the plan's own folders are in place, so only a session that named none is pointed at it.
193
+ applyDefaultProject(s, defaultProjectDir);
186
194
  });
187
195
  const titles = [];
188
196
  for (const task of planned.tasks) {
@@ -167,15 +167,12 @@ const TaskVcsInput = z.object({
167
167
  branch: z.string().trim().optional(),
168
168
  commitMessage: z.string().trim().optional(),
169
169
  });
170
- /** The project files copied into the chat. Absent means none, which is the default. */
171
- const MirrorInput = z.object({
172
- enabled: z.boolean().optional(),
173
- rootDir: z.string().optional(),
174
- includeDirs: z.array(z.string()).optional(),
175
- excludeDirs: z.array(z.string()).optional(),
176
- respectGitignore: z.boolean().optional(),
177
- includeEnvFiles: z.boolean().optional(),
178
- });
170
+ /**
171
+ * What was once the project files copied into the chat, removed on 2026-09-30. Still read, so a plan
172
+ * written before then imports: its `rootDir` becomes the session's project folder, and the rest is
173
+ * ignored with a warning. See `importPlan`.
174
+ */
175
+ const LegacyMirrorInput = z.object({ enabled: z.boolean().optional(), rootDir: z.string().optional() }).passthrough();
179
176
  const TaskInput = z.object({
180
177
  title: z
181
178
  .string()
@@ -271,7 +268,14 @@ const SessionInput = z.object({
271
268
  conversationGroup: z.string().trim().default(''),
272
269
  vcs: VcsInput,
273
270
  review: ReviewInput.optional(),
274
- mirror: MirrorInput.optional(),
271
+ /**
272
+ * The project folder of a session with version control off: where its commands run. Absent means
273
+ * the default project on the Project page. With version control on, `vcs.repoDir` is required (see
274
+ * `checkPlan`) and the commands run there, so this folder decides nothing for such a session.
275
+ */
276
+ projectDir: z.string().trim().optional(),
277
+ /** Read only for old plans; see `LegacyMirrorInput`. */
278
+ mirror: LegacyMirrorInput.optional(),
275
279
  tasks: z.array(TaskInput).min(1, 'A session with no tasks would import as an empty queue.'),
276
280
  });
277
281
  export const PlanSchema = z.object({
@@ -350,7 +354,6 @@ const KNOWN_KEYS = {
350
354
  vcs: keysOf(VcsInput),
351
355
  taskVcs: keysOf(TaskVcsInput),
352
356
  review: keysOf(ReviewInput),
353
- mirror: keysOf(MirrorInput),
354
357
  };
355
358
  function unknownKeys(value, kind, where, out) {
356
359
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -361,31 +364,46 @@ function unknownKeys(value, kind, where, out) {
361
364
  }
362
365
  }
363
366
  }
367
+ /**
368
+ * A value of the raw document as an object whose fields can be read, or nothing.
369
+ *
370
+ * The warning walk runs on the document as it arrived, before the schema has said anything about
371
+ * it, so every level of it may be null, a number or a list where an object belongs. A cast does
372
+ * not make it one: `sessions: [null]` read `null.vcs` and threw, where the schema would have
373
+ * refused it by its path. What is not an object has no fields to warn about, and is left for the
374
+ * schema to refuse.
375
+ */
376
+ function fieldsOf(value) {
377
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
378
+ }
364
379
  /** Fields a model invented, walked by hand because the schema drops them silently. */
365
380
  function collectWarnings(raw) {
366
381
  const out = [];
367
- if (!raw || typeof raw !== 'object')
382
+ const plan = fieldsOf(raw);
383
+ if (!plan)
368
384
  return out;
369
- unknownKeys(raw, 'plan', 'plan', out);
370
- const sessions = raw.sessions;
371
- if (!Array.isArray(sessions))
385
+ unknownKeys(plan, 'plan', 'plan', out);
386
+ if (!Array.isArray(plan.sessions))
372
387
  return out;
373
- sessions.forEach((s, i) => {
374
- unknownKeys(s, 'session', `sessions[${i}]`, out);
375
- const session = s;
388
+ plan.sessions.forEach((s, i) => {
389
+ const session = fieldsOf(s);
390
+ if (!session)
391
+ return;
392
+ unknownKeys(session, 'session', `sessions[${i}]`, out);
376
393
  unknownKeys(session.vcs, 'vcs', `sessions[${i}].vcs`, out);
377
394
  unknownKeys(session.review, 'review', `sessions[${i}].review`, out);
378
- unknownKeys(session.mirror, 'mirror', `sessions[${i}].mirror`, out);
379
- if (Array.isArray(session.tasks)) {
380
- session.tasks.forEach((t, j) => {
381
- unknownKeys(t, 'task', `sessions[${i}].tasks[${j}]`, out);
382
- unknownKeys(t?.vcs, 'taskVcs', `sessions[${i}].tasks[${j}].vcs`, out);
383
- const checks = t?.checks;
384
- if (Array.isArray(checks)) {
385
- checks.forEach((c, k) => unknownKeys(c, 'check', `sessions[${i}].tasks[${j}].checks[${k}]`, out));
386
- }
387
- });
388
- }
395
+ if (!Array.isArray(session.tasks))
396
+ return;
397
+ session.tasks.forEach((t, j) => {
398
+ const task = fieldsOf(t);
399
+ if (!task)
400
+ return;
401
+ unknownKeys(task, 'task', `sessions[${i}].tasks[${j}]`, out);
402
+ unknownKeys(task.vcs, 'taskVcs', `sessions[${i}].tasks[${j}].vcs`, out);
403
+ if (Array.isArray(task.checks)) {
404
+ task.checks.forEach((c, k) => unknownKeys(c, 'check', `sessions[${i}].tasks[${j}].checks[${k}]`, out));
405
+ }
406
+ });
389
407
  });
390
408
  return out;
391
409
  }
@@ -428,8 +446,25 @@ export function summarise(plan) {
428
446
  *
429
447
  * Never throws. Every way this can fail — not JSON at all, the wrong version, a task with no
430
448
  * prompt — is an answer the user is meant to see and hand back to the chat model.
449
+ *
450
+ * That promise is kept here, around the whole reading, and not only by each walk being careful:
451
+ * the walks run on whatever a chat produced, and one of them reading a field of null once turned
452
+ * a malformed plan into a request error carrying a JavaScript message, which neither the page nor
453
+ * the chat that wrote the plan can do anything with. A walk that throws again is an answer too.
431
454
  */
432
455
  export function checkPlan(text) {
456
+ try {
457
+ return readPlan(text);
458
+ }
459
+ catch (e) {
460
+ return {
461
+ ok: false,
462
+ warnings: [],
463
+ issues: [{ path: '', message: `This plan could not be read: ${e.message}. Check that every session, task and check is a JSON object.` }],
464
+ };
465
+ }
466
+ }
467
+ function readPlan(text) {
433
468
  const source = extractJson(text ?? '');
434
469
  if (!source.trim()) {
435
470
  return { ok: false, warnings: [], issues: [{ path: '', message: 'There is nothing here to import.' }] };
@@ -173,14 +173,6 @@ export const SCRIPT = [
173
173
  },
174
174
  ],
175
175
  },
176
- {
177
- choose: { en: 'How do I read its code from this chat?', bg: 'Как да чета кода му от този чат?' },
178
- options: [
179
- { en: 'As files attached to the first message of a task', bg: 'Като прикачени файлове към първото съобщение на задачата' },
180
- { en: 'Through the Desktop mirror (Desktop/copilot-operator-context/)', bg: 'През огледалото на Desktop-а (Desktop/copilot-operator-context/)' },
181
- { en: 'Both', bg: 'И двете' },
182
- ],
183
- },
184
176
  {
185
177
  choose: {
186
178
  en: 'May the bot make git branches and commits in this project?',
@@ -704,13 +696,6 @@ export const SCRIPT = [
704
696
  { en: 'Off', bg: 'Изключена' },
705
697
  ],
706
698
  },
707
- {
708
- choose: { en: 'Project files for the chat (`mirror`):', bg: 'Файлове на проекта към чата (`mirror`):' },
709
- options: [
710
- { en: 'No', bg: 'Не' },
711
- { en: 'Yes — give the root and the folders', bg: 'Да — напиши корена и папките' },
712
- ],
713
- },
714
699
  {
715
700
  choose: { en: 'Name of the run (`plan`):', bg: 'Име на пускането (`plan`):' },
716
701
  options: [{ en: '{the name you propose from the goal}', bg: '{името, което предлагаш от целта}' }],
@@ -1241,157 +1241,6 @@ export const SYSTEM_GUIDE = [
1241
1241
  },
1242
1242
  ],
1243
1243
  },
1244
- {
1245
- headingKey: 'mirror.title',
1246
- en: 'Project files for the chat',
1247
- bg: 'Файлове на проекта за чата',
1248
- controls: [
1249
- {
1250
- key: 'mirror.enable',
1251
- kind: 'checkbox',
1252
- en: 'attach project files',
1253
- bg: 'прикачай файловете на проекта',
1254
- doesEn: 'Attaches the selected files to the first message of every task; it takes effect only after Save below.',
1255
- doesBg: 'Прикача избраните файлове към първото съобщение на всяка задача; влиза в сила чак след Запази отдолу.',
1256
- },
1257
- {
1258
- key: 'mirror.root',
1259
- kind: 'field',
1260
- en: 'Project root',
1261
- bg: 'Корен на проекта',
1262
- doesEn: 'The folder the include and exclude lists below are relative to.',
1263
- doesBg: 'Папката, спрямо която са списъците за включване и изключване отдолу.',
1264
- },
1265
- {
1266
- key: 'mirror.listDirs',
1267
- kind: 'button',
1268
- en: 'List directories',
1269
- bg: 'Покажи директориите',
1270
- doesEn: 'Lists the root\'s folders, to pick what to include.',
1271
- doesBg: 'Изброява папките в корена, за да се избере какво да се включи.',
1272
- },
1273
- {
1274
- key: 'mirror.rules',
1275
- kind: 'note',
1276
- en: 'What is copied from it',
1277
- bg: 'Какво се копира от него',
1278
- doesEn: 'The heading above the include and exclude lists.',
1279
- doesBg: 'Заглавието над списъците за включване и изключване.',
1280
- },
1281
- {
1282
- key: 'mirror.include',
1283
- kind: 'field',
1284
- en: 'Include (one per line; a directory includes everything beneath it)',
1285
- bg: 'Включи (по една на ред; директория включва всичко под нея)',
1286
- doesEn: 'One folder or file per line; a folder brings everything beneath it.',
1287
- doesBg: 'По една папка или файл на ред; папка включва всичко под нея.',
1288
- },
1289
- {
1290
- key: 'mirror.exclude',
1291
- kind: 'field',
1292
- en: 'Exclude (one per line)',
1293
- bg: 'Изключи (по една на ред)',
1294
- doesEn: 'One per line: what to leave out even inside an included folder.',
1295
- doesBg: 'По един на ред: какво да се изостави дори в включена папка.',
1296
- },
1297
- {
1298
- key: 'tree.load',
1299
- kind: 'button',
1300
- en: 'Show the folders',
1301
- bg: 'Покажи папките',
1302
- doesEn: 'Reads the project folder and draws its tree, so folders can be ticked instead of typed.',
1303
- doesBg: 'Прочита папката на проекта и показва дървото ѝ, за да се избират папки с клик, вместо да се пишат.',
1304
- },
1305
- {
1306
- key: 'tree.reload',
1307
- kind: 'button',
1308
- en: 'Read the folders again',
1309
- bg: 'Прочети папките отново',
1310
- doesEn: 'Reads the folder tree again from disk.',
1311
- doesBg: 'Прочита отново дървото с папки от диска.',
1312
- },
1313
- {
1314
- key: 'tree.exclude',
1315
- kind: 'button',
1316
- en: 'carve this folder out',
1317
- bg: 'изрежи тази папка',
1318
- doesEn: 'The "−" on a tree row: carves that folder out of an included one.',
1319
- doesBg: '„−“ на ред в дървото: изважда тази папка от включена.',
1320
- },
1321
- {
1322
- key: 'tree.unexclude',
1323
- kind: 'button',
1324
- en: 'stop carving this folder out',
1325
- bg: 'спри да изрязваш тази папка',
1326
- doesEn: 'Puts a carved-out folder back.',
1327
- doesBg: 'Връща изважданата папка обратно.',
1328
- },
1329
- {
1330
- key: 'tree.fold',
1331
- kind: 'button',
1332
- en: 'fold',
1333
- bg: 'сгъни',
1334
- doesEn: 'Folds a tree row.',
1335
- doesBg: 'Сгъва ред в дървото.',
1336
- },
1337
- {
1338
- key: 'tree.unfold',
1339
- kind: 'button',
1340
- en: 'unfold',
1341
- bg: 'разгъни',
1342
- doesEn: 'Unfolds a tree row.',
1343
- doesBg: 'Разгъва ред в дървото.',
1344
- },
1345
- {
1346
- key: 'mirror.check',
1347
- kind: 'button',
1348
- en: 'Check what would be copied',
1349
- bg: 'Провери какво ще се копира',
1350
- doesEn: 'Counts what would be copied, without copying: files and size.',
1351
- doesBg: 'Преброява какво би се копирало, без да копира: файлове и размер.',
1352
- },
1353
- {
1354
- key: 'mirror.showFiles',
1355
- kind: 'disclosure',
1356
- en: 'show the list',
1357
- bg: 'покажи списъка',
1358
- doesEn: 'Under that count: the list of files.',
1359
- doesBg: 'Под това преброяване: списъкът с файлове.',
1360
- },
1361
- {
1362
- key: 'mirror.showSkipped',
1363
- kind: 'disclosure',
1364
- en: 'show what was left out',
1365
- bg: 'покажи какво остава навън',
1366
- doesEn: 'Under that count: what was left out, and why.',
1367
- doesBg: 'Под това преброяване: какво е изоставено и защо.',
1368
- },
1369
- {
1370
- key: 'mirror.gitignore',
1371
- kind: 'checkbox',
1372
- en: 'Leave out what .gitignore lists',
1373
- bg: 'Не добавяй това, което е в .gitignore',
1374
- doesEn: 'Keeps everything .gitignore names out of the files sent to the chat.',
1375
- doesBg: 'Държи всичко от .gitignore извън файловете, пратени в чата.',
1376
- },
1377
- {
1378
- key: 'mirror.env',
1379
- kind: 'checkbox',
1380
- en: 'Copy .env files as well',
1381
- bg: 'Копирай и .env файловете',
1382
- doesEn: 'Sends the .env files too; it asks for confirmation first, because they usually hold passwords and keys.',
1383
- doesBg: 'Праща и .env файловете; първо иска потвърждение, защото в тях обикновено има пароли и ключове.',
1384
- },
1385
- {
1386
- key: 'mirror.save',
1387
- kind: 'button',
1388
- en: 'Save',
1389
- bg: 'Запази',
1390
- doesEn: 'Stores the switch, the project root and both lists onto this session.',
1391
- doesBg: 'Записва отметката, корена на проекта и двата списъка върху тази сесия.',
1392
- },
1393
- ],
1394
- },
1395
1244
  {
1396
1245
  headingKey: 'tasks.title',
1397
1246
  en: 'Tasks',
@@ -1538,8 +1387,8 @@ export const SYSTEM_GUIDE = [
1538
1387
  kind: 'button',
1539
1388
  en: 'Run again from here',
1540
1389
  bg: 'Пусни отново оттук',
1541
- doesEn: 'Puts the code back, queues that task and every task after it — later sessions of its run included — and starts at once, in the mode that run had.',
1542
- doesBg: 'Връща кода, нарежда отново тази задача и всички след нея — включително следващите сесии от пускането ѝ — и тръгва веднага, в режима на онова пускане.',
1390
+ doesEn: 'Puts the code back, queues that task and every task after it — later sessions of its run included — and starts at once, in the mode that run had: step by step when that run left no record of its own, or went on its own and this machine no longer allows that.',
1391
+ doesBg: 'Връща кода, нарежда отново тази задача и всички след нея — включително следващите сесии от пускането ѝ — и тръгва веднага, в режима на онова пускане: стъпка по стъпка, когато то не е оставило запис за себе си или е вървяло самостоятелно, а тази машина вече не го позволява.',
1543
1392
  },
1544
1393
  {
1545
1394
  key: 'task.delete',
@@ -2736,30 +2585,6 @@ export const SYSTEM_GUIDE = [
2736
2585
  doesEn: 'Empties that field, so new sessions start with nothing chosen.',
2737
2586
  doesBg: 'Изпразва полето, така че новите сесии тръгват без избрано.',
2738
2587
  },
2739
- {
2740
- key: 'proj.mirrorToDesktop',
2741
- kind: 'checkbox',
2742
- en: 'Keep the projects on the Desktop',
2743
- bg: 'Дръж проектите на Desktop-а',
2744
- doesEn: 'The master switch: copies the projects on this page to their own Desktop folders before a run and after every round of steps, which is how the files reach the chat.',
2745
- doesBg: 'Главният ключ: копира проектите от тази страница в собствени папки на Desktop-а преди пускане и след всеки кръг стъпки — така файловете стигат до чата.',
2746
- },
2747
- {
2748
- key: 'proj.desktopThis',
2749
- kind: 'checkbox',
2750
- en: 'Keep a copy of this project on the Desktop',
2751
- bg: 'Дръж копие на този проект на Desktop-а',
2752
- doesEn: 'Under each project, on by default: untick to keep that one off the Desktop while the switch is on. Greyed out while the switch is off.',
2753
- doesBg: 'Под всеки проект, включено по подразбиране: махнете отметката, за да не се копира точно той, докато ключът е включен. Неактивно, докато ключът е изключен.',
2754
- },
2755
- {
2756
- key: 'proj.folders',
2757
- kind: 'disclosure',
2758
- en: 'Folders that go to the Desktop',
2759
- bg: 'Папки, които отиват на Desktop-а',
2760
- doesEn: 'On each project: which of its folders go to the Desktop copy — the same include and exclude lists and folder tree as on a session page.',
2761
- doesBg: 'На всеки проект: кои папки отиват в копието на Desktop-а — същите списъци за включване и изключване и дърво с папки като на страницата на сесия.',
2762
- },
2763
2588
  {
2764
2589
  key: 'proj.affects',
2765
2590
  kind: 'note',
@@ -169,7 +169,11 @@ export function parseReply(markdown, opts) {
169
169
  reply.notes = stripCitations(reply.notes);
170
170
  reply.summary = stripCitations(reply.summary);
171
171
  const steps = reply.steps.map((s) => ({ ...s, shell: s.shell ?? opts.defaultShell }));
172
- const markerHit = markdown.includes(opts.stopMarker);
172
+ // A blank stop word is no stop word. The setting is free text, and every reply contains the
173
+ // empty string and nearly every one a space, so searching for either would read each
174
+ // `continue` that carries a summary as done and close the task after one round.
175
+ const marker = opts.stopMarker.trim();
176
+ const markerHit = marker !== '' && markdown.includes(marker);
173
177
  const hasSummary = (reply.summary ?? '').trim().length > 0;
174
178
  // The stop word alone is not enough to end a task any more: the summary is the
175
179
  // deliverable, and a marker without one would let a task close with nothing to show.
@@ -145,7 +145,7 @@ export async function buildDebugExport(input) {
145
145
  chat: session.chat ? { name: session.chat.name, url: session.chat.url } : undefined,
146
146
  vcs: session.vcs,
147
147
  vcsBaseCommit: session.vcsBaseCommit,
148
- mirror: session.mirror,
148
+ projectDir: session.projectDir,
149
149
  tasks,
150
150
  });
151
151
  }
@@ -245,7 +245,7 @@ export async function buildExport(input) {
245
245
  `chat : ${session.chat?.url ?? '(no conversation was opened)'}`,
246
246
  `model : ${session.model || 'whatever the chat was set to'}${session.modelInUse ? ` (ran on ${session.modelInUse})` : ''}`,
247
247
  `repository : ${session.vcs?.enabled
248
- ? `${session.vcs.repoDir || session.mirror.rootDir || '(none set)'} — ${session.vcs.branchMode === 'per-session' ? 'one branch for the whole session' : 'a branch of its own for every task'}, prefix ${session.vcs.branchPrefix}${session.vcs.commitOnFinish ? '' : ', commits off'}`
248
+ ? `${session.vcs.repoDir || session.projectDir || '(none set)'} — ${session.vcs.branchMode === 'per-session' ? 'one branch for the whole session' : 'a branch of its own for every task'}, prefix ${session.vcs.branchPrefix}${session.vcs.commitOnFinish ? '' : ', commits off'}`
249
249
  : 'version control is off for this session'}`,
250
250
  `tasks : ${tasks.length} of ${session.tasks.length}`,
251
251
  `exported : ${when(new Date().toISOString())}`,