palmier 0.7.6 → 0.7.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 (122) hide show
  1. package/dist/agents/agent.d.ts +3 -0
  2. package/dist/agents/agent.js +1 -1
  3. package/dist/agents/aider.d.ts +1 -0
  4. package/dist/agents/aider.js +1 -0
  5. package/dist/agents/claude.d.ts +1 -0
  6. package/dist/agents/claude.js +1 -0
  7. package/dist/agents/cline.d.ts +1 -0
  8. package/dist/agents/cline.js +1 -0
  9. package/dist/agents/codex.d.ts +1 -0
  10. package/dist/agents/codex.js +1 -0
  11. package/dist/agents/copilot.d.ts +1 -0
  12. package/dist/agents/copilot.js +1 -0
  13. package/dist/agents/cursor.d.ts +1 -0
  14. package/dist/agents/cursor.js +1 -0
  15. package/dist/agents/deepagents.d.ts +1 -0
  16. package/dist/agents/deepagents.js +1 -0
  17. package/dist/agents/droid.d.ts +1 -0
  18. package/dist/agents/droid.js +1 -0
  19. package/dist/agents/gemini.d.ts +1 -0
  20. package/dist/agents/gemini.js +1 -0
  21. package/dist/agents/goose.d.ts +1 -0
  22. package/dist/agents/goose.js +1 -0
  23. package/dist/agents/hermes.d.ts +1 -0
  24. package/dist/agents/hermes.js +1 -0
  25. package/dist/agents/kimi.d.ts +1 -0
  26. package/dist/agents/kimi.js +1 -0
  27. package/dist/agents/kiro.d.ts +1 -0
  28. package/dist/agents/kiro.js +1 -0
  29. package/dist/agents/openclaw.d.ts +1 -0
  30. package/dist/agents/openclaw.js +2 -2
  31. package/dist/agents/opencode.d.ts +1 -0
  32. package/dist/agents/opencode.js +1 -0
  33. package/dist/agents/qoder.d.ts +1 -0
  34. package/dist/agents/qoder.js +1 -0
  35. package/dist/agents/qwen.d.ts +1 -0
  36. package/dist/agents/qwen.js +1 -0
  37. package/dist/agents/shared-prompt.js +1 -1
  38. package/dist/commands/init.js +3 -2
  39. package/dist/commands/pair.js +3 -3
  40. package/dist/commands/run.js +4 -4
  41. package/dist/commands/serve.js +1 -1
  42. package/dist/config.js +2 -2
  43. package/dist/device-capabilities.d.ts +1 -1
  44. package/dist/events.js +1 -1
  45. package/dist/mcp-tools.js +79 -7
  46. package/dist/nats-client.d.ts +1 -1
  47. package/dist/nats-client.js +6 -3
  48. package/dist/pending-requests.d.ts +30 -8
  49. package/dist/pending-requests.js +28 -15
  50. package/dist/pwa/assets/index-8cTctVnD.js +120 -0
  51. package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
  52. package/dist/pwa/assets/{web-DnuoxUd4.js → web-BNr628AV.js} +1 -1
  53. package/dist/pwa/assets/{web-7raT3zOZ.js → web-DyQPewAi.js} +1 -1
  54. package/dist/pwa/index.html +2 -2
  55. package/dist/pwa/service-worker.js +1 -1
  56. package/dist/rpc-handler.js +12 -16
  57. package/dist/transports/http-transport.js +6 -3
  58. package/dist/types.d.ts +4 -1
  59. package/package.json +1 -1
  60. package/palmier-server/PRODUCTION.md +31 -28
  61. package/palmier-server/README.md +35 -5
  62. package/palmier-server/nats.conf +9 -5
  63. package/palmier-server/package.json +2 -1
  64. package/palmier-server/pnpm-lock.yaml +6 -0
  65. package/palmier-server/pwa/src/App.css +66 -0
  66. package/palmier-server/pwa/src/App.tsx +1 -0
  67. package/palmier-server/pwa/src/components/HostMenu.tsx +65 -2
  68. package/palmier-server/pwa/src/components/RunsView.tsx +48 -22
  69. package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
  70. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  71. package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
  72. package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
  73. package/palmier-server/pwa/src/constants.ts +1 -1
  74. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
  75. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  76. package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
  77. package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
  78. package/palmier-server/pwa/src/types.ts +1 -6
  79. package/palmier-server/server/package.json +3 -1
  80. package/palmier-server/server/src/index.ts +83 -2
  81. package/palmier-server/server/src/nats-jwt.ts +299 -0
  82. package/palmier-server/server/src/nats-setup.ts +48 -0
  83. package/palmier-server/server/src/nats.ts +12 -4
  84. package/palmier-server/server/src/routes/device.ts +24 -0
  85. package/palmier-server/server/src/routes/hosts.ts +13 -2
  86. package/palmier-server/spec.md +28 -14
  87. package/src/agents/agent.ts +5 -1
  88. package/src/agents/aider.ts +1 -0
  89. package/src/agents/claude.ts +1 -0
  90. package/src/agents/cline.ts +1 -0
  91. package/src/agents/codex.ts +1 -0
  92. package/src/agents/copilot.ts +1 -0
  93. package/src/agents/cursor.ts +1 -0
  94. package/src/agents/deepagents.ts +1 -0
  95. package/src/agents/droid.ts +1 -0
  96. package/src/agents/gemini.ts +1 -0
  97. package/src/agents/goose.ts +1 -0
  98. package/src/agents/hermes.ts +1 -0
  99. package/src/agents/kimi.ts +1 -0
  100. package/src/agents/kiro.ts +1 -0
  101. package/src/agents/openclaw.ts +2 -2
  102. package/src/agents/opencode.ts +1 -0
  103. package/src/agents/qoder.ts +1 -0
  104. package/src/agents/qwen.ts +1 -0
  105. package/src/agents/shared-prompt.ts +1 -1
  106. package/src/commands/init.ts +7 -5
  107. package/src/commands/pair.ts +3 -3
  108. package/src/commands/run.ts +4 -4
  109. package/src/commands/serve.ts +1 -1
  110. package/src/config.ts +2 -2
  111. package/src/device-capabilities.ts +1 -0
  112. package/src/events.ts +1 -1
  113. package/src/mcp-tools.ts +83 -7
  114. package/src/nats-client.ts +10 -3
  115. package/src/pending-requests.ts +47 -15
  116. package/src/rpc-handler.ts +13 -16
  117. package/src/transports/http-transport.ts +6 -3
  118. package/src/types.ts +4 -3
  119. package/test/agent-instructions.test.ts +10 -10
  120. package/test/pairing.test.ts +2 -2
  121. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  122. package/dist/pwa/assets/index-uSwkmHBs.js +0 -118
