palmier 0.6.6 → 0.6.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 (96) hide show
  1. package/README.md +15 -1
  2. package/dist/agents/agent-instructions.md +6 -14
  3. package/dist/agents/aider.js +1 -1
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/cline.js +1 -1
  6. package/dist/agents/codex.js +1 -1
  7. package/dist/agents/copilot.js +1 -1
  8. package/dist/agents/cursor.js +1 -1
  9. package/dist/agents/deepagents.js +1 -1
  10. package/dist/agents/droid.js +1 -1
  11. package/dist/agents/gemini.js +1 -1
  12. package/dist/agents/goose.js +1 -1
  13. package/dist/agents/hermes.js +1 -1
  14. package/dist/agents/kimi.js +1 -1
  15. package/dist/agents/kiro.js +1 -1
  16. package/dist/agents/openclaw.js +1 -1
  17. package/dist/agents/opencode.js +1 -1
  18. package/dist/agents/qoder.js +1 -1
  19. package/dist/agents/qwen.js +1 -1
  20. package/dist/agents/shared-prompt.d.ts +3 -2
  21. package/dist/agents/shared-prompt.js +6 -4
  22. package/dist/commands/plan-generation.md +1 -0
  23. package/dist/commands/run.js +4 -7
  24. package/dist/location-device.d.ts +8 -0
  25. package/dist/location-device.js +32 -0
  26. package/dist/mcp-handler.d.ts +8 -0
  27. package/dist/mcp-handler.js +110 -0
  28. package/dist/mcp-tools.d.ts +27 -0
  29. package/dist/mcp-tools.js +218 -0
  30. package/dist/pwa/assets/{index-DhvJN8ie.css → index-C6Lz09EY.css} +1 -1
  31. package/dist/pwa/assets/index-C8vJwUNi.js +118 -0
  32. package/dist/pwa/assets/web-6UChJFov.js +1 -0
  33. package/dist/pwa/assets/web-NxTETXZK.js +1 -0
  34. package/dist/pwa/index.html +3 -3
  35. package/dist/pwa/service-worker.js +2 -2
  36. package/dist/rpc-handler.js +20 -8
  37. package/dist/spawn-command.js +3 -1
  38. package/dist/transports/http-transport.js +60 -129
  39. package/package.json +1 -1
  40. package/palmier-server/README.md +6 -1
  41. package/palmier-server/package.json +7 -1
  42. package/palmier-server/pnpm-lock.yaml +1025 -1
  43. package/palmier-server/pwa/index.html +1 -1
  44. package/palmier-server/pwa/package.json +3 -0
  45. package/palmier-server/pwa/src/App.css +64 -0
  46. package/palmier-server/pwa/src/api.ts +8 -2
  47. package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
  48. package/palmier-server/pwa/src/components/TaskCard.tsx +36 -8
  49. package/palmier-server/pwa/src/components/TaskForm.tsx +63 -53
  50. package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
  51. package/palmier-server/pwa/src/constants.ts +1 -1
  52. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
  53. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
  54. package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
  55. package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
  56. package/palmier-server/pwa/src/service-worker.ts +7 -7
  57. package/palmier-server/server/.env.example +4 -0
  58. package/palmier-server/server/package.json +1 -0
  59. package/palmier-server/server/src/db.ts +10 -0
  60. package/palmier-server/server/src/fcm.ts +74 -0
  61. package/palmier-server/server/src/index.ts +101 -21
  62. package/palmier-server/server/src/notify.ts +34 -0
  63. package/palmier-server/server/src/push.ts +1 -1
  64. package/palmier-server/server/src/routes/fcm.ts +64 -0
  65. package/palmier-server/server/src/routes/push.ts +6 -5
  66. package/palmier-server/spec.md +4 -2
  67. package/src/agents/agent-instructions.md +6 -14
  68. package/src/agents/aider.ts +1 -1
  69. package/src/agents/claude.ts +1 -1
  70. package/src/agents/cline.ts +1 -1
  71. package/src/agents/codex.ts +1 -1
  72. package/src/agents/copilot.ts +1 -1
  73. package/src/agents/cursor.ts +1 -1
  74. package/src/agents/deepagents.ts +1 -1
  75. package/src/agents/droid.ts +1 -1
  76. package/src/agents/gemini.ts +1 -1
  77. package/src/agents/goose.ts +1 -1
  78. package/src/agents/hermes.ts +1 -1
  79. package/src/agents/kimi.ts +1 -1
  80. package/src/agents/kiro.ts +1 -1
  81. package/src/agents/openclaw.ts +1 -1
  82. package/src/agents/opencode.ts +1 -1
  83. package/src/agents/qoder.ts +1 -1
  84. package/src/agents/qwen.ts +1 -1
  85. package/src/agents/shared-prompt.ts +7 -4
  86. package/src/commands/plan-generation.md +1 -0
  87. package/src/commands/run.ts +4 -7
  88. package/src/location-device.ts +35 -0
  89. package/src/mcp-handler.ts +133 -0
  90. package/src/mcp-tools.ts +253 -0
  91. package/src/rpc-handler.ts +21 -8
  92. package/src/spawn-command.ts +3 -1
  93. package/src/transports/http-transport.ts +57 -128
  94. package/test/agent-instructions.test.ts +68 -5
  95. package/test/fixtures/agent-instructions-snapshot.md +58 -0
  96. package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
