dsh-inner-browser-use 0.18.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, 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
@@ -419,9 +591,15 @@ async function until(page, expression, timeout) {
419
591
  async function findWords(page, words) {
420
592
  const read = await readPage(page, { mode: 'text', maxChars: 1200, max: 200 })
421
593
  const shown = read.controls.filter((control) => control.view !== false)
422
- const wanted = words.toLowerCase()
423
- const hit = shown.find((control) => control.name.toLowerCase() === wanted) ?? shown.find((control) => control.name.toLowerCase().includes(wanted))
424
- if (hit === undefined) return null
594
+ /* The words may sit in separate inline nodes: <span>Sign</span><span>in</span>
595
+ reads as "Signin" to the page, so compare with the spaces taken out too. */
596
+ const squeeze = (text) => String(text).toLowerCase().replace(/\s+/g, '')
597
+ const wanted = squeeze(words)
598
+ const found = shown.find((control) => squeeze(control.name) === wanted) ?? shown.find((control) => squeeze(control.name).includes(wanted))
599
+ if (found === undefined) return null
600
+ /* When only the squeezed form matched, say the words the caller used: the
601
+ page spread them over nodes and "Signin" is not what anyone wrote. */
602
+ const hit = squeeze(found.name) === wanted && found.name.toLowerCase() !== words.toLowerCase() ? { ...found, name: words } : found
425
603
  const spot = await locate(page, hit.ref)
426
604
  if (spot.gone === true) return null
427
605
  return { ...hit, x: Number(spot.x), y: Number(spot.y) }
@@ -438,12 +616,60 @@ const EDITABLE_KINDS = ['input', 'textarea', 'select', 'combobox', 'textbox', 's
438
616
  */
439
617
  async function fieldSpot(page, words) {
440
618
  const said = await evaluate(page, labelSpot(words))
441
- if (said.error !== undefined || said.result === undefined) return null
442
- const spot = said.result
443
- if (spot === undefined || spot === null || spot.name === undefined) return null
619
+ if (said === undefined || said === null || said.error !== undefined) return null
620
+ const spot = typeof said === 'string' ? JSON.parse(said) : said
621
+ if (spot === null || spot.name === undefined || spot.name === '') return null
444
622
  return { x: Number(spot.x), y: Number(spot.y), name: String(spot.name) }
445
623
  }
446
624
 
625
+ /**
626
+ * Where the field called these words is, clearing a banner over it first.
627
+ * @param {any} page - sidecar page.
628
+ * @param {string} words - what the field is called on screen.
629
+ * @returns {Promise<{x: number, y: number, name: string, lead: string}|null>} the click.
630
+ */
631
+ async function fieldByName(page, words) {
632
+ const hit = await findWords(page, words)
633
+ const lead = hit === null ? '' : await coverLead(page, hit.ref)
634
+ if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
635
+ const spot = await locate(page, hit.ref)
636
+ if (spot.gone !== true) return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
637
+ }
638
+ const field = await fieldSpot(page, words)
639
+ if (field !== null) return { ...field, lead }
640
+ if (hit === null) return null
641
+ const spot = await locate(page, hit.ref)
642
+ if (spot.gone === true) return null
643
+ return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
644
+ }
645
+
646
+ /**
647
+ * Click a spot, type into it, and optionally clear it first.
648
+ * @param {any} page - sidecar page.
649
+ * @param {{x: number, y: number}} point - where to click.
650
+ * @param {string} text - what to type.
651
+ * @param {{clear?: boolean, submit?: boolean}} options - how.
652
+ */
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))
661
+ await page.mouse('mouseMoved', point)
662
+ await wait(35 + Math.round(Math.random() * 60))
663
+ await page.mouse('mousePressed', { ...point, clickCount: 1 })
664
+ await wait(20 + Math.round(Math.random() * 40))
665
+ await page.mouse('mouseReleased', { ...point, clickCount: 1 })
666
+ await wait(90 + Math.round(Math.random() * 120))
667
+ if (options.clear === true) await clearFocus(page)
668
+ if (options.human === false) await page.insertText(text)
669
+ else await page.humanType(text)
670
+ if (options.submit === true) await pressKey(page, 'Enter')
671
+ }
672
+
447
673
  /**
448
674
  * Where the control behind a ref is now, following it across a re-render.
449
675
  * The module-level sibling of findAgain, for runStep, which has no closure.
@@ -454,8 +680,11 @@ async function fieldSpot(page, words) {
454
680
  */
