palmier 0.6.0 → 0.6.1

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 (82) hide show
  1. package/.github/workflows/publish.yml +15 -2
  2. package/CLAUDE.md +2 -2
  3. package/DISCLAIMER.md +36 -0
  4. package/README.md +76 -87
  5. package/dist/commands/pair.d.ts +1 -1
  6. package/dist/commands/pair.js +1 -1
  7. package/dist/pwa/apple-touch-icon.png +0 -0
  8. package/dist/pwa/assets/index-C7Ib48wG.js +118 -0
  9. package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
  10. package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  11. package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  12. package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  13. package/dist/pwa/favicon.ico +0 -0
  14. package/dist/pwa/index.html +17 -0
  15. package/dist/pwa/manifest.webmanifest +1 -0
  16. package/dist/pwa/pwa-192x192.png +0 -0
  17. package/dist/pwa/pwa-512x512.png +0 -0
  18. package/dist/pwa/registerSW.js +1 -0
  19. package/dist/pwa/service-worker.js +2 -0
  20. package/dist/rpc-handler.d.ts +4 -0
  21. package/dist/rpc-handler.js +1 -1
  22. package/dist/transports/http-transport.js +26 -40
  23. package/package.json +2 -2
  24. package/palmier-server/.github/workflows/ci.yml +21 -0
  25. package/palmier-server/.github/workflows/deploy.yml +38 -0
  26. package/palmier-server/CLAUDE.md +13 -0
  27. package/palmier-server/PRODUCTION.md +355 -0
  28. package/palmier-server/README.md +187 -0
  29. package/palmier-server/nats.conf +15 -0
  30. package/palmier-server/package.json +8 -0
  31. package/palmier-server/pnpm-lock.yaml +6597 -0
  32. package/palmier-server/pnpm-workspace.yaml +3 -0
  33. package/palmier-server/pwa/index.html +16 -0
  34. package/palmier-server/pwa/logo/logo-prompt.md +28 -0
  35. package/palmier-server/pwa/logo/logo_20260330.png +0 -0
  36. package/palmier-server/pwa/package.json +30 -0
  37. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  38. package/palmier-server/pwa/public/favicon.ico +0 -0
  39. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  40. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  41. package/palmier-server/pwa/src/App.css +2387 -0
  42. package/palmier-server/pwa/src/App.tsx +21 -0
  43. package/palmier-server/pwa/src/agentLabels.ts +11 -0
  44. package/palmier-server/pwa/src/api.ts +61 -0
  45. package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
  46. package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
  47. package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
  48. package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
  49. package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
  50. package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
  51. package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
  52. package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
  53. package/palmier-server/pwa/src/constants.ts +2 -0
  54. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
  55. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
  56. package/palmier-server/pwa/src/formatTime.ts +10 -0
  57. package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
  58. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
  59. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
  60. package/palmier-server/pwa/src/main.tsx +14 -0
  61. package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
  62. package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
  63. package/palmier-server/pwa/src/service-worker.ts +139 -0
  64. package/palmier-server/pwa/src/types.ts +79 -0
  65. package/palmier-server/pwa/src/vite-env.d.ts +11 -0
  66. package/palmier-server/pwa/tsconfig.json +21 -0
  67. package/palmier-server/pwa/tsconfig.node.json +19 -0
  68. package/palmier-server/pwa/vite.config.ts +47 -0
  69. package/palmier-server/server/.env.example +16 -0
  70. package/palmier-server/server/package.json +33 -0
  71. package/palmier-server/server/src/db.ts +34 -0
  72. package/palmier-server/server/src/index.ts +217 -0
  73. package/palmier-server/server/src/nats.ts +25 -0
  74. package/palmier-server/server/src/push.ts +68 -0
  75. package/palmier-server/server/src/routes/hosts.ts +45 -0
  76. package/palmier-server/server/src/routes/push.ts +100 -0
  77. package/palmier-server/server/tsconfig.json +20 -0
  78. package/palmier-server/spec.md +415 -0
  79. package/src/commands/pair.ts +1 -1
  80. package/src/rpc-handler.ts +1 -1
  81. package/src/transports/http-transport.ts +28 -41
  82. package/test/result-state.test.ts +110 -0
