handhold-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +427 -0
  2. package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
  3. package/dist/fonts/Phosphor.ttf +0 -0
  4. package/dist/fonts/Phosphor.woff +0 -0
  5. package/dist/fonts/Phosphor.woff2 +0 -0
  6. package/dist/handhold.min.js +1 -0
  7. package/package.json +114 -0
  8. package/src/HandHold.js +1599 -0
  9. package/src/agent/VENDOR.md +203 -0
  10. package/src/agent/agent/AgentLoop.js +366 -0
  11. package/src/agent/agent/prompts.js +163 -0
  12. package/src/agent/agent/tools.js +148 -0
  13. package/src/agent/controller.js +235 -0
  14. package/src/agent/dom/actions.js +456 -0
  15. package/src/agent/dom/domTree.js +1761 -0
  16. package/src/agent/dom/redact.js +98 -0
  17. package/src/agent/dom/serializer.js +332 -0
  18. package/src/agent/dom/utils.js +99 -0
  19. package/src/agent/index.js +169 -0
  20. package/src/agent/llm/connector.js +143 -0
  21. package/src/agent/package.json +3 -0
  22. package/src/agent/policy/PolicyGuard.js +172 -0
  23. package/src/agent/site/manifest.js +145 -0
  24. package/src/agent/ui/ChatWidget.js +219 -0
  25. package/src/agent-mode/AgentMode.js +429 -0
  26. package/src/api/APIClient.js +258 -0
  27. package/src/core/Config.js +207 -0
  28. package/src/core/UiState.js +83 -0
  29. package/src/core/defaults.js +20 -0
  30. package/src/events.js +59 -0
  31. package/src/index.js +77 -0
  32. package/src/intent/ActionVerbMap.js +324 -0
  33. package/src/intent/IntentExtractor.js +317 -0
  34. package/src/observers/ElementScanner.js +284 -0
  35. package/src/observers/NavigationObserver.js +182 -0
  36. package/src/observers/PageObserver.js +293 -0
  37. package/src/session/SessionManager.js +402 -0
  38. package/src/session/SessionStorage.js +148 -0
  39. package/src/ui/HelpWidget.js +1189 -0
  40. package/src/ui/UICoach.js +1725 -0
  41. package/src/ui/components/Button.css +137 -0
  42. package/src/ui/components/Button.js +102 -0
  43. package/src/ui/widget.css +599 -0
  44. package/src/utils/Logger.js +132 -0
  45. package/src/utils/MarkdownRenderer.js +39 -0
  46. package/src/utils/domUtils.js +350 -0
  47. package/src/utils/eventBus.js +102 -0
  48. package/src/utils/index.js +8 -0
  49. package/src/utils/stringUtils.js +202 -0
  50. package/types/index.d.ts +538 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * connector.js — BYOK LLM client with two drivers:
