@omercnet/paseo-connected-hosts 0.0.0 → 0.1.0-next.305.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/OVERVIEW.md CHANGED
@@ -1,17 +1,17 @@
1
1
  Connected hosts manages the hosts saved on the daemon you are working in, from a **Connected hosts** screen. It adds a sidebar row and a Command Center action, "Manage connected hosts".
2
2
 
3
- Each saved host shows its label, connection status, the safe description of where it connects, the server ID once learned, who registered it, its metadata, when it was last online, when the next retry happens, and the last connection error. Status updates live. From the screen you can add a host, edit its label, metadata and connection, disable and re-enable it, and remove it.
3
+ Each saved host shows its label, connection status, the safe description of where it connects, the server ID once learned, what registered it, its metadata, when it was last online, when the next retry happens, and the last connection error. Status updates live. From the screen you can add a host, edit its label, metadata and connection, disable and re-enable it, and remove it.
4
4
 
5
5
  The list is the daemon's own registry, the same one `paseo host` and agent tools such as `list_hosts` use. It is separate from the connections saved in the app, and it always describes the host selected in the screen's host picker. Changing the picker shows that daemon's registry.
6
6
 
7
- Connections are written as the CLI accepts them, for example `host:6767` or `ssh://user@host`. A host that cannot be reached is still saved and retried. A connection is write-only: the daemon never returns saved credentials, so the edit form shows the safe description and leaves the connection untouched unless you type a new one.
7
+ Connections are written as the CLI accepts them, for example `host:6767` or `ssh://user@host`. A host that cannot be reached is still saved and retried automatically. A host that is reachable but rejected (failed authentication, a different daemon identity, this daemon itself, a duplicate, or a daemon without the feature) stays in an error state until you disable and re-enable it, or save a new connection. A connection is write-only: the daemon never returns saved credentials, so the edit form shows the safe description and leaves the connection untouched unless you type a new one.
8
8
 
9
9
  Replacing a connection keeps the host's pinned identity. If the new connection reaches a different daemon, the new connection is saved but the host shows an identity error and stays disconnected. To accept the new daemon, edit the host, enter the connection again and tick **Accept a different server identity**. The daemon refuses an identity change without a connection, so the field cannot be left blank.
10
10
 
11
- Metadata is a set of `key=value` lines and saving replaces the whole map.
11
+ Metadata is a set of `key=value` lines and saving replaces the whole map. Only fields you change are sent, so editing a label never rewrites metadata.
12
12
 
13
- Who may change what follows the daemon's own rules. Label and access changes need tunnel management, and connection and metadata changes need the registering producer or access management. A refused change shows the daemon's message and leaves the host as it was. Removing a host that another process registered lets that process add it again; disabling keeps the host saved and switched off. Removing a host does not stop agents already running on it.
13
+ Who may change what follows the daemon's own rules. Adding, editing and removing need tunnel management. Changing a connection or metadata, and removing, also need the registering producer or access management. The screen cannot see your grants, so every action is shown, and a refused change shows the daemon's message and leaves the host as it was. Removing a host that another process registered lets that process add it again; disabling keeps the host saved and switched off. Removing a host does not stop agents already running on it, but this daemon can no longer reach them.
14
14
 
15
- Both daemons need the connected hosts feature.
15
+ Both daemons need the connected hosts feature, and the link between them needs host multiplexing.
16
16
 
17
17
  Requires Paseo ^0.13.0.
package/client/hosts.ts CHANGED
@@ -1,7 +1,21 @@
1
- import type { HostAdd, HostDirectory, HostSummary, HostUpdate, PaseoApi } from "@getpaseo/client";
1
+ import type { HostAdd, HostDirectory, HostSummary, HostUpdate } from "@getpaseo/protocol/hosts";
2
2
  import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
 
