opencody-notify 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -6,11 +6,13 @@ OpenCode **V2** server plugin that sends end-to-end encrypted push notifications
6
6
  - OpenCode asks for a permission (Allow once / Always / Deny right from the notification),
7
7
  - the agent asks a question (form).
8
8
 
9
+ It also lets OpenCody send a test notification through the server (since 0.3.0), so the app can check the whole path: OpenCode server → relay → APNs → device.
10
+
9
11
  The push goes through the OpenCody relay; the relay only sees ciphertext. No runtime dependencies.
10
12
 
11
13
  ## 1. Get a pairing string
12
14
 
13
- OpenCody → server settings → *Push notifications* → *Pair*. Copy the single-line pairing string. It is a credential: treat it like a password. One string per iPhone.
15
+ OpenCody → server settings → *Push notifications* → *Pair*. Copy the single-line pairing string. It is a credential: treat it like a password. One string per device (iPhone or iPad).
14
16
 
15
17
  ## 2. Install the plugin (self-hosted V2 server)
16
18
 
@@ -27,7 +29,7 @@ This adds it to the global config (`~/.config/opencode/opencode.jsonc`, applies
27
29
  "package": "opencody-notify",
28
30
  "options": {
29
31
  "label": "homelab", // shown in the notification; default: hostname
30
- "pairings": ["<pairing string iPhone 1>", "<pairing string iPhone 2>"] // optional, see env below
32
+ "pairings": ["<pairing string device 1>", "<pairing string device 2>"] // optional, see env below
31
33
  }
32
34
  }
33
35
  ]
@@ -82,6 +84,20 @@ The server must be able to reach `https://push.willsoftwaresolutions.de` (outbou
82
84
  - Each push carries a dedupe key (`<sessionID>@<idle time>` or `req:<requestID>`) inside the ciphertext and a SHA-256 prefix of it as APNs collapse id, so OpenCody shows one notification per event even when the app also notices it live.
83
85
  - Relay answers `401`/`410` mean the pairing was removed: re-pair in OpenCody and replace the string.
84
86
 
87
+ ## 5. Test notification (OpenCody → server → device)
88
+
89
+ OpenCody's *Send Test Notification* calls the plugin through OpenCode's plugin RPC route (OpenCode 2.1 or later), behind the server's normal authentication:
90
+
91
+ ```
92
+ POST /api/rpc/opencody-notify/test {"input": {"deviceId": "<deviceId from the pairing string>"}}
93
+ → 200 {"output": {"server": "<label>"}} push sent, relay accepted it
94
+ → 400 {"type": "not_paired", "data": {"pairings": <n>}, …} no pairing for this device on this server
95
+ → 400 {"type": "relay_failed", "data": {"status": <relay status, 0 = unreachable>}, …}
96
+ → 400 {"type": "rpc.unavailable", …} plugin not loaded, or older than 0.3.0
97
+ ```
98
+
99
+ It sends one encrypted message of type `test` to that device only. No session, no LLM call, nothing stored on the server. The route is answered at the server's default location (its working directory), so install the plugin globally (`opencode plugin add`) for the test to find it.
100
+
85
101
  ## Development
86
102
 
87
103
  Protocol: `SPEC.md` in the source repository.
package/dist/index.d.ts CHANGED
@@ -19,6 +19,9 @@ interface SessionInfo {
19
19
  idle?: number;
20
20
  };
21
21
  }
22
+ interface RpcCallContext {
23
+ error(type: string, message: string, data?: unknown): unknown;
24
+ }
22
25
  export interface PluginContext {
23
26
  options?: Record<string, unknown>;
24
27
  location?: {
@@ -34,7 +37,75 @@ export interface PluginContext {
34
37
  sessionID: string;
35
38
  }): Promise<SessionInfo>;
36
39
  };
40
+ /** OpenCode serves registered methods at `POST /api/rpc/<id>/<method>` (2.1+). */
41
+ rpc?: {
42
+ register(definition: object, handlers: Record<string, (input: any, context: RpcCallContext) => Promise<unknown>>): Promise<{
43
+ dispose(): Promise<void>;
44
+ }>;
45
+ };
37
46
  }
