copilot-operator 0.1.11 → 0.1.13
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.
- package/dist/src/api/operator.controller.js +1 -1
- package/dist/src/api/operator.service.js +98 -8
- package/dist/src/config/schema.js +14 -3
- package/dist/src/context/desktopMirror.js +73 -13
- package/dist/src/context/projectMirror.js +69 -1
- package/dist/src/orchestrator/contract.js +56 -1
- package/dist/src/orchestrator/taskRunner.js +140 -16
- package/dist/src/plan/brief.js +39 -8
- package/dist/src/plan/importPlan.js +3 -0
- package/dist/src/plan/schema.js +26 -2
- package/dist/src/plan/script.js +79 -75
- package/dist/src/plan/systemGuide.js +79 -15
- package/dist/src/session/compose.js +16 -1
- package/dist/src/session/exportRecord.js +8 -1
- package/dist/src/session/exports.js +3 -0
- package/dist/src/session/handoff.js +41 -3
- package/dist/src/session/model.js +9 -0
- package/dist/src/session/store.js +11 -7
- package/dist/src/session/story.js +74 -18
- package/dist/src/transport/chatTransport.js +1 -0
- package/dist/src/transport/copilotTransport.js +80 -61
- package/dist/src/vcs/git.js +92 -2
- package/dist/src/vcs/taskVcs.js +100 -19
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
- package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +1 -0
- package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +6 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
- package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
- package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +0 -1
- /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_ssgManifest.js +0 -0
|
@@ -457,7 +457,7 @@ let OperatorController = class OperatorController {
|
|
|
457
457
|
/** Stores it. A field left out is kept; an empty `rootDir` clears the default. */
|
|
458
458
|
setProject(body) {
|
|
459
459
|
return this.ops
|
|
460
|
-
.setProject({ rootDir: body?.rootDir, name: body?.name, others: body?.others, mirrorToDesktop: body?.mirrorToDesktop, mirror: body?.mirror })
|
|
460
|
+
.setProject({ rootDir: body?.rootDir, name: body?.name, others: body?.others, mirrorToDesktop: body?.mirrorToDesktop, mirror: body?.mirror, desktop: body?.desktop })
|
|
461
461
|
.catch(fail);
|
|
462
462
|
}
|
|
463
463
|
/**
|
|
@@ -19,7 +19,7 @@ import { join, resolve } from 'node:path';
|
|
|
19
19
|
import { installLayout } from '../config/layout.js';
|
|
20
20
|
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
|
|
21
21
|
import { EventBus } from '../session/events.js';
|
|
22
|
-
import { newId } from '../session/model.js';
|
|
22
|
+
import { isContinuable, newId } from '../session/model.js';
|
|
23
23
|
import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
|
|
24
24
|
import { buildExport } from '../session/exportRecord.js';
|
|
25
25
|
import { buildDebugExport } from '../session/debugExport.js';
|
|
@@ -38,7 +38,7 @@ function sameFolder(a, b) {
|
|
|
38
38
|
import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
|
|
39
39
|
import { readInterruption } from '../session/interruption.js';
|
|
40
40
|
import { commitInterrupted, repoDirOf, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
|
|
41
|
-
import { changedFilesBetween, fileAt, gitAvailable, repoUnusableReason } from '../vcs/git.js';
|
|
41
|
+
import { branchExists, changedFilesBetween, fileAt, gitAvailable, localBranches, plannedBranchName, repoUnusableReason } from '../vcs/git.js';
|
|
42
42
|
import { makeAuthorizer } from '../exec/authorizer.js';
|
|
43
43
|
import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
|
|
44
44
|
import { pickFolder } from './folderPicker.js';
|
|
@@ -235,9 +235,16 @@ let OperatorService = class OperatorService {
|
|
|
235
235
|
}
|
|
236
236
|
async updateSession(id, patch) {
|
|
237
237
|
await this.init();
|
|
238
|
+
if (patch.active !== undefined && this.running.has(id)) {
|
|
239
|
+
throw new Error('This session is running; its status can be changed once it has stopped.');
|
|
240
|
+
}
|
|
238
241
|
return await this.store.updateSession(id, (s) => {
|
|
239
242
|
if (patch.name !== undefined)
|
|
240
243
|
s.name = patch.name.trim() || s.name;
|
|
244
|
+
if (patch.active === true)
|
|
245
|
+
delete s.active;
|
|
246
|
+
else if (patch.active === false)
|
|
247
|
+
s.active = false;
|
|
241
248
|
if (patch.onFailure === 'stop' || patch.onFailure === 'continue')
|
|
242
249
|
s.onFailure = patch.onFailure;
|
|
243
250
|
// An empty string is a real choice here: it means "leave the chat on whatever it is".
|
|
@@ -270,6 +277,8 @@ let OperatorService = class OperatorService {
|
|
|
270
277
|
}
|
|
271
278
|
if (merged.baseBranch !== undefined)
|
|
272
279
|
merged.baseBranch = merged.baseBranch.trim();
|
|
280
|
+
if (merged.existingBranch !== undefined)
|
|
281
|
+
merged.existingBranch = merged.existingBranch.trim();
|
|
273
282
|
/*
|
|
274
283
|
* Where the session starts is fixed at its first run. A new choice clears that record, so
|
|
275
284
|
* the next task to run is cut from what was chosen now; the tasks that already ran keep
|
|
@@ -277,6 +286,8 @@ let OperatorService = class OperatorService {
|
|
|
277
286
|
*/
|
|
278
287
|
const startChanged = (merged.startFrom ?? 'head') !== (s.vcs?.startFrom ?? 'head') ||
|
|
279
288
|
(merged.baseBranch || 'main') !== (s.vcs?.baseBranch || 'main') ||
|
|
289
|
+
(merged.existingBranch ?? '') !== (s.vcs?.existingBranch ?? '') ||
|
|
290
|
+
(merged.updateFromRemote !== false) !== (s.vcs?.updateFromRemote !== false) ||
|
|
280
291
|
merged.repoDir.toLowerCase() !== (s.vcs?.repoDir ?? '').trim().toLowerCase();
|
|
281
292
|
if (startChanged) {
|
|
282
293
|
s.vcsBaseCommit = undefined;
|
|
@@ -324,7 +335,7 @@ let OperatorService = class OperatorService {
|
|
|
324
335
|
* the text it ran with, and only then is the new text applied. Editing in place would leave
|
|
325
336
|
* the old attempt's summary sitting under a question that was never asked.
|
|
326
337
|
*/
|
|
327
|
-
/** "Continue" on a task that stopped before it finished —
|
|
338
|
+
/** "Continue" on a task that stopped before it finished — a limit from the settings, the bot stopping, the operator: same chat, same branch, fresh count. */
|
|
328
339
|
async continueTask(sessionId, taskId) {
|
|
329
340
|
await this.init();
|
|
330
341
|
const task = await this.store.continueTask(sessionId, taskId);
|
|
@@ -749,6 +760,10 @@ let OperatorService = class OperatorService {
|
|
|
749
760
|
const session = await this.store.getSession(sessionId);
|
|
750
761
|
if (!session)
|
|
751
762
|
return { started: false, reason: 'no such session', done: Promise.resolve(idle) };
|
|
763
|
+
// Set aside by the operator on the Sessions page: not started by anything until made active.
|
|
764
|
+
if (session.active === false) {
|
|
765
|
+
return { started: false, reason: 'the session is inactive; make it active on the Sessions page to run it', done: Promise.resolve(idle) };
|
|
766
|
+
}
|
|
752
767
|
const queuedIds = queuedToRun(session, onlyTasks).map((t) => t.id);
|
|
753
768
|
if (queuedIds.length === 0)
|
|
754
769
|
return { started: false, reason: 'no queued tasks', done: Promise.resolve(idle) };
|
|
@@ -804,6 +819,8 @@ let OperatorService = class OperatorService {
|
|
|
804
819
|
// The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
|
|
805
820
|
// and the policy.json of a later task must say so.
|
|
806
821
|
currentMode: () => this.running.get(sessionId)?.mode ?? mode,
|
|
822
|
+
// The Desktop copies follow the work while it happens, not only before the run.
|
|
823
|
+
onWorkChanged: () => this.requestDesktopRefresh(),
|
|
807
824
|
// The session decides whether its tasks are one chain or a set of independent checks.
|
|
808
825
|
continueOnFailure: session.onFailure === 'continue',
|
|
809
826
|
transport,
|
|
@@ -1348,6 +1365,31 @@ let OperatorService = class OperatorService {
|
|
|
1348
1365
|
});
|
|
1349
1366
|
}
|
|
1350
1367
|
});
|
|
1368
|
+
if (repoIssues.length > 0)
|
|
1369
|
+
return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
|
|
1370
|
+
/*
|
|
1371
|
+
* A branch to carry on must be there to carry on — unless a session before it in this same plan
|
|
1372
|
+
* is the one that makes it. Refused now, with the branches that are there, rather than at the
|
|
1373
|
+
* first run with a task refused.
|
|
1374
|
+
*/
|
|
1375
|
+
for (const [i, session] of check.plan.sessions.entries()) {
|
|
1376
|
+
const wanted = session.vcs?.enabled === false ? '' : (session.vcs?.existingBranch ?? '').trim();
|
|
1377
|
+
if (!wanted)
|
|
1378
|
+
continue;
|
|
1379
|
+
const dir = session.vcs?.repoDir?.trim() || session.mirror?.rootDir?.trim() || '';
|
|
1380
|
+
const madeEarlier = check.plan.sessions
|
|
1381
|
+
.slice(0, i)
|
|
1382
|
+
.some((e) => !!e.vcs?.branchName?.trim() && plannedBranchName(e.vcs.branchName, e.vcs.branchPrefix?.trim() || 'cop/') === wanted);
|
|
1383
|
+
if (madeEarlier || (await branchExists(dir, wanted)))
|
|
1384
|
+
continue;
|
|
1385
|
+
const have = await localBranches(dir);
|
|
1386
|
+
repoIssues.push({
|
|
1387
|
+
path: `sessions[${i}].vcs.existingBranch`,
|
|
1388
|
+
message: `${dir} has no local branch "${wanted}" to carry on. ` +
|
|
1389
|
+
(have.length > 0 ? `Its branches: ${have.slice(0, 20).join(', ')}.` : 'It has no branches yet.') +
|
|
1390
|
+
' Give the exact name, or use startFrom "branch" to start a new one.',
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1351
1393
|
if (repoIssues.length > 0)
|
|
1352
1394
|
return { ok: false, issues: repoIssues, warnings: check.warnings, duplicates: [] };
|
|
1353
1395
|
return { ...check, duplicates: await this.findDuplicates(check.plan) };
|
|
@@ -1540,7 +1582,10 @@ let OperatorService = class OperatorService {
|
|
|
1540
1582
|
return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
|
|
1541
1583
|
// `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
|
|
1542
1584
|
// those differ, and the second is the one the operator is asking about.
|
|
1543
|
-
|
|
1585
|
+
const preflight = await vcsPreflight(session);
|
|
1586
|
+
// The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
|
|
1587
|
+
const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
|
|
1588
|
+
return { ...preflight, git, work: sessionBranches(session), branches };
|
|
1544
1589
|
}
|
|
1545
1590
|
/**
|
|
1546
1591
|
* What going back to before a task would do, and then doing it.
|
|
@@ -1814,7 +1859,7 @@ let OperatorService = class OperatorService {
|
|
|
1814
1859
|
const problem = repoUnusableReason(rootDir);
|
|
1815
1860
|
const others = (cfg.project?.others ?? []).map((o) => {
|
|
1816
1861
|
const p = repoUnusableReason(o.rootDir);
|
|
1817
|
-
return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror };
|
|
1862
|
+
return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror, desktop: o.desktop !== false };
|
|
1818
1863
|
});
|
|
1819
1864
|
return {
|
|
1820
1865
|
rootDir,
|
|
@@ -1823,10 +1868,42 @@ let OperatorService = class OperatorService {
|
|
|
1823
1868
|
...(problem ? { repoProblem: problem } : {}),
|
|
1824
1869
|
others,
|
|
1825
1870
|
mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
|
|
1871
|
+
desktop: cfg.project?.desktop !== false,
|
|
1826
1872
|
mirror: cfg.project?.mirror,
|
|
1827
1873
|
contextRoot: contextRoot(cfg),
|
|
1828
1874
|
};
|
|
1829
1875
|
}
|
|
1876
|
+
desktopRefresh = null;
|
|
1877
|
+
desktopRefreshAgain = false;
|
|
1878
|
+
/**
|
|
1879
|
+
* Asks for the Desktop copies to be brought up to date, while a run works.
|
|
1880
|
+
*
|
|
1881
|
+
* They used to be refreshed once, before a run, so a task that changed files left the Desktop —
|
|
1882
|
+
* and OneDrive, and the chat that reads from it — on the code as it was before the task. The
|
|
1883
|
+
* runner now calls this after every round of steps and after every task. One refresh runs at a
|
|
1884
|
+
* time: a request that arrives while one is running is remembered and served by one more pass
|
|
1885
|
+
* after it, so a burst of rounds costs two passes, not one per round, and the last pass always
|
|
1886
|
+
* sees the latest files. A failure is reported and never stops the run.
|
|
1887
|
+
*/
|
|
1888
|
+
requestDesktopRefresh() {
|
|
1889
|
+
if (this.desktopRefresh) {
|
|
1890
|
+
this.desktopRefreshAgain = true;
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
this.desktopRefresh = (async () => {
|
|
1894
|
+
do {
|
|
1895
|
+
this.desktopRefreshAgain = false;
|
|
1896
|
+
await this.refreshDesktopMirrors();
|
|
1897
|
+
} while (this.desktopRefreshAgain);
|
|
1898
|
+
})().finally(() => {
|
|
1899
|
+
this.desktopRefresh = null;
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
/** Resolves once no Desktop refresh is running or waiting. For the checks, and for a clean stop. */
|
|
1903
|
+
async desktopRefreshSettled() {
|
|
1904
|
+
while (this.desktopRefresh)
|
|
1905
|
+
await this.desktopRefresh;
|
|
1906
|
+
}
|
|
1830
1907
|
/**
|
|
1831
1908
|
* Refreshes every project's Desktop folder, when the switch is on. Called before a run
|
|
1832
1909
|
* starts, so what OneDrive holds when the chat opens is the code as it is now, for every
|
|
@@ -1841,8 +1918,8 @@ let OperatorService = class OperatorService {
|
|
|
1841
1918
|
this.bus.publish({
|
|
1842
1919
|
sessionId: '*',
|
|
1843
1920
|
type: 'desktop-mirror',
|
|
1844
|
-
level: 'info',
|
|
1845
|
-
message: outcomes.map((o) => `${o.name}: ${describeMirror(o.result)}`).join('; ') || 'no projects to mirror',
|
|
1921
|
+
level: outcomes.some((o) => o.problem) ? 'warn' : 'info',
|
|
1922
|
+
message: outcomes.map((o) => `${o.name}: ${o.result ? describeMirror(o.result) : `not refreshed — ${o.problem}`}`).join('; ') || 'no projects to mirror',
|
|
1846
1923
|
});
|
|
1847
1924
|
}
|
|
1848
1925
|
catch (e) {
|
|
@@ -1871,6 +1948,8 @@ let OperatorService = class OperatorService {
|
|
|
1871
1948
|
name: (o.name ?? '').trim(),
|
|
1872
1949
|
rootDir: (o.rootDir ?? '').trim(),
|
|
1873
1950
|
...(o.mirror ? { mirror: o.mirror } : {}),
|
|
1951
|
+
// Stored only when it is off: absent means the project goes to the Desktop with the rest.
|
|
1952
|
+
...(o.desktop === false ? { desktop: false } : {}),
|
|
1874
1953
|
}));
|
|
1875
1954
|
const seen = new Set();
|
|
1876
1955
|
for (const o of others) {
|
|
@@ -1889,7 +1968,12 @@ let OperatorService = class OperatorService {
|
|
|
1889
1968
|
}
|
|
1890
1969
|
const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
|
|
1891
1970
|
const mirror = patch.mirror ?? current.mirror;
|
|
1892
|
-
const
|
|
1971
|
+
const desktop = patch.desktop ?? current.desktop ?? true;
|
|
1972
|
+
const { desktop: _old, ...rest } = current;
|
|
1973
|
+
const cfg = await this.settings.save({
|
|
1974
|
+
...raw,
|
|
1975
|
+
project: { ...rest, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}), ...(desktop === false ? { desktop: false } : {}) },
|
|
1976
|
+
});
|
|
1893
1977
|
/*
|
|
1894
1978
|
* The switch acts at once, both ways. On: every project's folder appears on the Desktop
|
|
1895
1979
|
* now, not at the next run, because "I turned it on and nothing happened" is a support
|
|
@@ -2054,6 +2138,12 @@ let OperatorService = class OperatorService {
|
|
|
2054
2138
|
readOnly: t.readOnly || undefined,
|
|
2055
2139
|
scope: t.scope?.length ? t.scope : undefined,
|
|
2056
2140
|
stopCode: t.stopCode,
|
|
2141
|
+
limit: t.limit,
|
|
2142
|
+
continuable: isContinuable(t) || undefined,
|
|
2143
|
+
branch: t.vcs?.branch,
|
|
2144
|
+
commit: t.vcs?.commit,
|
|
2145
|
+
vcsProblem: t.vcs?.problem,
|
|
2146
|
+
sessionInactive: s.active === false || undefined,
|
|
2057
2147
|
autoRetries: t.autoRetries || undefined,
|
|
2058
2148
|
attempt: t.attempt,
|
|
2059
2149
|
changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
|
|
@@ -47,10 +47,17 @@ const TextOrFile = z.union([z.string(), z.object({ file: z.string().min(1) })]);
|
|
|
47
47
|
/** One of git's own options, before the subcommand: `-C <path>`, `--no-pager`, `-c k=v`. */
|
|
48
48
|
const GIT_GLOBAL_OPTION = '(?:--no-pager|--paginate|--bare|--literal-pathspecs|--exec-path=\\S*|-c\\s+\\S+|' +
|
|
49
49
|
'-C\\s+(?:"[^"]*"|\'[^\']*\'|\\S+)|--git-dir[= ]\\S+|--work-tree[= ]\\S+)';
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Subcommands that have no read-only form at all.
|
|
52
|
+
*
|
|
53
|
+
* Matched as a whole subcommand, followed by neither a letter nor a hyphen: `merge` must not catch
|
|
54
|
+
* `merge-base`, which only reads (refused live on 2026-09-30, while a task checked that its branch
|
|
55
|
+
* held a checkpoint). The hyphenated ones that do write are therefore listed by their full name.
|
|
56
|
+
*/
|
|
51
57
|
const GIT_ALWAYS_WRITES = 'commit|push|reset|rebase|merge|cherry-pick|revert|clean|checkout|switch|restore|stash|' +
|
|
52
58
|
'am|apply|init|clone|filter-branch|filter-repo|update-ref|update-index|gc|prune|repack|' +
|
|
53
|
-
'submodule|worktree|mv|rm|add|pull'
|
|
59
|
+
'submodule|worktree|mv|rm|add|pull|' +
|
|
60
|
+
'checkout-index|commit-tree|merge-file|merge-index|merge-one-file|read-tree|prune-packed|update-server-info';
|
|
54
61
|
/** Subcommands that read by default and write with these arguments. */
|
|
55
62
|
const GIT_SOMETIMES_WRITES = 'remote\\s+(?:add|remove|rm|rename|set-url|set-head|set-branches|prune)\\b|' +
|
|
56
63
|
'branch\\s+(?:-[dDmMfu]\\b|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))|' +
|
|
@@ -238,7 +245,7 @@ export const RunConfigSchema = z.object({
|
|
|
238
245
|
'Disable-WindowsOptionalFeature',
|
|
239
246
|
'net\\s+user\\s+\\w+\\s+/add',
|
|
240
247
|
// git that changes something. See GIT_WRITE_NOTE below.
|
|
241
|
-
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})\\
|
|
248
|
+
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})(?![\\w-])`,
|
|
242
249
|
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_SOMETIMES_WRITES})`,
|
|
243
250
|
]),
|
|
244
251
|
})
|
|
@@ -285,6 +292,8 @@ export const RunConfigSchema = z.object({
|
|
|
285
292
|
rootDir: z.string().trim().min(1),
|
|
286
293
|
/** Which of this project's directories go to the Desktop. See `mirror` below. */
|
|
287
294
|
mirror: ProjectMirrorSelection.optional(),
|
|
295
|
+
/** `false` keeps this project off the Desktop while the switch below is on. Absent means on. */
|
|
296
|
+
desktop: z.boolean().optional(),
|
|
288
297
|
}))
|
|
289
298
|
.default([]),
|
|
290
299
|
/**
|
|
@@ -299,6 +308,8 @@ export const RunConfigSchema = z.object({
|
|
|
299
308
|
mirrorToDesktop: z.boolean().default(false),
|
|
300
309
|
/** The default project's own selection. */
|
|
301
310
|
mirror: ProjectMirrorSelection.optional(),
|
|
311
|
+
/** `false` keeps the default project off the Desktop while the switch is on. Absent means on. */
|
|
312
|
+
desktop: z.boolean().optional(),
|
|
302
313
|
})
|
|
303
314
|
.prefault({}),
|
|
304
315
|
/**
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `project.mirrorToDesktop`; a session's own mirror (the files attached to its first message)
|
|
15
15
|
* uses the same folder for its project whether or not the switch is on.
|
|
16
16
|
*/
|
|
17
|
-
import { basename, join, resolve } from 'node:path';
|
|
17
|
+
import { basename, join, resolve, relative, isAbsolute } from 'node:path';
|
|
18
18
|
import { readdir, rm, stat } from 'node:fs/promises';
|
|
19
19
|
import { mirrorProject } from './projectMirror.js';
|
|
20
20
|
import { defaultExportDir } from './contextFiles.js';
|
|
@@ -31,12 +31,51 @@ export function safeProjectName(name) {
|
|
|
31
31
|
export function knownProjects(cfg) {
|
|
32
32
|
const out = [];
|
|
33
33
|
const root = (cfg.project?.rootDir ?? '').trim();
|
|
34
|
-
if (root)
|
|
35
|
-
out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION });
|
|
34
|
+
if (root) {
|
|
35
|
+
out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION, desktop: cfg.project?.desktop !== false });
|
|
36
|
+
}
|
|
36
37
|
for (const o of cfg.project?.others ?? [])
|
|
37
|
-
out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION });
|
|
38
|
+
out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION, desktop: o.desktop !== false });
|
|
38
39
|
return out;
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether the Desktop copy of the project at `rootDir` is kept by "Keep the projects on the Desktop".
|
|
43
|
+
*
|
|
44
|
+
* Then its Desktop folder has one owner, the Desktop mirror, and a task's attachments are copied
|
|
45
|
+
* elsewhere. The two used to share it with different selections — the whole project there, the
|
|
46
|
+
* task's folders here — so every run deleted most of the folder and every round of steps put it
|
|
47
|
+
* back: thousands of deletions and uploads in a OneDrive-synced Desktop, while the chat was
|
|
48
|
+
* uploading the task's own attachments to the same OneDrive (2026-09-30, a first message timing out).
|
|
49
|
+
*/
|
|
50
|
+
export function keptOnDesktop(cfg, rootDir) {
|
|
51
|
+
return !!cfg.project?.mirrorToDesktop && knownProjects(cfg).some((p) => p.desktop && sameFolder(p.rootDir, rootDir));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The project folder to copy from, for a folder a session names as its project files' root.
|
|
55
|
+
*
|
|
56
|
+
* A folder in the bot's own Desktop area (`contextRoot`) is not a project: it is the bot's copy of
|
|
57
|
+
* one, flattened, and the place the copies are written to. Named as a root — seen live on 2026-09-30,
|
|
58
|
+
* `...\Desktop\copilot-operator-context\Automation` — the mirror read the folder it was writing: with
|
|
59
|
+
* "Keep the projects on the Desktop" on it copied its own copies over the originals while the Desktop
|
|
60
|
+
* mirror put them back, and the first message timed out; with it off the folder was not there and
|
|
61
|
+
* the task was refused. Such a folder is taken to mean the project it is the copy of; one that is the
|
|
62
|
+
* copy of no known project is refused, with what to choose instead.
|
|
63
|
+
*/
|
|
64
|
+
export function mirrorSourceRoot(cfg, rootDir) {
|
|
65
|
+
const area = resolve(contextRoot(cfg));
|
|
66
|
+
const dir = resolve(rootDir);
|
|
67
|
+
const rel = relative(area, dir);
|
|
68
|
+
if (rel === '') {
|
|
69
|
+
return { problem: `${dir} is the folder where the bot keeps its Desktop copies, not a project. Choose the project's own folder as the root of the files to attach.` };
|
|
70
|
+
}
|
|
71
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
72
|
+
return { rootDir };
|
|
73
|
+
const copyOf = knownProjects(cfg).find((p) => sameFolder(projectTargetDir(cfg, p.name), join(area, rel.split(/[\\/]/)[0])));
|
|
74
|
+
if (!copyOf) {
|
|
75
|
+
return { problem: `${dir} is inside the folder where the bot keeps its Desktop copies (${area}), not a project, and it is not the copy of any project on the Project page. Choose the project's own folder as the root of the files to attach.` };
|
|
76
|
+
}
|
|
77
|
+
return { rootDir: copyOf.rootDir, fromCopy: dir };
|
|
78
|
+
}
|
|
40
79
|
/** What to call the folder for a session's project: its Settings name, else the folder's own. */
|
|
41
80
|
export function projectNameFor(rootDir, cfg) {
|
|
42
81
|
const known = knownProjects(cfg).find((p) => sameFolder(p.rootDir, rootDir));
|
|
@@ -88,26 +127,47 @@ export async function mirrorKnownProject(cfg, project) {
|
|
|
88
127
|
});
|
|
89
128
|
return { name, rootDir: project.rootDir, targetDir, result };
|
|
90
129
|
}
|
|
91
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Every known project that is kept on the Desktop, when the switch is on; nothing when it is off.
|
|
132
|
+
*
|
|
133
|
+
* The switch is the master control and each project has its own tick under it. A project whose
|
|
134
|
+
* tick is off has its Desktop folder removed here, for the same reason the switch going off
|
|
135
|
+
* removes them all: a stale copy of a code base in the cloud is worse than none.
|
|
136
|
+
*/
|
|
92
137
|
export async function mirrorAllProjects(cfg) {
|
|
93
138
|
if (!cfg.project?.mirrorToDesktop)
|
|
94
139
|
return [];
|
|
95
140
|
await removeLegacyFlatMirror(cfg);
|
|
96
141
|
const out = [];
|
|
97
|
-
for (const p of knownProjects(cfg))
|
|
98
|
-
|
|
142
|
+
for (const p of knownProjects(cfg)) {
|
|
143
|
+
// One project whose selection is wrong keeps its old copies and says so; the others are still refreshed.
|
|
144
|
+
if (p.desktop) {
|
|
145
|
+
out.push(await mirrorKnownProject(cfg, p).catch((e) => ({
|
|
146
|
+
name: safeProjectName(p.name),
|
|
147
|
+
rootDir: p.rootDir,
|
|
148
|
+
targetDir: projectTargetDir(cfg, p.name),
|
|
149
|
+
problem: e.message,
|
|
150
|
+
})));
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
await removeProjectFolder(cfg, p);
|
|
154
|
+
}
|
|
99
155
|
return out;
|
|
100
156
|
}
|
|
157
|
+
async function removeProjectFolder(cfg, p) {
|
|
158
|
+
const dir = projectTargetDir(cfg, p.name);
|
|
159
|
+
const info = await stat(dir).catch(() => null);
|
|
160
|
+
if (!info?.isDirectory())
|
|
161
|
+
return false;
|
|
162
|
+
await rm(dir, { recursive: true, force: true });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
101
165
|
/** Removes every known project's folder from the Desktop. The switch going off. */
|
|
102
166
|
export async function removeProjectMirrors(cfg) {
|
|
103
167
|
const removed = [];
|
|
104
168
|
for (const p of knownProjects(cfg)) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!info?.isDirectory())
|
|
108
|
-
continue;
|
|
109
|
-
await rm(dir, { recursive: true, force: true });
|
|
110
|
-
removed.push(dir);
|
|
169
|
+
if (await removeProjectFolder(cfg, p))
|
|
170
|
+
removed.push(projectTargetDir(cfg, p.name));
|
|
111
171
|
}
|
|
112
172
|
return removed;
|
|
113
173
|
}
|
|
@@ -49,6 +49,55 @@ export const DEFAULT_IGNORE_DIRS = [
|
|
|
49
49
|
];
|
|
50
50
|
export const DEFAULT_SEPARATOR = '--';
|
|
51
51
|
export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
52
|
+
/** A selection that cannot be copied as asked. Thrown before the target folder is touched. */
|
|
53
|
+
export class MirrorSelectionError extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'MirrorSelectionError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Each include directory as a path under `rootDir`, looked for in `alsoUnder` when it is not there.
|
|
61
|
+
*
|
|
62
|
+
* `found` are the paths to copy; `missing` are the ones that are nowhere, with why. A directory found
|
|
63
|
+
* in a folder outside `rootDir` is missing too: its files could not be named as the project's.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveIncludeDirs(rootDir, includeDirs, alsoUnder = []) {
|
|
66
|
+
const root = resolve(rootDir);
|
|
67
|
+
const found = [];
|
|
68
|
+
const moved = [];
|
|
69
|
+
const missing = [];
|
|
70
|
+
const isDir = async (p) => !!(await stat(p).catch(() => null))?.isDirectory();
|
|
71
|
+
for (const dir of includeDirs) {
|
|
72
|
+
const rel = normalizeDirPath(dir);
|
|
73
|
+
if (await isDir(join(root, rel))) {
|
|
74
|
+
found.push(rel);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let reason = `not a folder under the project root ${root}`;
|
|
78
|
+
let used = null;
|
|
79
|
+
for (const other of alsoUnder.filter((o) => o.trim() !== '')) {
|
|
80
|
+
const abs = resolve(other, rel);
|
|
81
|
+
if (!(await isDir(abs)))
|
|
82
|
+
continue;
|
|
83
|
+
const underRoot = relative(root, abs);
|
|
84
|
+
if (underRoot.startsWith('..') || resolve(root, underRoot) !== abs) {
|
|
85
|
+
reason = `found under ${resolve(other)}, which is not inside the project root ${root}: make the root that folder`;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
used = underRoot.split(osSep).join('/') || '.';
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (used) {
|
|
92
|
+
found.push(used);
|
|
93
|
+
moved.push({ asked: rel, used });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
missing.push({ dir: rel, reason });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { found, moved, missing };
|
|
100
|
+
}
|
|
52
101
|
/** `.\Src\Foo\` and `src/foo` are the same directory. This is the form both lists compare in. */
|
|
53
102
|
export function normalizeDirPath(dir) {
|
|
54
103
|
const cleaned = dir
|
|
@@ -293,7 +342,26 @@ export async function mirrorProject(cfg) {
|
|
|
293
342
|
const targetDir = resolve(cfg.targetDir);
|
|
294
343
|
const separator = cfg.separator ?? DEFAULT_SEPARATOR;
|
|
295
344
|
const txtMode = cfg.txtMode ?? 'append';
|
|
296
|
-
|
|
345
|
+
/*
|
|
346
|
+
* Checked before the target folder is touched.
|
|
347
|
+
*
|
|
348
|
+
* The folder is brought in line with the selection, which deletes whatever the selection does
|
|
349
|
+
* not hold. A directory that could not be found made the selection empty, and every copy the
|
|
350
|
+
* chat had been working from was deleted (seen live on 2026-09-30: `rules-engine` not found,
|
|
351
|
+
* the whole context folder gone, totalBytes 0). A selection that is wrong is now an error that
|
|
352
|
+
* leaves the copies as they were; so is one that finds no file at all.
|
|
353
|
+
*/
|
|
354
|
+
const dirs = await resolveIncludeDirs(rootDir, cfg.includeDirs, cfg.alsoUnder);
|
|
355
|
+
if (dirs.missing.length > 0) {
|
|
356
|
+
throw new MirrorSelectionError(`The project files could not be copied: ${dirs.missing.map((m) => `"${m.dir}" is ${m.reason}`).join('; ')}. ` +
|
|
357
|
+
`Nothing was copied, and the copies already in ${targetDir} were left as they are.`);
|
|
358
|
+
}
|
|
359
|
+
const { files, skipped } = await collectFiles({ ...cfg, includeDirs: dirs.found });
|
|
360
|
+
if (files.length === 0) {
|
|
361
|
+
throw new MirrorSelectionError(`The selection (${cfg.includeDirs.join(', ')}) holds no file that can be copied` +
|
|
362
|
+
`${skipped.length > 0 ? ` (${skipped.length} skipped, for example ${skipped[0].relPath}: ${skipped[0].reason})` : ''}. ` +
|
|
363
|
+
`Nothing was copied, and the copies already in ${targetDir} were left as they are.`);
|
|
364
|
+
}
|
|
297
365
|
await mkdir(targetDir, { recursive: true });
|
|
298
366
|
const used = new Set();
|
|
299
367
|
const desired = new Set();
|
|
@@ -28,10 +28,65 @@ async function fileCheckPassesNow(check, cwd) {
|
|
|
28
28
|
const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
|
|
29
29
|
return { passes: exists && text.includes(check.value ?? ''), path };
|
|
30
30
|
}
|
|
31
|
+
/** `git` with the global options that may come before its subcommand. */
|
|
32
|
+
const GIT = String.raw `\bgit(?:\s+(?:-C\s+(?:"[^"]*"|'[^']*'|\S+)|--no-pager|-c\s+\S+))*\s+`;
|
|
33
|
+
/** A check whose command asks which branch is checked out. */
|
|
34
|
+
export function readsCurrentBranch(check) {
|
|
35
|
+
return !!check.run && new RegExp(`${GIT}(?:branch\\s+--show-current|rev-parse\\s+--abbrev-ref\\s+HEAD|symbolic-ref\\s+(?:-q\\s+)?(?:--short\\s+)?HEAD|name-rev\\s+--name-only\\s+HEAD)`, 'i').test(check.run);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A check whose command asks whether the working tree is clean: `git status --porcelain`/`--short`,
|
|
39
|
+
* `git diff --quiet`/`--exit-code`. With version control on, the runner commits the task's changes
|
|
40
|
+
* after its checks — and the chat may not commit — so such a check is decided after that commit.
|
|
41
|
+
*/
|
|
42
|
+
export function readsTreeClean(check) {
|
|
43
|
+
return !!check.run && new RegExp(`${GIT}(?:status\\b[^|;&\\n]*(?:--porcelain|--short|\\s-s\\b)|diff\\b[^|;&\\n]*(?:--quiet|--exit-code))`, 'i').test(check.run);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a check about the checked-out branch can pass on `branch`, the one the runner chose.
|
|
47
|
+
* `null` when it cannot be told without running it.
|
|
48
|
+
*/
|
|
49
|
+
function branchCheckPasses(check, branch) {
|
|
50
|
+
const value = check.value ?? '';
|
|
51
|
+
if (check.expect === 'output-contains')
|
|
52
|
+
return value === '' || branch.includes(value);
|
|
53
|
+
if (check.expect === 'output-omits')
|
|
54
|
+
return value === '' || !branch.includes(value);
|
|
55
|
+
if (check.expect === 'output-matches') {
|
|
56
|
+
try {
|
|
57
|
+
return new RegExp(value).test(branch);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// A comparison written into the command: `(git branch --show-current) -eq 'recovery/x'`.
|
|
64
|
+
const named = [...(check.run ?? '').matchAll(/['"]([\w.-]+(?:\/[\w.-]+)+)['"]/g)].map((m) => m[1]);
|
|
65
|
+
if (named.length > 0)
|
|
66
|
+
return named.includes(branch);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
31
69
|
/** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
|
|
32
|
-
export async function contractConflicts(task, cwd, repoDir
|
|
70
|
+
export async function contractConflicts(task, cwd, repoDir,
|
|
71
|
+
/** The branch version control put the task on, when it is on. */
|
|
72
|
+
vcs = {}) {
|
|
33
73
|
const out = [];
|
|
34
74
|
const scope = task.scope ?? [];
|
|
75
|
+
/*
|
|
76
|
+
* A check that the task is on a branch the runner did not choose can never pass: the chat may not
|
|
77
|
+
* switch branches. Seen on 2026-09-30, a check still expecting the recovery branch the session had
|
|
78
|
+
* been started from, while the runner worked on its own. Named before the start, with the check
|
|
79
|
+
* that does what was meant: that the earlier work is in this branch.
|
|
80
|
+
*/
|
|
81
|
+
if (vcs.branch) {
|
|
82
|
+
const all = [...(task.checks ?? []), ...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check)];
|
|
83
|
+
for (const check of all.filter(readsCurrentBranch)) {
|
|
84
|
+
if (branchCheckPasses(check, vcs.branch) !== false)
|
|
85
|
+
continue;
|
|
86
|
+
out.push(`The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
|
|
87
|
+
'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
35
90
|
if (task.readOnly && scope.length > 0) {
|
|
36
91
|
out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
|
|
37
92
|
}
|