newmark-agent 0.4.4 → 0.4.6

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.
@@ -49,6 +49,7 @@ const terminalTakeover_1 = require("./terminalTakeover");
49
49
  const computerUse_1 = require("./computerUse");
50
50
  const nativeTools_1 = require("./nativeTools");
51
51
  const ssh_1 = require("../core/ssh");
52
+ const workspace_1 = require("../core/workspace");
52
53
  const wslHostToolBridge_1 = require("../core/wslHostToolBridge");
53
54
  const utilityHostToolBridge_1 = require("../core/utilityHostToolBridge");
54
55
  const toolPolicy_1 = require("../core/toolPolicy");
@@ -61,6 +62,41 @@ const computerUseSession_1 = require("../core/computerUseSession");
61
62
  function normalizeComputerUseAction(action) {
62
63
  return String(action || '').trim().toLowerCase();
63
64
  }
65
+ /**
66
+ * 统一跨环境路径归一。WSL 运行时里,Agent 可能用 Windows 盘符路径(`C:\...` /
67
+ * `C:/...`)引用 Windows 工作区;Linux 的 `path.isAbsolute` 不认识盘符,会把
68
+ * 它误当相对路径拼到 `/mnt/...` 工作区下。这里先把盘符路径转换为 `/mnt/<drive>/...`,
69
+ * 再做常规的绝对/相对判断。非 WSL 运行时行为与旧的 `resolve` 完全一致。
70
+ */
71
+ function normalizeCrossEnvPath(value, wsPath) {
72
+ const raw = String(value || '').trim();
73
+ if (!raw)
74
+ return wsPath;
75
+ if (process.env.NEWMARK_WSL_DISTRO) {
76
+ const posix = (0, workspace_1.windowsDrivePathToPosix)(raw);
77
+ if (posix)
78
+ return posix;
79
+ }
80
+ if (path.isAbsolute(raw))
81
+ return raw;
82
+ return path.join(wsPath, raw);
83
+ }
84
+ /**
85
+ * WSL 运行时:把 bash 命令里出现的 Windows 盘符路径保守翻译为 `/mnt/<drive>/...`。
86
+ * 仅替换「以盘符开头、后跟路径字符(不含空白、引号、反引号与 shell 元字符)」的
87
+ * token,避免误伤 `C:` 标签、环境变量与字符串字面量。非 WSL 原样返回。
88
+ */
89
+ function translateWindowsPathsForWslBash(script) {
90
+ if (!process.env.NEWMARK_WSL_DISTRO)
91
+ return script;
92
+ const value = String(script || '');
93
+ if (!/[A-Za-z]:[\\/]/.test(value))
94
+ return value;
95
+ return value.replace(/(?<![A-Za-z0-9_])([A-Za-z]):[\\/]([^\s"'`;|&<>()]+)/g, (_match, drive, rest) => {
96
+ const posix = rest.replace(/\\/g, '/').replace(/\/+$/, '');
97
+ return `/mnt/${drive.toLowerCase()}/${posix}`;
98
+ });
99
+ }
64
100
  function computerUseOwner(context, wsPath) {
65
101
  const conversationId = String(context.conversationId || '').trim();
66
102
  if (conversationId)
@@ -422,12 +458,15 @@ class ToolExecutor {
422
458
  include_remote: { type: 'boolean' },
423
459
  base_ref: { type: 'string' },
424
460
  }, []),
425
- t('repo_security_audit', 'Review a local or remote-backed repository for release/privacy risk before remote actions. Reports GitHub/private/public state, dirty files, ignored local-only files, likely secret material, release-excluded paths, and recommended next checks. Read-only.', {
461
+ t('repo_security_audit', 'Review a local or remote-backed repository for release/privacy risk before remote actions. Reports GitHub/private/public state, dirty files, ignored local-only files, likely secret material, privacy addresses (credential URLs, private network addresses, local user paths), release-excluded paths, and recommended next checks. Read-only.', {
426
462
  path: { type: 'string' },
427
463
  base_ref: { type: 'string' },
428
464
  }, []),
429
465
  t('git_pull', 'Pull from remote', {}, []),
430
- t('git_push', 'Stage, commit, push', { message: { type: 'string' } }, ['message']),
466
+ t('git_push', 'Stage, commit, and push changes. Before a remote push, an automatic repository security review runs: if it finds high-risk content (personal keys/tokens or privacy addresses such as credential URLs, private network addresses, or local user paths), the push is BLOCKED. Complete a second review of every reported finding (remove/ignore it or confirm it is safe), then call git_push again with security_review_confirmed=true to proceed.', {
467
+ message: { type: 'string' },
468
+ security_review_confirmed: { type: 'boolean', description: 'Set true only after a second review resolved or explicitly confirmed every reported high-risk finding.' },
469
+ }, ['message']),
431
470
  t('git_clone', 'Clone a git repo', { url: { type: 'string' }, path: { type: 'string' } }, ['url', 'path']),
432
471
  t('git_branch', 'Inspect or manage local git branches. Actions: current, list, create, switch.', {
433
472
  action: { type: 'string', enum: ['current', 'list', 'create', 'switch'] },
@@ -445,12 +484,13 @@ class ToolExecutor {
445
484
  remote: { type: 'boolean' },
446
485
  remote_name: { type: 'string' },
447
486
  }, []),
448
- t('gh_pr_create', 'Create a GitHub pull request for the current branch through GitHub CLI. Requires explicit title and body.', {
487
+ t('gh_pr_create', 'Create a GitHub pull request for the current branch through GitHub CLI. Requires explicit title and body. Before creation, the same automatic repository security review as git_push runs: high-risk findings (personal keys/tokens or privacy addresses) BLOCK creation until a second review resolves them and you call again with security_review_confirmed=true.', {
449
488
  title: { type: 'string' },
450
489
  body: { type: 'string' },
451
490
  base: { type: 'string' },
452
491
  head: { type: 'string' },
453
492
  draft: { type: 'boolean' },
493
+ security_review_confirmed: { type: 'boolean', description: 'Set true only after a second review resolved or explicitly confirmed every reported high-risk finding.' },
454
494
  }, ['title', 'body']),
455
495
  ];
456
496
  let visibleTools = tools.filter((tool) => (0, nativeTools_1.isNativeToolEnabled)(tool.function?.name || '', this.config.nativeToolEnabled()));
@@ -571,11 +611,7 @@ class ToolExecutor {
571
611
  const value = args[k];
572
612
  return value === undefined || value === null ? '' : String(value);
573
613
  };
574
- const resolve = (relPath) => {
575
- if (path.isAbsolute(relPath))
576
- return relPath;
577
- return path.join(wsPath, relPath);
578
- };
614
+ const resolve = (relPath) => normalizeCrossEnvPath(relPath, wsPath);
579
615
  const targetForTool = () => {
580
616
  switch (tool) {
581
617
  case 'read':
@@ -911,7 +947,7 @@ class ToolExecutor {
911
947
  case 'file_audit': return await this.fileAudit(resolve(g('path') || '.'), wsPath, args.include_remote !== false, g('base_ref'), context.signal);
912
948
  case 'repo_security_audit': return await this.repoSecurityAudit(resolve(g('path') || '.'), wsPath, g('base_ref'), context.signal);
913
949
  case 'git_pull': return await this.gpull(wsPath, context.signal);
914
- case 'git_push': return await this.withRemoteSecurityPreamble(wsPath, () => this.gpush(g('message'), wsPath, context.signal), context.signal);
950
+ case 'git_push': return await this.withRemoteSecurityPreamble(wsPath, () => this.gpush(g('message'), wsPath, context.signal), context.signal, args.security_review_confirmed === true);
915
951
  case 'git_clone': return await this.gclone(g('url'), resolve(g('path')), context.signal);
916
952
  case 'git_branch': return await this.gbranch(g('action'), g('name'), g('start_point'), wsPath, context.signal);
917
953
  case 'gh_auth_status': return await this.gh(['auth', 'status'], wsPath, context.signal);
@@ -919,7 +955,7 @@ class ToolExecutor {
919
955
  case 'gh_issue_list': return await this.ghList('issue', g('repo'), Number(args.limit || 20), wsPath, context.signal);
920
956
  case 'gh_pr_list': return await this.ghList('pr', g('repo'), Number(args.limit || 20), wsPath, context.signal);
921
957
  case 'gh_fork': return await this.ghFork(g('action'), g('repo'), args.clone === true, args.remote === true, g('remote_name'), wsPath, context.signal);
922
- case 'gh_pr_create': return await this.withRemoteSecurityPreamble(wsPath, () => this.ghPrCreate(g('title'), g('body'), g('base'), g('head'), args.draft === true, wsPath, context.signal), context.signal);
958
+ case 'gh_pr_create': return await this.withRemoteSecurityPreamble(wsPath, () => this.ghPrCreate(g('title'), g('body'), g('base'), g('head'), args.draft === true, wsPath, context.signal), context.signal, args.security_review_confirmed === true);
923
959
  default: return `[?] Unknown tool: ${tool}`;
924
960
  }
925
961
  }
@@ -1064,7 +1100,7 @@ class ToolExecutor {
1064
1100
  if (!this.looksLikePath(token))
1065
1101
  continue;
1066
1102
  const withoutWildcard = token.replace(/[\\/][*?][^\\/]*$/g, '');
1067
- refs.push(path.isAbsolute(withoutWildcard) ? path.resolve(withoutWildcard) : path.resolve(wsPath, withoutWildcard));
1103
+ refs.push(path.resolve(normalizeCrossEnvPath(withoutWildcard, wsPath)));
1068
1104
  }
1069
1105
  return Array.from(new Set(refs));
1070
1106
  }
@@ -1100,9 +1136,13 @@ class ToolExecutor {
1100
1136
  if (!cmd.trim())
1101
1137
  return '[bash] No command.';
1102
1138
  const timeout = this.resolveBashTimeout(timeoutMs);
1139
+ // WSL 运行时跨环境归一:工作目录若仍是 Windows 盘符路径则转 /mnt/<drive>/...,
1140
+ // 命令内的 Windows 盘符路径保守翻译为 WSL 挂载路径,让 bash 能直接操作 Windows 工作区。
1141
+ const workspaceCwd = process.env.NEWMARK_WSL_DISTRO ? ((0, workspace_1.windowsDrivePathToPosix)(ws) || ws) : ws;
1142
+ const translatedCmd = translateWindowsPathsForWslBash(cmd);
1103
1143
  try {
1104
- const result = await (0, nativeBash_1.executeWorkspaceBash)(cmd, ws, {
1105
- cwd: ws,
1144
+ const result = await (0, nativeBash_1.executeWorkspaceBash)(translatedCmd, workspaceCwd, {
1145
+ cwd: workspaceCwd,
1106
1146
  timeoutMs: timeout,
1107
1147
  signal,
1108
1148
  allowHostFallback: true,
@@ -1739,6 +1779,7 @@ class ToolExecutor {
1739
1779
  const ignoredFiles = await this.gitExecAt(repoRoot, ['ls-files', '--others', '--ignored', '--exclude-standard'], signal);
1740
1780
  const changedAgainstBase = chosenBase ? await this.gitExecAt(repoRoot, ['diff', '--name-status', chosenBase], signal) : '';
1741
1781
  const secretFindings = this.scanRepositorySecrets(repoRoot, trackedFiles, statusShort);
1782
+ const privacyFindings = this.scanRepositoryPrivacyLeaks(repoRoot, trackedFiles, statusShort);
1742
1783
  const localOnlyFindings = this.releaseExcludedPathFindings(repoRoot, ignoredFiles);
1743
1784
  const repoInfo = ghRemote
1744
1785
  ? await this.ghJson(['api', `repos/${ghRemote.owner}/${ghRemote.name}`, '--jq', '{name: .full_name, private: .private, visibility: .visibility, fork: .fork, archived: .archived, default_branch: .default_branch, html_url: .html_url}'], repoRoot, signal)
@@ -1751,6 +1792,8 @@ class ToolExecutor {
1751
1792
  risks.push('Remote GitHub repository is public; treat all tracked content and PR metadata as publicly visible.');
1752
1793
  if (ghRemote && secretFindings.length)
1753
1794
  risks.push('Potential secret-like material appears in tracked or changed files.');
1795
+ if (ghRemote && privacyFindings.length)
1796
+ risks.push('Privacy-address content (credential URLs, private network addresses, or local user paths) appears in tracked or changed files.');
1754
1797
  if (ghRemote && localOnlyFindings.length)
1755
1798
  risks.push('Workspace contains release-excluded/local-only files that must stay out of remote commits and public reports.');
1756
1799
  if (ghRemote && String(statusShort || '').trim())
@@ -1791,12 +1834,14 @@ class ToolExecutor {
1791
1834
  verdict: risks.length ? 'review-required' : 'no-obvious-risk',
1792
1835
  risks,
1793
1836
  secret_findings: secretFindings,
1837
+ privacy_findings: privacyFindings,
1838
+ high_risk_findings: [...secretFindings, ...privacyFindings],
1794
1839
  release_excluded_local_files: localOnlyFindings,
1795
1840
  recommendations,
1796
1841
  },
1797
1842
  }, null, 2);
1798
1843
  }
1799
- scanRepositorySecrets(repoRoot, trackedFilesRaw, statusRaw) {
1844
+ collectRepositoryFiles(trackedFilesRaw, statusRaw) {
1800
1845
  const files = new Set();
1801
1846
  for (const line of String(trackedFilesRaw || '').split(/\r?\n/)) {
1802
1847
  const rel = line.trim();
@@ -1808,6 +1853,10 @@ class ToolExecutor {
1808
1853
  if (rel)
1809
1854
  files.add(rel.replace(/\\/g, '/'));
1810
1855
  }
1856
+ return files;
1857
+ }
1858
+ scanRepositorySecrets(repoRoot, trackedFilesRaw, statusRaw) {
1859
+ const files = this.collectRepositoryFiles(trackedFilesRaw, statusRaw);
1811
1860
  const patterns = [
1812
1861
  { id: 'openai_or_generic_sk_key', re: /\bsk-[A-Za-z0-9._-]{16,}\b/ },
1813
1862
  { id: 'github_token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/ },
@@ -1834,7 +1883,50 @@ class ToolExecutor {
1834
1883
  for (const [idx, line] of text.split(/\r?\n/).entries()) {
1835
1884
  const matched = patterns.find(p => p.re.test(line));
1836
1885
  if (matched) {
1837
- findings.push({ path: rel, line: idx + 1, type: matched.id, sample: line.replace(/=.*/, '= <redacted>').replace(/\b(?:sk|gh[pousr]|sk-ant)-[A-Za-z0-9._-]{8,}\b/g, '<redacted-token>').slice(0, 160) });
1886
+ // 只汇报「类型 + 位置」,绝不把密钥值(含脱敏样本)暴露给 Agent,
1887
+ // 避免经 API 中转被拦截窃取。
1888
+ findings.push({ path: rel, line: idx + 1, type: matched.id });
1889
+ if (findings.length >= 40)
1890
+ break;
1891
+ }
1892
+ }
1893
+ }
1894
+ return findings;
1895
+ }
1896
+ /**
1897
+ * 扫描隐私地址类高危信息:带凭据的 URL(user:pass@)、私网 IP、本地用户目录
1898
+ * 绝对路径(C:\Users\<user>、/home/<user>、/Users/<user>)。这些内容泄露个人
1899
+ * 账号、内网拓扑或本地机器结构,进入公开 remote 即构成隐私暴露,需与密钥同级
1900
+ * 硬性阻挡并在 Agent 二轮审查确认后放行。
1901
+ */
1902
+ scanRepositoryPrivacyLeaks(repoRoot, trackedFilesRaw, statusRaw) {
1903
+ const files = this.collectRepositoryFiles(trackedFilesRaw, statusRaw);
1904
+ const patterns = [
1905
+ { id: 'credential_url', re: /(?:https?|git|ssh|ftp):\/\/[^\s/@:]+:[^\s/@]+@/i },
1906
+ { id: 'private_network_address', re: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/ },
1907
+ { id: 'local_user_path', re: /(?:^|[\s"'`])(?:C:\\(?:Users|Documents and Settings)\\[^\\\s"']+|~\/(?:[^/\s"']+\/){1,3}|\/(?:home|Users)\/[^/\s"']+\/[^\s"']*)/ },
1908
+ ];
1909
+ const findings = [];
1910
+ for (const rel of Array.from(files).sort()) {
1911
+ if (findings.length >= 40)
1912
+ break;
1913
+ const full = path.join(repoRoot, rel);
1914
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile())
1915
+ continue;
1916
+ if (fs.statSync(full).size > 512 * 1024)
1917
+ continue;
1918
+ let text = '';
1919
+ try {
1920
+ text = fs.readFileSync(full, 'utf-8');
1921
+ }
1922
+ catch {
1923
+ continue;
1924
+ }
1925
+ for (const [idx, line] of text.split(/\r?\n/).entries()) {
1926
+ const matched = patterns.find(p => p.re.test(line));
1927
+ if (matched) {
1928
+ // 只汇报「类型 + 位置」,绝不把隐私地址值暴露给 Agent。
1929
+ findings.push({ path: rel, line: idx + 1, type: matched.id });
1838
1930
  if (findings.length >= 40)
1839
1931
  break;
1840
1932
  }
@@ -1940,7 +2032,7 @@ class ToolExecutor {
1940
2032
  args.push('--draft');
1941
2033
  return this.gh(args, ws, signal);
1942
2034
  }
1943
- async withRemoteSecurityPreamble(ws, action, signal) {
2035
+ async withRemoteSecurityPreamble(ws, action, signal, securityReviewConfirmed = false) {
1944
2036
  const repoRoot = await this.findGitRoot(ws, ws, signal);
1945
2037
  if (!repoRoot)
1946
2038
  return await action();
@@ -1954,6 +2046,11 @@ class ToolExecutor {
1954
2046
  const remote = audit.remote || {};
1955
2047
  const risks = Array.isArray(review.risks) ? review.risks : [];
1956
2048
  const findings = Array.isArray(review.secret_findings) ? review.secret_findings : [];
2049
+ const privacy = Array.isArray(review.privacy_findings) ? review.privacy_findings : [];
2050
+ const highRisk = [...findings, ...privacy];
2051
+ if (highRisk.length && !securityReviewConfirmed) {
2052
+ return this.formatRemoteSecurityBlock(remote, findings, privacy);
2053
+ }
1957
2054
  const localOnly = Array.isArray(review.release_excluded_local_files) ? review.release_excluded_local_files : [];
1958
2055
  summary = [
1959
2056
  '[repo_security_audit]',
@@ -1961,8 +2058,10 @@ class ToolExecutor {
1961
2058
  `verdict=${review.verdict || 'unknown'}`,
1962
2059
  risks.length ? `risks=${risks.length}` : 'risks=0',
1963
2060
  findings.length ? `secret_findings=${findings.length}` : 'secret_findings=0',
2061
+ privacy.length ? `privacy_findings=${privacy.length}` : 'privacy_findings=0',
1964
2062
  localOnly.length ? `release_excluded_local_files=${localOnly.length}` : 'release_excluded_local_files=0',
1965
- ].join(' ');
2063
+ securityReviewConfirmed ? 'security_review_confirmed=true' : '',
2064
+ ].filter(Boolean).join(' ');
1966
2065
  }
1967
2066
  catch {
1968
2067
  // Keep the remote action result visible even if the preflight summary cannot be parsed.
@@ -1970,6 +2069,33 @@ class ToolExecutor {
1970
2069
  const actionOutput = await action();
1971
2070
  return `${summary}\n${actionOutput}`;
1972
2071
  }
2072
+ /**
2073
+ * 硬性阻挡远程写:当密钥或隐私地址类高危信息存在且 Agent 尚未二轮审查确认时,
2074
+ * 返回明确的阻挡结果(脱敏 findings),要求处理/确认后再以
2075
+ * security_review_confirmed=true 重试放行。
2076
+ */
2077
+ formatRemoteSecurityBlock(remote, secretFindings, privacyFindings) {
2078
+ // 只汇报「类型 + 位置」,绝不把密钥值 / 隐私地址值(含脱敏样本)暴露给 Agent。
2079
+ const detail = (finding) => `${String(finding.path || '')}:${String(finding.line || '')} [${String(finding.type || '')}]`.trim();
2080
+ const lines = [
2081
+ '[repo_security_audit] BLOCKED: remote write refused pending a second review.',
2082
+ 'High-risk content was detected in tracked or changed files.',
2083
+ `Remote: ${remote.provider || 'git'}${remote.repository ? ` ${remote.repository}` : ''}.`,
2084
+ `Secret-like findings: ${secretFindings.length}; privacy-address findings: ${privacyFindings.length}.`,
2085
+ ];
2086
+ if (secretFindings.length) {
2087
+ lines.push('Secret-like findings:');
2088
+ for (const finding of secretFindings.slice(0, 12))
2089
+ lines.push(` - ${detail(finding)}`);
2090
+ }
2091
+ if (privacyFindings.length) {
2092
+ lines.push('Privacy-address findings:');
2093
+ for (const finding of privacyFindings.slice(0, 12))
2094
+ lines.push(` - ${detail(finding)}`);
2095
+ }
2096
+ lines.push('Second review required: remove, .gitignore, or explicitly confirm each finding is safe.', 'Then retry this action with security_review_confirmed=true.');
2097
+ return lines.join('\n');
2098
+ }
1973
2099
  async gstat(ws, signal) {
1974
2100
  try {
1975
2101
  const r = await this.gitExec('git status --short', ws, signal);
@@ -154,6 +154,7 @@ function createCoreRuntimeAdapter(options = {}) {
154
154
  const { AutomationManager } = require(path.join(desktopDist, "core", "automation.js"));
155
155
  const { FlowEngine } = require(path.join(desktopDist, "core", "flow.js"));
156
156
  const installUpdate = require(path.join(desktopDist, "core", "installUpdate.js"));
157
+ const mobilePairing = require(path.join(desktopDist, "core", "mobilePairing.js"));
157
158
  const root = path.resolve(options.root || path.join(os.homedir(), ".Newmark"));
158
159
  const workspacePath = safeWorkspacePath(root, options.workspacePath || process.cwd());
159
160
  ensureRuntimeRoot(root, configModule);
@@ -211,7 +212,8 @@ function createCoreRuntimeAdapter(options = {}) {
211
212
  defaultTerminalShell: agent.config.getStr("terminal", "default_shell") || (process.platform === "win32" ? "powershell" : "bash"),
212
213
  status: agent.status,
213
214
  connected: true,
214
- runtimeRoot: root
215
+ runtimeRoot: root,
216
+ remoteTouchEnabled: agent.config.getBool('remote', 'touch_enabled')
215
217
  });
216
218
 
217
219
  const snapshotFor = (requested = currentTarget()) => {
@@ -473,6 +475,20 @@ function createCoreRuntimeAdapter(options = {}) {
473
475
  updateVersion() {
474
476
  return { version: installUpdate.currentAppVersion(), root };
475
477
  },
478
+ async pairingQr() {
479
+ const qr = await mobilePairing.pairingQrAscii(root);
480
+ return {
481
+ ascii: qr.ascii,
482
+ url: qr.session.url,
483
+ pairingId: qr.session.pairingId,
484
+ expiresAt: qr.session.expiresAt,
485
+ tokenFile: mobilePairing.pairingTokenPath(root),
486
+ tailscaleIpv4: mobilePairing.tailscaleIpv4(),
487
+ };
488
+ },
489
+ pairingStatus() {
490
+ return mobilePairing.pairingStatus(root);
491
+ },
476
492
  updateCheckGithub(input = {}) {
477
493
  return installUpdate.checkGitHubUpdate(input.repo, input.tag, input.asset, input.token);
478
494
  },
@@ -93,6 +93,46 @@ function executeAction(state, action) {
93
93
  const appearance = applyThemeAppearance(state, state.theme === "dark" ? "Light" : "Dark");
94
94
  state.adapter.saveConfig(appearance);
95
95
  state.notice = `${state.theme === "dark" ? "Dark" : "Light"} terminal theme`;
96
+ } else if (action === "pair-mobile") {
97
+ state.overlay = "pair";
98
+ state.pairingQrLines = ["Loading pairing QR…"];
99
+ state.pairingUrl = "";
100
+ state.pairingTokenFile = "";
101
+ if (typeof state.requestPaint === "function") state.requestPaint();
102
+ if (typeof state.adapter.pairingQr !== "function") {
103
+ state.pairingQrLines = ["Pairing QR is unavailable in this adapter."];
104
+ if (typeof state.requestPaint === "function") state.requestPaint();
105
+ return;
106
+ }
107
+ Promise.resolve(state.adapter.pairingQr())
108
+ .then((pairing) => {
109
+ state.pairingQrLines = String(pairing?.ascii || "").split(/\r?\n/);
110
+ state.pairingUrl = String(pairing?.url || "");
111
+ state.pairingTokenFile = String(pairing?.tokenFile || "");
112
+ if (typeof state.requestPaint === "function") state.requestPaint();
113
+ if (typeof state.adapter.pairingStatus === "function") {
114
+ if (state._pairingPoll) clearInterval(state._pairingPoll);
115
+ state._pairingPoll = setInterval(() => {
116
+ const status = state.adapter.pairingStatus();
117
+ if (!status) return;
118
+ if (status.confirmed) {
119
+ clearInterval(state._pairingPoll);
120
+ state.overlay = null;
121
+ state.notice = "Mobile device connected";
122
+ if (typeof state.requestPaint === "function") state.requestPaint();
123
+ } else if (status.expired || !status.active) {
124
+ clearInterval(state._pairingPoll);
125
+ state.overlay = null;
126
+ state.notice = "Pairing window expired";
127
+ if (typeof state.requestPaint === "function") state.requestPaint();
128
+ }
129
+ }, 1000);
130
+ }
131
+ })
132
+ .catch((error) => {
133
+ state.pairingQrLines = [`Pairing failed: ${error?.message || error}`];
134
+ if (typeof state.requestPaint === "function") state.requestPaint();
135
+ });
96
136
  } else if (action === "help") {
97
137
  state.overlay = "help";
98
138
  }
@@ -166,6 +206,7 @@ function start(options = {}) {
166
206
  return;
167
207
  }
168
208
  const state = createState({ adapter });
209
+ state.requestPaint = () => paint();
169
210
  let timer = null;
170
211
  let animationTimer = null;
171
212
  let closing = false;
@@ -315,6 +315,7 @@ const commands = [
315
315
  { label: "Open Automations", hint: "", action: "view:automation" },
316
316
  { label: "Open WorkFlow", hint: "", action: "view:workflow" },
317
317
  { label: "Open Settings", hint: "", action: "view:settings" },
318
+ { label: "Show mobile pairing QR", hint: "", action: "pair-mobile" },
318
319
  { label: "New conversation", hint: "N", action: "new-chat" },
319
320
  { label: "Toggle theme", hint: "T", action: "theme" },
320
321
  { label: "Keyboard shortcuts", hint: "?", action: "help" }
@@ -1154,6 +1154,17 @@ function overlayLines(state, width, p) {
1154
1154
  `${p.cyan}Press Esc or ? to close${p.reset}`
1155
1155
  ], Math.min(78, width - 4), p, "Keyboard shortcuts");
1156
1156
  }
1157
+ if (state.overlay === "pair") {
1158
+ const qrLines = Array.isArray(state.pairingQrLines) ? state.pairingQrLines : [];
1159
+ const rows = [
1160
+ ...qrLines,
1161
+ "",
1162
+ ...(state.pairingUrl ? [`${p.muted}${state.pairingUrl}${p.reset}`] : []),
1163
+ ...(state.pairingTokenFile ? [`${p.muted}Token: ${state.pairingTokenFile}${p.reset}`] : []),
1164
+ `${p.cyan}Scan with the Newmark mobile app · Esc close${p.reset}`
1165
+ ];
1166
+ return card(rows, Math.min(88, width - 4), p, "Pair mobile device");
1167
+ }
1157
1168
  if (state.overlay === "palette") {
1158
1169
  const commands = filteredCommands(state);
1159
1170
  const paletteViewport = Math.min(7, Math.max(1, commands.length));
@@ -23,7 +23,9 @@ function settingsRows(state, tab = state.settingsTab) {
23
23
  { key: "dialogStyle", label: "Conversation style", value: s.general.dialogStyle, choices: ["Formal", "Friendly"], save: ["config", "dialogStyle"] },
24
24
  { key: "feedbackLevel", label: "Option feedback", value: s.general.feedbackLevel, choices: ["Default", "Ask more", "Ask less", "Autonomous"], save: ["config", "feedbackLevel"] },
25
25
  { key: "closeBehavior", label: "Close behavior", value: s.general.closeBehavior, choices: ["Close app", "Minimize to tray"], save: ["setting", "general", "close_behavior"] },
26
- { key: "expandTools", label: "Expand tool usage", value: s.general.expandTools, choices: [true, false], save: ["setting", "general", "expand_tools"] }
26
+ { key: "expandTools", label: "Expand tool usage", value: s.general.expandTools, choices: [true, false], save: ["setting", "general", "expand_tools"] },
27
+ { key: "remoteTouch", label: "Remote behavior · Mobile remote-touch", value: s.general.remoteTouch, choices: [true, false], save: ["setting", "remote", "touch_enabled"] },
28
+ { key: "remotePair", label: "Remote behavior · Start connection", value: "Open QR", choices: [], action: "pair-mobile" }
27
29
  ];
28
30
  if (tab === "personalization") return [
29
31
  { key: "theme", label: "Theme", value: s.personalization.theme, choices: ["Dark", "Light", "System"], save: ["config", "theme"] },
@@ -253,7 +253,8 @@ function createState(options = {}) {
253
253
  dialogStyle: snapshot.dialogStyle === "friendly" ? "Friendly" : "Formal",
254
254
  feedbackLevel: { ask_more: "Ask more", ask_less: "Ask less", fully_autonomous: "Autonomous" }[snapshot.feedback] || "Default",
255
255
  closeBehavior: snapshot.closeBehavior === "minimize" ? "Minimize to tray" : "Close app",
256
- expandTools: snapshot.expandToolsDefault !== false
256
+ expandTools: snapshot.expandToolsDefault !== false,
257
+ remoteTouch: snapshot.remoteTouchEnabled !== false
257
258
  },
258
259
  personalization: {
259
260
  theme: { light: "Light", system: "System", dark: "Dark" }[String(snapshot.darkMode || "dark").toLowerCase()] || "Dark",
@@ -1071,6 +1072,25 @@ function toggleSelected(state) {
1071
1072
  }
1072
1073
  const row = settingsRows(state)[state.selected];
1073
1074
  if (!row) return;
1075
+ if (row.action === "pair-mobile") {
1076
+ state.overlay = "pair";
1077
+ state.pairingQrLines = ["Loading pairing QR…"];
1078
+ state.pairingUrl = "";
1079
+ state.pairingTokenFile = "";
1080
+ if (typeof state.adapter.pairingQr !== "function") {
1081
+ state.pairingQrLines = ["Pairing QR is unavailable in this adapter."];
1082
+ return false;
1083
+ }
1084
+ return Promise.resolve(state.adapter.pairingQr()).then((pairing) => {
1085
+ state.pairingQrLines = String(pairing?.ascii || "").split(/\r?\n/);
1086
+ state.pairingUrl = String(pairing?.url || "");
1087
+ state.pairingTokenFile = String(pairing?.tokenFile || "");
1088
+ return true;
1089
+ }).catch((error) => {
1090
+ state.pairingQrLines = [`Pairing failed: ${error?.message || error}`];
1091
+ return false;
1092
+ });
1093
+ }
1074
1094
  if (state.settingsTab === "general" && row.key === "inputBehavior") {
1075
1095
  const current = row.choices.findIndex((value) => value === row.value);
1076
1096
  state.settingChoiceTab = state.settingsTab;