@nordbyte/nordrelay 0.4.0 → 0.4.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.
package/README.md CHANGED
@@ -46,6 +46,7 @@ Adapter architecture:
46
46
  - `/channels` shows available and planned messaging adapters for Discord, WhatsApp, Slack, and Matrix.
47
47
  - Codex, Pi, Hermes, OpenClaw, and Claude Code are implemented as agent adapters.
48
48
  - `/agents` shows available/planned agent adapters and whether Codex, Pi, Hermes, OpenClaw, and Claude Code are enabled.
49
+ - Shared command-action renderers keep channel-neutral responses for adapter lists, queues, artifacts, logs, and update jobs separate from Telegram-specific keyboards and delivery.
49
50
 
50
51
  Codex runtime:
51
52
 
@@ -178,15 +179,16 @@ Operations:
178
179
 
179
180
  - Plugin command/skill starts, stops, restarts, and inspects the connector process.
180
181
  - Manual process commands support `start`, `stop`, `restart`, `status`, and `foreground`.
181
- - Telegram admin commands support `/logs`, `/diagnostics`, `/restart`, and `/update`.
182
+ - Telegram admin commands support `/logs`, `/diagnostics`, `/restart`, and `/update` for NordRelay and agent CLIs.
182
183
  - `/update` detects the install type: npm installs update with `npm install -g @nordbyte/nordrelay@latest`; source checkouts pull `origin/main`, install dependencies, run check, tests, and build, then restart.
183
- - `/logs` renders redacted connector and update logs with local-time timestamps, levels, file path, last-modified time, and highlighted warnings/errors.
184
+ - `/update agents`, `/update <agent>`, `/update jobs`, `/update log <id>`, `/update cancel <id>`, and `/update input <id> <text>` manage Codex, Pi, Hermes, OpenClaw, and Claude Code updater jobs from Telegram.
185
+ - `/logs` renders redacted connector, NordRelay update, and agent update logs with local-time timestamps, levels, file path, last-modified time, and highlighted warnings/errors.
184
186
  - Logs can be emitted as timestamped plain text or JSON records with `CONNECTOR_LOG_FORMAT`.
185
187
  - Telegram sends/edits/documents are routed through a rate-limit queue that honors Telegram retry-after responses.
186
188
  - Context metadata, queues, and preferences are written atomically with backup recovery.
187
189
  - Context metadata, queues, preferences, audit events, and locks can use JSON files or the optional SQLite state backend with `NORDRELAY_STATE_BACKEND=sqlite`.
188
190
  - Runtime config, state, and logs are written under `~/.nordrelay/`.
189
- - `nordrelay init` creates a private runtime config, `nordrelay doctor` validates host prerequisites, and `nordrelay web` starts a full local WebUI dashboard.
191
+ - `nordrelay init` creates a private runtime config, `nordrelay doctor` validates host prerequisites, and `nordrelay web` starts the connector plus a full local WebUI dashboard.
190
192
  - The WebUI has responsive header/sidebar/footer navigation, live chat streaming, session controls, queue/artifact/log/diagnostic views, and settings management.
191
193
  - The WebUI supports light and dark themes, tabbed settings groups, paginated session browsing, and chat uploads for images, documents, and audio transcription.
192
194
  - The WebUI exposes REST and SSE endpoints for chat streaming, sessions, settings, queue, artifacts, logs, health, and diagnostics.
@@ -270,6 +272,7 @@ Codex authentication:
270
272
  Pi setup:
271
273
 
272
274
  - Install Pi from https://pi.dev/ and confirm `pi --help` works on the host.
275
+ - npm installs should use the current package name: `npm install -g @earendil-works/pi-coding-agent`.
273
276
  - Set `NORDRELAY_PI_ENABLED=true` in `~/.nordrelay/nordrelay.env`.
274
277
  - Keep `NORDRELAY_DEFAULT_AGENT=codex` to start chats in Codex, or set `NORDRELAY_DEFAULT_AGENT=pi` to start chats in Pi.
275
278
  - Optional: set `PI_SESSION_DIR` if your Pi sessions are not stored in `~/.pi/agent/sessions/`.
@@ -374,6 +377,7 @@ Runtime files:
374
377
  - Log file: `~/.nordrelay/nordrelay.log`
