dsh-inner-browser-use 0.27.0 → 0.34.28

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/tools.js CHANGED
@@ -9,14 +9,113 @@
9
9
  * has settled.
10
10
  */
11
11
 
12
+ import { AsyncLocalStorage } from 'node:async_hooks'
13
+ import { createHash } from 'node:crypto'
12
14
  import { homedir } from 'node:os'
13
- import { join } from 'node:path'
14
- import { mkdir, writeFile } from 'node:fs/promises'
15
+ import { basename, join } from 'node:path'
16
+ import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
15
17
  import { VIEW } from './sidecar.js?v=1'
16
- import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, labelSpot, selectSpot, bounds, pictures, REF_GONE } from './read.js?v=1'
18
+ import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, REF_GONE } from './read.js?v=1'
19
+ import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
17
20
 
18
- /** Where a screenshot from `sidecar_shot` lands, under the user's home. */
19
- export const SHOT_DIR = join('.dsh-inner-browser-use', 'screenshots')
21
+ /** Which chat the running call belongs to, so each gets its own tab. */
22
+ export const sessionScope = new AsyncLocalStorage()
23
+
24
+ /** @returns {string} the chat id of the running call. */
25
+ function currentSession() {
26
+ return String(sessionScope.getStore() ?? '')
27
+ }
28
+
29
+ /** Which project the running call belongs to, so its files stay together. */
30
+ export const workScope = new AsyncLocalStorage()
31
+
32
+ /** Where files a call produces land, under the user's home. */
33
+ export const ARTIFACT_ROOT = String(process.env.DSH_INNER_BROWSER_ARTIFACTS ?? '') !== ''
34
+ ? String(process.env.DSH_INNER_BROWSER_ARTIFACTS)
35
+ : join(homedir(), '.dsh-inner-browser-use', 'artifacts')
36
+
37
+ /** How many files one kind keeps per chat; older ones go. */
38
+ export const ARTIFACT_KEEP = 40
39
+
40
+ /** Project folder name: the folder's own name, plus a short hash of its path. */
41
+ function projectKey() {
42
+ const cwd = String(workScope.getStore() ?? '')
43
+ if (cwd === '') return 'no-project'
44
+ const named = basename(cwd).toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/^-+|-+$/g, '')
45
+ return `${named === '' ? 'project' : named}-${createHash('sha1').update(cwd).digest('hex').slice(0, 6)}`
46
+ }
47
+
48
+ /** Chat folder name: the short id a person can match to the chat. */
49
+ function sessionKey() {
50
+ const id = currentSession().replace(/^session-/, '').replace(/[^a-z0-9-]/gi, '')
51
+ return id === '' ? 'no-chat' : id.slice(0, 8)
52
+ }
53
+
54
+ /**
55
+ * Where one kind of file for this project and this chat goes.
56
+ * `~/.dsh-inner-browser-use/artifacts/<project>/<chat>/<kind>/`
57
+ * @param {string} kind - shots, pictures, pdf.
58
+ * @returns {string} absolute directory; not created yet.
59
+ */
60
+ export function artifactDir(kind) {
61
+ return join(ARTIFACT_ROOT, projectKey(), sessionKey(), kind)
62
+ }
63
+
64
+ /**
65
+ * Write one file where the project and the chat expect it, and keep the folder
66
+ * from growing without a limit.
67
+ * @param {string} kind - shots, pictures, pdf.
68
+ * @param {string} body - base64 body.
69
+ * @param {{name?: string, ext?: string, prefix?: string}} [options] - file name.
70
+ * @returns {Promise<string>} absolute path.
71
+ */
72
+ export async function saveArtifact(kind, body, options = {}) {
73
+ const dir = artifactDir(kind)
74
+ await mkdir(dir, { recursive: true })
75
+ const ext = String(options.ext ?? '.png')
76
+ /* A name is a name: no separators, no runs of dots that could read as a path
77
+ climb, and no leading dot to hide the file. */
78
+ const wanted = String(options.name ?? '').replace(/[^\w.-]/g, '').replace(/\.(png|jpe?g|webp|pdf)$/i, '').replace(/\.{2,}/g, '.').replace(/^\.+/, '')
79
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)
80
+ const prefix = String(options.prefix ?? kind)
81
+ const file = join(dir, `${wanted === '' ? `${prefix}-${stamp}` : wanted}${ext}`)
82
+ await writeFile(file, Buffer.from(body, 'base64'))
83
+ await pruneArtifacts(dir, ARTIFACT_KEEP)
84
+ return file
85
+ }
86
+
87
+ /**
88
+ * Keep the newest `keep` files in one folder: a browser session that runs all
89
+ * day does not leave a folder nobody can look through.
90
+ * @param {string} dir - folder to trim.
91
+ * @param {number} keep - how many files survive.
92
+ * @returns {Promise<void>} done.
93
+ */
94
+ export async function pruneArtifacts(dir, keep) {
95
+ let names
96
+ try {
97
+ names = await readdir(dir)
98
+ } catch {
99
+ return
100
+ }
101
+ const listed = []
102
+ for (const name of names) {
103
+ try {
104
+ listed.push({ name, at: (await stat(join(dir, name))).mtimeMs })
105
+ } catch {
106
+ /* a file that vanished between listing and reading is already gone */
107
+ }
108
+ }
109
+ if (listed.length <= keep) return
110
+ listed.sort((left, right) => right.at - left.at)
111
+ for (const spare of listed.slice(keep)) {
112
+ try {
113
+ await unlink(join(dir, spare.name))
114
+ } catch {
115
+ /* best effort: a file in use stays */
116
+ }
117
+ }
118
+ }
20
119
 
21
120
  /** How many times a call found a control again after the page redrew it. */
22
121
  const follows = { count: 0 }
@@ -25,6 +124,41 @@ const follows = { count: 0 }
25
124
  export const SIDECAR_OFF = 'The sidecar browser is not running. Call sidecar_open with the address first.'
26
125
 
27
126
  /** Output schema every tool returns. */
