palmier 0.9.6 → 0.9.7

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 (250) 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 +0 -1
  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/pwa/service-worker.js +1 -1
  96. package/dist/rpc-handler.d.ts +0 -1
  97. package/dist/rpc-handler.js +0 -1
  98. package/dist/sms-store.d.ts +0 -1
  99. package/dist/sms-store.js +0 -1
  100. package/dist/spawn-command.d.ts +0 -1
  101. package/dist/spawn-command.js +0 -1
  102. package/dist/task.d.ts +0 -1
  103. package/dist/task.js +0 -1
  104. package/dist/transports/http-transport.d.ts +0 -1
  105. package/dist/transports/http-transport.js +0 -1
  106. package/dist/transports/nats-transport.d.ts +0 -1
  107. package/dist/transports/nats-transport.js +0 -1
  108. package/dist/types.d.ts +0 -1
  109. package/dist/types.js +0 -1
  110. package/dist/update-checker.d.ts +0 -1
  111. package/dist/update-checker.js +0 -1
  112. package/package.json +5 -1
  113. package/.github/workflows/ci.yml +0 -16
  114. package/.github/workflows/publish.yml +0 -37
  115. package/CLAUDE.md +0 -22
  116. package/palmier-server/.github/workflows/ci.yml +0 -21
  117. package/palmier-server/.github/workflows/deploy.yml +0 -38
  118. package/palmier-server/CLAUDE.md +0 -17
  119. package/palmier-server/PRODUCTION.md +0 -358
  120. package/palmier-server/README.md +0 -231
  121. package/palmier-server/nats.conf +0 -19
  122. package/palmier-server/package.json +0 -15
  123. package/palmier-server/pnpm-lock.yaml +0 -7639
  124. package/palmier-server/pnpm-workspace.yaml +0 -3
  125. package/palmier-server/pwa/index.html +0 -16
  126. package/palmier-server/pwa/logo/logo_20260421.png +0 -0
  127. package/palmier-server/pwa/package.json +0 -34
  128. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  129. package/palmier-server/pwa/public/favicon.ico +0 -0
  130. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  131. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  132. package/palmier-server/pwa/src/App.css +0 -3012
  133. package/palmier-server/pwa/src/App.tsx +0 -59
  134. package/palmier-server/pwa/src/agentLabels.ts +0 -11
  135. package/palmier-server/pwa/src/api.ts +0 -67
  136. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +0 -170
  137. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +0 -113
  138. package/palmier-server/pwa/src/components/HostMenu.tsx +0 -429
  139. package/palmier-server/pwa/src/components/PermissionsDialog.tsx +0 -34
  140. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +0 -46
  141. package/palmier-server/pwa/src/components/RunDetailView.tsx +0 -343
  142. package/palmier-server/pwa/src/components/SessionComposer.tsx +0 -157
  143. package/palmier-server/pwa/src/components/SessionsView.tsx +0 -326
  144. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +0 -170
  145. package/palmier-server/pwa/src/components/TabBar.tsx +0 -40
  146. package/palmier-server/pwa/src/components/TaskCard.tsx +0 -255
  147. package/palmier-server/pwa/src/components/TaskForm.tsx +0 -766
  148. package/palmier-server/pwa/src/components/TasksView.tsx +0 -179
  149. package/palmier-server/pwa/src/constants.ts +0 -2
  150. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +0 -432
  151. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +0 -124
  152. package/palmier-server/pwa/src/draftGuard.ts +0 -24
  153. package/palmier-server/pwa/src/formatTime.ts +0 -44
  154. package/palmier-server/pwa/src/hooks/useBackClose.ts +0 -75
  155. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +0 -17
  156. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +0 -102
  157. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +0 -77
  158. package/palmier-server/pwa/src/main.tsx +0 -14
  159. package/palmier-server/pwa/src/native/Device.ts +0 -49
  160. package/palmier-server/pwa/src/pages/Dashboard.tsx +0 -542
  161. package/palmier-server/pwa/src/pages/PairHost.tsx +0 -232
  162. package/palmier-server/pwa/src/pages/PairSetup.tsx +0 -134
  163. package/palmier-server/pwa/src/service-worker.ts +0 -142
  164. package/palmier-server/pwa/src/types.ts +0 -75
  165. package/palmier-server/pwa/src/vite-env.d.ts +0 -11
  166. package/palmier-server/pwa/tsconfig.json +0 -21
  167. package/palmier-server/pwa/tsconfig.node.json +0 -19
  168. package/palmier-server/pwa/vite.config.ts +0 -47
  169. package/palmier-server/server/.env.example +0 -20
  170. package/palmier-server/server/package.json +0 -36
  171. package/palmier-server/server/src/db.ts +0 -44
  172. package/palmier-server/server/src/fcm.ts +0 -74
  173. package/palmier-server/server/src/index.ts +0 -688
  174. package/palmier-server/server/src/nats-jwt.ts +0 -299
  175. package/palmier-server/server/src/nats-setup.ts +0 -48
  176. package/palmier-server/server/src/nats.ts +0 -33
  177. package/palmier-server/server/src/notify.ts +0 -34
  178. package/palmier-server/server/src/push.ts +0 -68
  179. package/palmier-server/server/src/routes/device.ts +0 -224
  180. package/palmier-server/server/src/routes/fcm.ts +0 -64
  181. package/palmier-server/server/src/routes/hosts.ts +0 -56
  182. package/palmier-server/server/src/routes/push.ts +0 -101
  183. package/palmier-server/server/tsconfig.json +0 -20
  184. package/palmier-server/spec.md +0 -533
  185. package/src/agents/agent-instructions.md +0 -28
  186. package/src/agents/agent.ts +0 -114
  187. package/src/agents/aider.ts +0 -35
  188. package/src/agents/claude.ts +0 -39
  189. package/src/agents/cline.ts +0 -35
  190. package/src/agents/codex.ts +0 -40
  191. package/src/agents/copilot.ts +0 -37
  192. package/src/agents/cursor.ts +0 -36
  193. package/src/agents/deepagents.ts +0 -36
  194. package/src/agents/droid.ts +0 -35
  195. package/src/agents/gemini.ts +0 -43
  196. package/src/agents/goose.ts +0 -33
  197. package/src/agents/hermes.ts +0 -36
  198. package/src/agents/kimi.ts +0 -35
  199. package/src/agents/kiro.ts +0 -36
  200. package/src/agents/openclaw.ts +0 -29
  201. package/src/agents/opencode.ts +0 -36
  202. package/src/agents/qoder.ts +0 -36
  203. package/src/agents/qwen.ts +0 -32
  204. package/src/agents/shared-prompt.ts +0 -30
  205. package/src/client-store.ts +0 -68
  206. package/src/commands/clients.ts +0 -29
  207. package/src/commands/info.ts +0 -29
  208. package/src/commands/init.ts +0 -165
  209. package/src/commands/pair.ts +0 -137
  210. package/src/commands/restart.ts +0 -6
  211. package/src/commands/run.ts +0 -608
  212. package/src/commands/serve.ts +0 -211
  213. package/src/commands/uninstall.ts +0 -9
  214. package/src/config.ts +0 -36
  215. package/src/cross-spawn.d.ts +0 -5
  216. package/src/event-queues.ts +0 -41
  217. package/src/events.ts +0 -29
  218. package/src/index.ts +0 -111
  219. package/src/linked-device.ts +0 -52
  220. package/src/mcp-handler.ts +0 -200
  221. package/src/mcp-tools.ts +0 -839
  222. package/src/nats-client.ts +0 -19
  223. package/src/network.ts +0 -96
  224. package/src/notification-store.ts +0 -30
  225. package/src/pending-requests.ts +0 -73
  226. package/src/platform/index.ts +0 -20
  227. package/src/platform/linux.ts +0 -296
  228. package/src/platform/macos.ts +0 -329
  229. package/src/platform/platform.ts +0 -31
  230. package/src/platform/windows.ts +0 -299
  231. package/src/rpc-handler.ts +0 -691
  232. package/src/sms-store.ts +0 -28
  233. package/src/spawn-command.ts +0 -123
  234. package/src/task.ts +0 -343
  235. package/src/transports/http-transport.ts +0 -478
  236. package/src/transports/nats-transport.ts +0 -76
  237. package/src/types.ts +0 -89
  238. package/src/update-checker.ts +0 -40
  239. package/test/agent-instructions.test.ts +0 -209
  240. package/test/agent-output-parsing.test.ts +0 -74
  241. package/test/linux-cron.test.ts +0 -41
  242. package/test/macos-plist.test.ts +0 -112
  243. package/test/notification-store.test.ts +0 -57
  244. package/test/pairing.test.ts +0 -35
  245. package/test/result-state.test.ts +0 -110
  246. package/test/task-parsing.test.ts +0 -82
  247. package/test/taskrun-messages.test.ts +0 -224
  248. package/test/tsconfig.json +0 -9
  249. package/test/windows-xml.test.ts +0 -89
  250. 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
- }