copilot-operator 0.1.3 → 0.1.5

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 (49) hide show
  1. package/README.md +8 -2
  2. package/dist/src/api/operator.controller.js +6 -1
  3. package/dist/src/api/operator.service.js +69 -22
  4. package/dist/src/exec/authorizer.js +2 -2
  5. package/dist/src/exec/commandHeads.js +799 -0
  6. package/dist/src/exec/policy.js +1 -1
  7. package/dist/src/exec/programs.js +22 -82
  8. package/dist/src/exec/reportFile.js +5 -2
  9. package/dist/src/orchestrator/review.js +4 -3
  10. package/dist/src/orchestrator/taskRunner.js +33 -8
  11. package/dist/src/plan/brief.js +1248 -1221
  12. package/dist/src/plan/schema.js +19 -0
  13. package/dist/src/plan/systemGuide.js +40 -0
  14. package/dist/src/protocol/reporter.js +8 -6
  15. package/dist/src/session/exports.js +131 -1
  16. package/dist/src/session/store.js +2 -0
  17. package/dist/src/vcs/taskVcs.js +153 -11
  18. package/dist/web/404.html +2 -2
  19. package/dist/web/_next/static/chunks/867-0cd2c3cfd0ff0a45.js +1 -0
  20. package/dist/web/_next/static/chunks/89-382e9b29486d2747.js +1 -0
  21. package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-83543937cecde5de.js +1 -0
  23. package/dist/web/_next/static/chunks/app/sessions/view/page-4631bdf5113a73b2.js +1 -0
  24. package/dist/web/appearance.html +2 -2
  25. package/dist/web/appearance.txt +10 -10
  26. package/dist/web/defaults.html +2 -2
  27. package/dist/web/defaults.txt +10 -10
  28. package/dist/web/history.html +2 -2
  29. package/dist/web/history.txt +10 -10
  30. package/dist/web/import.html +2 -2
  31. package/dist/web/import.txt +10 -10
  32. package/dist/web/index.html +2 -2
  33. package/dist/web/index.txt +10 -10
  34. package/dist/web/level1.html +2 -2
  35. package/dist/web/level1.txt +10 -10
  36. package/dist/web/presets.html +2 -2
  37. package/dist/web/presets.txt +10 -10
  38. package/dist/web/sessions/view.html +2 -2
  39. package/dist/web/sessions/view.txt +10 -10
  40. package/dist/web/system.html +2 -2
  41. package/dist/web/system.txt +10 -10
  42. package/package.json +4 -3
  43. package/dist/web/_next/static/chunks/867-55627c5122ff6336.js +0 -1
  44. package/dist/web/_next/static/chunks/89-5585315f51e4c481.js +0 -1
  45. package/dist/web/_next/static/chunks/app/defaults/page-4ff25cafe6de779c.js +0 -1
  46. package/dist/web/_next/static/chunks/app/history/page-2232949a2ff8559e.js +0 -1
  47. package/dist/web/_next/static/chunks/app/sessions/view/page-9dda275f4b1b0423.js +0 -1
  48. /package/dist/web/_next/static/{QwjyyBjkO5xlKFU5O6Wux → 7TE2rEuHqGdGx14If8TEb}/_buildManifest.js +0 -0
  49. /package/dist/web/_next/static/{QwjyyBjkO5xlKFU5O6Wux → 7TE2rEuHqGdGx14If8TEb}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -132,8 +132,14 @@ Six rules decide what a step may do, and each is recorded with the run that it g
132
132
  - **Only the declared toolchain runs.** `execution.allowedPrograms` names the programs a command
133
133
  may start — the shells, the JavaScript, .NET, Java, Python, Go and Rust tools, `git`, and a few
134
134
  Windows utilities. A command that starts anything else is refused and sent back with the reason.
