palmier 0.9.6 → 0.9.8

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 (255) hide show
  1. package/README.md +28 -13
  2. package/dist/agents/agent.d.ts +0 -1
  3. package/dist/agents/agent.js +0 -1
  4. package/dist/agents/aider.d.ts +0 -1
  5. package/dist/agents/aider.js +0 -1
  6. package/dist/agents/claude.d.ts +0 -1
  7. package/dist/agents/claude.js +0 -1
  8. package/dist/agents/cline.d.ts +0 -1
  9. package/dist/agents/cline.js +0 -1
  10. package/dist/agents/codex.d.ts +0 -1
  11. package/dist/agents/codex.js +0 -1
  12. package/dist/agents/copilot.d.ts +0 -1
  13. package/dist/agents/copilot.js +0 -1
  14. package/dist/agents/cursor.d.ts +0 -1
  15. package/dist/agents/cursor.js +0 -1
  16. package/dist/agents/deepagents.d.ts +0 -1
  17. package/dist/agents/deepagents.js +0 -1
  18. package/dist/agents/droid.d.ts +0 -1
  19. package/dist/agents/droid.js +0 -1
  20. package/dist/agents/gemini.d.ts +0 -1
  21. package/dist/agents/gemini.js +0 -1
  22. package/dist/agents/goose.d.ts +0 -1
  23. package/dist/agents/goose.js +0 -1
  24. package/dist/agents/hermes.d.ts +0 -1
  25. package/dist/agents/hermes.js +0 -1
  26. package/dist/agents/kimi.d.ts +0 -1
  27. package/dist/agents/kimi.js +0 -1
  28. package/dist/agents/kiro.d.ts +0 -1
  29. package/dist/agents/kiro.js +0 -1
  30. package/dist/agents/openclaw.d.ts +0 -1
  31. package/dist/agents/openclaw.js +0 -1
  32. package/dist/agents/opencode.d.ts +0 -1
  33. package/dist/agents/opencode.js +0 -1
  34. package/dist/agents/qoder.d.ts +0 -1
  35. package/dist/agents/qoder.js +0 -1
  36. package/dist/agents/qwen.d.ts +0 -1
  37. package/dist/agents/qwen.js +0 -1
  38. package/dist/agents/shared-prompt.d.ts +0 -1
  39. package/dist/agents/shared-prompt.js +0 -1
  40. package/dist/client-store.d.ts +0 -1
  41. package/dist/client-store.js +0 -1
  42. package/dist/commands/clients.d.ts +0 -1
  43. package/dist/commands/clients.js +0 -1
  44. package/dist/commands/info.d.ts +0 -1
  45. package/dist/commands/info.js +0 -1
  46. package/dist/commands/init.d.ts +0 -1
  47. package/dist/commands/init.js +1 -2
  48. package/dist/commands/pair.d.ts +0 -1
  49. package/dist/commands/pair.js +0 -1
  50. package/dist/commands/restart.d.ts +0 -1
  51. package/dist/commands/restart.js +0 -1
  52. package/dist/commands/run.d.ts +0 -1
  53. package/dist/commands/run.js +19 -3
  54. package/dist/commands/serve.d.ts +0 -1
  55. package/dist/commands/serve.js +0 -1
  56. package/dist/commands/uninstall.d.ts +0 -1
  57. package/dist/commands/uninstall.js +0 -1
  58. package/dist/config.d.ts +0 -1
  59. package/dist/config.js +0 -1
  60. package/dist/event-queues.d.ts +0 -1
  61. package/dist/event-queues.js +0 -1
  62. package/dist/events.d.ts +0 -1
  63. package/dist/events.js +0 -1
  64. package/dist/index.d.ts +0 -1
  65. package/dist/index.js +0 -1
  66. package/dist/linked-device.d.ts +0 -1
  67. package/dist/linked-device.js +0 -1
  68. package/dist/mcp-handler.d.ts +0 -1
  69. package/dist/mcp-handler.js +0 -1
  70. package/dist/mcp-tools.d.ts +0 -1
  71. package/dist/mcp-tools.js +0 -1
  72. package/dist/nats-client.d.ts +0 -1
  73. package/dist/nats-client.js +0 -1
  74. package/dist/network.d.ts +0 -1
  75. package/dist/network.js +0 -1
  76. package/dist/notification-store.d.ts +0 -1
  77. package/dist/notification-store.js +0 -1
  78. package/dist/pending-requests.d.ts +0 -1
  79. package/dist/pending-requests.js +0 -1
  80. package/dist/platform/index.d.ts +0 -1
  81. package/dist/platform/index.js +0 -1
  82. package/dist/platform/linux.d.ts +0 -1
  83. package/dist/platform/linux.js +0 -1
  84. package/dist/platform/macos.d.ts +0 -1
  85. package/dist/platform/macos.js +0 -1
  86. package/dist/platform/platform.d.ts +0 -1
  87. package/dist/platform/platform.js +0 -1
  88. package/dist/platform/windows.d.ts +0 -1
  89. package/dist/platform/windows.js +0 -1
  90. package/dist/pwa/assets/{index-MLEFUP3r.js → index-DWvRAUiy.js} +31 -31
  91. package/dist/pwa/assets/{web-B1sKCc7e.js → web-C4iZbqTC.js} +1 -1
  92. package/dist/pwa/assets/{web-ETD-8ZHd.js → web-CBFqJGX6.js} +1 -1
  93. package/dist/pwa/assets/{web-B4xEa6WO.js → web-DL4uXOpS.js} +1 -1
  94. package/dist/pwa/index.html +2 -2
  95. package/dist/rpc-handler.d.ts +0 -1
  96. package/dist/rpc-handler.js +0 -1
  97. package/dist/sms-store.d.ts +0 -1
  98. package/dist/sms-store.js +0 -1
  99. package/dist/spawn-command.d.ts +0 -1
  100. package/dist/spawn-command.js +0 -1
  101. package/dist/task.d.ts +0 -1
  102. package/dist/task.js +0 -1
  103. package/dist/transports/http-transport.d.ts +0 -1
  104. package/dist/transports/http-transport.js +0 -1
  105. package/dist/transports/nats-transport.d.ts +0 -1
  106. package/dist/transports/nats-transport.js +0 -1
  107. package/dist/types.d.ts +0 -1
  108. package/dist/types.js +0 -1
  109. package/dist/update-checker.d.ts +0 -1
  110. package/dist/update-checker.js +0 -1
  111. package/package.json +11 -1
  112. package/.github/workflows/ci.yml +0 -16
  113. package/.github/workflows/publish.yml +0 -37
  114. package/CLAUDE.md +0 -22
  115. package/dist/pwa/apple-touch-icon.png +0 -0
  116. package/dist/pwa/manifest.webmanifest +0 -1
  117. package/dist/pwa/pwa-192x192.png +0 -0
  118. package/dist/pwa/pwa-512x512.png +0 -0
  119. package/dist/pwa/registerSW.js +0 -1
  120. package/dist/pwa/service-worker.js +0 -2
  121. package/palmier-server/.github/workflows/ci.yml +0 -21
  122. package/palmier-server/.github/workflows/deploy.yml +0 -38
  123. package/palmier-server/CLAUDE.md +0 -17
  124. package/palmier-server/PRODUCTION.md +0 -358
  125. package/palmier-server/README.md +0 -231
  126. package/palmier-server/nats.conf +0 -19
  127. package/palmier-server/package.json +0 -15
  128. package/palmier-server/pnpm-lock.yaml +0 -7639
  129. package/palmier-server/pnpm-workspace.yaml +0 -3
  130. package/palmier-server/pwa/index.html +0 -16
  131. package/palmier-server/pwa/logo/logo_20260421.png +0 -0
  132. package/palmier-server/pwa/package.json +0 -34
  133. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  134. package/palmier-server/pwa/public/favicon.ico +0 -0
  135. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  136. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  137. package/palmier-server/pwa/src/App.css +0 -3012
  138. package/palmier-server/pwa/src/App.tsx +0 -59
  139. package/palmier-server/pwa/src/agentLabels.ts +0 -11
  140. package/palmier-server/pwa/src/api.ts +0 -67
  141. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +0 -170
  142. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +0 -113
  143. package/palmier-server/pwa/src/components/HostMenu.tsx +0 -429
  144. package/palmier-server/pwa/src/components/PermissionsDialog.tsx +0 -34
  145. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +0 -46
  146. package/palmier-server/pwa/src/components/RunDetailView.tsx +0 -343
  147. package/palmier-server/pwa/src/components/SessionComposer.tsx +0 -157
  148. package/palmier-server/pwa/src/components/SessionsView.tsx +0 -326
  149. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +0 -170
  150. package/palmier-server/pwa/src/components/TabBar.tsx +0 -40
  151. package/palmier-server/pwa/src/components/TaskCard.tsx +0 -255
  152. package/palmier-server/pwa/src/components/TaskForm.tsx +0 -766
  153. package/palmier-server/pwa/src/components/TasksView.tsx +0 -179
  154. package/palmier-server/pwa/src/constants.ts +0 -2
  155. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +0 -432
  156. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +0 -124
  157. package/palmier-server/pwa/src/draftGuard.ts +0 -24
  158. package/palmier-server/pwa/src/formatTime.ts +0 -44
  159. package/palmier-server/pwa/src/hooks/useBackClose.ts +0 -75
  160. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +0 -17
  161. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +0 -102
  162. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +0 -77
  163. package/palmier-server/pwa/src/main.tsx +0 -14
  164. package/palmier-server/pwa/src/native/Device.ts +0 -49
  165. package/palmier-server/pwa/src/pages/Dashboard.tsx +0 -542
  166. package/palmier-server/pwa/src/pages/PairHost.tsx +0 -232
  167. package/palmier-server/pwa/src/pages/PairSetup.tsx +0 -134
  168. package/palmier-server/pwa/src/service-worker.ts +0 -142
  169. package/palmier-server/pwa/src/types.ts +0 -75
  170. package/palmier-server/pwa/src/vite-env.d.ts +0 -11
  171. package/palmier-server/pwa/tsconfig.json +0 -21
  172. package/palmier-server/pwa/tsconfig.node.json +0 -19
  173. package/palmier-server/pwa/vite.config.ts +0 -47
  174. package/palmier-server/server/.env.example +0 -20
  175. package/palmier-server/server/package.json +0 -36
  176. package/palmier-server/server/src/db.ts +0 -44
  177. package/palmier-server/server/src/fcm.ts +0 -74
  178. package/palmier-server/server/src/index.ts +0 -688
  179. package/palmier-server/server/src/nats-jwt.ts +0 -299
  180. package/palmier-server/server/src/nats-setup.ts +0 -48
  181. package/palmier-server/server/src/nats.ts +0 -33
  182. package/palmier-server/server/src/notify.ts +0 -34
  183. package/palmier-server/server/src/push.ts +0 -68
  184. package/palmier-server/server/src/routes/device.ts +0 -224
  185. package/palmier-server/server/src/routes/fcm.ts +0 -64
  186. package/palmier-server/server/src/routes/hosts.ts +0 -56
  187. package/palmier-server/server/src/routes/push.ts +0 -101
  188. package/palmier-server/server/tsconfig.json +0 -20
  189. package/palmier-server/spec.md +0 -533
  190. package/src/agents/agent-instructions.md +0 -28
  191. package/src/agents/agent.ts +0 -114
  192. package/src/agents/aider.ts +0 -35
  193. package/src/agents/claude.ts +0 -39
  194. package/src/agents/cline.ts +0 -35
  195. package/src/agents/codex.ts +0 -40
  196. package/src/agents/copilot.ts +0 -37
  197. package/src/agents/cursor.ts +0 -36
  198. package/src/agents/deepagents.ts +0 -36
  199. package/src/agents/droid.ts +0 -35
  200. package/src/agents/gemini.ts +0 -43
  201. package/src/agents/goose.ts +0 -33
  202. package/src/agents/hermes.ts +0 -36
  203. package/src/agents/kimi.ts +0 -35
  204. package/src/agents/kiro.ts +0 -36
  205. package/src/agents/openclaw.ts +0 -29
  206. package/src/agents/opencode.ts +0 -36
  207. package/src/agents/qoder.ts +0 -36
  208. package/src/agents/qwen.ts +0 -32
  209. package/src/agents/shared-prompt.ts +0 -30
  210. package/src/client-store.ts +0 -68
  211. package/src/commands/clients.ts +0 -29
  212. package/src/commands/info.ts +0 -29
  213. package/src/commands/init.ts +0 -165
  214. package/src/commands/pair.ts +0 -137
  215. package/src/commands/restart.ts +0 -6
  216. package/src/commands/run.ts +0 -608
  217. package/src/commands/serve.ts +0 -211
  218. package/src/commands/uninstall.ts +0 -9
  219. package/src/config.ts +0 -36
  220. package/src/cross-spawn.d.ts +0 -5
  221. package/src/event-queues.ts +0 -41
  222. package/src/events.ts +0 -29
  223. package/src/index.ts +0 -111
  224. package/src/linked-device.ts +0 -52
  225. package/src/mcp-handler.ts +0 -200
  226. package/src/mcp-tools.ts +0 -839
  227. package/src/nats-client.ts +0 -19
  228. package/src/network.ts +0 -96
  229. package/src/notification-store.ts +0 -30
  230. package/src/pending-requests.ts +0 -73
  231. package/src/platform/index.ts +0 -20
  232. package/src/platform/linux.ts +0 -296
  233. package/src/platform/macos.ts +0 -329
  234. package/src/platform/platform.ts +0 -31
  235. package/src/platform/windows.ts +0 -299
  236. package/src/rpc-handler.ts +0 -691
  237. package/src/sms-store.ts +0 -28
  238. package/src/spawn-command.ts +0 -123
  239. package/src/task.ts +0 -343
  240. package/src/transports/http-transport.ts +0 -478
  241. package/src/transports/nats-transport.ts +0 -76
  242. package/src/types.ts +0 -89
  243. package/src/update-checker.ts +0 -40
  244. package/test/agent-instructions.test.ts +0 -209
  245. package/test/agent-output-parsing.test.ts +0 -74
  246. package/test/linux-cron.test.ts +0 -41
  247. package/test/macos-plist.test.ts +0 -112
  248. package/test/notification-store.test.ts +0 -57
  249. package/test/pairing.test.ts +0 -35
  250. package/test/result-state.test.ts +0 -110
  251. package/test/task-parsing.test.ts +0 -82
  252. package/test/taskrun-messages.test.ts +0 -224
  253. package/test/tsconfig.json +0 -9
  254. package/test/windows-xml.test.ts +0 -89
  255. package/tsconfig.json +0 -19
