@xpufx/paseo-helper-demo 0.2.0 → 0.2.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
@@ -55,3 +55,24 @@ The modal navbar renders two ways, switchable in the Settings tab (`navigationSt
55
55
  - `helper-demo.agent-identity`: active agent identity/session for self-inspection.
56
56
  - `helper-demo.beacon-set` / `beacon-blink` / `beacon-clear`: workspace status beacon control.
57
57
  - `helper-demo.settings`: persisted settings (`showCpuUsage`, `accentPillLabel`, `pollingRate`, `navigationStyle`, `highCpuThreshold`, flair fields).
58
+
59
+ ## TEMP: upstream server settings handle (issue #62)
60
+
61
+ A **temporary** sidebar page (`Server Settings (temp)`) demonstrates the
62
+ upstream server-side settings handle added in Paseo `0.9.0-beta.1` (upstream
63
+ PR #4674), *not* our helper's `PluginStorage`/`registerSettingsRpc` layer:
64
+
65
+ - `index.server.ts` calls `server.registerSettings(defineSettings({ id: "server-demo", ... }))`
66
+ and keeps the returned `PluginSettings` handle.
67
+ - The daemon reads through `handle.read()` and subscribes through
68
+ `handle.subscribe()` (see `server/server-settings.ts`).
69
+ - The page (`client/server-settings.tsx`) renders the daemon's handle state
70
+ (`status` / `revision` / `values` / `subscribe()` event count) and writes the
71
+ same document through the host's auto-registered `settings.server-demo.*` RPC.
72
+ - The document lives at `~/.paseo/plugin-settings/paseo-helper-demo/server-demo.json`.
73
+
74
+ On hosts older than `0.9.0-beta.1`, `registerSettings()` returns `void`; the page
75
+ then reports the handle as unavailable and points at the SDK version. This page
76
+ is scaffolding for the #62 demo and is not wired into the pill/modal surfaces.
77
+
78
+ Tests: `npm test --workspace plugins/demo` (or `npx vitest run` in `plugins/demo`).
@@ -0,0 +1,350 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Text } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import {
5
+ Badge,
6
+ Button,
7
+ Card,
8
+ CodeBlock,
9
+ FormRow,
10
+ KeyValue,
11
+ KeyValueGroup,
12
+ ModalBody,
13
+ SectionHeader,
14
+ Stack,
15
+ StatusDot,
16
+ TextInput,
17
+ Toggle,
18
+ registerSidebarSurface,
19
+ triggerHaptic,
20
+ usePluginTheme,
21
+ type SidebarSurfaceRegistrar,
22
+ } from "paseo-plugin-helper/client";
23
+ import { useRpcQuery, useRpcMutation } from "paseo-plugin-helper/core";
24
+ import {
25
+ DEMO_SERVER_SETTINGS_EXPECTED_PATH,
26
+ DEMO_SERVER_SETTINGS_ID,
27
+ DemoSettingsSchema,
28
+ demoSettingsRpc,
29
+ demoSettingsSnapshotContract,
30
+ type DemoSettingsValues,
31
+ type ServerSettingsSnapshot,
32
+ } from "../shared/server-settings.js";
33
+
34
+ /**
35
+ * TEMP DEMO (issue #62) - not a production surface.
36
+ *
37
+ * Shows the upstream server-side settings handle for `id: "server-demo"`:
38
+ * the daemon's `registerSettings()` returned a `PluginSettings` handle and
39
+ * reads/subscribes through it. The write path is the host's auto-registered
40
+ * `settings.server-demo.*` RPC (same document the host `useSettings()` uses),
41
+ * never our helper `PluginStorage`/`registerSettingsRpc` layer.
42
+ */
43
+
44
+ function statusBadge(snapshot: ServerSettingsSnapshot | undefined) {
45
+ if (!snapshot || snapshot.status === "uninitialized") {
46
+ return { variant: "warning" as const, label: "Awaiting first read" };
47
+ }
48
+ if (snapshot.status === "invalid") {
49
+ return { variant: "danger" as const, label: "Invalid document" };
50
+ }
51
+ return { variant: "success" as const, label: "ready" };
52
+ }
53
+
54
+ function handleSource(snapshot: ServerSettingsSnapshot | undefined): string {
55
+ if (!snapshot) return "reading server handle...";
56
+ if (!snapshot.handleAvailable) return "unavailable (SDK < 0.9.0-beta.1)";
57
+ if (snapshot.subscribed) return "registerSettings() -> read() + subscribe()";
58
+ return "registerSettings() -> read() (subscribe inactive)";
59
+ }
60
+
61
+ export function ServerSettingsDemoSurface(_props: PluginSurfaceProps) {
62
+ const { colors, typography } = usePluginTheme();
63
+ const [label, setLabel] = useState<string>("");
64
+
65
+ // Snapshot reflects what the DAEMON observed on the handle; each poll issues a
66
+ // fresh handle.read() server-side (see handleGetServerSettingsSnapshot).
67
+ const { data, isLoading, refetch, error } = useRpcQuery(
68
+ demoSettingsSnapshotContract,
69
+ {},
70
+ { refetchInterval: 2000 },
71
+ );
72
+
73
+ // The write side of the exact same document, through upstream's host RPC.
74
+ const readThroughRpc = useRpcQuery(demoSettingsRpc.read, {}, { refetchInterval: 5000 });
75
+ const { mutate: writeSettings, isPending: isSaving } = useRpcMutation(demoSettingsRpc.write, {
76
+ onSuccess: () => {
77
+ triggerHaptic("success");
78
+ void refetch();
79
+ void readThroughRpc.refetch();
80
+ },
81
+ });
82
+ const { mutate: resetSettings, isPending: isResetting } = useRpcMutation(demoSettingsRpc.reset, {
83
+ onSuccess: () => {
84
+ triggerHaptic("warning");
85
+ void refetch();
86
+ void readThroughRpc.refetch();
87
+ },
88
+ });
89
+
90
+ const rpcRead = readThroughRpc.data;
91
+ const values: DemoSettingsValues | undefined =
92
+ rpcRead?.status === "ready" ? DemoSettingsSchema.parse(rpcRead.values) : undefined;
93
+ const revision = data?.revision ?? rpcRead?.revision ?? "missing";
94
+
95
+ useEffect(() => {
96
+ if (values && label === "") setLabel(String(values.label));
97
+ }, [values, label]);
98
+
99
+ const badge = statusBadge(data);
100
+ const eventHistory = useMemo(() => {
101
+ if (!data?.lastEventAt) return "no subscribe() events observed yet";
102
+ return `${data.eventCount} event(s); last ${data.lastEventAt} (${data.lastEventStatus})`;
103
+ }, [data]);
104
+
105
+ const handleAvailable = data?.handleAvailable ?? false;
106
+ const controlsDisabled = !handleAvailable || isSaving || isResetting || !values;
107
+
108
+ return (
109
+ <ModalBody
110
+ headerMode="pinned"
111
+ refreshing={isLoading}
112
+ onRefresh={() => {
113
+ void refetch();
114
+ }}
115
+ >
116
+ <Stack gap={12}>
117
+ <Card variant="elevated">
118
+ <Card.Header
119
+ title="TEMP — Upstream server settings handle (#62)"
120
+ subtitle="registerSettings() -> read()/subscribe(), read on the daemon process"
121
+ badge={<Badge label="DEMO" variant="warning" />}
122
+ />
123
+
124
+ <KeyValueGroup columns={2}>
125
+ <KeyValue
126
+ label="Settings id"
127
+ value={data?.settingsId ?? DEMO_SERVER_SETTINGS_ID}
128
+ copyable
129
+ />
130
+ <KeyValue label="Schema version" value={String(data?.schemaVersion ?? 1)} />
131
+ <KeyValue label="Source" value={handleSource(data)} />
132
+ <KeyValue
133
+ label="Handle available"
134
+ value={handleAvailable ? "yes" : "no"}
135
+ subValue={
136
+ handleAvailable
137
+ ? "returned by registerSettings()"
138
+ : "older host runtime returned void"
139
+ }
140
+ />
141
+ </KeyValueGroup>
142
+
143
+ {data?.unavailableReason ? (
144
+ <Text style={{ color: colors.statusWarning, ...typography.caption }}>
145
+ {data.unavailableReason}
146
+ </Text>
147
+ ) : null}
148
+ </Card>
149
+
150
+ <Card variant="elevated">
151
+ <Card.Header
152
+ title="handle.read() snapshot"
153
+ subtitle="State the daemon process observed, straight from the upstream handle"
154
+ badge={<Badge label={badge.label} variant={badge.variant} />}
155
+ />
156
+ <KeyValueGroup columns={2}>
157
+ <KeyValue
158
+ label="Status"
159
+ value={data?.status ?? "loading"}
160
+ subValue={data?.error ?? undefined}
161
+ />
162
+ <KeyValue
163
+ label="Revision"
164
+ value={revision}
165
+ subValue="sha256 of the stored document"
166
+ copyable={revision !== "missing"}
167
+ />
168
+ <KeyValue label="Daemon read() calls" value={String(data?.readCount ?? 0)} />
169
+ <KeyValue
170
+ label="Subscribe active"
171
+ value={data?.subscribed ? "yes" : "no"}
172
+ subValue={eventHistory}
173
+ />
174
+ </KeyValueGroup>
175
+
176
+ <SectionHeader title="Values seen by the server handle" />
177
+ {data?.values ? (
178
+ <KeyValueGroup columns={2}>
179
+ <KeyValue label="label" value={String(data.values.label)} copyable />
180
+ <KeyValue label="enabled" value={String(data.values.enabled)} />
181
+ <KeyValue label="threshold" value={String(data.values.threshold)} />
182
+ </KeyValueGroup>
183
+ ) : (
184
+ <Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
185
+ No ready values yet. The document may not exist on disk until the first write.
186
+ </Text>
187
+ )}
188
+
189
+ <Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
190
+ Stored at {data?.expectedPath ?? DEMO_SERVER_SETTINGS_EXPECTED_PATH}
191
+ </Text>
192
+ </Card>
193
+
194
+ <Card variant="elevated">
195
+ <Card.Header
196
+ title="Write path (host settings.<id>.* RPC)"
197
+ subtitle="Same document, updated through upstream's persisted RPC — then re-read on the daemon"
198
+ />
199
+
200
+ <FormRow
201
+ label="Label"
202
+ description="Persisted into the host-scoped document the handle reads"
203
+ >
204
+ <TextInput
205
+ value={label}
206
+ onChangeText={setLabel}
207
+ placeholder="upstream handle demo"
208
+ />
209
+ </FormRow>
210
+
211
+ <FormRow label="Enabled" description="Boolean inside the same document" layout="inline">
212
+ <Toggle
213
+ value={Boolean(values?.enabled)}
214
+ onValueChange={(next) => {
215
+ triggerHaptic("light");
216
+ writeSettings({
217
+ revision,
218
+ values: {
219
+ label: values?.label ?? label,
220
+ enabled: next,
221
+ threshold: values?.threshold ?? 70,
222
+ },
223
+ });
224
+ }}
225
+ />
226
+ </FormRow>
227
+
228
+ <FormRow
229
+ label="Threshold"
230
+ description={`Integer 0-100 (current: ${values?.threshold ?? "—"})`}
231
+ >
232
+ <TextInput
233
+ value={values ? String(values.threshold) : ""}
234
+ keyboardType="numeric"
235
+ onChangeText={(text) => {
236
+ if (text.trim() === "") return;
237
+ const threshold = Number.parseInt(text, 10);
238
+ if (Number.isNaN(threshold)) return;
239
+ writeSettings({
240
+ revision,
241
+ values: {
242
+ label: values?.label ?? label,
243
+ enabled: values?.enabled ?? true,
244
+ threshold,
245
+ },
246
+ });
247
+ }}
248
+ />
249
+ </FormRow>
250
+
251
+ <Button
252
+ label={isSaving ? "Saving..." : "Save label to host settings"}
253
+ variant="primary"
254
+ size="sm"
255
+ disabled={controlsDisabled}
256
+ onPress={() => {
257
+ triggerHaptic("medium");
258
+ writeSettings({
259
+ revision,
260
+ values: {
261
+ label: label.trim() || "upstream handle demo",
262
+ enabled: values?.enabled ?? true,
263
+ threshold: values?.threshold ?? 70,
264
+ },
265
+ });
266
+ }}
267
+ />
268
+
269
+ <Stack gap={4}>
270
+ <Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
271
+ RPC read status: {readThroughRpc.data?.status ?? (readThroughRpc.isLoading ? "loading" : "—")}
272
+ {readThroughRpc.data?.status === "invalid"
273
+ ? ` — ${readThroughRpc.data.error}`
274
+ : ""}
275
+ </Text>
276
+ <Button
277
+ label={isResetting ? "Resetting..." : "Reset host settings document"}
278
+ variant="ghost"
279
+ size="sm"
280
+ disabled={!handleAvailable || isResetting}
281
+ onPress={() => {
282
+ triggerHaptic("warning");
283
+ resetSettings({ revision });
284
+ }}
285
+ />
286
+ </Stack>
287
+
288
+ {error ? (
289
+ <Text style={{ color: colors.statusDanger, ...typography.caption }}>
290
+ Snapshot error: {error.message}
291
+ </Text>
292
+ ) : null}
293
+ </Card>
294
+
295
+ <Card variant="elevated">
296
+ <Card.Header
297
+ title="What this demo proves"
298
+ subtitle="Upstream 0.9 server settings handle vs our helper storage layer"
299
+ />
300
+ <Stack gap={4}>
301
+ <Text style={{ color: colors.foreground, ...typography.bodyStrong }}>
302
+ <StatusDot variant={handleAvailable ? "success" : "warning"} />{" "}
303
+ {handleAvailable
304
+ ? "registerSettings() returned a handle"
305
+ : "handle unavailable on this runtime"}
306
+ </Text>
307
+ <Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
308
+ The daemon reads and subscribes through the handle returned by
309
+ registerSettings(); nothing here touches paseo-plugin-helper's
310
+ PluginStorage/registerSettingsRpc. The host owns persistence
311
+ (revision-checked, atomic) and the client writes through the same
312
+ document via the auto-registered settings RPC.
313
+ </Text>
314
+ </Stack>
315
+
316
+ <CodeBlock
317
+ language="typescript"
318
+ title="server/index.server.ts"
319
+ code={`const settings = server.registerSettings(defineSettings({
320
+ id: "server-demo", scope: "host", version: 1,
321
+ schema: z.object({ label: z.string().default("upstream handle demo"), ... }),
322
+ }));
323
+
324
+ // Server-side read + change subscription (upstream 0.9.0-beta.1+, PR #4674):
325
+ settings.subscribe((state) => { /* { status, revision, values|error } */ });
326
+ const state = await settings.read();
327
+ `}
328
+ copyable
329
+ />
330
+ </Card>
331
+ </Stack>
332
+ </ModalBody>
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Registers the temp demo surface + sidebar entry. Returns a disposer, or null
338
+ * when the host does not expose surface registration.
339
+ */
340
+ export function registerServerSettingsDemoSurface(
341
+ client: SidebarSurfaceRegistrar,
342
+ ): (() => void) | null {
343
+ if (!("addSurface" in client) || !("addSidebarItem" in client)) return null;
344
+ return registerSidebarSurface(client, {
345
+ id: "helper-demo-server-settings",
346
+ title: "Server Settings (temp)",
347
+ icon: "Sliders",
348
+ Component: ServerSettingsDemoSurface,
349
+ });
350
+ }
@@ -24,16 +24,23 @@ export interface ClipboardEnvironment {
24
24
  * `react-native-web` `Clipboard.setString` reports success even when its
25
25
  * `document.execCommand("copy")` fails, which leaves the previous clipboard
26
26
  * item in place while the UI claims success (xpufx-org/paseo#278); it is only
27
- * usable off-DOM (native), where it is the real platform clipboard. In a DOM
28
- * the checked `execCommand` fallback is preferred to that unverifiable path.
27
+ * usable off-DOM (native), where it is the real platform clipboard. The same
28
+ * applies to any `setStringAsync` whose DOM fallback is that unverified
29
+ * `execCommand`. In a DOM the checked `execCommand` fallback is preferred to
30
+ * those unverifiable paths.
29
31
  */
30
32
  export function clipboardTierOrder(env: ClipboardEnvironment): ClipboardTier[] {
31
33
  const tiers: ClipboardTier[] = [];
32
34
  if (env.hasNavigatorClipboard) tiers.push("navigator");
33
35
  if (env.hasHostCopyText) tiers.push("host");
34
- if (env.hasRnSetStringAsync) {
36
+ // On a DOM both RN-web paths are unverifiable: `setString` reports success
37
+ // even when its `execCommand` no-ops, and a `setStringAsync` that falls back
38
+ // to it does the same. Off-DOM they are the real native clipboard, so they
39
+ // lead there; on a DOM only the checked `execCommand` fallback is honest.
40
+ const rnDomOk = !env.isDom;
41
+ if (env.hasRnSetStringAsync && rnDomOk) {
35
42
  tiers.push("rnAsync");
36
- } else if (env.hasRnClipboard && !env.isDom) {
43
+ } else if (env.hasRnClipboard && rnDomOk) {
37
44
  tiers.push("rnSync");
38
45
  }
39
46
  tiers.push("execCommand");
@@ -0,0 +1,14 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { contributeClient } from "./client/pill.js";
3
+ import { registerServerSettingsDemoSurface } from "./client/server-settings.js";
4
+
5
+ export default function contribute(client: PluginClientContext) {
6
+ const cleanupPill = contributeClient(client);
7
+ // TEMP DEMO (issue #62): a sidebar page showing the upstream server
8
+ // settings handle. Not wired into the production pill/modal surfaces.
9
+ const cleanupSettingsSurface = registerServerSettingsDemoSurface(client);
10
+ return () => {
11
+ cleanupPill();
12
+ cleanupSettingsSurface?.();
13
+ };
14
+ }
@@ -0,0 +1,55 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import {
3
+ demoSettingsContract,
4
+ getDemoDataRpc,
5
+ triggerDemoActionRpc,
6
+ demoAgentIdentityContract,
7
+ demoBeaconSetContract,
8
+ demoBeaconBlinkContract,
9
+ demoBeaconClearContract,
10
+ } from "./shared/demo.js";
11
+ import {
12
+ handleGetDemoData,
13
+ handleTriggerDemoAction,
14
+ handleGetAgentIdentity,
15
+ handleDemoBeaconSet,
16
+ handleDemoBeaconBlink,
17
+ handleDemoBeaconClear,
18
+ settingsHandlers,
19
+ backgroundWorker,
20
+ demoBeacon,
21
+ log,
22
+ } from "./server/demo.js";
23
+ import { suiteSettings, suiteSettingsHandlers } from "./server/suite-settings.js";
24
+ import { demoSettingsSnapshotContract } from "./shared/server-settings.js";
25
+ import {
26
+ handleGetServerSettingsSnapshot,
27
+ registerDemoServerSettings,
28
+ } from "./server/server-settings.js";
29
+
30
+ export default function contribute(server: PluginServerContext) {
31
+ // TEMP DEMO (issue #62): upstream registerSettings() -> read()/subscribe().
32
+ const serverSettings = registerDemoServerSettings(server);
33
+ server.handle(demoSettingsSnapshotContract, handleGetServerSettingsSnapshot);
34
+ server.handle(demoSettingsContract.get, settingsHandlers.get);
35
+ server.handle(demoSettingsContract.update, settingsHandlers.update);
36
+ server.handle(demoSettingsContract.reset, settingsHandlers.reset);
37
+ server.handle(suiteSettings.contract.get, suiteSettingsHandlers.get);
38
+ server.handle(suiteSettings.contract.update, suiteSettingsHandlers.update);
39
+ server.handle(suiteSettings.contract.reset, suiteSettingsHandlers.reset);
40
+ server.handle(getDemoDataRpc, handleGetDemoData);
41
+ server.handle(triggerDemoActionRpc, handleTriggerDemoAction);
42
+ server.handle(demoAgentIdentityContract, handleGetAgentIdentity);
43
+ server.handle(demoBeaconSetContract, handleDemoBeaconSet);
44
+ server.handle(demoBeaconBlinkContract, handleDemoBeaconBlink);
45
+ server.handle(demoBeaconClearContract, handleDemoBeaconClear);
46
+
47
+ log.info("Helper demo server handlers registered");
48
+
49
+ return () => {
50
+ backgroundWorker.stop();
51
+ demoBeacon.stopAll();
52
+ serverSettings.dispose();
53
+ log.info("Helper demo server background task stopped");
54
+ };
55
+ }
package/package.json CHANGED
@@ -2,10 +2,11 @@
2
2
  "name": "@xpufx/paseo-helper-demo",
3
3
  "private": false,
4
4
  "license": "MIT",
5
- "version": "0.2.0",
5
+ "version": "0.2.1",
6
6
  "scripts": {
7
7
  "stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
8
- "typecheck": "tsc --noEmit"
8
+ "typecheck": "tsc --noEmit",
9
+ "test": "vitest run --passWithNoTests"
9
10
  },
10
11
  "devDependencies": {
11
12
  "@getpaseo/plugin": "0.9.0-beta.2",
@@ -14,12 +15,15 @@
14
15
  "react": "19.1.0",
15
16
  "react-native": "0.81.5",
16
17
  "typescript": "^5.9.3",
18
+ "vitest": "^3.0.0",
17
19
  "zod": "^4.4.3"
18
20
  },
19
21
  "files": [
20
22
  "paseo-plugin.json",
21
23
  "README.md",
22
24
  "LICENSE",
25
+ "index.client.tsx",
26
+ "index.server.ts",
23
27
  "client",
24
28
  "server",
25
29
  "shared",
@@ -0,0 +1,177 @@
1
+ import type {
2
+ PluginHandlerContext,
3
+ PluginServerContext,
4
+ } from "@getpaseo/plugin/server";
5
+ import type { SettingsDefinition } from "@getpaseo/plugin";
6
+ import { createPluginLogger } from "paseo-plugin-helper/server";
7
+ import {
8
+ buildServerSettingsSnapshot,
9
+ demoSettingsDefinition,
10
+ DEMO_SERVER_SETTINGS_EXPECTED_PATH,
11
+ DEMO_SERVER_SETTINGS_ID,
12
+ type ServerSettingsHandleState,
13
+ type ServerSettingsSnapshot,
14
+ } from "../shared/server-settings.js";
15
+
16
+ // Own logger so this module is importable without the demo's background task.
17
+ const log = createPluginLogger("helper-demo", {
18
+ banner: false,
19
+ subsystem: "server-settings",
20
+ });
21
+
22
+ type HandleState = ServerSettingsHandleState;
23
+
24
+ /**
25
+ * Structural shape of upstream's 0.9 `PluginSettings` handle. Typed locally so
26
+ * the module compiles and degrades cleanly on the 0.8 SDK, where
27
+ * `registerSettings()` returns `void` and no handle exists.
28
+ */
29
+ interface SettingsHandleLike {
30
+ read(): Promise<HandleState>;
31
+ subscribe(listener: (state: HandleState) => void | Promise<void>): () => void;
32
+ }
33
+
34
+ interface RegisterSettingsCapable {
35
+ registerSettings(definition: SettingsDefinition<typeof demoSettingsDefinition.schema>): unknown;
36
+ }
37
+
38
+ function isSettingsHandle(value: unknown): value is SettingsHandleLike {
39
+ if (typeof value !== "object" || value === null) return false;
40
+ const candidate = value as { read?: unknown; subscribe?: unknown };
41
+ return typeof candidate.read === "function" && typeof candidate.subscribe === "function";
42
+ }
43
+
44
+ /**
45
+ * TEMP DEMO (issue #62): owns the upstream server settings handle for this
46
+ * plugin installation. Everything the demo shows about "server settings" comes
47
+ * from `handle.read()`/`handle.subscribe()` below - never from the helper's
48
+ * `PluginStorage`/`registerSettingsRpc` layer.
49
+ */
50
+ class DemoSettingsHandle {
51
+ private readonly handle: SettingsHandleLike | null;
52
+ private readonly unavailableReason: string | null;
53
+ private current: HandleState | null = null;
54
+ private readCount = 0;
55
+ private eventCount = 0;
56
+ private lastEventAt: string | null = null;
57
+ private lastEventStatus: "ready" | "invalid" | null = null;
58
+ private subscribed = false;
59
+ private unsubscribe: (() => void) | null = null;
60
+
61
+ constructor(handle: SettingsHandleLike | null, unavailableReason: string | null) {
62
+ this.handle = handle;
63
+ this.unavailableReason = unavailableReason;
64
+ }
65
+
66
+ /**
67
+ * Subscribe before the first read so a concurrent client write during startup
68
+ * is not missed; the initial `read()` below establishes the baseline.
69
+ */
70
+ async initialize(): Promise<void> {
71
+ if (this.handle) {
72
+ this.unsubscribe = this.handle.subscribe((state) => {
73
+ this.eventCount += 1;
74
+ this.lastEventAt = new Date().toISOString();
75
+ this.lastEventStatus = state.status === "ready" ? "ready" : "invalid";
76
+ this.current = state;
77
+ log.info("Demo upstream settings handle observed a change", {
78
+ status: state.status,
79
+ revision: state.revision,
80
+ });
81
+ });
82
+ this.subscribed = true;
83
+ }
84
+ await this.refresh();
85
+ }
86
+
87
+ /** Reads through the upstream handle and returns the current snapshot. */
88
+ async refresh(): Promise<ServerSettingsSnapshot> {
89
+ if (!this.handle) return this.snapshot();
90
+ try {
91
+ this.current = await this.handle.read();
92
+ } catch (error) {
93
+ const detail = error instanceof Error ? error.message : String(error);
94
+ this.current = { status: "invalid", revision: "unknown", error: detail };
95
+ }
96
+ this.readCount += 1;
97
+ return this.snapshot();
98
+ }
99
+
100
+ dispose(): void {
101
+ this.unsubscribe?.();
102
+ this.unsubscribe = null;
103
+ this.subscribed = false;
104
+ }
105
+
106
+ private snapshot(): ServerSettingsSnapshot {
107
+ return buildServerSettingsSnapshot({
108
+ settingsId: DEMO_SERVER_SETTINGS_ID,
109
+ expectedPath: DEMO_SERVER_SETTINGS_EXPECTED_PATH,
110
+ handleAvailable: this.handle !== null,
111
+ unavailableReason: this.unavailableReason,
112
+ subscribed: this.subscribed,
113
+ readCount: this.readCount,
114
+ eventCount: this.eventCount,
115
+ lastEventAt: this.lastEventAt,
116
+ lastEventStatus: this.lastEventStatus,
117
+ schemaVersion: demoSettingsDefinition.version,
118
+ current: this.current,
119
+ });
120
+ }
121
+ }
122
+
123
+ let activeHandle: DemoSettingsHandle | null = null;
124
+
125
+ /**
126
+ * Registers the host-scoped demo settings document via upstream
127
+ * `registerSettings()` and captures the returned handle. Called once from
128
+ * `contribute()`; the async `initialize()` is intentionally not awaited so the
129
+ * synchronous contribution contract is preserved.
130
+ */
131
+ export function registerDemoServerSettings(server: PluginServerContext): DemoSettingsHandle {
132
+ const capable = server as unknown as RegisterSettingsCapable;
133
+ const returned = capable.registerSettings(demoSettingsDefinition);
134
+
135
+ let handle: SettingsHandleLike | null = null;
136
+ let reason: string | null = null;
137
+ if (isSettingsHandle(returned)) {
138
+ handle = returned;
139
+ } else {
140
+ reason =
141
+ "registerSettings() did not return a PluginSettings handle. On Paseo < 0.9.0-beta.1 it returns void (the handle shipped in 0.9.0-beta.1, upstream PR #4674); this build is running against an older host runtime.";
142
+ }
143
+
144
+ const demo = new DemoSettingsHandle(handle, reason);
145
+ activeHandle = demo;
146
+ void demo.initialize().catch((error) => {
147
+ log.error("Demo upstream settings handle failed to initialize", error);
148
+ });
149
+ return demo;
150
+ }
151
+
152
+ function unavailableSnapshot(): ServerSettingsSnapshot {
153
+ return buildServerSettingsSnapshot({
154
+ settingsId: DEMO_SERVER_SETTINGS_ID,
155
+ expectedPath: DEMO_SERVER_SETTINGS_EXPECTED_PATH,
156
+ handleAvailable: false,
157
+ unavailableReason: "Settings handle was not registered (plugin server did not initialize it).",
158
+ subscribed: false,
159
+ readCount: 0,
160
+ eventCount: 0,
161
+ lastEventAt: null,
162
+ lastEventStatus: null,
163
+ schemaVersion: demoSettingsDefinition.version,
164
+ current: null,
165
+ });
166
+ }
167
+
168
+ /** RPC handler backing the temp demo surface's snapshot read. */
169
+ export async function handleGetServerSettingsSnapshot(
170
+ _input: Record<string, never>,
171
+ _context?: PluginHandlerContext,
172
+ ): Promise<ServerSettingsSnapshot> {
173
+ if (!activeHandle) return unavailableSnapshot();
174
+ return activeHandle.refresh();
175
+ }
176
+
177
+ export type { DemoSettingsHandle };
@@ -0,0 +1,143 @@
1
+ import { defineSettings, settingsRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import { defineContract } from "paseo-plugin-helper/shared";
4
+
5
+ /**
6
+ * TEMP DEMO (issue #62): upstream server-side settings handle.
7
+ *
8
+ * The `SettingsDefinition` below is the single source of truth for a
9
+ * host-scoped settings document. Upstream `registerSettings()` on the server
10
+ * now returns a `PluginSettings` handle with `read()`/`subscribe()`; the client
11
+ * half is the auto-registered `settings.<id>.*` RPC plus the host `useSettings()`
12
+ * hook built on it. This module is intentionally outside our helper's
13
+ * `PluginStorage`/`registerSettingsRpc` layer - that is the point of the demo.
14
+ */
15
+ export const DEMO_SERVER_SETTINGS_ID = "server-demo";
16
+
17
+ /** Upstream store layout, shown in the demo UI: `~/.paseo/plugin-settings/<pluginId>/<id>.json`. */
18
+ export const DEMO_SERVER_SETTINGS_EXPECTED_PATH =
19
+ "~/.paseo/plugin-settings/paseo-helper-demo/server-demo.json";
20
+
21
+ export const DemoSettingsSchema = z.object({
22
+ label: z.string().min(1).default("upstream handle demo"),
23
+ enabled: z.boolean().default(true),
24
+ threshold: z.number().int().min(0).max(100).default(70),
25
+ });
26
+
27
+ export type DemoSettingsValues = z.infer<typeof DemoSettingsSchema>;
28
+
29
+ export const demoSettingsDefinition = defineSettings({
30
+ id: DEMO_SERVER_SETTINGS_ID,
31
+ scope: "host",
32
+ version: 1,
33
+ schema: DemoSettingsSchema,
34
+ });
35
+
36
+ /** The host RPC the server handle auto-registers; the client writes through it. */
37
+ export const demoSettingsRpc = settingsRpc(DEMO_SERVER_SETTINGS_ID);
38
+
39
+ /**
40
+ * Server-handle snapshot: what the daemon process has observed via the
41
+ * upstream handle's `read()` and `subscribe()`, not our helper storage.
42
+ */
43
+ export const ServerSettingsSnapshotSchema = z.object({
44
+ settingsId: z.string(),
45
+ expectedPath: z.string(),
46
+ handleAvailable: z.boolean(),
47
+ unavailableReason: z.string().nullable(),
48
+ subscribed: z.boolean(),
49
+ status: z.enum(["uninitialized", "ready", "invalid"]),
50
+ revision: z.string(),
51
+ values: DemoSettingsSchema.nullable(),
52
+ error: z.string().nullable(),
53
+ readCount: z.number(),
54
+ eventCount: z.number(),
55
+ lastEventAt: z.string().nullable(),
56
+ lastEventStatus: z.enum(["ready", "invalid"]).nullable(),
57
+ schemaVersion: z.number(),
58
+ });
59
+
60
+ export type ServerSettingsSnapshot = z.infer<typeof ServerSettingsSnapshotSchema>;
61
+
62
+ /** A single state emitted by the upstream handle (structurally mirrors 0.9's PluginSettingsState). */
63
+ export type ServerSettingsHandleState =
64
+ | { status: "ready"; revision: string; values: unknown }
65
+ | { status: "invalid"; revision: string; error: string };
66
+
67
+ export interface ServerSettingsObservation {
68
+ settingsId: string;
69
+ expectedPath: string;
70
+ handleAvailable: boolean;
71
+ unavailableReason: string | null;
72
+ subscribed: boolean;
73
+ readCount: number;
74
+ eventCount: number;
75
+ lastEventAt: string | null;
76
+ lastEventStatus: "ready" | "invalid" | null;
77
+ schemaVersion: number;
78
+ current: ServerSettingsHandleState | null;
79
+ }
80
+
81
+ /**
82
+ * Pure projection of the daemon's handle observations into the RPC snapshot
83
+ * shape. Kept free of the SDK so it is unit-testable; the server class only
84
+ * tracks counters and calls this.
85
+ */
86
+ export function buildServerSettingsSnapshot(
87
+ observation: ServerSettingsObservation,
88
+ ): ServerSettingsSnapshot {
89
+ const base = {
90
+ settingsId: observation.settingsId,
91
+ expectedPath: observation.expectedPath,
92
+ handleAvailable: observation.handleAvailable,
93
+ unavailableReason: observation.unavailableReason,
94
+ subscribed: observation.subscribed,
95
+ readCount: observation.readCount,
96
+ eventCount: observation.eventCount,
97
+ lastEventAt: observation.lastEventAt,
98
+ lastEventStatus: observation.lastEventStatus,
99
+ schemaVersion: observation.schemaVersion,
100
+ };
101
+ if (!observation.current) {
102
+ return ServerSettingsSnapshotSchema.parse({
103
+ ...base,
104
+ status: "uninitialized",
105
+ revision: "missing",
106
+ values: null,
107
+ error: null,
108
+ });
109
+ }
110
+ if (observation.current.status === "ready") {
111
+ const parsed = DemoSettingsSchema.safeParse(observation.current.values);
112
+ if (!parsed.success) {
113
+ return ServerSettingsSnapshotSchema.parse({
114
+ ...base,
115
+ status: "invalid",
116
+ revision: observation.current.revision,
117
+ values: null,
118
+ error: parsed.error.message,
119
+ });
120
+ }
121
+ return ServerSettingsSnapshotSchema.parse({
122
+ ...base,
123
+ status: "ready",
124
+ revision: observation.current.revision,
125
+ values: parsed.data,
126
+ error: null,
127
+ });
128
+ }
129
+ return ServerSettingsSnapshotSchema.parse({
130
+ ...base,
131
+ status: "invalid",
132
+ revision: observation.current.revision,
133
+ values: null,
134
+ error: observation.current.error,
135
+ });
136
+ }
137
+
138
+ export const demoSettingsSnapshotContract = defineContract({
139
+ name: "helper-demo.server-settings.snapshot",
140
+ description: "Server handle state observed via upstream registerSettings().read()/subscribe()",
141
+ input: z.object({}),
142
+ output: ServerSettingsSnapshotSchema,
143
+ });