@@ -1,89 +1,42 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
- import { createPortal } from "react-dom";
3
2
  import TaskCard from "./TaskCard";
4
3
  import TaskForm from "./TaskForm";
5
- import { setAgentLabels } from "../agentLabels";
6
- import type { AgentInfo, Task, TaskStatus, RequiredPermission } from "../types";
7
- import { MIN_HOST_VERSION } from "../constants";
8
-
9
- function isOlderThan(current: string, minimum: string): boolean {
10
- // Dev builds (e.g. "0.3.2-dev") are never forced to update
11
- if (current.includes("-")) return false;
12
- const a = current.split(".").map(Number);
13
- const b = minimum.split(".").map(Number);
14
- for (let i = 0; i < 3; i++) {
15
- if ((a[i] ?? 0) < (b[i] ?? 0)) return true;
16
- if ((a[i] ?? 0) > (b[i] ?? 0)) return false;
17
- }
18
- return false;
19
- }
4
+ import type { AgentInfo, Task, TaskStatus } from "../types";
20
5
 
21
6
  interface TaskListViewProps {
22
7
  connected: boolean;
23
8
  hostId: string | null;
24
9
  request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
25
10
  subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
11
+ agents: AgentInfo[];
12
+ hostPlatform?: string;
26
13
  onViewRun(taskId: string, runId?: string): void;
27
- onUpdateRequired?(required: boolean): void;
28
- onVersion?(version: string | null): void;
29
- onCapabilityTokens?(tokens: Record<string, string | null>): void;
30
14
  }
31
15
 
