copilot-operator 0.1.16 → 0.1.17

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 (41) hide show
  1. package/dist/src/api/operator.controller.js +23 -0
  2. package/dist/src/api/operator.service.js +32 -1
  3. package/dist/src/plan/brief.js +34 -0
  4. package/dist/src/plan/importPlan.js +8 -1
  5. package/dist/src/plan/schema.js +24 -0
  6. package/dist/src/plan/systemGuide.js +32 -0
  7. package/dist/src/session/exports.js +2 -0
  8. package/dist/src/session/store.js +68 -6
  9. package/dist/src/vcs/snapshot.js +354 -0
  10. package/dist/src/vcs/taskVcs.js +68 -23
  11. package/dist/web/404.html +2 -2
  12. package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +1 -0
  13. package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +1 -0
  14. package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +1 -0
  15. package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +1 -0
  16. package/dist/web/_next/static/css/{e2a2c9eb3c226313.css → c37978b44194c37b.css} +1 -1
  17. package/dist/web/appearance.html +2 -2
  18. package/dist/web/appearance.txt +12 -12
  19. package/dist/web/defaults.html +2 -2
  20. package/dist/web/defaults.txt +12 -12
  21. package/dist/web/history.html +2 -2
  22. package/dist/web/history.txt +12 -12
  23. package/dist/web/import.html +2 -2
  24. package/dist/web/import.txt +12 -12
  25. package/dist/web/index.html +2 -2
  26. package/dist/web/index.txt +12 -12
  27. package/dist/web/level1.html +2 -2
  28. package/dist/web/level1.txt +12 -12
  29. package/dist/web/presets.html +2 -2
  30. package/dist/web/presets.txt +12 -12
  31. package/dist/web/sessions/view.html +2 -2
  32. package/dist/web/sessions/view.txt +12 -12
  33. package/dist/web/system.html +2 -2
  34. package/dist/web/system.txt +12 -12
  35. package/package.json +5 -3
  36. package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +0 -1
  37. package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +0 -1
  38. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +0 -1
  39. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +0 -1
  40. /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → kkxFcGzA9Uo1uRAgcoDti}/_buildManifest.js +0 -0
  41. /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → kkxFcGzA9Uo1uRAgcoDti}/_ssgManifest.js +0 -0