@@ -0,0 +1,223 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useNavigate, useLocation, useParams } from "react-router-dom";
3
+
4
+ import { useHostStore } from "../contexts/HostStoreContext";
5
+ import { useHostConnection } from "../contexts/HostConnectionContext";
6
+ import TaskListView from "../components/TaskListView";
7
+ import TabBar from "../components/TabBar";
8
+ import HostMenu from "../components/HostMenu";
9
+ import RunsView from "../components/RunsView";
10
+ import RunDetailView from "../components/RunDetailView";
11
+ import { usePushSubscription } from "../hooks/usePushSubscription";
12
+ import { useMediaQuery } from "../hooks/useMediaQuery";
13
+
14
+ export default function Dashboard() {
15
+ const { pairedHosts, activeHostId, removePairedHost } = useHostStore();
16
+ const { connected, request, subscribeEvents, unauthorized } = useHostConnection();
17
+ const navigate = useNavigate();
18
+ const location = useLocation();
19
+ const params = useParams<{ taskId?: string; runId?: string }>();
20
+ const isDesktop = useMediaQuery("(min-width: 768px)");
21
+
22
+ const isRunsTab = location.pathname.startsWith("/runs");
23
+ const runsFilterTaskId = params.runId ? undefined : params.taskId;
24
+
25
+ // Resolve "latest" run ID to the actual run ID
26
+ const [resolvedRunId, setResolvedRunId] = useState<string | null>(null);
27
+ const rawRunId = params.runId;
28
+ const isLatest = rawRunId === "latest";
29
+
30
+ useEffect(() => {
31
+ if (!isLatest || !params.taskId || !connected) {
32
+ setResolvedRunId(null);
33
+ return;
34
+ }
35
+ request<{ entries?: Array<{ run_id: string }> }>("taskrun.list", { task_id: params.taskId, limit: 1 })
36
+ .then((result) => {
37
+ const latest = result.entries?.[0]?.run_id;
38
+ setResolvedRunId(latest ?? null);
39
+ })
40
+ .catch(() => setResolvedRunId(null));
41
+ }, [isLatest, params.taskId, connected]);
42
+
43
+ const effectiveRunId = isLatest ? resolvedRunId : rawRunId;
44
+ const isRunDetail = !!(params.taskId && effectiveRunId && effectiveRunId !== "latest");
45
+
46
+ const [updateRequired, setUpdateRequired] = useState(false);
47
+ const [updating, setUpdating] = useState(false);
48
+ const [updateError, setUpdateError] = useState<string | null>(null);
49
+ const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
50
+
51
+ // Register push subscription for the active host
52
+ usePushSubscription();
53
+
54
+ // Reset scroll when switching hosts
55
+ useEffect(() => {
56
+ window.scrollTo(0, 0);
57
+ }, [activeHostId]);
58
+
59
+ function handleViewRun(taskId: string, runId?: string) {
60
+ if (runId) {
61
+ navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
62
+ } else {
63
+ navigate(`/runs/${encodeURIComponent(taskId)}`);
64
+ }
65
+ }
66
+
67
+ async function handleUpdate() {
68
+ setUpdating(true);
69
+ setUpdateError(null);
70
+ try {
71
+ const result = await request<{ ok?: boolean; error?: string }>("host.update");
72
+ if (result.error) {
73
+ setUpdateError(result.error);
74
+ setUpdating(false);
75
+ return;
76
+ }
77
+ } catch {
78
+ // Expected: connection drops during daemon restart
79
+ }
80
+ // Daemon will restart; reload after it has time to come back up.
81
+ setTimeout(() => window.location.reload(), 10000);
82
+ }
83
+
84
+ const hasHosts = pairedHosts.length > 0;
85
+ const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
86
+
87
+ return (
88
+ <div className="dashboard">
89
+ {isDesktop && <HostMenu daemonVersion={daemonVersion} />}
90
+
91
+ <div className="dashboard-content">
92
+ <div className="tab-bar">
93
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} />}
94
+ <TabBar />
95
+ </div>
96
+
97
+ <main className="dashboard-main">
98
+ {unauthorized ? (
99
+ <div className="revoked-state">
100
+ <div className="revoked-icon">
101
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
102
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
103
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
104
+ <line x1="12" y1="15" x2="12" y2="18" />
105
+ </svg>
106
+ </div>
107
+ <h2 className="revoked-title">Client Revoked</h2>
108
+ <p className="revoked-description">
109
+ This client was revoked by the host. To reconnect, generate a new pairing code on the host machine.
110
+ </p>
111
+ <div className="revoked-command">
112
+ <code>palmier pair</code>
113
+ </div>
114
+ <div className="revoked-actions">
115
+ <button
116
+ className="btn btn-primary"
117
+ onClick={() => navigate("/pair")}
118
+ >
119
+ Re-pair Device
120
+ </button>
121
+ <button
122
+ className="btn btn-secondary"
123
+ onClick={() => {
124
+ if (activeHostId) removePairedHost(activeHostId);
125
+ }}
126
+ >
127
+ Remove Host
128
+ </button>
129
+ </div>
130
+ </div>
131
+ ) : showTaskContent ? (
132
+ <>
133
+ <div style={{ display: !isRunsTab ? "contents" : "none" }}>
134
+ <TaskListView
135
+ connected={connected}
136
+ hostId={activeHostId}
137
+ request={request}
138
+ subscribeEvents={subscribeEvents}
139
+ onViewRun={handleViewRun}
140
+ onUpdateRequired={setUpdateRequired}
141
+ onVersion={setDaemonVersion}
142
+ />
143
+ </div>
144
+ {isRunDetail ? (
145
+ <RunDetailView
146
+ connected={connected}
147
+ hostId={activeHostId}
148
+ request={request}
149
+ subscribeEvents={subscribeEvents}
150
+ taskId={params.taskId!}
151
+ runId={decodeURIComponent(effectiveRunId!)}
152
+ />
153
+ ) : isRunsTab ? (
154
+ <RunsView
155
+ connected={connected}
156
+ hostId={activeHostId}
157
+ request={request}
158
+ subscribeEvents={subscribeEvents}
159
+ filterTaskId={runsFilterTaskId}
160
+ onClearFilter={() => navigate("/runs")}
161
+ />
162
+ ) : null}
163
+ </>
164
+ ) : (
165
+ <div className="empty-state">
166
+ <p>{hasHosts ? "Connecting to host..." : "No hosts paired yet."}</p>
167
+ {!hasHosts && (
168
+ <button
169
+ className="btn btn-primary"
170
+ onClick={() => navigate("/pair")}
171
+ >
172
+ Pair Host
173
+ </button>
174
+ )}
175
+ </div>
176
+ )}
177
+ </main>
178
+
179
+ {updateRequired && !updating && !updateError && (
180
+ <div className="confirm-modal-overlay">
181
+ <div className="confirm-modal">
182
+ <h2 className="confirm-modal-title">Update Required</h2>
183
+ <p className="confirm-modal-message">
184
+ Your Palmier host{daemonVersion ? ` (v${daemonVersion})` : ""} is too old for this version of the app. Please update to continue.
185
+ </p>
186
+ <div className="confirm-modal-actions">
187
+ <button className="btn btn-primary" onClick={handleUpdate}>
188
+ Update Now
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ )}
194
+ {updating && (
195
+ <div className="confirm-modal-overlay">
196
+ <div className="confirm-modal">
197
+ <h2 className="confirm-modal-title">Updating...</h2>
198
+ <p className="confirm-modal-message">
199
+ Installing update and restarting daemon. Please wait...
200
+ </p>
201
+ </div>
202
+ </div>
203
+ )}
204
+ {updateError && (
205
+ <div className="confirm-modal-overlay">
206
+ <div className="confirm-modal">
207
+ <h2 className="confirm-modal-title">Update Failed</h2>
208
+ <p className="confirm-modal-message" style={{ whiteSpace: "pre-line" }}>
209
+ {updateError}
210
+ </p>
211
+ <div className="confirm-modal-actions">
212
+ <button className="btn btn-secondary" onClick={() => { setUpdateError(null); }}>
213
+ Retry
214
+ </button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ )}
219
+
220
+ </div>
221
+ </div>
222
+ );
223
+ }
@@ -0,0 +1,178 @@
1
+ import { useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { connect, StringCodec } from "nats.ws";
4
+ import { useHostStore } from "../contexts/HostStoreContext";
5
+ import type { PairedHost } from "../types";
6
+
7
+ interface PairResponse {
8
+ hostId: string;
9
+ clientToken: string;
10
+ directUrl?: string;
11
+ }
12
+
13
+ /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
14
+ const isLanMode = !!(window as any).__PALMIER_SERVE__;
15
+
16
+ export default function PairHost() {
17
+ const [code, setCode] = useState("");
18
+ const [pairing, setPairing] = useState(false);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const { addPairedHost } = useHostStore();
21
+ const navigate = useNavigate();
22
+
23
+ async function handlePair() {
24
+ const trimmedCode = code.trim().toUpperCase();
25
+ if (!trimmedCode) {
26
+ setError("Enter a pairing code.");
27
+ return;
28
+ }
29
+
30
+ setPairing(true);
31
+ setError(null);
32
+
33
+ try {
34
+ let response: PairResponse;
35
+
36
+ if (isLanMode) {
37
+ // LAN mode — same-origin fetch to the host serving this page
38
+ const res = await fetch("/pair", {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ code: trimmedCode, label: navigator.userAgent }),
42
+ });
43
+
44
+ if (!res.ok) {
45
+ const body = await res.json().catch(() => ({ error: "Connection failed" })) as { error?: string };
46
+ throw new Error(body.error || `HTTP ${res.status}`);
47
+ }
48
+
49
+ response = await res.json() as PairResponse;
50
+ } else {
51
+ // Server mode — pair via NATS
52
+ const configRes = await fetch("/api/config");
53
+ if (!configRes.ok) throw new Error("Failed to fetch server config");
54
+ const config = await configRes.json() as { natsWsUrl: string; natsToken: string };
55
+ if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
56
+
57
+ const nc = await connect({
58
+ servers: config.natsWsUrl,
59
+ token: config.natsToken,
60
+ });
61
+
62
+ const sc = StringCodec();
63
+ const subject = `pair.${trimmedCode}`;
64
+ const msg = await nc.request(
65
+ subject,
66
+ sc.encode(JSON.stringify({ label: navigator.userAgent })),
67
+ { timeout: 10000 },
68
+ );
69
+
70
+ response = JSON.parse(sc.decode(msg.data)) as PairResponse;
71
+ await nc.close();
72
+ }
73
+
74
+ const host: PairedHost = {
75
+ hostId: response.hostId,
76
+ clientToken: response.clientToken,
77
+ directUrl: isLanMode ? window.location.origin : undefined,
78
+ };
79
+
80
+ addPairedHost(host);
81
+ navigate("/");
82
+ } catch (err) {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ if (message.includes("timeout") || message.includes("TIMEOUT") || message.includes("503") || message.toLowerCase().includes("no responders")) {
85
+ setError("Code not found or expired. Check the code and try again.");
86
+ } else {
87
+ setError(message);
88
+ }
89
+ } finally {
90
+ setPairing(false);
91
+ }
92
+ }
93
+
94
+ return (
95
+ <div className="pair-page">
96
+ <div className="pair-card">
97
+ <div className="pair-header">
98
+ <h1 className="pair-title">{isLanMode ? "Pair" : "Pair with Host"}</h1>
99
+ <p className="pair-subtitle">
100
+ {isLanMode
101
+ ? "Enter the pairing code shown in your terminal."
102
+ : "Connect this device to a Palmier host"}
103
+ </p>
104
+ </div>
105
+
106
+ {!isLanMode && (
107
+ <div className="pair-instructions">
108
+ <div className="pair-instruction-block">
109
+ <h3 className="pair-instruction-heading">Setting up a new host?</h3>
110
+ <ol className="pair-steps">
111
+ <li>Install at least one agent CLI (e.g., <a href="https://www.palmier.me/agents" target="_blank" rel="noopener noreferrer">Claude Code, Gemini CLI, Codex CLI</a>)</li>
112
+ <li>Install Palmier on your host machine:
113
+ <code className="pair-command">npm install -g palmier</code>
114
+ </li>
115
+ <li>Run the setup wizard:
116
+ <code className="pair-command">palmier init</code>
117
+ </li>
118
+ <li>A pairing code will display automatically</li>
119
+ </ol>
120
+ </div>
121
+ <div className="pair-instruction-divider" />
122
+ <div className="pair-instruction-block">
123
+ <h3 className="pair-instruction-heading">Pairing an existing host?</h3>
124
+ <ol className="pair-steps">
125
+ <li>
126
+ Run <code>palmier pair</code> on the host machine
127
+ </li>
128
+ <li>Enter the 6-character code below</li>
129
+ </ol>
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ <div className="pair-form">
135
+ <label className="form-label" htmlFor="pair-code">
136
+ Pairing code
137
+ <input
138
+ id="pair-code"
139
+ type="text"
140
+ maxLength={6}
141
+ value={code}
142
+ onChange={(e) => setCode(e.target.value.toUpperCase())}
143
+ placeholder="A7K9M2"
144
+ className="form-input form-input-mono pair-code-input"
145
+ autoFocus
146
+ autoComplete="off"
147
+ disabled={pairing}
148
+ />
149
+ </label>
150
+
151
+ {error && <p className="pair-error">{error}</p>}
152
+
153
+ <button
154
+ className="btn btn-primary btn-full"
155
+ onClick={handlePair}
156
+ disabled={pairing || !code.trim()}
157
+ >
158
+ {pairing && <span className="btn-spinner" />}
159
+ {pairing ? "Pairing..." : "Pair"}
160
+ </button>
161
+ <button
162
+ className="btn btn-secondary btn-full"
163
+ onClick={() => navigate("/")}
164
+ disabled={pairing}
165
+ >
166
+ Cancel
167
+ </button>
168
+
169
+ <p className="pair-consent">
170
+ By pairing, you agree to our{" "}
171
+ <a href="https://www.palmier.me/terms" target="_blank" rel="noopener noreferrer">Terms of Service</a> and{" "}
172
+ <a href="https://www.palmier.me/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
173
+ </p>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,139 @@
1
+ /// <reference lib="webworker" />
2
+ import { precacheAndRoute } from "workbox-precaching";
3
+
4
+ declare const self: ServiceWorkerGlobalScope;
5
+
6
+ // Precache assets injected by vite-plugin-pwa
7
+ precacheAndRoute(self.__WB_MANIFEST);
8
+
9
+ const API_URL = "/api/push/respond";
10
+
11
+ // hostId stored for potential future use (e.g., scoped push responses)
12
+ self.addEventListener("message", (_event) => {
13
+ // Handle messages from the main app (e.g., set-host-id)
14
+ });
15
+
16
+ self.addEventListener("push", (event) => {
17
+ if (!event.data) return;
18
+
19
+ let payload: {
20
+ title?: string;
21
+ body?: string;
22
+ data?: Record<string, unknown>;
23
+ type?: string;
24
+ };
25
+ try {
26
+ payload = event.data.json();
27
+ } catch {
28
+ payload = { title: "Palmier", body: event.data.text() };
29
+ }
30
+
31
+ const type = payload.type ?? (payload.data as Record<string, unknown>)?.type;
32
+
33
+ // Silent dismiss: close matching notification without showing a new one
34
+ if (type === "confirm-dismiss" || type === "permission-dismiss" || type === "input-dismiss") {
35
+ const data = payload.data ?? payload;
36
+ const taskId = (data as Record<string, unknown>).task_id;
37
+ const dataHostId = (data as Record<string, unknown>).host_id;
38
+ event.waitUntil(
39
+ self.registration.getNotifications().then((notifications) => {
40
+ for (const n of notifications) {
41
+ if (n.data?.task_id === taskId && n.data?.host_id === dataHostId) {
42
+ n.close();
43
+ }
44
+ }
45
+ })
46
+ );
47
+ return;
48
+ }
49
+
50
+ const title = payload.title ?? "Palmier";
51
+ let body = payload.body ?? "";
52
+ if (!body && type === "confirm") {
53
+ body = "A task requires confirmation to run.";
54
+ }
55
+ if (!body && type === "permission") {
56
+ body = "A task needs additional permissions to continue.";
57
+ }
58
+ if (!body && type === "input") {
59
+ body = "A task needs your input to continue.";
60
+ }
61
+
62
+ const options: NotificationOptions & { vibrate?: number[]; actions?: Array<{ action: string; title: string }> } = {
63
+ body,
64
+ icon: "/pwa-192x192.png",
65
+ badge: "/pwa-192x192.png",
66
+ data: payload.data ?? payload,
67
+ vibrate: [100, 50, 100],
68
+ };
69
+
70
+ // Add action buttons for confirmation notifications
71
+ if (type === "confirm") {
72
+ options.actions = [
73
+ { action: "confirm", title: "Confirm" },
74
+ { action: "abort", title: "Abort" },
75
+ ];
76
+ }
77
+
78
+ event.waitUntil(self.registration.showNotification(title, options));
79
+ });
80
+
81
+ self.addEventListener("notificationclick", (event) => {
82
+ const notification = event.notification;
83
+ notification.close();
84
+
85
+ const data = notification.data ?? {};
86
+ const action = event.action;
87
+
88
+ if (action && data.type === "confirm" && data.task_id && data.host_id) {
89
+ const response = action === "confirm" ? "confirmed" : "aborted";
90
+
91
+ event.waitUntil(
92
+ fetch(API_URL, {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify({
96
+ type: data.type,
97
+ task_id: data.task_id,
98
+ host_id: data.host_id,
99
+ response,
100
+ }),
101
+ }).catch((err) => {
102
+ console.error("Failed to send push response:", err);
103
+ })
104
+ );
105
+ } else {
106
+ // User tapped the notification body — open the PWA.
107
+ // For task-complete/fail notifications, deep-link to the result view.
108
+ const taskId = data.task_id;
109
+ const runId = data.run_id;
110
+ const targetUrl = taskId && runId && (data.type === "complete" || data.type === "fail")
111
+ ? `/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`
112
+ : taskId && (data.type === "complete" || data.type === "fail")
113
+ ? `/runs/${encodeURIComponent(taskId)}`
114
+ : "/";
115
+
116
+ event.waitUntil(
117
+ self.clients
118
+ .matchAll({ type: "window", includeUncontrolled: true })
119
+ .then((clients) => {
120
+ for (const client of clients) {
121
+ if (client.url.includes(self.location.origin) && "focus" in client) {
122
+ (client as WindowClient).navigate(targetUrl);
123
+ return client.focus();
124
+ }
125
+ }
126
+ return self.clients.openWindow(targetUrl);
127
+ })
128
+ );
129
+ }
130
+ });
131
+
132
+ // Activate immediately
133
+ self.addEventListener("install", () => {
134
+ self.skipWaiting();
135
+ });
136
+
137
+ self.addEventListener("activate", (event) => {
138
+ event.waitUntil(self.clients.claim());
139
+ });
@@ -0,0 +1,79 @@
1
+ export interface AgentInfo {
2
+ key: string;
3
+ label: string;
4
+ supportsPermissions?: boolean;
5
+ }
6
+
7
+
8
+ export interface Task {
9
+ id: string;
10
+ name: string;
11
+ user_prompt: string;
12
+ agent?: string;
13
+ triggers: Trigger[];
14
+ triggers_enabled: boolean;
15
+ requires_confirmation: boolean;
16
+ yolo_mode?: boolean;
17
+ foreground_mode?: boolean;
18
+ permissions?: RequiredPermission[];
19
+ command?: string;
20
+ body?: string;
21
+ }
22
+
23
+ export interface Trigger {
24
+ type: "cron" | "once";
25
+ value: string;
26
+ }
27
+
28
+ export interface RequiredPermission {
29
+ name: string;
30
+ description: string;
31
+ }
32
+
33
+ export interface ConfirmNotification {
34
+ type: "confirm";
35
+ task_id: string;
36
+ host_id: string;
37
+ }
38
+
39
+ export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
40
+
41
+ export interface TaskStatus {
42
+ running_state: TaskRunningState;
43
+ /** UTC time in milliseconds since epoch */
44
+ time_stamp: number;
45
+ /** Whether this task is awaiting user confirmation */
46
+ pending_confirmation?: boolean;
47
+ /** Permissions the task needs granted to continue */
48
+ pending_permission?: RequiredPermission[];
49
+ pending_input?: string[];
50
+ user_input?: string[];
51
+ }
52
+
53
+ export interface HistoryEntry {
54
+ task_id: string;
55
+ run_id: string;
56
+ // Enriched by taskrun.list RPC from TASKRUN.md:
57
+ task_name?: string;
58
+ running_state?: string;
59
+ start_time?: number;
60
+ end_time?: number;
61
+ error?: string;
62
+ }
63
+
64
+ export interface ConversationMessage {
65
+ role: "assistant" | "user" | "status";
66
+ time: number;
67
+ content: string;
68
+ type?: "input" | "permission" | "confirmation" | "monitoring" | "started" | "finished" | "failed" | "aborted" | "stopped";
69
+ attachments?: string[];
70
+ }
71
+
72
+ /** A host paired via pairing code (stored in localStorage). */
73
+ export interface PairedHost {
74
+ hostId: string;
75
+ clientToken: string;
76
+ name?: string;
77
+ /** If set, all communication uses HTTP to this URL instead of NATS. */
78
+ directUrl?: string;
79
+ }
@@ -0,0 +1,11 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "@fontsource-variable/plus-jakarta-sans";
4
+
5
+ interface ImportMetaEnv {
6
+ readonly VITE_API_URL: string;
7
+ }
8
+
9
+ interface ImportMeta {
10
+ readonly env: ImportMetaEnv;
11
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "Bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedSideEffectImports": true
19
+ },
20
+ "include": ["src"]
21
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023"],
5
+ "module": "ESNext",
6
+ "skipLibCheck": true,
7
+ "moduleResolution": "Bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "isolatedModules": true,
10
+ "moduleDetection": "force",
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUncheckedSideEffectImports": true
17
+ },
18
+ "include": ["vite.config.ts"]
19
+ }