@lightworkai.official/debug-capture 0.11.2 → 0.12.1

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,69 @@
1
+ /**
2
+ * The widget's URL policy, applied to the replay.
3
+ *
4
+ * rrweb records the page as it is, and a page is full of URLs: every link's
5
+ * href, every image's src, the address bar in the Meta event. The network
6
+ * log and the action trail already mask the query keys that carry secrets
7
+ * (`redactUrl` — tokens, signatures, one-time codes); a replay that kept
8
+ * them would undo that in the one file nobody reads by eye.
9
+ *
10
+ * Every URL-bearing attribute in the full snapshot and in every mutation is
11
+ * passed through the same function. Text is untouched: what the page SAYS
12
+ * is what a replay is for, and a host whose text is itself the secret has
13
+ * `maskText`.
14
+ */
15
+ import { redactUrl } from "./redact";
16
+ import type { ReplayEvent } from "../types";
17
+
18
+ const META = 4;
19
+ const FULL_SNAPSHOT = 2;
20
+ const INCREMENTAL = 3;
21
+ const MUTATION = 0;
22
+
23
+ const URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "xlink:href", "data", "poster", "srcset"]);
24
+
25
+ interface SerializedNode {
26
+ attributes?: Record<string, unknown>;
27
+ childNodes?: SerializedNode[];
28
+ }
29
+
30
+ function redactAttributes(attributes: Record<string, unknown> | undefined): void {
31
+ if (!attributes) return;
32
+ for (const key of Object.keys(attributes)) {
33
+ const value = attributes[key];
34
+ if (typeof value !== "string" || !URL_ATTRIBUTES.has(key) || !value.includes("?")) continue;
35
+ attributes[key] = redactUrl(value);
36
+ }
37
+ }
38
+
39
+ function walk(node: SerializedNode | undefined): void {
40
+ if (!node) return;
41
+ redactAttributes(node.attributes);
42
+ for (const child of node.childNodes ?? []) walk(child);
43
+ }
44
+
45
+ /**
46
+ * In place, on a copy: the recorder's buffers are live and a report may be
47
+ * filed again, so the events handed in are cloned before anything is masked.
48
+ */
49
+ export function redactReplay(events: readonly ReplayEvent[]): ReplayEvent[] {
50
+ // A JSON round trip rather than structuredClone: the events are JSON by
51
+ // construction, and this is the one clone every browser the widget runs in
52
+ // can make.
53
+ const out: ReplayEvent[] = JSON.parse(JSON.stringify(events)) as ReplayEvent[];
54
+ for (const event of out) {
55
+ const data = event.data as Record<string, unknown> | null;
56
+ if (!data || typeof data !== "object") continue;
57
+ if (event.type === META && typeof data.href === "string") {
58
+ data.href = redactUrl(data.href);
59
+ } else if (event.type === FULL_SNAPSHOT) {
60
+ walk(data.node as SerializedNode | undefined);
61
+ } else if (event.type === INCREMENTAL && data.source === MUTATION) {
62
+ for (const add of (data.adds as Array<{ node?: SerializedNode }> | undefined) ?? []) walk(add.node);
63
+ for (const change of (data.attributes as Array<{ attributes?: Record<string, unknown> }> | undefined) ?? []) {
64
+ redactAttributes(change.attributes);
65
+ }
66
+ }
67
+ }
68
+ return out;
69
+ }
package/src/config.ts CHANGED
@@ -114,6 +114,27 @@ export interface DebugCaptureConfig {
114
114
  capture?: CaptureOptions;
115
115
  redact?: RedactOptions;
116
116
 
117
+ /**
118
+ * Keep a replay of the last minute and attach it to a report.
119
+ *
120
+ * Off unless set. What is kept is the DOM, not pixels — rrweb records a
121
+ * snapshot of the page and every change to it after that, and the desk plays
122
+ * it back as the page the person was looking at, with their clicks, scrolls
123
+ * and typing (masked) on top. It is the difference between "I pressed save
124
+ * and it broke" and watching them press save and it break.
125
+ *
126
+ * It is also the screen, which is why it is opt-in and why the defaults hide
127
+ * everything they can: every input's value is masked before it is recorded,
128
+ * and anything under `blockSelector` is recorded as a grey box of the same
129
+ * size. Mark the parts of your app that must never leave the browser —
130
+ * document viewers, a citizen's record — with `data-lw-private`, and set
131
+ * `maskText` if the text on your screens is itself the secret.
132
+ *
133
+ * replay: true // 60 s, masked inputs
134
+ * replay: { seconds: 90, maskText: true }
135
+ */
136
+ replay?: boolean | ReplayOptions;
137
+
117
138
  /**
118
139
  * A DOM-rendering screenshot fallback, for browsers without the Screen
119
140
  * Capture API. Supplied by the host so this package never names a dependency
@@ -181,6 +202,25 @@ export interface CaptureOptions {
181
202
  overlaySelectors?: string[];
182
203
  }
183
204
 
205
+ export interface ReplayOptions {
206
+ /** How much history to send with a report. Default 60. */
207
+ seconds?: number;
208
+ /**
209
+ * Elements never recorded — drawn as a box of their size, contents and all
210
+ * changes inside them dropped. Default `[data-lw-private]`; the widget's own
211
+ * dialog and toasts are always excluded.
212
+ */
213
+ blockSelector?: string;
214
+ /** Mask every piece of text too, not only what is typed. Default false. */
215
+ maskText?: boolean;
216
+ /**
217
+ * The most a replay may weigh once compressed. A report still files when its
218
+ * replay is over this; the replay is left out and the desk is told why.
219
+ * Default 4 MB, under the host's 5 MB per-file cap.
220
+ */
221
+ maxBytes?: number;
222
+ }
223
+
184
224
  export interface RedactOptions {
185
225
  /** Extra header names to mask. Matched case-insensitively, as whole names. */
186
226
  headers?: string[];
@@ -301,6 +341,28 @@ export function overlaySelectors(): string[] {
301
341
  return current?.capture?.overlaySelectors ?? [];
302
342
  }
303
343
 
344
+ const REPLAY_DEFAULTS: Required<ReplayOptions> = {
345
+ seconds: 60,
346
+ blockSelector: "[data-lw-private]",
347
+ maskText: false,
348
+ maxBytes: 4_000_000,
349
+ };
350
+
351
+ /** The replay settings, filled in — or null when the host did not ask for one. */
352
+ export function replayOptions(): Required<ReplayOptions> | null {
353
+ const set = current?.replay;
354
+ if (!set) return null;
355
+ const given = set === true ? {} : set;
356
+ const seconds = typeof given.seconds === "number" && given.seconds > 0 ? given.seconds : REPLAY_DEFAULTS.seconds;
357
+ const maxBytes = typeof given.maxBytes === "number" && given.maxBytes > 0 ? given.maxBytes : REPLAY_DEFAULTS.maxBytes;
358
+ return {
359
+ seconds,
360
+ blockSelector: given.blockSelector?.trim() || REPLAY_DEFAULTS.blockSelector,
361
+ maskText: given.maskText === true,
362
+ maxBytes,
363
+ };
364
+ }
365
+
304
366
  /**
305
367
  * Whether a response body from this URL may be stored.
306
368
  *
package/src/embed.ts CHANGED
@@ -8,16 +8,31 @@
8
8
  * data-app-name="ระบบสารบรรณ"
9
9
  * data-app-version="1.4.2"
10
10
  * data-environment="production"
11
- * data-modules='{"/saraban":"งานสารบรรณ","/asset":"งานพัสดุ"}'></script>
11
+ * data-modules='{"/saraban":"งานสารบรรณ","/asset":"งานพัสดุ"}'
12
+ * data-replay="60"></script>
13
+ *
14
+ * `data-replay` records the last N seconds of the page for a report (see
15
+ * `replay` in config.ts). The recorder is `rrweb-record.mjs`, shipped beside
16
+ * this file and fetched only when asked for — keep the two together.
12
17
  */
13
18
  import { configureDebugCapture } from "./config";
14
19
  import { moduleResolver, type ModuleMap } from "./modules";
15
20
  import { installDebugCapture } from "./install";
16
21
  import { launch } from "./reporter";
22
+ import { setRecorderLoader } from "./capture/replay";
17
23
 
18
24
  const script = document.currentScript as HTMLScriptElement | null;
19
25
  const data = script?.dataset ?? {};
20
26
 
27
+ /*
28
+ * Beside this script, wherever the host put it. A bare `import("@rrweb/record")`
29
+ * means nothing to a browser without a bundler, and inlining the recorder
30
+ * here would nearly triple this file for every host, most of whom never turn
31
+ * the replay on.
32
+ */
33
+ const RECORDER_FILE = "rrweb-record.mjs";
34
+ setRecorderLoader(() => import(/* @vite-ignore */ new URL(RECORDER_FILE, script?.src ?? location.href).href));
35
+
21
36
  /**
22
37
  * Bad JSON becomes an empty map, not an exception.
23
38
  *
@@ -49,6 +64,7 @@ if (data.host && data.realmKey) {
49
64
  // A declared map, because a data- attribute carries text and not a function.
50
65
  module: moduleResolver(parseModules(data.modules)),
51
66
  locale: data.locale === "en" ? "en" : "th",
67
+ ...(data.replay !== undefined ? { replay: { seconds: Number(data.replay) || undefined } } : {}),
52
68
  });
53
69
  installDebugCapture();
54
70
  }
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  export { configureDebugCapture, isConfigured, getConfig, appInfo } from "./config";
13
13
  export { moduleResolver } from "./modules";
14
14
  export type { ModuleMap } from "./modules";
15
- export type { DebugCaptureConfig, CaptureOptions, RedactOptions, ToastNotice } from "./config";
15
+ export type { DebugCaptureConfig, CaptureOptions, RedactOptions, ReplayOptions, ToastNotice } from "./config";
16
16
 
17
17
  export { installDebugCapture } from "./install";
18
18
  export type { InstallOptions } from "./install";
@@ -29,6 +29,8 @@ export { captureScreen } from "./screenshot";
29
29
  export { getNetworkLog } from "./capture/networkBuffer";
30
30
  export { getConsoleLog } from "./capture/consoleBuffer";
31
31
  export { getActionTrail, actionCountSuffix } from "./capture/actionTrail";
32
+ export { takeReplay, isReplayRecording, stopReplay } from "./capture/replay";
33
+ export { packReplay, REPLAY_FILENAME } from "./replayPack";
32
34
  export { correlateActionTrail, isQuietStep, isFailedRequest, requestPath, formatDuration } from "./capture/stepCorrelation";
33
35
  export type { StepActivity } from "./capture/stepCorrelation";
34
36
  export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./capture/redact";
package/src/install.ts CHANGED
@@ -7,6 +7,7 @@ import { installActionTrail } from "./capture/actionTrail";
7
7
  import { installConsoleCapture } from "./capture/consoleBuffer";
8
8
  import { installCrashWatcher } from "./capture/crashWatcher";
9
9
  import { installNetworkCapture } from "./capture/networkBuffer";
10
+ import { installReplay } from "./capture/replay";
10
11
  import { getConfig, isConfigured } from "./config";
11
12
  import { launch, onCrash } from "./reporter";
12
13
  import { strings } from "./ui/strings";
@@ -34,6 +35,8 @@ export function installDebugCapture(options: InstallOptions = {}): void {
34
35
  installConsoleCapture();
35
36
  installActionTrail();
36
37
  installCrashWatcher();
38
+ // Only when asked for, and only once the page has settled — see the module.
39
+ installReplay();
37
40
 
38
41
  if (options.ui === false) return;
39
42
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A replay, made into the attachment that carries it.
3
+ *
4
+ * rrweb events compress about ten to one — a full snapshot is mostly
5
+ * repeated markup — so the browser gzips them before they travel, with the
6
+ * stream API every current browser has. Where it is missing the JSON goes as
7
+ * it is, if it fits; a replay is the one part of a report worth losing rather
8
+ * than failing the report over.
9
+ *
10
+ * Its own file, like the screenshot and for the same reason: `context` is a
11
+ * column on the ticket row, read by every query that returns the ticket, and
12
+ * the JSON bundle is opened by whoever reads the network log. A megabyte of
13
+ * DOM belongs in neither.
14
+ */
15
+ import { toBase64 } from "./base64";
16
+ import { redactReplay } from "./capture/replayRedact";
17
+ import type { ReplayCapture, ReplaySummary } from "./types";
18
+
19
+ /** The name the desk looks for. Changing it here changes it nowhere else. */
20
+ export const REPLAY_FILENAME = "session-replay.json.gz";
21
+ const PLAIN_FILENAME = "session-replay.json";
22
+
23
+ export interface PackedReplay {
24
+ dataUri: string;
25
+ filename: string;
26
+ summary: ReplaySummary;
27
+ }
28
+
29
+ /** Is there something to attach, before the work of packing it. */
30
+ export function hasReplayEvents(replay: ReplayCapture | null | undefined): replay is ReplayCapture {
31
+ return Boolean(replay && replay.events.length > 0);
32
+ }
33
+
34
+ async function gzip(bytes: Uint8Array): Promise<Uint8Array | null> {
35
+ if (typeof CompressionStream === "undefined") return null;
36
+ try {
37
+ const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new CompressionStream("gzip"));
38
+ return new Uint8Array(await new Response(stream).arrayBuffer());
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Pack, or say why not. Never throws: the report goes on without it.
46
+ *
47
+ * The cap is applied to what is SENT — compressed bytes when compression is
48
+ * available — because that is the number the server's per-file limit is
49
+ * measured against.
50
+ */
51
+ export async function packReplay(replay: ReplayCapture | null | undefined, maxBytes: number): Promise<PackedReplay | { summary: ReplaySummary }> {
52
+ if (!hasReplayEvents(replay)) return { summary: { omitted: "empty" } };
53
+ const seconds = Math.round(replay.durationMs / 1000);
54
+ const base = { seconds, events: replay.events.length, startedAt: replay.startedAt, endedAt: replay.endedAt };
55
+ // The same URL policy as the network log — see capture/replayRedact.
56
+ const plain = new TextEncoder().encode(JSON.stringify({ events: redactReplay(replay.events) }));
57
+ const zipped = await gzip(plain);
58
+ const bytes = zipped ?? plain;
59
+ if (bytes.byteLength > maxBytes) return { summary: { ...base, bytes: bytes.byteLength, omitted: "too-large" } };
60
+ const compressed = zipped !== null;
61
+ return {
62
+ dataUri: `data:${compressed ? "application/gzip" : "application/json"};base64,${toBase64(bytes)}`,
63
+ filename: compressed ? REPLAY_FILENAME : PLAIN_FILENAME,
64
+ summary: { ...base, bytes: bytes.byteLength, compressed },
65
+ };
66
+ }
package/src/styles.css CHANGED
@@ -147,7 +147,7 @@
147
147
  reasonable because the full size is one click away, and `zoom-in` says so
148
148
  before you try. Matches the app's own thread. */
149
149
  .lw-body img {
150
- margin: 4px 0; max-height: 160px; max-width: 100%; width: auto; height: auto;
150
+ margin: 4px 0; max-height: 128px; max-width: 100%; width: auto; height: auto;
151
151
  object-fit: contain; border: 1px solid var(--lw-border); border-radius: 8px;
152
152
  cursor: zoom-in;
153
153
  }
package/src/submit.ts CHANGED
@@ -7,10 +7,12 @@
7
7
  * cookie, and nothing here that a reader of the bundle could not already see.
8
8
  */
9
9
  import { compressScreenshot } from "./screenshot";
10
- import { appInfo, getConfig } from "./config";
10
+ import { appInfo, getConfig, replayOptions } from "./config";
11
11
  import { errorSignature } from "./signature";
12
12
  import { trimToBudget, BUNDLE_BUDGET_BYTES } from "./budget";
13
- import type { DebugBundle } from "./types";
13
+ import { toBase64 } from "./base64";
14
+ import { packReplay } from "./replayPack";
15
+ import type { DebugBundle, ReplaySummary } from "./types";
14
16
 
15
17
  export interface FiledTicket {
16
18
  id: string;
@@ -18,26 +20,7 @@ export interface FiledTicket {
18
20
  status: string;
19
21
  }
20
22
 
21
- /**
22
- * Base64 for a payload of any size.
23
- *
24
- * This was `btoa(String.fromCharCode(...bytes))`, which spreads every byte into
25
- * the argument list — and an argument list has a hard limit. It worked on the
26
- * tiny bundles a test produces and threw "Maximum call stack size exceeded" on
27
- * a real one, at the last possible moment: the user has written the report,
28
- * drawn on the screenshot, and pressed send.
29
- *
30
- * Chunked, so the argument count is bounded no matter how large the bundle is.
31
- */
32
- const CHUNK = 0x8000;
33
-
34
- export function toBase64(bytes: Uint8Array): string {
35
- let binary = "";
36
- for (let i = 0; i < bytes.length; i += CHUNK) {
37
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
38
- }
39
- return btoa(binary);
40
- }
23
+ export { toBase64 } from "./base64";
41
24
 
42
25
  /**
43
26
  * A data URI for the JSON bundle, so the bulk lands in object storage.
@@ -51,7 +34,9 @@ export function toBase64(bytes: Uint8Array): string {
51
34
  * (`budget.ts`), which is what made the duplication invisible.
52
35
  */
53
36
  export function bundleAttachment(bundle: DebugBundle): { dataUri: string; filename: string } {
54
- const { screenshotDataUrl: _sentSeparately, ...rest } = bundle;
37
+ // The replay too: it is the largest thing in a report by an order of
38
+ // magnitude, and it has its own file (replayPack.ts).
39
+ const { screenshotDataUrl: _sentSeparately, replay: _alsoSeparately, ...rest } = bundle;
55
40
  // btoa is latin1-only and a bundle is full of Thai. Encode to bytes first.
56
41
  const base64 = toBase64(new TextEncoder().encode(JSON.stringify(rest)));
57
42
  return { dataUri: `data:application/json;base64,${base64}`, filename: "debug-bundle.json" };
@@ -90,6 +75,20 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
90
75
  }
91
76
  attachments.push(bundleAttachment(trimmed));
92
77
 
78
+ /*
79
+ * The replay, when there is one, as the third file. Taken the way the
80
+ * network and console logs are — it is the support team's debug log, not
81
+ * a question put to the reporter — and when a recording host has none to
82
+ * send, the desk is told why rather than left to wonder.
83
+ */
84
+ let replay: ReplaySummary | undefined;
85
+ const recording = replayOptions();
86
+ if (recording) {
87
+ const packed = await packReplay(trimmed.replay, recording.maxBytes);
88
+ replay = packed.summary;
89
+ if ("dataUri" in packed) attachments.push({ dataUri: packed.dataUri, filename: packed.filename });
90
+ }
91
+
93
92
  /*
94
93
  * `context` is a jsonb column on the ticket row, read back by every query that
95
94
  * returns the ticket — so it gets the SUMMARY, not the bundle. The full thing
@@ -154,6 +153,7 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
154
153
  actions: trimmed.actionTrail.length,
155
154
  },
156
155
  screenshotMethod: trimmed.screenshotMethod,
156
+ ...(replay ? { replay } : {}),
157
157
  },
158
158
  attachments,
159
159
  };
package/src/types.ts CHANGED
@@ -154,6 +154,50 @@ export interface DebugContext {
154
154
 
155
155
  export type ScreenshotMethod = "display-media" | "html-to-image" | "none" | "cancelled";
156
156
 
157
+ /**
158
+ * One rrweb event. Typed by shape rather than imported from `@rrweb/types`,
159
+ * so a host that reads a bundle needs nothing of rrweb's to do it — the
160
+ * recorder is loaded on demand and its types stay its own.
161
+ */
162
+ export interface ReplayEvent {
163
+ type: number;
164
+ timestamp: number;
165
+ data: unknown;
166
+ }
167
+
168
+ /**
169
+ * The last stretch of the session, as the desk will play it back.
170
+ *
171
+ * Always begins with a full snapshot of the page: rrweb can only replay from
172
+ * one, so the window is cut at a checkpoint and never mid-stream. That is why
173
+ * `durationMs` is at least the configured length and can be up to half as
174
+ * much again.
175
+ */
176
+ export interface ReplayCapture {
177
+ events: ReplayEvent[];
178
+ startedAt: string;
179
+ endedAt: string;
180
+ durationMs: number;
181
+ }
182
+
183
+ /**
184
+ * What the ticket says about its replay, without the replay: the summary that
185
+ * travels in `context` and lets the desk label the play button before the
186
+ * attachment loads — or say why there is none.
187
+ */
188
+ export interface ReplaySummary {
189
+ /** Whole seconds covered, when one was attached. */
190
+ seconds?: number;
191
+ events?: number;
192
+ startedAt?: string;
193
+ endedAt?: string;
194
+ /** Size of the attachment as sent, and whether it is gzip. */
195
+ bytes?: number;
196
+ compressed?: boolean;
197
+ /** Present when the host records but this report carries no replay. */
198
+ omitted?: "too-large" | "empty";
199
+ }
200
+
157
201
  export interface DebugBundle {
158
202
  context: DebugContext;
159
203
  reason: DebugReason;
@@ -165,5 +209,11 @@ export interface DebugBundle {
165
209
  /** PNG data URL, or null if capture failed / was skipped. */
166
210
  screenshotDataUrl: string | null;
167
211
  screenshotMethod: ScreenshotMethod;
212
+ /**
213
+ * The session replay, when the host records one. Its own attachment on the
214
+ * way out, like the screenshot — never inside the JSON bundle. Absent (or
215
+ * null) when nothing was recorded.
216
+ */
217
+ replay?: ReplayCapture | null;
168
218
  meta: { generatedBy: string; schemaVersion: number };
169
219
  }
package/src/ui/element.ts CHANGED
@@ -183,9 +183,17 @@ function createReporterClass(): CustomElementConstructor {
183
183
  // environment became optional, and it was telling the person filing the
184
184
  // report something only the person reading it needs — both still travel
185
185
  // with the bundle.
186
+ //
187
+ // The replay counts as one more thing captured, listed with the rest.
188
+ // It is the support team's debug log, taken the way the network and
189
+ // console logs are — not a choice put to the reporter. There was a
190
+ // tick box here once; the platform owner's call was that a report
191
+ // should always carry what makes it reproducible.
186
192
  [
187
193
  t.captured,
188
- `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions`,
194
+ `${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
195
+ bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
196
+ }`,
189
197
  ],
190
198
  ] as [string, string][]) {
191
199
  const row = document.createElement("div");
package/src/ui/strings.ts CHANGED
@@ -38,6 +38,8 @@ export interface Strings {
38
38
  captured: string;
39
39
  reporter: string;
40
40
  page: string;
41
+ /** In the captured row: "… · replay 14s". */
42
+ replay: string;
41
43
  /** The hand-off dialog, shown before leaving for the support portal. */
42
44
  handoffTitle: string;
43
45
  handoffBody: string;
@@ -80,6 +82,7 @@ const th: Strings = {
80
82
  captured: "เก็บข้อมูลแล้ว",
81
83
  reporter: "ผู้แจ้ง",
82
84
  page: "หน้า",
85
+ replay: "บันทึกหน้าจอ",
83
86
  handoffTitle: "กำลังพาไปที่ระบบแจ้งปัญหา",
84
87
  handoffBody: "ระบบจะลงชื่อเข้าใช้ให้อัตโนมัติ เพื่อดูเรื่องที่คุณแจ้งไว้",
85
88
  handoffGo: "ไปเลย",
@@ -121,6 +124,7 @@ const en: Strings = {
121
124
  captured: "Captured",
122
125
  reporter: "Reporter",
123
126
  page: "Page",
127
+ replay: "replay",
124
128
  handoffTitle: "Opening the support portal",
125
129
  handoffBody: "You will be signed in automatically to see what you reported",
126
130
  handoffGo: "Go now",