47
+ /**
48
+ * `POST /api/rpc/opencody-notify/test {"input":{"deviceId":"…"}}` sends a test push to that
49
+ * device's pairing, so OpenCody can check the whole path server → relay → APNs → device.
50
+ */
51
+ export declare const RPC: {
52
+ readonly id: "opencody-notify";
53
+ readonly methods: {
54
+ readonly test: {
55
+ readonly input: {
56
+ readonly type: "object";
57
+ readonly properties: {
58
+ readonly deviceId: {
59
+ readonly type: "string";
60
+ };
61
+ };
62
+ readonly required: readonly ["deviceId"];
63
+ };
64
+ readonly output: {
65
+ readonly type: "object";
66
+ readonly properties: {
67
+ readonly server: {
68
+ readonly type: "string";
69
+ };
70
+ };
71
+ readonly required: readonly ["server"];
72
+ };
73
+ readonly errors: {
74
+ readonly not_paired: {
75
+ readonly type: "object";
76
+ readonly properties: {
77
+ readonly pairings: {
78
+ readonly type: "number";
79
+ };
80
+ };
81
+ readonly required: readonly ["pairings"];
82
+ };
83
+ readonly relay_failed: {
84
+ readonly type: "object";
85
+ readonly properties: {
86
+ readonly status: {
87
+ readonly type: "number";
88
+ };
89
+ };
90
+ readonly required: readonly ["status"];
91
+ };
92
+ };
93
+ };
94
+ };
95
+ readonly events: {};
96
+ };
97
+ export type TestResult = {
98
+ ok: true;
99
+ } | {
100
+ ok: false;
101
+ error: "not_paired";
102
+ pairings: number;
103
+ } | {
104
+ ok: false;
105
+ error: "relay_failed";
106
+ status: number;
107
+ message: string;
108
+ };
38
109
  export interface NotifierOptions {
39
110
  pairings: Pairing[];
40
111
  label: string;
@@ -47,6 +118,7 @@ export interface NotifierOptions {
47
118
  }
48
119
  export declare function createNotifier(options: NotifierOptions): {
49
120
  onEvent(event: OpenCodeEvent): Promise<void>;
121
+ sendTest(deviceId: string): Promise<TestResult>;
50
122
  };
51
123
  export declare function loadConfig(options?: Record<string, unknown>, env?: Record<string, string | undefined>): {
52
124
  pairings: Pairing[];
@@ -55,6 +127,6 @@ export declare function loadConfig(options?: Record<string, unknown>, env?: Reco
55
127
  };
56
128
  declare const _default: {
57
129
  id: string;
58
- setup(ctx: PluginContext): (() => void) | undefined;
130
+ setup(ctx: PluginContext): () => void;
59
131
  };
60
132
  export default _default;
package/dist/index.js CHANGED
@@ -1,5 +1,24 @@
1
1
  import { hostname } from "node:os";
2
2
  import { CATEGORY, collapseId, encodeMessage, parsePairing, seal, splitPairings } from "./protocol.js";
3
+ /**
4
+ * `POST /api/rpc/opencody-notify/test {"input":{"deviceId":"…"}}` sends a test push to that
5
+ * device's pairing, so OpenCody can check the whole path server → relay → APNs → device.
6
+ */
7
+ export const RPC = {
8
+ id: "opencody-notify",
9
+ methods: {
10
+ test: {
11
+ input: { type: "object", properties: { deviceId: { type: "string" } }, required: ["deviceId"] },
12
+ output: { type: "object", properties: { server: { type: "string" } }, required: ["server"] },
13
+ errors: {
14
+ // Error data is required: OpenCode validates it against these schemas.
15
+ not_paired: { type: "object", properties: { pairings: { type: "number" } }, required: ["pairings"] },
16
+ relay_failed: { type: "object", properties: { status: { type: "number" } }, required: ["status"] },
17
+ },
18
+ },
19
+ },
20
+ events: {},
21
+ };
3
22
  const SKIPPED_INTERRUPTS = new Set(["user", "superseded"]);
4
23
  /**
5
24
  * The session's `time.idle` for this run end, which OpenCody's catch-up keys on. OpenCode sets
@@ -113,10 +132,11 @@ export function createNotifier(options) {
113
132
  }),
114
133
  signal: AbortSignal.timeout(timeoutMs),
115
134
  });
116
- if (res.ok)
117
- return;
118
- const hint = res.status === 401 || res.status === 410 ? " (pairing revoked or invalid, re-pair in OpenCody)" : "";
119
- log(`relay answered ${res.status} for device ${pairing.deviceId.slice(0, 6)}…${hint}`);
135
+ if (!res.ok) {
136
+ const hint = res.status === 401 || res.status === 410 ? " (pairing revoked or invalid, re-pair in OpenCody)" : "";
137
+ log(`relay answered ${res.status} for device ${pairing.deviceId.slice(0, 6)}…${hint}`);
138
+ }
139
+ return res.status;
120
140
  }
121
141
  return {
122
142
  async onEvent(event) {
@@ -133,6 +153,19 @@ export function createNotifier(options) {
133
153
  log(`event handling failed: ${error?.message}`);
134
154
  }
135
155
  },
156
+ async sendTest(deviceId) {
157
+ const pairing = pairings.find((p) => p.deviceId === deviceId);
158
+ if (!pairing)
159
+ return { ok: false, error: "not_paired", pairings: pairings.length };
160
+ const message = { v: 1, type: "test", server: label, sessionID: "", title: "OpenCody", text: "Test notification from your server. Push works.", ts: now() };
161
+ try {
162
+ const status = await post(pairing, message);
163
+ return status >= 200 && status < 300 ? { ok: true } : { ok: false, error: "relay_failed", status, message: `relay answered ${status}` };
164
+ }
165
+ catch (error) {
166
+ return { ok: false, error: "relay_failed", status: 0, message: `relay not reachable: ${error?.message}` };
167
+ }
168
+ },
136
169
  };
137
170
  }
138
171
  export function loadConfig(options = {}, env = process.env) {
@@ -157,16 +190,30 @@ export default {
157
190
  const { pairings, label, errors } = loadConfig(ctx.options);
158
191
  for (const error of errors)
159
192
  log(`ignoring pairing: ${error}`);
160
- if (!pairings.length) {
161
- log("no valid pairing configured (OPENCODY_PAIRING or plugin option `pairings`); push disabled");
162
- return;
163
- }
164
193
  const notifier = createNotifier({
165
194
  pairings,
166
195
  label,
167
196
  log,
168
197
  getSession: (sessionID) => ctx.session.get({ sessionID }),
169
198
  });
199
+ // Registered even without pairings, so OpenCody can tell "plugin missing" from "this device's pairing missing".
200
+ const registration = ctx.rpc
201
+ ?.register(RPC, {
202
+ test: async ({ deviceId }, { error }) => {
203
+ const result = await notifier.sendTest(deviceId);
204
+ if (result.ok)
205
+ return { server: label };
206
+ if (result.error === "not_paired")
207
+ return error("not_paired", `no pairing for this device on "${label}" (${result.pairings} configured)`, { pairings: result.pairings });
208
+ return error("relay_failed", result.message, { status: result.status });
209
+ },
210
+ })
211
+ .catch((error) => log(`test endpoint unavailable: ${error?.message}`));
212
+ const unregister = () => void registration?.then((r) => r?.dispose()).catch(() => { });
213
+ if (!pairings.length) {
214
+ log("no valid pairing configured (OPENCODY_PAIRING or plugin option `pairings`); push disabled");
215
+ return unregister;
216
+ }
170
217
  const debug = process.env.OPENCODY_DEBUG === "1";
171
218
  const controller = new AbortController();
172
219
  void (async () => {
@@ -189,6 +236,9 @@ export default {
189
236
  }
190
237
  })();
191
238
  log(`push enabled for ${pairings.length} device(s) as "${label}"`);
192
- return () => controller.abort();
239
+ return () => {
240
+ controller.abort();
241
+ unregister();
242
+ };
193
243
  },
194
244
  };
@@ -5,7 +5,7 @@ export interface Pairing {
5
5
  secret: string;
6
6
  key: string;
7
7
  }
8
- export type PushType = "session.finished" | "session.failed" | "permission.asked" | "form.asked";
8
+ export type PushType = "session.finished" | "session.failed" | "permission.asked" | "form.asked" | "test";
9
9
  export type Category = "OC_SESSION" | "OC_PERMISSION" | "OC_FORM";
10
10
  export interface Message {
11
11
  v: 1;
package/dist/protocol.js CHANGED
@@ -4,6 +4,7 @@ export const CATEGORY = {
4
4
  "session.failed": "OC_SESSION",
5
5
  "permission.asked": "OC_PERMISSION",
6
6
  "form.asked": "OC_FORM",
7
+ test: "OC_SESSION",
7
8
  };
8
9
  export const MAX_PLAINTEXT = 2048;
9
10
  /** APNs collapse id for a dedupe key: hashed, so the relay and Apple never see session or request IDs. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencody-notify",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode V2 server plugin: end-to-end encrypted push notifications to OpenCody (iOS) via the OpenCody push relay",
5
5
  "type": "module",
6
6
  "license": "MIT",