@@ -7,7 +7,7 @@
7
7
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
8
8
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
9
9
  <title>Palmier</title>
10
- <meta name="description" content="Control AI agents running on your machine from any device. Schedule tasks, monitor runs, and stay in control." />
10
+ <meta name="description" content="Remote control for AI agents running on your own machine. Schedule tasks, approve permissions, and get push notifications." />
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -9,6 +9,9 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
+ "@capacitor/app": "^8.1.0",
13
+ "@capacitor/core": "^8.3.0",
14
+ "@capacitor/preferences": "^8.0.1",
12
15
  "@fontsource-variable/plus-jakarta-sans": "^5.2.8",
13
16
  "nats.ws": "^1.30.0",
14
17
  "react": "^19.0.0",
@@ -1152,6 +1152,7 @@ body {
1152
1152
  width: auto;
1153
1153
  }
1154
1154
 
1155
+ .triggers-section-body > .form-select,
1155
1156
  .trigger-row-card .form-select,
1156
1157
  .trigger-row-card .form-input {
1157
1158
  margin-bottom: 0;
@@ -1160,6 +1161,14 @@ body {
1160
1161
  height: 32px;
1161
1162
  box-sizing: border-box;
1162
1163
  min-width: 0;
1164
+ }
1165
+
1166
+ .triggers-section-body > .form-select {
1167
+ width: 100%;
1168
+ }
1169
+
1170
+ .trigger-row-card .form-select,
1171
+ .trigger-row-card .form-input {
1163
1172
  flex: 1;
1164
1173
  }
1165
1174
 
@@ -1308,6 +1317,12 @@ body {
1308
1317
  font-size: 1.125rem;
1309
1318
  font-weight: 700;
1310
1319
  letter-spacing: -0.02em;
1320
+ margin-bottom: var(--space-xs);
1321
+ }
1322
+
1323
+ .confirm-modal-subtitle {
1324
+ font-size: 0.8rem;
1325
+ color: var(--color-muted);
1311
1326
  margin-bottom: var(--space-sm);
1312
1327
  }
1313
1328
 
@@ -1681,6 +1696,55 @@ body {
1681
1696
  margin-bottom: var(--space-sm);
1682
1697
  }
1683
1698
 
1699
+ .drawer-toggle {
1700
+ display: flex;
1701
+ align-items: center;
1702
+ justify-content: space-between;
1703
+ gap: var(--space-sm);
1704
+ }
1705
+
1706
+ .drawer-toggle-label {
1707
+ font-size: 0.85rem;
1708
+ color: var(--color-text);
1709
+ }
1710
+
1711
+ .toggle-switch {
1712
+ position: relative;
1713
+ width: 40px;
1714
+ height: 22px;
1715
+ border-radius: 11px;
1716
+ border: none;
1717
+ background: var(--color-border);
1718
+ cursor: pointer;
1719
+ padding: 0;
1720
+ transition: background 0.2s;
1721
+ flex-shrink: 0;
1722
+ }
1723
+
1724
+ .toggle-switch-on {
1725
+ background: var(--color-primary);
1726
+ }
1727
+
1728
+ .toggle-switch-thumb {
1729
+ position: absolute;
1730
+ top: 2px;
1731
+ left: 2px;
1732
+ width: 18px;
1733
+ height: 18px;
1734
+ border-radius: 50%;
1735
+ background: white;
1736
+ transition: transform 0.2s;
1737
+ }
1738
+
1739
+ .toggle-switch-on .toggle-switch-thumb {
1740
+ transform: translateX(18px);
1741
+ }
1742
+
1743
+ .toggle-switch:disabled {
1744
+ opacity: 0.5;
1745
+ cursor: not-allowed;
1746
+ }
1747
+
1684
1748
  .drawer-footer {
1685
1749
  margin-top: auto;
1686
1750
  padding: var(--space-md);
@@ -1,17 +1,23 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+
3
+ /** On native platforms, API calls go to the production server. On web, they're relative (same-origin). */
4
+ export const SERVER_URL = Capacitor.isNativePlatform() ? "https://app.palmier.me" : "";
5
+
1
6
  async function request<T>(
2
7
  method: string,
3
8
  path: string,
4
9
  body?: unknown,
5
10
  token?: string
6
11
  ): Promise<T> {
7
- console.log(`[API] ${method} ${path}`);
12
+ const url = `${SERVER_URL}${path}`;
13
+ console.log(`[API] ${method} ${url}`);
8
14
  const headers: Record<string, string> = {
9
15
  "Content-Type": "application/json",
10
16
  };
11
17
  if (token) {
12
18
  headers["Authorization"] = `Bearer ${token}`;
13
19
  }
14
- const res = await fetch(path, {
20
+ const res = await fetch(url, {
15
21
  method,
16
22
  headers,
17
23
  body: body != null ? JSON.stringify(body) : undefined,
@@ -1,17 +1,39 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useNavigate } from "react-router-dom";
4
+ import { Capacitor, registerPlugin } from "@capacitor/core";
5
+ import { App as CapApp } from "@capacitor/app";
6
+ import { Preferences } from "@capacitor/preferences";
7
+
8
+ interface LocationPermissionResult {
9
+ fine: boolean;
10
+ background: boolean;
11
+ }
12
+
13
+ interface LocationPermissionPlugin {
14
+ request(): Promise<LocationPermissionResult>;
15
+ check(): Promise<LocationPermissionResult>;
16
+ }
17
+
18
+ const LocationPermission = Capacitor.isNativePlatform()
19
+ ? registerPlugin<LocationPermissionPlugin>("LocationPermission")
20
+ : null;
4
21
  import { useHostStore } from "../contexts/HostStoreContext";
5
22
  import { useMediaQuery } from "../hooks/useMediaQuery";
6
23
 
7
24
  /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
8
25
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
26
+ const isNative = Capacitor.isNativePlatform();
9
27
 
10
28
  interface HostMenuProps {
11
29
  daemonVersion?: string | null;
30
+ locationClientToken?: string | null;
31
+ activeClientToken?: string | null;
32
+ request?<T = unknown>(method: string, params?: unknown): Promise<T>;
33
+ onLocationClientTokenChange?(token: string | null): void;
12
34
  }
13
35
 
14
- export default function HostMenu({ daemonVersion }: HostMenuProps) {
36
+ export default function HostMenu({ daemonVersion, locationClientToken, activeClientToken, request, onLocationClientTokenChange }: HostMenuProps) {
15
37
  const { pairedHosts, activeHostId, setActiveHostId, removePairedHost, renamePairedHost } = useHostStore();
16
38
  const navigate = useNavigate();
17
39
  const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -21,6 +43,65 @@ export default function HostMenu({ daemonVersion }: HostMenuProps) {
21
43
  const [renamingId, setRenamingId] = useState<string | null>(null);
22
44
  const [renameValue, setRenameValue] = useState("");
23
45
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
46
+ const [togglingLocation, setTogglingLocation] = useState(false);
47
+
48
+ const locationEnabled = !!(activeClientToken && locationClientToken === activeClientToken);
49
+
50
+ // Sync location toggle with permission state — on mount and when app resumes from background
51
+ useEffect(() => {
52
+ if (!isNative || !LocationPermission || !request) return;
53
+
54
+ function syncPermissionState() {
55
+ if (!locationEnabled) return;
56
+ LocationPermission!.check().then(({ fine }) => {
57
+ if (!fine) {
58
+ // Permission revoked — disable on host
59
+ request!("device.location.disable").then(() => {
60
+ onLocationClientTokenChange?.(null);
61
+ }).catch(() => {});
62
+ }
63
+ });
64
+ }
65
+
66
+ syncPermissionState();
67
+
68
+ const listener = CapApp.addListener("resume", () => {
69
+ syncPermissionState();
70
+ });
71
+
72
+ return () => { listener.then((h) => h.remove()); };
73
+ }, [locationEnabled, activeClientToken]);
74
+
75
+ async function handleLocationToggle() {
76
+ if (!request) return;
77
+ setTogglingLocation(true);
78
+ try {
79
+ if (locationEnabled) {
80
+ await request("device.location.disable");
81
+ onLocationClientTokenChange?.(null);
82
+ } else {
83
+ // Request location permissions before enabling
84
+ if (LocationPermission) {
85
+ const result = await LocationPermission.request();
86
+ if (!result.fine) {
87
+ return; // User denied permission
88
+ }
89
+ }
90
+
91
+ const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
92
+ if (!fcmToken) {
93
+ console.warn("No FCM token available");
94
+ return;
95
+ }
96
+ await request("device.location.enable", { fcmToken });
97
+ onLocationClientTokenChange?.(activeClientToken ?? null);
98
+ }
99
+ } catch (err) {
100
+ console.error("Failed to toggle location:", err);
101
+ } finally {
102
+ setTogglingLocation(false);
103
+ }
104
+ }
24
105
  const drawerRef = useRef<HTMLDivElement>(null);
25
106
  const renameInputRef = useRef<HTMLInputElement>(null);
26
107
 
@@ -197,6 +278,26 @@ export default function HostMenu({ daemonVersion }: HostMenuProps) {
197
278
  </div>
198
279
  </>)}
199
280
 
281
+ {isNative && (
282
+ <>
283
+ <div className="drawer-divider" />
284
+ <div className="drawer-section">
285
+ <label className="drawer-toggle">
286
+ <span className="drawer-toggle-label">Location Access</span>
287
+ <button
288
+ className={`toggle-switch ${locationEnabled ? "toggle-switch-on" : ""}`}
289
+ onClick={handleLocationToggle}
290
+ disabled={togglingLocation}
291
+ role="switch"
292
+ aria-checked={locationEnabled}
293
+ >
294
+ <span className="toggle-switch-thumb" />
295
+ </button>
296
+ </label>
297
+ </div>
298
+ </>
299
+ )}
300
+
200
301
  <div className="drawer-footer">
201
302
  {daemonVersion && (
202
303
  <div className="drawer-version">
@@ -99,25 +99,53 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
99
99
  };
100
100
 
101
101
 
102
- function formatTrigger(t: { type: string; value: string }): string {
102
+ function formatTriggersGrouped(triggers: { type: string; value: string }[]): string {
103
+ if (triggers.length === 0) return "";
104
+ if (triggers.length === 1) return formatSingleTrigger(triggers[0]);
105
+
106
+ // Detect the shared schedule type
107
+ const classified = triggers.map(classifyTrigger);
108
+ const types = new Set(classified.map((c) => c.kind));
109
+
110
+ // If all the same type, group them
111
+ if (types.size === 1) {
112
+ const kind = classified[0].kind;
113
+ if (kind === "hourly") return "Every hour";
114
+ const details = classified.map((c) => c.detail);
115
+ return `${kind.charAt(0).toUpperCase() + kind.slice(1)}: ${details.join(", ")}`;
116
+ }
117
+
118
+ // Mixed types — fall back to listing each
119
+ return triggers.map(formatSingleTrigger).join(", ");
120
+ }
121
+
122
+ function classifyTrigger(t: { type: string; value: string }): { kind: string; detail: string } {
103
123
  if (t.type === "once") {
104
124
  const d = new Date(t.value);
105
- return isNaN(d.getTime()) ? t.value : `Once on ${d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} at ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
125
+ const label = isNaN(d.getTime()) ? t.value : `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} at ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
126
+ return { kind: "once", detail: label };
106
127
  }
107
128
  const parts = t.value.split(" ");
108
- if (parts.length !== 5) return t.value;
129
+ if (parts.length !== 5) return { kind: "unknown", detail: t.value };
109
130
  const [min, hour, dom, , dow] = parts;
110
131
  const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
111
- if (hour === "*") return "Every hour";
132
+ if (hour === "*") return { kind: "hourly", detail: "" };
112
133
  const d = new Date();
113
134
  d.setHours(Number(hour), Number(min), 0, 0);
114
135
  const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
115
- if (dow !== "*") return `Weekly on ${DAYS[Number(dow)] ?? dow} at ${time}`;
116
- if (dom !== "*") return `Monthly on day ${dom} at ${time}`;
117
- return `Daily at ${time}`;
136
+ if (dow !== "*") return { kind: "weekly", detail: `${DAYS[Number(dow)] ?? dow} at ${time}` };
137
+ if (dom !== "*") return { kind: "monthly", detail: `day ${dom} at ${time}` };
138
+ return { kind: "daily", detail: time };
139
+ }
140
+
141
+ function formatSingleTrigger(t: { type: string; value: string }): string {
142
+ const c = classifyTrigger(t);
143
+ if (c.kind === "hourly") return "Every hour";
144
+ if (c.kind === "once") return `Once on ${c.detail}`;
145
+ return `${c.kind.charAt(0).toUpperCase() + c.kind.slice(1)}: ${c.detail}`;
118
146
  }
119
147
 
120
- const triggersText = task.triggers.map(formatTrigger).join(", ");
148
+ const triggersText = formatTriggersGrouped(task.triggers);
121
149
 
122
150
  const actionItems = (
123
151
  <>
@@ -104,6 +104,9 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
104
104
  const [triggerRows, setTriggerRows] = useState<TriggerRow[]>(
105
105
  () => (initial?.triggers ?? []).map(triggerToRow)
106
106
  );
107
+ const [schedule, setSchedule] = useState<Schedule>(
108
+ () => (initial?.triggers ?? []).map(triggerToRow)[0]?.schedule ?? "daily"
109
+ );
107
110
  const [triggersEnabled, setTriggersEnabled] = useState(
108
111
  initial?.triggers_enabled ?? false
109
112
  );
@@ -152,7 +155,12 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
152
155
  }
153
156
 
154
157
  function addRow() {
155
- setTriggerRows((prev) => [...prev, newRow(prev.length > 0 ? prev[prev.length - 1].schedule : undefined)]);
158
+ setTriggerRows((prev) => [...prev, newRow(schedule)]);
159
+ }
160
+
161
+ function changeSchedule(s: Schedule) {
162
+ setSchedule(s);
163
+ setTriggerRows([newRow(s)]);
156
164
  }
157
165
 
158
166
  function collectTriggers(): Trigger[] {
@@ -370,26 +378,47 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
370
378
  }}
371
379
  disabled={saving}
372
380
  />
373
- Add schedules
381
+ Enable schedule
374
382
  </label>
375
383
  <div className={`triggers-section-body${triggersEnabled ? "" : " disabled"}`}>
376
- {triggerRows.map((row, i) => (
384
+ {triggerRows.length > 0 && (
385
+ <select
386
+ className="form-select"
387
+ value={schedule}
388
+ disabled={!triggersEnabled}
389
+ onChange={(e) => changeSchedule(e.target.value as Schedule)}
390
+ >
391
+ <option value="once">Specific Time</option>
392
+ <option value="hourly">Hourly</option>
393
+ <option value="daily">Daily</option>
394
+ <option value="weekly">Weekly</option>
395
+ <option value="monthly">Monthly</option>
396
+ </select>
397
+ )}
398
+ {schedule !== "hourly" && triggerRows.map((row, i) => (
377
399
  <div key={i} className="trigger-row-card">
378
400
  <div className="trigger-row-content">
379
- <div className="trigger-row-top">
380
- <select
381
- className="form-select"
382
- value={row.schedule}
401
+ {schedule === "daily" && (
402
+ <input
403
+ className="form-input"
404
+ type="time"
405
+ value={row.time}
383
406
  disabled={!triggersEnabled}
384
- onChange={(e) => updateRow(i, { schedule: e.target.value as Schedule })}
385
- >
386
- <option value="once">Specific Time</option>
387
- <option value="hourly">Hourly</option>
388
- <option value="daily">Daily</option>
389
- <option value="weekly">Weekly</option>
390
- <option value="monthly">Monthly</option>
391
- </select>
392
- {row.schedule === "daily" && (
407
+ onChange={(e) => updateRow(i, { time: e.target.value })}
408
+ />
409
+ )}
410
+ {schedule === "weekly" && (
411
+ <div className="trigger-row-top">
412
+ <select
413
+ className="form-select"
414
+ value={row.dayOfWeek}
415
+ disabled={!triggersEnabled}
416
+ onChange={(e) => updateRow(i, { dayOfWeek: e.target.value })}
417
+ >
418
+ {DAYS_OF_WEEK.map((d, di) => (
419
+ <option key={di} value={String(di)}>{d}</option>
420
+ ))}
421
+ </select>
393
422
  <input
394
423
  className="form-input"
395
424
  type="time"
@@ -397,31 +426,10 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
397
426
  disabled={!triggersEnabled}
398
427
  onChange={(e) => updateRow(i, { time: e.target.value })}
399
428
  />
400
- )}
401
- {row.schedule === "weekly" && (
402
- <>
403
- <select
404
- className="form-select"
405
- value={row.dayOfWeek}
406
- disabled={!triggersEnabled}
407
- onChange={(e) => updateRow(i, { dayOfWeek: e.target.value })}
408
- >
409
- {DAYS_OF_WEEK.map((d, di) => (
410
- <option key={di} value={String(di)}>{d}</option>
411
- ))}
412
- </select>
413
- <input
414
- className="form-input"
415
- type="time"
416
- value={row.time}
417
- disabled={!triggersEnabled}
418
- onChange={(e) => updateRow(i, { time: e.target.value })}
419
- />
420
- </>
421
- )}
422
- </div>
423
- {row.schedule === "monthly" && (
424
- <div className="trigger-details">
429
+ </div>
430
+ )}
431
+ {schedule === "monthly" && (
432
+ <div className="trigger-row-top">
425
433
  <select
426
434
  className="form-select"
427
435
  value={row.dayOfMonth}
@@ -441,8 +449,8 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
441
449
  />
442
450
  </div>
443
451
  )}
444
- {row.schedule === "once" && (
445
- <div className="trigger-details">
452
+ {schedule === "once" && (
453
+ <div className="trigger-row-top">
446
454
  <input
447
455
  className="form-input"
448
456
  type="date"
@@ -464,19 +472,21 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
464
472
  </div>
465
473
  )}
466
474
  </div>
467
- <button
468
- className="trigger-remove-btn"
469
- onClick={() => removeRow(i)}
470
- disabled={!triggersEnabled}
471
- title="Remove trigger"
472
- >
473
- &times;
474
- </button>
475
+ {triggerRows.length > 1 && (
476
+ <button
477
+ className="trigger-remove-btn"
478
+ onClick={() => removeRow(i)}
479
+ disabled={!triggersEnabled}
480
+ title="Remove trigger"
481
+ >
482
+ &times;
483
+ </button>
484
+ )}
475
485
  </div>
476
486
  ))}
477
- {triggerRows.length > 0 && (
487
+ {triggerRows.length > 0 && schedule !== "hourly" && (
478
488
  <button className="trigger-add-btn" onClick={addRow} disabled={!triggersEnabled}>
479
- + Add Schedule
489
+ + Add
480
490
  </button>
481
491
  )}
482
492
  </div>