passenger-mode 0.0.0-stage → 0.3.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 (40) hide show
  1. package/AGENTS.md +82 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -2
  4. package/dist/demo/index.html +112 -0
  5. package/dist/lib/cli.d.ts +2 -0
  6. package/dist/lib/cli.js +158 -0
  7. package/dist/lib/crawl.d.ts +15 -0
  8. package/dist/lib/crawl.js +189 -0
  9. package/dist/lib/gates-text.d.ts +10 -0
  10. package/dist/lib/gates-text.js +48 -0
  11. package/dist/lib/playwright.d.ts +5 -0
  12. package/dist/lib/playwright.js +35 -0
  13. package/dist/lib/react.d.ts +8 -0
  14. package/dist/lib/react.js +23 -0
  15. package/dist/lib/runtime/driver.d.ts +101 -0
  16. package/dist/lib/runtime/driver.js +422 -0
  17. package/dist/lib/runtime/gates.d.ts +1 -0
  18. package/dist/lib/runtime/gates.js +56 -0
  19. package/dist/lib/runtime/index.d.ts +43 -0
  20. package/dist/lib/runtime/index.js +99 -0
  21. package/dist/lib/runtime/planner.d.ts +61 -0
  22. package/dist/lib/runtime/planner.js +107 -0
  23. package/dist/lib/runtime/remote-planner.d.ts +18 -0
  24. package/dist/lib/runtime/remote-planner.js +37 -0
  25. package/dist/lib/runtime/resolve.d.ts +6 -0
  26. package/dist/lib/runtime/resolve.js +72 -0
  27. package/dist/lib/runtime/routes.d.ts +13 -0
  28. package/dist/lib/runtime/routes.js +65 -0
  29. package/dist/lib/runtime/ui.d.ts +58 -0
  30. package/dist/lib/runtime/ui.js +267 -0
  31. package/dist/lib/scan.d.ts +17 -0
  32. package/dist/lib/scan.js +431 -0
  33. package/dist/lib/server/claude-planner.d.ts +43 -0
  34. package/dist/lib/server/claude-planner.js +159 -0
  35. package/dist/lib/testdrive.d.ts +12 -0
  36. package/dist/lib/testdrive.js +92 -0
  37. package/dist/lib/types.d.ts +70 -0
  38. package/dist/lib/types.js +3 -0
  39. package/dist/passenger-mode.js +52 -0
  40. package/package.json +95 -4