32
- export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onCapabilityTokens }: TaskListViewProps) {
16
+ export default function TaskListView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, onViewRun }: TaskListViewProps) {
33
17
  const [tasks, setTasks] = useState<Task[]>([]);
34
18
  const [loadingTasks, setLoadingTasks] = useState(false);
35
19
  const [taskError, setTaskError] = useState<string | null>(null);
36
- const [taskEvents, setTaskStatuss] = useState<Map<string, TaskStatus>>(new Map());
37
- const [pendingConfirms, setPendingConfirms] = useState<Map<string, { description: string; agentName?: string }>>(new Map());
38
- const [pendingPermissions, setPendingPermissions] = useState<Map<string, RequiredPermission[]>>(new Map());
39
- const [pendingInputs, setPendingInputs] = useState<Map<string, { questions: string[]; description?: string; agentName?: string }>>(new Map());
40
- const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
20
+ const [taskStatuses, setTaskStatuses] = useState<Map<string, TaskStatus>>(new Map());
41
21
 
42
22
  const [showForm, setShowForm] = useState(false);
43
23
  const [editingTask, setEditingTask] = useState<Task | undefined>(undefined);
44
24
 
45
- const [agents, setAgents] = useState<AgentInfo[]>([]);
46
- const [hostPlatform, setHostPlatform] = useState<string | undefined>();
47
-
48
25
  const closeForm = useCallback(() => { setShowForm(false); setEditingTask(undefined); }, []);
49
26
 
50
- // Load tasks
51
27
  const loadTasks = useCallback(async () => {
52
28
  if (!connected) return;
53
29
  setLoadingTasks(true);
54
30
  setTaskError(null);
55
31
  try {
56
- const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null; capability_tokens?: Record<string, string | null> }>("task.list");
32
+ const result = await request<{ tasks?: (Task & { status?: TaskStatus })[] }>("task.list");
57
33
  const taskList = result.tasks ?? [];
58
- const initialEvents = new Map<string, TaskStatus>();
59
- const initialConfirms = new Map<string, { description: string; agentName?: string }>();
60
- const initialPerms = new Map<string, RequiredPermission[]>();
61
- const initialInputs = new Map<string, { questions: string[]; description?: string; agentName?: string }>();
62
- const initialInputVals = new Map<string, string[]>();
34
+ const initialStatuses = new Map<string, TaskStatus>();
63
35
  for (const t of taskList) {
64
- if (t.status) {
65
- initialEvents.set(t.id, t.status);
66
- // pending_confirmation no longer comes from task.list (confirmation is sessionId-based now)
67
- if (t.status.pending_permission?.length) initialPerms.set(t.id, t.status.pending_permission);
68
- if (t.status.pending_input?.length) {
69
- initialInputs.set(t.id, { questions: t.status.pending_input });
70
- initialInputVals.set(t.id, new Array(t.status.pending_input.length).fill(""));
71
- }
72
- }
36
+ if (t.status) initialStatuses.set(t.id, t.status);
73
37
  }
74
- setTaskStatuss(initialEvents);
75
- setPendingConfirms(initialConfirms);
76
- setPendingPermissions(initialPerms);
77
- setPendingInputs(initialInputs);
78
- setInputValues(initialInputVals);
38
+ setTaskStatuses(initialStatuses);
79
39
  setTasks(taskList);
80
- setAgents(result.agents ?? []);
81
- setHostPlatform(result.host_platform);
82
- setAgentLabels(result.agents ?? []);
83
- const version = result.version ?? null;
84
- onVersion?.(version);
85
- onCapabilityTokens?.(result.capability_tokens ?? {});
86
- onUpdateRequired?.(!!version && isOlderThan(version, MIN_HOST_VERSION));
87
40
  } catch (err) {
88
41
  const errMsg = err instanceof Error ? err.message : String(err);
89
42
  if (errMsg === "Not connected" || errMsg.includes("503") || errMsg.toLowerCase().includes("no responders")) {
@@ -102,10 +55,11 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
102
55
  else { setTasks([]); setLoadingTasks(false); }
103
56
  }, [connected, hostId, loadTasks]);
104
57
 
105
- // Subscribe to task events
58
+ // While mounted, keep task-card state fresh by refetching task.status on
59
+ // running-state / permission-resolved events. Dashboard owns the modal
60
+ // subscription; this one only drives the card indicators.
106
61
  useEffect(() => {
107
62
  if (!connected || !hostId) return;
108
- const sc = { decode: (data: Uint8Array) => new TextDecoder().decode(data) };
109
63
  const unsubscribe = subscribeEvents(hostId, async (msg) => {
110
64
  const tokens = msg.subject.split(".");
111
65
  if (tokens.length < 3) return;
@@ -113,127 +67,20 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
113
67
 
114
68
  let parsed: Record<string, unknown> = {};
115
69
  try {
116
- parsed = JSON.parse(sc.decode(msg.data)) as Record<string, unknown>;
70
+ parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
117
71
  } catch { return; }
118
72
  const eventType = parsed.event_type as string | undefined;
119
- const sessionId = parsed.session_id as string | undefined;
120
-
121
- // Handle input-request: show standalone input dialog (keyed by sessionId)
122
- if (eventType === "input-request" && sessionId) {
123
- const questions = parsed.input_questions as string[];
124
- const agentName = parsed.agent_name as string | undefined;
125
- const description = parsed.description as string | undefined;
126
- if (questions?.length) {
127
- setPendingInputs((prev) => {
128
- if (prev.has(sessionId)) return prev;
129
- const next = new Map(prev);
130
- next.set(sessionId, { questions, description, agentName });
131
- return next;
132
- });
133
- setInputValues((prev) => {
134
- if (prev.has(sessionId)) return prev;
135
- const next = new Map(prev);
136
- next.set(sessionId, new Array(questions.length).fill(""));
137
- return next;
138
- });
139
- }
140
- return;
141
- }
142
-
143
- // Handle input-resolved: close pending input dialog
144
- if (eventType === "input-resolved" && sessionId) {
145
- setPendingInputs((prev) => {
146
- if (!prev.has(sessionId)) return prev;
147
- const next = new Map(prev);
148
- next.delete(sessionId);
149
- return next;
150
- });
151
- setInputValues((prev) => {
152
- const next = new Map(prev);
153
- next.delete(sessionId);
154
- return next;
155
- });
156
- return;
157
- }
158
-
159
- // Handle confirm-request: show standalone confirmation dialog (keyed by sessionId)
160
- if (eventType === "confirm-request" && sessionId) {
161
- const description = parsed.description as string;
162
- const agentName = parsed.agent_name as string | undefined;
163
- if (description) {
164
- setPendingConfirms((prev) => {
165
- if (prev.has(sessionId)) return prev;
166
- const next = new Map(prev);
167
- next.set(sessionId, { description, agentName });
168
- return next;
169
- });
170
- }
171
- return;
172
- }
173
-
174
- // Handle confirm-resolved: close pending confirmation dialog
175
- if (eventType === "confirm-resolved" && sessionId) {
176
- setPendingConfirms((prev) => {
177
- if (!prev.has(sessionId)) return prev;
178
- const next = new Map(prev);
179
- next.delete(sessionId);
180
- return next;
181
- });
182
- return;
183
- }
184
-
185
- // Handle permission-resolved: close pending permission dialog
186
- if (eventType === "permission-resolved") {
187
- setPendingPermissions((prev) => {
188
- if (!prev.has(taskId)) return prev;
189
- const next = new Map(prev);
190
- next.delete(taskId);
191
- return next;
192
- });
193
- return;
194
- }
73
+ if (eventType !== "running-state" && eventType !== "permission-resolved") return;
195
74
 
196
75
  try {
197
76
  const status = await request<TaskStatus & { error?: string }>("task.status", { id: taskId }, { timeout: 5000 });
198
77
  if (status.error) return;
199
- setTaskStatuss((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
200
- if (status.pending_permission?.length) {
201
- setPendingPermissions((prev) => {
202
- if (prev.has(taskId)) return prev;
203
- const next = new Map(prev);
204
- next.set(taskId, status.pending_permission!);
205
- return next;
206
- });
207
- }
78
+ setTaskStatuses((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
208
79
  } catch { /* skip */ }
209
80
  });
210
81
  return unsubscribe;
211
82
  }, [connected, hostId, subscribeEvents, request]);
212
83
 
213
- async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
214
- try {
215
- await request("task.user_input", { id: sessionId, value: [response] });
216
- } catch (err) {
217
- console.error("[TaskListView] Failed to respond to confirmation:", err);
218
- }
219
- }
220
-
221
- async function respondToPermission(taskId: string, response: "granted" | "granted_all" | "aborted") {
222
- try {
223
- await request("task.user_input", { id: taskId, value: [response] });
224
- } catch (err) {
225
- console.error("[TaskListView] Failed to respond to permission request:", err);
226
- }
227
- }
228
-
229
- async function respondToInput(sessionId: string, values: string[]) {
230
- try {
231
- await request("task.user_input", { id: sessionId, value: values });
232
- } catch (err) {
233
- console.error("[TaskListView] Failed to respond to input request:", err);
234
- }
235
- }
236
-
237
84
  function handleTaskSaved(task: Task) {
238
85
  setTasks((prev) => {
239
86
  const idx = prev.findIndex((t) => t.id === task.id);
@@ -246,9 +93,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
246
93
 
247
94
  function handleTaskDeleted(taskId: string) {
248
95
  setTasks((prev) => prev.filter((t) => t.id !== taskId));
249
- setTaskStatuss((prev) => { const next = new Map(prev); next.delete(taskId); return next; });
250
- setPendingInputs((prev) => { const next = new Map(prev); next.delete(taskId); return next; });
251
- setInputValues((prev) => { const next = new Map(prev); next.delete(taskId); return next; });
96
+ setTaskStatuses((prev) => { const next = new Map(prev); next.delete(taskId); return next; });
252
97
  }
253
98
 
254
99
  return (
@@ -289,7 +134,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
289
134
  <TaskCard
290
135
  key={task.id}
291
136
  task={task}
292
- lastEvent={taskEvents.get(task.id)}
137
+ lastEvent={taskStatuses.get(task.id)}
293
138
  onEdit={async (t) => {
294
139
  try {
295
140
  const latest = await request<Task & { error?: string }>("task.get", { id: t.id });
@@ -312,120 +157,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
312
157
  agents={agents}
313
158
  hostPlatform={hostPlatform}
314
159
  onSaved={handleTaskSaved}
315
- onRun={onViewRun}
316
160
  onCancel={closeForm}
317
161
  />
318
162
  )}
319
-
320
- {createPortal(<>
321
- {[...pendingConfirms.entries()].map(([sessionId, { description, agentName }]) => {
322
- const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
323
- return (
324
- <div key={sessionId} className="confirm-modal-overlay">
325
- <div className="confirm-modal">
326
- <h2 className="confirm-modal-title">Confirmation Required</h2>
327
- {subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
328
- <p className="confirm-modal-message">{description}</p>
329
- <div className="confirm-modal-actions">
330
- <button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
331
- Confirm
332
- </button>
333
- <button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
334
- Abort
335
- </button>
336
- </div>
337
- </div>
338
- </div>
339
- );
340
- })}
341
-
342
- {[...pendingPermissions.entries()].map(([taskId, permissions]) => {
343
- const task = tasks.find((t) => t.id === taskId);
344
- return (
345
- <div key={taskId} className="confirm-modal-overlay">
346
- <div className="confirm-modal permission-modal">
347
- <h2 className="confirm-modal-title">Permission Required</h2>
348
- <p className="confirm-modal-message">
349
- <strong>{task?.name || task?.user_prompt || taskId}</strong>
350
- </p>
351
- <div className="permission-list">
352
- {permissions.map((p, i) => (
353
- <div key={i} className="permission-item">
354
- <span className="permission-name">{p.name}</span>
355
- {p.description && <span className="permission-desc">{p.description}</span>}
356
- </div>
357
- ))}
358
- </div>
359
- <div className="permission-actions">
360
- <button className="btn btn-primary" onClick={() => respondToPermission(taskId, "granted")}>
361
- Allow Once
362
- </button>
363
- <button className="btn btn-secondary" onClick={() => respondToPermission(taskId, "granted_all")}>
364
- Allow Always
365
- </button>
366
- </div>
367
- <button
368
- className="permission-abort-link"
369
- onClick={() => respondToPermission(taskId, "aborted")}
370
- >
371
- Deny & Abort Task
372
- </button>
373
- </div>
374
- </div>
375
- );
376
- })}
377
-
378
- {[...pendingInputs.entries()].map(([sessionId, { questions, description, agentName }]) => {
379
- const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
380
- const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
381
- return (
382
- <div key={sessionId} className="confirm-modal-overlay">
383
- <div className="confirm-modal input-modal">
384
- <h2 className="confirm-modal-title">Input Required</h2>
385
- {subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
386
- {description && <p className="confirm-modal-message">{description}</p>}
387
- <div className="input-list">
388
- {questions.map((desc: string, i: number) => (
389
- <div key={i} className="input-item">
390
- <label className="input-label">{desc}</label>
391
- <input
392
- type="text"
393
- className="input-field"
394
- value={values[i] ?? ""}
395
- onChange={(e) => {
396
- setInputValues((prev) => {
397
- const next = new Map(prev);
398
- const arr = [...(next.get(sessionId) ?? [])];
399
- arr[i] = e.target.value;
400
- next.set(sessionId, arr);
401
- return next;
402
- });
403
- }}
404
- autoFocus={i === 0}
405
- />
406
- </div>
407
- ))}
408
- </div>
409
- <div className="input-actions">
410
- <button
411
- className="btn btn-primary"
412
- disabled={values.some((v) => !v.trim())}
413
- onClick={() => respondToInput(sessionId, values)}
414
- >
415
- Submit
416
- </button>
417
- </div>
418
- <button
419
- className="permission-abort-link"
420
- onClick={() => respondToInput(sessionId, ["aborted"])}
421
- >
422
- Cancel
423
- </button>
424
- </div>
425
- </div>
426
- );
427
- })}
428
- </>, document.body)}
429
163
  </>
430
164
  );
431
165
  }
@@ -1,2 +1,2 @@
1
1
  /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.7.4";
2
+ export const MIN_HOST_VERSION = "0.7.8";
@@ -7,7 +7,7 @@ import {
7
7
  useCallback,
8
8
  type ReactNode,
9
9
  } from "react";
10
- import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
10
+ import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
11
11
  import { SERVER_URL } from "../api";
12
12
  import { useHostStore } from "./HostStoreContext";
13
13
  import type { PairedHost } from "../types";
@@ -90,12 +90,13 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
90
90
 
91
91
  async function init() {
92
92
  try {
93
- const res = await fetch(`${SERVER_URL}/api/config`);
93
+ // Fetch host-scoped NATS credentials (can only access this host's subjects)
94
+ const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
94
95
  if (!res.ok) {
95
- console.error("[NATS] Failed to fetch config");
96
+ console.error("[NATS] Failed to fetch credentials");
96
97
  return;
97
98
  }
98
- const config = await res.json() as { natsWsUrl: string; natsToken: string };
99
+ const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
99
100
  if (!config.natsWsUrl) {
100
101
  console.warn("[NATS] No WebSocket URL configured");
101
102
  return;
@@ -105,7 +106,10 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
105
106
  console.log("[NATS] Connecting to", config.natsWsUrl);
106
107
  const conn = await connect({
107
108
  servers: config.natsWsUrl,
108
- token: config.natsToken,
109
+ authenticator: jwtAuthenticator(
110
+ config.natsJwt,
111
+ new TextEncoder().encode(config.natsNkeySeed),
112
+ ),
109
113
  });
110
114
  if (cancelled) { conn.close().catch(() => {}); return; }
111
115
  console.log("[NATS] Connected");
@@ -0,0 +1,24 @@
1
+ let currentDraftMessage: string | null = null;
2
+
3
+ function handleBeforeUnload(e: BeforeUnloadEvent) {
4
+ if (currentDraftMessage) {
5
+ e.preventDefault();
6
+ e.returnValue = "";
7
+ }
8
+ }
9
+
10
+ export function setDraftMessage(message: string | null) {
11
+ const wasActive = !!currentDraftMessage;
12
+ currentDraftMessage = message;
13
+ const nowActive = !!currentDraftMessage;
14
+ if (nowActive && !wasActive) {
15
+ window.addEventListener("beforeunload", handleBeforeUnload);
16
+ } else if (!nowActive && wasActive) {
17
+ window.removeEventListener("beforeunload", handleBeforeUnload);
18
+ }
19
+ }
20
+
21
+ export function confirmLeaveDraft(): boolean {
22
+ if (!currentDraftMessage) return true;
23
+ return window.confirm(currentDraftMessage);
24
+ }