135
- An empty list turns the gate off. This is a floor against the unknown binary, not a boundary:
136
- allowing `node` allows `node -e`.
135
+ The programs are read the way PowerShell reads a line (`src/exec/commandHeads.ts`): strings,
136
+ operators, array values, method arguments and hashtable keys are not programs, and a command in a
137
+ script block, a subexpression, a string's `$( )` or after an assignment is still found. A line that
138
+ cannot be read with certainty — an unclosed quote or bracket, a program called through a variable
139
+ (`& $exe`) — is refused rather than guessed at. `cmd` steps are read by `cmd`'s own rules. A refused
140
+ step is reported to the chat as refused and never executed, with no exit code, apart from a
141
+ command that ran and failed. An empty list turns the gate off. This is a floor against the unknown
142
+ binary, not a boundary: allowing `node` allows `node -e`.
137
143
  - **Autonomy is graded.** An unattended run carries strictly more restrictions than a watched one,
138
144
  because the thing that makes a watched run safe is a person reading each line. Unattended
139
145
  requires a non-empty allowlist, and refuses an allowed program used to evaluate a string
@@ -251,7 +251,12 @@ let OperatorController = class OperatorController {
251
251
  */
252
252
  async exportBundle(res, body) {
253
253
  const pairs = (body?.tasks ?? [])
254
- .map((x) => ({ sessionId: (x?.sessionId ?? '').trim(), taskId: (x?.taskId ?? '').trim() }))
254
+ .map((x) => ({
255
+ sessionId: (x?.sessionId ?? '').trim(),
256
+ taskId: (x?.taskId ?? '').trim(),
257
+ // One attempt of the task rather than the task as it is now; see `exportBundle`.
258
+ ...(Number.isInteger(x?.attempt) ? { attempt: x.attempt } : {}),
259
+ }))
255
260
  .filter((x) => x.sessionId && x.taskId);
256
261
  try {
257
262
  const { fileName, content } = await this.ops.exportBundle(pairs);
@@ -23,7 +23,7 @@ import { newId, tidyVcsPlan } from '../session/model.js';
23
23
  import { runSession, openBrowser } from '../orchestrator/taskRunner.js';
24
24
  import { buildExport } from '../session/exportRecord.js';
25
25
  import { buildDebugExport } from '../session/debugExport.js';
26
- import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName } from '../session/exports.js';
26
+ import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName, exportMachine, taskAtAttempt, withTask, writeAttemptRecord } from '../session/exports.js';
27
27
  import { suggestRunName } from '../session/runName.js';
28
28
  import { mirrorAllProjects, removeProjectMirrors, contextRoot } from '../context/desktopMirror.js';
29
29
  import { describeMirror } from '../context/projectMirror.js';
@@ -36,7 +36,7 @@ function sameFolder(a, b) {
36
36
  return a.trim() !== '' && norm(a) === norm(b);
37
37
  }
38
38
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
39
- import { vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
39
+ import { commitInterrupted, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
40
40
  import { gitAvailable, repoUnusableReason } from '../vcs/git.js';
41
41
  import { makeAuthorizer } from '../exec/authorizer.js';
42
42
  import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
@@ -81,6 +81,9 @@ let OperatorService = class OperatorService {
81
81
  level: 'warn',
82
82
  message: `"${r.title}" was left unfinished by an earlier run and has been marked aborted`,
83
83
  });
84
+ // What it had changed goes onto its branch, and its record into its run folder, as a
85
+ // finished task's would. See `commitInterrupted` and `writeAttemptRecord`.
86
+ await this.settleRecovered(r.sessionId, r.taskId).catch((e) => console.warn(`[api] could not settle "${r.title}": ${e.message}`));
84
87
  }
85
88
  if (recovered.length > 0) {
86
89
  console.log(`[api] closed ${recovered.length} task(s) left unfinished by an earlier run`);
@@ -88,6 +91,31 @@ let OperatorService = class OperatorService {
88
91
  })();
89
92
  return this.ready;
90
93
  }
