copilot-operator 0.1.0

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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Naming an Edge crash, so a run that dies says why.
3
+ *
4
+ * When the browser process goes away, every Playwright call afterwards fails with "Target
5
+ * page, context or browser has been closed", which explains nothing: a closed window, a
6
+ * second Edge on the same profile and a crash all read the same. But Edge writes a minidump
7
+ * into the profile's `Crashpad/reports` the instant it crashes, with the process type and the
8
+ * version in `watson_metadata` next to it. A run that ended that way can point at the report
9
+ * — which is what made the attachment crash diagnosable at all: three reports, one per
10
+ * attempt, `ProcessType=browser`, the same `SubCode`, and the click was the cause.
11
+ */
12
+ import { readdir, readFile, stat } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ /** The last values of the fields that matter, out of the binary metadata file. */
15
+ export function parseWatsonMetadata(text) {
16
+ const last = (re) => {
17
+ let value;
18
+ for (const m of text.matchAll(re))
19
+ value = m[1];
20
+ return value;
21
+ };
22
+ return {
23
+ processType: last(/ProcessType=([A-Za-z-]+)/g),
24
+ version: last(/ApplicationVersion=([0-9.]+)/g),
25
+ subCode: last(/SubCode=(0x[0-9A-Fa-f]+)/g),
26
+ };
27
+ }
28
+ /** The newest minidump written within `sinceMs`, with what the metadata says about it. */
29
+ export async function findRecentCrash(profileDir, sinceMs = 5 * 60_000, now = Date.now()) {
30
+ const dir = join(profileDir, 'Crashpad', 'reports');
31
+ let names;
32
+ try {
33
+ names = (await readdir(dir)).filter((n) => n.toLowerCase().endsWith('.dmp'));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ let newest = null;
39
+ for (const name of names) {
40
+ const path = join(dir, name);
41
+ const s = await stat(path).catch(() => null);
42
+ if (!s)
43
+ continue;
44
+ if (s.mtimeMs < now - sinceMs)
45
+ continue;
46
+ if (!newest || s.mtimeMs > newest.mtime)
47
+ newest = { path, mtime: s.mtimeMs };
48
+ }
49
+ if (!newest)
50
+ return null;
51
+ const meta = await readFile(join(profileDir, 'Crashpad', 'watson_metadata'), 'latin1').catch(() => '');
52
+ return { report: newest.path, at: new Date(newest.mtime).toISOString(), ...parseWatsonMetadata(meta) };
53
+ }
54
+ /** One sentence for a task's reason and the log. */
55
+ export function describeCrash(c) {
56
+ const what = c.processType ? `Edge's ${c.processType} process crashed` : 'Edge crashed';
57
+ const details = [c.version ? `Edge ${c.version}` : '', c.subCode ? `code ${c.subCode}` : ''].filter(Boolean).join(', ');
58
+ return `${what}${details ? ` (${details})` : ''} at ${c.at}; report: ${c.report}`;
59
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The ONLY file in the project that knows about the Copilot web UI's DOM.
3
+ *
4
+ * Captured live from https://m365.cloud.microsoft/chat on 2026-09-17.
5
+ *
6
+ * Rule of thumb for maintenance: `data-testid` values and `aria-label` values are
7
+ * stable across deployments; Fluent UI class hashes (f1c21dwh, ___7qar2c0, OGl5QnhL)
8
+ * are NOT — never select on them. The `scriptor-*` class names are semantic and
9
+ * are the one exception, because the code-block widget exposes no test id.
10
+ */
11
+ /** Stable `data-testid` values in the chat surface. */
12
+ export const TestId = {
13
+ /** Scroll container holding every turn. */
14
+ messageList: 'MessageListContainer',
15
+ /** One conversation turn: contains both the user question and the Copilot answer. */
16
+ turn: 'm365-chat-llm-web-ui-chat-message',
17
+ /** The user's own message inside a turn. */
18
+ userMessage: 'chatQuestion',
19
+ /** The Copilot answer inside a turn. */
20
+ assistantOutput: 'chatOutput',
21
+ /** Wrapper of one Copilot answer; its DOM id is `chatMessageResponse-<hash>`. */
22
+ assistantMessage: 'copilot-message-div',
23
+ /** Body of the Copilot answer (no toolbar). */
24
+ assistantReplyBody: 'copilot-message-reply-div',
25
+ /** Rendered markdown of the answer. Download links and code blocks live here. */
26
+ markdownReply: 'markdown-reply',
27
+ /** Present on the newest answer only. */
28
+ lastMessage: 'lastChatMessage',
29
+ /** Message-level copy button (aria-label "Copy Response"). */
30
+ copyResponse: 'CopyButtonTestId',
31
+ /** Composer add-sources ("+") button. Opens the menu; not needed for uploads. */
32
+ plusMenu: 'PlusMenuButton',
33
+ };
34
+ /**
35
+ * File upload.
36
+ *
37
+ * The page keeps a hidden, always-present multi-file input in the DOM. Playwright can
38
+ * call `setInputFiles` on it directly, so the bot never opens the "+" menu and never
39
+ * touches a native Windows file dialog.
40
+ *
41
+ * Its `accept` list includes `.txt` and `text/plain`, which is what the runner reports use.
42
+ * Also accepted, should we ever need them: .log, .json, .csv, .md, .xml, .yml. .ps1 is NOT
43
+ * in the list. Uploads are the runner's own reports; nothing is ever downloaded.
44
+ */
45
+ export const Upload = {
46
+ /** Hidden `<input type="file" multiple>`. */
47
+ input: '#upload-file-button',
48
+ /** Extensions the input accepts that are useful to us. */
49
+ safeReportExtension: '.txt',
50
+ /** Container of the pending attachments in the composer. */
51
+ list: 'div.fx-AttachmentList',
52
+ /** One pending attachment. Its `aria-label` is the file name. */
53
+ chip: 'div.fx-Attachment',
54
+ /** `aria-label` of the chip's remove button, with the file name appended. */
55
+ removeLabelPrefix: 'Remove attachment ',
56
+ /**
57
+ * Wait for this before clicking Send: the chip's DOM id starts with `SPO_` only once
58
+ * the file has finished uploading to SharePoint/OneDrive.
59
+ */
60
+ uploadedIdPrefix: 'SPO_',
61
+ };
62
+ /**
63
+ * Accessible names used with getByRole / getByLabel.
64
+ * The UI is English-only in this tenant; if a localized tenant is targeted these
65
+ * must be moved into a locale map.
66
+ */
67
+ export const Label = {
68
+ /** The composer. It is a contenteditable SPAN, not a textarea. */
69
+ composer: 'Message Copilot',
70
+ send: 'Send',
71
+ /** Visible only while the answer is streaming. Its absence = generation finished. */
72
+ stopGenerating: 'Stop generating',
73
+ copyResponse: 'Copy Response',
74
+ /** Copy button inside a code block; yields the full code, ignoring virtualization. */
75
+ copyCode: 'Copy code',
76
+ /** Expands a long code block. */
77
+ showMoreLines: 'Show more lines',
78
+ newChat: 'New chat',
79
+ modelSelector: 'Model Selector',
80
+ };
81
+ /**
82
+ * The model picker.
83
+ *
84
+ * The button was captured live: `button#gptModeSwitcher[aria-label="Model Selector"]`, showing
85
+ * `Auto`. What its popup contains was **not** captured, and it is the one thing here that
86
+ * Microsoft changes without warning: models come and go, and a tenant sees a different list
87
+ * from the next tenant. So nothing in this project hard-codes a list of models. The names are
88
+ * read from the open popup at the moment the user asks for them, and what is offered in the UI
89
+ * is whatever the chat itself offered.
90
+ *
91
+ * Because the popup's markup is unknown, the reader is deliberately role-based and tries
92
+ * several shapes rather than one selector. Anything matched inside the popup counts; the
93
+ * button's own text is the current selection.
94
+ */
95
+ export const Model = {
96
+ /** The picker button. The id has been stable; the aria-label is the fallback. */
97
+ button: 'button#gptModeSwitcher',
98
+ buttonLabel: 'Model Selector',
99
+ /** Containers a Fluent popup can use. The first visible one after the click wins. */
100
+ popupSelectors: ['[role="menu"]', '[role="listbox"]', '[role="dialog"]', '[role="group"]'],
101
+ /**
102
+ * Roles an option can carry. All of them are read, not just the first that matches:
103
+ * the menu mixes `menuitemradio` for the choices with plain `menuitem` for the groups
104
+ * that open a submenu, so stopping at the first role hides every grouped model.
105
+ */
106
+ optionRoles: ['menuitemradio', 'menuitemcheckbox', 'option', 'radio', 'menuitem'],
107
+ /** Attributes that mark the option currently in force. */
108
+ selectedAttributes: ['aria-checked', 'aria-selected', 'aria-pressed'],
109
+ /**
110
+ * A row that opens a submenu instead of choosing anything. Seen live as `GPT / OpenAI`
111
+ * with a chevron, holding `GPT 5.6 Think deeper` and `GPT 5.6 Quick response`. A tenant
112
+ * with other vendors enabled gets one such group per vendor, which is where the models
113
+ * this project is asked to pick actually live.
114
+ */
115
+ submenuAttributes: ['aria-haspopup', 'aria-expanded'],
116
+ };
117
+ /** CSS selectors for things that have neither a test id nor an accessible name. */
118
+ export const Css = {
119
+ /** The composer element. Its DOM id has been stable so far. */
120
+ composer: '#m365-chat-editor-target-element',
121
+ /**
122
+ * The composer's own wrapper, and the reason every Send lookup goes through it.
123
+ *
124
+ * "The button called Send" is not unique on this page. The Office feedback panel
125
+ * (`data-testid="obf-…"`) has one too, and when it opens over the chat a page-wide lookup
126
+ * matches two elements and Playwright refuses to guess — which is a run ending on a strict
127
+ * mode violation because Microsoft asked the operator what they thought of Copilot.
128
+ *
129
+ * Note the spelling: this one is `data-test-id`, with hyphens, unlike the `data-testid`
130
+ * values above. Both spellings exist in this UI.
131
+ */
132
+ composerWrapper: '[data-test-id="chat-input-wrapper"]',
133
+ /**
134
+ * The shape of the real Send button, used when the wrapper cannot be found.
135
+ *
136
+ * The composer's Send is its form's submit control; the feedback panel's is an ordinary
137
+ * button. So this tells them apart without knowing either one's container.
138
+ */
139
+ composerSendSubmit: 'button[type="submit"][aria-label="Send"]',
140
+ /**
141
+ * Anything belonging to the Office feedback panel. Never clicked, only detected: its
142
+ * buttons submit an opinion on the operator's own account, and this program has none.
143
+ */
144
+ feedbackPanel: '[data-testid^="obf-"]',
145
+ /**
146
+ * A rendered code block. Beware: it carries `scriptor-codeblock-virtualized`
147
+ * and renders line numbers as separate text nodes, so `innerText` is
148
+ * "PowerShell\n1\nline one\n2\nline two". Never parse it directly - use the
149
+ * "Copy code" button or the message-level "Copy Response" button instead.
150
+ */
151
+ codeBlock: '.scriptor-component-code-block',
152
+ codeBlockVirtualized: '.scriptor-codeblock-virtualized',
153
+ /**
154
+ * A file Copilot generated for download. It is a plain anchor with a blob href,
155
+ * a `download` attribute and `target="_blank"`, which is why nothing useful shows
156
+ * on hover. `aria-label` and `download` both carry the file name.
157
+ *
158
+ * Read only to name what was ignored: the runner never clicks it, and the browser is
159
+ * opened with downloads refused.
160
+ */
161
+ downloadLink: 'a[download]',
162
+ downloadLinkBlob: 'a[href^="blob:"]',
163
+ };
164
+ /** Signals the transport uses to decide what state the page is in. */
165
+ export const Signal = {
166
+ /** Sign-in redirect target. */
167
+ loginHost: 'login.microsoftonline.com',
168
+ /** Generation is running while a button with this label exists anywhere. */
169
+ streamingButtonLabel: Label.stopGenerating,
170
+ /**
171
+ * Generation has finished when the newest answer exposes its copy button.
172
+ *
173
+ * Two traps here, both found by checking against the live DOM rather than assuming:
174
+ *
175
+ * 1. `[data-testid="loading-message"]` is NOT a busy flag. It stays in the DOM after
176
+ * streaming ends.
177
+ * 2. The copy button is **not** inside `lastChatMessage`. `lastChatMessage` is the answer
178
+ * body (its DOM id is `response-id_...`); the copy button lives in a toolbar that is a
179
+ * sibling of it. Both sit inside `copilot-message-div`, which is therefore the anchor
180
+ * for anything that has to see the whole answer.
181
+ *
182
+ * There is no CSS for "the last element with this test id" across separate containers, so
183
+ * the check is done in JS: take the last `copilot-message-div` and look inside it.
184
+ */
185
+ answerWrapper: `[data-testid="${TestId.assistantMessage}"]`,
186
+ copyButtonInAnswer: `[data-testid="${TestId.copyResponse}"]`,
187
+ };
188
+ /**
189
+ * Things that stop the chat from working and that only a human can clear.
190
+ *
191
+ * The bot **detects** these and waits. It never clicks the verification checkbox and never
192
+ * tries to get past a challenge: that is the human's job, by design and on purpose. The
193
+ * value here is that the run pauses with a clear message instead of timing out with a
194
+ * confusing one.
195
+ */
196
+ export const Blocker = {
197
+ /** Text seen on the human-verification dialog. */
198
+ verificationText: ['Verification required', 'Verify you are human'],
199
+ /** Transient failure banner; a reload usually clears it. */
200
+ errorBannerText: ["Your request couldn't be completed", 'Refresh to try again'],
201
+ refreshLabel: 'Refresh',
202
+ /** Challenge widgets are iframed from these hosts. */
203
+ challengeFrameHosts: ['challenges.cloudflare.com', 'hcaptcha.com', 'recaptcha.net', 'google.com/recaptcha'],
204
+ };
205
+ /**
206
+ * Telling the work Copilot apart from the consumer one.
207
+ *
208
+ * `m365.cloud.microsoft` redirects to the consumer Copilot when the profile is not signed in
209
+ * with a work or school account. Both surfaces have a message box, so "a textbox is visible"
210
+ * is not proof of being in the right place. The consumer surface also runs bot protection,
211
+ * which the work surface does not, so landing there looks exactly like being detected as a
212
+ * bot when it is really a sign-in problem.
213
+ */
214
+ export const Surface = {
215
+ workHost: 'm365.cloud.microsoft',
216
+ /** Only the work surface has this composer id and these test ids. */
217
+ workMarkers: ['#m365-chat-editor-target-element'],
218
+ consumerHosts: ['copilot.microsoft.com', 'www.bing.com', 'bing.com'],
219
+ /** Chips that only the consumer surface shows. */
220
+ consumerMarkers: ['Shop', 'Play'],
221
+ };
222
+ /** Chat URL. Microsoft is consolidating this to copilot.cloud.microsoft. */
223
+ export const Url = {
224
+ chat: 'https://m365.cloud.microsoft/chat',
225
+ /** "New chat" is an anchor, so a plain navigation is equivalent to clicking it. */
226
+ newChat: 'https://m365.cloud.microsoft/chat?es=SSR',
227
+ /** Every conversation has a stable address: /chat/conversation/<uuid>. */
228
+ conversation: (id) => `https://m365.cloud.microsoft/chat/conversation/${id}?es=SSR`,
229
+ /** Full chat list, used as the last resort when looking a chat up by name. */
230
+ allChats: 'https://m365.cloud.microsoft/chat/all?es=SSR',
231
+ };
232
+ /**
233
+ * Naming a chat, so the bot can find its own conversation again after a re-login.
234
+ *
235
+ * Path: the sidebar entry's "More" menu -> "Rename" -> a dialog with a text input and a
236
+ * Save button. Enter does **not** submit the dialog; Save must be clicked.
237
+ *
238
+ * The name is capped at 50 characters by the UI, which shows the inline error
239
+ * "Your Copilot chat name can't be longer than 50 characters".
240
+ */
241
+ export const Rename = {
242
+ /** Per-chat overflow button in the sidebar. There is one per chat, so scope it to the row. */
243
+ moreButtonLabel: 'More',
244
+ menuItem: 'Rename',
245
+ /** The dialog's text input. */
246
+ input: '#new-session-name',
247
+ inputLabel: 'Chat name',
248
+ /** The dialog's confirm button. Text, not an aria-label. */
249
+ saveText: 'Save',
250
+ cancelText: 'Cancel',
251
+ maxLength: 50,
252
+ };
253
+ /**
254
+ * Sidebar chat rows. `aria-label` carries the chat's current name, `href` carries its id,
255
+ * so a row identifies a conversation both ways.
256
+ *
257
+ * `document.title` is NOT reliable here: after a rename it still showed the old title.
258
+ */
259
+ export const Sidebar = {
260
+ conversationLink: 'a[href*="/chat/conversation/"]',
261
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Guards the browser profile directory.
3
+ *
4
+ * Chromium profiles are single-writer. If any Edge process already has this profile open,
5
+ * a second launch does not get its own browser: it hands the request to the running one and
6
+ * exits, which Playwright then reports as
7
+ *
8
+ * Target page, context or browser has been closed
9
+ *
10
+ * a message that says nothing about the actual cause. That failure has already been seen in
11
+ * practice, with leftover `msedge.exe` processes still holding the profile from an earlier
12
+ * run. So the profile is checked and locked before the browser is launched, and the error
13
+ * says what to do.
14
+ */
15
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { winPsEnv } from '../exec/winps.js';
19
+ /**
20
+ * Edge processes that currently have this profile open.
21
+ *
22
+ * Windows only, via CIM. A failure to query is reported as "unknown" rather than "none", so
23
+ * a broken check never silently claims the profile is free.
24
+ */
25
+ export function findEdgeUsingProfile(profileDir) {
26
+ const script = `Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" | ` +
27
+ `Where-Object { $_.CommandLine -like '*${profileDir.replace(/\\/g, '\\').replace(/'/g, "''")}*' } | ` +
28
+ `Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`;
29
+ const res = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
30
+ env: winPsEnv(),
31
+ encoding: 'utf8',
32
+ windowsHide: true,
33
+ timeout: 15_000,
34
+ });
35
+ if (res.status !== 0 || typeof res.stdout !== 'string')
36
+ return 'unknown';
37
+ const text = res.stdout.trim();
38
+ if (text.length === 0)
39
+ return [];
40
+ try {
41
+ const parsed = JSON.parse(text);
42
+ const list = Array.isArray(parsed) ? parsed : [parsed];
43
+ return list.map((p) => ({ pid: p.ProcessId, commandLine: p.CommandLine ?? '' }));
44
+ }
45
+ catch {
46
+ return 'unknown';
47
+ }
48
+ }
49
+ /**
50
+ * Takes the lock, or explains why it cannot.
51
+ *
52
+ * A stale lock file (its process is gone) is taken over rather than treated as fatal,
53
+ * because a crashed run would otherwise leave the profile unusable until someone deleted a
54
+ * file they have never heard of.
55
+ */
56
+ export function acquireProfileLock(profileDir) {
57
+ mkdirSync(profileDir, { recursive: true });
58
+ const lockPath = join(profileDir, '.cop-lock.json');
59
+ if (existsSync(lockPath)) {
60
+ try {
61
+ const held = JSON.parse(readFileSync(lockPath, 'utf8'));
62
+ let alive = false;
63
+ try {
64
+ process.kill(held.pid, 0);
65
+ alive = true;
66
+ }
67
+ catch {
68
+ alive = false;
69
+ }
70
+ if (alive && held.pid !== process.pid) {
71
+ throw new Error(`The browser profile at ${profileDir} is already in use by copilot-operator ` +
72
+ `(process ${held.pid}, started ${held.startedAt}). Wait for that run to finish, or stop it.`);
73
+ }
74
+ }
75
+ catch (e) {
76
+ if (e instanceof Error && e.message.startsWith('The browser profile'))
77
+ throw e;
78
+ // An unreadable lock file is stale by definition; take it over.
79
+ }
80
+ }
81
+ const users = findEdgeUsingProfile(profileDir);
82
+ if (Array.isArray(users) && users.length > 0) {
83
+ const pids = users.map((u) => u.pid).join(', ');
84
+ throw new Error(`Microsoft Edge is already running with this profile (process ${pids}), so a new ` +
85
+ `browser cannot be launched and Playwright would fail with "Target page, context or ` +
86
+ `browser has been closed".\n` +
87
+ `Close that Edge window, or end those processes:\n` +
88
+ ` Stop-Process -Id ${users.map((u) => u.pid).join(',')} -Force\n` +
89
+ `Profile: ${profileDir}`);
90
+ }
91
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), 'utf8');
92
+ return {
93
+ release: () => {
94
+ try {
95
+ unlinkSync(lockPath);
96
+ }
97
+ catch {
98
+ /* already gone */
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pacing: the small amount of deliberate waiting the bot actually needs.
3
+ *
4
+ * An earlier version drew delays from a log-normal distribution and occasionally took a
5
+ * long pause, to make the traffic look less mechanical. That was dropped: it changed
6
+ * nothing measurable. The browser still reports `navigator.webdriver === true`, and with
7
+ * the default limits the hourly cap never binds, so the randomness only added a few
8
+ * minutes per run.
9
+ *
10
+ * What is left is the part that does work:
11
+ *
12
+ * - `settle()` a short fixed pause after an action, as a belt on top of the real
13
+ * condition waits in the transport (Stop-generating gone, SPO_ prefix on the
14
+ * attachment chip, Send button present). Those waits are the actual fix for
15
+ * flaky clicks; this is only a cushion.
16
+ * - `throttleSend()` a hard cap on messages per hour, which is the one thing that protects
17
+ * against service-side throttling on long runs.
18
+ * - `backoffFor()` exponential backoff with jitter, so a failing step cannot turn into a
19
+ * tight retry loop.
20
+ */
21
+ export const DEFAULT_PACING = {
22
+ enabled: true,
23
+ settleMs: 1_000,
24
+ maxMessagesPerHour: 60,
25
+ };
26
+ /** Small, fast, seedable PRNG, used only for backoff jitter. */
27
+ function mulberry32(seed) {
28
+ let a = seed >>> 0;
29
+ return () => {
30
+ a = (a + 0x6d2b79f5) >>> 0;
31
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
32
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
33
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
34
+ };
35
+ }
36
+ export class Pacer {
37
+ rnd;
38
+ profile;
39
+ /** Timestamps of sent messages, for the hourly cap. */
40
+ sentAt = [];
41
+ seed;
42
+ constructor(profile = {}) {
43
+ this.profile = { ...DEFAULT_PACING, ...profile };
44
+ this.seed = this.profile.seed ?? ((Date.now() ^ (Math.random() * 0xffffffff)) >>> 0);
45
+ this.rnd = mulberry32(this.seed);
46
+ }
47
+ /** Short fixed pause after an action. Zero when pacing is disabled. */
48
+ async settle(signal) {
49
+ const ms = this.profile.enabled ? this.profile.settleMs : 0;
50
+ if (ms > 0)
51
+ await sleep(ms, signal);
52
+ return ms;
53
+ }
54
+ /**
55
+ * Wait however long is needed to stay under the hourly message cap, then record that a
56
+ * message is being sent. Call immediately before clicking Send. Returns how long it
57
+ * waited, so the caller can log a visible "throttled for N s".
58
+ */
59
+ async throttleSend(signal) {
60
+ const hour = 3_600_000;
61
+ const now = Date.now();
62
+ while (this.sentAt.length && now - this.sentAt[0] > hour)
63
+ this.sentAt.shift();
64
+ let waited = 0;
65
+ if (this.sentAt.length >= this.profile.maxMessagesPerHour) {
66
+ waited = this.sentAt[0] + hour - now;
67
+ if (waited > 0)
68
+ await sleep(waited, signal);
69
+ }
70
+ this.sentAt.push(Date.now());
71
+ return Math.max(waited, 0);
72
+ }
73
+ /** Exponential backoff with jitter, for retries after a failure. */
74
+ backoffFor(attempt, baseMs = 2_000, capMs = 60_000) {
75
+ const exp = Math.min(baseMs * 2 ** attempt, capMs);
76
+ return Math.round(exp * (0.5 + this.rnd() * 0.5));
77
+ }
78
+ }
79
+ export function sleep(ms, signal) {
80
+ return new Promise((resolve, reject) => {
81
+ if (signal?.aborted)
82
+ return reject(new Error('aborted'));
83
+ const t = setTimeout(resolve, ms);
84
+ signal?.addEventListener('abort', () => {
85
+ clearTimeout(t);
86
+ reject(new Error('aborted'));
87
+ }, { once: true });
88
+ });
89
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What a commit made by the runner should not carry.
3
+ *
4
+ * `commitOnFinish` commits everything the task left in the working tree, and that is the right
5
+ * default: the runner cannot know which of the model's files are the work. But it can know
6
+ * what tool output looks like. A nine-task plan wrote its `.gitignore` exactly as told, nine
7
+ * entries, and the second web task still committed `web/tsconfig.tsbuildinfo` — the build had
8
+ * turned on `incremental` in a file the plan had dictated the contents of, the reviewer saw the
9
+ * path in its list of changed files and said nothing, and the plan's own audit looked for
10
+ * `node_modules`, `.next` and `dist` and found none. Nobody was wrong; nobody was looking for
11
+ * that.
12
+ *
13
+ * So the runner looks, once. A file that is installed, built, cached or logged rather than
14
+ * written, or one that holds secrets, is pointed out to the model before the task can end —
15
+ * through the same channel as the operator's checks — with the instruction to ignore it or to
16
+ * say why it belongs. Pointed out once: a second `done` with the file still there commits it
17
+ * and records it as suspicious on the task, where a person will see it. The alternative,
18
+ * refusing to commit, leaves the tree dirty and the next task refusing to start over it.
19
+ */
20
+ /** Directory names that are installed, built or cached rather than written. */
21
+ const GENERATED_DIRS = {
22
+ node_modules: 'installed by the package manager',
23
+ dist: 'build output',
24
+ build: 'build output',
25
+ out: 'build output',
26
+ '.next': "Next.js's build output",
27
+ '.nuxt': "Nuxt's build output",
28
+ '.svelte-kit': "SvelteKit's build output",
29
+ coverage: 'test coverage output',
30
+ '.turbo': 'a build cache',
31
+ '.cache': 'a cache',
32
+ '.parcel-cache': 'a build cache',
33
+ __pycache__: 'compiled Python',
34
+ '.pytest_cache': 'a test cache',
35
+ '.mypy_cache': 'a type-checker cache',
36
+ '.venv': 'a Python virtual environment',
37
+ venv: 'a Python virtual environment',
38
+ };
39
+ /** File names, or endings, that a tool writes and nobody reads. */
40
+ const GENERATED_FILES = [
41
+ [(n) => n.endsWith('.tsbuildinfo'), "TypeScript's incremental build state"],
42
+ [(n) => n.endsWith('.log'), 'a log file'],
43
+ [(n) => n === '.DS_Store' || n === 'Thumbs.db' || n === 'desktop.ini', "the operating system's folder metadata"],
44
+ [(n) => n.endsWith('.pyc'), 'compiled Python'],
45
+ ];
46
+ /** `.env` and its variants, except the ones that exist to be committed. */
47
+ function isSecretsFile(name) {
48
+ if (!name.startsWith('.env'))
49
+ return false;
50
+ const rest = name.slice('.env'.length);
51
+ if (rest === '')
52
+ return true;
53
+ if (!rest.startsWith('.'))
54
+ return false;
55
+ const suffix = rest.slice(1).toLowerCase();
56
+ return suffix !== 'example' && suffix !== 'sample' && suffix !== 'template';
57
+ }
58
+ /**
59
+ * Why a path should not be committed, or null when it looks like work.
60
+ *
61
+ * `key` is what to report: for a file, the file; for anything under a generated folder, the
62
+ * folder — `api/node_modules/` once, not fifteen thousand lines under it.
63
+ */
64
+ export function looksGenerated(path) {
65
+ const parts = path.replace(/\\/g, '/').split('/').filter(Boolean);
66
+ if (parts.length === 0)
67
+ return null;
68
+ for (const [i, dir] of parts.slice(0, -1).entries()) {
69
+ const reason = GENERATED_DIRS[dir];
70
+ if (reason)
71
+ return { reason: `a \`${dir}/\` folder, ${reason}`, key: `${parts.slice(0, i + 1).join('/')}/` };
72
+ }
73
+ const last = parts[parts.length - 1];
74
+ // A folder `git status` lists as one entry, with a trailing slash.
75
+ if (/[\\/]$/.test(path) && GENERATED_DIRS[last])
76
+ return { reason: `a \`${last}/\` folder, ${GENERATED_DIRS[last]}`, key: `${parts.join('/')}/` };
77
+ const file = parts.join('/');
78
+ for (const [test, reason] of GENERATED_FILES)
79
+ if (test(last))
80
+ return { reason, key: file };
81
+ if (isSecretsFile(last))
82
+ return { reason: 'a secrets file', key: file };
83
+ return null;
84
+ }
85
+ /** The paths, out of a list, that should not be committed — one entry per file or folder. */
86
+ export function findSuspicious(paths) {
87
+ const seen = new Map();
88
+ for (const path of paths) {
89
+ const hit = looksGenerated(path);
90
+ if (hit && !seen.has(hit.key))
91
+ seen.set(hit.key, { path: hit.key, reason: hit.reason });
92
+ }
93
+ return [...seen.values()];
94
+ }
95
+ /** What the model is told, once, before the commit. */
96
+ export function suspiciousDetail(found) {
97
+ const list = found.map((s) => `${s.path} (${s.reason})`).join('; ');
98
+ return (`${found.length} file(s) that look like tool output or secrets are in the working tree and would be committed ` +
99
+ `with this task: ${list}. Add them to .gitignore — they are installed, built or generated, not written — and ` +
100
+ 'take them out of the index if git already tracks them. If one of them truly belongs in the repository, leave ' +
101
+ 'it and say why in your summary: it will be committed and marked.');
102
+ }