455
681
  async function followRef(page, ref, known) {
456
682
  const spot = await locate(page, ref)
457
- if (spot.gone !== true) return spot
458
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
+ }
459
688
  if (was === undefined) return spot
460
689
  const want = identityOf(was)
461
690
  if (want.name === '') return spot
@@ -468,12 +697,40 @@ async function followRef(page, ref, known) {
468
697
  return { ...next, followed: true, was: want.name }
469
698
  }
470
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
+
471
728
  async function runStep(page, step, known) {
472
729
  if (step.ref !== undefined) {
473
730
  if (step.tap !== undefined || step.text !== undefined) {
474
731
  const lead = await coverLead(page, Number(step.ref))
475
732
  const spot = await followRef(page, Number(step.ref), known)
476
- if (spot.gone === true) return `ref ${String(step.ref)}: ${REF_GONE}`
733
+ if (spot.gone === true) return stuck(`ref ${String(step.ref)}: ${REF_GONE}`)
477
734
  const after = spot.followed === true ? ` (it was redrawn under a new number, used where it is now)` : ''
478
735
  const point = { x: Number(spot.x), y: Number(spot.y) }
479
736
  await page.mouse('mouseMoved', point)
@@ -482,17 +739,17 @@ async function runStep(page, step, known) {
482
739
  if (step.tap !== undefined) return `${lead}clicked ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
483
740
  await wait(80)
484
741
  if (step.clear === true) await clearFocus(page)
485
- await page.insertText(String(step.text))
742
+ await page.humanType(String(step.text))
486
743
  if (step.submit === true) await pressKey(page, 'Enter')
487
744
  return `${lead}typed into ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
488
745
  }
489
746
  }
490
747
  if (step.tap !== undefined && !/^\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*$/.test(String(step.tap))) {
491
748
  const hit = await findWords(page, String(step.tap))
492
- 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))}`)
493
750
  const lead = await coverLead(page, hit.ref)
494
751
  const spot = await locate(page, hit.ref)
495
- if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
752
+ if (spot.gone === true) return stuck(`ref ${String(hit.ref)}: ${REF_GONE}`)
496
753
  const point = { x: Number(spot.x), y: Number(spot.y) }
497
754
  await page.mouse('mouseMoved', point)
498
755
  await page.mouse('mousePressed', { ...point, clickCount: 1 })
@@ -502,40 +759,10 @@ async function runStep(page, step, known) {
502
759
  if (step.field !== undefined) {
503
760
  /* The words may sit on the field or in a div above a bare input. Only a
504
761
  click that lands on the field itself can be trusted with the typing. */
505
- const hit = await findWords(page, String(step.field))
506
- let lead = ''
507
- let point = null
508
- let called = ''
509
- if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
510
- lead = await coverLead(page, hit.ref)
511
- const spot = await locate(page, hit.ref)
512
- if (spot.gone !== true) {
513
- point = { x: Number(spot.x), y: Number(spot.y) }
514
- called = String(hit.name)
515
- }
516
- }
517
- if (point === null) {
518
- const field = await fieldSpot(page, String(step.field))
519
- if (field !== null) {
520
- point = { x: field.x, y: field.y }
521
- called = field.name
522
- }
523
- }
524
- if (point === null) {
525
- if (hit === null) return `no control says ${JSON.stringify(String(step.field))}`
526
- const spot = await locate(page, hit.ref)
527
- if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
528
- point = { x: Number(spot.x), y: Number(spot.y) }
529
- called = String(hit.name)
530
- }
531
- await page.mouse('mouseMoved', point)
532
- await page.mouse('mousePressed', { ...point, clickCount: 1 })
533
- await page.mouse('mouseReleased', { ...point, clickCount: 1 })
534
- await wait(80)
535
- if (step.clear === true) await clearFocus(page)
536
- await page.insertText(String(step.text ?? ''))
537
- if (step.submit === true) await pressKey(page, 'Enter')
538
- return `${lead}typed into "${called.slice(0, 60)}"`
762
+ const target = await fieldByName(page, String(step.field))
763
+ if (target === null) return stuck(`${String(step.field)}: ${REF_GONE}`)
764
+ await writeAt(page, { x: target.x, y: target.y }, String(step.text ?? ''), { clear: step.clear, submit: step.submit })
765
+ return `${target.lead}typed into "${target.name.slice(0, 60)}"`
539
766
  }
540
767
  if (step.tap !== undefined) {
541
768
  const [x, y] = String(step.tap).split(',').map(Number)
@@ -547,7 +774,7 @@ async function runStep(page, step, known) {
547
774
  }
548
775
  if (step.text !== undefined) {
549
776
  if (step.clear === true) await clearFocus(page)
550
- await page.insertText(String(step.text))
777
+ await page.humanType(String(step.text))
551
778
  if (step.submit === true) await pressKey(page, 'Enter')
552
779
  return `typed ${String(step.text).slice(0, 60)}`
553
780
  }
@@ -563,17 +790,17 @@ async function runStep(page, step, known) {
563
790
  if (step.wait_text !== undefined) {
564
791
  const needle = JSON.stringify(String(step.wait_text))
565
792
  const ok = await until(page, `document.body ? document.body.innerText.includes(${needle}) : false`, Number(step.timeout ?? 8000))
566
- 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)}`)
567
794
  }
