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,4 +1,5 @@
1
1
  import { useState, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { useNavigate, useLocation, useParams } from "react-router-dom";
3
4
 
4
5
  import { useHostStore } from "../contexts/HostStoreContext";
@@ -10,6 +11,37 @@ import RunsView from "../components/RunsView";
10
11
  import RunDetailView from "../components/RunDetailView";
11
12
  import { usePushSubscription } from "../hooks/usePushSubscription";
12
13
  import { useMediaQuery } from "../hooks/useMediaQuery";
14
+ import { setAgentLabels } from "../agentLabels";
15
+ import { confirmLeaveDraft } from "../draftGuard";
16
+ import { MIN_HOST_VERSION } from "../constants";
17
+ import type { AgentInfo, RequiredPermission } from "../types";
18
+
19
+ function isOlderThan(current: string, minimum: string): boolean {
20
+ if (current.includes("-")) return false;
21
+ const a = current.split(".").map(Number);
22
+ const b = minimum.split(".").map(Number);
23
+ for (let i = 0; i < 3; i++) {
24
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return true;
25
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return false;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ interface PendingPrompt {
31
+ key: string;
32
+ type: "confirmation" | "permission" | "input";
33
+ params?: RequiredPermission[] | string[];
34
+ meta?: {
35
+ session_id?: string;
36
+ session_name?: string;
37
+ description?: string;
38
+ input_questions?: string[];
39
+ };
40
+ }
41
+
42
+ interface ConfirmPrompt { description: string; sessionName?: string }
43
+ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: string }
44
+ interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
13
45
 
14
46
  export default function Dashboard() {
15
47
  const { pairedHosts, activeHostId, removePairedHost } = useHostStore();
@@ -19,7 +51,7 @@ export default function Dashboard() {
19
51
  const params = useParams<{ taskId?: string; runId?: string }>();
20
52
  const isDesktop = useMediaQuery("(min-width: 768px)");
21
53
 
22
- const isRunsTab = location.pathname.startsWith("/runs");
54
+ const isTasksTab = location.pathname.startsWith("/tasks");
23
55
  const runsFilterTaskId = params.runId ? undefined : params.taskId;
24
56
 
25
57
  // Resolve "latest" run ID to the actual run ID
@@ -48,16 +80,205 @@ export default function Dashboard() {
48
80
  const [updateError, setUpdateError] = useState<string | null>(null);
49
81
  const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
50
82
  const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
83
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
84
+ const [hostPlatform, setHostPlatform] = useState<string | undefined>();
85
+
86
+ // Pending prompt state — owned by Dashboard because these modals must show
87
+ // regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
88
+ const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
89
+ const [pendingPermissions, setPendingPermissions] = useState<Map<string, PermissionPrompt>>(new Map());
90
+ const [pendingInputs, setPendingInputs] = useState<Map<string, InputPrompt>>(new Map());
91
+ const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
51
92
 
52
- // Register push subscription for the active host
53
93
  usePushSubscription();
54
94
 
55
- // Reset scroll when switching hosts
56
95
  useEffect(() => {
57
96
  window.scrollTo(0, 0);
58
97
  }, [activeHostId]);
59
98
 
99
+ // host.info bootstrap: agents/version/platform + any prompts that were already
100
+ // pending when this PWA connected. Runs once per (host, connection).
101
+ useEffect(() => {
102
+ if (!connected) return;
103
+ request<{
104
+ agents?: AgentInfo[];
105
+ version?: string | null;
106
+ host_platform?: string;
107
+ capability_tokens?: Record<string, string | null>;
108
+ pending_prompts?: PendingPrompt[];
109
+ }>("host.info")
110
+ .then((result) => {
111
+ setAgents(result.agents ?? []);
112
+ setHostPlatform(result.host_platform);
113
+ setCapabilityTokens(result.capability_tokens ?? {});
114
+ setAgentLabels(result.agents ?? []);
115
+ const version = result.version ?? null;
116
+ setDaemonVersion(version);
117
+ setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
118
+
119
+ // Seed modal state from already-pending prompts.
120
+ const confirms = new Map<string, ConfirmPrompt>();
121
+ const perms = new Map<string, PermissionPrompt>();
122
+ const inputs = new Map<string, InputPrompt>();
123
+ const inputVals = new Map<string, string[]>();
124
+ for (const p of result.pending_prompts ?? []) {
125
+ if (p.type === "confirmation") {
126
+ confirms.set(p.key, {
127
+ description: p.meta?.description ?? "",
128
+ sessionName: p.meta?.session_name,
129
+ });
130
+ } else if (p.type === "permission") {
131
+ perms.set(p.key, {
132
+ permissions: (p.params as RequiredPermission[]) ?? [],
133
+ sessionName: p.meta?.session_name,
134
+ });
135
+ } else if (p.type === "input") {
136
+ const questions = (p.params as string[]) ?? p.meta?.input_questions ?? [];
137
+ inputs.set(p.key, {
138
+ questions,
139
+ description: p.meta?.description,
140
+ sessionName: p.meta?.session_name,
141
+ });
142
+ inputVals.set(p.key, new Array(questions.length).fill(""));
143
+ }
144
+ }
145
+ setPendingConfirms(confirms);
146
+ setPendingPermissions(perms);
147
+ setPendingInputs(inputs);
148
+ setInputValues(inputVals);
149
+ })
150
+ .catch(() => { /* silent — update-required prompt guards the broken case */ });
151
+ }, [connected, activeHostId, request]);
152
+
153
+ // Always-on event subscription for modal lifecycle. Independent of which tab
154
+ // is active. Task-card status updates happen inside TaskListView while mounted.
155
+ useEffect(() => {
156
+ if (!connected || !activeHostId) return;
157
+ const unsubscribe = subscribeEvents(activeHostId, (msg) => {
158
+ const tokens = msg.subject.split(".");
159
+ if (tokens.length < 3) return;
160
+ const taskId = tokens.slice(2).join(".");
161
+
162
+ let parsed: Record<string, unknown> = {};
163
+ try {
164
+ parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
165
+ } catch { return; }
166
+ const eventType = parsed.event_type as string | undefined;
167
+ const sessionId = parsed.session_id as string | undefined;
168
+
169
+ if (eventType === "input-request" && sessionId) {
170
+ const questions = parsed.input_questions as string[] | undefined;
171
+ const sessionName = parsed.session_name as string | undefined;
172
+ const description = parsed.description as string | undefined;
173
+ if (questions?.length) {
174
+ setPendingInputs((prev) => {
175
+ if (prev.has(sessionId)) return prev;
176
+ const next = new Map(prev);
177
+ next.set(sessionId, { questions, description, sessionName });
178
+ return next;
179
+ });
180
+ setInputValues((prev) => {
181
+ if (prev.has(sessionId)) return prev;
182
+ const next = new Map(prev);
183
+ next.set(sessionId, new Array(questions.length).fill(""));
184
+ return next;
185
+ });
186
+ }
187
+ return;
188
+ }
189
+
190
+ if (eventType === "input-resolved" && sessionId) {
191
+ setPendingInputs((prev) => {
192
+ if (!prev.has(sessionId)) return prev;
193
+ const next = new Map(prev);
194
+ next.delete(sessionId);
195
+ return next;
196
+ });
197
+ setInputValues((prev) => {
198
+ const next = new Map(prev);
199
+ next.delete(sessionId);
200
+ return next;
201
+ });
202
+ return;
203
+ }
204
+
205
+ if (eventType === "confirm-request" && sessionId) {
206
+ const description = parsed.description as string | undefined;
207
+ const sessionName = parsed.session_name as string | undefined;
208
+ if (description) {
209
+ setPendingConfirms((prev) => {
210
+ if (prev.has(sessionId)) return prev;
211
+ const next = new Map(prev);
212
+ next.set(sessionId, { description, sessionName });
213
+ return next;
214
+ });
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (eventType === "confirm-resolved" && sessionId) {
220
+ setPendingConfirms((prev) => {
221
+ if (!prev.has(sessionId)) return prev;
222
+ const next = new Map(prev);
223
+ next.delete(sessionId);
224
+ return next;
225
+ });
226
+ return;
227
+ }
228
+
229
+ if (eventType === "permission-request") {
230
+ const permissions = parsed.required_permissions as RequiredPermission[] | undefined;
231
+ const sessionName = parsed.session_name as string | undefined;
232
+ if (permissions?.length) {
233
+ setPendingPermissions((prev) => {
234
+ if (prev.has(taskId)) return prev;
235
+ const next = new Map(prev);
236
+ next.set(taskId, { permissions, sessionName });
237
+ return next;
238
+ });
239
+ }
240
+ return;
241
+ }
242
+
243
+ if (eventType === "permission-resolved") {
244
+ setPendingPermissions((prev) => {
245
+ if (!prev.has(taskId)) return prev;
246
+ const next = new Map(prev);
247
+ next.delete(taskId);
248
+ return next;
249
+ });
250
+ return;
251
+ }
252
+ });
253
+ return unsubscribe;
254
+ }, [connected, activeHostId, subscribeEvents]);
255
+
256
+ async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
257
+ try {
258
+ await request("task.user_input", { id: sessionId, value: [response] });
259
+ } catch (err) {
260
+ console.error("[Dashboard] Failed to respond to confirmation:", err);
261
+ }
262
+ }
263
+
264
+ async function respondToPermission(taskId: string, response: "granted" | "granted_all" | "aborted") {
265
+ try {
266
+ await request("task.user_input", { id: taskId, value: [response] });
267
+ } catch (err) {
268
+ console.error("[Dashboard] Failed to respond to permission request:", err);
269
+ }
270
+ }
271
+
272
+ async function respondToInput(sessionId: string, values: string[]) {
273
+ try {
274
+ await request("task.user_input", { id: sessionId, value: values });
275
+ } catch (err) {
276
+ console.error("[Dashboard] Failed to respond to input request:", err);
277
+ }
278
+ }
279
+
60
280
  function handleViewRun(taskId: string, runId?: string) {
281
+ if (!confirmLeaveDraft()) return;
61
282
  if (runId) {
62
283
  navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
63
284
  } else {
@@ -78,7 +299,6 @@ export default function Dashboard() {
78
299
  } catch {
79
300
  // Expected: connection drops during daemon restart
80
301
  }
81
- // Daemon will restart; reload after it has time to come back up.
82
302
  setTimeout(() => window.location.reload(), 10000);
83
303
  }
84
304
 
@@ -116,7 +336,7 @@ export default function Dashboard() {
116
336
  <div className="revoked-actions">
117
337
  <button
118
338
  className="btn btn-primary"
119
- onClick={() => navigate("/pair")}
339
+ onClick={() => { if (confirmLeaveDraft()) navigate("/pair"); }}
120
340
  >
121
341
  Re-pair Device
122
342
  </button>
@@ -132,18 +352,17 @@ export default function Dashboard() {
132
352
  </div>
133
353
  ) : showTaskContent ? (
134
354
  <>
135
- <div style={{ display: !isRunsTab ? "contents" : "none" }}>
355
+ {isTasksTab && !isRunDetail && (
136
356
  <TaskListView
137
357
  connected={connected}
138
358
  hostId={activeHostId}
139
359
  request={request}
140
360
  subscribeEvents={subscribeEvents}
361
+ agents={agents}
362
+ hostPlatform={hostPlatform}
141
363
  onViewRun={handleViewRun}
142
- onUpdateRequired={setUpdateRequired}
143
- onVersion={setDaemonVersion}
144
- onCapabilityTokens={setCapabilityTokens}
145
364
  />
146
- </div>
365
+ )}
147
366
  {isRunDetail ? (
148
367
  <RunDetailView
149
368
  connected={connected}
@@ -153,14 +372,15 @@ export default function Dashboard() {
153
372
  taskId={params.taskId!}
154
373
  runId={decodeURIComponent(effectiveRunId!)}
155
374
  />
156
- ) : isRunsTab ? (
375
+ ) : !isTasksTab ? (
157
376
  <RunsView
158
377
  connected={connected}
159
378
  hostId={activeHostId}
160
379
  request={request}
161
380
  subscribeEvents={subscribeEvents}
381
+ agents={agents}
162
382
  filterTaskId={runsFilterTaskId}
163
- onClearFilter={() => navigate("/runs")}
383
+ onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
164
384
  />
165
385
  ) : null}
166
386
  </>
@@ -221,6 +441,109 @@ export default function Dashboard() {
221
441
  )}
222
442
 
223
443
  </div>
444
+
445
+ {createPortal(<>
446
+ {[...pendingConfirms.entries()].map(([sessionId, { description, sessionName }]) => (
447
+ <div key={sessionId} className="confirm-modal-overlay">
448
+ <div className="confirm-modal">
449
+ <h2 className="confirm-modal-title">Confirmation Required</h2>
450
+ {sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
451
+ <p className="confirm-modal-message">{description}</p>
452
+ <div className="confirm-modal-actions">
453
+ <button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
454
+ Confirm
455
+ </button>
456
+ <button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
457
+ Abort
458
+ </button>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ ))}
463
+
464
+ {[...pendingPermissions.entries()].map(([taskId, { permissions, sessionName }]) => (
465
+ <div key={taskId} className="confirm-modal-overlay">
466
+ <div className="confirm-modal permission-modal">
467
+ <h2 className="confirm-modal-title">Permission Required</h2>
468
+ <p className="confirm-modal-message">
469
+ <strong>{sessionName || taskId}</strong>
470
+ </p>
471
+ <div className="permission-list">
472
+ {permissions.map((p, i) => (
473
+ <div key={i} className="permission-item">
474
+ <span className="permission-name">{p.name}</span>
475
+ {p.description && <span className="permission-desc">{p.description}</span>}
476
+ </div>
477
+ ))}
478
+ </div>
479
+ <div className="permission-actions">
480
+ <button className="btn btn-primary" onClick={() => respondToPermission(taskId, "granted")}>
481
+ Allow Once
482
+ </button>
483
+ <button className="btn btn-secondary" onClick={() => respondToPermission(taskId, "granted_all")}>
484
+ Allow Always
485
+ </button>
486
+ </div>
487
+ <button
488
+ className="permission-abort-link"
489
+ onClick={() => respondToPermission(taskId, "aborted")}
490
+ >
491
+ Deny & Abort Task
492
+ </button>
493
+ </div>
494
+ </div>
495
+ ))}
496
+
497
+ {[...pendingInputs.entries()].map(([sessionId, { questions, description, sessionName }]) => {
498
+ const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
499
+ return (
500
+ <div key={sessionId} className="confirm-modal-overlay">
501
+ <div className="confirm-modal input-modal">
502
+ <h2 className="confirm-modal-title">Input Required</h2>
503
+ {sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
504
+ {description && <p className="confirm-modal-message">{description}</p>}
505
+ <div className="input-list">
506
+ {questions.map((desc: string, i: number) => (
507
+ <div key={i} className="input-item">
508
+ <label className="input-label">{desc}</label>
509
+ <input
510
+ type="text"
511
+ className="input-field"
512
+ value={values[i] ?? ""}
513
+ onChange={(e) => {
514
+ setInputValues((prev) => {
515
+ const next = new Map(prev);
516
+ const arr = [...(next.get(sessionId) ?? [])];
517
+ arr[i] = e.target.value;
518
+ next.set(sessionId, arr);
519
+ return next;
520
+ });
521
+ }}
522
+ autoFocus={i === 0}
523
+ />
524
+ </div>
525
+ ))}
526
+ </div>
527
+ <div className="input-actions">
528
+ <button
529
+ className="btn btn-primary"
530
+ disabled={values.some((v) => !v.trim())}
531
+ onClick={() => respondToInput(sessionId, values)}
532
+ >
533
+ Submit
534
+ </button>
535
+ </div>
536
+ <button
537
+ className="permission-abort-link"
538
+ onClick={() => respondToInput(sessionId, ["aborted"])}
539
+ >
540
+ Cancel
541
+ </button>
542
+ </div>
543
+ </div>
544
+ );
545
+ })}
546
+ </>, document.body)}
224
547
  </div>
225
548
  );
226
549
  }
@@ -1,6 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
- import { connect, StringCodec } from "nats.ws";
3
+ import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
4
4
  import { Capacitor } from "@capacitor/core";
5
5
  import { Preferences } from "@capacitor/preferences";
6
6
  import { useHostStore } from "../contexts/HostStoreContext";
@@ -54,12 +54,15 @@ export default function PairHost() {
54
54
  // Server mode — pair via NATS
55
55
  const configRes = await fetch(`${SERVER_URL}/api/config`);
56
56
  if (!configRes.ok) throw new Error("Failed to fetch server config");
57
- const config = await configRes.json() as { natsWsUrl: string; natsToken: string };
57
+ const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
58
58
  if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
59
59
 
60
60
  const nc = await connect({
61
61
  servers: config.natsWsUrl,
62
- token: config.natsToken,
62
+ authenticator: jwtAuthenticator(
63
+ config.natsJwt,
64
+ new TextEncoder().encode(config.natsNkeySeed),
65
+ ),
63
66
  });
64
67
 
65
68
  const sc = StringCodec();
@@ -2,6 +2,7 @@ export interface AgentInfo {
2
2
  key: string;
3
3
  label: string;
4
4
  supportsPermissions?: boolean;
5
+ supportsYolo?: boolean;
5
6
  }
6
7
 
7
8
 
@@ -41,12 +42,6 @@ export interface TaskStatus {
41
42
  running_state: TaskRunningState;
42
43
  /** UTC time in milliseconds since epoch */
43
44
  time_stamp: number;
44
- /** Whether this task is awaiting user confirmation */
45
- pending_confirmation?: boolean;
46
- /** Permissions the task needs granted to continue */
47
- pending_permission?: RequiredPermission[];
48
- pending_input?: string[];
49
- user_input?: string[];
50
45
  }
51
46
 
52
47
  export interface HistoryEntry {
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "tsx watch src/index.ts",
7
7
  "build": "tsc",
8
- "start": "node dist/index.js"
8
+ "start": "node dist/index.js",
9
+ "nats-setup": "tsx src/nats-setup.ts"
9
10
  },
10
11
  "dependencies": {
11
12
  "bcrypt": "^5.1.1",
@@ -16,6 +17,7 @@
16
17
  "helmet": "^8.0.0",
17
18
  "jsonwebtoken": "^9.0.2",
18
19
  "nats": "^2.29.1",
20
+ "nkeys.js": "^1.1.0",
19
21
  "pg": "^8.13.1",
20
22
  "uuid": "^11.0.5",
21
23
  "web-push": "^3.6.7"
@@ -13,6 +13,7 @@ import fcmRoutes from "./routes/fcm.js";
13
13
  import deviceRoutes from "./routes/device.js";
14
14
  import { notifyClients } from "./notify.js";
15
15
  import { sendFcmToClients, sendFcmToDevice } from "./fcm.js";
16
+ import { createPairingCredentials, createPwaCredentials } from "./nats-jwt.js";
16
17
 
17
18
  const PORT = parseInt(process.env.PORT || "3000", 10);
18
19
 
@@ -446,6 +447,61 @@ async function main(): Promise<void> {
446
447
  }
447
448
  })();
448
449
 
450
+ // Subscribe to email requests from hosts
451
+ (async () => {
452
+ try {
453
+ const conn = await getNatsConnection();
454
+ const sub = conn.subscribe("host.*.fcm.email");
455
+ console.log("Listening for FCM email requests");
456
+
457
+ for await (const msg of sub) {
458
+ try {
459
+ const data = JSON.parse(sc.decode(msg.data)) as {
460
+ hostId: string;
461
+ requestId: string;
462
+ fcmToken?: string;
463
+ [key: string]: string | undefined;
464
+ };
465
+
466
+ const subjectHostId = msg.subject.split(".")[1];
467
+ if (data.hostId !== subjectHostId) {
468
+ if (msg.reply) {
469
+ msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
470
+ }
471
+ continue;
472
+ }
473
+
474
+ const fcmPayload: Record<string, string> = {
475
+ type: "send-email",
476
+ requestId: data.requestId,
477
+ hostId: data.hostId,
478
+ };
479
+ for (const key of ["to", "subject", "body", "cc", "bcc"]) {
480
+ if (data[key]) fcmPayload[key] = data[key]!;
481
+ }
482
+
483
+ console.log(`[FCM] Sending email request for host ${data.hostId}`);
484
+ if (data.fcmToken) {
485
+ await sendFcmToDevice(data.fcmToken, fcmPayload);
486
+ } else {
487
+ await sendFcmToClients(data.hostId, fcmPayload);
488
+ }
489
+
490
+ if (msg.reply) {
491
+ msg.respond(sc.encode(JSON.stringify({ ok: true })));
492
+ }
493
+ } catch (err) {
494
+ console.error("[FCM] Error handling email request:", err);
495
+ if (msg.reply) {
496
+ msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
497
+ }
498
+ }
499
+ }
500
+ } catch (err) {
501
+ console.error("Failed to subscribe to FCM email requests:", err);
502
+ }
503
+ })();
504
+
449
505
  // Subscribe to battery requests from hosts
450
506
  (async () => {
451
507
  try {
@@ -569,11 +625,36 @@ async function main(): Promise<void> {
569
625
  app.use("/api/fcm", fcmRoutes);
570
626
  app.use("/api/device", deviceRoutes);
571
627
 
572
- // Public NATS config endpoint (used by PWA for pairing)
628
+ // Public NATS config endpoint returns pairing-only credentials.
629
+ // These can only publish to pair.* subjects (no RPC, no event subscriptions).
573
630
  app.get("/api/config", (_req, res) => {
631
+ const accountSeed = process.env.NATS_ACCOUNT_SEED;
632
+ if (!accountSeed) {
633
+ res.status(500).json({ error: "Server NATS auth not configured" });
634
+ return;
635
+ }
636
+ const creds = createPairingCredentials(accountSeed);
637
+ res.json({
638
+ natsWsUrl: process.env.NATS_WS_URL || "",
639
+ natsJwt: creds.jwt,
640
+ natsNkeySeed: creds.nkeySeed,
641
+ });
642
+ });
643
+
644
+ // Host-scoped NATS credentials — returns JWT scoped to a single host's subjects.
645
+ // Called by the PWA after pairing to get credentials for RPC + event subscriptions.
646
+ app.get("/api/nats-credentials/:hostId", (req, res) => {
647
+ const accountSeed = process.env.NATS_ACCOUNT_SEED;
648
+ if (!accountSeed) {
649
+ res.status(500).json({ error: "Server NATS auth not configured" });
650
+ return;
651
+ }
652
+ const { hostId } = req.params;
653
+ const creds = createPwaCredentials(accountSeed, hostId);
574
654
  res.json({
575
655
  natsWsUrl: process.env.NATS_WS_URL || "",
576
- natsToken: process.env.NATS_TOKEN || "",
656
+ natsJwt: creds.jwt,
657
+ natsNkeySeed: creds.nkeySeed,
577
658
  });
578
659
  });
579
660