@@ -0,0 +1,422 @@
1
+ import { runtimeGateReason } from "./gates.js";
2
+ import { liveLabel, resolveElement } from "./resolve.js";
3
+ import { canonicalPath, matchRoute } from "./routes.js";
4
+ const STORAGE_KEY = "passenger-mode:trip";
5
+ const MAX_DEST = 300;
6
+ /** Bulk goals are refused here, whatever the planner says: one trip does one thing. */
7
+ const BULK = /\b(all|every|each|everyone|everybody)\b.{0,40}\b(users?|members?|people|contacts?|customers?|items?|posts?|messages?|accounts?|orders?|friends?|followers?)\b|\b\d{2,}\s+times\b|\bmass\b|\bbulk\b/i;
8
+ const GATE_NOUN = {
9
+ payment: "pay buttons",
10
+ "cancels a plan or order": "cancel buttons",
11
+ deletion: "delete buttons",
12
+ "sends to other people": "send buttons",
13
+ publishes: "publish buttons",
14
+ "credential change": "password or email changes",
15
+ "ends the session": "sign-out buttons",
16
+ "uses your password": "buttons that use your password",
17
+ };
18
+ function tripId() {
19
+ const c = globalThis.crypto;
20
+ if (c?.randomUUID)
21
+ return c.randomUUID();
22
+ return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
23
+ }
24
+ const noopUI = { render() { }, async moveCursor() { }, ring() { }, owns: () => false };
25
+ export class Driver {
26
+ state = "off";
27
+ plan = null;
28
+ log = null;
29
+ o;
30
+ ui;
31
+ run = 0;
32
+ gateEl = null;
33
+ gateAction = null;
34
+ stepCount = 0;
35
+ detach = [];
36
+ constructor(opts) {
37
+ this.o = {
38
+ routeFrom: "path",
39
+ isTrusted: (e) => e.isTrusted,
40
+ maxSteps: 15,
41
+ timeLimitMs: 3 * 60_000,
42
+ settleMs: 250,
43
+ ...opts,
44
+ };
45
+ this.ui = opts.ui ?? noopUI;
46
+ if (this.o.storage === undefined) {
47
+ try {
48
+ this.o.storage = opts.window.sessionStorage;
49
+ }
50
+ catch {
51
+ this.o.storage = null;
52
+ }
53
+ }
54
+ this.listen();
55
+ this.restore();
56
+ }
57
+ get doc() { return this.o.window.document; }
58
+ path() {
59
+ const loc = this.o.window.location;
60
+ if (typeof this.o.routeFrom === "function")
61
+ return this.o.routeFrom();
62
+ // Path plus query: apps that switch screens with parameters (?lobby=1) have one route per screen.
63
+ return this.o.routeFrom === "hash" ? (loc.hash.replace(/^#/, "") || "/") : loc.pathname + loc.search;
64
+ }
65
+ set(state, info = {}) {
66
+ this.state = state;
67
+ this.ui.render(state, { plan: this.plan, destination: this.log?.destination, step: this.stepCount, ...info });
68
+ }
69
+ // ---------- what the passenger does (each needs a real gesture) ----------
70
+ /** Flip the switch on. A script cannot do this: it needs a trusted event. */
71
+ open(e) {
72
+ if (!this.o.isTrusted(e))
73
+ return false;
74
+ if (this.state === "off")
75
+ this.set("open");
76
+ return true;
77
+ }
78
+ /** Switch off. Always allowed: stopping is the safe direction. */
79
+ close() {
80
+ this.finish("ended");
81
+ this.plan = null;
82
+ this.set("off");
83
+ }
84
+ async planTrip(destination, e) {
85
+ if (!this.o.isTrusted(e))
86
+ return null;
87
+ const dest = destination.trim().slice(0, MAX_DEST);
88
+ if (!dest)
89
+ return null;
90
+ this.log = { id: tripId(), destination: dest, startedAt: Date.now(), steps: [] };
91
+ this.stepCount = 0;
92
+ if (BULK.test(dest)) {
93
+ this.set("stuck", { message: "Passenger Mode does one thing per trip. Try a single destination." });
94
+ return null;
95
+ }
96
+ this.set("planning");
97
+ let plan = null;
98
+ const view = this.view();
99
+ try {
100
+ plan = await this.o.planner.plan(view, this.o.manifest);
101
+ }
102
+ catch {
103
+ // The planner couldn't be reached: say so, rather than blaming how the passenger put it.
104
+ this.set("stuck", { message: "I can't plan trips right now. Try again in a moment." });
105
+ return null;
106
+ }
107
+ if (!plan) {
108
+ this.set("stuck", { message: "I don't know this road. Try saying it another way." });
109
+ return null;
110
+ }
111
+ this.plan = plan;
112
+ this.set("ready");
113
+ return plan;
114
+ }
115
+ /** Nothing moves until the passenger presses Go. */
116
+ go(e) {
117
+ if (!this.o.isTrusted(e) || this.state !== "ready")
118
+ return false;
119
+ this.log.startedAt = Date.now();
120
+ this.drive();
121
+ return true;
122
+ }
123
+ /** Back to choosing a destination. */
124
+ notThis() {
125
+ this.plan = null;
126
+ this.log = null;
127
+ this.set("open");
128
+ }
129
+ takeWheel() {
130
+ if (this.state !== "driving")
131
+ return;
132
+ this.run++;
133
+ this.ui.ring(null);
134
+ this.set("paused", { message: "Nothing moves until you resume." });
135
+ }
136
+ resume(e) {
137
+ if (!this.o.isTrusted(e) || (this.state !== "paused" && this.state !== "handoff"))
138
+ return false;
139
+ this.drive();
140
+ return true;
141
+ }
142
+ end() {
143
+ this.finish(this.state === "arrived" ? "arrived" : this.state === "paused" ? "taken-over" : "ended");
144
+ this.plan = null;
145
+ this.set("open");
146
+ }
147
+ rate(up) {
148
+ if (this.log) {
149
+ this.log.rating = up ? "up" : "down";
150
+ this.o.onTrip?.(this.log);
151
+ }
152
+ }
153
+ // ---------- the loop ----------
154
+ /** What the planner may see: the destination and the manifest actions on this page. No page text. */
155
+ view() {
156
+ const route = matchRoute(this.path(), this.o.manifest.routes);
157
+ const visible = [];
158
+ for (const a of this.o.manifest.actions) {
159
+ if (a.route !== null && a.route !== route)
160
+ continue;
161
+ if (a.route === null && a.seenOn && route !== null && !a.seenOn.includes(route))
162
+ continue;
163
+ const el = this.find(a);
164
+ if (!el)
165
+ continue;
166
+ const runtimeLabel = a.label.startsWith("{");
167
+ visible.push({ id: a.id, label: runtimeLabel ? liveLabel(el) : a.label, kind: a.kind, irreversible: a.irreversible, runtimeLabel });
168
+ }
169
+ return {
170
+ destination: this.log?.destination ?? "",
171
+ route,
172
+ path: this.path(),
173
+ visible,
174
+ history: (this.log?.steps ?? []).map((s) => ({ actionId: s.actionId, label: s.label, route: s.route })),
175
+ ...(this.plan ? { approvedPlan: this.plan.steps.slice(0, 8) } : {}),
176
+ };
177
+ }
178
+ find(a) {
179
+ const el = resolveElement(a, this.doc);
180
+ return el && !this.ui.owns(el) ? el : null;
181
+ }
182
+ drive() {
183
+ const id = ++this.run;
184
+ this.set("driving", { caption: "Starting the trip" });
185
+ void this.loop(id);
186
+ }
187
+ live(id) { return id === this.run && this.state === "driving"; }
188
+ async loop(id) {
189
+ // Pages that draw their controls after load (data fetches, client routers) get a moment first.
190
+ await this.settle();
191
+ let lookedAgain = false;
192
+ while (this.live(id)) {
193
+ if (this.stepCount >= this.o.maxSteps)
194
+ return this.stuck("That's more steps than one trip allows, so I stopped.");
195
+ if (Date.now() - this.log.startedAt > this.o.timeLimitMs)
196
+ return this.stuck("This trip is taking too long, so I stopped.");
197
+ const view = this.view();
198
+ let step;
199
+ try {
200
+ step = await this.o.planner.next(view, this.o.manifest);
201
+ }
202
+ catch {
203
+ return this.stuck("I lost the road. Try again in a moment.");
204
+ }
205
+ if (!this.live(id))
206
+ return;
207
+ if (step.kind === "arrived")
208
+ return this.arrive(step.summary);
209
+ if (step.kind === "stuck") {
210
+ // One more look before giving up: the control may still be on its way.
211
+ if (!lookedAgain) {
212
+ lookedAgain = true;
213
+ await this.settle(1500);
214
+ continue;
215
+ }
216
+ // The driver's own last step brought the passenger to a page the scan never saw (a game table,
217
+ // an item with an id in its address). That is where the step leads; say so plainly.
218
+ const last = this.log.steps.at(-1);
219
+ if (view.route === null && last?.by === "driver") {
220
+ return this.arrive(`This is where “${last.label}” leads. I don't know this page, so take it from here.`);
221
+ }
222
+ return this.stuck(step.reason);
223
+ }
224
+ lookedAgain = false;
225
+ // Going in circles: the same action twice in a row already, and the planner wants it again.
226
+ const recent = this.log.steps.slice(-2);
227
+ if (recent.length === 2 && recent.every((r) => r.actionId === step.actionId)) {
228
+ return this.stuck("I'm going in circles, so I stopped. Try saying it another way.");
229
+ }
230
+ // Scope is the manifest, and the action must be on this page right now.
231
+ const action = this.o.manifest.actions.find((a) => a.id === step.actionId);
232
+ if (!action || !view.visible.some((v) => v.id === action.id))
233
+ return this.stuck("I don't know this road.");
234
+ const el = this.find(action);
235
+ if (!el)
236
+ return this.stuck("I don't know this road.");
237
+ // A link to the page the passenger is already on goes nowhere: the planner is pointing at here.
238
+ if (action.kind === "navigate" && this.linksHere(el))
239
+ return this.arrive(step.caption ?? "You're already here.");
240
+ const label = action.label.startsWith("{") ? liveLabel(el) : action.label;
241
+ const gate = action.irreversible ? action.irreversibleReason : runtimeGateReason(el);
242
+ const caption = gate ? `Pull over: ${label} is yours to press` : step.caption ?? `Pressing ${label}`;
243
+ el.scrollIntoView?.({ block: "center", behavior: "smooth" });
244
+ this.ui.ring(el, gate ? "gate" : "drive");
245
+ await this.ui.moveCursor(el, caption);
246
+ if (!this.live(id))
247
+ return;
248
+ if (gate)
249
+ return this.pullOver(el, action, label, gate);
250
+ if (action.kind === "submit") {
251
+ const form = (el.tagName === "FORM" ? el : el.closest("form"));
252
+ const empty = form ? Array.from(form.querySelectorAll("input[required],select[required],textarea[required]")).filter((f) => !f.value) : [];
253
+ if (empty.length) {
254
+ // The driver never types for the passenger: their values stay theirs.
255
+ this.ui.ring(form, "handoff");
256
+ // Middle of the screen, clear of the trip bar at the bottom.
257
+ empty[0].scrollIntoView?.({ block: "center" });
258
+ empty[0].focus?.({ preventScroll: true });
259
+ this.set("handoff", { message: `Your turn: fill in ${empty.length === 1 ? "the highlighted field" : `the ${empty.length} highlighted fields`}, then press Resume.` });
260
+ return;
261
+ }
262
+ this.record(action, label, "driver");
263
+ this.markNav();
264
+ if (form?.requestSubmit)
265
+ form.requestSubmit();
266
+ else
267
+ form?.submit();
268
+ }
269
+ else {
270
+ this.record(action, label, "driver");
271
+ this.markNav();
272
+ el.click();
273
+ }
274
+ await this.settle();
275
+ this.clearNav();
276
+ }
277
+ }
278
+ /** The driver stops at the control and waits for the passenger's own, trusted click on it. */
279
+ pullOver(el, action, label, reason) {
280
+ this.gateEl = el;
281
+ this.gateAction = { action, label };
282
+ // On a form, point at the button the passenger presses; the whole form still counts as the gate.
283
+ const face = el.tagName === "FORM"
284
+ ? el.querySelector('button:not([type="button"]):not([type="reset"]), input[type="submit"]') ?? el
285
+ : el;
286
+ this.ui.ring(face, "gate");
287
+ if (face !== el)
288
+ void this.ui.moveCursor(face, `Pull over: ${label} is yours to press`);
289
+ this.set("gate", { caption: `Pull over: ${label} is yours to press`, message: `This one's yours. Passenger Mode never presses ${GATE_NOUN[reason] ?? "buttons like this"}. Press ${label} to finish, or end the trip.` });
290
+ }
291
+ onGateClick(e) {
292
+ if (this.state !== "gate" || !this.gateEl || !this.gateAction || !this.o.isTrusted(e))
293
+ return;
294
+ const t = e.target;
295
+ if (!t || !this.gateEl.contains(t))
296
+ return;
297
+ const { action, label } = this.gateAction;
298
+ this.gateAction = null;
299
+ this.record(action, label, "passenger");
300
+ this.gateEl = null;
301
+ this.ui.ring(null);
302
+ // Let the passenger's own click do its work, then carry on to "You're here".
303
+ this.markNav();
304
+ const id = ++this.run;
305
+ this.state = "driving";
306
+ this.ui.render("driving", { plan: this.plan, caption: "Checking you arrived", destination: this.log?.destination, step: this.stepCount });
307
+ void this.settle().then(() => { this.clearNav(); return this.loop(id); });
308
+ }
309
+ record(action, label, by) {
310
+ this.stepCount++;
311
+ this.log?.steps.push({ actionId: action.id, label, route: matchRoute(this.path(), this.o.manifest.routes), by, at: Date.now() });
312
+ }
313
+ arrive(summary) {
314
+ this.ui.ring(null);
315
+ this.finish("arrived");
316
+ this.set("arrived", { message: summary });
317
+ }
318
+ stuck(reason) {
319
+ this.ui.ring(null);
320
+ this.finish("stuck", reason);
321
+ this.set("stuck", { message: reason });
322
+ }
323
+ finish(outcome, reason) {
324
+ this.run++;
325
+ this.gateEl = null;
326
+ this.clearNav(true);
327
+ if (this.log && !this.log.outcome) {
328
+ this.log.outcome = outcome;
329
+ this.log.reason = reason;
330
+ this.log.endedAt = Date.now();
331
+ this.o.onTrip?.(this.log);
332
+ }
333
+ }
334
+ // ---------- page plumbing ----------
335
+ /** Waits for the page to react: a route change or DOM changes, then a short quiet period. */
336
+ settle(quietMs) {
337
+ const win = this.o.window;
338
+ const quiet = quietMs ?? this.o.settleMs;
339
+ return new Promise((resolve) => {
340
+ let t;
341
+ const done = () => { obs?.disconnect(); clearTimeout(cap); resolve(); };
342
+ const bump = () => { clearTimeout(t); t = setTimeout(done, quiet); };
343
+ const MO = win.MutationObserver;
344
+ const obs = MO ? new MO(bump) : null;
345
+ obs?.observe(win.document.documentElement, { subtree: true, childList: true, attributes: true });
346
+ const cap = setTimeout(done, Math.max(2500, quiet * 4));
347
+ bump();
348
+ });
349
+ }
350
+ linksHere(el) {
351
+ const href = el.getAttribute("href");
352
+ if (!href || href.startsWith("#") || this.o.routeFrom === "hash" || typeof this.o.routeFrom === "function")
353
+ return false;
354
+ try {
355
+ const loc = this.o.window.location;
356
+ const to = new URL(href, loc.href);
357
+ return to.origin === loc.origin && canonicalPath(to.pathname + to.search) === canonicalPath(loc.pathname + loc.search);
358
+ }
359
+ catch {
360
+ return false;
361
+ }
362
+ }
363
+ /** Full page loads end the script; a trip in flight is saved so the next page can carry on. */
364
+ markNav() {
365
+ try {
366
+ this.o.storage?.setItem(STORAGE_KEY, JSON.stringify({ log: this.log, plan: this.plan, stepCount: this.stepCount, at: Date.now(), origin: this.o.window.location.origin }));
367
+ }
368
+ catch { /* storage full or blocked: trips just won't survive reloads */ }
369
+ }
370
+ clearNav(always = false) {
371
+ if (always || this.state === "driving")
372
+ try {
373
+ this.o.storage?.removeItem(STORAGE_KEY);
374
+ }
375
+ catch { /* ignore */ }
376
+ }
377
+ restore() {
378
+ let saved = null;
379
+ try {
380
+ saved = JSON.parse(this.o.storage?.getItem(STORAGE_KEY) ?? "null");
381
+ }
382
+ catch {
383
+ saved = null;
384
+ }
385
+ if (!saved)
386
+ return;
387
+ try {
388
+ this.o.storage?.removeItem(STORAGE_KEY);
389
+ }
390
+ catch { /* ignore */ }
391
+ // Only a navigation the driver itself started, seconds ago, on this site, may continue.
392
+ if (Date.now() - saved.at > 15_000 || saved.origin !== this.o.window.location.origin || !saved.log)
393
+ return;
394
+ this.log = saved.log;
395
+ this.plan = saved.plan;
396
+ this.stepCount = saved.stepCount ?? 0;
397
+ setTimeout(() => this.drive(), 400);
398
+ }
399
+ listen() {
400
+ const win = this.o.window;
401
+ const takeover = (e) => {
402
+ if (this.state !== "driving" || !this.o.isTrusted(e))
403
+ return;
404
+ if (this.ui.owns(e.target))
405
+ return;
406
+ this.takeWheel();
407
+ };
408
+ for (const type of ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"]) {
409
+ win.addEventListener(type, takeover, { capture: true, passive: true });
410
+ this.detach.push(() => win.removeEventListener(type, takeover, { capture: true }));
411
+ }
412
+ const gate = (e) => this.onGateClick(e);
413
+ win.addEventListener("click", gate, { capture: true });
414
+ win.addEventListener("submit", gate, { capture: true }); // pressing Enter in a gated form
415
+ this.detach.push(() => { win.removeEventListener("click", gate, { capture: true }); win.removeEventListener("submit", gate, { capture: true }); });
416
+ }
417
+ destroy() {
418
+ this.run++;
419
+ this.detach.forEach((f) => f());
420
+ this.detach = [];
421
+ }
422
+ }
@@ -0,0 +1 @@
1
+ export declare function runtimeGateReason(el: Element): string | null;
@@ -0,0 +1,56 @@
1
+ import { PRICE, irreversibleReason, linkGateReason } from "../gates-text.js";
2
+ /**
3
+ * Gates found on the live page, on top of the manifest's flags. The manifest is a floor:
4
+ * a "Continue" button that charges a card is caught here even though its label looked harmless.
5
+ */
6
+ const CARD_FIELDS = [
7
+ 'input[autocomplete^="cc-"]',
8
+ 'input[name*="card" i]',
9
+ 'input[name*="cvc" i]',
10
+ 'input[name*="cvv" i]',
11
+ 'input[id*="card-number" i]',
12
+ "[data-stripe]",
13
+ 'iframe[src*="js.stripe.com"]',
14
+ 'iframe[name^="__privateStripeFrame"]',
15
+ 'iframe[src*="paypal.com"]',
16
+ 'iframe[src*="braintree"]',
17
+ 'iframe[src*="squareup.com"]',
18
+ 'iframe[src*="adyen"]',
19
+ 'iframe[title*="payment" i]',
20
+ ].join(",");
21
+ const visibleText = (el) => (el.textContent ?? "").replace(/\s+/g, " ").trim();
22
+ export function runtimeGateReason(el) {
23
+ // A form is judged by its submit button's words, not by every word inside it.
24
+ const face = el.tagName === "FORM"
25
+ ? el.querySelector('button:not([type="button"]):not([type="reset"]), input[type="submit"]') ?? el
26
+ : el;
27
+ const label = [
28
+ face.getAttribute("aria-label"),
29
+ face === el && el.tagName === "FORM" ? "" : visibleText(face),
30
+ face.getAttribute("value"),
31
+ face.getAttribute("title"),
32
+ ].filter(Boolean).join(" ").slice(0, 300);
33
+ const isLink = el.tagName === "A";
34
+ if (isLink)
35
+ return linkGateReason(label, el.getAttribute("href"));
36
+ // What the control says right now (runtime labels can differ from the source).
37
+ const byText = irreversibleReason([label]);
38
+ if (byText)
39
+ return byText;
40
+ if (PRICE.test(label))
41
+ return "payment";
42
+ // What the control submits.
43
+ const form = el.tagName === "FORM" ? el : el.closest("form");
44
+ const scope = form ?? null;
45
+ if (scope) {
46
+ if (scope.querySelector(CARD_FIELDS))
47
+ return "payment";
48
+ if (scope.querySelector('input[type="password"]'))
49
+ return "uses your password";
50
+ }
51
+ else if (el.ownerDocument?.querySelector(CARD_FIELDS)) {
52
+ // A button outside any form on a page that takes card details: assume it pays.
53
+ return "payment";
54
+ }
55
+ return null;
56
+ }
@@ -0,0 +1,43 @@
1
+ import type { Manifest } from "../types.ts";
2
+ import { Driver, type DriverOptions, type TripLog } from "./driver.ts";
3
+ import { RoutePlanner, type Planner } from "./planner.ts";
4
+ import { RemotePlanner } from "./remote-planner.ts";
5
+ import { OverlayUI } from "./ui.ts";
6
+ export { Driver, RoutePlanner, RemotePlanner, OverlayUI };
7
+ export type { Planner, TripLog };
8
+ export declare const HOSTED = "https://passengermode.com";
9
+ export interface MountOptions {
10
+ /**
11
+ * The site's public key from passengermode.com. With a key, the manifest, the on/off switch
12
+ * and planning all come from the hosted service, and trip outcomes go to the dashboard.
13
+ */
14
+ key?: string;
15
+ /** Hosted service origin (tests and self-hosting). */
16
+ apiBase?: string;
17
+ /** The manifest object, or a URL to fetch it from. Optional with a key. */
18
+ manifest?: Manifest | string;
19
+ /** Default: the site's planner endpoint if given, else the offline route planner. */
20
+ planner?: Planner;
21
+ /** POST endpoint that runs the Claude planner on your server (see passenger-mode/server). */
22
+ endpoint?: string;
23
+ window?: Window;
24
+ routeFrom?: DriverOptions["routeFrom"];
25
+ onTrip?: (log: TripLog) => void;
26
+ badge?: boolean;
27
+ /** Move the switch to clear the site's own corner buttons (px from the bottom-right). */
28
+ offset?: {
29
+ right?: number;
30
+ bottom?: number;
31
+ };
32
+ /**
33
+ * Off by default: inside a frame Passenger Mode does not appear, so another site can't
34
+ * frame yours and drive it. Only set this for your own previews.
35
+ */
36
+ allowInFrame?: boolean;
37
+ }
38
+ export interface Handle {
39
+ driver: Driver;
40
+ ui: OverlayUI;
41
+ destroy(): void;
42
+ }
43
+ export declare function mount(opts: MountOptions): Promise<Handle | null>;
@@ -0,0 +1,99 @@
1
+ import { Driver } from "./driver.js";
2
+ import { RoutePlanner } from "./planner.js";
3
+ import { RemotePlanner } from "./remote-planner.js";
4
+ import { OverlayUI } from "./ui.js";
5
+ export { Driver, RoutePlanner, RemotePlanner, OverlayUI };
6
+ export const HOSTED = "https://passengermode.com";
7
+ function framed(win) {
8
+ try {
9
+ return win.top !== win.self;
10
+ }
11
+ catch {
12
+ return true;
13
+ }
14
+ }
15
+ export async function mount(opts) {
16
+ const win = opts.window ?? window;
17
+ if (framed(win) && !opts.allowInFrame) {
18
+ console.info("Passenger Mode is off inside frames.");
19
+ return null;
20
+ }
21
+ const api = (opts.apiBase ?? HOSTED).replace(/\/$/, "");
22
+ const keyed = opts.key ? `key=${encodeURIComponent(opts.key)}` : "";
23
+ // Hosted: the owner's kill switch and the free-tier cap are checked before the switch appears.
24
+ if (opts.key) {
25
+ try {
26
+ const st = await (await win.fetch(`${api}/api/v1/status?${keyed}`, { credentials: "omit" })).json();
27
+ if (!st?.on) {
28
+ console.info(`Passenger Mode is off for this site${st?.reason ? `: ${st.reason}` : ""}.`);
29
+ return null;
30
+ }
31
+ }
32
+ catch {
33
+ return null; // can't confirm it's on: stay off
34
+ }
35
+ }
36
+ const source = opts.manifest ?? (opts.key ? `${api}/api/v1/manifest?${keyed}` : undefined);
37
+ if (!source)
38
+ throw new Error("Passenger Mode needs a manifest or a key.");
39
+ const manifest = typeof source === "string"
40
+ ? await (await win.fetch(source, { credentials: opts.key ? "omit" : "same-origin" })).json()
41
+ : source;
42
+ const planner = opts.planner
43
+ ?? (opts.key ? new RemotePlanner(`${api}/api/v1/plan`, undefined, { "x-passenger-key": opts.key })
44
+ : opts.endpoint ? new RemotePlanner(opts.endpoint) : new RoutePlanner());
45
+ const report = opts.key
46
+ ? (log) => {
47
+ try {
48
+ void win.fetch(`${api}/api/v1/trips`, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json", "x-passenger-key": opts.key },
51
+ body: JSON.stringify(log),
52
+ credentials: "omit",
53
+ keepalive: true,
54
+ }).catch(() => { });
55
+ }
56
+ catch { /* reporting never breaks a trip */ }
57
+ }
58
+ : null;
59
+ const onTrip = (log) => { report?.(log); opts.onTrip?.(log); };
60
+ let driver;
61
+ const ui = new OverlayUI(win, {
62
+ open: (e) => driver.open(e),
63
+ close: () => driver.close(),
64
+ plan: (dest, e) => { void driver.planTrip(dest, e); },
65
+ go: (e) => driver.go(e),
66
+ notThis: () => driver.notThis(),
67
+ takeWheel: () => driver.takeWheel(),
68
+ resume: (e) => driver.resume(e),
69
+ end: () => driver.end(),
70
+ rate: (up) => driver.rate(up),
71
+ }, { suggestions: manifest.destinations.map((d) => d.goal), badge: opts.badge ?? true, offset: opts.offset });
72
+ driver = new Driver({ manifest, planner, window: win, ui, routeFrom: opts.routeFrom, onTrip });
73
+ return { driver, ui, destroy() { driver.destroy(); ui.destroy(); } };
74
+ }
75
+ /**
76
+ * Script-tag install:
77
+ * <script src="https://passengermode.com/v1.js" data-key="pk_..."></script> (hosted)
78
+ * <script src="passenger-mode.js" data-manifest="/passenger.manifest.json" data-endpoint="/api/passenger-mode"></script> (self-hosted)
79
+ * Optional: data-route="hash", data-badge="false", data-right / data-bottom (px) to move the switch.
80
+ */
81
+ if (typeof document !== "undefined") {
82
+ const tag = document.currentScript;
83
+ if (tag?.dataset.manifest || tag?.dataset.key) {
84
+ const num = (v) => (v != null && v !== "" && !Number.isNaN(Number(v)) ? Number(v) : undefined);
85
+ const go = () => void mount({
86
+ key: tag.dataset.key,
87
+ apiBase: tag.dataset.api,
88
+ manifest: tag.dataset.manifest,
89
+ endpoint: tag.dataset.endpoint,
90
+ routeFrom: tag.dataset.route ?? "path",
91
+ badge: tag.dataset.badge !== "false",
92
+ offset: { right: num(tag.dataset.right), bottom: num(tag.dataset.bottom) },
93
+ });
94
+ if (document.readyState === "loading")
95
+ document.addEventListener("DOMContentLoaded", go);
96
+ else
97
+ go();
98
+ }
99
+ }
@@ -0,0 +1,61 @@
1
+ import type { ActionKind, Manifest } from "../types.ts";
2
+ /**
3
+ * What the planner is allowed to see. Deliberately NO free page text: only the destination
4
+ * (the passenger's words, the one instruction) and the manifest actions present on this page.
5
+ * Labels filled in at runtime can be user-written, so they arrive flagged as data.
6
+ */
7
+ export interface PageView {
8
+ destination: string;
9
+ route: string | null;
10
+ path: string;
11
+ visible: Array<{
12
+ id: string;
13
+ label: string;
14
+ kind: ActionKind;
15
+ irreversible: boolean;
16
+ runtimeLabel: boolean;
17
+ }>;
18
+ history: Array<{
19
+ actionId: string;
20
+ label: string;
21
+ route: string | null;
22
+ }>;
23
+ /** The plan the passenger approved with Go. The driver keeps to it. */
24
+ approvedPlan?: string[];
25
+ }
26
+ export type Step = {
27
+ kind: "act";
28
+ actionId: string;
29
+ caption?: string;
30
+ } | {
31
+ kind: "arrived";
32
+ summary: string;
33
+ } | {
34
+ kind: "stuck";
35
+ reason: string;
36
+ };
37
+ export interface TripPlan {
38
+ /** One line, e.g. "Cancel your Pro plan: 3 steps". */
39
+ summary: string;
40
+ /** Plain-language steps; the last one says if the passenger presses it. */
41
+ steps: string[];
42
+ }
43
+ export interface Planner {
44
+ plan(view: PageView, manifest: Manifest): Promise<TripPlan | null>;
45
+ next(view: PageView, manifest: Manifest): Promise<Step>;
46
+ }
47
+ /**
48
+ * Plans without a model: matches the destination to a manifest action, then walks the link graph
49
+ * from the current page to that action's page. Good for tests, demos and as a fallback when the
50
+ * model is unavailable. Production plans with Claude (see ../server/claude-planner.ts).
51
+ */
52
+ export declare class RoutePlanner implements Planner {
53
+ private target;
54
+ private pickTarget;
55
+ /** Whether an action can be on a page: its own page, the pages it was seen on, or anywhere. */
56
+ private static on;
57
+ /** Breadth-first over navigate actions: which links to take from `from` to a page where `target` is. */
58
+ private path;
59
+ plan(view: PageView, m: Manifest): Promise<TripPlan | null>;
60
+ next(view: PageView, m: Manifest): Promise<Step>;
61
+ }