@@ -1,179 +0,0 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import TaskCard from "./TaskCard";
3
- import TaskForm from "./TaskForm";
4
- import PullToRefreshIndicator from "./PullToRefreshIndicator";
5
- import { usePullToRefresh } from "../hooks/usePullToRefresh";
6
- import type { AgentInfo, Task, TaskStatus } from "../types";
7
-
8
- interface TasksViewProps {
9
- connected: boolean;
10
- hostId: string | null;
11
- request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
12
- subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
13
- agents: AgentInfo[];
14
- hostPlatform?: string;
15
- isNotificationListener: boolean;
16
- onViewRun(taskId: string, runId?: string): void;
17
- }
18
-
19
- export default function TasksView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, isNotificationListener, onViewRun }: TasksViewProps) {
20
- const [tasks, setTasks] = useState<Task[]>([]);
21
- const [loadingTasks, setLoadingTasks] = useState(false);
22
- const [taskError, setTaskError] = useState<string | null>(null);
23
- const [taskStatuses, setTaskStatuses] = useState<Map<string, TaskStatus>>(new Map());
24
-
25
- const [showForm, setShowForm] = useState(false);
26
- const [editingTask, setEditingTask] = useState<Task | undefined>(undefined);
27
-
28
- const closeForm = useCallback(() => { setShowForm(false); setEditingTask(undefined); }, []);
29
-
30
- const loadTasks = useCallback(async () => {
31
- if (!connected) return;
32
- setLoadingTasks(true);
33
- setTaskError(null);
34
- try {
35
- const result = await request<{ tasks?: (Task & { status?: TaskStatus })[] }>("task.list");
36
- const taskList = result.tasks ?? [];
37
- const initialStatuses = new Map<string, TaskStatus>();
38
- for (const t of taskList) {
39
- if (t.status) initialStatuses.set(t.id, t.status);
40
- }
41
- setTaskStatuses(initialStatuses);
42
- setTasks(taskList);
43
- } catch (err) {
44
- const errMsg = err instanceof Error ? err.message : String(err);
45
- if (errMsg === "Not connected" || errMsg.includes("503") || errMsg.toLowerCase().includes("no responders")) {
46
- setTaskError(null);
47
- } else {
48
- setTaskError(errMsg);
49
- }
50
- setTasks([]);
51
- } finally {
52
- setLoadingTasks(false);
53
- }
54
- }, [connected, hostId, request]);
55
-
56
- useEffect(() => {
57
- if (connected) loadTasks();
58
- else { setTasks([]); setLoadingTasks(false); }
59
- }, [connected, hostId, loadTasks]);
60
-
61
- const ptr = usePullToRefresh({ onRefresh: loadTasks, enabled: connected });
62
-
63
- // While mounted, keep task-card state fresh by refetching task.status on
64
- // running-state / permission-resolved events. Dashboard owns the modal
65
- // subscription; this one only drives the card indicators.
66
- useEffect(() => {
67
- if (!connected || !hostId) return;
68
- const unsubscribe = subscribeEvents(hostId, async (msg) => {
69
- const tokens = msg.subject.split(".");
70
- if (tokens.length < 3) return;
71
- const taskId = tokens.slice(2).join(".");
72
-
73
- let parsed: Record<string, unknown> = {};
74
- try {
75
- parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
76
- } catch { return; }
77
- const eventType = parsed.event_type as string | undefined;
78
- if (eventType !== "running-state" && eventType !== "permission-resolved") return;
79
-
80
- try {
81
- const status = await request<TaskStatus & { error?: string }>("task.status", { id: taskId }, { timeout: 5000 });
82
- if (status.error) return;
83
- setTaskStatuses((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
84
- } catch { /* skip */ }
85
- });
86
- return unsubscribe;
87
- }, [connected, hostId, subscribeEvents, request]);
88
-
89
- function handleTaskSaved(task: Task) {
90
- setTasks((prev) => {
91
- const idx = prev.findIndex((t) => t.id === task.id);
92
- if (idx >= 0) { const next = [...prev]; next[idx] = task; return next; }
93
- return [task, ...prev];
94
- });
95
- setShowForm(false);
96
- setEditingTask(undefined);
97
- }
98
-
99
- function handleTaskDeleted(taskId: string) {
100
- setTasks((prev) => prev.filter((t) => t.id !== taskId));
101
- setTaskStatuses((prev) => { const next = new Map(prev); next.delete(taskId); return next; });
102
- }
103
-
104
- return (
105
- <>
106
- <PullToRefreshIndicator pullDistance={ptr.pullDistance} refreshing={ptr.refreshing} threshold={ptr.threshold} />
107
- {taskError && <div className="form-error">{taskError}{taskError.toLowerCase().includes("failed to fetch") && <>{" "}<a href="#" onClick={(e) => { e.preventDefault(); window.location.reload(); }}>Reload</a></>}</div>}
108
-
109
- {loadingTasks && !tasks.length ? (
110
- <div className="task-list">
111
- {[0, 1, 2].map((i) => (
112
- <div key={i} className="task-card" style={{ pointerEvents: "none" }}>
113
- <div className="task-card-header">
114
- <div className="task-card-title-row">
115
- <div className="skeleton-line" style={{ width: 10, height: 10, borderRadius: "50%" }} />
116
- <div className="skeleton-line" style={{ width: `${60 + i * 12}%` }} />
117
- </div>
118
- </div>
119
- <div className="task-card-meta">
120
- <div className="skeleton-line" style={{ width: "30%" }} />
121
- <div className="skeleton-line" style={{ width: "25%" }} />
122
- </div>
123
- </div>
124
- ))}
125
- </div>
126
- ) : tasks.length === 0 ? (
127
- <div className="empty-state">
128
- <p className="empty-state-text">No tasks yet</p>
129
- <p className="empty-state-hint">Tap the + button to create your first task.</p>
130
- </div>
131
- ) : (
132
- <div className="task-list">
133
- {tasks.map((task) => (
134
- <TaskCard
135
- key={task.id}
136
- task={task}
137
- lastEvent={taskStatuses.get(task.id)}
138
- onEdit={async (t) => {
139
- try {
140
- const latest = await request<Task & { error?: string }>("task.get", { id: t.id });
141
- setEditingTask(latest.error ? t : latest);
142
- } catch {
143
- setEditingTask(t);
144
- }
145
- setShowForm(true);
146
- }}
147
- onDelete={handleTaskDeleted}
148
- onViewRun={onViewRun}
149
- />
150
- ))}
151
- </div>
152
- )}
153
-
154
- <button
155
- className="fab"
156
- onClick={() => { setEditingTask(undefined); setShowForm(true); }}
157
- aria-label="New task"
158
- title="New task"
159
- >
160
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
161
- <line x1="12" y1="5" x2="12" y2="19" />
162
- <line x1="5" y1="12" x2="19" y2="12" />
163
- </svg>
164
- </button>
165
-
166
- {showForm && (
167
- <TaskForm
168
- initial={editingTask}
169
- agents={agents}
170
- hostPlatform={hostPlatform}
171
- isNotificationListener={isNotificationListener}
172
- onSaved={handleTaskSaved}
173
- onRun={onViewRun}
174
- onCancel={closeForm}
175
- />
176
- )}
177
- </>
178
- );
179
- }
@@ -1,2 +0,0 @@
1
- /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.9.2";
@@ -1,432 +0,0 @@
1
- import {
2
- createContext,
3
- useContext,
4
- useState,
5
- useEffect,
6
- useRef,
7
- useCallback,
8
- type ReactNode,
9
- } from "react";
10
- import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
11
- import { Capacitor } from "@capacitor/core";
12
- import { App as CapacitorApp } from "@capacitor/app";
13
- import { Network } from "@capacitor/network";
14
- import { SERVER_URL } from "../api";
15
- import type { PairedHost } from "../types";
16
-
17
- type ConnectionMode = "nats" | "lan" | "direct" | "connecting" | "disconnected";
18
-
19
- const isNative = Capacitor.isNativePlatform();
20
-
21
- interface HostConnectionContextValue {
22
- /** Whether we have an active connection to the host. */
23
- connected: boolean;
24
- /** Current connection mode. */
25
- mode: ConnectionMode;
26
- /** The raw NATS connection (null in direct-only mode). Used for pub/sub. */
27
- nc: NatsConnection | null;
28
- /** Transport-agnostic RPC request. */
29
- request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
30
- /** Subscribe to task events. Returns unsubscribe function. */
31
- subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
32
- /** Current active host. */
33
- activeHost: PairedHost;
34
- /** Whether the current client has been revoked by the host. */
35
- unauthorized: boolean;
36
- }
37
-
38
- const HostConnectionContext = createContext<HostConnectionContextValue | null>(null);
39
-
40
- const SSE_CONNECT_TIMEOUT_MS = 2_000;
41
- const HEARTBEAT_TIMEOUT_MS = 6_000;
42
- const LAN_PROBE_TIMEOUT_MS = 1_500;
43
- const LAN_KEEPALIVE_MS = 60_000;
44
-
45
- export function HostConnectionProvider({ children, activeHost }: { children: ReactNode; activeHost: PairedHost }) {
46
-
47
- const [nc, setNc] = useState<NatsConnection | null>(null);
48
- const [natsConnected, setNatsConnected] = useState(false);
49
- const ncRef = useRef<NatsConnection | null>(null);
50
-
51
- const [sseConnected, setSseConnected] = useState(false);
52
- const [unauthorized, setUnauthorized] = useState(false);
53
- const [hostNotFound, setHostNotFound] = useState(false);
54
- const [lanReachable, setLanReachable] = useState(false);
55
-
56
- const sc = useRef(StringCodec());
57
- const sseEventCallbacksRef = useRef<Set<(msg: { subject: string; data: Uint8Array }) => void>>(new Set());
58
- const lastHeartbeat = useRef(0);
59
-
60
- // Host is a "direct-only" host if it has a directUrl (paired with address)
61
- const isDirectHost = !!activeHost.directUrl;
62
- // Auto-LAN is opportunistic on native server-paired hosts that have a known LAN URL.
63
- const useLanRpc = isNative && !isDirectHost && !!activeHost.lanUrl && lanReachable;
64
-
65
- const mode: ConnectionMode = unauthorized || hostNotFound
66
- ? "disconnected"
67
- : isDirectHost
68
- ? (sseConnected ? "direct" : "connecting")
69
- : !natsConnected
70
- ? "connecting"
71
- : (useLanRpc ? "lan" : "nats");
72
- const connected = mode !== "connecting" && mode !== "disconnected";
73
-
74
- // Reset terminal states when switching hosts or re-pairing (new client token).
75
- useEffect(() => {
76
- setUnauthorized(false);
77
- setHostNotFound(false);
78
- }, [activeHost.hostId, activeHost.clientToken]);
79
-
80
- // Probe the host's LAN URL on native server-paired hosts. RPC routes via direct
81
- // HTTP when the probe succeeds; events stay on NATS regardless.
82
- useEffect(() => {
83
- if (!isNative || isDirectHost || !activeHost.lanUrl) {
84
- setLanReachable(false);
85
- return;
86
- }
87
-
88
- let cancelled = false;
89
- const lanUrl = activeHost.lanUrl;
90
- const expectedHostId = activeHost.hostId;
91
-
92
- async function probe() {
93
- console.log("[HOST/LAN] probing", lanUrl);
94
- try {
95
- const res = await fetch(`${lanUrl}/health`, {
96
- signal: AbortSignal.timeout(LAN_PROBE_TIMEOUT_MS),
97
- });
98
- if (cancelled) return;
99
- if (!res.ok) {
100
- console.log("[HOST/LAN] probe got non-ok:", res.status);
101
- setLanReachable(false);
102
- return;
103
- }
104
- const data = await res.json() as { hostId?: string };
105
- const reachable = data.hostId === expectedHostId;
106
- if (!reachable) console.log("[HOST/LAN] hostId mismatch — got:", data.hostId, "expected:", expectedHostId);
107
- else console.log("[HOST/LAN] reachable");
108
- setLanReachable(reachable);
109
- } catch (err) {
110
- if (!cancelled) {
111
- console.log("[HOST/LAN] probe failed:", err instanceof Error ? err.message : err);
112
- setLanReachable(false);
113
- }
114
- }
115
- }
116
-
117
- probe();
118
- const intervalId = window.setInterval(probe, LAN_KEEPALIVE_MS);
119
-
120
- const appHandle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
121
- if (state.isActive) probe();
122
- });
123
- const netHandle = Network.addListener("networkStatusChange", () => probe());
124
-
125
- return () => {
126
- cancelled = true;
127
- clearInterval(intervalId);
128
- appHandle.then((h) => h.remove());
129
- netHandle.then((h) => h.remove());
130
- };
131
- }, [activeHost.hostId, activeHost.lanUrl, isDirectHost]);
132
-
133
- // Fetch NATS config from server and connect (only for NATS hosts)
134
- useEffect(() => {
135
- if (isDirectHost) {
136
- if (ncRef.current) {
137
- ncRef.current.close().catch(() => {});
138
- ncRef.current = null;
139
- setNc(null);
140
- setNatsConnected(false);
141
- }
142
- return;
143
- }
144
-
145
- let cancelled = false;
146
- let retryDelayMs = 1_000;
147
- const MAX_RETRY_MS = 30_000;
148
-
149
- async function connectLoop() {
150
- while (!cancelled) {
151
- try {
152
- const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost.hostId}`);
153
- if (cancelled) return;
154
- if (res.status === 401 || res.status === 403 || res.status === 404) {
155
- console.error("[NATS] Host not found or rejected by relay:", res.status);
156
- setHostNotFound(true);
157
- return;
158
- }
159
- if (!res.ok) throw new Error(`credentials fetch ${res.status}`);
160
-
161
- const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
162
- if (!config.natsWsUrl) {
163
- console.error("[NATS] Relay returned empty natsWsUrl — treating as terminal");
164
- setHostNotFound(true);
165
- return;
166
- }
167
- if (cancelled) return;
168
-
169
- console.log("[NATS] Connecting to", config.natsWsUrl);
170
- const conn = await connect({
171
- servers: config.natsWsUrl,
172
- authenticator: jwtAuthenticator(
173
- config.natsJwt,
174
- new TextEncoder().encode(config.natsNkeySeed),
175
- ),
176
- // Default is 2 min — too slow to notice a silently-dead WebSocket
177
- // when the user has the page foregrounded but the network drops.
178
- // The resume-probe useEffect below catches dead conns within 2s
179
- // when the user returns to the app, so this background heartbeat
180
- // can stay conservative (maxPingOut: 2 → ~60s) and avoid false
181
- // positives from transient network jitter.
182
- pingInterval: 30_000,
183
- maxPingOut: 2,
184
- });
185
- if (cancelled) { conn.close().catch(() => {}); return; }
186
- console.log("[NATS] Connected");
187
- ncRef.current = conn;
188
- setNc(conn);
189
- setNatsConnected(true);
190
- retryDelayMs = 1_000;
191
-
192
- await conn.closed();
193
- if (cancelled) return;
194
-
195
- console.log("[NATS] Connection closed, will reconnect");
196
- ncRef.current = null;
197
- setNc(null);
198
- setNatsConnected(false);
199
- } catch (err) {
200
- if (cancelled) return;
201
- console.warn(`[NATS] Connect failed, retrying in ${retryDelayMs}ms:`, err);
202
- setNatsConnected(false);
203
- await new Promise((r) => setTimeout(r, retryDelayMs));
204
- retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_MS);
205
- }
206
- }
207
- }
208
-
209
- connectLoop();
210
-
211
- return () => {
212
- cancelled = true;
213
- if (ncRef.current) {
214
- ncRef.current.close().catch(() => {});
215
- ncRef.current = null;
216
- setNc(null);
217
- setNatsConnected(false);
218
- }
219
- };
220
- }, [isDirectHost, activeHost]);
221
-
222
- // WebSockets can die silently — mobile WebViews suspend them on background,
223
- // laptops on sleep, networks on switch. On any resume (visibility change on
224
- // web, foreground on native), probe the connection with a short-timeout
225
- // flush (PING/PONG round-trip); close only if it doesn't respond. nats.ws's
226
- // own pingInterval catches it eventually, but probing on resume gets us
227
- // back faster when the user immediately fires an RPC.
228
- useEffect(() => {
229
- if (isDirectHost) return;
230
- const handle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
231
- if (!state.isActive || !ncRef.current) return;
232
- const conn = ncRef.current;
233
- Promise.race([
234
- conn.flush(),
235
- new Promise<void>((_, reject) => setTimeout(() => reject(new Error("ping timeout")), 2_000)),
236
- ]).catch(() => {
237
- console.log("[NATS] Resume probe failed — cycling conn");
238
- conn.close().catch(() => {});
239
- });
240
- });
241
- return () => { handle.then((h) => h.remove()); };
242
- }, [isDirectHost]);
243
-
244
- // SSE connection for direct (LAN) hosts only
245
- useEffect(() => {
246
- if (!isDirectHost) {
247
- setSseConnected(false);
248
- return;
249
- }
250
-
251
- const controller = new AbortController();
252
-
253
- async function connectSSE(): Promise<ReadableStreamDefaultReader<Uint8Array> | null> {
254
- const connectAc = new AbortController();
255
- const timer = setTimeout(() => connectAc.abort(), SSE_CONNECT_TIMEOUT_MS);
256
- controller.signal.addEventListener("abort", () => connectAc.abort());
257
- try {
258
- const res = await fetch(`${activeHost.directUrl}/events`, {
259
- headers: { Authorization: `Bearer ${activeHost.clientToken}` },
260
- signal: connectAc.signal,
261
- });
262
- clearTimeout(timer);
263
- if (!res.ok) return null;
264
- return res.body?.getReader() ?? null;
265
- } catch {
266
- clearTimeout(timer);
267
- return null;
268
- }
269
- }
270
-
271
- async function readStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
272
- setSseConnected(true);
273
- console.log("[HOST] SSE connected to", activeHost.directUrl);
274
-
275
- lastHeartbeat.current = Date.now();
276
- function checkHeartbeat() {
277
- setTimeout(() => {
278
- if (controller.signal.aborted) return;
279
- if (Date.now() - lastHeartbeat.current < HEARTBEAT_TIMEOUT_MS) return;
280
- console.log("[HOST] Heartbeat timeout — no data for", HEARTBEAT_TIMEOUT_MS / 1000, "s");
281
- controller.abort();
282
- setSseConnected(false);
283
- console.log("[HOST] Direct host unreachable");
284
- }, HEARTBEAT_TIMEOUT_MS);
285
- }
286
- checkHeartbeat();
287
-
288
- const decoder = new TextDecoder();
289
- let buffer = "";
290
- try {
291
- while (true) {
292
- const { done, value } = await reader.read();
293
- if (done) break;
294
- buffer += decoder.decode(value, { stream: true });
295
- lastHeartbeat.current = Date.now();
296
- checkHeartbeat();
297
-
298
- const lines = buffer.split("\n");
299
- buffer = lines.pop() ?? "";
300
- for (const line of lines) {
301
- if (line.startsWith("data: ")) {
302
- const jsonStr = line.slice(6);
303
- try {
304
- const event = JSON.parse(jsonStr) as { task_id?: string; event_type?: string };
305
- if (event.task_id && event.event_type) {
306
- const subject = `host-event.${activeHost.hostId}.${event.task_id}`;
307
- for (const cb of sseEventCallbacksRef.current) cb({ subject, data: sc.current.encode(jsonStr) });
308
- }
309
- } catch { /* skip malformed — includes heartbeat pings */ }
310
- }
311
- }
312
- }
313
- } catch {
314
- // Stream ended or aborted
315
- }
316
- }
317
-
318
- (async () => {
319
- const reader = await connectSSE();
320
- if (reader) await readStream(reader);
321
- })();
322
-
323
- return () => { controller.abort(); setSseConnected(false); };
324
- }, [activeHost, isDirectHost]);
325
-
326
- // Transport-agnostic RPC
327
- const request = useCallback(async <T = unknown>(
328
- method: string,
329
- params?: unknown,
330
- opts?: { timeout?: number },
331
- ): Promise<T> => {
332
- function checkUnauthorized(data: unknown): void {
333
- if (data && typeof data === "object" && (data as Record<string, unknown>).error === "Unauthorized") {
334
- setUnauthorized(true);
335
- }
336
- }
337
-
338
- async function callHttp(baseUrl: string, label: string): Promise<T> {
339
- console.log(`[HOST/${label}] → ${method}`, params ?? "");
340
- const res = await fetch(`${baseUrl}/rpc/${method}`, {
341
- method: "POST",
342
- headers: {
343
- "Content-Type": "application/json",
344
- Authorization: `Bearer ${activeHost.clientToken}`,
345
- },
346
- body: params != null ? JSON.stringify(params) : undefined,
347
- signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
348
- });
349
- if (res.status === 401) {
350
- setUnauthorized(true);
351
- throw new Error("Unauthorized");
352
- }
353
- const data = await res.json() as T;
354
- checkUnauthorized(data);
355
- console.log(`[HOST/${label}] ← ${method}`, data);
356
- return data;
357
- }
358
-
359
- async function callNats(): Promise<T> {
360
- if (!ncRef.current) throw new Error("Not connected");
361
- const subject = `host.${activeHost.hostId}.rpc.${method}`;
362
- const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost.clientToken };
363
- const body = sc.current.encode(JSON.stringify(payload));
364
- console.log(`[HOST/NATS] → ${method}`, params ?? "");
365
- const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
366
- const decoded = JSON.parse(sc.current.decode(msg.data)) as T;
367
- checkUnauthorized(decoded);
368
- console.log(`[HOST/NATS] ← ${method}`, decoded);
369
- return decoded;
370
- }
371
-
372
- // Direct (loopback) host — always HTTP, no fallback.
373
- if (isDirectHost) return callHttp(activeHost.directUrl!, "HTTP");
374
-
375
- // Auto-LAN: try HTTP first; on failure mark unreachable and retry over NATS.
376
- if (useLanRpc && activeHost.lanUrl) {
377
- try {
378
- return await callHttp(activeHost.lanUrl, "LAN");
379
- } catch (err) {
380
- if (err instanceof Error && err.message === "Unauthorized") throw err;
381
- console.log("[HOST/LAN] failed, falling back to NATS:", err);
382
- setLanReachable(false);
383
- }
384
- }
385
-
386
- return callNats();
387
- }, [activeHost, isDirectHost, useLanRpc]);
388
-
389
- // Subscribe to task events
390
- const subscribeEvents = useCallback((hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void => {
391
- // Direct mode — register callback for SSE to forward events
392
- if (isDirectHost) {
393
- sseEventCallbacksRef.current.add(callback);
394
- return () => { sseEventCallbacksRef.current.delete(callback); };
395
- }
396
-
397
- // NATS subscription
398
- if (ncRef.current) {
399
- const sub: Subscription = ncRef.current.subscribe(`host-event.${hostId}.>`);
400
- let cancelled = false;
401
-
402
- (async () => {
403
- try {
404
- for await (const msg of sub) {
405
- if (cancelled) break;
406
- callback({ subject: msg.subject, data: msg.data });
407
- }
408
- } catch {
409
- // Subscription ended
410
- }
411
- })();
412
-
413
- return () => { cancelled = true; sub.unsubscribe(); };
414
- }
415
-
416
- return () => {};
417
- }, [activeHost, isDirectHost]);
418
-
419
- return (
420
- <HostConnectionContext.Provider
421
- value={{ connected, mode, nc, request, subscribeEvents, activeHost, unauthorized }}
422
- >
423
- {children}
424
- </HostConnectionContext.Provider>
425
- );
426
- }
427
-
428
- export function useHostConnection(): HostConnectionContextValue {
429
- const ctx = useContext(HostConnectionContext);
430
- if (!ctx) throw new Error("useHostConnection must be used within HostConnectionProvider");
431
- return ctx;
432
- }