copilot-operator 0.1.15 → 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.
- package/dist/src/api/operator.controller.js +34 -0
- package/dist/src/api/operator.service.js +112 -1
- package/dist/src/plan/brief.js +34 -0
- package/dist/src/plan/importPlan.js +8 -1
- package/dist/src/plan/schema.js +24 -0
- package/dist/src/plan/systemGuide.js +63 -0
- package/dist/src/session/exports.js +2 -0
- package/dist/src/session/store.js +68 -6
- package/dist/src/transport/copilotTransport.js +39 -1
- package/dist/src/vcs/snapshot.js +354 -0
- package/dist/src/vcs/taskVcs.js +68 -23
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +1 -0
- package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.js +1 -0
- package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +1 -0
- package/dist/web/_next/static/css/{e2a2c9eb3c226313.css → c37978b44194c37b.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/549-9548d9a132c8c7d4.js +0 -1
- package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +0 -1
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +0 -1
- /package/dist/web/_next/static/{IwNZiBrEMAcXEUFj2miiU → kkxFcGzA9Uo1uRAgcoDti}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{IwNZiBrEMAcXEUFj2miiU → 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() {
|
|
@@ -509,6 +517,10 @@ let OperatorController = class OperatorController {
|
|
|
509
517
|
* Re-reads the picker from the live chat. Slow on purpose: it opens the browser with the
|
|
510
518
|
* bot profile, so it cannot run while a session is running.
|
|
511
519
|
*/
|
|
520
|
+
/** "Sign in" from Settings: opens the bot's Edge and waits for the operator to sign in. See `OperatorService.login`. */
|
|
521
|
+
login(body) {
|
|
522
|
+
return this.ops.login(body?.account).catch(fail);
|
|
523
|
+
}
|
|
512
524
|
refreshModels() {
|
|
513
525
|
return this.ops.refreshModels().catch(fail);
|
|
514
526
|
}
|
|
@@ -628,6 +640,13 @@ __decorate([
|
|
|
628
640
|
__metadata("design:paramtypes", []),
|
|
629
641
|
__metadata("design:returntype", Promise)
|
|
630
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);
|
|
631
650
|
__decorate([
|
|
632
651
|
Post('sessions'),
|
|
633
652
|
__param(0, Body()),
|
|
@@ -1023,12 +1042,27 @@ __decorate([
|
|
|
1023
1042
|
__metadata("design:paramtypes", [String]),
|
|
1024
1043
|
__metadata("design:returntype", Promise)
|
|
1025
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);
|
|
1026
1053
|
__decorate([
|
|
1027
1054
|
Get('models'),
|
|
1028
1055
|
__metadata("design:type", Function),
|
|
1029
1056
|
__metadata("design:paramtypes", []),
|
|
1030
1057
|
__metadata("design:returntype", Promise)
|
|
1031
1058
|
], OperatorController.prototype, "models", null);
|
|
1059
|
+
__decorate([
|
|
1060
|
+
Post('login'),
|
|
1061
|
+
__param(0, Body()),
|
|
1062
|
+
__metadata("design:type", Function),
|
|
1063
|
+
__metadata("design:paramtypes", [Object]),
|
|
1064
|
+
__metadata("design:returntype", Promise)
|
|
1065
|
+
], OperatorController.prototype, "login", null);
|
|
1032
1066
|
__decorate([
|
|
1033
1067
|
Post('models/refresh'),
|
|
1034
1068
|
__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';
|
|
@@ -56,6 +57,15 @@ import { settingsOf } from './settings.js';
|
|
|
56
57
|
*/
|
|
57
58
|
function browserRefusal(held, ask) {
|
|
58
59
|
const modelsWhileRunning = 'A session is running and it is using the browser profile. Stop it first, then read the models.';
|
|
60
|
+
const loginWhileBusy = 'The browser profile is in use right now. Sign in once the run, or the reading of the models, has finished.';
|
|
61
|
+
// The sign-in window holds the profile until the operator has signed in and it closes.
|
|
62
|
+
if (held.kind === 'login') {
|
|
63
|
+
return ask.kind === 'login'
|
|
64
|
+
? 'The sign-in window is already open. Finish signing in there.'
|
|
65
|
+
: 'the sign-in window is open and has the browser profile; finish signing in first';
|
|
66
|
+
}
|
|
67
|
+
if (ask.kind === 'login')
|
|
68
|
+
return loginWhileBusy;
|
|
59
69
|
if (held.kind === 'batch') {
|
|
60
70
|
if (ask.kind === 'models')
|
|
61
71
|
return modelsWhileRunning;
|
|
@@ -311,6 +321,14 @@ let OperatorService = class OperatorService {
|
|
|
311
321
|
await this.store.deletePersona(name);
|
|
312
322
|
}
|
|
313
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
|
+
}
|
|
314
332
|
async listSessions() {
|
|
315
333
|
await this.init();
|
|
316
334
|
const all = await this.store.listSessions();
|
|
@@ -395,6 +413,12 @@ let OperatorService = class OperatorService {
|
|
|
395
413
|
merged.baseBranch = merged.baseBranch.trim();
|
|
396
414
|
if (merged.existingBranch !== undefined)
|
|
397
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 }) };
|
|
398
422
|
// Only the program marks a name as one of its own branches, and a name changed is not that one.
|
|
399
423
|
const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
|
|
400
424
|
if (stillOwn)
|
|
@@ -1813,11 +1837,27 @@ let OperatorService = class OperatorService {
|
|
|
1813
1837
|
return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
|
|
1814
1838
|
// `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
|
|
1815
1839
|
// those differ, and the second is the one the operator is asking about.
|
|
1816
|
-
const preflight = await vcsPreflight(session);
|
|
1840
|
+
const preflight = await vcsPreflight(session, () => this.store.listSessions());
|
|
1817
1841
|
// The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
|
|
1818
1842
|
const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
|
|
1819
1843
|
return { ...preflight, git, work: sessionBranches(session), branches };
|
|
1820
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
|
+
}
|
|
1821
1861
|
/**
|
|
1822
1862
|
* What going back to before a task would do, and then doing it.
|
|
1823
1863
|
*
|
|
@@ -2397,6 +2437,77 @@ let OperatorService = class OperatorService {
|
|
|
2397
2437
|
* gets to choose from, which is the only way this keeps working when Microsoft changes the
|
|
2398
2438
|
* line-up.
|
|
2399
2439
|
*/
|
|
2440
|
+
/**
|
|
2441
|
+
* "Sign in" from Settings: what `cop login` does, from the page.
|
|
2442
|
+
*
|
|
2443
|
+
* Opens Edge with the bot's own profile — the one in Settings, which is the one every run uses —
|
|
2444
|
+
* and waits for the operator to sign in to Microsoft 365 Copilot. With an account, the profile is
|
|
2445
|
+
* signed out first and the chat asked for that account, because Edge on a work machine otherwise
|
|
2446
|
+
* signs the profile in with whatever account Windows knows. The bot never types a credential:
|
|
2447
|
+
* the person signs in, completes any verification, and the window closes once the chat is there.
|
|
2448
|
+
*
|
|
2449
|
+
* The request stays open until then (up to `copilot.signInTimeoutSec`). It takes the browser like
|
|
2450
|
+
* a run does, so it is refused while a run or a reading of the models has it, and they are while
|
|
2451
|
+
* it is open.
|
|
2452
|
+
*/
|
|
2453
|
+
async login(account) {
|
|
2454
|
+
const upn = account?.trim() || undefined;
|
|
2455
|
+
if (upn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(upn)) {
|
|
2456
|
+
throw new Error(`"${upn}" is not an account address. Give the work or school address, for example name@company.com, or leave it empty.`);
|
|
2457
|
+
}
|
|
2458
|
+
const claim = this.claimBrowser({ kind: 'login' });
|
|
2459
|
+
if ('refused' in claim)
|
|
2460
|
+
throw new Error(claim.refused);
|
|
2461
|
+
let transport = null;
|
|
2462
|
+
try {
|
|
2463
|
+
await this.init();
|
|
2464
|
+
const cfg = await this.settings.load();
|
|
2465
|
+
transport = createTransport({
|
|
2466
|
+
profileDir: cfg.resolved.profileDir,
|
|
2467
|
+
transportDir: join(cfg.resolved.runsDir, '_login'),
|
|
2468
|
+
chatUrl: cfg.copilot.url,
|
|
2469
|
+
channel: cfg.copilot.channel,
|
|
2470
|
+
// Always on screen, whatever Settings say: a person has to see the window to sign in.
|
|
2471
|
+
headless: false,
|
|
2472
|
+
replyTimeoutMs: 60_000,
|
|
2473
|
+
signInTimeoutMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2474
|
+
humanWaitMs: cfg.copilot.signInTimeoutSec * 1000,
|
|
2475
|
+
keepFailurePage: cfg.copilot.keepFailurePage,
|
|
2476
|
+
});
|
|
2477
|
+
await transport.open();
|
|
2478
|
+
if (upn) {
|
|
2479
|
+
await transport.signOut();
|
|
2480
|
+
await transport.gotoChatAs(upn, cfg.copilot.url);
|
|
2481
|
+
}
|
|
2482
|
+
await transport.ensureSignedIn(upn ? undefined : cfg.copilot.url);
|
|
2483
|
+
const accounts = await transport.findAccountsInPage();
|
|
2484
|
+
if (!upn) {
|
|
2485
|
+
return {
|
|
2486
|
+
ok: true,
|
|
2487
|
+
accounts,
|
|
2488
|
+
message: accounts.length > 0 ? `Signed in. The page shows: ${accounts.join(', ')}.` : 'Signed in.',
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
const matched = accounts.some((a) => a.toLowerCase() === upn.toLowerCase());
|
|
2492
|
+
if (matched)
|
|
2493
|
+
return { ok: true, accounts, account: upn, matched, message: `Signed in as ${upn}.` };
|
|
2494
|
+
if (accounts.length > 0) {
|
|
2495
|
+
return {
|
|
2496
|
+
ok: false,
|
|
2497
|
+
accounts,
|
|
2498
|
+
account: upn,
|
|
2499
|
+
matched,
|
|
2500
|
+
message: `Signed in, but not as ${upn}: the page shows ${accounts.join(', ')}. Sign in again and choose "Use another account".`,
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
return { ok: true, accounts, account: upn, message: `Signed in. The account could not be read from the page, so ${upn} is not confirmed.` };
|
|
2504
|
+
}
|
|
2505
|
+
finally {
|
|
2506
|
+
// Always closed: a window left open keeps the profile locked, and the next run would fail on it.
|
|
2507
|
+
await transport?.close().catch(() => undefined);
|
|
2508
|
+
this.releaseBrowser(claim.holder);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2400
2511
|
async refreshModels() {
|
|
2401
2512
|
// Asked of the one claim every window goes through (see `browser`), before anything is waited
|
|
2402
2513
|
// for. It used to count the sessions running, which is none in the moment between two
|
package/dist/src/plan/brief.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -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',
|
|
@@ -2650,6 +2682,37 @@ export const SYSTEM_GUIDE = [
|
|
|
2650
2682
|
},
|
|
2651
2683
|
],
|
|
2652
2684
|
},
|
|
2685
|
+
{
|
|
2686
|
+
headingKey: 'login.title',
|
|
2687
|
+
en: 'Sign in to Copilot',
|
|
2688
|
+
bg: 'Вход в Copilot',
|
|
2689
|
+
controls: [
|
|
2690
|
+
{
|
|
2691
|
+
key: 'login.account',
|
|
2692
|
+
kind: 'field',
|
|
2693
|
+
en: 'Account (optional)',
|
|
2694
|
+
bg: 'Акаунт (по желание)',
|
|
2695
|
+
doesEn: 'The work or school address to sign in as. Empty: whichever account is chosen in the window. With one, the profile is signed out first and Microsoft is asked for it.',
|
|
2696
|
+
doesBg: 'Служебният адрес, с който да се влезе. Празно: какъвто акаунт се избере в прозореца. С адрес профилът първо излиза и Microsoft се пита точно за него.',
|
|
2697
|
+
},
|
|
2698
|
+
{
|
|
2699
|
+
key: 'login.button',
|
|
2700
|
+
kind: 'button',
|
|
2701
|
+
en: 'Sign in to Copilot',
|
|
2702
|
+
bg: 'Вход в Copilot',
|
|
2703
|
+
doesEn: 'What "cop login" does: opens the bot\'s own Edge, waits for the operator to sign in there (the bot never types a password), and closes once the chat appears. Needed once before a run or a reading of the models.',
|
|
2704
|
+
doesBg: 'Това, което прави „cop login“: отваря собствения Edge на бота, чака операторът да влезе в него (ботът никога не пише парола) и се затваря, щом чатът се появи. Нужно е веднъж преди пускане или четене на моделите.',
|
|
2705
|
+
},
|
|
2706
|
+
{
|
|
2707
|
+
key: 'login.busy',
|
|
2708
|
+
kind: 'note',
|
|
2709
|
+
en: 'Waiting for you to sign in…',
|
|
2710
|
+
bg: 'Чака да влезете…',
|
|
2711
|
+
doesEn: 'The same button while the sign-in window is open; runs and the reading of the models wait for it.',
|
|
2712
|
+
doesBg: 'Същият бутон, докато прозорецът за вход е отворен; пусканията и четенето на моделите чакат.',
|
|
2713
|
+
},
|
|
2714
|
+
],
|
|
2715
|
+
},
|
|
2653
2716
|
{
|
|
2654
2717
|
headingKey: 'def.modelTitle',
|
|
2655
2718
|
en: 'Model for new sessions',
|
|
@@ -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(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 3. A message cannot consist of an attachment alone; Send stays disabled without text.
|
|
12
12
|
*/
|
|
13
13
|
import { chromium } from 'playwright';
|
|
14
|
-
import { mkdir } from 'node:fs/promises';
|
|
14
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { findRecentCrash, describeCrash } from './edgeCrash.js';
|
|
17
17
|
import { landed } from './acceptance.js';
|
|
@@ -575,6 +575,7 @@ Current URL: ${url}`);
|
|
|
575
575
|
return { options: [], current, note: 'The model picker opened nothing that could be read.' };
|
|
576
576
|
}
|
|
577
577
|
const top = await this.readMenuRows();
|
|
578
|
+
await this.captureMenu('top', top);
|
|
578
579
|
await this.closeMenu();
|
|
579
580
|
const options = top.filter((r) => !r.opensSubmenu).map(({ opensSubmenu, ...o }) => o);
|
|
580
581
|
const groups = top.filter((r) => r.opensSubmenu);
|
|
@@ -589,8 +590,44 @@ Current URL: ${url}`);
|
|
|
589
590
|
}
|
|
590
591
|
}
|
|
591
592
|
this.emit('models-read', { count: options.length, groups: groups.length, current });
|
|
593
|
+
await this.captureMenu('result', top, { current, options }).catch(() => undefined);
|
|
592
594
|
return { options, current };
|
|
593
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* What the picker showed, kept beside the reading: the menu as HTML, a picture of it, and the rows
|
|
598
|
+
* that were read from it.
|
|
599
|
+
*
|
|
600
|
+
* The picker is Microsoft's markup and it changes without notice; when "Read the list from
|
|
601
|
+
* Copilot" comes back with the wrong models, the only way to see why is the menu as it was at that
|
|
602
|
+
* moment. Written to this transport's folder (runs/_models for that button), overwritten by the
|
|
603
|
+
* next reading, and never a reason for the reading to fail. Nothing personal is in a model menu.
|
|
604
|
+
*/
|
|
605
|
+
async captureMenu(label, rows, extra) {
|
|
606
|
+
try {
|
|
607
|
+
await mkdir(this.opts.transportDir, { recursive: true });
|
|
608
|
+
const base = join(this.opts.transportDir, `models-${label}`);
|
|
609
|
+
if (label !== 'result') {
|
|
610
|
+
const html = await this.p
|
|
611
|
+
.evaluate((selectors) => {
|
|
612
|
+
const parts = [];
|
|
613
|
+
for (const sel of selectors) {
|
|
614
|
+
for (const el of Array.from(document.querySelectorAll(sel))) {
|
|
615
|
+
if (el.getClientRects().length > 0)
|
|
616
|
+
parts.push(`<!-- ${sel} -->\n${el.outerHTML}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return parts.join('\n\n');
|
|
620
|
+
}, [...Model.popupSelectors])
|
|
621
|
+
.catch((e) => `<!-- could not read the menu: ${String(e)} -->`);
|
|
622
|
+
await writeFile(`${base}.html`, html, 'utf8');
|
|
623
|
+
await this.p.screenshot({ path: `${base}.png` }).catch(() => undefined);
|
|
624
|
+
}
|
|
625
|
+
await writeFile(`${base}.json`, JSON.stringify({ at: new Date().toISOString(), rows, ...(extra ?? {}) }, null, 2), 'utf8');
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
/* a diagnostic, never the reason a reading fails */
|
|
629
|
+
}
|
|
630
|
+
}
|
|
594
631
|
/** Every visible menu row right now, across the menu and whatever submenu is open. */
|
|
595
632
|
async readMenuRows() {
|
|
596
633
|
return await this.p.evaluate(({ roles, selectedAttrs, submenuAttrs }) => {
|
|
@@ -652,6 +689,7 @@ Current URL: ${url}`);
|
|
|
652
689
|
await trigger.click().catch(() => undefined);
|
|
653
690
|
children = await this.waitForNewRows(topNames);
|
|
654
691
|
}
|
|
692
|
+
await this.captureMenu(`group-${groupName.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
|
|
655
693
|
await this.closeMenu();
|
|
656
694
|
return children;
|
|
657
695
|
}
|