94
+ /**
95
+ * Closes the loose ends of one task the previous process left unfinished: commits its work on its
96
+ * branch, says so on the task, and writes the attempt's plan, work and runner into its run folder.
97
+ */
98
+ async settleRecovered(sessionId, taskId) {
99
+ const session = await this.store.getSession(sessionId);
100
+ const task = session?.tasks.find((t) => t.id === taskId);
101
+ if (!session || !task)
102
+ return;
103
+ const vcs = await commitInterrupted(session, task, task.reason ?? 'interrupted', this.bus);
104
+ const settled = vcs?.commit
105
+ ? await this.store.updateSession(sessionId, (s) => {
106
+ const t = s.tasks.find((x) => x.id === taskId);
107
+ if (!t)
108
+ return;
109
+ t.vcs = vcs;
110
+ t.reason = `${t.reason ?? ''} What it had changed is committed on ${vcs.branch} as ${vcs.commit?.slice(0, 8)}.`.trim();
111
+ })
112
+ : session;
113
+ const ended = settled.tasks.find((t) => t.id === taskId);
114
+ if (!ended)
115
+ return;
116
+ const cfg = await this.settings.load();
117
+ await writeAttemptRecord(settled, ended, cfg.resolved.runsDir, exportMachine(cfg));
118
+ }
91
119
  /** Runs the startup work now, so it does not wait for the first request. */
92
120
  bootstrap() {
93
121
  return this.init();
@@ -221,6 +249,20 @@ let OperatorService = class OperatorService {
221
249
  if (reason)
222
250
  throw new Error(reason);
223
251
  }
252
+ if (merged.baseBranch !== undefined)
253
+ merged.baseBranch = merged.baseBranch.trim();
254
+ /*
255
+ * Where the session starts is fixed at its first run. A new choice clears that record, so
256
+ * the next task to run is cut from what was chosen now; the tasks that already ran keep
257
+ * their own base commits, which is what a re-run of them goes back to.
258
+ */
259
+ const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
260
+ (merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
261
+ merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
262
+ if (startChanged) {
263
+ s.vcsBaseCommit = undefined;
264
+ s.vcsStart = undefined;
265
+ }
224
266
  s.vcs = merged;
225
267
  }
226
268
  });
@@ -498,12 +540,7 @@ let OperatorService = class OperatorService {
498
540
  ? buildPlanExport(scope)
499
541
  : kind === 'domain'
500
542
  ? await buildDomainExport(scope, cfg.resolved.runsDir)
501
- : await buildBotExport(scope, cfg.resolved.runsDir, {
502
- node: process.versions.node,
503
- platform: process.platform,
504
- cwd: cfg.resolved.cwd,
505
- limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, mode: cfg.execution.mode } },
506
- });
543
+ : await buildBotExport(scope, cfg.resolved.runsDir, exportMachine(cfg));
507
544
  return { fileName: exportFileName(kind, scope.label), content: JSON.stringify(document, null, 2) };
508
545
  }
509
546
  /**
@@ -525,29 +562,39 @@ let OperatorService = class OperatorService {
525
562
  set.add(taskId);
526
563
  wanted.set(sessionId, set);
527
564
  }
565
+ /*
566
+ * A pair may name one attempt of its task — the button beside an earlier attempt does. That
567
+ * task is then exported as it stood for that attempt (`taskAtAttempt`): its text, its outcome,
568
+ * its run folder, and only the attempts before it.
569
+ */
528
570
  const sessions = [];
529
571
  for (const sessionId of wanted.keys()) {
530
- const session = await this.store.getSession(sessionId);
531
- if (session)
532
- sessions.push(session);
572
+ let session = await this.store.getSession(sessionId);
573
+ if (!session)
574
+ continue;
575
+ for (const pair of pairs.filter((x) => x.sessionId === sessionId && x.attempt !== undefined)) {
576
+ const task = session.tasks.find((t) => t.id === pair.taskId);
577
+ const asItWas = task ? taskAtAttempt(task, pair.attempt) : null;
578
+ if (task && !asItWas)
579
+ throw new Error(`"${task.title}" has no attempt ${pair.attempt}.`);
580
+ if (asItWas)
581
+ session = withTask(session, asItWas);
582
+ }
583
+ sessions.push(session);
533
584
  }
534
585
  const found = sessions.flatMap((s) => s.tasks.filter((t) => wanted.get(s.id)?.has(t.id)));