568
795
  if (step.wait_gone !== undefined) {
569
796
  const needle = JSON.stringify(String(step.wait_gone))
570
797
  const ok = await until(page, `document.body ? !document.body.innerText.includes(${needle}) : true`, Number(step.timeout ?? 8000))
571
- 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`)
572
799
  }
573
800
  if (step.wait_url !== undefined) {
574
801
  const needle = JSON.stringify(String(step.wait_url))
575
802
  const ok = await until(page, `location.href.includes(${needle})`, Number(step.timeout ?? 8000))
576
- 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`)
577
804
  }
578
805
  if (step.open !== undefined) {
579
806
  const landed = await page.navigate(String(step.open))
@@ -610,6 +837,22 @@ async function remember(page, refs, options) {
610
837
  return read
611
838
  }
612
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
+
613
856
  /**
614
857
  * Say only what changed since the previous read.
615
858
  * @param {any} previous - the read before.
@@ -705,7 +948,140 @@ export async function extractRows(page, args) {
705
948
  return `${rows.length} of ${Number(value.total)} rows matching ${select}:\n${said.join('\n')}${more}`
706
949
  }
707
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
+
708
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)
709
1085
  /* Reading, clicking and typing on Chrome's own error page is not driving the
710
1086
  site: the text there is Chrome talking, the buttons reload. Those calls are
711
1087
  refused with the error code instead. The tools that report the browser
@@ -713,14 +1089,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
713
1089
  run, because they are how a stuck person finds out what happened. */
714
1090
  const withRawPage = async (work) => {
715
1091
  if (!sidecar.alive) return { text: SIDECAR_OFF }
716
- const page = await sidecar.ensure()
1092
+ const page = await sidecar.ensure({ session: currentSession() })
717
1093
  const text = await work(page)
718
1094
  return { text: text + personNote(sidecar) }
719
1095
  }
720
1096
  /** Looking is still useful while the person has the page. */
721
1097
  const withRead = async (work) => {
722
- if (!sidecar.alive) return { text: SIDECAR_OFF }
723
- 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() })
724
1109
  const dead = await unreachable(page)
725
1110
  if (dead !== '') return { text: dead + personNote(sidecar) }
726
1111
  const text = await work(page)
@@ -733,7 +1118,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
733
1118
  return withRead(work)
734
1119
  }
735
1120
  const look = async (page, options) => {
736
- const read = await remember(page, refs, options)
1121
+ const read = await remember(page, refsFor(), options)
737
1122
  const said = renderPage(read, options)
738
1123
  return isCheckPage(read) ? `${said}\n${CHECK_NOTE}` : said
739
1124
  }
@@ -754,8 +1139,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
754
1139
  */
755
1140
  const findAgain = async (page, ref) => {
756
1141
  const spot = await locate(page, ref)
757
- if (spot.gone !== true) return spot
758
- 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
+ }
759
1147
  if (known === undefined) return spot