@@ -126,6 +126,10 @@ let OperatorController = class OperatorController {
126
126
  sessions() {
127
127
  return this.ops.listSessions();
128
128
  }
129
+ /** The sessions list in the operator's order: every session id, top to bottom. */
130
+ reorderSessions(body) {
131
+ return this.ops.reorderSessions(body?.ids).catch(fail);
132
+ }
129
133
  createSession(body) {
130
134
  return this.ops.createSession(body?.name ?? '', body?.projectDir).catch(fail);
131
135
  }
@@ -500,6 +504,10 @@ let OperatorController = class OperatorController {
500
504
  vcsStatus(id) {
501
505
  return this.ops.vcsStatus(id).catch(fail);
502
506
  }
507
+ /** The operator's approval of the starting snapshot: one choice per file of the list they saw. */
508
+ vcsSnapshot(id, body) {
509
+ return this.ops.vcsSnapshot(id, body?.choices ?? {}).catch(fail);
510
+ }
503
511
  // --- the model picker ---------------------------------------------------------------------
504
512
  /** The cached list. Returns null when the picker has never been read on this machine. */
505
513
  models() {
@@ -632,6 +640,13 @@ __decorate([
632
640
  __metadata("design:paramtypes", []),
633
641
  __metadata("design:returntype", Promise)
634
642
  ], OperatorController.prototype, "sessions", null);
643
+ __decorate([
644
+ Put('sessions-order'),
645
+ __param(0, Body()),
646
+ __metadata("design:type", Function),
647
+ __metadata("design:paramtypes", [Object]),
648
+ __metadata("design:returntype", Promise)
649
+ ], OperatorController.prototype, "reorderSessions", null);
635
650
  __decorate([
636
651
  Post('sessions'),
637
652
  __param(0, Body()),
@@ -1027,6 +1042,14 @@ __decorate([
1027
1042
  __metadata("design:paramtypes", [String]),
1028
1043
  __metadata("design:returntype", Promise)
1029
1044
  ], OperatorController.prototype, "vcsStatus", null);
1045
+ __decorate([
1046
+ Post('sessions/:id/vcs/snapshot'),
1047
+ __param(0, Param('id')),
1048
+ __param(1, Body()),
1049
+ __metadata("design:type", Function),
1050
+ __metadata("design:paramtypes", [String, Object]),
1051
+ __metadata("design:returntype", Promise)
1052
+ ], OperatorController.prototype, "vcsSnapshot", null);
1030
1053
  __decorate([
1031
1054
  Get('models'),
1032
1055
  __metadata("design:type", Function),
@@ -45,6 +45,7 @@ import { unattendedPrecondition } from '../exec/policy.js';
45
45
  import { createTransport } from '../transport/chatTransport.js';
46
46
  import { composeHandoff } from '../session/handoff.js';
47
47
  import { planSync } from '../vcs/syncCommand.js';
48
+ import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
48
49
  import { computeMetrics } from '../session/metrics.js';
49
50
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
50
51
  import { saveAndReveal } from './saveToDesktop.js';
@@ -320,6 +321,14 @@ let OperatorService = class OperatorService {
320
321
  await this.store.deletePersona(name);
321
322
  }
322
323
  // --- sessions and tasks ---------------------------------------------------------------
324
+ /** The operator's order for the sessions list, top to bottom. See `SessionStore.reorderSessions`. */
325
+ async reorderSessions(ids) {
326
+ await this.init();
327
+ if (!Array.isArray(ids) || ids.some((x) => typeof x !== 'string'))
328
+ throw new Error('ids must be a list of session ids.');
329
+ await this.store.reorderSessions(ids);
330
+ return await this.listSessions();
331
+ }
323
332
  async listSessions() {
324
333
  await this.init();
325
334
  const all = await this.store.listSessions();
@@ -404,6 +413,12 @@ let OperatorService = class OperatorService {
404
413
  merged.baseBranch = merged.baseBranch.trim();
405
414
  if (merged.existingBranch !== undefined)
406
415
  merged.existingBranch = merged.existingBranch.trim();
416
+ // Only the two known fields, and absent when it says what absent says: reject, asking first.
417
+ const dirty = dirtyPolicy(merged);
418
+ if (dirty.policy === 'reject')
419
+ delete merged.dirtyWorktree;
420
+ else
421
+ merged.dirtyWorktree = { policy: dirty.policy, ...(dirty.requireApproval ? {} : { requireApproval: false }) };
407
422
  // Only the program marks a name as one of its own branches, and a name changed is not that one.
408
423
  const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
409
424
  if (stillOwn)
@@ -1822,11 +1837,27 @@ let OperatorService = class OperatorService {
1822
1837
  return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
1823
1838
  // `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
1824
1839
  // those differ, and the second is the one the operator is asking about.
1825
- const preflight = await vcsPreflight(session);
1840
+ const preflight = await vcsPreflight(session, () => this.store.listSessions());
1826
1841
  // The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
1827
1842
  const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
1828
1843
  return { ...preflight, git, work: sessionBranches(session), branches };
1829
1844
  }
1845
+ /**
1846
+ * The operator's approval of the starting snapshot: their uncommitted changes become the commit the
1847
+ * session starts from, each file taken or left out as they chose on the list. See `vcs/snapshot.ts`.
1848
+ * Refused while anything could be working in the repository, as a restore is: it changes branches.
1849
+ */
1850
+ async vcsSnapshot(sessionId, choices) {
1851
+ await this.init();
1852
+ const session = await this.store.getSession(sessionId);
1853
+ if (!session)
1854
+ throw new Error('No such session.');
1855
+ const refused = await this.restoreRefusal(session);
1856
+ if (refused)
1857
+ return { ok: false, problem: refused };
1858
+ const taken = await takeSnapshot(session, { approved: true, choices: choices && typeof choices === 'object' ? choices : {} }, this.bus, async (mutate) => void (await this.store.updateSession(sessionId, mutate)), () => this.store.listSessions());
1859
+ return taken.ok ? { ok: true, branch: taken.start.branch, commit: taken.start.commit } : { ok: false, problem: taken.problem };
1860
+ }
1830
1861
  /**
1831
1862
  * What going back to before a task would do, and then doing it.
1832
1863
  *
@@ -454,6 +454,16 @@ Before a session's first branch the runner fetches and fast-forwards the branch
454
454
  merges. \`"updateFromRemote": false\` turns that off — only when the user asks to work offline or from
455
455
  local commits on purpose.
456
456
 
457
+ **dirtyWorktree** — what happens when the repository has uncommitted changes (the user's own, such as
458
+ input files a task needs) before a session's first task. Ask only when the user mentions such files:
459
+ \`{ "policy": "snapshot" }\` makes them one commit, "Capture operator baseline before run", on a branch of
460
+ its own, and the session starts from it; the user approves the exact list on the session's page first.
461
+ \`"tracked-only-snapshot"\` takes tracked changes only. Secrets (\`.env\`), tool output and files outside
462
+ the project are never taken, and an ignored file only when a task's \`scope\` names it. The snapshot is
463
+ taken where the changes are, so use it with \`startFrom\` \`"branch"\` only when the repository is already on
464
+ that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`: version control stays out
465
+ of a tree with uncommitted changes.
466
+
457
467
  Put the same \`startFrom\` on every session that works in one repository unless the user wants it
458
468
  mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
459
469
  the one to avoid.
@@ -512,6 +522,16 @@ const VCS_DETAIL_BG = `
512
522
  reset или merge. \`"updateFromRemote": false\` го изключва — само ако потребителят поиска да работи
513
523
  офлайн или нарочно от локални commit-и.
514
524
 
525
+ **dirtyWorktree** — какво става, когато хранилището има некомитнати промени (на потребителя, например
526
+ входни файлове, които задача използва) преди първата задача на сесията. Питай само ако потребителят
527
+ спомене такива файлове: \`{ "policy": "snapshot" }\` ги прави един commit, „Capture operator baseline before
528
+ run“, на отделен клон, и сесията тръгва от него; потребителят първо одобрява точния списък на страницата на
529
+ сесията. \`"tracked-only-snapshot"\` взема само tracked промени. Тайни (\`.env\`), изход от инструменти и
530
+ файлове извън проекта никога не се вземат, а ignored файл — само когато \`scope\` на задача го посочва.
531
+ Снимката се прави там, където са промените, затова с \`startFrom\` \`"branch"\` само ако хранилището вече е на
532
+ този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: контролът на версиите
533
+ не влиза в дърво с некомитнати промени.
534
+
515
535
  Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
516
536
  различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
517
537
  което е за избягване.
@@ -1438,6 +1458,13 @@ bot actually does, because it changes what a good task looks like:
1438
1458
  reads it and decides the next step. This repeats until Copilot writes a final summary.
1439
1459
  - Nothing is interactive. A command that waits for a keypress, opens an editor or needs a
1440
1460
  browser login will hang the task.
1461
+ - **How the working chat learns the project: by running commands.** No file of the project is
1462
+ copied, attached or uploaded to it. In each task it reads what it needs itself, with terminal
1463
+ commands in the project folder — it lists the folders, opens the files, looks at the git log —
1464
+ and the output comes back to it. So never ask the user how the chat will get the project's files,
1465
+ how it will see the code or whether to attach anything; that is settled. If the organisation
1466
+ document still says how the chat reaches a project (attached files, a Desktop copy), ignore it:
1467
+ that way no longer exists. Write prompts that name the paths to read, not files to attach.
1441
1468
  - **Only the project folders.** The runner refuses any command, check or file path that reaches
1442
1469
  outside the session's folder and the projects listed below — reading as well as writing — and
1443
1470
  anything that manages the machine: the registry, services, the network, users, installs that
@@ -1655,6 +1682,13 @@ function buildBg(projects, ctx = {}) {
1655
1682
  чете и решава следващата стъпка. Това се повтаря, докато Copilot не напише финално резюме.
1656
1683
  - Нищо не е интерактивно. Команда, която чака клавиш, отваря редактор или иска вход през
1657
1684
  браузър, ще увисне.
1685
+ - **Как работният чат опознава проекта: с команди.** Никой файл от проекта не се копира, прикачва
1686
+ или качва в него. Във всяка задача той сам чете каквото му трябва, с терминални команди в папката
1687
+ на проекта — изброява папките, отваря файловете, гледа git историята — и изходът се връща при него.
1688
+ Затова никога не питай потребителя как чатът ще получи файловете на проекта, как ще види кода или
1689
+ дали да прикачи нещо; това е решено. Ако документът за организацията още казва как чатът стига до
1690
+ проект (прикачени файлове, копие на Desktop-а), не го слушай: такъв начин вече няма. Пиши prompt-ове,
1691
+ които назовават пътищата за четене, а не файлове за прикачване.
1658
1692
  - **Само папките на проектите.** Runner-ът отказва всяка команда, проверка или път към файл, който
1659
1693
  излиза извън папката на сесията и проектите, изброени по-долу — и за четене, и за запис — и всичко,
1660
1694
  което управлява машината: регистъра, услугите, мрежата, потребителите, инсталации извън проекта.
@@ -172,7 +172,12 @@ defaultProjectDir = '') {
172
172
  'the chat reads the project by running commands. "mirror" was ignored.');
173
173
  }
174
174
  const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
175
- const vcs = { ...DEFAULT_VCS, ...(planned.vcs ?? {}) };
175
+ const { dirtyWorktree: plannedDirty, ...plannedVcs } = planned.vcs ?? {};
176
+ const vcs = { ...DEFAULT_VCS, ...plannedVcs };
177
+ // Stored in the one spelling the session has: see `DirtyWorktree`.
178
+ const policy = plannedDirty?.policy === 'snapshot' && plannedDirty.includeUntracked === false ? 'tracked-only-snapshot' : plannedDirty?.policy;
179
+ if (policy && policy !== 'reject')
180
+ vcs.dirtyWorktree = { policy, ...(plannedDirty?.requireApproval === false ? { requireApproval: false } : {}) };
176
181
  vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
177
182
  vcs.repoDir = vcs.repoDir.trim();
178
183
  // A branch to carry on, given alone, is that choice: see `VersionControl.startFrom`.
@@ -211,5 +216,7 @@ defaultProjectDir = '') {
211
216
  }
212
217
  sessions.push({ id: created.id, name: created.name, tasks: titles.length, titles });
213
218
  }
219
+ // The plan's sessions on top of the list, in the order the plan gives them, not newest first.
220
+ await store.placeOnTop(sessions.map((s) => s.id));
214
221
  return { sessions, taskCount: sessions.reduce((n, s) => n + s.tasks, 0), warnings };
215
222
  }
@@ -60,6 +60,23 @@ const VcsInput = z
60
60
  * Absent means true. See `VersionControl.updateFromRemote`.
61
61
  */
62
62
  updateFromRemote: z.boolean().optional(),
63
+ /**
64
+ * What the session's first run does with uncommitted changes. See `DirtyWorktree`. Accepts the
65
+ * longer spelling a chat model may write — `includeUntracked: false` is `tracked-only-snapshot`,
66
+ * and `includeIgnoredOnlyWhenScoped` can only be true, since that is the rule, not a choice.
67
+ */
68
+ dirtyWorktree: z
69
+ .object({
70
+ policy: z.enum(['reject', 'snapshot', 'tracked-only-snapshot'], {
71
+ error: 'dirtyWorktree.policy must be "reject" (uncommitted changes keep version control out, as until now), "snapshot" (they become the commit the session starts from) or "tracked-only-snapshot" (the same, tracked changes only). "discard" is not offered: the runner never throws work away.',
72
+ }),
73
+ requireApproval: z.boolean().optional(),
74
+ includeUntracked: z.boolean().optional(),
75
+ includeIgnoredOnlyWhenScoped: z
76
+ .literal(true, { error: 'dirtyWorktree.includeIgnoredOnlyWhenScoped can only be true: an ignored file goes into a snapshot only when a task\'s scope names it. Leave the field out.' })
77
+ .optional(),
78
+ })
79
+ .optional(),
63
80
  }, {
64
81
  error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
65
82
  })
@@ -78,6 +95,13 @@ const VcsInput = z
78
95
  message: `"existingBranch" carries on a branch that exists, and startFrom "${vcs.startFrom}" cuts a new one. Keep one: startFrom "existing-branch" with existingBranch, or leave existingBranch out.`,
79
96
  });
80
97
  }
98
+ if (vcs.dirtyWorktree?.policy === 'tracked-only-snapshot' && vcs.dirtyWorktree.includeUntracked === true) {
99
+ ctx.addIssue({
100
+ code: 'custom',
101
+ path: ['dirtyWorktree', 'includeUntracked'],
102
+ message: '"tracked-only-snapshot" takes tracked changes only, and includeUntracked true asks for new files too. Keep one: policy "snapshot", or leave includeUntracked out.',
103
+ });
104
+ }
81
105
  if (vcs.enabled && !vcs.repoDir.trim()) {
82
106
  ctx.addIssue({
83
107
  code: 'custom',
@@ -827,6 +827,22 @@ export const SYSTEM_GUIDE = [
827
827
  doesEn: 'Deletes every session ticked in the first column after one question, also during a run; never the one running. Run folders and chats survive.',
828
828
  doesBg: 'Изтрива всички отметнати в първата колона сесии след един въпрос, и по време на пускане; никога работещата. Папките и разговорите остават.',
829
829
  },
830
+ {
831
+ key: 'home.moveUp',
832
+ kind: 'button',
833
+ en: 'Move {name} up in the list',
834
+ bg: 'Премести {name} нагоре в списъка',
835
+ doesEn: 'The ↑ arrow in the Order column: one place up. ↓ is one place down. Dragging a row onto another moves it there. An imported plan comes in on top in its own order.',
836
+ doesBg: 'Стрелката ↑ в колона „Ред“: едно място нагоре. ↓ е едно надолу. Плъзгане на ред върху друг го мести там. Импортиран план идва най-отгоре в своя ред.',
837
+ },
838
+ {
839
+ key: 'home.moveDown',
840
+ kind: 'button',
841
+ en: 'Move {name} down in the list',
842
+ bg: 'Премести {name} надолу в списъка',
843
+ doesEn: 'The ↓ arrow in the Order column: one place down. A run of several takes the ticked sessions top to bottom.',
844
+ doesBg: 'Стрелката ↓ в колона „Ред“: едно място надолу. Пускане на няколко взима отметнатите сесии отгоре надолу.',
845
+ },
830
846
  {
831
847
  key: 'home.delete',
832
848
  kind: 'button',
@@ -1192,6 +1208,22 @@ export const SYSTEM_GUIDE = [
1192
1208
  doesEn: 'One local commit per task whatever the outcome; unticked leaves the changes loose in the working tree.',
1193
1209
  doesBg: 'По един локален комит на задача, независимо от изхода; без отметка промените остават свободни в работното дърво.',
1194
1210
  },
1211
+ {
1212
+ key: 'vcs.dirty',
1213
+ kind: 'note',
1214
+ en: 'Uncommitted changes',
1215
+ bg: 'Некомитнати промени',
1216
+ doesEn: 'What the first task does when the repository has uncommitted changes: keep version control out (as until now), or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
1217
+ doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: без контрол на версиите (както досега) или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
1218
+ },
1219
+ {
1220
+ key: 'vcs.snapshotTake',
1221
+ kind: 'button',
1222
+ en: 'Take the starting snapshot',
1223
+ bg: 'Направи началната снимка',
1224
+ doesEn: 'Under the list of uncommitted files, each set to "Include in the starting snapshot" or "Leave out of the run": commits the included ones on the snapshot branch; left-out files go to the repository\'s .git/info/exclude. Until it is pressed a run of the session is refused and nothing changes.',
1225
+ doesBg: 'Под списъка с некомитнати файлове, всеки с „Включи в началната снимка“ или „Остави извън изпълнението“: комитва включените на клона на снимката; оставените извън отиват в .git/info/exclude на хранилището. Докато не е натиснат, пускане на сесията се отказва и нищо не се променя.',
1226
+ },
1195
1227
  {
1196
1228
  key: 'vcs.ready',
1197
1229
  kind: 'note',
@@ -147,6 +147,8 @@ export function buildPlanExport(scope) {
147
147
  ...(s.vcs?.existingBranch ? { existingBranch: s.vcs.existingBranch } : {}),
148
148
  // Only when switched off: absent is on.
149
149
  ...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
150
+ // Only when chosen: absent is reject, as it always was.
151
+ ...(s.vcs?.dirtyWorktree && s.vcs.dirtyWorktree.policy !== 'reject' ? { dirtyWorktree: { ...s.vcs.dirtyWorktree } } : {}),
150
152
  },
151
153
  review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
152
154
  ...(s.projectDir ? { projectDir: s.projectDir } : {}),
@@ -29,6 +29,27 @@ const ACTIVE_STATUSES = ['running', 'waiting-approval'];
29
29
  * feature works at all would mean most runs go unreviewed.
30
30
  */
31
31
  export const DEFAULT_REVIEW = { enabled: true, model: '' };
32
+ /**
33
+ * Where a session sits in the list: its position, or, for one saved before the list could be
34
+ * arranged, minus the time it was created — which keeps those newest first, as they always were,
35
+ * and above any arranged session only if they are newer than the list's top was.
36
+ */
37
+ export function sessionListKey(s) {
38
+ if (typeof s.position === 'number' && Number.isFinite(s.position))
39
+ return s.position;
40
+ const t = Date.parse(s.createdAt);
41
+ return Number.isNaN(t) ? 0 : -t;
42
+ }
43
+ /** The sessions list's order: by position, then newest first, then by id so it never wobbles. */
44
+ export function bySessionList(a, b) {
45
+ const byKey = sessionListKey(a) - sessionListKey(b);
46
+ if (byKey !== 0)
47
+ return byKey;
48
+ const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
49
+ if (byTime !== 0 && !Number.isNaN(byTime))
50
+ return byTime;
51
+ return b.id.localeCompare(a.id);
52
+ }
32
53
  export const DEFAULT_VCS = {
33
54
  enabled: true,
34
55
  repoDir: '',
@@ -254,12 +275,50 @@ export class SessionStore {
254
275
  if (s)
255
276
  out.push(s);
256
277
  }
257
- return out.sort((a, b) => {
258
- const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
259
- if (byTime !== 0 && !Number.isNaN(byTime))
260
- return byTime;
261
- return b.id.localeCompare(a.id);
262
- });
278
+ return out.sort(bySessionList);
279
+ }
280
+ /**
281
+ * Puts these sessions at the top of the list, in this order.
282
+ *
283
+ * An import calls it with the plan's sessions, so they appear as the plan wrote them. Before
284
+ * this, the list was newest first, and a plan whose sessions were created one after another
285
+ * read upside down.
286
+ */
287
+ async placeOnTop(ids) {
288
+ const wanted = new Set(ids);
289
+ const others = (await this.listSessions()).filter((s) => !wanted.has(s.id));
290
+ const top = others.length > 0 ? Math.min(...others.map(sessionListKey)) : 0;
291
+ for (const [i, id] of ids.entries()) {
292
+ await this.updateSession(id, (s) => {
293
+ s.position = top - ids.length + i;
294
+ });
295
+ }
296
+ }
297
+ /**
298
+ * The operator's own order: every session, top to bottom.
299
+ *
300
+ * The whole list is given, not one move, so two pages moving rows at once cannot leave positions
301
+ * that contradict each other: the last order sent is the order there is. A list that names a
302
+ * session that does not exist, or leaves one out, is refused rather than guessed at.
303
+ */
304
+ async reorderSessions(ids) {
305
+ const all = await this.listSessions();
306
+ const known = new Set(all.map((s) => s.id));
307
+ const given = new Set(ids);
308
+ if (given.size !== ids.length)
309
+ throw new Error('The order names a session twice.');
310
+ const unknown = ids.filter((id) => !known.has(id));
311
+ if (unknown.length > 0)
312
+ throw new Error(`The order names a session that does not exist: ${unknown.join(', ')}.`);
313
+ const missing = all.filter((s) => !given.has(s.id)).map((s) => s.name);
314
+ if (missing.length > 0)
315
+ throw new Error(`The order leaves out ${missing.length} session(s): ${missing.slice(0, 5).join(', ')}. Reload the list and try again.`);
316
+ for (const [i, id] of ids.entries()) {
317
+ await this.updateSession(id, (s) => {
318
+ s.position = i;
319
+ });
320
+ }
321
+ return await this.listSessions();
263
322
  }
264
323
  /**
265
324
  * The session of this id, or null when there is none.
@@ -282,10 +341,13 @@ export class SessionStore {
282
341
  return await this.readSession(path);
283
342
  }
284
343
  async createSession(name, projectDir = '') {
344
+ // A new session goes on top of the list, where it is looked for.
345
+ const existing = await this.listSessions();
285
346
  const session = {
286
347
  id: newId(),
287
348
  name: name.trim() || 'untitled',
288
349
  createdAt: new Date().toISOString(),
350
+ position: existing.length > 0 ? Math.min(...existing.map(sessionListKey)) - 1 : 0,
289
351
  status: 'idle',
290
352
  contractSent: false,
291
353
  // A queue is a chain until the operator says otherwise, which is how it has always