total-recall-brain 3.25.6 → 3.28.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 (81) hide show
  1. package/bin/total-recall.mjs +31 -1
  2. package/docs/ARCHITECTURE.md +76 -9
  3. package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/src/App.tsx +39 -1
  6. package/frontend/src/api/index.ts +1 -0
  7. package/frontend/src/api/mesh.ts +32 -0
  8. package/frontend/src/api/plugins.ts +167 -0
  9. package/frontend/src/components/MobilePairing.spec.tsx +89 -0
  10. package/frontend/src/components/MobilePairing.tsx +72 -1
  11. package/frontend/src/components/TailnetEnroll.spec.tsx +29 -1
  12. package/frontend/src/components/TailnetEnroll.tsx +85 -3
  13. package/frontend/src/pages/ChatPage.tsx +44 -17
  14. package/frontend/src/pages/IntegrationsPage.tsx +7 -0
  15. package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
  16. package/frontend/src/pages/PluginsPage.tsx +1331 -0
  17. package/package.json +1 -1
  18. package/scaffold/.agent/plugins/.gitkeep +0 -0
  19. package/scaffold/.agent/plugins/code-quality/README.md +16 -0
  20. package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
  21. package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
  22. package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
  23. package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
  24. package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
  25. package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
  26. package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
  27. package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
  28. package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
  29. package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
  30. package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
  31. package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
  32. package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
  33. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
  34. package/src/cli/agent.mjs +173 -0
  35. package/src/cli/connect.mjs +89 -4
  36. package/src/cli/deploy-ui.mjs +6 -0
  37. package/src/cli/harness.mjs +135 -0
  38. package/src/cli/ingest-openwiki.mjs +71 -2
  39. package/src/cli/init.mjs +1 -0
  40. package/src/cli/lint.mjs +47 -3
  41. package/src/cli/mesh.mjs +286 -0
  42. package/src/cli/plugin/create.mjs +186 -0
  43. package/src/cli/plugin/index.mjs +139 -0
  44. package/src/cli/plugin/install.mjs +141 -0
  45. package/src/cli/plugin/list.mjs +83 -0
  46. package/src/cli/plugin/remove.mjs +51 -0
  47. package/src/cli/plugin/search.mjs +37 -0
  48. package/src/cli/skill-projection.mjs +14 -0
  49. package/src/cli/wizard.html +9 -0
  50. package/src/core/agent-manager.mjs +290 -0
  51. package/src/core/bound-hosts.mjs +64 -0
  52. package/src/core/cache-prune.mjs +341 -0
  53. package/src/core/context-cache.mjs +130 -0
  54. package/src/core/daemon-loop.mjs +47 -1
  55. package/src/core/evolving-context.mjs +171 -0
  56. package/src/core/mesh-activity.mjs +140 -0
  57. package/src/core/mesh-late-bind.mjs +105 -0
  58. package/src/core/mesh.mjs +174 -1
  59. package/src/core/meta-harness.mjs +251 -0
  60. package/src/core/pairing.mjs +49 -10
  61. package/src/core/plugin-context.mjs +206 -0
  62. package/src/core/plugin-loader.mjs +210 -0
  63. package/src/core/registration-watch.mjs +193 -0
  64. package/src/core/runtime.mjs +65 -13
  65. package/src/core/secrets-store.mjs +8 -0
  66. package/src/core/surface.mjs +32 -10
  67. package/src/server/api.mjs +18 -2
  68. package/src/server/auth.mjs +11 -1
  69. package/src/server/index.mjs +43 -2
  70. package/src/server/rest.mjs +4 -0
  71. package/src/server/route-manifest.json +12 -0
  72. package/src/server/routes/auth.mjs +31 -0
  73. package/src/server/routes/integrations.mjs +5 -1
  74. package/src/server/routes/mesh-presence.mjs +143 -0
  75. package/src/server/routes/mesh.mjs +55 -0
  76. package/src/server/routes/models.mjs +42 -30
  77. package/src/server/routes/plugins.mjs +517 -0
  78. package/src/server/routes/secrets.mjs +1 -0
  79. package/src/server/routes/system.mjs +5 -0
  80. package/src/server/tools.mjs +266 -0
  81. package/frontend/dist/assets/index-DSxCyPAx.js +0 -5742
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-DSxCyPAx.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-U7o-qU99.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { BrowserRouter, Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
3
3
  import './App.css'
4
- import { getApiBase, setApiBase, checkSession, logout, registerUnauthedCallback, fetchHealth } from './api'
4
+ import { getApiBase, setApiBase, checkSession, logout, registerUnauthedCallback, fetchHealth, fetchPlugins, type PluginInfo } from './api'
5
5
  import type { HealthData } from './types'
6
6
  import LoginPage from './pages/LoginPage'
7
7
  import ChatPage from './pages/ChatPage'
@@ -19,6 +19,7 @@ import RulesPage from './pages/RulesPage'
19
19
  import HelpPage from './pages/HelpPage'
20
20
  import GraphPage from './pages/GraphPage'
21
21
  import OpenWikiPage from './pages/OpenWikiPage'
22
+ import PluginsPage from './pages/PluginsPage'
22
23
  import OnboardingPage from './pages/OnboardingPage'
23
24
  import { NotificationsPage } from './pages/NotificationsPage'
24
25
  import { isOnboardingComplete } from './utils/onboarding'
@@ -86,6 +87,12 @@ interface SidebarProps {
86
87
  const getNavLinkClass = ({ isActive }: { isActive: boolean }) => `nav-link ${isActive ? 'active' : ''}`;
87
88
 
88
89
  function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProps) {
90
+ const [plugins, setPlugins] = useState<PluginInfo[]>([])
91
+
92
+ useEffect(() => {
93
+ fetchPlugins().then(setPlugins).catch(() => {})
94
+ }, [])
95
+
89
96
  return (
90
97
  <aside className="sidebar">
91
98
  <div className="sidebar-brand">
@@ -158,6 +165,36 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
158
165
  </svg>
159
166
  OpenWiki
160
167
  </NavLink>
168
+ <NavLink to="/plugins" className={getNavLinkClass} id="nav-plugins">
169
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
170
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
171
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
172
+ <line x1="12" y1="22.08" x2="12" y2="12" />
173
+ </svg>
174
+ Plugins
175
+ </NavLink>
176
+
177
+ {plugins.length > 0 && (
178
+ <>
179
+ <div className="nav-section-label">Plugins</div>
180
+ {plugins.map((p) => (
181
+ <NavLink
182
+ key={p.id}
183
+ to={`/plugins?id=${encodeURIComponent(p.id)}`}
184
+ className={getNavLinkClass}
185
+ id={`nav-plugin-${p.id}`}
186
+ title={p.description}
187
+ >
188
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
189
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
190
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
191
+ <line x1="12" y1="22.08" x2="12" y2="12" />
192
+ </svg>
193
+ {p.name}
194
+ </NavLink>
195
+ ))}
196
+ </>
197
+ )}
161
198
 
162
199
  <div className="nav-section-label">Account</div>
163
200
  <NavLink to="/network" className={getNavLinkClass} id="nav-network">
@@ -396,6 +433,7 @@ function MainContent({ activeBrainId, onBrainChange }: { activeBrainId: string;
396
433
  <Route path="/tasks" element={<TasksPage />} />
397
434
  <Route path="/skills" element={<SkillsPage activeBrainId={activeBrainId} />} />
398
435
  <Route path="/openwiki" element={<OpenWikiPage activeBrainId={activeBrainId} />} />
436
+ <Route path="/plugins" element={<PluginsPage activeBrainId={activeBrainId} />} />
399
437
  <Route path="/integrations" element={<IntegrationsPage activeBrainId={activeBrainId} />} />
400
438
  <Route path="/keys" element={<Navigate to="/secrets" replace />} />
401
439
  <Route path="/settings" element={<SettingsPage activeBrainId={activeBrainId} />} />
@@ -19,3 +19,4 @@ export * from './sessions'
19
19
  export * from './integrations'
20
20
  export * from './config'
21
21
  export * from './secrets'
22
+ export * from './plugins'
@@ -303,6 +303,38 @@ export async function registerNode(authId: string, user?: string): Promise<Regis
303
303
  return data as RegisteredNode;
304
304
  }
305
305
 
306
+ export interface WatchStatus {
307
+ state: 'idle' | 'watching' | 'registered' | 'expired' | 'stopped' | 'unavailable' | 'error';
308
+ id?: string | null;
309
+ node?: { id: string | null; name: string | null; ip_addresses: string[] } | null;
310
+ error?: string | null;
311
+ remaining_ms?: number;
312
+ source?: string;
313
+ }
314
+
315
+ /** Arm the server to approve the next device that signs in. */
316
+ export async function startWatch(ttlMinutes?: number): Promise<WatchStatus> {
317
+ const res = await apiFetch(`${API_BASE}/api/mesh/watch`, {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify(ttlMinutes ? { ttlMinutes } : {}),
321
+ });
322
+ // 409 means watch mode is not available here — a real answer the UI acts on,
323
+ // not an error to throw away.
324
+ const data = await res.json().catch(() => ({}));
325
+ if (!res.ok && res.status !== 409) throw new Error(data.error || `watch failed: ${res.status}`);
326
+ return data as WatchStatus;
327
+ }
328
+
329
+ export async function getWatchStatus(): Promise<WatchStatus> {
330
+ return get('/api/mesh/watch');
331
+ }
332
+
333
+ export async function stopWatch(): Promise<WatchStatus> {
334
+ const res = await apiFetch(`${API_BASE}/api/mesh/watch`, { method: 'DELETE' });
335
+ return (await res.json().catch(() => ({ state: 'idle' }))) as WatchStatus;
336
+ }
337
+
306
338
  export async function fetchEnrollmentStatus(): Promise<EnrollmentStatus> {
307
339
  return get('/api/mesh/enrollment');
308
340
  }
@@ -0,0 +1,167 @@
1
+ // ─── Plugins domain ───────────────────────────────────────────────────────────
2
+
3
+ import { apiFetch, API_BASE } from "./_base"
4
+
5
+ export interface PluginInfo {
6
+ id: string
7
+ name: string
8
+ version: string
9
+ description: string
10
+ valid: boolean
11
+ errors: string[]
12
+ dir: string
13
+ rating: number
14
+ reviewCount: number
15
+ installCount: string
16
+ userRating?: number | null
17
+ userReview?: string | null
18
+ verified?: boolean
19
+ categories: Array<{ name: string; description?: string }>
20
+ tasks: Array<{ intent: string; schedule: string }>
21
+ openwiki_hubs: Array<{ title: string; path: string }>
22
+ tools: Array<{ name: string; description?: string }>
23
+ cli?: { command: string; handler: string } | null
24
+ manifest?: Record<string, unknown>
25
+ }
26
+
27
+ export interface CatalogPlugin {
28
+ id: string
29
+ name: string
30
+ version: string
31
+ description: string
32
+ tags: string[]
33
+ author: string
34
+ sourceUrl: string
35
+ rating: number
36
+ reviewCount: number
37
+ installCount: string
38
+ userRating?: number | null
39
+ verified?: boolean
40
+ isInstalled: boolean
41
+ }
42
+
43
+ export interface PluginsResponse {
44
+ success: boolean
45
+ count: number
46
+ plugins: PluginInfo[]
47
+ }
48
+
49
+ export interface CatalogResponse {
50
+ success: boolean
51
+ catalog: CatalogPlugin[]
52
+ }
53
+
54
+ export async function fetchPlugins(): Promise<PluginInfo[]> {
55
+ try {
56
+ const res = await apiFetch(`${API_BASE}/api/plugins`)
57
+ if (!res.ok) return []
58
+ const data: PluginsResponse = await res.json()
59
+ return Array.isArray(data.plugins) ? data.plugins : []
60
+ } catch {
61
+ return []
62
+ }
63
+ }
64
+
65
+ export async function fetchPlugin(id: string): Promise<PluginInfo | null> {
66
+ try {
67
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}`)
68
+ if (!res.ok) return null
69
+ const data = await res.json()
70
+ return data.plugin || null
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ export async function fetchPluginCatalog(): Promise<CatalogPlugin[]> {
77
+ try {
78
+ const res = await apiFetch(`${API_BASE}/api/plugins/catalog`)
79
+ if (!res.ok) return []
80
+ const data: CatalogResponse = await res.json()
81
+ return Array.isArray(data.catalog) ? data.catalog : []
82
+ } catch {
83
+ return []
84
+ }
85
+ }
86
+
87
+ export async function installPlugin(options: { source: string; link?: boolean; global?: boolean }): Promise<{ success: boolean; message?: string; error?: string }> {
88
+ try {
89
+ const res = await apiFetch(`${API_BASE}/api/plugins/install`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify(options)
93
+ })
94
+ const data = await res.json()
95
+ if (!res.ok) {
96
+ return { success: false, error: data.error || data.message || "Failed to install plugin" }
97
+ }
98
+ return { success: true, message: data.message }
99
+ } catch (err: any) {
100
+ return { success: false, error: err.message || "Network error" }
101
+ }
102
+ }
103
+
104
+ export async function removePlugin(id: string, global = false): Promise<{ success: boolean; message?: string; error?: string }> {
105
+ try {
106
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}?global=${global ? "true" : "false"}`, {
107
+ method: "DELETE"
108
+ })
109
+ const data = await res.json()
110
+ if (!res.ok) {
111
+ return { success: false, error: data.error || data.message || "Failed to remove plugin" }
112
+ }
113
+ return { success: true, message: data.message }
114
+ } catch (err: any) {
115
+ return { success: false, error: err.message || "Network error" }
116
+ }
117
+ }
118
+
119
+ export async function ratePlugin(id: string, rating: number, review?: string): Promise<{ success: boolean; message?: string; error?: string }> {
120
+ try {
121
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/rate`, {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify({ rating, review })
125
+ })
126
+ const data = await res.json()
127
+ if (!res.ok) {
128
+ return { success: false, error: data.error || data.message || "Failed to submit rating" }
129
+ }
130
+ return { success: true, message: data.message }
131
+ } catch (err: any) {
132
+ return { success: false, error: err.message || "Network error" }
133
+ }
134
+ }
135
+
136
+ export async function runPluginCommand(
137
+ id: string,
138
+ subcommand = "",
139
+ args: string[] = []
140
+ ): Promise<{ success: boolean; output?: string; error?: string }> {
141
+ try {
142
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/run`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ subcommand, args })
146
+ })
147
+ const data = await res.json()
148
+ if (!res.ok) {
149
+ return { success: false, error: data.error || data.message || "Failed to execute plugin command" }
150
+ }
151
+ return { success: true, output: data.output || "" }
152
+ } catch (err: any) {
153
+ return { success: false, error: err.message || "Network error" }
154
+ }
155
+ }
156
+
157
+ export async function fetchPluginReadme(id: string): Promise<string> {
158
+ try {
159
+ const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/readme`)
160
+ if (!res.ok) return ""
161
+ const data = await res.json()
162
+ return data.readme || ""
163
+ } catch {
164
+ return ""
165
+ }
166
+ }
167
+
@@ -61,4 +61,93 @@ describe('MobilePairing', () => {
61
61
  expect(screen.getByText(/Reset Keychain/i)).toBeTruthy();
62
62
  });
63
63
 
64
+ const pairingResponse = (over = {}) => ({
65
+ ok: true,
66
+ json: async () => ({
67
+ port: 3000,
68
+ protocol: 'http',
69
+ preferred_url: 'http://100.64.0.2:3000',
70
+ listen_hosts: ['127.0.0.1'],
71
+ listen_hosts_source: 'actual',
72
+ reachable_from_other_devices: false,
73
+ warnings: ['This brain is listening on loopback only'],
74
+ endpoints: [
75
+ {
76
+ kind: 'mesh',
77
+ label: 'Tailscale / mesh',
78
+ ip: '100.64.0.2',
79
+ url: 'http://100.64.0.2:3000',
80
+ recommended: true,
81
+ reachable_hint: 'Tailscale',
82
+ listening: false,
83
+ },
84
+ ],
85
+ ...over,
86
+ }),
87
+ });
88
+
89
+ it('says no device can reach the brain instead of drawing a QR that fails', async () => {
90
+ // The Mac Mini shipped this card for a week: brain bound loopback-only
91
+ // after losing the boot race to the mesh client, while the card happily
92
+ // recommended a mesh URL nothing had ever listened on.
93
+ vi.mocked(apiFetch).mockResolvedValue(pairingResponse() as never);
94
+
95
+ render(<MobilePairing />);
96
+
97
+ const alert = await screen.findByTestId('pairing-unreachable');
98
+ expect(alert.textContent).toMatch(/No other device can reach this brain/i);
99
+ // Naming the cause is what turns an error into something a user can act on.
100
+ expect(alert.textContent).toMatch(/still starting when the brain launched/i);
101
+ expect(alert.textContent).toMatch(/restart the brain/i);
102
+ // And the code itself must not look scannable.
103
+ expect(screen.getByTestId('pairing-qr-dead')).toBeTruthy();
104
+ });
105
+
106
+ it('flags the specific address that is not listening', async () => {
107
+ vi.mocked(apiFetch).mockResolvedValue(pairingResponse() as never);
108
+ render(<MobilePairing />);
109
+ await screen.findByTestId('pairing-unreachable');
110
+ expect(screen.getByTestId('pairing-endpoint-select').textContent).toMatch(/not listening/i);
111
+ });
112
+
113
+ it('stays quiet when the brain is genuinely reachable', async () => {
114
+ vi.mocked(apiFetch).mockResolvedValue(
115
+ pairingResponse({
116
+ preferred_url: 'http://100.64.0.6:3000',
117
+ listen_hosts: ['100.64.0.6', '127.0.0.1'],
118
+ reachable_from_other_devices: true,
119
+ warnings: [],
120
+ endpoints: [
121
+ {
122
+ kind: 'mesh',
123
+ label: 'Tailscale / mesh',
124
+ ip: '100.64.0.6',
125
+ url: 'http://100.64.0.6:3000',
126
+ recommended: true,
127
+ reachable_hint: 'Tailscale',
128
+ listening: true,
129
+ },
130
+ ],
131
+ }) as never,
132
+ );
133
+ render(<MobilePairing />);
134
+ await waitFor(() => {
135
+ expect(screen.getByTestId('pairing-url').textContent).toContain('100.64.0.6');
136
+ });
137
+ expect(screen.queryByTestId('pairing-unreachable')).toBeNull();
138
+ expect(screen.queryByTestId('pairing-qr-dead')).toBeNull();
139
+ });
140
+
141
+ it('does not cry wolf when the server could not observe its own sockets', async () => {
142
+ // `null` is unknown. Rendering unknown as a red banner would put a false
143
+ // alarm on every install whose page load beat the listen callback.
144
+ vi.mocked(apiFetch).mockResolvedValue(
145
+ pairingResponse({ reachable_from_other_devices: null, listen_hosts_source: 'derived' }) as never,
146
+ );
147
+ render(<MobilePairing />);
148
+ await waitFor(() => {
149
+ expect(screen.getByTestId('pairing-url')).toBeTruthy();
150
+ });
151
+ expect(screen.queryByTestId('pairing-unreachable')).toBeNull();
152
+ });
64
153
  });
@@ -10,6 +10,8 @@ export interface PairingEndpoint {
10
10
  interface?: string | null;
11
11
  recommended: boolean;
12
12
  reachable_hint: string;
13
+ /** null when the server could not observe its own sockets. */
14
+ listening?: boolean | null;
13
15
  }
14
16
 
15
17
  export interface PairingInfo {
@@ -19,6 +21,10 @@ export interface PairingInfo {
19
21
  endpoints: PairingEndpoint[];
20
22
  warnings: string[];
21
23
  listen_hosts: string[];
24
+ /** 'actual' = observed sockets. 'derived' = a guess; do not render as fact. */
25
+ listen_hosts_source?: 'actual' | 'derived';
26
+ /** null means unknown — must not be shown as either working or broken. */
27
+ reachable_from_other_devices?: boolean | null;
22
28
  }
23
29
 
24
30
  export function MobilePairing() {
@@ -67,6 +73,8 @@ export function MobilePairing() {
67
73
  ],
68
74
  warnings: ['Could not load live network endpoints from the brain.'],
69
75
  listen_hosts: [],
76
+ listen_hosts_source: 'derived',
77
+ reachable_from_other_devices: null,
70
78
  });
71
79
  } finally {
72
80
  setLoading(false);
@@ -78,6 +86,9 @@ export function MobilePairing() {
78
86
  }, []);
79
87
 
80
88
  const selected = info?.endpoints?.find((e) => e.url === selectedUrl) || null;
89
+ // Observed, not guessed. `undefined`/`null` means the server could not tell
90
+ // us — that is not the same as broken, so it must not raise this banner.
91
+ const unreachable = info?.reachable_from_other_devices === false;
81
92
  const isLoopback =
82
93
  selected?.kind === 'loopback' ||
83
94
  selectedUrl.includes('127.0.0.1') ||
@@ -111,6 +122,39 @@ export function MobilePairing() {
111
122
  Wi‑Fi, or <strong>Tailscale/mesh</strong> if the phone is on your tailnet.
112
123
  </p>
113
124
 
125
+ {unreachable && (
126
+ <div
127
+ role="alert"
128
+ data-testid="pairing-unreachable"
129
+ style={{
130
+ marginBottom: 16,
131
+ padding: '12px 14px',
132
+ borderRadius: 8,
133
+ border: '1px solid #ef4444',
134
+ background: 'rgba(239, 68, 68, 0.08)',
135
+ color: 'var(--text-primary)',
136
+ fontSize: 13,
137
+ lineHeight: 1.55,
138
+ }}
139
+ >
140
+ <div style={{ fontWeight: 700, marginBottom: 4 }}>
141
+ No other device can reach this brain
142
+ </div>
143
+ <div style={{ color: 'var(--text-secondary)' }}>
144
+ It is listening on{' '}
145
+ <code>{info?.listen_hosts?.join(', ') || 'loopback'}</code> only, so every code on this
146
+ card is a dead address — a phone that scans one will time out no matter how it is
147
+ connected. This normally means the mesh client was still starting when the brain
148
+ launched, so the brain never got a mesh address to bind.
149
+ <div style={{ marginTop: 6 }}>
150
+ <strong>Fix:</strong> restart the brain now that the mesh is up. It re-checks for a
151
+ mesh address on its own for the first few minutes after launch, so a restart is
152
+ usually all this needs.
153
+ </div>
154
+ </div>
155
+ </div>
156
+ )}
157
+
114
158
  {loading && (
115
159
  <div data-testid="pairing-loading" style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>
116
160
  Detecting reachable addresses…
@@ -149,7 +193,33 @@ export function MobilePairing() {
149
193
  data-testid="pairing-qr"
150
194
  >
151
195
  {selectedUrl ? (
152
- <QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
196
+ <div style={{ position: 'relative' }}>
197
+ {/* Greyed rather than hidden: the address is still the right one
198
+ to reach for once the bind is fixed. */}
199
+ <div style={{ opacity: unreachable ? 0.15 : 1 }}>
200
+ <QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
201
+ </div>
202
+ {unreachable && (
203
+ <div
204
+ data-testid="pairing-qr-dead"
205
+ style={{
206
+ position: 'absolute',
207
+ inset: 0,
208
+ display: 'flex',
209
+ alignItems: 'center',
210
+ justifyContent: 'center',
211
+ textAlign: 'center',
212
+ padding: 8,
213
+ color: '#b91c1c',
214
+ fontSize: 12,
215
+ fontWeight: 700,
216
+ lineHeight: 1.4,
217
+ }}
218
+ >
219
+ Nothing is listening on this address
220
+ </div>
221
+ )}
222
+ </div>
153
223
  ) : (
154
224
  <div style={{ width: 150, height: 150 }} />
155
225
  )}
@@ -173,6 +243,7 @@ export function MobilePairing() {
173
243
  <option key={ep.url} value={ep.url}>
174
244
  {ep.label}
175
245
  {ep.recommended ? ' ★' : ''} — {ep.url}
246
+ {ep.listening === false ? ' (not listening)' : ''}
176
247
  </option>
177
248
  ))}
178
249
  </select>
@@ -6,8 +6,12 @@ vi.mock('../api/mesh', () => ({
6
6
  fetchNodes: vi.fn(),
7
7
  fetchEnrollmentStatus: vi.fn(),
8
8
  mintPreAuthKey: vi.fn(),
9
+ registerNode: vi.fn(),
10
+ startWatch: vi.fn(),
11
+ getWatchStatus: vi.fn(),
12
+ stopWatch: vi.fn(),
9
13
  }));
10
- import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
14
+ import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, startWatch } from '../api/mesh';
11
15
 
12
16
  const node = (over = {}) => ({
13
17
  hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
@@ -150,4 +154,28 @@ describe('TailnetEnroll', () => {
150
154
  expect(screen.getByText(/Crostini/i)).toBeTruthy();
151
155
  });
152
156
 
157
+ it('leads with a watch button rather than a key to carry between devices', async () => {
158
+ // No Tailscale client can scan a QR to authenticate, and nobody types a
159
+ // 40-character secret from a laptop into a phone. The device generates the
160
+ // id and waits; the server approves it.
161
+ vi.mocked(startWatch).mockResolvedValue({ state: 'watching', remaining_ms: 300000 } as never);
162
+ render(<TailnetEnroll />);
163
+ const btn = await screen.findByTestId('watch-start');
164
+ fireEvent.click(btn);
165
+ await waitFor(() => expect(screen.getByTestId('watch-active')).toBeTruthy());
166
+ expect(startWatch).toHaveBeenCalled();
167
+ });
168
+
169
+ it('says watch mode is unavailable instead of offering a button that cannot work', async () => {
170
+ vi.mocked(startWatch).mockResolvedValue({
171
+ state: 'unavailable',
172
+ error: 'no log source is configured.',
173
+ } as never);
174
+ render(<TailnetEnroll />);
175
+ fireEvent.click(await screen.findByTestId('watch-start'));
176
+ await waitFor(() => expect(screen.getByTestId('watch-unavailable')).toBeTruthy());
177
+ // and the manual path is still reachable
178
+ expect(screen.getByTestId('enroll-approve')).toBeTruthy();
179
+ });
180
+
153
181
  });
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { QRCodeSVG } from 'qrcode.react';
3
- import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode } from '../api/mesh';
3
+ import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode, startWatch, getWatchStatus, stopWatch } from '../api/mesh';
4
+ import type { WatchStatus } from '../api/mesh';
4
5
  import type { MeshNode, PreAuthKey } from '../api/mesh';
5
6
 
6
7
  /**
@@ -28,6 +29,7 @@ export function TailnetEnroll() {
28
29
  const [authId, setAuthId] = useState('');
29
30
  const [approving, setApproving] = useState(false);
30
31
  const [approved, setApproved] = useState<string | null>(null);
32
+ const [watch, setWatch] = useState<WatchStatus | null>(null);
31
33
 
32
34
  useEffect(() => {
33
35
  fetchNodes()
@@ -64,6 +66,34 @@ export function TailnetEnroll() {
64
66
  }
65
67
  };
66
68
 
69
+ // Poll while armed. The server holds the watch; this only mirrors it, so a
70
+ // page reload cannot orphan a running watch.
71
+ useEffect(() => {
72
+ if (watch?.state !== 'watching') return undefined;
73
+ const id = setInterval(async () => {
74
+ try {
75
+ const st = await getWatchStatus();
76
+ setWatch(st);
77
+ if (st.state === 'registered') setNodes(await fetchNodes().catch(() => nodes));
78
+ } catch { /* transient */ }
79
+ }, 2000);
80
+ return () => clearInterval(id);
81
+ }, [watch?.state, nodes]);
82
+
83
+ const arm = async () => {
84
+ setError(null);
85
+ setApproved(null);
86
+ try {
87
+ setWatch(await startWatch(5));
88
+ } catch (e) {
89
+ setError(e instanceof Error ? e.message : 'could not start watching');
90
+ }
91
+ };
92
+
93
+ const disarm = async () => {
94
+ try { setWatch(await stopWatch()); } catch { setWatch(null); }
95
+ };
96
+
67
97
  const approve = async () => {
68
98
  setApproving(true);
69
99
  setError(null);
@@ -136,11 +166,63 @@ export function TailnetEnroll() {
136
166
  existed, finishing that required SSH and a CLI -- on the machine you
137
167
  are not holding.
138
168
  */}
169
+ {/*
170
+ The primary action. The device generates the id and waits; the server
171
+ approves it. Nothing is typed, copied or scanned — which matters
172
+ because no Tailscale client scans QR codes to authenticate, and moving
173
+ a 40-character key from a laptop to a phone by hand is not a flow
174
+ anyone finishes.
175
+ */}
139
176
  <div
177
+ data-testid="enroll-watch"
178
+ style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
179
+ >
180
+ <strong style={{ fontSize: 12 }}>Add a device</strong>
181
+ <p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
182
+ Press this, then sign in on the device. It joins on its own — nothing to copy.
183
+ </p>
184
+
185
+ {watch?.state === 'watching' ? (
186
+ <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
187
+ <span data-testid="watch-active" style={{ fontSize: 12, color: '#10b981' }}>
188
+ ● Watching — sign in on the device now
189
+ {typeof watch.remaining_ms === 'number'
190
+ ? ` (${Math.ceil(watch.remaining_ms / 1000)}s left)`
191
+ : ''}
192
+ </span>
193
+ <button type="button" onClick={disarm} data-testid="watch-stop">Stop</button>
194
+ </div>
195
+ ) : (
196
+ <button type="button" onClick={arm} data-testid="watch-start">
197
+ Watch for a device
198
+ </button>
199
+ )}
200
+
201
+ {watch?.state === 'registered' && (
202
+ <p data-testid="watch-registered" style={{ fontSize: 12, color: '#10b981', margin: '8px 0 0' }}>
203
+ {watch.node?.name || 'Device'} joined the tailnet
204
+ {watch.node?.ip_addresses?.[0] ? ` (${watch.node.ip_addresses[0]})` : ''}.
205
+ </p>
206
+ )}
207
+ {watch?.state === 'expired' && (
208
+ <p data-testid="watch-expired" style={{ fontSize: 12, color: '#f59e0b', margin: '8px 0 0' }}>
209
+ Nothing signed in before the watch ran out. Press it again when the device is ready.
210
+ </p>
211
+ )}
212
+ {watch?.state === 'unavailable' && (
213
+ <p data-testid="watch-unavailable" style={{ fontSize: 12, color: '#f59e0b', margin: '8px 0 0' }}>
214
+ {watch.error} Use the manual box below in the meantime.
215
+ </p>
216
+ )}
217
+ </div>
218
+
219
+ <details
140
220
  data-testid="enroll-approve"
141
221
  style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
142
222
  >
143
- <strong style={{ fontSize: 12 }}>Device waiting for approval?</strong>
223
+ <summary style={{ cursor: 'pointer' }}>
224
+ <strong style={{ fontSize: 12 }}>Device waiting for approval? (manual)</strong>
225
+ </summary>
144
226
  <p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
145
227
  If a device shows “run the command below in the headscale server”, paste that whole line
146
228
  (or just the <code>hskey-authreq-…</code> part) here.
@@ -173,7 +255,7 @@ export function TailnetEnroll() {
173
255
  {approved}
174
256
  </p>
175
257
  )}
176
- </div>
258
+ </details>
177
259
 
178
260
  <details open style={{ fontSize: 12 }}>
179
261
  <summary style={{ cursor: 'pointer', fontWeight: 600 }}>On a phone (iOS)</summary>