760
1148
  const want = identityOf(known)
761
1149
  if (want.name === '') return spot
@@ -787,7 +1175,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
787
1175
  isConcurrencySafe: () => false,
788
1176
  /** @param {any} args - arguments. @param {any} exec - tool run. @returns {Promise<{text: string}>} what the page is. */
789
1177
  execute: async (args, exec) => {
790
- 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 })
791
1180
  if (args?.url === undefined) await page.readTitle()
792
1181
  /* A window on the person's screen is their business, not a surprise: say
793
1182
  it, so nobody wonders what popped up. A site that refuses a windowless
@@ -795,7 +1184,6 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
795
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' : ''
796
1185
  /* The tab appears in the calling chat and nowhere else: the person may
797
1186
  be reading a different chat, and a browser there would be a surprise. */
798
- const who = String(exec?.agent?.id ?? '')
799
1187
  if (who !== '') host.want?.(who) // ui-copy-ignore
800
1188
  await settle(page, 8000)
801
1189
  const dead = await unreachable(page)
@@ -824,10 +1212,29 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
824
1212
  /** @param {any} args - arguments. @returns {Promise<{text: string}>} the read. */
825
1213
  execute: async (args) => withRead(async (page) => {
826
1214
  const options = { mode: String(args?.mode ?? 'text'), maxChars: Number(args?.max_chars ?? 4600) }
827
- 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)
828
1220
  const note = isCheckPage(read) ? `\n${CHECK_NOTE}` : ''
829
- if (args?.changed_only === true && refs.last !== undefined) return diffRead(refs.last, read, options) + note
830
- 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)
831
1238
  return renderPage(read, options) + note
832
1239
  }),
833
1240
  },
@@ -843,6 +1250,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
843
1250
  y: { type: 'integer', description: 'Viewport y, when clicking a point.' },
844
1251
  double: { type: 'boolean', description: 'Double-click.' },
845
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.' },
846
1254
  read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
847
1255
  },
848
1256
  additionalProperties: false,
@@ -857,7 +1265,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
857
1265
  let followed = ''
858
1266
  if (args?.ref !== undefined) {
859
1267
  const spot = await findAgain(page, Number(args.ref))
860
- if (spot.gone === true) return REF_GONE
1268
+ if (spot.gone === true) return spot.why ?? REF_GONE
861
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`
862
1270
  point = { x: Number(spot.x), y: Number(spot.y) }
863
1271
  } else if (String(args?.text ?? '') !== '') {
@@ -872,6 +1280,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
872
1280
  if (args?.ref !== undefined && button === 'left') {
873
1281
  const gate = await clearCover(page, Number(args.ref))
874
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
+ }
875
1288
  if (gate.cleared && cover !== '') {
876
1289
  const again = await locate(page, Number(args.ref))
877
1290
  if (again.gone !== true) point = { x: Number(again.x), y: Number(again.y) }
@@ -879,6 +1292,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
879
1292
  }
880
1293
  await page.mouse('mouseMoved', point)
881
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))
882
1297
  await page.mouse('mouseReleased', { ...point, clickCount: args?.double === true ? 2 : 1, button })
883
1298
  await settle(page)
884
1299
  const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 3000 }), `click ${label}`)
@@ -893,6 +1308,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
893
1308
  properties: {
894
1309
  text: { type: 'string', description: 'Text to type.' },
895
1310
  ref: { type: 'integer', description: 'Control to click before typing.' },
1311
+ field: { type: 'string', description: 'The field is called this on screen, when you have no number for it.' },
896
1312
  submit: { type: 'boolean', description: 'Press Enter after typing.' },
897
1313
  clear: { type: 'boolean', description: 'Select all and delete before typing.' },
898
1314
  read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
@@ -907,11 +1323,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
907
1323
  execute: async (args) => withPage(async (page) => {
908
1324
  let cover = ''
909
1325
  let followed = ''
1326
+ if (args?.field !== undefined && args?.ref === undefined) {
1327
+ const target = await fieldByName(page, String(args.field))
1328
+ if (target === null) return `${String(args.field)}: ${REF_GONE}`
1329
+ cover = target.lead
1330
+ await writeAt(page, { x: target.x, y: target.y }, String(args?.text ?? ''), { clear: args?.clear, submit: args?.submit === true })
1331
+ await settle(page)
1332
+ const called = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
1333
+ return called + cover + ` (typed into "${target.name.slice(0, 50)}")` // ui-copy-ignore
1334
+ }
910
1335
  if (args?.ref !== undefined) {
911
1336
  const gate = await clearCover(page, Number(args.ref))
912
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
+ }
913
1341
  const spot = await findAgain(page, Number(args.ref))
914
- if (spot.gone === true) return REF_GONE + cover
1342
+ if (spot.gone === true) return spot.why ?? REF_GONE + cover
915
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`
916
1344
  const point = { x: Number(spot.x), y: Number(spot.y) }
917
1345
  await page.mouse('mouseMoved', point)
@@ -920,21 +1348,131 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
920
1348
  await wait(80)
921
1349
  }