5
+ /** The slice of the plugin's `usePaseo()` client that follows the saved hosts. */
6
+ export interface HostsApi {
7
+ hosts: {
8
+ observe(): {
9
+ subscribe(observer: {
10
+ snapshot(directory: HostDirectory): void;
11
+ update(message: SessionOutboundMessage): void;
12
+ error(cause: unknown): void;
13
+ }): () => void;
14
+ release(): Promise<void>;
15
+ };
16
+ };
17
+ }
18
+
5
19
  export type Built<T> = { ok: true; value: T } | { ok: false; error: string };
6
20
 
7
21
  /** Prefers the daemon's `userMessage` over the transport message, which appends request details. */
@@ -64,15 +78,23 @@ export interface EditForm {
64
78
  replaceIdentity: boolean;
65
79
  }
66
80
 
67
- /** Sends only what changed, so an untouched field is never rewritten or erased. */
81
+ /**
82
+ * Sends only what the user changed. A field still equal to its prefilled text is never parsed or
83
+ * sent, so values the form cannot round-trip (padding, `=` in a key) are not rewritten and a
84
+ * label-only edit never asks for metadata permission.
85
+ */
68
86
  export function buildUpdatePatch(host: HostSummary, form: EditForm): Built<HostUpdate> {
69
87
  const patch: HostUpdate = {};
70
- const label = form.label.trim() || null;
71
- if (label !== host.label) patch.label = label;
72
- const metadata = parseMetadata(form.metadata);
73
- if (!metadata.ok) return metadata;
74
- if (formatMetadata(metadata.value) !== formatMetadata(host.metadata)) {
75
- patch.metadata = metadata.value;
88
+ if (form.label !== (host.label ?? "")) {
89
+ const label = form.label.trim() || null;
90
+ if (label !== host.label) patch.label = label;
91
+ }
92
+ if (form.metadata !== formatMetadata(host.metadata)) {
93
+ const metadata = parseMetadata(form.metadata);
94
+ if (!metadata.ok) return metadata;
95
+ if (formatMetadata(metadata.value) !== formatMetadata(host.metadata)) {
96
+ patch.metadata = metadata.value;
97
+ }
76
98
  }
77
99
  const connection = form.connection.trim();
78
100
  if (connection) {
@@ -86,47 +108,61 @@ export function directoryFromMessage(message: SessionOutboundMessage): HostDirec
86
108
  return message.type === "hosts.changed" ? message.payload.directory : null;
87
109
  }
88
110
 
111
+ export interface DirectoryHandlers {
112
+ onDirectory(directory: HostDirectory): void;
113
+ onError(message: string): void;
114
+ }
115
+
116
+ /**
117
+ * Follows the selected daemon's saved hosts and returns the cleanup. `observe()` throws
118
+ * synchronously when the daemon is offline or too old, so that is reported like any other failure.
119
+ */
120
+ export function observeDirectory(paseo: HostsApi, handlers: DirectoryHandlers): () => void {
121
+ try {
122
+ const subscription = paseo.hosts.observe();
123
+ const stop = subscription.subscribe({
124
+ snapshot: handlers.onDirectory,
125
+ update: (message) => {
126
+ const directory = directoryFromMessage(message);
127
+ if (directory) handlers.onDirectory(directory);
128
+ },
129
+ error: (cause) => handlers.onError(errorMessage(cause)),
130
+ });
131
+ return () => {
132
+ stop();
133
+ void subscription.release();
134
+ };
135
+ } catch (cause) {
136
+ handlers.onError(errorMessage(cause));
137
+ return () => {};
138
+ }
139
+ }
140
+
89
141
  export interface HostDirectoryState {
90
142
  directory: HostDirectory | null;
91
143
  error: string | null;
92
- refresh(): Promise<void>;
144
+ /** Subscribes again after a failure. */
145
+ retry(): void;
93
146
  }
94
147
 
95
148
  /** Live view of the selected daemon's saved hosts. Resets when the selected host changes. */
96
- export function useHostDirectory(paseo: PaseoApi): HostDirectoryState {
149
+ export function useHostDirectory(paseo: HostsApi): HostDirectoryState {
97
150
  const [directory, setDirectory] = useState<HostDirectory | null>(null);
98
151
  const [error, setError] = useState<string | null>(null);
152
+ const [attempt, setAttempt] = useState(0);
99
153
 
100
154
  useEffect(() => {
101
155
  setDirectory(null);
102
156
  setError(null);
103
- const subscription = paseo.hosts.observe();
104
- const stop = subscription.subscribe({
105
- snapshot: (snapshot) => {
106
- setDirectory(snapshot);
157
+ return observeDirectory(paseo, {
158
+ onDirectory: (next) => {
159
+ setDirectory(next);
107
160
  setError(null);
108
161
  },
109
- update: (message) => {
110
- const next = directoryFromMessage(message);
111
- if (next) setDirectory(next);
112
- },
113
- error: (cause) => setError(errorMessage(cause)),
162
+ onError: setError,
114
163
  });
115
- subscription.ready.catch((cause) => setError(errorMessage(cause)));
116
- return () => {
117
- stop();
118
- void subscription.release();
119
- };
120
- }, [paseo]);
121
-
122
- const refresh = useCallback(async () => {
123
- try {
124
- setDirectory(await paseo.hosts.list());
125
- setError(null);
126
- } catch (cause) {
127
- setError(errorMessage(cause));
128
- }
129
- }, [paseo]);
164
+ }, [paseo, attempt]);
130
165
 
131
- return { directory, error, refresh };
166
+ const retry = useCallback(() => setAttempt((count) => count + 1), []);
167
+ return { directory, error, retry };
132
168
  }
package/client/screen.tsx CHANGED
@@ -1,7 +1,7 @@
1
- import type { HostSummary } from "@getpaseo/client";
2
1
  import type { PluginTheme } from "@getpaseo/plugin";
3
2
  import { type PluginScreenProps, usePaseo } from "@getpaseo/plugin/client";
4
3
  import { Icon, Modal, ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/ui";
4
+ import type { HostSummary } from "@getpaseo/protocol/hosts";
5
5
  import { useMemo, useState } from "react";
6
6
  import { Pressable, Text, type TextStyle, View, type ViewStyle } from "react-native";
7
7
  import {
@@ -57,7 +57,7 @@ type Styles = Record<StyleKey, ViewStyle & TextStyle>;
57
57
  function makeStyles(theme: PluginTheme, compact: boolean): Styles {
58
58
  const c = theme.colors;
59
59
  const button = {
60
- minHeight: 40,
60
+ minHeight: compact ? 44 : 40,
61
61
  paddingHorizontal: 14,
62
62
  borderRadius: 10,
63
63
  alignItems: "center" as const,
@@ -125,17 +125,20 @@ function Action({
125
125
  style,
126
126
  textStyle,
127
127
  disabled,
128
+ accessibilityLabel,
128
129
  }: {
129
130
  label: string;
130
131
  onPress(): void;
131
132
  style: object;
132
133
  textStyle: object;
133
134
  disabled?: boolean;
135
+ /** Defaults to `label`; set it when several buttons share a label. */
136
+ accessibilityLabel?: string;
134
137
  }) {
135
138
  return (
136
139
  <Pressable
137
140
  accessibilityRole="button"
138
- accessibilityLabel={label}
141
+ accessibilityLabel={accessibilityLabel ?? label}
139
142
  accessibilityState={{ disabled: !!disabled }}
140
143
  disabled={disabled}
141
144
  onPress={onPress}
@@ -189,7 +192,7 @@ function HostForm({
189
192
  styles: Styles;
190
193
  theme: PluginTheme;
191
194
  onCancel(): void;
192
- onDone(message: string): Promise<void>;
195
+ onDone(message: string): void;
193
196
  }) {
194
197
  const paseo = usePaseo();
195
198
  const host = target.kind === "edit" ? target.host : null;
@@ -198,7 +201,7 @@ function HostForm({
198
201
  const [metadata, setMetadata] = useState(host ? formatMetadata(host.metadata) : "");
199
202
  const [sourceKey, setSourceKey] = useState("");
200
203
  const mismatch = host?.error?.code === "server_id_mismatch";
201
- const [replaceIdentity, setReplaceIdentity] = useState(mismatch);
204
+ const [replaceIdentity, setReplaceIdentity] = useState(false);
202
205
  const [error, setError] = useState<string | null>(null);
203
206
  const [pending, setPending] = useState(false);
204
207
 
@@ -217,12 +220,12 @@ function HostForm({
217
220
  try {
218
221
  if (add?.ok) {
219
222
  await paseo.hosts.add(add.value);
220
- await onDone("Host added");
223
+ onDone("Host added");
221
224
  } else if (host && update?.ok && Object.keys(update.value).length > 0) {
222
225
  await paseo.hosts.update(host.registrationId, update.value);
223
- await onDone("Host updated");
226
+ onDone("Host updated");
224
227
  } else {
225
- await onDone("No changes to save");
228
+ onCancel();
226
229
  }
227
230
  } catch (cause) {
228
231
  setError(errorMessage(cause));
@@ -245,7 +248,7 @@ function HostForm({
245
248
  host
246
249
  ? `Currently ${host.targetDescription}. Saved credentials are never shown.${
247
250
  mismatch
248
- ? " This host reached a different daemon. Enter the connection again and accept the new identity to keep it."
251
+ ? " This host reached a different daemon. Enter the connection again and tick the box below to accept the new identity."
249
252
  : ""
250
253
  }`
251
254
  : "Saved on this daemon. Unreachable hosts are kept and retried."
@@ -254,6 +257,7 @@ function HostForm({
254
257
  {host && connection.trim() ? (
255
258
  <Pressable
256
259
  accessibilityRole="checkbox"
260
+ accessibilityLabel="Accept a different server identity"
257
261
  accessibilityState={{ checked: replaceIdentity }}
258
262
  onPress={() => setReplaceIdentity((value) => !value)}
259
263
  style={styles.row}
@@ -295,7 +299,7 @@ function HostForm({
295
299
  value={sourceKey}
296
300
  onChangeText={setSourceKey}
297
301
  placeholder="Optional"
298
- hint="Names this registration so repeating the same add returns the saved entry."
302
+ hint="Names this registration. Repeating an add with the same connection, label and metadata returns the saved entry."
299
303
  />
300
304
  )}
301
305
  {error ? (
@@ -353,7 +357,7 @@ function HostCard({
353
357
  </View>
354
358
  <Text style={styles.mono}>{host.targetDescription}</Text>
355
359
  <Text style={styles.mono}>
356
- Server {host.serverId ?? "not learned yet"} | Source {host.source.key}
360
+ Server {host.serverId ?? "not learned yet"} | Source {host.source.id}
357
361
  </Text>
358
362
  {host.lastOnlineAt ? (
359
363
  <Text style={styles.muted}>Last online {new Date(host.lastOnlineAt).toLocaleString()}</Text>
@@ -380,6 +384,7 @@ function HostCard({
380
384
  <View style={styles.row}>
381
385
  <Action
382
386
  label={host.enabled ? "Disable" : "Enable"}
387
+ accessibilityLabel={`${host.enabled ? "Disable" : "Enable"} ${name}`}
383
388
  onPress={onToggle}
384
389
  disabled={busy}
385
390
  style={styles.button}
@@ -387,6 +392,7 @@ function HostCard({
387
392
  />
388
393
  <Action
389
394
  label="Edit"
395
+ accessibilityLabel={`Edit ${name}`}
390
396
  onPress={onEdit}
391
397
  disabled={busy}
392
398
  style={styles.button}
@@ -394,6 +400,7 @@ function HostCard({
394
400
  />
395
401
  <Action
396
402
  label="Remove"
403
+ accessibilityLabel={`Remove ${name}`}
397
404
  onPress={onRemove}
398
405
  disabled={busy}
399
406
  style={styles.danger}
@@ -407,7 +414,7 @@ function HostCard({
407
414
  export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginScreenProps) {
408
415
  const paseo = usePaseo();
409
416
  const toast = useToast();
410
- const { directory, error, refresh } = useHostDirectory(paseo);
417
+ const { directory, error, retry } = useHostDirectory(paseo);
411
418
  const styles = useMemo(() => makeStyles(theme, layout.compact), [theme, layout.compact]);
412
419
  const [form, setForm] = useState<FormTarget | null>(null);
413
420
  const [removing, setRemoving] = useState<HostSummary | null>(null);
@@ -418,7 +425,6 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
418
425
  try {
419
426
  await action();
420
427
  toast.show(done, { variant: "success" });
421
- await refresh();
422
428
  } catch (cause) {
423
429
  toast.error(errorMessage(cause));
424
430
  } finally {
@@ -426,10 +432,9 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
426
432
  }
427
433
  }
428
434
 
429
- async function finishForm(message: string) {
435
+ function finishForm(message: string) {
430
436
  toast.show(message, { variant: "success" });
431
437
  setForm(null);
432
- await refresh();
433
438
  }
434
439
 
435
440
  const hosts = directory?.hosts ?? [];
@@ -456,7 +461,7 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
456
461
  </Text>
457
462
  <Action
458
463
  label="Retry"
459
- onPress={refresh}
464
+ onPress={retry}
460
465
  style={styles.button}
461
466
  textStyle={styles.buttonText}
462
467
  />
@@ -522,8 +527,9 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
522
527
  <View style={{ gap: 12 }}>
523
528
  <Text style={styles.body}>
524
529
  Remove {removing?.label ?? removing?.targetDescription} from this daemon? Agents
525
- already running on it keep running. If another process registered this host, it can
526
- add it again. Disable the host instead to keep it saved and switched off.
530
+ already running on it keep running on their own daemon, but this daemon can no longer
531
+ reach them. If another process registered this host, it can add it again. Disable the
532
+ host instead to keep it saved and switched off.
527
533
  </Text>
528
534
  <View style={[styles.row, { justifyContent: "flex-end" }]}>
529
535
  <Action
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-connected-hosts",
3
- "version": "0.0.0",
3
+ "version": "0.1.0-next.305.1",
4
4
  "type": "module",
5
5
  "description": "A Paseo plugin that manages the daemon's connected hosts from the UI.",
6
6
  "license": "MIT",
@@ -42,17 +42,17 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@biomejs/biome": "catalog:",
46
- "@getpaseo/cli": "catalog:",
47
- "@getpaseo/client": "catalog:",
48
- "@getpaseo/plugin": "catalog:",
49
- "@getpaseo/protocol": "catalog:",
50
- "@tanstack/react-query": "catalog:",
51
- "@types/node": "catalog:",
52
- "@types/react": "catalog:",
53
- "react": "catalog:",
54
- "react-native": "catalog:",
55
- "typescript": "catalog:",
56
- "vitest": "catalog:"
45
+ "@biomejs/biome": "^2.5.10",
46
+ "@getpaseo/cli": "0.13.0",
47
+ "@getpaseo/client": "0.13.0",
48
+ "@getpaseo/plugin": "0.13.0",
49
+ "@getpaseo/protocol": "0.13.0",
50
+ "@tanstack/react-query": "^5.102.3",
51
+ "@types/node": "^24.10.1",
52
+ "@types/react": "^19.2.18",
53
+ "react": "19.1.0",
54
+ "react-native": "0.81.5",
55
+ "typescript": "^7.0.2",
56
+ "vitest": "^5.0.0"
57
57
  }
58
58
  }