535
586
  if (found.length === 0)
536
587
  throw new Error('None of the chosen tasks still exists.');
537
588
  // Named after what was chosen rather than after a run, because a selection is not a run: it
538
589
  // may be one task of one, or a task from each of three.
590
+ const one = pairs.length === 1 ? pairs[0] : undefined;
539
591
  const label = found.length === 1 && sessions.length === 1
540
- ? `${sessions[0].name}-${found[0].title}`
592
+ ? `${sessions[0].name}-${found[0].title}${one?.attempt !== undefined ? `-attempt-${one.attempt}` : ''}`
541
593
  : sessions.length === 1
542
594
  ? `${sessions[0].name}-${found.length}-tasks`
543
595
  : `${found.length}-tasks-${sessions.length}-sessions`;
544
596
  const cfg = await this.settings.load();
545
- const document = await buildBundleExport({ sessions, taskFilter: (s, t) => !!wanted.get(s.id)?.has(t.id), label }, cfg.resolved.runsDir, {
546
- node: process.versions.node,
547
- platform: process.platform,
548
- cwd: cfg.resolved.cwd,
549
- limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, mode: cfg.execution.mode } },
550
- });
597
+ const document = await buildBundleExport({ sessions, taskFilter: (s, t) => !!wanted.get(s.id)?.has(t.id), label }, cfg.resolved.runsDir, exportMachine(cfg));
551
598
  return { fileName: exportFileName('bundle', label), content: JSON.stringify(document, null, 2) };
552
599
  }
553
600
  // --- running --------------------------------------------------------------------------
@@ -718,7 +765,7 @@ let OperatorService = class OperatorService {
718
765
  r.controller.abort();
719
766
  for (const [id, w] of this.waiting) {
720
767
  if (w.approval.sessionId === sessionId) {
721
- w.resolve({ action: 'abort', reason: 'stopped by the operator' });
768
+ w.resolve({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
722
769
  this.waiting.delete(id);
723
770
  }
724
771
  }
@@ -1275,8 +1322,8 @@ let OperatorService = class OperatorService {
1275
1322
  const decision = action === 'run' || action === 'run-all'
1276
1323
  ? { action: 'run' }
1277
1324
  : action === 'skip'
1278
- ? { action: 'skip', reason: 'skipped by the operator' }
1279
- : { action: 'abort', reason: 'aborted by the operator' };
1325
+ ? { action: 'skip', reason: 'skipped by the operator', by: 'operator' }
1326
+ : { action: 'abort', reason: 'aborted by the operator', by: 'operator' };
1280
1327
  w.resolve(decision);
1281
1328
  this.bus.publish({ sessionId: w.approval.sessionId, taskId: w.approval.taskId, type: 'approval-decided', level: 'info',
1282
1329
  message: `step ${w.approval.stepId}: ${action}`, data: { approvalId, action } });
@@ -1359,7 +1406,7 @@ let OperatorService = class OperatorService {
1359
1406
  ...(held ? { network: held.network } : {}),
1360
1407
  };
1361
1408
  if (signal.aborted) {
1362
- resolvePromise({ action: 'abort', reason: 'stopped by the operator' });
1409
+ resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
1363
1410
  return;
1364
1411
  }
1365
1412
  this.waiting.set(approval.id, { approval, resolve: resolvePromise });
@@ -47,9 +47,9 @@ export function terminalAuthorizer(cfg, print) {
47
47
  try {
48
48
  const answer = (await rl.question(' [Enter] run [s] skip [q] abort > ')).trim().toLowerCase();
49
49
  if (answer === 'q')
50
- return { action: 'abort', reason: 'aborted by the operator' };
50
+ return { action: 'abort', reason: 'aborted by the operator', by: 'operator' };
51
51
  if (answer === 's')
52
- return { action: 'skip', reason: 'skipped by the operator' };
52
+ return { action: 'skip', reason: 'skipped by the operator', by: 'operator' };
53
53
  return { action: 'run' };
54
54
  }
55
55
  finally {