375
378
  - Home override: `NORDRELAY_HOME=/custom/path`
376
379
  - Local dashboard: `nordrelay web --host 127.0.0.1 --port 31878`
380
+ - `nordrelay start` and `nordrelay status` print the configured WebUI URL.
377
381
 
378
382
  ## WebUI Dashboard
379
383
 
@@ -383,6 +387,8 @@ Start the local WebUI:
383
387
  nordrelay web
384
388
  ```
385
389
 
390
+ If the connector is not already running, `nordrelay web` starts it automatically before binding the dashboard.
391
+
386
392
  Open:
387
393
 
388
394
  ```text
@@ -403,7 +409,10 @@ The dashboard is a second NordRelay client next to Telegram. It can:
403
409
  - Browse, preview, download, ZIP, and delete artifacts.
404
410
  - Inspect the activity timeline for WebUI and mirrored CLI turns.
405
411
  - Edit all supported runtime settings from tabbed Settings groups with option selects, validation feedback, and restart actions.
406
- - View filtered logs, structured diagnostics, enabled channels, and agent adapters.
412
+ - View filtered connector/update/agent-update logs, structured diagnostics, enabled channels, and agent adapters.
413
+ - Inspect a per-agent capability matrix showing model, reasoning, launch, fast mode, attachments, activity, usage, auth, login/logout, and handback support.
414
+ - Check NordRelay and agent CLI versions, then start Codex, Pi, Hermes, OpenClaw, or Claude Code updates from outdated version rows with live output, cancel, full-log, and stdin response controls for interactive updaters.
415
+ - Load dashboard CSS and client JavaScript as authenticated static assets instead of inline HTML, keeping the server shell, style, and browser client modules separate.
407
416
 
408
417
  Dashboard API endpoints are served under `/api/*`. Streaming uses `GET /api/events`.
409
418
 