3
+ *
4
+ * 1. 'openai-compatible' — one driver covers:
5
+ * • OpenAI baseURL: https://api.openai.com/v1 (apiKey required)
6
+ * • OpenRouter baseURL: https://openrouter.ai/api/v1 (apiKey required)
7
+ * • Ollama (local) baseURL: http://localhost:11434/v1 (no key)
8
+ * • LM Studio (local) baseURL: http://localhost:1234/v1 (no key)
9
+ * • vLLM (local/LAN) baseURL: http://<host>:8000/v1 (key optional)
10
+ * 2. 'anthropic' — Claude via api.anthropic.com/v1/messages.
11
+ *
12
+ * Security properties:
13
+ * - Keys live only in the object you construct; nothing here persists or
14
+ * forwards them anywhere except the endpoint you configured.
15
+ * - Plain-http endpoints are REJECTED unless the host is localhost/127.x/
16
+ * ::1/private-LAN (10.x, 172.16-31.x, 192.168.x). Sending page content or
17
+ * an API key over plaintext to a public host is a config mistake this
18
+ * module refuses to make.
19
+ * - No streaming, no retries-with-backdoor, no telemetry. One POST per call.
20
+ */
21
+
22
+ const PRIVATE_HOST_RE = /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/i
23
+
24
+ function assertSafeBaseURL(baseURL) {
25
+ let u
26
+ try {
27
+ u = new URL(baseURL)
28
+ } catch {
29
+ throw new Error(`connector: invalid baseURL "${baseURL}"`)
30
+ }
31
+ if (u.protocol === 'https:') return
32
+ if (u.protocol === 'http:' && PRIVATE_HOST_RE.test(u.hostname)) return
33
+ throw new Error(
34
+ `connector: refusing plain-http baseURL "${baseURL}" for a non-local host. ` +
35
+ 'Use https, or a localhost/private-LAN address for locally hosted models.'
36
+ )
37
+ }
38
+
39
+ /**
40
+ * @param {Object} config
41
+ * @param {'openai-compatible'|'anthropic'} config.provider
42
+ * @param {string} [config.baseURL] required for openai-compatible
43
+ * @param {string} [config.apiKey] optional for local endpoints
44
+ * @param {string} config.model
45
+ * @param {number} [config.temperature=0]
46
+ * @param {number} [config.maxTokens=2000]
47
+ * @param {typeof fetch} [config.fetchImpl] injectable for tests
48
+ * @returns {{ complete: (args: {system: string, messages: {role:string,content:string}[], signal?: AbortSignal}) => Promise<{text: string, raw: any}> }}
49
+ */
50
+ /**
51
+ * Named presets that normalize to the openai-compatible driver.
52
+ * keyRequired presets throw early rather than failing with a confusing 401.
53
+ */
54
+ const PRESETS = {
55
+ deepseek: { baseURL: 'https://api.deepseek.com/v1', keyRequired: true },
56
+ }
57
+
58
+ export function createLLM(config) {
59
+ // Resolve presets (e.g. provider:'deepseek') to the generic driver.
60
+ if (config.provider in PRESETS) {
61
+ const preset = PRESETS[config.provider]
62
+ if (preset.keyRequired && !config.apiKey) {
63
+ throw new Error(`connector: apiKey is required for the ${config.provider} provider`)
64
+ }
65
+ config = { ...config, provider: 'openai-compatible', baseURL: config.baseURL ?? preset.baseURL }
66
+ }
67
+
68
+ const {
69
+ provider, apiKey, model,
70
+ temperature = 0, maxTokens = 2000,
71
+ fetchImpl = globalThis.fetch,
72
+ } = config
73
+
74
+ if (!model) throw new Error('connector: model is required')
75
+
76
+ if (provider === 'openai-compatible') {
77
+ if (!config.baseURL) throw new Error('connector: baseURL is required for openai-compatible provider')
78
+ assertSafeBaseURL(config.baseURL)
79
+ const baseURL = config.baseURL.replace(/\/+$/, '')
80
+
81
+ return {
82
+ async complete({ system, messages, signal }) {
83
+ const headers = { 'Content-Type': 'application/json' }
84
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
85
+
86
+ const res = await fetchImpl(`${baseURL}/chat/completions`, {
87
+ method: 'POST',
88
+ headers,
89
+ signal,
90
+ body: JSON.stringify({
91
+ model,
92
+ temperature,
93
+ max_tokens: maxTokens,
94
+ messages: [{ role: 'system', content: system }, ...messages],
95
+ }),
96
+ })
97
+ if (!res.ok) {
98
+ const body = await res.text().catch(() => '')
99
+ throw new Error(`LLM request failed: HTTP ${res.status} ${body.slice(0, 300)}`)
100
+ }
101
+ const data = await res.json()
102
+ const text = data?.choices?.[0]?.message?.content ?? ''
103
+ return { text, raw: data }
104
+ },
105
+ }
106
+ }
107
+
108
+ if (provider === 'anthropic') {
109
+ if (!apiKey) throw new Error('connector: apiKey is required for the anthropic provider')
110
+
111
+ return {
112
+ async complete({ system, messages, signal }) {
113
+ const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
114
+ method: 'POST',
115
+ signal,
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ 'x-api-key': apiKey,
119
+ 'anthropic-version': '2023-06-01',
120
+ // allows calling from a browser page (official CORS opt-in header)
121
+ 'anthropic-dangerous-direct-browser-access': 'true',
122
+ },
123
+ body: JSON.stringify({
124
+ model,
125
+ max_tokens: maxTokens,
126
+ temperature,
127
+ system,
128
+ messages,
129
+ }),
130
+ })
131
+ if (!res.ok) {
132
+ const body = await res.text().catch(() => '')
133
+ throw new Error(`LLM request failed: HTTP ${res.status} ${body.slice(0, 300)}`)
134
+ }
135
+ const data = await res.json()
136
+ const text = (data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('\n')
137
+ return { text, raw: data }
138
+ },
139
+ }
140
+ }
141
+
142
+ throw new Error(`connector: unknown provider "${provider}"`)
143
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * PolicyGuard.js — the enforcement layer upstream doesn't have.
3
+ *
4
+ * Every action the LLM proposes goes through guard.check() BEFORE it touches
5
+ * the DOM. The guard is deliberately independent of the agent loop so a bug
6
+ * (or a prompt-injected detour) in the loop cannot bypass it.
7
+ *
8
+ * Guarantees:
9
+ * 1. Origin pinning — actions run only if the current window origin is on
10
+ * an explicit allowlist. Constructor THROWS if the list is missing/empty:
11
+ * there is no "allow everywhere" default to forget about.
12
+ * 2. Fails closed — sensitive actions (payments, deletion, submission,
13
+ * credentials...) are BLOCKED unless a human onConfirm() callback
14
+ * explicitly returns true. No handler => no sensitive actions, period.
15
+ * 3. Rate limiting — hard ceiling on actions/minute, independent of the
16
+ * LLM loop's own maxSteps.
17
+ * 4. Audit log — timestamped record of every decision (allowed,
18
+ * blocked, sensitive, reason) for post-hoc review.
19
+ */
20
+
21
+ const DEFAULT_SENSITIVE_KEYWORDS = [
22
+ // destructive
23
+ 'delete', 'remove', 'destroy', 'terminate', 'deactivate', 'revoke', 'cancel',
24
+ // financial
25
+ 'pay', 'payment', 'purchase', 'buy', 'checkout', 'transfer', 'withdraw',
26
+ 'send money', 'disbursement', 'payout', 'refund',
27
+ // commitment
28
+ 'submit', 'confirm', 'approve', 'sign', 'agree', 'accept terms', 'place order',
29
+ // account/session
30
+ 'logout', 'log out', 'sign out', 'unsubscribe', 'close account',
31
+ // comms
32
+ 'send', 'publish', 'post', 'share',
33
+ ]
34
+
35
+ const SENSITIVE_INPUT_TYPES = new Set(['password'])
36
+ const SENSITIVE_INPUT_AUTOCOMPLETE = ['cc-number', 'cc-csc', 'cc-exp', 'current-password', 'new-password', 'one-time-code']
37
+
38
+ export class PolicyGuard {
39
+ #allowedOrigins
40
+ #getOrigin
41
+ #onConfirm
42
+ #keywords
43
+ #maxPerMinute
44
+ #actionTimestamps = []
45
+ #auditLog = []
46
+
47
+ /**
48
+ * @param {Object} config
49
+ * @param {string[]} config.allowedOrigins REQUIRED, non-empty. e.g. ['https://app.example.com']
50
+ * @param {() => string} [config.getOrigin] defaults to window.location.origin
51
+ * @param {(pending: {name:string,input:Object,reason:string}) => Promise<boolean>} [config.onConfirm]
52
+ * @param {string[]} [config.extraSensitiveKeywords]
53
+ * @param {number} [config.maxActionsPerMinute=30]
54
+ */
55
+ constructor(config = {}) {
56
+ if (!Array.isArray(config.allowedOrigins) || config.allowedOrigins.length === 0) {
57
+ throw new Error(
58
+ 'PolicyGuard: allowedOrigins is required and must be a non-empty array. ' +
59
+ 'There is intentionally no default — decide where this agent may act.'
60
+ )
61
+ }
62
+ this.#allowedOrigins = config.allowedOrigins.map((o) => o.replace(/\/+$/, ''))
63
+ this.#getOrigin = config.getOrigin ?? (() => globalThis.window?.location?.origin ?? 'unknown://')
64
+ this.#onConfirm = config.onConfirm ?? null
65
+ this.#keywords = [
66
+ ...DEFAULT_SENSITIVE_KEYWORDS,
67
+ ...(config.extraSensitiveKeywords ?? []).map((k) => k.toLowerCase()),
68
+ ]
69
+ this.#maxPerMinute = config.maxActionsPerMinute ?? 30
70
+ }
71
+
72
+ /**
73
+ * @param {Object} action
74
+ * @param {string} action.name e.g. 'click', 'input_text'
75
+ * @param {Object} action.input tool input from the LLM
76
+ * @param {string} [action.elementText] visible text of the target element
77
+ * @param {Record<string,string>} [action.elementAttributes]
78
+ * @returns {Promise<{allowed: boolean, sensitive: boolean, reason: string}>}
79
+ */
80
+ async check(action) {
81
+ const verdict = await this.#evaluate(action)
82
+ this.#auditLog.push({
83
+ ts: Date.now(),
84
+ name: action.name,
85
+ input: action.input,
86
+ elementText: action.elementText ?? '',
87
+ allowed: verdict.allowed,
88
+ sensitive: verdict.sensitive,
89
+ reason: verdict.reason,
90
+ })
91
+ return verdict
92
+ }
93
+
94
+ async #evaluate(action) {
95
+ // 1. origin pin
96
+ const origin = this.#getOrigin().replace(/\/+$/, '')
97
+ if (!this.#allowedOrigins.includes(origin)) {
98
+ return { allowed: false, sensitive: false, reason: `Origin ${origin} is not on the allowlist.` }
99
+ }
100
+
101
+ // 2. rate limit
102
+ const now = Date.now()
103
+ this.#actionTimestamps = this.#actionTimestamps.filter((t) => now - t < 60_000)
104
+ if (this.#actionTimestamps.length >= this.#maxPerMinute) {
105
+ return { allowed: false, sensitive: false, reason: `Rate limit: more than ${this.#maxPerMinute} actions/minute.` }
106
+ }
107
+ this.#actionTimestamps.push(now)
108
+
109
+ // 3. sensitivity gate (fails closed)
110
+ const sensitiveReason = this.#sensitiveReason(action)
111
+ if (sensitiveReason) {
112
+ if (!this.#onConfirm) {
113
+ return {
114
+ allowed: false, sensitive: true,
115
+ reason: `${sensitiveReason} — blocked: no onConfirm handler configured (fails closed).`,
116
+ }
117
+ }
118
+ let ok = false
119
+ try {
120
+ ok = (await this.#onConfirm({ name: action.name, input: action.input, reason: sensitiveReason })) === true
121
+ } catch {
122
+ ok = false
123
+ }
124
+ return ok
125
+ ? { allowed: true, sensitive: true, reason: `${sensitiveReason} — approved by human.` }
126
+ : { allowed: false, sensitive: true, reason: `${sensitiveReason} — denied by human.` }
127
+ }
128
+
129
+ return { allowed: true, sensitive: false, reason: 'ok' }
130
+ }
131
+
132
+ #sensitiveReason(action) {
133
+ /**
134
+ * @handhold-patch H6 (see sdk/src/agent/VENDOR.md) — forced sensitivity.
135
+ * Callers that KNOW an action is sensitive (e.g. navigation to a page
136
+ * the human-reviewed manifest marks sensitive:true) pass forceSensitive
137
+ * with the reason. This can only ADD the confirm gate, never remove it:
138
+ * keyword/attribute detection below still runs for everything else.
139
+ */
140
+ if (typeof action.forceSensitive === 'string' && action.forceSensitive) {
141
+ return action.forceSensitive
142
+ }
143
+
144
+ const attrs = action.elementAttributes ?? {}
145
+ const type = (attrs.type ?? '').toLowerCase()
146
+ const autocomplete = (attrs.autocomplete ?? '').toLowerCase()
147
+
148
+ if (SENSITIVE_INPUT_TYPES.has(type)) return `Target is a ${type} field`
149
+ if (SENSITIVE_INPUT_AUTOCOMPLETE.some((v) => autocomplete.includes(v))) {
150
+ return `Target autocomplete="${autocomplete}" indicates credentials/payment data`
151
+ }
152
+
153
+ const haystack = [
154
+ action.elementText ?? '',
155
+ attrs['aria-label'] ?? '',
156
+ attrs.name ?? '',
157
+ attrs.value ?? '',
158
+ attrs.title ?? '',
159
+ ].join(' ').toLowerCase()
160
+
161
+ for (const kw of this.#keywords) {
162
+ // word-boundary match so 'send' doesn't fire on 'ascending'
163
+ const re = new RegExp(`(^|[^a-z])${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z]|$)`)
164
+ if (re.test(haystack)) return `Action targets "${(action.elementText ?? '').slice(0, 60)}" (matched sensitive keyword "${kw}")`
165
+ }
166
+ return null
167
+ }
168
+
169
+ getAuditLog() {
170
+ return this.#auditLog.slice()
171
+ }
172
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * site/manifest.js — Handhold whole-site addition (H6.1; VENDOR.md patch log).
3
+ *
4
+ * The site manifest is the HUMAN-REVIEWED map of pages the agent may know and
5
+ * navigate (specs/whole-site-control-design.md §5). This module is the
6
+ * client-side half of that contract:
7
+ *
8
+ * - `reviewed: true` is REQUIRED to load. The backend already refuses to
9
+ * serve unreviewed manifests (GET /api/v1/manifest -> 404); enforcing it
10
+ * here again means even a tampered/bypassed transport cannot hand the
11
+ * agent an unapproved map (defense in depth).
12
+ * - Page paths are same-origin absolute: leading '/', no '//', no
13
+ * scheme/host, no '..', no whitespace. `:param` segments are allowed
14
+ * (/employees/:id). A manifest with any bad path is refused whole, with
15
+ * every offending path in the error.
16
+ * - Pages/states not listed DO NOT EXIST for the agent: matchManifestPath
17
+ * returns null and the navigate tool refuses before the guard.
18
+ *
19
+ * Trust note: the manifest is trusted config (human-reviewed), unlike page
20
+ * content — it may appear in the system prompt outside untrusted tags.
21
+ */
22
+
23
+ function pathError(path) {
24
+ if (typeof path !== 'string' || path.length === 0) return 'path must be a non-empty string'
25
+ if (!path.startsWith('/')) return `path must start with '/' (got "${path}")`
26
+ if (path.startsWith('//')) return `path must not be protocol-relative (got "${path}")`
27
+ if (path.includes('://')) return `path must not contain a scheme or host (got "${path}")`
28
+ if (path.includes('..')) return `path must not contain '..' (got "${path}")`
29
+ if (/\s/.test(path)) return `path must not contain whitespace (got "${path}")`
30
+ return null
31
+ }
32
+
33
+ /**
34
+ * Validate and normalize a raw manifest JSON (as served by the Handhold
35
+ * backend). Throws on anything that must never reach the agent.
36
+ * @returns {{version:number, origin:string, pages:Array}} normalized manifest
37
+ */
38
+ export function loadSiteManifest(raw) {
39
+ if (!raw || typeof raw !== 'object') {
40
+ throw new Error('site manifest: expected an object')
41
+ }
42
+ if (raw.reviewed !== true) {
43
+ throw new Error(
44
+ 'site manifest: refused — "reviewed" must be exactly true. ' +
45
+ 'A human must review the site map before the agent may use it.'
46
+ )
47
+ }
48
+ const pages = Array.isArray(raw.pages) ? raw.pages : []
49
+ const errors = []
50
+ const normalized = pages.map((page, i) => {
51
+ const err = pathError(page?.path)
52
+ if (err) {
53
+ errors.push(`pages[${i}]: ${err}`)
54
+ return null
55
+ }
56
+ return {
57
+ path: page.path,
58
+ name: String(page.name ?? page.path),
59
+ purpose: page.purpose != null ? String(page.purpose) : '',
60
+ sensitive: page.sensitive === true,
61
+ states: Array.isArray(page.states)
62
+ ? page.states.map((s) => ({
63
+ name: String(s?.name ?? ''),
64
+ steps: Array.isArray(s?.steps)
65
+ ? s.steps.map((st) => ({
66
+ type: String(st?.type ?? ''),
67
+ role: st?.role != null ? String(st.role) : '',
68
+ name: st?.name != null ? String(st.name) : '',
69
+ }))
70
+ : [],
71
+ }))
72
+ : [],
73
+ }
74
+ })
75
+ if (errors.length) {
76
+ throw new Error(`site manifest: refused — invalid page paths:\n${errors.join('\n')}`)
77
+ }
78
+ return {
79
+ version: Number(raw.version ?? 1),
80
+ origin: String(raw.origin ?? ''),
81
+ pages: normalized,
82
+ }
83
+ }
84
+
85
+ /** Strip query/hash and a trailing slash (except root) from a concrete path. */
86
+ function normalizeConcretePath(path) {
87
+ let p = String(path).split('?')[0].split('#')[0]
88
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
89
+ return p
90
+ }
91
+
92
+ /**
93
+ * Resolve a concrete path against the manifest. Supports `:param` pattern
94
+ * segments (each matches exactly one non-empty segment). Returns the page or
95
+ * null — null means the page DOES NOT EXIST for the agent.
96
+ */
97
+ export function matchManifestPath(manifest, path) {
98
+ if (!manifest || !Array.isArray(manifest.pages)) return null
99
+ const target = normalizeConcretePath(path)
100
+ if (pathError(target)) return null
101
+ const targetSegs = target.split('/').filter(Boolean)
102
+
103
+ for (const page of manifest.pages) {
104
+ const patternSegs = page.path.split('/').filter(Boolean)
105
+ if (patternSegs.length !== targetSegs.length) {
106
+ // exact-root special case: '/' matches '/'
107
+ if (page.path === '/' && target === '/') return page
108
+ continue
109
+ }
110
+ let ok = true
111
+ for (let i = 0; i < patternSegs.length; i++) {
112
+ const pat = patternSegs[i]
113
+ if (pat.startsWith(':')) {
114
+ if (!targetSegs[i]) { ok = false; break }
115
+ } else if (pat !== targetSegs[i]) {
116
+ ok = false
117
+ break
118
+ }
119
+ }
120
+ if (ok) return page
121
+ }
122
+ return null
123
+ }
124
+
125
+ /** Every page path — used in navigate-rejection errors for self-correction. */
126
+ export function listManifestPaths(manifest) {
127
+ if (!manifest || !Array.isArray(manifest.pages)) return []
128
+ return manifest.pages.map((p) => p.path)
129
+ }
130
+
131
+ /**
132
+ * Resolve a full URL to a human label "name (path)" for the state message's
133
+ * `Current page:` line. Null when the URL is not a manifest page (caller
134
+ * falls back to the raw URL).
135
+ */
136
+ export function describeCurrentPage(manifest, url) {
137
+ let pathname
138
+ try {
139
+ pathname = new URL(url).pathname
140
+ } catch {
141
+ return null
142
+ }
143
+ const page = matchManifestPath(manifest, pathname)
144
+ return page ? `${page.name} (${page.path})` : null
145
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * ChatWidget.js — the "chatbot bubble" UI: a floating button bottom-right
3
+ * that opens a chat panel. Zero dependencies, injects its own styles once.
4
+ *
5
+ * Security notes (this is UI, but it sits in the trust path):
6
+ * - All content is rendered with textContent, NEVER innerHTML — model output
7
+ * and page-derived strings cannot inject markup into the host page.
8
+ * - requestConfirm() renders the PolicyGuard reason with explicit
9
+ * Allow/Deny buttons, so the human gate is a real UI affordance instead
10
+ * of a window.confirm() that people click through.
11
+ *
12
+ * Wire-up (see TrustedPageAgent.attachWidget for the one-liner):
13
+ * const widget = new ChatWidget({ title: 'Assistant', onTask: run })
14
+ * widget.mount()
15
+ * agent config: onConfirm: (p) => widget.requestConfirm(p),
16
+ * onAskUser: (q) => widget.askUser(q),
17
+ * onActivity: (ev) => widget.showActivity(ev)
18
+ */
19
+
20
+ const STYLE = `
21
+ .tpa-bubble{position:fixed;right:20px;bottom:20px;width:56px;height:56px;border-radius:50%;
22
+ border:0;background:#1a73e8;color:#fff;font-size:24px;cursor:pointer;z-index:2147483000;
23
+ box-shadow:0 4px 14px rgba(0,0,0,.25);transition:transform .15s}
24
+ .tpa-bubble:hover{transform:scale(1.06)}
25
+ .tpa-panel{position:fixed;right:20px;bottom:88px;width:340px;max-width:calc(100vw - 40px);
26
+ height:460px;max-height:calc(100vh - 120px);display:none;flex-direction:column;
27
+ background:#fff;border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.3);z-index:2147483000;
28
+ font-family:system-ui,sans-serif;overflow:hidden}
29
+ .tpa-panel.tpa-open{display:flex}
30
+ .tpa-header{background:#1a73e8;color:#fff;padding:12px 14px;font-weight:600;display:flex;justify-content:space-between;align-items:center}
31
+ .tpa-close{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}
32
+ .tpa-messages{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px;background:#f5f7fa}
33
+ .tpa-msg{max-width:82%;padding:8px 12px;border-radius:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
34
+ .tpa-user{align-self:flex-end;background:#1a73e8;color:#fff;border-bottom-right-radius:4px}
35
+ .tpa-agent{align-self:flex-start;background:#fff;color:#222;border:1px solid #e3e6ea;border-bottom-left-radius:4px}
36
+ .tpa-status{align-self:flex-start;color:#777;font-size:11px;font-style:italic;padding:0 4px}
37
+ .tpa-confirm{align-self:flex-start;background:#fff8e6;border:1px solid #f0d489;border-radius:12px;padding:10px 12px;font-size:13px;max-width:88%}
38
+ .tpa-confirm-actions{margin-top:8px;display:flex;gap:8px}
39
+ .tpa-allow,.tpa-deny{border:0;border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer}
40
+ .tpa-allow{background:#1a8f3c;color:#fff}
41
+ .tpa-deny{background:#c0392b;color:#fff}
42
+ .tpa-inputrow{display:flex;gap:8px;padding:10px;border-top:1px solid #e3e6ea;background:#fff}
43
+ .tpa-input{flex:1;border:1px solid #cfd6dd;border-radius:10px;padding:8px 10px;font:inherit;font-size:13px}
44
+ .tpa-send{border:0;border-radius:10px;background:#1a73e8;color:#fff;padding:8px 14px;cursor:pointer;font-size:13px}
45
+ .tpa-send:disabled{background:#9db8dd;cursor:default}
46
+ `
47
+
48
+ export class ChatWidget {
49
+ #root = null
50
+ #messages = null
51
+ #input = null
52
+ #send = null
53
+ #panel = null
54
+ #onTask
55
+ #title
56
+ #pendingAsk = null // {resolve} — next send resolves an askUser instead of firing onTask
57
+
58
+ /**
59
+ * @param {Object} cfg
60
+ * @param {string} [cfg.title='Page Assistant']
61
+ * @param {(task: string) => void} [cfg.onTask] called when the user sends a message
62
+ */
63
+ constructor(cfg = {}) {
64
+ this.#title = cfg.title ?? 'Page Assistant'
65
+ this.#onTask = cfg.onTask ?? (() => {})
66
+ }
67
+
68
+ mount(parent = document.body) {
69
+ if (this.#root) return this
70
+
71
+ if (!document.getElementById('tpa-style')) {
72
+ const style = document.createElement('style')
73
+ style.id = 'tpa-style'
74
+ style.textContent = STYLE
75
+ document.head.appendChild(style)
76
+ }
77
+
78
+ this.#root = document.createElement('div')
79
+
80
+ const bubble = document.createElement('button')
81
+ bubble.className = 'tpa-bubble'
82
+ bubble.setAttribute('aria-label', 'Open assistant')
83
+ bubble.textContent = '💬'
84
+ bubble.addEventListener('click', () => this.toggle())
85
+
86
+ this.#panel = document.createElement('div')
87
+ this.#panel.className = 'tpa-panel'
88
+
89
+ const header = document.createElement('div')
90
+ header.className = 'tpa-header'
91
+ const titleEl = document.createElement('span')
92
+ titleEl.textContent = this.#title
93
+ const close = document.createElement('button')
94
+ close.className = 'tpa-close'
95
+ close.setAttribute('aria-label', 'Close')
96
+ close.textContent = '✕'
97
+ close.addEventListener('click', () => this.toggle(false))
98
+ header.append(titleEl, close)
99
+
100
+ this.#messages = document.createElement('div')
101
+ this.#messages.className = 'tpa-messages'
102
+
103
+ const inputRow = document.createElement('div')
104
+ inputRow.className = 'tpa-inputrow'
105
+ this.#input = document.createElement('input')
106
+ this.#input.className = 'tpa-input'
107
+ this.#input.placeholder = 'Describe a task…'
108
+ this.#input.addEventListener('keydown', (e) => {
109
+ if (e.key === 'Enter') this.#submit()
110
+ })
111
+ this.#send = document.createElement('button')
112
+ this.#send.className = 'tpa-send'
113
+ this.#send.textContent = 'Send'
114
+ this.#send.addEventListener('click', () => this.#submit())
115
+ inputRow.append(this.#input, this.#send)
116
+
117
+ this.#panel.append(header, this.#messages, inputRow)
118
+ this.#root.append(bubble, this.#panel)
119
+ parent.appendChild(this.#root)
120
+ return this
121
+ }
122
+
123
+ toggle(force) {
124
+ const open = force ?? !this.#panel.classList.contains('tpa-open')
125
+ this.#panel.classList.toggle('tpa-open', open)
126
+ }
127
+
128
+ #submit() {
129
+ const text = this.#input.value.trim()
130
+ if (!text) return
131
+ this.#input.value = ''
132
+ this.addMessage('user', text)
133
+ if (this.#pendingAsk) {
134
+ const { resolve } = this.#pendingAsk
135
+ this.#pendingAsk = null
136
+ this.#input.placeholder = 'Describe a task…'
137
+ resolve(text)
138
+ return
139
+ }
140
+ this.#onTask(text)
141
+ }
142
+
143
+ /** Append a chat bubble. role: 'user' | 'agent'. XSS-safe (textContent). */
144
+ addMessage(role, text) {
145
+ const el = document.createElement('div')
146
+ el.className = `tpa-msg ${role === 'user' ? 'tpa-user' : 'tpa-agent'}`
147
+ el.textContent = text
148
+ this.#messages.appendChild(el)
149
+ this.#messages.scrollTop = this.#messages.scrollHeight
150
+ return el
151
+ }
152
+
153
+ /** Small italic status line ("step 2: clicking [3]…"). */
154
+ addStatus(text) {
155
+ const el = document.createElement('div')
156
+ el.className = 'tpa-status'
157
+ el.textContent = text
158
+ this.#messages.appendChild(el)
159
+ this.#messages.scrollTop = this.#messages.scrollHeight
160
+ return el
161
+ }
162
+
163
+ /** Render agent activity events (drop-in for onActivity). */
164
+ showActivity(ev) {
165
+ if (ev.type === 'thinking') this.addStatus(`step ${ev.step}: thinking…`)
166
+ if (ev.type === 'executing') this.addStatus(`step ${ev.step}: ${ev.actionName} — ${ev.nextGoal ?? ''}`)
167
+ if (ev.type === 'executed') this.addStatus(`→ ${ev.resultMessage}`)
168
+ }
169
+
170
+ /**
171
+ * Human gate UI (drop-in for PolicyGuard onConfirm).
172
+ * Resolves true only if the user clicks Allow.
173
+ */
174
+ requestConfirm({ reason }) {
175
+ this.toggle(true)
176
+ return new Promise((resolve) => {
177
+ const box = document.createElement('div')
178
+ box.className = 'tpa-confirm'
179
+ const msg = document.createElement('div')
180
+ msg.textContent = `⚠️ Sensitive action needs your approval:\n${reason}`
181
+ const actions = document.createElement('div')
182
+ actions.className = 'tpa-confirm-actions'
183
+ const allow = document.createElement('button')
184
+ allow.className = 'tpa-allow'
185
+ allow.textContent = 'Allow'
186
+ const deny = document.createElement('button')
187
+ deny.className = 'tpa-deny'
188
+ deny.textContent = 'Deny'
189
+ const decide = (v) => {
190
+ actions.remove()
191
+ this.addStatus(v ? 'approved by you' : 'denied by you')
192
+ resolve(v)
193
+ }
194
+ allow.addEventListener('click', () => decide(true))
195
+ deny.addEventListener('click', () => decide(false))
196
+ actions.append(allow, deny)
197
+ box.append(msg, actions)
198
+ this.#messages.appendChild(box)
199
+ this.#messages.scrollTop = this.#messages.scrollHeight
200
+ })
201
+ }
202
+
203
+ /**
204
+ * ask_user tool UI (drop-in for onAskUser): shows the question as an agent
205
+ * bubble; the next message the user sends resolves it.
206
+ */
207
+ askUser(question) {
208
+ this.toggle(true)
209
+ this.addMessage('agent', question)
210
+ this.#input.placeholder = 'Answer the question…'
211
+ return new Promise((resolve) => {
212
+ this.#pendingAsk = { resolve }
213
+ })
214
+ }
215
+
216
+ setBusy(busy) {
217
+ if (this.#send) this.#send.disabled = busy
218
+ }
219
+ }