922
1350
  if (args?.clear === true) await clearFocus(page)
923
- await page.insertText(String(args?.text ?? ''))
1351
+ await page.humanType(String(args?.text ?? ''))
924
1352
  if (args?.submit === true) await pressKey(page, 'Enter')
925
1353
  await settle(page)
926
1354
  const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
927
1355
  return said + cover + (followed === '' ? '' : ` (${followed})`) // ui-copy-ignore
928
1356
  }),
929
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
+ },
930
1467
  {
931
1468
  name: 'sidecar_key', // ui-copy-ignore
932
- 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.',
933
1470
  parameters: {
934
1471
  type: 'object', // ui-copy-ignore
935
1472
  properties: {
936
- 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.' },
937
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.' },
938
1476
  read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
939
1477
  },
940
1478
  required: ['key'], // ui-copy-ignore
@@ -948,8 +1486,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
948
1486
  const name = String(args?.key ?? 'Enter')
949
1487
  const held = String(args?.modifiers ?? '')
950
1488
  const mods = (held.includes('Alt') ? 1 : 0) + (held.includes('Ctrl') ? 2 : 0) + (held.includes('Meta') ? 4 : 0) + (held.includes('Shift') ? 8 : 0)
951
- await pressKey(page, name, mods)
952
- 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)
953
1496
  return acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), `key ${name}`)
954
1497
  }),
955
1498
  },
