appilot 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -7
- package/dist/dom/surfaces.d.ts +81 -0
- package/dist/dom/surfaces.js +91 -0
- package/dist/dom/uniqueSelector.d.ts +23 -0
- package/dist/dom/uniqueSelector.js +104 -0
- package/dist/domResponder.d.ts +38 -0
- package/dist/domResponder.js +344 -0
- package/dist/focusedSessions/pageSdk.d.ts +107 -0
- package/dist/focusedSessions/pageSdk.js +238 -0
- package/dist/httpFetch.d.ts +52 -0
- package/dist/httpFetch.js +137 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +24 -0
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.js +18 -0
- package/dist/sessions/types.d.ts +140 -0
- package/dist/sessions/types.js +12 -0
- package/dist/webmcp/registry.d.ts +75 -0
- package/dist/webmcp/registry.js +152 -0
- package/dist/webmcp/sdk.d.ts +38 -0
- package/dist/webmcp/sdk.js +81 -0
- package/dist/widget/boot.d.ts +72 -0
- package/dist/widget/boot.js +264 -0
- package/package.json +51 -16
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page DOM responder for the agent runtime.
|
|
3
|
+
*
|
|
4
|
+
* Implements the six Web-MCP-aligned DOM tools (plus the `http_fetch`
|
|
5
|
+
* HTTP-proxy tool) the agent calls via the agent_dom_request SSE round-trip.
|
|
6
|
+
* See docs/agent/dom-tools.md for the wire format.
|
|
7
|
+
*
|
|
8
|
+
* This module is the in-page side of the contract, shared by both surfaces:
|
|
9
|
+
* - Chrome extension: invoked from the content-script message listener
|
|
10
|
+
* (content.ts) when the background relays an agent_dom_request to the
|
|
11
|
+
* active tab.
|
|
12
|
+
* - Embeddable widget: invoked directly by the widget's AgentDomBridge
|
|
13
|
+
* (services/AgentDomBridge.ts), which intercepts agent_dom_request on the
|
|
14
|
+
* widget SSE stream and POSTs the result to /widget/agent/dom-response.
|
|
15
|
+
*
|
|
16
|
+
* Element-id correlation: each call to get_page_outline / find_elements /
|
|
17
|
+
* inspect_element returns ephemeral ids that subsequent calls (inspect /
|
|
18
|
+
* wait_for) can dereference. Ids are kept in `currentTurnElementMap`; the
|
|
19
|
+
* map is cleared on each NEW turn (`resetTurn` on the request, or
|
|
20
|
+
* `resetDomToolTurn()`).
|
|
21
|
+
*
|
|
22
|
+
* Authoring controls bridge: when find_elements / inspect_element hit an
|
|
23
|
+
* element matched by an authored Control's locator, the `matches_authored_control`
|
|
24
|
+
* field returns the Control's semantic_id. The Controls registry is provided
|
|
25
|
+
* per turn (controlsForTurn).
|
|
26
|
+
*/
|
|
27
|
+
import { httpFetch } from './httpFetch.js';
|
|
28
|
+
import { invokeClientTool } from './webmcp/registry.js';
|
|
29
|
+
import { computeUniqueSelector } from './dom/uniqueSelector.js';
|
|
30
|
+
const MAX_HITS = 20;
|
|
31
|
+
const HIT_LIMIT_HARD = 50;
|
|
32
|
+
const MAX_TEXT_CHARS = 2000;
|
|
33
|
+
let nextElementId = 1;
|
|
34
|
+
let currentTurnElementMap = new Map();
|
|
35
|
+
let controlsForTurn = [];
|
|
36
|
+
/** Clear the per-turn element-id map. Call at the start of every new turn. */
|
|
37
|
+
export function resetDomToolTurn() {
|
|
38
|
+
currentTurnElementMap = new Map();
|
|
39
|
+
nextElementId = 1;
|
|
40
|
+
}
|
|
41
|
+
function freshId() {
|
|
42
|
+
return `el-${nextElementId++}`;
|
|
43
|
+
}
|
|
44
|
+
function registerElement(el) {
|
|
45
|
+
for (const [id, existing] of currentTurnElementMap) {
|
|
46
|
+
if (existing === el)
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
const id = freshId();
|
|
50
|
+
currentTurnElementMap.set(id, el);
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
function rectOf(el) {
|
|
54
|
+
const r = el.getBoundingClientRect();
|
|
55
|
+
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
|
56
|
+
}
|
|
57
|
+
function isVisible(el) {
|
|
58
|
+
if (!(el instanceof HTMLElement) && !(el instanceof SVGElement))
|
|
59
|
+
return false;
|
|
60
|
+
const r = el.getBoundingClientRect();
|
|
61
|
+
if (r.width === 0 && r.height === 0)
|
|
62
|
+
return false;
|
|
63
|
+
const style = window.getComputedStyle(el);
|
|
64
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
65
|
+
return false;
|
|
66
|
+
if (parseFloat(style.opacity || '1') === 0)
|
|
67
|
+
return false;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
function accessibleName(el) {
|
|
71
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
72
|
+
if (ariaLabel)
|
|
73
|
+
return ariaLabel.trim();
|
|
74
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
75
|
+
if (labelledby) {
|
|
76
|
+
const ref = document.getElementById(labelledby);
|
|
77
|
+
if (ref)
|
|
78
|
+
return (ref.textContent || '').trim();
|
|
79
|
+
}
|
|
80
|
+
const text = (el.textContent || '').trim();
|
|
81
|
+
return text.slice(0, 120);
|
|
82
|
+
}
|
|
83
|
+
function roleOf(el) {
|
|
84
|
+
const explicit = el.getAttribute('role');
|
|
85
|
+
if (explicit)
|
|
86
|
+
return explicit;
|
|
87
|
+
const tag = el.tagName.toLowerCase();
|
|
88
|
+
if (tag === 'button')
|
|
89
|
+
return 'button';
|
|
90
|
+
if (tag === 'a')
|
|
91
|
+
return 'link';
|
|
92
|
+
if (tag === 'input') {
|
|
93
|
+
const type = el.type || 'text';
|
|
94
|
+
if (type === 'checkbox')
|
|
95
|
+
return 'checkbox';
|
|
96
|
+
if (type === 'radio')
|
|
97
|
+
return 'radio';
|
|
98
|
+
return 'textbox';
|
|
99
|
+
}
|
|
100
|
+
if (tag === 'textarea')
|
|
101
|
+
return 'textbox';
|
|
102
|
+
if (tag === 'select')
|
|
103
|
+
return 'combobox';
|
|
104
|
+
return tag;
|
|
105
|
+
}
|
|
106
|
+
function matchesAuthoredControl(el) {
|
|
107
|
+
for (const c of controlsForTurn) {
|
|
108
|
+
try {
|
|
109
|
+
const candidate = document.querySelector(c.locator);
|
|
110
|
+
if (candidate === el)
|
|
111
|
+
return c.semantic_id;
|
|
112
|
+
}
|
|
113
|
+
catch { /* invalid selector */ }
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
function elementSummary(el) {
|
|
118
|
+
const id = registerElement(el);
|
|
119
|
+
const disableable = el;
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
tag: el.tagName.toLowerCase(),
|
|
123
|
+
role: roleOf(el),
|
|
124
|
+
name: accessibleName(el),
|
|
125
|
+
text: (el.textContent || '').trim().slice(0, 120),
|
|
126
|
+
visible: isVisible(el),
|
|
127
|
+
disabled: 'disabled' in disableable ? !!disableable.disabled : false,
|
|
128
|
+
rect: rectOf(el),
|
|
129
|
+
selector: computeUniqueSelector(el),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ── Tool implementations ────────────────────────────────────────────────
|
|
133
|
+
function getPageOutline() {
|
|
134
|
+
const landmarks = [];
|
|
135
|
+
for (const el of document.querySelectorAll('[role="navigation"], [role="main"], [role="banner"], [role="contentinfo"], [role="complementary"], header, nav, main, aside, footer')) {
|
|
136
|
+
landmarks.push({
|
|
137
|
+
id: registerElement(el),
|
|
138
|
+
role: roleOf(el),
|
|
139
|
+
name: accessibleName(el),
|
|
140
|
+
rect: rectOf(el),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const headings = [];
|
|
144
|
+
for (const el of document.querySelectorAll('h1, h2, h3, h4')) {
|
|
145
|
+
headings.push({
|
|
146
|
+
id: registerElement(el),
|
|
147
|
+
level: Number(el.tagName.substring(1)),
|
|
148
|
+
text: (el.textContent || '').trim().slice(0, 120),
|
|
149
|
+
rect: rectOf(el),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { landmarks, headings };
|
|
153
|
+
}
|
|
154
|
+
function findElements(args) {
|
|
155
|
+
const selector = typeof args?.selector === 'string' ? args.selector : undefined;
|
|
156
|
+
const role = typeof args?.role === 'string' ? args.role : undefined;
|
|
157
|
+
const name = typeof args?.name === 'string' ? args.name : undefined;
|
|
158
|
+
const visibleOnly = args?.visible_only !== false;
|
|
159
|
+
const limit = Math.min(Math.max(Number(args?.limit) || MAX_HITS, 1), HIT_LIMIT_HARD);
|
|
160
|
+
if (!selector && !role && !name) {
|
|
161
|
+
return { error: 'invalid_args', detail: 'at least one of selector, role, name required' };
|
|
162
|
+
}
|
|
163
|
+
let candidates = [];
|
|
164
|
+
if (selector) {
|
|
165
|
+
try {
|
|
166
|
+
candidates = Array.from(document.querySelectorAll(selector));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { error: 'invalid_selector', detail: selector };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
candidates = Array.from(document.querySelectorAll('*'));
|
|
174
|
+
}
|
|
175
|
+
const hits = [];
|
|
176
|
+
const nameLower = name?.toLowerCase();
|
|
177
|
+
for (const el of candidates) {
|
|
178
|
+
if (visibleOnly && !isVisible(el))
|
|
179
|
+
continue;
|
|
180
|
+
if (role && roleOf(el) !== role)
|
|
181
|
+
continue;
|
|
182
|
+
if (nameLower && !accessibleName(el).toLowerCase().includes(nameLower))
|
|
183
|
+
continue;
|
|
184
|
+
hits.push(elementSummary(el));
|
|
185
|
+
if (hits.length >= limit)
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
return { hits, truncated: candidates.length > hits.length };
|
|
189
|
+
}
|
|
190
|
+
function inspectElement(args) {
|
|
191
|
+
const id = String(args?.id || '');
|
|
192
|
+
const el = currentTurnElementMap.get(id);
|
|
193
|
+
if (!el)
|
|
194
|
+
return { error: 'unknown_id', detail: id };
|
|
195
|
+
const attributes = {};
|
|
196
|
+
for (const attr of Array.from(el.attributes))
|
|
197
|
+
attributes[attr.name] = attr.value;
|
|
198
|
+
const parentChain = [];
|
|
199
|
+
let parent = el.parentElement;
|
|
200
|
+
while (parent && parentChain.length < 8) {
|
|
201
|
+
parentChain.push({ tag: parent.tagName.toLowerCase(), id: registerElement(parent), role: roleOf(parent) });
|
|
202
|
+
parent = parent.parentElement;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...elementSummary(el),
|
|
206
|
+
attributes,
|
|
207
|
+
parent_chain: parentChain,
|
|
208
|
+
matches_authored_control: matchesAuthoredControl(el),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function readFormState(args) {
|
|
212
|
+
let forms = [];
|
|
213
|
+
if (args?.form_id) {
|
|
214
|
+
const el = currentTurnElementMap.get(String(args.form_id));
|
|
215
|
+
if (el)
|
|
216
|
+
forms = [el];
|
|
217
|
+
}
|
|
218
|
+
else if (typeof args?.selector === 'string') {
|
|
219
|
+
try {
|
|
220
|
+
forms = Array.from(document.querySelectorAll(args.selector));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { error: 'invalid_selector', detail: args.selector };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
forms = Array.from(document.querySelectorAll('form'));
|
|
228
|
+
}
|
|
229
|
+
const result = forms.slice(0, 5).map(form => {
|
|
230
|
+
const fields = [];
|
|
231
|
+
for (const fieldEl of form.querySelectorAll('input, select, textarea, [contenteditable]')) {
|
|
232
|
+
const f = fieldEl;
|
|
233
|
+
fields.push({
|
|
234
|
+
name: f.name || f.id || accessibleName(f),
|
|
235
|
+
label: accessibleName(f),
|
|
236
|
+
value: f.value ?? f.textContent ?? '',
|
|
237
|
+
visible: isVisible(f),
|
|
238
|
+
disabled: !!f.disabled,
|
|
239
|
+
required: !!f.required,
|
|
240
|
+
validation_error: f.validationMessage || null,
|
|
241
|
+
matches_authored_control: matchesAuthoredControl(f) ?? null,
|
|
242
|
+
selector: computeUniqueSelector(f),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return { form_id: registerElement(form), fields };
|
|
246
|
+
});
|
|
247
|
+
return { forms: result };
|
|
248
|
+
}
|
|
249
|
+
function getVisibleText(args) {
|
|
250
|
+
let region = null;
|
|
251
|
+
if (args?.region_id)
|
|
252
|
+
region = currentTurnElementMap.get(String(args.region_id)) || null;
|
|
253
|
+
if (!region && typeof args?.selector === 'string') {
|
|
254
|
+
try {
|
|
255
|
+
region = document.querySelector(args.selector);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return { error: 'invalid_selector', detail: args.selector };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!region)
|
|
262
|
+
return { error: 'no_target', detail: 'pass region_id or selector' };
|
|
263
|
+
const max = Math.min(Math.max(Number(args?.max_chars) || MAX_TEXT_CHARS, 50), 10_000);
|
|
264
|
+
const text = region.innerText || region.textContent || '';
|
|
265
|
+
const trimmed = text.trim();
|
|
266
|
+
return { text: trimmed.slice(0, max), truncated: trimmed.length > max };
|
|
267
|
+
}
|
|
268
|
+
async function waitFor(args) {
|
|
269
|
+
const event = String(args?.event || '');
|
|
270
|
+
const target = args?.target || {};
|
|
271
|
+
const timeoutMs = Math.min(Math.max(Number(args?.timeout_ms) || 5000, 500), 30_000);
|
|
272
|
+
const t0 = performance.now();
|
|
273
|
+
const check = () => {
|
|
274
|
+
let el = null;
|
|
275
|
+
if (target.id)
|
|
276
|
+
el = currentTurnElementMap.get(String(target.id)) || null;
|
|
277
|
+
if (!el && target.selector) {
|
|
278
|
+
try {
|
|
279
|
+
el = document.querySelector(target.selector);
|
|
280
|
+
}
|
|
281
|
+
catch { /* noop */ }
|
|
282
|
+
}
|
|
283
|
+
if (event === 'element_visible')
|
|
284
|
+
return { matched: !!el && isVisible(el), evidence: el ? { id: registerElement(el) } : undefined };
|
|
285
|
+
if (event === 'element_disappears')
|
|
286
|
+
return { matched: !el || !isVisible(el) };
|
|
287
|
+
if (event === 'text_appears') {
|
|
288
|
+
const text = (target.text_substring || '').toLowerCase();
|
|
289
|
+
if (!text)
|
|
290
|
+
return { matched: false };
|
|
291
|
+
const body = document.body?.innerText?.toLowerCase() || '';
|
|
292
|
+
return { matched: body.includes(text), evidence: { text_excerpt: target.text_substring } };
|
|
293
|
+
}
|
|
294
|
+
if (event === 'text_disappears') {
|
|
295
|
+
const text = (target.text_substring || '').toLowerCase();
|
|
296
|
+
if (!text)
|
|
297
|
+
return { matched: true };
|
|
298
|
+
const body = document.body?.innerText?.toLowerCase() || '';
|
|
299
|
+
return { matched: !body.includes(text) };
|
|
300
|
+
}
|
|
301
|
+
if (event === 'value_changes') {
|
|
302
|
+
// Best-effort: snapshot value first time we're called for this target.
|
|
303
|
+
// A more robust impl would diff against a snapshot taken at args-receive.
|
|
304
|
+
return { matched: false };
|
|
305
|
+
}
|
|
306
|
+
return { matched: false };
|
|
307
|
+
};
|
|
308
|
+
return new Promise(resolve => {
|
|
309
|
+
const tick = () => {
|
|
310
|
+
const r = check();
|
|
311
|
+
const elapsed = performance.now() - t0;
|
|
312
|
+
if (r.matched)
|
|
313
|
+
return resolve({ matched: true, elapsed_ms: Math.round(elapsed), evidence: r.evidence });
|
|
314
|
+
if (elapsed >= timeoutMs)
|
|
315
|
+
return resolve({ matched: false, elapsed_ms: Math.round(elapsed), reason: 'timeout' });
|
|
316
|
+
setTimeout(tick, 150);
|
|
317
|
+
};
|
|
318
|
+
tick();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
export async function runDomTool(req) {
|
|
322
|
+
if (req.resetTurn) {
|
|
323
|
+
resetDomToolTurn();
|
|
324
|
+
}
|
|
325
|
+
if (req.controlsForTurn)
|
|
326
|
+
controlsForTurn = req.controlsForTurn;
|
|
327
|
+
switch (req.tool) {
|
|
328
|
+
case 'get_page_outline': return getPageOutline();
|
|
329
|
+
case 'find_elements': return findElements(req.args);
|
|
330
|
+
case 'inspect_element': return inspectElement(req.args);
|
|
331
|
+
case 'read_form_state': return readFormState(req.args);
|
|
332
|
+
case 'get_visible_text': return getVisibleText(req.args);
|
|
333
|
+
case 'wait_for': return waitFor(req.args);
|
|
334
|
+
case 'http_fetch': return httpFetch(req.args);
|
|
335
|
+
case 'invoke_client_action': {
|
|
336
|
+
const action = typeof req.args.action === 'string' ? req.args.action : '';
|
|
337
|
+
const actionArgs = (req.args.args && typeof req.args.args === 'object')
|
|
338
|
+
? req.args.args
|
|
339
|
+
: {};
|
|
340
|
+
return invokeClientTool(action, actionArgs);
|
|
341
|
+
}
|
|
342
|
+
default: return { error: 'unknown_tool', detail: req.tool };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused Sessions page SDK: the surface a host app calls to run an
|
|
3
|
+
* app-initiated, mission-scoped agent conversation and receive its structured
|
|
4
|
+
* outcome (docs/agent/focused-sessions.md).
|
|
5
|
+
*
|
|
6
|
+
* const handle = await startFocusedSession({ template_id: 'role-play', variables: { role: 'Sam' } });
|
|
7
|
+
* handle.onOutcome(outcome => saveEvidence(outcome));
|
|
8
|
+
*
|
|
9
|
+
* Architecture mirrors the WebMCP registry: the SDK itself performs NO HTTP
|
|
10
|
+
* and holds NO credentials. It delegates to a PROVIDER the assistant surface
|
|
11
|
+
* registers on `window.__APPILOT_SESSIONS__`:
|
|
12
|
+
* - the widget registers a direct provider (it shares the page realm and
|
|
13
|
+
* owns the widget-key + user-token REST calls), and
|
|
14
|
+
* - the extension's MAIN-world script registers a postMessage relay to its
|
|
15
|
+
* isolated content script -> background -> JWT REST.
|
|
16
|
+
* A window-anchored global (not a module singleton) because the host app's
|
|
17
|
+
* bundle and the assistant surface's bundle are DIFFERENT module graphs; a
|
|
18
|
+
* module-level variable would give each its own copy.
|
|
19
|
+
*
|
|
20
|
+
* Import from `appilot` (same shipping posture as `registerTool`).
|
|
21
|
+
*/
|
|
22
|
+
import type { FocusedSessionEndedEvent, FocusedSessionEndStatus, FocusedSessionOutcomeEvent, FocusedSessionStartRequest, FocusedSessionStartResult } from '../sessions/types.js';
|
|
23
|
+
/** What the assistant surface (widget / extension bridge) implements. */
|
|
24
|
+
export interface FocusedSessionProvider {
|
|
25
|
+
start(request: FocusedSessionStartRequest): Promise<FocusedSessionStartResult>;
|
|
26
|
+
end(focusedSessionId: string): Promise<void>;
|
|
27
|
+
/** Subscribe to outcome events; returns an unsubscribe function. */
|
|
28
|
+
onOutcome(cb: (event: FocusedSessionOutcomeEvent) => void): () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Subscribe to terminal-WITHOUT-outcome events; returns an unsubscribe
|
|
31
|
+
* function. Optional so older surfaces (the extension MAIN-world bridge)
|
|
32
|
+
* keep type-checking until they wire it; the page handle degrades to a
|
|
33
|
+
* never-firing subscription in that case.
|
|
34
|
+
*/
|
|
35
|
+
onEnded?(cb: (event: FocusedSessionEndedEvent) => void): () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Re-attach to a session this identity already started, after the page lost
|
|
38
|
+
* its handle (a full browser reload). Resolves to null when the session is
|
|
39
|
+
* no longer running. Optional so older surfaces keep type-checking; without
|
|
40
|
+
* it `resumeFocusedSession` resolves to null rather than throwing.
|
|
41
|
+
*/
|
|
42
|
+
resume?(focusedSessionId: string): Promise<FocusedSessionStartResult | null>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Called by the assistant surface (widget boot / extension MAIN-world bridge)
|
|
46
|
+
* to expose the session transport to the page. Last registration wins: when
|
|
47
|
+
* both the widget and the extension are present on a page, the most recent
|
|
48
|
+
* surface serves new sessions (in practice hosts embed exactly one).
|
|
49
|
+
*/
|
|
50
|
+
export declare function registerFocusedSessionProvider(provider: FocusedSessionProvider): void;
|
|
51
|
+
/** A running (or finished) focused session, from the host page's side. */
|
|
52
|
+
export interface FocusedSessionPageHandle {
|
|
53
|
+
focusedSessionId: string;
|
|
54
|
+
conversationId: number;
|
|
55
|
+
/** Resolved display name (the session header the assistant surface shows). */
|
|
56
|
+
name: string;
|
|
57
|
+
/**
|
|
58
|
+
* Subscribe to THIS session's outcome. The outcome is AI output: run it
|
|
59
|
+
* through your app's own review/validation model before treating it as
|
|
60
|
+
* truth. Returns an unsubscribe function.
|
|
61
|
+
*/
|
|
62
|
+
onOutcome(cb: (outcome: Record<string, unknown>) => void): () => void;
|
|
63
|
+
/**
|
|
64
|
+
* Subscribe to THIS session ending WITHOUT an outcome: abandoned by the
|
|
65
|
+
* server (turn budget exhausted), expired, failed, or ended by the page
|
|
66
|
+
* (`end()`). Fires exactly once per session and is mutually exclusive with
|
|
67
|
+
* `onOutcome`, a completed session fires only `onOutcome`. Use it to stop
|
|
68
|
+
* waiting: without it a page that waits for the outcome hangs forever when
|
|
69
|
+
* the session dies outcome-less. Returns an unsubscribe function.
|
|
70
|
+
*/
|
|
71
|
+
onEnded(cb: (event: {
|
|
72
|
+
focusedSessionId: string;
|
|
73
|
+
status: FocusedSessionEndStatus;
|
|
74
|
+
}) => void): () => void;
|
|
75
|
+
/** Abandon the session (no outcome will be produced). */
|
|
76
|
+
end(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Start a focused session. Resolves once the assistant surface has created
|
|
80
|
+
* the session server-side (mission resolved + conversation bound); rejects
|
|
81
|
+
* with the surface's typed error message when the template is unknown, a
|
|
82
|
+
* required variable is missing, or no assistant surface is present.
|
|
83
|
+
*/
|
|
84
|
+
export declare function startFocusedSession(request: FocusedSessionStartRequest): Promise<FocusedSessionPageHandle>;
|
|
85
|
+
/**
|
|
86
|
+
* The id of the session this tab last started, if it has not reached a terminal
|
|
87
|
+
* state. Useful for deciding whether to offer a "continue" affordance before
|
|
88
|
+
* paying for the round-trip that `resumeFocusedSession` makes.
|
|
89
|
+
*/
|
|
90
|
+
export declare function getResumableFocusedSessionId(): string | null;
|
|
91
|
+
/** Forget the remembered session without ending it server-side. */
|
|
92
|
+
export declare function forgetFocusedSession(): void;
|
|
93
|
+
/**
|
|
94
|
+
* Re-attach to a session this tab started before a reload.
|
|
95
|
+
*
|
|
96
|
+
* Resolves to a handle when the session is still running and belongs to the
|
|
97
|
+
* current identity, and to `null` otherwise: nothing remembered, the assistant
|
|
98
|
+
* surface cannot resume, the session already ended, or it is not this user's.
|
|
99
|
+
* Ownership is checked server-side; possessing an id proves nothing.
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* const handle = await resumeFocusedSession();
|
|
103
|
+
* if (handle) handle.onOutcome(saveEvidence);
|
|
104
|
+
* else showActivityFinishedState();
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export declare function resumeFocusedSession(focusedSessionId?: string): Promise<FocusedSessionPageHandle | null>;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused Sessions page SDK: the surface a host app calls to run an
|
|
3
|
+
* app-initiated, mission-scoped agent conversation and receive its structured
|
|
4
|
+
* outcome (docs/agent/focused-sessions.md).
|
|
5
|
+
*
|
|
6
|
+
* const handle = await startFocusedSession({ template_id: 'role-play', variables: { role: 'Sam' } });
|
|
7
|
+
* handle.onOutcome(outcome => saveEvidence(outcome));
|
|
8
|
+
*
|
|
9
|
+
* Architecture mirrors the WebMCP registry: the SDK itself performs NO HTTP
|
|
10
|
+
* and holds NO credentials. It delegates to a PROVIDER the assistant surface
|
|
11
|
+
* registers on `window.__APPILOT_SESSIONS__`:
|
|
12
|
+
* - the widget registers a direct provider (it shares the page realm and
|
|
13
|
+
* owns the widget-key + user-token REST calls), and
|
|
14
|
+
* - the extension's MAIN-world script registers a postMessage relay to its
|
|
15
|
+
* isolated content script -> background -> JWT REST.
|
|
16
|
+
* A window-anchored global (not a module singleton) because the host app's
|
|
17
|
+
* bundle and the assistant surface's bundle are DIFFERENT module graphs; a
|
|
18
|
+
* module-level variable would give each its own copy.
|
|
19
|
+
*
|
|
20
|
+
* Import from `appilot` (same shipping posture as `registerTool`).
|
|
21
|
+
*/
|
|
22
|
+
const GLOBAL_KEY = '__APPILOT_SESSIONS__';
|
|
23
|
+
const PROVIDER_WAIT_TIMEOUT_MS = 10_000;
|
|
24
|
+
function getWindow() {
|
|
25
|
+
return typeof window !== 'undefined' ? window : undefined;
|
|
26
|
+
}
|
|
27
|
+
function getGlobal() {
|
|
28
|
+
const w = getWindow();
|
|
29
|
+
if (!w)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!w[GLOBAL_KEY])
|
|
32
|
+
w[GLOBAL_KEY] = {};
|
|
33
|
+
return w[GLOBAL_KEY];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Called by the assistant surface (widget boot / extension MAIN-world bridge)
|
|
37
|
+
* to expose the session transport to the page. Last registration wins: when
|
|
38
|
+
* both the widget and the extension are present on a page, the most recent
|
|
39
|
+
* surface serves new sessions (in practice hosts embed exactly one).
|
|
40
|
+
*/
|
|
41
|
+
export function registerFocusedSessionProvider(provider) {
|
|
42
|
+
const g = getGlobal();
|
|
43
|
+
if (!g)
|
|
44
|
+
return;
|
|
45
|
+
g.provider = provider;
|
|
46
|
+
const waiters = g.waiters ?? [];
|
|
47
|
+
g.waiters = [];
|
|
48
|
+
for (const w of waiters) {
|
|
49
|
+
try {
|
|
50
|
+
w(provider);
|
|
51
|
+
}
|
|
52
|
+
catch { /* waiter errors are the page's problem */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function waitForProvider(timeoutMs) {
|
|
56
|
+
const g = getGlobal();
|
|
57
|
+
if (!g)
|
|
58
|
+
return Promise.reject(new Error('Focused sessions require a browser window'));
|
|
59
|
+
if (g.provider)
|
|
60
|
+
return Promise.resolve(g.provider);
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
const idx = g.waiters?.indexOf(onReady) ?? -1;
|
|
64
|
+
if (idx >= 0)
|
|
65
|
+
g.waiters?.splice(idx, 1);
|
|
66
|
+
reject(new Error('No Appilot assistant surface available (widget not loaded / extension not active)'));
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
const onReady = (provider) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
resolve(provider);
|
|
71
|
+
};
|
|
72
|
+
g.waiters = g.waiters ?? [];
|
|
73
|
+
g.waiters.push(onReady);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Start a focused session. Resolves once the assistant surface has created
|
|
78
|
+
* the session server-side (mission resolved + conversation bound); rejects
|
|
79
|
+
* with the surface's typed error message when the template is unknown, a
|
|
80
|
+
* required variable is missing, or no assistant surface is present.
|
|
81
|
+
*/
|
|
82
|
+
export async function startFocusedSession(request) {
|
|
83
|
+
// Wake the assistant surface BEFORE waiting for the provider. The widget
|
|
84
|
+
// registers its provider from the lazily-loaded panel chunk, so on a page
|
|
85
|
+
// where the user never opened the panel there is NO provider yet; without
|
|
86
|
+
// this signal, start would deadlock into the wait timeout (found in the
|
|
87
|
+
// first L3 Learn headful soak). The widget loader listens for
|
|
88
|
+
// `appilot:open` (the same event behind `window.Appilot.open()`) and
|
|
89
|
+
// mounts the panel, which binds the provider and resolves the waiter.
|
|
90
|
+
// Opening the panel is also the DESIRED UX: the session dialogue happens
|
|
91
|
+
// there. Surfaces that register eagerly (extension MAIN-world bridge)
|
|
92
|
+
// simply never hear the event.
|
|
93
|
+
try {
|
|
94
|
+
getWindow()?.dispatchEvent(new CustomEvent('appilot:open'));
|
|
95
|
+
}
|
|
96
|
+
catch { /* older browsers without CustomEvent; the eager-surface path still works */ }
|
|
97
|
+
const provider = await waitForProvider(PROVIDER_WAIT_TIMEOUT_MS);
|
|
98
|
+
const started = await provider.start(request);
|
|
99
|
+
storeSessionId(started.focused_session_id);
|
|
100
|
+
return buildHandle(provider, started);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The page-side handle. Shared by `startFocusedSession` and
|
|
104
|
+
* `resumeFocusedSession` so a resumed session behaves identically to a fresh
|
|
105
|
+
* one, terminal latch included.
|
|
106
|
+
*/
|
|
107
|
+
function buildHandle(provider, session) {
|
|
108
|
+
// Handle-level terminal latch, defence in depth on top of the provider's
|
|
109
|
+
// per-session exactly-once guarantee: the FIRST terminal event (outcome OR
|
|
110
|
+
// ended) settles the handle, later events of the other kind are ignored,
|
|
111
|
+
// so `onOutcome` and `onEnded` are mutually exclusive even against a
|
|
112
|
+
// misbehaving surface.
|
|
113
|
+
let settled = null;
|
|
114
|
+
return {
|
|
115
|
+
focusedSessionId: session.focused_session_id,
|
|
116
|
+
conversationId: session.conversation_id,
|
|
117
|
+
name: session.name,
|
|
118
|
+
onOutcome(cb) {
|
|
119
|
+
return provider.onOutcome(event => {
|
|
120
|
+
if (event.focused_session_id !== session.focused_session_id)
|
|
121
|
+
return;
|
|
122
|
+
if (settled === 'ended')
|
|
123
|
+
return;
|
|
124
|
+
settled = 'outcome';
|
|
125
|
+
storeSessionId(null);
|
|
126
|
+
cb(event.outcome);
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
onEnded(cb) {
|
|
130
|
+
// Older surfaces without onEnded: a never-firing subscription (the
|
|
131
|
+
// page still compiles and runs; it simply keeps the legacy behavior).
|
|
132
|
+
if (!provider.onEnded)
|
|
133
|
+
return () => { };
|
|
134
|
+
return provider.onEnded(event => {
|
|
135
|
+
if (event.focused_session_id !== session.focused_session_id)
|
|
136
|
+
return;
|
|
137
|
+
if (settled === 'outcome')
|
|
138
|
+
return;
|
|
139
|
+
settled = 'ended';
|
|
140
|
+
storeSessionId(null);
|
|
141
|
+
cb({ focusedSessionId: event.focused_session_id, status: event.status });
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
end() {
|
|
145
|
+
storeSessionId(null);
|
|
146
|
+
return provider.end(session.focused_session_id);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// ── Surviving a reload ───────────────────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// A full browser reload destroys the page's handle while the session keeps
|
|
153
|
+
// running on the server, which used to strand the page (learning #17). The id
|
|
154
|
+
// is remembered here so the page can ask for it back.
|
|
155
|
+
//
|
|
156
|
+
// sessionStorage, not localStorage: a focused session belongs to the tab that
|
|
157
|
+
// started it, and it must not leak into a second tab where a different mission
|
|
158
|
+
// may be running. The read is NON-destructive; the entry is cleared only on a
|
|
159
|
+
// terminal event or an explicit call. A destructive read looks tempting and is
|
|
160
|
+
// a trap: under React StrictMode the double-invoked effect erases the value
|
|
161
|
+
// before the component that needed it ever sees it.
|
|
162
|
+
const RESUME_STORAGE_KEY = 'appilot.focused_session';
|
|
163
|
+
function readStoredSessionId() {
|
|
164
|
+
try {
|
|
165
|
+
const raw = window.sessionStorage?.getItem(RESUME_STORAGE_KEY);
|
|
166
|
+
return raw && raw.trim() !== '' ? raw : null;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Private mode, blocked storage, or a sandboxed frame. Resume simply is
|
|
170
|
+
// not available; nothing else degrades.
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function storeSessionId(id) {
|
|
175
|
+
try {
|
|
176
|
+
if (id)
|
|
177
|
+
window.sessionStorage?.setItem(RESUME_STORAGE_KEY, id);
|
|
178
|
+
else
|
|
179
|
+
window.sessionStorage?.removeItem(RESUME_STORAGE_KEY);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* storage unavailable: resume is not offered, the session still runs */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The id of the session this tab last started, if it has not reached a terminal
|
|
187
|
+
* state. Useful for deciding whether to offer a "continue" affordance before
|
|
188
|
+
* paying for the round-trip that `resumeFocusedSession` makes.
|
|
189
|
+
*/
|
|
190
|
+
export function getResumableFocusedSessionId() {
|
|
191
|
+
return getWindow() ? readStoredSessionId() : null;
|
|
192
|
+
}
|
|
193
|
+
/** Forget the remembered session without ending it server-side. */
|
|
194
|
+
export function forgetFocusedSession() {
|
|
195
|
+
if (getWindow())
|
|
196
|
+
storeSessionId(null);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Re-attach to a session this tab started before a reload.
|
|
200
|
+
*
|
|
201
|
+
* Resolves to a handle when the session is still running and belongs to the
|
|
202
|
+
* current identity, and to `null` otherwise: nothing remembered, the assistant
|
|
203
|
+
* surface cannot resume, the session already ended, or it is not this user's.
|
|
204
|
+
* Ownership is checked server-side; possessing an id proves nothing.
|
|
205
|
+
*
|
|
206
|
+
* ```ts
|
|
207
|
+
* const handle = await resumeFocusedSession();
|
|
208
|
+
* if (handle) handle.onOutcome(saveEvidence);
|
|
209
|
+
* else showActivityFinishedState();
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export async function resumeFocusedSession(focusedSessionId) {
|
|
213
|
+
const id = focusedSessionId ?? getResumableFocusedSessionId();
|
|
214
|
+
if (!id)
|
|
215
|
+
return null;
|
|
216
|
+
try {
|
|
217
|
+
getWindow()?.dispatchEvent(new CustomEvent('appilot:open'));
|
|
218
|
+
}
|
|
219
|
+
catch { /* older browsers without CustomEvent; the eager-surface path still works */ }
|
|
220
|
+
let provider;
|
|
221
|
+
try {
|
|
222
|
+
provider = await waitForProvider(PROVIDER_WAIT_TIMEOUT_MS);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// No surface on this page. Resuming is a best-effort recovery, so it
|
|
226
|
+
// answers "no" rather than throwing at a page that is merely reloading.
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (!provider.resume)
|
|
230
|
+
return null;
|
|
231
|
+
const resumed = await provider.resume(id);
|
|
232
|
+
if (!resumed) {
|
|
233
|
+
storeSessionId(null);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
storeSessionId(resumed.focused_session_id);
|
|
237
|
+
return buildHandle(provider, resumed);
|
|
238
|
+
}
|