127
+ /** The picture half of a screenshot result: shown only when the route can see it. */
128
+ const SHOT_IMAGE = {
129
+ type: 'object', // ui-copy-ignore
130
+ properties: {
131
+ attachmentId: { type: 'string' },
132
+ mediaType: { type: 'string' },
133
+ bytes: { type: 'integer' }, // ui-copy-ignore
134
+ width: { type: 'integer' }, // ui-copy-ignore
135
+ height: { type: 'integer' }, // ui-copy-ignore
136
+ name: { type: 'string' }, // ui-copy-ignore
137
+ },
138
+ required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'],
139
+ additionalProperties: false,
140
+ }
141
+
142
+ /** A screenshot's path, plus the picture when this model can look at it. */
143
+ export const SHOT_OUTPUT = {
144
+ schema: { // ui-copy-ignore
145
+ type: 'object', // ui-copy-ignore
146
+ properties: { text: { type: 'string' }, image: SHOT_IMAGE },
147
+ required: ['text'],
148
+ additionalProperties: false,
149
+ },
150
+ /**
151
+ * @param {unknown} _args - call arguments, unused.
152
+ * @param {any} value - tool value.
153
+ * @returns {any[]} the text, and the picture when there is one.
154
+ */
155
+ render(_args, value) {
156
+ const blocks = [{ type: 'text', text: String(value?.text ?? '') }]
157
+ if (value?.image !== undefined) blocks.push({ type: 'image', attachment: value.image })
158
+ return blocks
159
+ },
160
+ }
161
+
28
162
  const TEXT_OUTPUT = {
29
163
  schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
30
164
  /**
@@ -45,18 +179,21 @@ const TEXT_OUTPUT = {
45
179
  * @param {any} page - sidecar page.
46
180
  * @returns {Promise<string>} address, title, focus and any open dialog.
47
181
  */
182
+ export const BRIEF_JS = `(() => {
183
+ const a = document.activeElement
184
+ const secret = a !== null && typeof a === 'object' && (String(a.type ?? '') === 'password' || String(a.getAttribute === undefined ? '' : a.getAttribute('type') ?? '') === 'password')
185
+ const focus = a !== null && a !== document.body && a !== document.documentElement
186
+ ? { name: String(a.getAttribute('aria-label') || a.getAttribute('placeholder') || (a.labels && a.labels[0] && a.labels[0].innerText) || a.innerText || a.name || a.tagName).trim().slice(0, 60), value: secret ? '' : String(a.value === undefined ? '' : a.value).slice(0, 60) }
187
+ : null
188
+ return { url: location.href, title: document.title.slice(0, 80), focus }
189
+ })()`
190
+
48
191
  async function brief(page) {
49
192
  const dialog = page.dialog
50
193
  if (dialog !== null && dialog !== undefined) {
51
194
  return `${String(page.url ?? '')} · dialog ${String(dialog.type ?? '')}: ${String(dialog.message ?? '')}`
52
195
  }
53
- const info = await evaluate(page, `(() => {
54
- const a = document.activeElement
55
- const focus = a !== null && a !== document.body && a !== document.documentElement
56
- ? { name: String(a.getAttribute('aria-label') || a.getAttribute('placeholder') || (a.labels && a.labels[0] && a.labels[0].innerText) || a.innerText || a.name || a.tagName).trim().slice(0, 60), value: String(a.value === undefined ? '' : a.value).slice(0, 60) }
57
- : null
58
- return { url: location.href, title: document.title.slice(0, 80), focus }
59
- })()`)
196
+ const info = await evaluate(page, BRIEF_JS)
60
197
  const bits = [String(info?.url ?? '')]
61
198
  if (String(info?.title ?? '') !== '') bits.push(String(info.title))
62
199
  if (info?.focus) bits.push(`focus ${info.focus.name}${info.focus.value === '' ? '' : ` = ${info.focus.value}`}`)
@@ -188,6 +325,18 @@ export const KEYS = {
188
325
  Space: { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
189
326
  }
190
327
 
328
+ /* A game moves while a key is held, and the keys a game listens for are letters
329
+ and digits, not the ones a form needs. Each entry carries what a browser
330
+ reports: the `key` value, the physical `code`, and the legacy number old
331
+ engines still look at. */
332
+ for (const letter of 'abcdefghijklmnopqrstuvwxyz') {
333
+ KEYS[letter] = { key: letter, code: `Key${letter.toUpperCase()}`, keyCode: letter.toUpperCase().charCodeAt(0), text: letter }
334
+ KEYS[letter.toUpperCase()] = KEYS[letter]
335
+ }
336
+ for (const digit of '0123456789') {
337
+ KEYS[digit] = { key: digit, code: `Digit${digit}`, keyCode: digit.charCodeAt(0), text: digit }
338
+ }
339
+
191
340
  /**
192
341
  * Press a key: keyDown, then keyUp, with the character when the key types one.
193
342
  * @param {any} page - sidecar page.
@@ -195,14 +344,37 @@ export const KEYS = {
195
344
  * @param {number} [modifiers] - CDP modifier bits: Alt 1, Ctrl 2, Meta 4, Shift 8.
196
345
  * @returns {Promise<void>} done.
197
346
  */
198
- export async function pressKey(page, name, modifiers = 0) {
347
+ export async function pressKey(page, name, modifiers = 0, holdMs = 0) {
199
348
  const key = KEYS[name] ?? KEYS[name.charAt(0).toUpperCase() + name.slice(1)]
200
349
  if (key === undefined) throw new Error(`sidecar: no such key ${name}`) // ui-copy-ignore
201
350
  const base = { key: key.key, code: key.code, windowsVirtualKeyCode: key.keyCode, nativeVirtualKeyCode: key.keyCode, modifiers }
202
351
  await page.key('keyDown', key.text === undefined ? base : { ...base, text: key.text })
352
+ /* A key held down is how a game moves: down, wait, up — not a tap. */
353
+ if (holdMs > 0) await new Promise((settle) => setTimeout(settle, holdMs))
203
354
  await page.key('keyUp', base)
204
355
  }
205
356
 
357
+ /**
358
+ * Press several keys at once and hold them: the diagonal a game expects, and a
359
+ * chord such as Shift+ArrowLeft. Releasing in reverse order is what a pair of
360
+ * hands does, and some engines only notice the last key that went up.
361
+ * @param {any} page - sidecar page.
362
+ * @param {string[]} names - key names, for example ['ArrowLeft', 'ArrowUp'].
363
+ * @param {number} [modifiers] - CDP modifier bits.
364
+ * @param {number} [holdMs] - how long every key stays down.
365
+ * @returns {Promise<void>} done.
366
+ */
367
+ export async function pressChord(page, names, modifiers = 0, holdMs = 0) {
368
+ const keys = names.map((name) => {
369
+ const key = KEYS[name] ?? KEYS[name.charAt(0).toUpperCase() + name.slice(1)]
370
+ if (key === undefined) throw new Error(`sidecar: no such key ${name}`) // ui-copy-ignore
371
+ return { key, base: { key: key.key, code: key.code, windowsVirtualKeyCode: key.keyCode, nativeVirtualKeyCode: key.keyCode, modifiers } }
372
+ })
373
+ for (const one of keys) await page.key('keyDown', one.key.text === undefined ? one.base : { ...one.base, text: one.key.text })
374
+ if (holdMs > 0) await new Promise((settle) => setTimeout(settle, holdMs))
375
+ for (const one of [...keys].reverse()) await page.key('keyUp', one.base)
376
+ }
377
+
206
378
  /**
207
379
  * Empty the field that has focus, whatever this machine calls select-all:
208
380
  * Command on macOS, Control elsewhere. A page can swallow the shortcut, so any
@@ -479,12 +651,22 @@ async function fieldByName(page, words) {
479
651
  * @param {{clear?: boolean, submit?: boolean}} options - how.
480
652
  */
481
653
  async function writeAt(page, point, text, options = {}) {
654
+ /* Approach the control the way a hand does: a couple of small moves that
655
+ land in different places, not a teleport followed by a click. */
656
+ const drift = (by) => ({ x: point.x + by, y: point.y + Math.round(by / 2) })
657
+ await page.mouse('mouseMoved', drift(-14 - Math.round(Math.random() * 10)))
658
+ await wait(40 + Math.round(Math.random() * 45))
659
+ await page.mouse('mouseMoved', drift(-4 - Math.round(Math.random() * 4)))
660
+ await wait(25 + Math.round(Math.random() * 35))
482
661
  await page.mouse('mouseMoved', point)
662
+ await wait(35 + Math.round(Math.random() * 60))
483
663
  await page.mouse('mousePressed', { ...point, clickCount: 1 })
664
+ await wait(20 + Math.round(Math.random() * 40))
484
665
  await page.mouse('mouseReleased', { ...point, clickCount: 1 })
485
- await wait(80)
666
+ await wait(90 + Math.round(Math.random() * 120))
486
667
  if (options.clear === true) await clearFocus(page)
487
- await page.insertText(text)
668
+ if (options.human === false) await page.insertText(text)
669
+ else await page.humanType(text)
488
670
  if (options.submit === true) await pressKey(page, 'Enter')
489
671
  }
490
672
 
@@ -498,8 +680,11 @@ async function writeAt(page, point, text, options = {}) {
498
680
  */
499
681
  async function followRef(page, ref, known) {
500
682
  const spot = await locate(page, ref)
501
- if (spot.gone !== true) return spot
502
683
  const was = Array.isArray(known) ? known.find((control) => Number(control.ref) === Number(ref)) : undefined
684
+ if (spot.gone !== true) {
685
+ const drift = differs(was, spot)
686
+ return drift === '' ? spot : { gone: true, why: drift }
687
+ }
503
688
  if (was === undefined) return spot
504
689
  const want = identityOf(was)
505
690
  if (want.name === '') return spot
@@ -512,12 +697,40 @@ async function followRef(page, ref, known) {
512
697
  return { ...next, followed: true, was: want.name }
513
698
  }
514
699
 
700
+ /**
701
+ * One read state per chat, created the first time that chat reads.
702
+ * @param {Map<string, any>} store - per-chat states.
703
+ * @param {string} id - chat id.
704
+ * @param {any} fallback - state for calls that name no chat.
705
+ * @returns {any} the state of that chat.
706
+ */
707
+ export function readStateFor(store, id, fallback) {
708
+ const key = String(id ?? '')
709
+ const known = store.get(key)
710
+ if (known !== undefined) return known
711
+ /* The one object the plugin was started with belongs to calls that name no
712
+ chat, so a chat that does name itself gets an empty one of its own. */
713
+ const fresh = key === '' ? fallback : { refs: [], at: 0 }
714
+ store.set(key, fresh)
715
+ return fresh
716
+ }
717
+
718
+ /**
719
+ * A step that could not be carried out. The batch stops here: the next step was
720
+ * written against a page that never arrived.
721
+ * @param {string} text - what happened.
722
+ * @returns {{text: string, blocked: boolean}} the step result.
723
+ */
724
+ function stuck(text) {
725
+ return { text, blocked: true }
726
+ }
727
+
515
728
  async function runStep(page, step, known) {
516
729
  if (step.ref !== undefined) {
517
730
  if (step.tap !== undefined || step.text !== undefined) {
518
731
  const lead = await coverLead(page, Number(step.ref))
519
732
  const spot = await followRef(page, Number(step.ref), known)
520
- if (spot.gone === true) return `ref ${String(step.ref)}: ${REF_GONE}`
733
+ if (spot.gone === true) return stuck(`ref ${String(step.ref)}: ${REF_GONE}`)
521
734
  const after = spot.followed === true ? ` (it was redrawn under a new number, used where it is now)` : ''
522
735
  const point = { x: Number(spot.x), y: Number(spot.y) }
523
736
  await page.mouse('mouseMoved', point)
@@ -526,17 +739,17 @@ async function runStep(page, step, known) {
526
739
  if (step.tap !== undefined) return `${lead}clicked ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
527
740
  await wait(80)
528
741
  if (step.clear === true) await clearFocus(page)
529
- await page.insertText(String(step.text))
742
+ await page.humanType(String(step.text))
530
743
  if (step.submit === true) await pressKey(page, 'Enter')
531
744
  return `${lead}typed into ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
532
745
  }
533
746
  }
534
747
  if (step.tap !== undefined && !/^\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*$/.test(String(step.tap))) {
535
748
  const hit = await findWords(page, String(step.tap))
536
- if (hit === null) return `no control says ${JSON.stringify(String(step.tap))}`
749
+ if (hit === null) return stuck(`no control says ${JSON.stringify(String(step.tap))}`)
537
750
  const lead = await coverLead(page, hit.ref)
538
751
  const spot = await locate(page, hit.ref)
539
- if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
752
+ if (spot.gone === true) return stuck(`ref ${String(hit.ref)}: ${REF_GONE}`)
540
753
  const point = { x: Number(spot.x), y: Number(spot.y) }
541
754
  await page.mouse('mouseMoved', point)
542
755
  await page.mouse('mousePressed', { ...point, clickCount: 1 })
@@ -547,7 +760,7 @@ async function runStep(page, step, known) {
547
760
  /* The words may sit on the field or in a div above a bare input. Only a
548
761
  click that lands on the field itself can be trusted with the typing. */
549
762
  const target = await fieldByName(page, String(step.field))
550
- if (target === null) return `${String(step.field)}: ${REF_GONE}`
763
+ if (target === null) return stuck(`${String(step.field)}: ${REF_GONE}`)
551
764
  await writeAt(page, { x: target.x, y: target.y }, String(step.text ?? ''), { clear: step.clear, submit: step.submit })
552
765
  return `${target.lead}typed into "${target.name.slice(0, 60)}"`
553
766
  }
@@ -561,7 +774,7 @@ async function runStep(page, step, known) {
561
774
  }
562
775
  if (step.text !== undefined) {
563
776
  if (step.clear === true) await clearFocus(page)
564
- await page.insertText(String(step.text))
777
+ await page.humanType(String(step.text))
565
778
  if (step.submit === true) await pressKey(page, 'Enter')
566
779
  return `typed ${String(step.text).slice(0, 60)}`
567
780
  }
@@ -577,17 +790,17 @@ async function runStep(page, step, known) {
577
790
  if (step.wait_text !== undefined) {
578
791
  const needle = JSON.stringify(String(step.wait_text))
579
792
  const ok = await until(page, `document.body ? document.body.innerText.includes(${needle}) : false`, Number(step.timeout ?? 8000))
580
- return ok ? `saw ${String(step.wait_text)}` : `did not see ${String(step.wait_text)}`
793
+ return ok ? `saw ${String(step.wait_text)}` : stuck(`did not see ${String(step.wait_text)}`)
581
794
  }
582
795
  if (step.wait_gone !== undefined) {
583
796
  const needle = JSON.stringify(String(step.wait_gone))
584
797
  const ok = await until(page, `document.body ? !document.body.innerText.includes(${needle}) : true`, Number(step.timeout ?? 8000))
585
- return ok ? `${String(step.wait_gone)} went away` : `${String(step.wait_gone)} stayed`
798
+ return ok ? `${String(step.wait_gone)} went away` : stuck(`${String(step.wait_gone)} stayed`)
586
799
  }
587
800
  if (step.wait_url !== undefined) {
588
801
  const needle = JSON.stringify(String(step.wait_url))
589
802
  const ok = await until(page, `location.href.includes(${needle})`, Number(step.timeout ?? 8000))
590
- return ok ? `at ${String(step.wait_url)}` : `still at a different address`
803
+ return ok ? `at ${String(step.wait_url)}` : stuck(`still at a different address`)
591
804
  }
592
805
  if (step.open !== undefined) {
593
806
  const landed = await page.navigate(String(step.open))
@@ -624,6 +837,22 @@ async function remember(page, refs, options) {
624
837
  return read
625
838
  }
626
839
 
840
+ /**
841
+ * Two reads of the same page in the same shape say the same thing. Saying it
842
+ * again costs tokens and teaches nothing, so the second read can be one line.
843
+ * @param {any} before - the earlier read.
844
+ * @param {any} after - the read now.
845
+ * @returns {boolean} true when nothing a read would show has changed.
846
+ */
847
+ export function sameRead(before, after) {
848
+ if (before === undefined || before === null || after === undefined || after === null) return false
849
+ if (before.url !== after.url || before.title !== after.title) return false
850
+ if (before.text !== after.text) return false
851
+ if (JSON.stringify(before.controls) !== JSON.stringify(after.controls)) return false
852
+ if ((before.focused?.ref ?? null) !== (after.focused?.ref ?? null)) return false
853
+ return true
854
+ }
855
+
627
856
  /**
628
857
  * Say only what changed since the previous read.
629
858
  * @param {any} previous - the read before.
@@ -719,7 +948,140 @@ export async function extractRows(page, args) {
719
948
  return `${rows.length} of ${Number(value.total)} rows matching ${select}:\n${said.join('\n')}${more}`
720
949
  }
721
950
 
951
+ /**
952
+ * Say one thing to a box in the page and take the answer back. The wait ends
953
+ * when the answer has stopped changing, so a streaming reply is not cut off and
954
+ * a page that never answers does not hold the call open forever.
955
+ * @param {any} page - sidecar page.
956
+ * @param {{text?: string, ref?: number, submit?: boolean, timeout_ms?: number}} args - what to say.
957
+ * @returns {Promise<string>} the answer, or why there is none.
958
+ */
959
+ /**
960
+ * Whether a box on the page still holds the words that were typed.
961
+ * A box that keeps them never sent them, which is what a signed-out page does.
962
+ * @param {any} page - sidecar page.
963
+ * @param {string} words - what was typed.
964
+ * @returns {Promise<boolean>} true when the words are still in a box.
965
+ */
966
+ async function boxHolds(page, words) {
967
+ const wanted = String(words).slice(0, 24)
968
+ if (wanted === '') return false
969
+ const held = await evaluate(page, `(() => {
970
+ const wanted = ${JSON.stringify(wanted)}
971
+ for (const el of document.querySelectorAll('textarea, [contenteditable=""], [contenteditable="true"], input[type="text"], input[type="search"], input:not([type])')) {
972
+ if (String(el.value ?? el.innerText ?? '').trim().includes(wanted)) return true
973
+ }
974
+ return false
975
+ })()`)
976
+ return held === true
977
+ }
978
+
979
+ /**
980
+ * Sends a line to whatever chat the page holds and waits for the answer to stop
981
+ * growing. The ref is followed the same way a click follows it, so a reply box
982
+ * that was redrawn since the read still gets the text.
983
+ * @param {any} page - sidecar page.
984
+ * @param {any} args - text, optional ref, optional timeout.
985
+ * @returns {Promise<string>} the answer.
986
+ */
987
+ export async function askInPage(page, args) {
988
+ const said = String(args?.text ?? '').trim()
989
+ if (said === '') return 'Give me something to say.'
990
+ const budget = Math.max(3000, Math.min(180_000, Math.trunc(Number(args?.timeout_ms ?? 60_000)) || 60_000))
991
+ const before = await readPage(page, { mode: 'text', maxChars: 4000 })
992
+ let point = null
993
+ if (args?.ref !== undefined) {
994
+ const spot = await followRef(page, Number(args.ref))
995
+ if (spot.gone === true) return spot.why ?? REF_GONE
996
+ point = { x: Number(spot.x), y: Number(spot.y) }
997
+ } else {
998
+ const at = await evaluate(page, `(() => {
999
+ let best = null
1000
+ for (const el of document.querySelectorAll('textarea, [contenteditable=""], [contenteditable="true"], input[type="text"], input[type="search"], input:not([type])')) {
1001
+ const b = el.getBoundingClientRect()
1002
+ const s = getComputedStyle(el)
1003
+ if (s.visibility === 'hidden' || s.display === 'none' || b.width < 80 || b.height < 18) continue
1004
+ if (b.bottom < 0 || b.top > innerHeight) continue
1005
+ if (best === null || b.width * b.height > best.area) best = { area: b.width * b.height, x: b.x + Math.min(24, b.width / 2), y: b.y + b.height / 2, name: String(el.getAttribute('placeholder') ?? el.getAttribute('aria-label') ?? '').slice(0, 40) }
1006
+ }
1007
+ return best === null ? null : { x: Math.round(best.x), y: Math.round(best.y), name: best.name }
1008
+ })()`)
1009
+ if (at === null || at === undefined) return 'No box to type in on this page. Call sidecar_view to see what is there.'
1010
+ point = { x: Number(at.x), y: Number(at.y) }
1011
+ }
1012
+ await page.mouse('mouseMoved', point)
1013
+ await page.mouse('mousePressed', { ...point, clickCount: 1 })
1014
+ await page.mouse('mouseReleased', { ...point, clickCount: 1 })
1015
+ await wait(60)
1016
+ await page.humanType(said)
1017
+ if (args?.submit !== false) await pressKey(page, 'Enter')
1018
+ await wait(500)
1019
+ if (args?.submit !== false && (await boxHolds(page, said))) {
1020
+ return `the words are still in the box 0.5s after pressing Enter: the page did not take the message. If it is waiting for a sign-in, sign in in the column, then ask again.`
1021
+ }
1022
+ const started = Date.now()
1023
+ const deadline = started + budget
1024
+ let last = before.text
1025
+ let fresh = ''
1026
+ let steady = 0
1027
+ let quiet = 0
1028
+ while (Date.now() < deadline) {
1029
+ await wait(500)
1030
+ const now = await readPage(page, { mode: 'text', maxChars: 4000 })
1031
+ if (now.text !== last) {
1032
+ last = now.text
1033
+ fresh = now.text
1034
+ steady = 0
1035
+ quiet = 0
1036
+ continue
1037
+ }
1038
+ if (fresh !== '') {
1039
+ steady += 1
1040
+ if (steady >= 3) break
1041
+ } else {
1042
+ quiet += 1
1043
+ if (quiet >= 4 && Date.now() - started > 4000) break
1044
+ }
1045
+ }
1046
+ const took = Date.now() - started
1047
+ /* A page that shows neither the words in a box nor the message itself never
1048
+ took it: the chips moved, the menu redrew, and none of that is an answer. */
1049
+ const echo = String(said.slice(0, 24))
1050
+ if (fresh !== '' && echo.length >= 8 && !fresh.includes(echo)) {
1051
+ const asking = /(sign in|log in|登录|注册)/i.test(fresh)
1052
+ return `the page changed but does not show the message: it did not take what was sent.${asking ? ' It is asking for a sign-in — sign in in the column, then ask again.' : ' Try sidecar_view to see what is in the way.'}`
1053
+ }
1054
+ /* A box that still holds the words is the page saying it did not take them:
1055
+ a signed-out page leaves them there, and the old wording sent the reader
1056
+ looking for an answer that was never asked for. */
1057
+ const stuck = fresh === '' && (await boxHolds(page, said))
1058
+ if (fresh === '') {
1059
+ if (stuck) return `sent it — no answer after ${Math.round(took / 100) / 10}s, and the words are still in the box: the page did not take the message. If it is waiting for a sign-in, sign in in the column, then ask again.`
1060
+ return `sent it — no answer after ${Math.round(took / 100) / 10}s; the page still says: ${before.text.slice(0, 400)}`
1061
+ }
1062
+ /* The answer may replace the words that were there, so cut at the last
1063
+ character the two reads share, not at the old length. */
1064
+ let shared = 0
1065
+ while (shared < fresh.length && shared < before.text.length && fresh[shared] === before.text[shared]) shared += 1
1066
+ const added = fresh.slice(shared)
1067
+ /* Lines are kept: a reviewer's list is one point per line, and flattening it
1068
+ would leave the next round with a paragraph instead of items. */
1069
+ const words = added.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim().slice(0, 2500)
1070
+ return `the answer came back in ${Math.round(took / 100) / 10}s:\n${words}`
1071
+ }
1072
+
722
1073
  export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1074
+ /* A ref belongs to the page it was read from, and two chats read two pages.
1075
+ One shared read state meant chat B's read replaced chat A's baseline, so A's
1076
+ next "what changed" was a diff against a page it never saw and a number from
1077
+ A could be matched against B's control. Each chat now keeps its own. */
1078
+ const byChat = new Map()
1079
+ /**
1080
+ * The read state of the chat this call speaks for.
1081
+ * @param {string} [id] - chat id; the current one by default.
1082
+ * @returns {{refs: any[], at: number, last?: any, lastShape?: string}} its state.
1083
+ */
1084
+ const refsFor = (id = currentSession()) => readStateFor(byChat, id, refs)
723
1085
  /* Reading, clicking and typing on Chrome's own error page is not driving the
724
1086
  site: the text there is Chrome talking, the buttons reload. Those calls are
725
1087
  refused with the error code instead. The tools that report the browser
@@ -727,14 +1089,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
727
1089
  run, because they are how a stuck person finds out what happened. */
728
1090
  const withRawPage = async (work) => {
729
1091
  if (!sidecar.alive) return { text: SIDECAR_OFF }
730
- const page = await sidecar.ensure()
1092
+ const page = await sidecar.ensure({ session: currentSession() })
731
1093
  const text = await work(page)
732
1094
  return { text: text + personNote(sidecar) }
733
1095
  }
734
1096
  /** Looking is still useful while the person has the page. */
735
1097
  const withRead = async (work) => {
736
- if (!sidecar.alive) return { text: SIDECAR_OFF }
737
- const page = await sidecar.ensure()
1098
+ /* A plugin reload makes a new driver while the browser it started keeps
1099
+ running. Refusing every read until someone opens a page again would throw
1100
+ away a browser that is still there: attach to it instead. */
1101
+ if (!sidecar.alive) {
1102
+ try {
1103
+ await sidecar.ensure({ session: currentSession() })
1104
+ } catch {
1105
+ return { text: SIDECAR_OFF }
1106
+ }
1107
+ }
1108
+ const page = await sidecar.ensure({ session: currentSession() })
738
1109
  const dead = await unreachable(page)
739
1110
  if (dead !== '') return { text: dead + personNote(sidecar) }
740
1111
  const text = await work(page)
@@ -747,7 +1118,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
747
1118
  return withRead(work)
748
1119
  }
749
1120
  const look = async (page, options) => {
750
- const read = await remember(page, refs, options)
1121
+ const read = await remember(page, refsFor(), options)
751
1122
  const said = renderPage(read, options)
752
1123
  return isCheckPage(read) ? `${said}\n${CHECK_NOTE}` : said
753
1124
  }
@@ -768,8 +1139,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
768
1139
  */
769
1140
  const findAgain = async (page, ref) => {
770
1141
  const spot = await locate(page, ref)
771
- if (spot.gone !== true) return spot
772
- const known = (refs?.refs ?? []).find((control) => Number(control.ref) === Number(ref))
1142
+ const known = (refsFor().refs ?? []).find((control) => Number(control.ref) === Number(ref))
1143
+ if (spot.gone !== true) {
1144
+ const drift = differs(known, spot)
1145
+ return drift === '' ? spot : { gone: true, why: drift }
1146
+ }
773
1147
  if (known === undefined) return spot
774
1148
  const want = identityOf(known)
775
1149
  if (want.name === '') return spot
@@ -801,7 +1175,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
801
1175
  isConcurrencySafe: () => false,
802
1176
  /** @param {any} args - arguments. @param {any} exec - tool run. @returns {Promise<{text: string}>} what the page is. */
803
1177
  execute: async (args, exec) => {
804
- const page = await sidecar.ensure({ url: args?.url, headless: args?.headless })
1178
+ const who = String(exec?.agent?.id ?? '')
1179
+ const page = await sidecar.ensure({ url: args?.url, headless: args?.headless, session: who })
805
1180
  if (args?.url === undefined) await page.readTitle()
806
1181
  /* A window on the person's screen is their business, not a surprise: say
807
1182
  it, so nobody wonders what popped up. A site that refuses a windowless
@@ -809,7 +1184,6 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
809
1184
  const windowed = sidecar.headless === false ? 'The browser is showing a window this time — a site only opens for a windowed one. Same profile, same logins.\n' : ''
810
1185
  /* The tab appears in the calling chat and nowhere else: the person may
811
1186
  be reading a different chat, and a browser there would be a surprise. */
812
- const who = String(exec?.agent?.id ?? '')
813
1187
  if (who !== '') host.want?.(who) // ui-copy-ignore
814
1188
  await settle(page, 8000)
815
1189
  const dead = await unreachable(page)
@@ -838,10 +1212,29 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
838
1212
  /** @param {any} args - arguments. @returns {Promise<{text: string}>} the read. */
839
1213
  execute: async (args) => withRead(async (page) => {
840
1214
  const options = { mode: String(args?.mode ?? 'text'), maxChars: Number(args?.max_chars ?? 4600) }
841
- const read = await remember(page, refs, options)
1215
+ /* Each chat keeps its own read state: a shared one let another chat's
1216
+ read replace this chat's baseline, so its numbers and its "what
1217
+ changed" answered about a page it never saw. */
1218
+ const state = refsFor()
1219
+ const read = await remember(page, state, options)
842
1220
  const note = isCheckPage(read) ? `\n${CHECK_NOTE}` : ''
843
- if (args?.changed_only === true && refs.last !== undefined) return diffRead(refs.last, read, options) + note
844
- refs.last = read
1221
+ if (args?.changed_only === true && state.last !== undefined) {
1222
+ /* The diff becomes the new baseline: a page that moved zero -> one
1223
+ should not report that move again on the next read. */
1224
+ const text = diffRead(state.last, read, options) + note
1225
+ state.last = read
1226
+ state.lastShape = JSON.stringify(options)
1227
+ return text
1228
+ }
1229
+ const before = state.last
1230
+ state.last = read
1231
+ /* Same page, same shape, same words: the numbers above still work, and
1232
+ repeating the page would only spend tokens. */
1233
+ if (sameRead(before, read) && state.lastShape === JSON.stringify(options)) {
1234
+ const moved = Number(before.scroll?.y ?? 0) !== Number(read.scroll?.y ?? 0) ? `, scrolled to ${Math.round(Number(read.scroll?.y ?? 0))}` : ''
1235
+ return `still at ${read.url}${moved} — nothing changed since the last read; the numbers above still work${note}`
1236
+ }
1237
+ state.lastShape = JSON.stringify(options)
845
1238
  return renderPage(read, options) + note
846
1239
  }),
847
1240
  },
@@ -857,6 +1250,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
857
1250
  y: { type: 'integer', description: 'Viewport y, when clicking a point.' },
858
1251
  double: { type: 'boolean', description: 'Double-click.' },
859
1252
  right: { type: 'boolean', description: 'Right-click, opening a context menu.' },
1253
+ hold_ms: { type: 'integer', description: 'Keep the button down this long before releasing: a press-and-hold a game or a drag handle needs.' },
860
1254
  read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
861
1255
  },
862
1256
  additionalProperties: false,
@@ -871,7 +1265,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
871
1265
  let followed = ''
872
1266
  if (args?.ref !== undefined) {
873
1267
  const spot = await findAgain(page, Number(args.ref))
874
- if (spot.gone === true) return REF_GONE
1268
+ if (spot.gone === true) return spot.why ?? REF_GONE
875
1269
  if (spot.followed === true) followed = `the page redrew, so "${String(spot.was).slice(0, 50)}" has a new number — used where it is now`
876
1270
  point = { x: Number(spot.x), y: Number(spot.y) }
877
1271
  } else if (String(args?.text ?? '') !== '') {
@@ -886,6 +1280,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
886
1280
  if (args?.ref !== undefined && button === 'left') {
887
1281
  const gate = await clearCover(page, Number(args.ref))
888
1282
  cover = gate.note
1283
+ /* An overlay that would not go away leaves the control underneath:
1284
+ the click would land on the overlay, so it is not sent. */
1285
+ if (gate.cleared !== true) {
1286
+ return `${cover.trim()}\nNot clicked: the control is still covered. Call sidecar_view and handle the overlay first.`
1287
+ }
889
1288
  if (gate.cleared && cover !== '') {
890
1289
  const again = await locate(page, Number(args.ref))
891
1290
  if (again.gone !== true) point = { x: Number(again.x), y: Number(again.y) }
@@ -893,6 +1292,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
893
1292
  }
894
1293
  await page.mouse('mouseMoved', point)
895
1294
  await page.mouse('mousePressed', { ...point, clickCount: args?.double === true ? 2 : 1, button })
1295
+ const hold = Math.max(0, Math.min(60_000, Number(args?.hold_ms ?? 0)))
1296
+ if (hold > 0) await new Promise((settle) => setTimeout(settle, hold))
896
1297
  await page.mouse('mouseReleased', { ...point, clickCount: args?.double === true ? 2 : 1, button })
897
1298
  await settle(page)
898
1299
  const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 3000 }), `click ${label}`)
@@ -934,8 +1335,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
934
1335
  if (args?.ref !== undefined) {
935
1336
  const gate = await clearCover(page, Number(args.ref))
936
1337
  cover = gate.note
1338
+ if (gate.cleared !== true) {
1339
+ return `${cover.trim()}\nNot typed into: the field is still covered. Call sidecar_view and handle the overlay first.`
1340
+ }
937
1341
  const spot = await findAgain(page, Number(args.ref))
938
- if (spot.gone === true) return REF_GONE + cover
1342
+ if (spot.gone === true) return spot.why ?? REF_GONE + cover
939
1343
  if (spot.followed === true) followed = `the page redrew, so "${String(spot.was).slice(0, 50)}" has a new number — typed where it is now`
940
1344
  const point = { x: Number(spot.x), y: Number(spot.y) }
941
1345
  await page.mouse('mouseMoved', point)
@@ -944,21 +1348,131 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
944
1348
  await wait(80)
945
1349
  }
946
1350
  if (args?.clear === true) await clearFocus(page)
947
- await page.insertText(String(args?.text ?? ''))
1351
+ await page.humanType(String(args?.text ?? ''))
948
1352
  if (args?.submit === true) await pressKey(page, 'Enter')
949
1353
  await settle(page)
950
1354
  const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
951
1355
  return said + cover + (followed === '' ? '' : ` (${followed})`) // ui-copy-ignore
952
1356
  }),
953
1357
  },
1358
+ {
1359
+ name: 'sidecar_ask', // ui-copy-ignore
1360
+ description: 'Say something to a chat that is open in the page — a model, a support window, a search box — and read its answer back in the same call. Types into the box, presses Enter, then waits until the answer stops growing. Use it to get a review or a picture prompt answered without a read between every step.',
1361
+ parameters: {
1362
+ type: 'object', // ui-copy-ignore
1363
+ properties: {
1364
+ text: { type: 'string', description: 'What to say.' },
1365
+ ref: { type: 'integer', description: 'The box to type in, from sidecar_view. Omit to use the page\u2019s own chat box.' },
1366
+ submit: { type: 'boolean', description: 'Press Enter to send. Default true.' },
1367
+ timeout_ms: { type: 'integer', description: 'How long to wait for the answer. Default 60000.' },
1368
+ },
1369
+ required: ['text'], // ui-copy-ignore
1370
+ additionalProperties: false,
1371
+ },
1372
+ output: TEXT_OUTPUT, // ui-copy-ignore
1373
+ timeoutMs: 180_000,
1374
+ isConcurrencySafe: () => false,
1375
+ /** @param {any} args - arguments. @returns {Promise<{text: string}>} the answer. */
1376
+ execute: async (args) => withPage((page) => askInPage(page, args)),
1377
+ },
1378
+ {
1379
+ name: 'sidecar_review', // ui-copy-ignore
1380
+ description: 'Ask the chat in the page to review a repository and take its review back as a list: builds the message, sends it, waits for the answer, and writes the round down so the next round knows what was already said. Use it for a review round, not for a one-off question (that is sidecar_ask).',
1381
+ parameters: {
1382
+ type: 'object', // ui-copy-ignore
1383
+ properties: {
1384
+ repo: { type: 'string', description: 'Address of the repository to review.' },
1385
+ commit: { type: 'string', description: 'The commit the review is about. Omit for the newest one.' },
1386
+ focus: { type: 'string', description: 'One thing to look at especially.' },
1387
+ diff: { type: 'string', description: 'The change to review, as a diff. Include it when the reviewer cannot open the repository itself.' },
1388
+ ref: { type: 'integer', description: 'The box to type in, from sidecar_view. Omit to use the page\u2019s own chat box.' },
1389
+ timeout_ms: { type: 'integer', description: 'How long to wait for the review. Default 120000.' },
1390
+ },
1391
+ required: ['repo'], // ui-copy-ignore
1392
+ additionalProperties: false,
1393
+ },
1394
+ output: TEXT_OUTPUT, // ui-copy-ignore
1395
+ timeoutMs: 240_000,
1396
+ isConcurrencySafe: () => false,
1397
+ /** @param {any} args - arguments. @returns {Promise<{text: string}>} the review. */
1398
+ execute: async (args) => withPage(async (page) => {
1399
+ const repo = String(args?.repo ?? '').trim()
1400
+ if (repo === '') return 'Which repository should the reviewer look at?'
1401
+ const commit = String(args?.commit ?? '').trim()
1402
+ /* What was settled last time travels with the question. */
1403
+ const before = lastRound()
1404
+ const already = Array.isArray(before?.marks) ? before.marks : []
1405
+ const asked = askBrief({ repo, commit, focus: String(args?.focus ?? ''), diff: String(args?.diff ?? ''), already })
1406
+ const started = Date.now()
1407
+ const told = await askInPage(page, { text: asked, ref: args?.ref, timeout_ms: args?.timeout_ms ?? 120_000 })
1408
+ const tookMs = Date.now() - started
1409
+ if (!String(told).startsWith('the answer came back in')) return told
1410
+ const answer = String(told).split('\n').slice(1).join('\n').trim()
1411
+ const { round } = recordRound({ commit, question: asked, answer, tookMs })
1412
+ /* A wall answers too, and its words are not advice: name it rather than
1413
+ call the login screen prose. */
1414
+ const wall = /(sign in|log in|登录|注册|verif|checking your browser)/i.test(answer)
1415
+ /* The question sitting there unanswered is not prose: say that instead. */
1416
+ const busy = asked.slice(0, 24)
1417
+ const waiting = round.points.length === 0 && busy.length >= 8 && answer.includes(busy)
1418
+ const list = round.points.length > 0
1419
+ ? round.points.map((point, at) => `${at + 1}. ${point}`).join('\n')
1420
+ : waiting
1421
+ ? `the page has the message but has not answered yet; it shows: ${answer.replace(/\s+/g, ' ').slice(0, 200)}`
1422
+ : wall
1423
+ ? `the page is asking to sign in first, so there is no review yet: ${answer.replace(/\s+/g, ' ').slice(0, 200)}`
1424
+ : `It answered in prose; no list to work from. It said: ${answer.replace(/\s+/g, ' ').slice(0, 200)}`
1425
+ const settled = already.filter((mark) => mark.status !== 'open').length
1426
+ const carried = settled === 0 ? '' : `\n(${settled} earlier point${settled === 1 ? '' : 's'} left out as already handled)`
1427
+ return `review round: ${commit === '' ? 'newest commit' : commit.slice(0, 12)} in ${Math.round(tookMs / 100) / 10}s\n${list}${carried}`
1428
+ }),
1429
+ },
1430
+ {
1431
+ name: 'sidecar_review_verdict', // ui-copy-ignore
1432
+ description: 'Record what happened to each point of the last review round, so the next round leaves settled points out: point numbers are the ones sidecar_review printed, and each mark is fixed, verified (checked in the code and the point does not apply), invalid, or open.',
1433
+ parameters: {
1434
+ type: 'object', // ui-copy-ignore
1435
+ properties: {
1436
+ mark: { // ui-copy-ignore
1437
+ type: 'array',
1438
+ description: 'One entry per point: which point, its status, and the evidence that backs it.',
1439
+ items: { // ui-copy-ignore
1440
+ type: 'object', // ui-copy-ignore
1441
+ properties: { // ui-copy-ignore
1442
+ point: { type: 'integer', description: 'The point number sidecar_review printed, starting at 1.' },
1443
+ status: { type: 'string', enum: MARK_STATUS, description: 'fixed, verified, invalid, or open.' },
1444
+ evidence: { type: 'string', description: 'Where the code shows it: file, test, or commit.' },
1445
+ },
1446
+ required: ['point', 'status'], // ui-copy-ignore
1447
+ additionalProperties: false,
1448
+ },
1449
+ },
1450
+ },
1451
+ required: ['mark'], // ui-copy-ignore
1452
+ additionalProperties: false,
1453
+ },
1454
+ output: TEXT_OUTPUT, // ui-copy-ignore
1455
+ timeoutMs: 10_000,
1456
+ isConcurrencySafe: () => false,
1457
+ /** @param {any} args - arguments. @returns {Promise<{text: string}>} what was recorded. */
1458
+ execute: async (args) => {
1459
+ const written = markRound({ marks: Array.isArray(args?.mark) ? args.mark : [] })
1460
+ if (written === null) return 'No review round to mark: play one with sidecar_review first.'
1461
+ const counts = new Map()
1462
+ for (const mark of written.marks) counts.set(mark.status, (counts.get(mark.status) ?? 0) + 1)
1463
+ const said = [...counts].map(([status, n]) => `${n} ${status}`).join(', ')
1464
+ return `review round ${String(written.round.commit || 'newest').slice(0, 12)}: recorded ${written.marks.length} of ${written.round.points.length} points (${said}).`
1465
+ },
1466
+ },
954
1467
  {
955
1468
  name: 'sidecar_key', // ui-copy-ignore
956
- description: 'Press one key in the sidecar browser: Enter, Tab, Escape, Backspace, Delete, ArrowUp/ArrowDown/ArrowLeft/ArrowRight, Home, End, PageUp, PageDown, Space.',
1469
+ description: 'Press one key in the sidecar browser: Enter, Tab, Escape, Backspace, Delete, arrows, Home, End, PageUp, PageDown, Space, letters and digits. Join keys with + to hold them together, and hold_ms to keep them down.',
957
1470
  parameters: {
958
1471
  type: 'object', // ui-copy-ignore
959
1472
  properties: {
960
- key: { type: 'string', description: 'Key name.' },
1473
+ key: { type: 'string', description: 'Key name, or several joined by + to hold them together (ArrowLeft+ArrowUp, Shift+ArrowLeft). Letters and digits work too.' },
961
1474
  modifiers: { type: 'string', description: 'Held while pressing: Ctrl, Alt, Shift or Meta, joined by +.' },
1475
+ hold_ms: { type: 'integer', description: 'Keep the key down this long before releasing. Games move while a key is held.' },
962
1476
  read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
963
1477
  },
964
1478
  required: ['key'], // ui-copy-ignore
@@ -972,8 +1486,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
972
1486
  const name = String(args?.key ?? 'Enter')
973
1487
  const held = String(args?.modifiers ?? '')
974
1488
  const mods = (held.includes('Alt') ? 1 : 0) + (held.includes('Ctrl') ? 2 : 0) + (held.includes('Meta') ? 4 : 0) + (held.includes('Shift') ? 8 : 0)
975
- await pressKey(page, name, mods)
976
- await settle(page, 3000)
1489
+ const hold = Math.max(0, Math.min(60_000, Number(args?.hold_ms ?? 0)))
1490
+ const names = name.split('+').map((one) => one.trim()).filter((one) => one !== '')
1491
+ if (names.length > 1) await pressChord(page, names, mods, hold)
1492
+ else await pressKey(page, name, mods, hold)
1493
+ /* A held key is game input: waiting for the page to fall quiet after one
1494
+ costs more than the frame it was meant to drive. */
1495
+ if (hold === 0) await settle(page, 3000)
977
1496
  return acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), `key ${name}`)
978
1497
  }),
979
1498
  },
@@ -1019,7 +1538,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1019
1538
  /** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
1020
1539
  execute: async (args) => withPage(async (page) => {
1021
1540
  const spot = await findAgain(page, Number(args?.ref))
1022
- if (spot.gone === true) return REF_GONE
1541
+ if (spot.gone === true) return spot.why ?? REF_GONE
1023
1542
  const cursor = await page.hover({ x: Number(spot.x), y: Number(spot.y) })
1024
1543
  await wait(400)
1025
1544
  const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2500 }), `hover ${String(args?.ref)} · ${cursor}`)
@@ -1103,9 +1622,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1103
1622
  if (steps.length === 0) return 'Give at least one step.'
1104
1623
  const report = []
1105
1624
  for (const [index, step] of steps.entries()) {
1106
- const said = await runStep(page, step ?? {}, refs?.refs)
1107
- report.push(`${index + 1}. ${said}`)
1108
- if (/^ref \d+: That control is gone/.test(said)) return `${report.join('\n')}\nStopped: the page changed. Call sidecar_view and use the new numbers.`
1625
+ const said = await runStep(page, step ?? {}, refsFor().refs)
1626
+ const text = typeof said === 'string' ? said : said.text
1627
+ report.push(`${index + 1}. ${text}`)
1628
+ /* A step that never landed makes every later step a guess, so the
1629
+ batch ends here instead of typing into a page that is not there. */
1630
+ if (typeof said !== 'string' && said.blocked === true) {
1631
+ return `${report.join('\n')}\nStopped after step ${index + 1}. Call sidecar_view and plan again from what the page shows now.`
1632
+ }
1109
1633
  /* A step can navigate straight onto Chrome's error page — a mistyped
1110
1634
  address, or a site that does not answer. The rest of the batch
1111
1635
  would then type into "This site can't be reached", so it stops
@@ -1118,13 +1642,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1118
1642
  const mode = String(args?.read ?? 'text')
1119
1643
  if (mode === 'none') return report.join('\n')
1120
1644
  const options = { mode: mode === 'changed' ? 'text' : mode, maxChars: Number(args?.max_chars ?? 3000) }
1121
- const read = await remember(page, refs, options)
1122
- if (mode === 'changed' && refs.last !== undefined) {
1123
- const diff = diffRead(refs.last, read, options)
1124
- refs.last = read
1645
+ const state = refsFor()
1646
+ const read = await remember(page, state, options)
1647
+ if (mode === 'changed' && state.last !== undefined) {
1648
+ const diff = diffRead(state.last, read, options)
1649
+ state.last = read
1125
1650
  return `${report.join('\n')}\n${diff}`
1126
1651
  }
1127
- refs.last = read
1652
+ state.last = read
1128
1653
  return `${report.join('\n')}\n\n${renderPage(read, options)}`
1129
1654
  }),
1130
1655
  },
@@ -1208,7 +1733,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1208
1733
  return `${said} · ${await brief(page)}`
1209
1734
  }
1210
1735
  const spot = await findAgain(page, Number(args?.ref))
1211
- if (spot.gone === true) return REF_GONE
1736
+ if (spot.gone === true) return spot.why ?? REF_GONE
1212
1737
  const wanted = JSON.stringify(String(args?.value ?? ''))
1213
1738
  const route = await contextFor(page, Number(args?.ref))
1214
1739
  const local = route?.local ?? Number(args?.ref)
@@ -1255,7 +1780,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1255
1780
  if (route?.contextId !== undefined) uploadParams.contextId = route.contextId
1256
1781
  const handle = await page.cdp.send('Runtime.evaluate', uploadParams, route?.sessionId)
1257
1782
  const objectId = handle.result?.objectId
1258
- if (objectId === undefined) return REF_GONE
1783
+ if (objectId === undefined) return spot.why ?? REF_GONE
1259
1784
  const node = await page.cdp.send('DOM.describeNode', { objectId }, route?.sessionId)
1260
1785
  const backendNodeId = node?.node?.backendNodeId
1261
1786
  if (backendNodeId === undefined) return 'That control cannot take a file.'
@@ -1266,7 +1791,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1266
1791
  },
1267
1792
  {
1268
1793
  name: 'sidecar_shot', // ui-copy-ignore
1269
- description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path. Default is the visible screen; full takes the whole page, ref takes one control.',
1794
+ description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path; when the current model accepts images the picture comes back in the same call, so a captcha, a chart or a broken layout can be looked at instead of guessed. Default is the visible screen; full takes the whole page, ref takes one control.',
1270
1795
  parameters: {
1271
1796
  type: 'object', // ui-copy-ignore
1272
1797
  properties: {
@@ -1277,21 +1802,31 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1277
1802
  },
1278
1803
  additionalProperties: false,
1279
1804
  },
1280
- output: TEXT_OUTPUT, // ui-copy-ignore
1805
+ output: SHOT_OUTPUT, // ui-copy-ignore
1281
1806
  timeoutMs: 40_000,
1282
1807
  isConcurrencySafe: () => false,
1283
- /** @param {any} args - arguments. @returns {Promise<{text: string}>} the saved path. */
1284
- execute: async (args) => withRead(async (page) => {
1808
+ /**
1809
+ * A picture nobody can look at is only a file path: when the route declares
1810
+ * image input, the same call carries the PNG as an attachment so the next
1811
+ * step can be planned from what is actually on screen instead of guessing.
1812
+ * @param {any} args - arguments.
1813
+ * @param {any} [exec] - call context, for the route and its attachments.
1814
+ * @returns {Promise<any>} the saved path, and the picture when available.
1815
+ */
1816
+ execute: async (args, exec) => {
1817
+ /** @type {{data: string, file: string}|undefined} */
1818
+ let taken
1819
+ const said = await withRead(async (page) => {
1285
1820
  const scale = Math.max(1, Math.min(3, Number(args?.scale ?? 1)))
1286
1821
  let clip = undefined
1287
1822
  if (args?.ref !== undefined) {
1288
1823
  const spot = await findAgain(page, Number(args.ref))
1289
- if (spot.gone === true) return REF_GONE
1824
+ if (spot.gone === true) return spot.why ?? REF_GONE
1290
1825
  /* The picture is of the control, not of a guess around its middle:
1291
1826
  a canvas can be most of the page, a checkbox a few pixels. */
1292
1827
  const box = await evaluate(page, bounds(Number(args.ref)))
1293
1828
  const rect = typeof box === 'string' ? JSON.parse(box) : box
1294
- if (rect?.gone === true) return REF_GONE
1829
+ if (rect?.gone === true) return spot.why ?? REF_GONE
1295
1830
  const pad = 8
1296
1831
  clip = {
1297
1832
  x: Math.max(0, Math.round(Number(rect.x) - pad)),
@@ -1301,13 +1836,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1301
1836
  }
1302
1837
  }
1303
1838
  const data = await page.shot({ full: args?.full === true, rect: clip, scale })
1304
- const dir = join(homedir(), SHOT_DIR)
1305
- await mkdir(dir, { recursive: true })
1306
- const name = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-${Date.now()}`
1307
- const file = join(dir, name.endsWith('.png') ? name : `${name}.png`)
1308
- await writeFile(file, Buffer.from(data, 'base64'))
1839
+ const file = await saveArtifact('shots', data, { name: args?.name, prefix: 'shot' })
1840
+ taken = { data, file }
1309
1841
  return file
1310
- }),
1842
+ })
1843
+ if (taken === undefined) return said
1844
+ let image
1845
+ try {
1846
+ image = await host.showShot?.(taken.data, taken.file.split('/').pop() ?? 'shot.png', exec) // ui-copy-ignore
1847
+ } catch {
1848
+ image = undefined
1849
+ }
1850
+ /* The route may not carry pictures at all: then the file path is all
1851
+ this call can give, and saying so is better than a silent path the
1852
+ next step assumes was looked at. */
1853
+ if (image === undefined) return { text: `${said.text}\nno picture reached this result — sidecar_view reads the words here, sidecar_grab returns a picture as a file` }
1854
+ return { text: said.text, image }
1855
+ },
1311
1856
  },
1312
1857
  {
1313
1858
  name: 'sidecar_grab', // ui-copy-ignore
@@ -1334,9 +1879,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1334
1879
  found = { pictures: [] }
1335
1880
  }
1336
1881
  const rows = []
1337
- const dir = join(homedir(), SHOT_DIR)
1338
- await mkdir(dir, { recursive: true })
1339
- const base = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-picture-${Date.now()}`
1882
+ const base = String(args?.name ?? '').replace(/[^\w.-]/g, '')
1340
1883
  const list = Array.isArray(found?.pictures) ? found.pictures : []
1341
1884
  for (let index = 0; index < list.length; index += 1) {
1342
1885
  const item = list[index]
@@ -1345,8 +1888,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1345
1888
  if (mark < 0) continue
1346
1889
  const type = String(item?.type ?? 'image/png')
1347
1890
  const ext = type.includes('jpeg') ? 'jpg' : type.includes('webp') ? 'webp' : type.includes('svg') ? 'svg' : 'png'
1348
- const file = join(dir, `${base}${list.length > 1 ? `-${index + 1}` : ''}.${ext}`)
1349
- await writeFile(file, Buffer.from(body.slice(mark + 1), 'base64'))
1891
+ const file = await saveArtifact('pictures', body.slice(mark + 1), {
1892
+ name: base === '' ? '' : `${base}${list.length > 1 ? `-${index + 1}` : ''}`,
1893
+ ext: `.${ext}`,
1894
+ prefix: 'picture',
1895
+ })
1350
1896
  const size = `${Math.round(Number(item.width) || 0)}x${Math.round(Number(item.height) || 0)}`
1351
1897
  /* Say what the picture looks like in words: a model that cannot open
1352
1898
  a file still needs to know a chart came back blank. */
@@ -1372,13 +1918,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1372
1918
  execute: async () => {
1373
1919
  const state = await sidecar.state()
1374
1920
  const owner = String(host.owner?.() ?? '') // ui-copy-ignore
1921
+ const session = currentSession()
1922
+ const seats = String(host.seats?.() ?? '?') // ui-copy-ignore
1923
+ const mine = sidecar.pageFor(session)
1375
1924
  const rows = [
1376
1925
  state.alive ? 'running' : 'stopped',
1926
+ `chat ${session === '' ? '(no chat id)' : session}`,
1927
+ `this chat's tab ${mine === null ? 'none' : String(mine.url)}`, // ui-copy-ignore
1928
+ `tabs in the browser ${String(sidecar.pages.size)}`,
1929
+ `tab list ${[...sidecar.pages.entries()].map(([key, each]) => `${key.slice(-8)}=${String(each.url)}=${each.cdp?.closed === true ? 'closed' : 'open'}`).join(' | ')}`, // ui-copy-ignore
1930
+ `default tab ${String(sidecar.page?.url)}`, // ui-copy-ignore
1377
1931
  state.headless === false ? 'showing a window' : 'no window',
1378
1932
  `profile ${String(state.profile)}`,
1379
1933
  `url ${String(state.url)}`,
1380
1934
  `title ${String(state.title)}`,
1381
1935
  `viewers ${String(state.viewers)}`,
1936
+ `listening chats ${seats}`, // ui-copy-ignore
1937
+ `mirror owner ${owner === '' ? 'none' : owner}`, // ui-copy-ignore
1382
1938
  owner === '' ? 'no owner' : `owner ${owner}`,
1383
1939
  `refs followed ${String(follows.count)}`,
1384
1940
  holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
@@ -1440,7 +1996,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1440
1996
  let start = { x: Number(args?.x ?? NaN), y: Number(args?.y ?? NaN) }
1441
1997
  if (args?.from !== undefined) {
1442
1998
  const spot = await findAgain(page, Number(args.from))
1443
- if (spot.gone === true) return REF_GONE
1999
+ if (spot.gone === true) return spot.why ?? REF_GONE
1444
2000
  start = { x: Number(spot.x), y: Number(spot.y) }
1445
2001
  }
1446
2002
  const end = { x: Number(args?.to_x ?? NaN), y: Number(args?.to_y ?? NaN) }
@@ -1485,11 +2041,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1485
2041
  if (checkRoute?.contextId !== undefined) checkParams.contextId = checkRoute.contextId
1486
2042
  const probe = await page.cdp.send('Runtime.evaluate', checkParams, checkRoute?.sessionId)
1487
2043
  const now = probe.result?.value
1488
- if (now === null || now === undefined) return REF_GONE
2044
+ if (now === null || now === undefined) return spot.why ?? REF_GONE
1489
2045
  const want = args?.checked === undefined ? !Boolean(now.checked) : args.checked === true
1490
2046
  if (Boolean(now.checked) !== want) {
1491
2047
  const spot = await findAgain(page, Number(args.ref))
1492
- if (spot.gone === true) return REF_GONE
2048
+ if (spot.gone === true) return spot.why ?? REF_GONE
1493
2049
  await page.mouse('mouseMoved', { x: Number(spot.x), y: Number(spot.y) })
1494
2050
  await page.mouse('mousePressed', { x: Number(spot.x), y: Number(spot.y) })
1495
2051
  await page.mouse('mouseReleased', { x: Number(spot.x), y: Number(spot.y) })
@@ -1711,18 +2267,18 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1711
2267
  execute: async (args) => withRawPage(async (page) => {
1712
2268
  const action = String(args?.action ?? 'list')
1713
2269
  if (action === 'list') {
1714
- const rows = await sidecar.tabs()
2270
+ const rows = await sidecar.tabs(currentSession())
1715
2271
  if (rows.length === 0) return 'no tabs'
1716
2272
  return rows.map((row, i) => `${i}${row.current ? ' *' : ''} ${row.title || row.url} · ${row.url}`).join('\n')
1717
2273
  }
1718
2274
  const index = Number(args?.index ?? 0)
1719
2275
  if (action === 'switch') {
1720
- const next = await sidecar.useTab(index)
2276
+ const next = await sidecar.useTab(index, currentSession())
1721
2277
  return `tab ${index} · ${String(next?.url ?? '')}`
1722
2278
  }
1723
2279
  if (action === 'close') {
1724
- await sidecar.closeTab(index)
1725
- return `closed tab ${index} · ${String(sidecar.page?.url ?? '')}`
2280
+ await sidecar.closeTab(index, currentSession())
2281
+ return `closed tab ${index} · ${String((sidecar.pageFor(currentSession()) ?? sidecar.page)?.url ?? '')}`
1726
2282
  }
1727
2283
  throw new Error(`sidecar: unknown action ${action}`)
1728
2284
  }),
@@ -1738,11 +2294,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1738
2294
  /** @returns {Promise<{text: string}>} where the PDF is. */
1739
2295
  execute: async () => withRawPage(async (page) => {
1740
2296
  const data = await page.pdf()
1741
- const dir = join(homedir(), SHOT_DIR)
1742
- await mkdir(dir, { recursive: true })
1743
- const file = join(dir, `sidecar-${Date.now()}.pdf`)
1744
- await writeFile(file, Buffer.from(data, 'base64'))
1745
- return file
2297
+ return saveArtifact('pdf', data, { ext: '.pdf', prefix: 'page' })
1746
2298
  }),
1747
2299
  },
1748
2300
  ]