@@ -995,7 +1538,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
995
1538
  /** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
996
1539
  execute: async (args) => withPage(async (page) => {
997
1540
  const spot = await findAgain(page, Number(args?.ref))
998
- if (spot.gone === true) return REF_GONE
1541
+ if (spot.gone === true) return spot.why ?? REF_GONE
999
1542
  const cursor = await page.hover({ x: Number(spot.x), y: Number(spot.y) })
1000
1543
  await wait(400)
1001
1544
  const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2500 }), `hover ${String(args?.ref)} · ${cursor}`)
@@ -1079,9 +1622,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1079
1622
  if (steps.length === 0) return 'Give at least one step.'
1080
1623
  const report = []
1081
1624
  for (const [index, step] of steps.entries()) {
1082
- const said = await runStep(page, step ?? {}, refs?.refs)
1083
- report.push(`${index + 1}. ${said}`)
1084
- 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
+ }
1085
1633
  /* A step can navigate straight onto Chrome's error page — a mistyped
1086
1634
  address, or a site that does not answer. The rest of the batch
1087
1635
  would then type into "This site can't be reached", so it stops
@@ -1094,13 +1642,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1094
1642
  const mode = String(args?.read ?? 'text')
1095
1643
  if (mode === 'none') return report.join('\n')
1096
1644
  const options = { mode: mode === 'changed' ? 'text' : mode, maxChars: Number(args?.max_chars ?? 3000) }
1097
- const read = await remember(page, refs, options)
1098
- if (mode === 'changed' && refs.last !== undefined) {
1099
- const diff = diffRead(refs.last, read, options)
1100
- 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
1101
1650
  return `${report.join('\n')}\n${diff}`
1102
1651
  }
1103
- refs.last = read
1652
+ state.last = read
1104
1653
  return `${report.join('\n')}\n\n${renderPage(read, options)}`
1105
1654
  }),
1106
1655
  },
@@ -1163,9 +1712,10 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1163
1712
  type: 'object', // ui-copy-ignore
1164
1713
  properties: {
1165
1714
  ref: { type: 'integer', description: 'Number of the select control from sidecar_view.' },
1715
+ field: { type: 'string', description: 'The dropdown is called this on screen, when you have no number for it.' },
1166
1716
  value: { type: 'string', description: 'Option label or value to choose.' },
1167
1717
  },
1168
- required: ['ref', 'value'], // ui-copy-ignore
1718
+ required: ['value'], // ui-copy-ignore
1169
1719
  additionalProperties: false,
1170
1720
  },
1171
1721
  output: TEXT_OUTPUT, // ui-copy-ignore
@@ -1173,8 +1723,17 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1173
1723
  isConcurrencySafe: () => false,
1174
1724
  /** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
1175
1725
  execute: async (args) => withPage(async (page) => {
1726
+ if (args?.ref === undefined) {
1727
+ if (args?.field === undefined) return 'name the dropdown: pass its number from sidecar_view, or the words on screen that name it.' // ui-copy-ignore
1728
+ const chosen = await evaluate(page, selectSpot(String(args.field), String(args?.value ?? '')))
1729
+ const said = typeof chosen === 'string' ? chosen : chosen?.error === undefined ? '' : String(chosen.error)
1730
+ if (said === '' || said.startsWith('no dropdown')) return said === '' ? 'the page did not answer; read it and try again.' : said // ui-copy-ignore
1731
+ await settle(page, 3000)
1732
+ if (args?.read === true) return `${said}\n${await look(page, { mode: 'text', maxChars: 2000 })}`
1733
+ return `${said} · ${await brief(page)}`
1734
+ }
1176
1735
  const spot = await findAgain(page, Number(args?.ref))
1177
- if (spot.gone === true) return REF_GONE
1736
+ if (spot.gone === true) return spot.why ?? REF_GONE
1178
1737
  const wanted = JSON.stringify(String(args?.value ?? ''))
1179
1738
  const route = await contextFor(page, Number(args?.ref))
1180
1739
  const local = route?.local ?? Number(args?.ref)
@@ -1221,7 +1780,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1221
1780
  if (route?.contextId !== undefined) uploadParams.contextId = route.contextId
1222
1781
  const handle = await page.cdp.send('Runtime.evaluate', uploadParams, route?.sessionId)
1223
1782
  const objectId = handle.result?.objectId
1224
- if (objectId === undefined) return REF_GONE
1783
+ if (objectId === undefined) return spot.why ?? REF_GONE
1225
1784
  const node = await page.cdp.send('DOM.describeNode', { objectId }, route?.sessionId)
1226
1785
  const backendNodeId = node?.node?.backendNodeId
1227
1786
  if (backendNodeId === undefined) return 'That control cannot take a file.'
@@ -1232,7 +1791,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1232
1791
  },
1233
1792
  {
1234
1793
  name: 'sidecar_shot', // ui-copy-ignore
1235
- 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.',
1236
1795
  parameters: {
1237
1796
  type: 'object', // ui-copy-ignore
1238
1797
  properties: {
@@ -1243,25 +1802,109 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1243
1802
  },
1244
1803
  additionalProperties: false,
1245
1804
  },
1246
- output: TEXT_OUTPUT, // ui-copy-ignore
1805
+ output: SHOT_OUTPUT, // ui-copy-ignore
1247
1806
  timeoutMs: 40_000,
1248
1807
  isConcurrencySafe: () => false,
1249
- /** @param {any} args - arguments. @returns {Promise<{text: string}>} the saved path. */
1250
- 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) => {
1251
1820
  const scale = Math.max(1, Math.min(3, Number(args?.scale ?? 1)))
1252
1821
  let clip = undefined
1253
1822
  if (args?.ref !== undefined) {
1254
1823
  const spot = await findAgain(page, Number(args.ref))
1255
- if (spot.gone === true) return REF_GONE
1256
- clip = { x: Math.max(0, Number(spot.x) - 20), y: Math.max(0, Number(spot.y) - 20), width: 220, height: 140 }
1824
+ if (spot.gone === true) return spot.why ?? REF_GONE
1825
+ /* The picture is of the control, not of a guess around its middle:
1826
+ a canvas can be most of the page, a checkbox a few pixels. */
1827
+ const box = await evaluate(page, bounds(Number(args.ref)))
1828
+ const rect = typeof box === 'string' ? JSON.parse(box) : box
1829
+ if (rect?.gone === true) return spot.why ?? REF_GONE
1830
+ const pad = 8
1831
+ clip = {
1832
+ x: Math.max(0, Math.round(Number(rect.x) - pad)),
1833
+ y: Math.max(0, Math.round(Number(rect.y) - pad)),
1834
+ width: Math.max(8, Math.round(Number(rect.width) + pad * 2)),
1835
+ height: Math.max(8, Math.round(Number(rect.height) + pad * 2)),
1836
+ }
1257
1837
  }
1258
1838
  const data = await page.shot({ full: args?.full === true, rect: clip, scale })
1259
- const dir = join(homedir(), SHOT_DIR)
1260
- await mkdir(dir, { recursive: true })
1261
- const name = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-${Date.now()}`
1262
- const file = join(dir, name.endsWith('.png') ? name : `${name}.png`)
1263
- await writeFile(file, Buffer.from(data, 'base64'))
1839
+ const file = await saveArtifact('shots', data, { name: args?.name, prefix: 'shot' })
1840
+ taken = { data, file }
1264
1841
  return file
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
+ },
1856
+ },
1857
+ {
1858
+ name: 'sidecar_grab', // ui-copy-ignore
1859
+ description: 'Save the picture on the page as a file: an image, a chart, a drawing surface. Picks the largest one, or the one inside a given control.',
1860
+ parameters: {
1861
+ type: 'object', // ui-copy-ignore
1862
+ properties: {
1863
+ ref: { type: 'integer', description: 'Only the picture at or inside this control.' },
1864
+ name: { type: 'string', description: 'File name without extension.' },
1865
+ count: { type: 'integer', description: 'How many, largest first. Default 1, at most 4.' },
1866
+ },
1867
+ additionalProperties: false,
1868
+ },
1869
+ output: TEXT_OUTPUT, // ui-copy-ignore
1870
+ timeoutMs: 40_000,
1871
+ isConcurrencySafe: () => false,
1872
+ /** @param {any} args - arguments. @returns {Promise<string>} the saved paths, one per line. */
1873
+ execute: async (args) => withRead(async (page) => {
1874
+ const said = await evaluate(page, pictures(args?.ref, args?.count))
1875
+ let found
1876
+ try {
1877
+ found = JSON.parse(typeof said === 'string' ? said : JSON.stringify(said))
1878
+ } catch {
1879
+ found = { pictures: [] }
1880
+ }
1881
+ const rows = []
1882
+ const base = String(args?.name ?? '').replace(/[^\w.-]/g, '')
1883
+ const list = Array.isArray(found?.pictures) ? found.pictures : []
1884
+ for (let index = 0; index < list.length; index += 1) {
1885
+ const item = list[index]
1886
+ const body = String(item?.data ?? '')
1887
+ const mark = body.indexOf(',')
1888
+ if (mark < 0) continue
1889
+ const type = String(item?.type ?? 'image/png')
1890
+ const ext = type.includes('jpeg') ? 'jpg' : type.includes('webp') ? 'webp' : type.includes('svg') ? 'svg' : 'png'
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
+ })
1896
+ const size = `${Math.round(Number(item.width) || 0)}x${Math.round(Number(item.height) || 0)}`
1897
+ /* Say what the picture looks like in words: a model that cannot open
1898
+ a file still needs to know a chart came back blank. */
1899
+ const flat = Number(item.flat) || 0
1900
+ const colors = Number(item.colors) || 0
1901
+ const look = flat >= 0.9 ? 'mostly one colour' : colors <= 3 ? 'a few colours' : 'a picture with detail'
1902
+ rows.push(`${file} ${size} ${look}`)
1903
+ }
1904
+ if (rows.length === 0) {
1905
+ return list.length === 0 ? 'There is no picture on the page.' : 'That picture could not be saved.'
1906
+ }
1907
+ return rows.join('\n')
1265
1908
  }),
1266
1909
  },
1267
1910
  {
@@ -1275,13 +1918,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1275
1918
  execute: async () => {
1276
1919
  const state = await sidecar.state()
1277
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)
1278
1924
  const rows = [
1279
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
1280
1931
  state.headless === false ? 'showing a window' : 'no window',
1281
1932
  `profile ${String(state.profile)}`,
1282
1933
  `url ${String(state.url)}`,
1283
1934
  `title ${String(state.title)}`,
1284
1935
  `viewers ${String(state.viewers)}`,
1936
+ `listening chats ${seats}`, // ui-copy-ignore
1937
+ `mirror owner ${owner === '' ? 'none' : owner}`, // ui-copy-ignore
1285
1938
  owner === '' ? 'no owner' : `owner ${owner}`,
1286
1939
  `refs followed ${String(follows.count)}`,
1287
1940
  holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
@@ -1343,7 +1996,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1343
1996
  let start = { x: Number(args?.x ?? NaN), y: Number(args?.y ?? NaN) }
1344
1997
  if (args?.from !== undefined) {
1345
1998
  const spot = await findAgain(page, Number(args.from))
1346
- if (spot.gone === true) return REF_GONE
1999
+ if (spot.gone === true) return spot.why ?? REF_GONE
1347
2000
  start = { x: Number(spot.x), y: Number(spot.y) }
1348
2001
  }
1349
2002
  const end = { x: Number(args?.to_x ?? NaN), y: Number(args?.to_y ?? NaN) }
@@ -1388,11 +2041,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1388
2041
  if (checkRoute?.contextId !== undefined) checkParams.contextId = checkRoute.contextId
1389
2042
  const probe = await page.cdp.send('Runtime.evaluate', checkParams, checkRoute?.sessionId)
1390
2043
  const now = probe.result?.value
1391
- if (now === null || now === undefined) return REF_GONE
2044
+ if (now === null || now === undefined) return spot.why ?? REF_GONE
1392
2045
  const want = args?.checked === undefined ? !Boolean(now.checked) : args.checked === true
1393
2046
  if (Boolean(now.checked) !== want) {
1394
2047
  const spot = await findAgain(page, Number(args.ref))
1395
- if (spot.gone === true) return REF_GONE
2048
+ if (spot.gone === true) return spot.why ?? REF_GONE
1396
2049
  await page.mouse('mouseMoved', { x: Number(spot.x), y: Number(spot.y) })
1397
2050
  await page.mouse('mousePressed', { x: Number(spot.x), y: Number(spot.y) })
1398
2051
  await page.mouse('mouseReleased', { x: Number(spot.x), y: Number(spot.y) })
@@ -1614,18 +2267,18 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1614
2267
  execute: async (args) => withRawPage(async (page) => {
1615
2268
  const action = String(args?.action ?? 'list')
1616
2269
  if (action === 'list') {
1617
- const rows = await sidecar.tabs()
2270
+ const rows = await sidecar.tabs(currentSession())
1618
2271
  if (rows.length === 0) return 'no tabs'
1619
2272
  return rows.map((row, i) => `${i}${row.current ? ' *' : ''} ${row.title || row.url} · ${row.url}`).join('\n')
1620
2273
  }
1621
2274
  const index = Number(args?.index ?? 0)
1622
2275
  if (action === 'switch') {
1623
- const next = await sidecar.useTab(index)
2276
+ const next = await sidecar.useTab(index, currentSession())
1624
2277
  return `tab ${index} · ${String(next?.url ?? '')}`
1625
2278
  }
1626
2279
  if (action === 'close') {
1627
- await sidecar.closeTab(index)
1628
- 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 ?? '')}`
1629
2282
  }
1630
2283
  throw new Error(`sidecar: unknown action ${action}`)
1631
2284
  }),
@@ -1641,11 +2294,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1641
2294
  /** @returns {Promise<{text: string}>} where the PDF is. */
1642
2295
  execute: async () => withRawPage(async (page) => {
1643
2296
  const data = await page.pdf()
1644
- const dir = join(homedir(), SHOT_DIR)
1645
- await mkdir(dir, { recursive: true })
1646
- const file = join(dir, `sidecar-${Date.now()}.pdf`)
1647
- await writeFile(file, Buffer.from(data, 'base64'))
1648
- return file
2297
+ return saveArtifact('pdf', data, { ext: '.pdf', prefix: 'page' })
1649
2298
  }),
1650
2299
  },
1651
2300
  ]