viveworker 0.1.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 ADDED
@@ -0,0 +1,178 @@
1
+ # viveworker
2
+
3
+ `viveworker` brings Codex Desktop to your iPhone.
4
+
5
+ When Codex needs an approval, asks whether to implement a plan, wants you to choose from options, or finishes a task while you are away from your desk, `viveworker` keeps all of that within reach on your phone. Instead of breaking your rhythm, it helps you keep vivecoding going from anywhere in your home or office.
6
+
7
+ Think of it as a local companion for Codex on your Mac:
8
+ your Mac keeps building, and your iPhone keeps you in the loop.
9
+
10
+ ## Why It Feels Good
11
+
12
+ With `viveworker`, you can:
13
+
14
+ - approve or reject actions the moment Codex asks
15
+ - respond to `Implement this plan?` without walking back to your desk
16
+ - answer multiple-choice questions quickly from your phone
17
+ - review completions and jump back into the latest thread
18
+ - get a Home Screen notification when Codex needs you
19
+
20
+ The point is simple:
21
+ keep Codex moving, keep context close, and keep your momentum.
22
+
23
+ ## Best Fit
24
+
25
+ `viveworker` works best with:
26
+
27
+ - Mac + iPhone
28
+ - the same Wi-Fi or LAN
29
+ - a trusted local network
30
+ - the Home Screen web app with Web Push enabled
31
+
32
+ It gets even more fun with a Mac mini.
33
+ Leave Codex running on a small always-on machine, and `viveworker` starts to feel like a local coding appliance: your Mac mini keeps building in the background while your iPhone handles approvals, plan checks, questions, and follow-up replies from anywhere in your home or office.
34
+
35
+ `viveworker` is designed for local use only.
36
+ It is not intended for Internet exposure.
37
+
38
+ ## Mac mini Ideas
39
+
40
+ `viveworker` pairs especially well with a Mac mini.
41
+
42
+ You can use it as:
43
+
44
+ - an always-on Codex station that stays running in the background
45
+ - a way to keep approvals and plan checks moving even when you are away from your desk
46
+ - a lightweight monitor for long-running coding or research tasks, where your iPhone only surfaces what needs your attention
47
+ - a small local AI appliance for your home or office
48
+ - a quick way to review a completion and send “do this next” back into the latest thread from your phone
49
+
50
+ ## Quick Start
51
+
52
+ For the full experience, start here:
53
+
54
+ ```bash
55
+ npx viveworker setup --install-mkcert
56
+ ```
57
+
58
+ If `mkcert` is already installed and trusted on your Mac, plain setup is enough:
59
+
60
+ ```bash
61
+ npx viveworker setup
62
+ ```
63
+
64
+ By default, `viveworker` uses port `8810`.
65
+ If that port is already in use, choose another one:
66
+
67
+ ```bash
68
+ npx viveworker setup --port 8820
69
+ ```
70
+
71
+ ## Recommended Setup Path
72
+
73
+ `viveworker` enables Web Push by default. The recommended first-time flow is:
74
+
75
+ 1. Run `npx viveworker setup --install-mkcert` on your Mac
76
+ 2. If macOS asks, allow the local CA install
77
+ 3. On your iPhone, open the printed `rootCA.pem` URL
78
+ 4. Install the certificate profile and trust it in iPhone certificate trust settings
79
+ 5. Open the printed pairing URL in Safari
80
+ 6. Pair your iPhone with the code if needed
81
+ 7. Add `viveworker` to your Home Screen
82
+ 8. Open the Home Screen app
83
+ 9. In `Settings`, tap `Enable Notifications`
84
+ 10. Tap `Send Test Notification` to verify delivery
85
+
86
+ During setup, `viveworker` prints:
87
+
88
+ - a `.local` URL
89
+ - a fallback IP-based URL
90
+ - a `rootCA.pem` download URL
91
+ - a short-lived pairing code
92
+ - a pairing URL
93
+ - a pairing QR code
94
+
95
+ After setup:
96
+
97
+ - use the Home Screen app for daily use
98
+ - use the pairing URL only for first-time setup or when you intentionally add another device
99
+ - keep using the Home Screen app if you want notifications to work reliably
100
+
101
+ ## Common Commands
102
+
103
+ Use these commands most often:
104
+
105
+ - `npx viveworker setup`
106
+ create or refresh the local setup, generate pairing info, and start the app
107
+ - `npx viveworker start`
108
+ start `viveworker` again using the saved config
109
+ - `npx viveworker stop`
110
+ stop the local background service
111
+ - `npx viveworker status`
112
+ show the current app URL, launchd/background status, and health
113
+ - `npx viveworker doctor`
114
+ diagnose local setup problems when something is not working
115
+ - `npx viveworker setup --pair`
116
+ generate a fresh one-time pairing code and pairing URL for adding another device
117
+
118
+ Useful options:
119
+
120
+ - `--port <n>` if `8810` is already in use
121
+ - `--install-mkcert` to automate the local certificate setup
122
+ - `--disable-web-push` only if you intentionally do not want notifications
123
+
124
+ `--pair` reissues only the short-lived pairing code and pairing URL.
125
+ It does not change the main app URL, port, session secret, TLS, or Web Push settings.
126
+ Use it only when you want to add another trusted iPhone or browser.
127
+
128
+ ## Questions and Limits
129
+
130
+ - Multiple-choice questions are handled as a single item
131
+ - Up to 5 questions are shown per page
132
+ - 6 or more questions are split across multiple pages
133
+ - Answers are submitted together on the final page
134
+ - Questions that include `Other` or free text must be answered on your Mac
135
+
136
+ ## Security Model
137
+
138
+ - use `viveworker` only on a trusted LAN
139
+ - do not expose it directly to the Internet
140
+ - if you lose a paired device, revoke it from `Settings > Devices`
141
+ - use `setup --pair` only when you want to add another trusted device
142
+
143
+ ## Optional `ntfy`
144
+
145
+ `ntfy` is optional.
146
+
147
+ Start with `viveworker` and Web Push first.
148
+ If you later want a second wake-up notification path, you can add `ntfy` alongside it.
149
+
150
+ ## Troubleshooting
151
+
152
+ - If the `.local` URL does not open, use the printed IP-based URL
153
+ - If pairing has expired, run `npx viveworker setup --pair`
154
+ - If notifications do not appear, make sure you opened the Home Screen app, not just a Safari tab
155
+ - If Web Push is enabled, make sure you are opening the HTTPS URL
156
+ - If you are stuck, run:
157
+
158
+ ```bash
159
+ npx viveworker status
160
+ npx viveworker doctor
161
+ ```
162
+
163
+ ## Notes
164
+
165
+ - `viveworker` stays local and runs on your Mac on the same LAN
166
+ - Web Push still depends on the browser/platform push service
167
+ - `--install-mkcert` can automate the Mac-side `mkcert` install and `mkcert -install`
168
+ - macOS may still show an administrator prompt while installing the local CA
169
+ - iPhone trust is still manual: you need to trust the local CA profile on the device
170
+ - Web Push supports approvals, plans, multiple-choice questions, and completions
171
+
172
+ ## Roadmap
173
+
174
+ Planned next steps include:
175
+
176
+ - Android support
177
+ - Windows support
178
+ - image attachment support from mobile
@@ -0,0 +1,39 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!--
6
+ This file is only for manual launchd setups.
7
+ Most users should run `npx viveworker setup`, which generates the real LaunchAgent automatically.
8
+ -->
9
+ <key>Label</key>
10
+ <string>io.viveworker.app</string>
11
+
12
+ <key>ProgramArguments</key>
13
+ <array>
14
+ <string>/ABSOLUTE/PATH/TO/node</string>
15
+ <string>/ABSOLUTE/PATH/TO/viveworker/scripts/viveworker-bridge.mjs</string>
16
+ </array>
17
+
18
+ <key>WorkingDirectory</key>
19
+ <string>/ABSOLUTE/PATH/TO/viveworker</string>
20
+
21
+ <key>EnvironmentVariables</key>
22
+ <dict>
23
+ <key>VIVEWORKER_ENV_FILE</key>
24
+ <string>/ABSOLUTE/PATH/TO/.viveworker/config.env</string>
25
+ </dict>
26
+
27
+ <key>KeepAlive</key>
28
+ <true/>
29
+
30
+ <key>RunAtLoad</key>
31
+ <true/>
32
+
33
+ <key>StandardOutPath</key>
34
+ <string>/ABSOLUTE/PATH/TO/.viveworker/logs/viveworker.log</string>
35
+
36
+ <key>StandardErrorPath</key>
37
+ <string>/ABSOLUTE/PATH/TO/.viveworker/logs/viveworker.log</string>
38
+ </dict>
39
+ </plist>
@@ -0,0 +1,10 @@
1
+ services:
2
+ ntfy:
3
+ image: binwiederhier/ntfy:latest
4
+ restart: unless-stopped
5
+ command: serve
6
+ ports:
7
+ - "8080:80"
8
+ volumes:
9
+ - ./server.yml:/etc/ntfy/server.yml:ro
10
+ - ../.ntfy-data:/var/lib/ntfy
@@ -0,0 +1,28 @@
1
+ # Trusted-LAN ntfy setup for viveworker notifications.
2
+ # Use HTTP like this only on a Wi-Fi/LAN you trust.
3
+ # If you want remote access or stronger transport security, put ntfy behind HTTPS.
4
+
5
+ # This URL must exactly match the Default Server you configure in the mobile app.
6
+ base-url: "http://YOUR-MAC-IP-OR-HOSTNAME:8080"
7
+
8
+ # The container listens on port 80 internally; Docker maps 8080 -> 80.
9
+ listen-http: ":80"
10
+
11
+ # Persistent cache is required so iPhone can fetch message contents after a poll request.
12
+ cache-file: "/var/lib/ntfy/cache.db"
13
+
14
+ # User, ACL, and token database. Created automatically if it does not exist.
15
+ auth-file: "/var/lib/ntfy/auth.db"
16
+ auth-default-access: "deny-all"
17
+
18
+ # Login is handy for debugging, but self-signup should stay off on a private instance.
19
+ enable-login: true
20
+ enable-signup: false
21
+
22
+ # Needed for near-instant iPhone delivery on a self-hosted server.
23
+ # Only a poll request is forwarded upstream, not your real message body.
24
+ upstream-base-url: "https://ntfy.sh"
25
+
26
+ # Production-safe defaults: do not log message bodies.
27
+ log-level: "info"
28
+ log-format: "json"
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "viveworker",
3
+ "version": "0.1.0",
4
+ "description": "A local iPhone companion for Codex Desktop approvals, plans, questions, and completions.",
5
+ "type": "module",
6
+ "bin": {
7
+ "viveworker": "./scripts/viveworker.mjs"
8
+ },
9
+ "files": [
10
+ "scripts/viveworker.mjs",
11
+ "scripts/viveworker-bridge.mjs",
12
+ "scripts/lib",
13
+ "web",
14
+ "README.md",
15
+ "viveworker.env.example",
16
+ "launchd/io.viveworker.app.plist.example",
17
+ "ntfy/server.yml.example",
18
+ "ntfy/docker-compose.yml.example"
19
+ ],
20
+ "dependencies": {
21
+ "qrcode-terminal": "^0.12.0",
22
+ "web-push": "^3.6.7"
23
+ }
24
+ }
@@ -0,0 +1,274 @@
1
+ function escapeHtml(value) {
2
+ return String(value ?? "")
3
+ .replace(/&/gu, "&amp;")
4
+ .replace(/</gu, "&lt;")
5
+ .replace(/>/gu, "&gt;")
6
+ .replace(/"/gu, "&quot;")
7
+ .replace(/'/gu, "&#39;");
8
+ }
9
+
10
+ function stripPresentationEnvelopeTags(markdown) {
11
+ return String(markdown ?? "")
12
+ .replace(/^\s*<\/?proposed_plan>\s*$/gimu, "")
13
+ .replace(/<\/?proposed_plan>/giu, "")
14
+ .trim();
15
+ }
16
+
17
+ function sanitizeHref(value) {
18
+ const text = String(value ?? "").trim();
19
+ if (!text) {
20
+ return "";
21
+ }
22
+ if (/^https?:\/\//iu.test(text) || /^mailto:/iu.test(text) || /^codex:/iu.test(text)) {
23
+ return text;
24
+ }
25
+ if (/^\/(?:approvals|native-approvals|completion-details)\b/u.test(text)) {
26
+ return text;
27
+ }
28
+ return "";
29
+ }
30
+
31
+ function parseAutoLink(text, start) {
32
+ const segment = text.slice(start);
33
+ const match = segment.match(/^(https?:\/\/[^\s<>"']+|mailto:[^\s<>"']+|codex:[^\s<>"']+)/iu);
34
+ if (!match) {
35
+ return null;
36
+ }
37
+
38
+ let href = match[0];
39
+ while (/[),.;!?]$/u.test(href)) {
40
+ href = href.slice(0, -1);
41
+ }
42
+
43
+ const sanitized = sanitizeHref(href);
44
+ if (!sanitized) {
45
+ return null;
46
+ }
47
+
48
+ return {
49
+ href: sanitized,
50
+ end: start + href.length,
51
+ };
52
+ }
53
+
54
+ function renderInline(text) {
55
+ let html = "";
56
+ let index = 0;
57
+
58
+ while (index < text.length) {
59
+ if (text.startsWith("**", index)) {
60
+ const end = text.indexOf("**", index + 2);
61
+ if (end > index + 2) {
62
+ html += `<strong>${renderInline(text.slice(index + 2, end))}</strong>`;
63
+ index = end + 2;
64
+ continue;
65
+ }
66
+ }
67
+
68
+ if (text[index] === "`") {
69
+ const end = text.indexOf("`", index + 1);
70
+ if (end > index + 1) {
71
+ html += `<code>${escapeHtml(text.slice(index + 1, end))}</code>`;
72
+ index = end + 1;
73
+ continue;
74
+ }
75
+ }
76
+
77
+ if (text[index] === "[") {
78
+ const link = parseMarkdownLink(text, index);
79
+ if (link) {
80
+ const labelHtml = renderInline(link.label);
81
+ const href = sanitizeHref(link.href);
82
+ html += href
83
+ ? `<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">${labelHtml}</a>`
84
+ : `${labelHtml} <code>${escapeHtml(link.href)}</code>`;
85
+ index = link.end;
86
+ continue;
87
+ }
88
+ }
89
+
90
+ const autoLink = parseAutoLink(text, index);
91
+ if (autoLink) {
92
+ html += `<a href="${escapeHtml(autoLink.href)}" target="_blank" rel="noreferrer">${escapeHtml(autoLink.href)}</a>`;
93
+ index = autoLink.end;
94
+ continue;
95
+ }
96
+
97
+ if (text[index] === "*") {
98
+ const end = text.indexOf("*", index + 1);
99
+ if (end > index + 1) {
100
+ const content = text.slice(index + 1, end).trim();
101
+ if (content) {
102
+ html += `<em>${renderInline(content)}</em>`;
103
+ index = end + 1;
104
+ continue;
105
+ }
106
+ }
107
+ }
108
+
109
+ html += escapeHtml(text[index]);
110
+ index += 1;
111
+ }
112
+
113
+ return html;
114
+ }
115
+
116
+ function parseMarkdownLink(text, start) {
117
+ const closeLabel = text.indexOf("]", start + 1);
118
+ if (closeLabel === -1 || text[closeLabel + 1] !== "(") {
119
+ return null;
120
+ }
121
+
122
+ let depth = 1;
123
+ let index = closeLabel + 2;
124
+ while (index < text.length) {
125
+ if (text[index] === "(") {
126
+ depth += 1;
127
+ } else if (text[index] === ")") {
128
+ depth -= 1;
129
+ if (depth === 0) {
130
+ return {
131
+ label: text.slice(start + 1, closeLabel),
132
+ href: text.slice(closeLabel + 2, index),
133
+ end: index + 1,
134
+ };
135
+ }
136
+ }
137
+ index += 1;
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ function isUnorderedList(line) {
144
+ return /^[-*+]\s+/u.test(line);
145
+ }
146
+
147
+ function isOrderedList(line) {
148
+ return /^\d+\.\s+/u.test(line);
149
+ }
150
+
151
+ function isFence(line) {
152
+ return /^```/u.test(line);
153
+ }
154
+
155
+ function renderList(lines, ordered) {
156
+ const tag = ordered ? "ol" : "ul";
157
+ const items = lines
158
+ .map((line) => line.replace(ordered ? /^\d+\.\s+/u : /^[-*+]\s+/u, ""))
159
+ .map((line) => `<li>${renderInline(line.trim())}</li>`)
160
+ .join("");
161
+ return `<${tag}>${items}</${tag}>`;
162
+ }
163
+
164
+ function renderBlockquote(lines) {
165
+ const inner = renderMarkdownHtml(lines.map((line) => line.replace(/^>\s?/u, "")).join("\n"), {
166
+ fallbackHtml: "<p></p>",
167
+ });
168
+ return `<blockquote>${inner}</blockquote>`;
169
+ }
170
+
171
+ function renderCodeBlock(lines) {
172
+ const [fence, ...rest] = lines;
173
+ const language = fence.replace(/^```/u, "").trim();
174
+ const className = language ? ` class="language-${escapeHtml(language)}"` : "";
175
+ return `<pre><code${className}>${escapeHtml(rest.join("\n"))}</code></pre>`;
176
+ }
177
+
178
+ function renderParagraph(lines) {
179
+ return `<p>${lines.map((line) => renderInline(line.trim())).join("<br>")}</p>`;
180
+ }
181
+
182
+ function parseBlocks(markdown) {
183
+ const lines = markdown.replace(/\r\n/gu, "\n").trim().split("\n");
184
+ const blocks = [];
185
+ let index = 0;
186
+
187
+ while (index < lines.length) {
188
+ const line = lines[index];
189
+ if (!line.trim()) {
190
+ index += 1;
191
+ continue;
192
+ }
193
+
194
+ if (isFence(line)) {
195
+ const block = [line];
196
+ index += 1;
197
+ while (index < lines.length) {
198
+ block.push(lines[index]);
199
+ if (isFence(lines[index])) {
200
+ index += 1;
201
+ break;
202
+ }
203
+ index += 1;
204
+ }
205
+ blocks.push(renderCodeBlock(block));
206
+ continue;
207
+ }
208
+
209
+ const heading = line.match(/^(#{1,6})\s+(.*)$/u);
210
+ if (heading) {
211
+ const level = heading[1].length;
212
+ blocks.push(`<h${level}>${renderInline(heading[2].trim())}</h${level}>`);
213
+ index += 1;
214
+ continue;
215
+ }
216
+
217
+ if (/^(?:---|\*\*\*|___)\s*$/u.test(line)) {
218
+ blocks.push("<hr>");
219
+ index += 1;
220
+ continue;
221
+ }
222
+
223
+ if (/^>\s?/u.test(line)) {
224
+ const block = [];
225
+ while (index < lines.length && /^>\s?/u.test(lines[index])) {
226
+ block.push(lines[index]);
227
+ index += 1;
228
+ }
229
+ blocks.push(renderBlockquote(block));
230
+ continue;
231
+ }
232
+
233
+ if (isUnorderedList(line) || isOrderedList(line)) {
234
+ const ordered = isOrderedList(line);
235
+ const block = [];
236
+ while (index < lines.length && (ordered ? isOrderedList(lines[index]) : isUnorderedList(lines[index]))) {
237
+ block.push(lines[index]);
238
+ index += 1;
239
+ }
240
+ blocks.push(renderList(block, ordered));
241
+ continue;
242
+ }
243
+
244
+ const block = [];
245
+ while (index < lines.length) {
246
+ const current = lines[index];
247
+ if (!current.trim() || isFence(current) || /^(#{1,6})\s+/u.test(current) || /^>\s?/u.test(current)) {
248
+ break;
249
+ }
250
+ if (isUnorderedList(current) || isOrderedList(current) || /^(?:---|\*\*\*|___)\s*$/u.test(current)) {
251
+ break;
252
+ }
253
+ block.push(current);
254
+ index += 1;
255
+ }
256
+ blocks.push(renderParagraph(block));
257
+ }
258
+
259
+ return blocks;
260
+ }
261
+
262
+ export function renderMarkdownHtml(markdown, { fallbackHtml = "<p></p>" } = {}) {
263
+ const normalized = stripPresentationEnvelopeTags(markdown);
264
+ if (!normalized) {
265
+ return fallbackHtml;
266
+ }
267
+
268
+ const blocks = parseBlocks(normalized);
269
+ if (blocks.length === 0) {
270
+ return fallbackHtml;
271
+ }
272
+
273
+ return blocks.join("\n");
274
+ }
@@ -0,0 +1,83 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const PAIRING_TTL_MS = 15 * 60 * 1000;
4
+
5
+ const PAIRING_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
6
+
7
+ export function isPairingExpired(expiresAtMs, now = Date.now()) {
8
+ const normalized = Number(expiresAtMs) || 0;
9
+ return normalized > 0 && now >= normalized;
10
+ }
11
+
12
+ export function shouldRotatePairing({ force = false, pairingCode = "", pairingToken = "", pairingExpiresAtMs = 0 } = {}, now = Date.now()) {
13
+ return Boolean(force || !pairingCode || !pairingToken || isPairingExpired(pairingExpiresAtMs, now));
14
+ }
15
+
16
+ export function generatePairingCode(length = 8) {
17
+ const bytes = crypto.randomBytes(length);
18
+ let output = "";
19
+ for (let index = 0; index < length; index += 1) {
20
+ output += PAIRING_ALPHABET[bytes[index] % PAIRING_ALPHABET.length];
21
+ }
22
+ return output;
23
+ }
24
+
25
+ export function generatePairingCredentials(now = Date.now()) {
26
+ return {
27
+ pairingCode: generatePairingCode(8),
28
+ pairingToken: crypto.randomBytes(18).toString("hex"),
29
+ pairingExpiresAtMs: now + PAIRING_TTL_MS,
30
+ };
31
+ }
32
+
33
+ export function upsertEnvText(rawText, updates) {
34
+ const text = String(rawText || "");
35
+ const entries = Object.entries(updates || {}).filter(([key]) => key);
36
+ if (entries.length === 0) {
37
+ return text;
38
+ }
39
+
40
+ const updateMap = new Map(entries.map(([key, value]) => [String(key), String(value ?? "")]));
41
+ const seen = new Set();
42
+ const output = [];
43
+
44
+ for (const rawLine of text.split(/\r?\n/u)) {
45
+ const line = String(rawLine);
46
+ const trimmed = line.trim();
47
+ if (!trimmed || trimmed.startsWith("#")) {
48
+ output.push(line);
49
+ continue;
50
+ }
51
+
52
+ const separator = line.indexOf("=");
53
+ if (separator === -1) {
54
+ output.push(line);
55
+ continue;
56
+ }
57
+
58
+ const key = line.slice(0, separator).trim();
59
+ if (!updateMap.has(key)) {
60
+ output.push(line);
61
+ continue;
62
+ }
63
+
64
+ if (seen.has(key)) {
65
+ continue;
66
+ }
67
+
68
+ output.push(`${key}=${updateMap.get(key)}`);
69
+ seen.add(key);
70
+ }
71
+
72
+ for (const [key, value] of updateMap.entries()) {
73
+ if (!seen.has(key)) {
74
+ output.push(`${key}=${value}`);
75
+ }
76
+ }
77
+
78
+ while (output.length > 0 && output[output.length - 1] === "") {
79
+ output.pop();
80
+ }
81
+
82
+ return `${output.join("\n")}\n`;
83
+ }