@@ -492,10 +501,12 @@ Run NordRelay behind your reverse proxy so the public URL forwards to `http://12
492
501
  - `/version` reports connector, Codex CLI, Pi CLI, Hermes CLI, OpenClaw CLI, and Claude Code CLI paths plus installed/latest NordRelay, Codex, Pi, Hermes, OpenClaw, and Claude Code versions with status icons.
493
502
  - `/logs [lines]` shows a redacted, timestamped connector log tail. Admin only.
494
503
  - `/logs update [lines]` shows the self-update log. Admin only.
495
- - `/logs all [lines]` shows connector and self-update logs together. Admin only.
504
+ - `/logs agent [lines]` shows the aggregate agent updater log. Admin only.
505
+ - `/logs all [lines]` shows connector, self-update, and agent update logs together. Admin only.
496
506
  - `/diagnostics` shows redacted connector diagnostics. Admin only.
497
507
  - `/restart` restarts the connector process. Admin only.
498
508
  - `/update` updates through npm or git depending on the detected install type, then restarts only on success. Admin only.
509
+ - `/update agents`, `/update <agent>`, `/update jobs`, `/update log <id>`, `/update cancel <id>`, and `/update input <id> <text>` manage agent CLI update jobs. Admin only.
499
510
 
500
511
  ## Command Examples
501
512
 
@@ -820,6 +831,7 @@ NordRelay wrapper:
820
831
  - `NORDRELAY_HOME`: config/state/log directory override. Defaults to `~/.nordrelay`.
821
832
  - `NORDRELAY_SOURCE_ROOT`: runtime source root override. Useful when the plugin is launched from Codex cache.
822
833
  - `NORDRELAY_UPDATE_METHOD`: optional `auto`, `npm`, or `git` self-update method override. Auto uses git when the runtime root has a `.git` directory and npm otherwise.
834
+ - Agent updates from the dashboard and Telegram use each agent's native updater where possible: `codex update`, `pi update pi`, `hermes update --yes`, `openclaw update --yes`, and `claude update`.
823
835
  - `NORDRELAY_KEEP_PENDING_UPDATES`: set true to avoid dropping pending Telegram updates on start.
824
836
  - `NORDRELAY_FORWARD_TOOL_OUTPUT`: backward-compatible alias that sets `TOOL_VERBOSITY=all` when `TOOL_VERBOSITY` is unset.
825
837
  - `NORDRELAY_STATE_FILE`: internal state-file path passed by the wrapper.
@@ -114,6 +114,9 @@ export function permissionForCallbackData(callbackData) {
114
114
  if (/^(launch_|launchconfirm_|model_|effort_|agent_)/.test(callbackData)) {
115
115
  return "settings";
116
116
  }
117
+ if (callbackData.startsWith("upd_")) {
118
+ return "admin";
119
+ }
117
120
  if (callbackData.startsWith("approval_") || callbackData.startsWith("codex_abort:") || callbackData.startsWith("agent_abort:")) {
118
121
  return "prompt";
119
122
  }
@@ -0,0 +1,42 @@
1
+ import { escapeHTML } from "./format.js";
2
+ export const AGENT_FEATURES = [
3
+ { key: "modelSelection", label: "Model", description: "Pick the model used for new turns or sessions." },
4
+ { key: "reasoningSelection", label: "Reasoning", description: "Pick thinking/reasoning effort where the agent exposes it." },
5
+ { key: "launchProfiles", label: "Launch profiles", description: "Switch sandbox/approval launch behavior for new sessions." },
6
+ { key: "fastMode", label: "Fast mode", description: "Toggle the agent-specific low-latency mode." },
7
+ { key: "workspaces", label: "Workspaces", description: "List and switch allowed workspaces." },
8
+ { key: "attachments", label: "Files/images", description: "Send files, photos, staged attachments, and voice transcripts." },
9
+ { key: "externalActivity", label: "External busy", description: "Detect active CLI turns started outside NordRelay." },
10
+ { key: "cliMirror", label: "CLI mirror", description: "Mirror CLI-started turns back to Telegram/WebUI." },
11
+ { key: "activityLog", label: "Activity", description: "Read activity timelines for sessions and turns." },
12
+ { key: "usageStats", label: "Usage stats", description: "Show token/context usage reported by the agent." },
13
+ { key: "subscriptionLimits", label: "Limits", description: "Show subscription/quota limits when the agent exposes them." },
14
+ { key: "auth", label: "Auth status", description: "Check whether the agent is authenticated." },
15
+ { key: "login", label: "Login", description: "Start an agent login flow from NordRelay." },
16
+ { key: "logout", label: "Logout", description: "Sign out of the agent from NordRelay." },
17
+ { key: "handback", label: "Handback", description: "Return a remote session to the native CLI." },
18
+ ];
19
+ export function agentFeatureStates(capabilities) {
20
+ return AGENT_FEATURES.map((feature) => ({
21
+ ...feature,
22
+ supported: Boolean(capabilities[feature.key]),
23
+ }));
24
+ }
25
+ export function formatAgentFeatureSummaryPlain(capabilities) {
26
+ const states = agentFeatureStates(capabilities);
27
+ const supported = states.filter((feature) => feature.supported).map((feature) => feature.label);
28
+ const unsupported = states.filter((feature) => !feature.supported).map((feature) => feature.label);
29
+ return [
30
+ `Supported: ${supported.join(", ") || "-"}`,
31
+ `Not supported: ${unsupported.join(", ") || "-"}`,
32
+ ];
33
+ }
34
+ export function formatAgentFeatureSummaryHTML(capabilities) {
35
+ const states = agentFeatureStates(capabilities);
36
+ const supported = states.filter((feature) => feature.supported).map((feature) => feature.label);
37
+ const unsupported = states.filter((feature) => !feature.supported).map((feature) => feature.label);
38
+ return [
39
+ `<b>Supported:</b> ${escapeHTML(supported.join(", ") || "-")}`,
40
+ `<b>Not supported:</b> ${escapeHTML(unsupported.join(", ") || "-")}`,
41
+ ];
42
+ }
@@ -0,0 +1,294 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { agentLabel } from "./agent.js";
6
+ import { resolveClaudeCodeCli } from "./claude-code-cli.js";
7
+ import { resolveCodexCli } from "./codex-cli.js";
8
+ import { resolveHermesCli } from "./hermes-cli.js";
9
+ import { resolveOpenClawCli } from "./openclaw-cli.js";
10
+ import { getAgentUpdateLogPath, getConnectorHome } from "./operations.js";
11
+ import { resolvePiCli } from "./pi-cli.js";
12
+ import { redactText } from "./redaction.js";
13
+ export class AgentUpdateManager {
14
+ options;
15
+ jobs = new Map();
16
+ home;
17
+ manifestPath;
18
+ aggregateLogPath;
19
+ constructor(options = {}) {
20
+ this.options = options;
21
+ this.home = options.home ?? getConnectorHome();
22
+ this.manifestPath = path.join(this.home, "updates", "jobs.json");
23
+ this.aggregateLogPath = getAgentUpdateLogPath(this.home);
24
+ this.loadPersistedJobs();
25
+ }
26
+ list() {
27
+ return [...this.jobs.values()]
28
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt))
29
+ .map((job) => this.snapshot(job));
30
+ }
31
+ get(id) {
32
+ const job = this.jobs.get(id);
33
+ return job ? this.snapshot(job) : null;
34
+ }
35
+ readLog(id) {
36
+ const job = this.requireJob(id);
37
+ try {
38
+ return { job: this.snapshot(job), plain: redactText(readFileSync(job.logPath, "utf8")) };
39
+ }
40
+ catch (error) {
41
+ return {
42
+ job: this.snapshot(job),
43
+ plain: `Cannot read update log: ${error instanceof Error ? error.message : String(error)}`,
44
+ };
45
+ }
46
+ }
47
+ start(agentId, context = {}) {
48
+ const running = [...this.jobs.values()].find((job) => job.agentId === agentId && job.status === "running");
49
+ if (running) {
50
+ throw new Error(`${agentLabel(agentId)} update is already running.`);
51
+ }
52
+ const plan = resolveAgentUpdatePlan(agentId, { ...context, env: context.env ?? this.options.env });
53
+ const now = new Date().toISOString();
54
+ const id = `${agentId.replace(/[^a-z0-9]/gi, "")}-${Date.now().toString(36)}`;
55
+ const logPath = path.join(this.home, "updates", `${id}.log`);
56
+ mkdirSync(path.dirname(logPath), { recursive: true });
57
+ const job = {
58
+ id,
59
+ agentId,
60
+ agentLabel: plan.agentLabel,
61
+ status: "running",
62
+ method: plan.method,
63
+ command: plan.command,
64
+ args: plan.args,
65
+ cwd: plan.cwd,
66
+ summary: plan.summary,
67
+ interactive: plan.interactive,
68
+ canInput: true,
69
+ needsInput: false,
70
+ startedAt: now,
71
+ updatedAt: now,
72
+ logPath,
73
+ ownerPid: process.pid,
74
+ output: "",
75
+ outputTail: "",
76
+ };
77
+ this.jobs.set(id, job);
78
+ this.persistJobs();
79
+ this.append(job, [
80
+ `[${now}] Starting ${job.agentLabel} update`,
81
+ `Method: ${job.method}`,
82
+ `Command: ${[job.command, ...job.args].join(" ")}`,
83
+ `Working directory: ${job.cwd}`,
84
+ "",
85
+ ].join("\n"));
86
+ const child = spawn(plan.command, plan.args, {
87
+ cwd: plan.cwd,
88
+ env: { ...process.env, ...(this.options.env ?? {}), ...(context.env ?? {}) },
89
+ shell: process.platform === "win32",
90
+ windowsHide: true,
91
+ stdio: "pipe",
92
+ });
93
+ job.child = child;
94
+ child.stdin.setDefaultEncoding("utf8");
95
+ child.stdout.on("data", (chunk) => this.append(job, chunk.toString("utf8")));
96
+ child.stderr.on("data", (chunk) => this.append(job, chunk.toString("utf8")));
97
+ child.on("error", (error) => {
98
+ this.finish(job, "failed", null, null, error.message);
99
+ });
100
+ child.on("close", (code, signal) => {
101
+ if (job.status !== "running") {
102
+ return;
103
+ }
104
+ this.finish(job, code === 0 ? "completed" : "failed", code, signal, code === 0 ? undefined : `Update exited with code ${code ?? "unknown"}`);
105
+ });
106
+ this.emit(job);
107
+ return this.snapshot(job);
108
+ }
109
+ sendInput(id, input) {
110
+ const job = this.requireJob(id);
111
+ if (job.status !== "running" || !job.child?.stdin.writable) {
112
+ throw new Error("Update job is not accepting input.");
113
+ }
114
+ const line = input.endsWith("\n") ? input : `${input}\n`;
115
+ job.child.stdin.write(line);
116
+ job.needsInput = false;
117
+ this.append(job, `[${new Date().toISOString()}] Input sent from dashboard.\n`);
118
+ return this.snapshot(job);
119
+ }
120
+ cancel(id) {
121
+ const job = this.requireJob(id);
122
+ if (job.status !== "running") {
123
+ return this.snapshot(job);
124
+ }
125
+ job.child?.kill("SIGTERM");
126
+ this.finish(job, "cancelled", null, "SIGTERM", "Cancelled from dashboard.");
127
+ return this.snapshot(job);
128
+ }
129
+ cancelAll() {
130
+ for (const job of this.jobs.values()) {
131
+ if (job.status === "running") {
132
+ job.child?.kill("SIGTERM");
133
+ }
134
+ }
135
+ }
136
+ requireJob(id) {
137
+ const job = this.jobs.get(id);
138
+ if (!job) {
139
+ throw new Error(`Unknown update job: ${id}`);
140
+ }
141
+ return job;
142
+ }
143
+ append(job, text) {
144
+ const redacted = redactText(text);
145
+ job.output += redacted;
146
+ if (job.output.length > 120_000) {
147
+ job.output = job.output.slice(-120_000);
148
+ }
149
+ job.outputTail = job.output.slice(-16_000);
150
+ job.updatedAt = new Date().toISOString();
151
+ job.needsInput = job.status === "running" && looksLikePrompt(job.outputTail);
152
+ writeFileSync(job.logPath, redacted, { flag: "a", encoding: "utf8" });
153
+ this.appendAggregate(job, redacted);
154
+ this.persistJobs();
155
+ this.emit(job);
156
+ }
157
+ finish(job, status, code, signal, error) {
158
+ job.status = status;
159
+ job.canInput = false;
160
+ job.needsInput = false;
161
+ job.exitCode = code;
162
+ job.signal = signal;
163
+ job.error = error ? redactText(error) : undefined;
164
+ job.finishedAt = new Date().toISOString();
165
+ job.updatedAt = job.finishedAt;
166
+ job.child = undefined;
167
+ this.append(job, `\n[${job.finishedAt}] ${job.agentLabel} update ${status}${error ? `: ${job.error}` : ""}\n`);
168
+ }
169
+ emit(job) {
170
+ this.options.onUpdate?.(this.snapshot(job));
171
+ }
172
+ snapshot(job) {
173
+ const { child: _child, output: _output, ...snapshot } = job;
174
+ return { ...snapshot };
175
+ }
176
+ loadPersistedJobs() {
177
+ if (!existsSync(this.manifestPath)) {
178
+ return;
179
+ }
180
+ try {
181
+ const parsed = JSON.parse(readFileSync(this.manifestPath, "utf8"));
182
+ let changed = false;
183
+ for (const snapshot of parsed) {
184
+ const staleRunning = snapshot.status === "running" && !isProcessRunning(snapshot.ownerPid);
185
+ if (staleRunning) {
186
+ changed = true;
187
+ }
188
+ const job = {
189
+ ...snapshot,
190
+ status: staleRunning ? "failed" : snapshot.status,
191
+ canInput: false,
192
+ needsInput: false,
193
+ error: staleRunning
194
+ ? "Update process was still running when NordRelay restarted; inspect the agent update log before retrying."
195
+ : snapshot.error,
196
+ finishedAt: staleRunning ? new Date().toISOString() : snapshot.finishedAt,
197
+ updatedAt: staleRunning ? new Date().toISOString() : snapshot.updatedAt,
198
+ output: snapshot.outputTail ?? "",
199
+ outputTail: snapshot.outputTail ?? "",
200
+ };
201
+ this.jobs.set(job.id, job);
202
+ }
203
+ if (changed) {
204
+ this.persistJobs();
205
+ }
206
+ }
207
+ catch {
208
+ this.jobs.clear();
209
+ }
210
+ }
211
+ persistJobs() {
212
+ mkdirSync(path.dirname(this.manifestPath), { recursive: true });
213
+ const snapshots = this.list().slice(0, 100);
214
+ writeFileSync(this.manifestPath, `${JSON.stringify(snapshots, null, 2)}\n`, "utf8");
215
+ }
216
+ appendAggregate(job, text) {
217
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
218
+ if (lines.length === 0) {
219
+ return;
220
+ }
221
+ mkdirSync(path.dirname(this.aggregateLogPath), { recursive: true });
222
+ const now = new Date().toISOString();
223
+ const prefix = `[${now}] INFO [${job.id}]`;
224
+ appendFileSync(this.aggregateLogPath, `${lines.map((line) => `${prefix} ${line}`).join("\n")}\n`, "utf8");
225
+ }
226
+ }
227
+ function isProcessRunning(pid) {
228
+ if (!Number.isInteger(pid) || pid <= 0) {
229
+ return false;
230
+ }
231
+ try {
232
+ process.kill(pid, 0);
233
+ return true;
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ }
239
+ export function resolveAgentUpdatePlan(agentId, context = {}) {
240
+ const env = context.env ?? process.env;
241
+ switch (agentId) {
242
+ case "codex": {
243
+ const cli = resolveCodexCli(env);
244
+ if (!cli.path) {
245
+ throw new Error("Codex CLI is not installed on PATH. Install or update it with npm i -g @openai/codex@latest.");
246
+ }
247
+ return plan(agentId, "codex update", cli.path, ["update"], "Runs the Codex CLI self-updater. If this install cannot self-update, use npm i -g @openai/codex@latest.", env);
248
+ }
249
+ case "pi": {
250
+ const cli = resolvePiCli(env, context.piCliPath);
251
+ if (!cli.path) {
252
+ throw new Error("Pi CLI is not installed on PATH. Install or update it with npm install -g @earendil-works/pi-coding-agent@latest.");
253
+ }
254
+ return plan(agentId, "pi update pi", cli.path, ["update", "pi"], "Updates only the Pi coding agent, not installed Pi extensions.", env);
255
+ }
256
+ case "hermes": {
257
+ const cli = resolveHermesCli(env, context.hermesCliPath);
258
+ if (!cli.path) {
259
+ throw new Error("Hermes CLI is not installed on PATH. Install Hermes with the official installer before updating.");
260
+ }
261
+ return plan(agentId, "hermes update --yes", cli.path, ["update", "--yes"], "Runs the Hermes git/dependency updater with confirmation prompts accepted where supported.", env);
262
+ }
263
+ case "openclaw": {
264
+ const cli = resolveOpenClawCli(env, context.openClawCliPath);
265
+ if (!cli.path) {
266
+ throw new Error("OpenClaw CLI is not installed on PATH. Install OpenClaw before updating.");
267
+ }
268
+ return plan(agentId, "openclaw update --yes", cli.path, ["update", "--yes"], "Runs the OpenClaw updater, which detects npm/git installs and may restart the Gateway.", env);
269
+ }
270
+ case "claude-code": {
271
+ const cli = resolveClaudeCodeCli(env, context.claudeCodeCliPath);
272
+ if (!cli.path) {
273
+ throw new Error("Claude Code host CLI is not installed on PATH. Bundled SDK updates arrive with NordRelay releases.");
274
+ }
275
+ return plan(agentId, "claude update", cli.path, ["update"], "Runs the Claude Code updater. Some package-manager installs may print a manual command instead.", env);
276
+ }
277
+ }
278
+ }
279
+ function plan(agentId, method, command, args, summary, env) {
280
+ return {
281
+ agentId,
282
+ agentLabel: agentLabel(agentId),
283
+ method,
284
+ command,
285
+ args,
286
+ cwd: env.HOME || os.homedir(),
287
+ summary,
288
+ interactive: true,
289
+ };
290
+ }
291
+ function looksLikePrompt(text) {
292
+ const tail = text.split(/\r?\n/).slice(-4).join("\n");
293
+ return /\b(y\/n|yes\/no|continue|proceed|confirm|password|passphrase|token|api key|enter|select)\b|[?>]\s*$/i.test(tail);
294
+ }