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.
@@ -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
+ }