palmier 0.6.0 → 0.6.2

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 (110) 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/agents/agent-instructions.md +1 -1
  6. package/dist/agents/agent.d.ts +2 -0
  7. package/dist/agents/agent.js +21 -0
  8. package/dist/agents/aider.d.ts +9 -0
  9. package/dist/agents/aider.js +32 -0
  10. package/dist/agents/cursor.d.ts +9 -0
  11. package/dist/agents/cursor.js +35 -0
  12. package/dist/agents/deepagents.d.ts +9 -0
  13. package/dist/agents/deepagents.js +35 -0
  14. package/dist/agents/droid.d.ts +9 -0
  15. package/dist/agents/droid.js +32 -0
  16. package/dist/agents/goose.d.ts +9 -0
  17. package/dist/agents/goose.js +32 -0
  18. package/dist/agents/opencode.d.ts +9 -0
  19. package/dist/agents/opencode.js +35 -0
  20. package/dist/agents/openhands.d.ts +9 -0
  21. package/dist/agents/openhands.js +35 -0
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +1 -1
  24. package/dist/commands/run.js +2 -2
  25. package/dist/pwa/apple-touch-icon.png +0 -0
  26. package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
  27. package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
  28. package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  29. package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  30. package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  31. package/dist/pwa/favicon.ico +0 -0
  32. package/dist/pwa/index.html +17 -0
  33. package/dist/pwa/manifest.webmanifest +1 -0
  34. package/dist/pwa/pwa-192x192.png +0 -0
  35. package/dist/pwa/pwa-512x512.png +0 -0
  36. package/dist/pwa/registerSW.js +1 -0
  37. package/dist/pwa/service-worker.js +2 -0
  38. package/dist/rpc-handler.d.ts +4 -0
  39. package/dist/rpc-handler.js +5 -4
  40. package/dist/transports/http-transport.js +29 -41
  41. package/package.json +2 -2
  42. package/palmier-server/.github/workflows/ci.yml +21 -0
  43. package/palmier-server/.github/workflows/deploy.yml +38 -0
  44. package/palmier-server/CLAUDE.md +13 -0
  45. package/palmier-server/PRODUCTION.md +355 -0
  46. package/palmier-server/README.md +187 -0
  47. package/palmier-server/nats.conf +15 -0
  48. package/palmier-server/package.json +8 -0
  49. package/palmier-server/pnpm-lock.yaml +6597 -0
  50. package/palmier-server/pnpm-workspace.yaml +3 -0
  51. package/palmier-server/pwa/index.html +16 -0
  52. package/palmier-server/pwa/logo/logo-prompt.md +28 -0
  53. package/palmier-server/pwa/logo/logo_20260330.png +0 -0
  54. package/palmier-server/pwa/package.json +30 -0
  55. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  56. package/palmier-server/pwa/public/favicon.ico +0 -0
  57. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  58. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  59. package/palmier-server/pwa/src/App.css +2387 -0
  60. package/palmier-server/pwa/src/App.tsx +21 -0
  61. package/palmier-server/pwa/src/agentLabels.ts +11 -0
  62. package/palmier-server/pwa/src/api.ts +61 -0
  63. package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
  64. package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
  65. package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
  66. package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
  67. package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
  68. package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
  69. package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
  70. package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
  71. package/palmier-server/pwa/src/constants.ts +2 -0
  72. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
  73. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
  74. package/palmier-server/pwa/src/formatTime.ts +10 -0
  75. package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
  76. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
  77. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
  78. package/palmier-server/pwa/src/main.tsx +14 -0
  79. package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
  80. package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
  81. package/palmier-server/pwa/src/service-worker.ts +139 -0
  82. package/palmier-server/pwa/src/types.ts +79 -0
  83. package/palmier-server/pwa/src/vite-env.d.ts +11 -0
  84. package/palmier-server/pwa/tsconfig.json +21 -0
  85. package/palmier-server/pwa/tsconfig.node.json +19 -0
  86. package/palmier-server/pwa/vite.config.ts +47 -0
  87. package/palmier-server/server/.env.example +16 -0
  88. package/palmier-server/server/package.json +33 -0
  89. package/palmier-server/server/src/db.ts +34 -0
  90. package/palmier-server/server/src/index.ts +219 -0
  91. package/palmier-server/server/src/nats.ts +25 -0
  92. package/palmier-server/server/src/push.ts +68 -0
  93. package/palmier-server/server/src/routes/hosts.ts +45 -0
  94. package/palmier-server/server/src/routes/push.ts +100 -0
  95. package/palmier-server/server/tsconfig.json +20 -0
  96. package/palmier-server/spec.md +415 -0
  97. package/src/agents/agent-instructions.md +1 -1
  98. package/src/agents/agent.ts +23 -0
  99. package/src/agents/aider.ts +37 -0
  100. package/src/agents/cursor.ts +38 -0
  101. package/src/agents/deepagents.ts +38 -0
  102. package/src/agents/droid.ts +37 -0
  103. package/src/agents/goose.ts +35 -0
  104. package/src/agents/opencode.ts +38 -0
  105. package/src/agents/openhands.ts +38 -0
  106. package/src/commands/pair.ts +1 -1
  107. package/src/commands/run.ts +2 -2
  108. package/src/rpc-handler.ts +5 -4
  109. package/src/transports/http-transport.ts +31 -43
  110. package/test/result-state.test.ts +110 -0
@@ -0,0 +1,580 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import { useHostConnection } from "../contexts/HostConnectionContext";
3
+ import PlanDialog from "./PlanDialog";
4
+ import { useBackClose } from "../hooks/useBackClose";
5
+ import type { AgentInfo, Task, Trigger } from "../types";
6
+
7
+
8
+ const DAYS_OF_WEEK = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
9
+
10
+ type Schedule = "once" | "hourly" | "daily" | "weekly" | "monthly";
11
+
12
+ interface TriggerRow {
13
+ schedule: Schedule;
14
+ time: string;
15
+ dayOfWeek: string;
16
+ dayOfMonth: string;
17
+ onceDate: string;
18
+ onceTime: string;
19
+ }
20
+
21
+ function newRow(schedule: Schedule = "daily"): TriggerRow {
22
+ return { schedule, time: "00:00", dayOfWeek: "1", dayOfMonth: "1", onceDate: "", onceTime: "00:00" };
23
+ }
24
+
25
+ function cronToRow(cron: string): TriggerRow {
26
+ const parts = cron.split(" ");
27
+ if (parts.length !== 5) return newRow();
28
+ const [min, hour, dom, , dow] = parts;
29
+ if (hour === "*") return newRow("hourly");
30
+ const time = `${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
31
+ if (dow !== "*") return { ...newRow("weekly"), time, dayOfWeek: dow };
32
+ if (dom !== "*") return { ...newRow("monthly"), time, dayOfMonth: dom };
33
+ return { ...newRow("daily"), time };
34
+ }
35
+
36
+ function triggerToRow(t: Trigger): TriggerRow {
37
+ if (t.type === "once") {
38
+ const [datePart, timePart] = t.value.split("T");
39
+ return { ...newRow("once"), onceDate: datePart ?? "", onceTime: (timePart ?? "09:00").slice(0, 5) };
40
+ }
41
+ return cronToRow(t.value);
42
+ }
43
+
44
+ function rowToCron(row: TriggerRow): string {
45
+ const [hh, mm] = row.time.split(":").map(Number);
46
+ switch (row.schedule) {
47
+ case "hourly": return "0 * * * *";
48
+ case "daily": return `${mm} ${hh} * * *`;
49
+ case "weekly": return `${mm} ${hh} * * ${row.dayOfWeek}`;
50
+ case "monthly": return `${mm} ${hh} ${row.dayOfMonth} * *`;
51
+ default: return "0 * * * *";
52
+ }
53
+ }
54
+
55
+ function rowToTrigger(row: TriggerRow): Trigger | null {
56
+ if (row.schedule === "once") {
57
+ return row.onceDate ? { type: "once", value: `${row.onceDate}T${row.onceTime}` } : null;
58
+ }
59
+ return { type: "cron", value: rowToCron(row) };
60
+ }
61
+
62
+ interface TaskFormProps {
63
+ initial?: Task;
64
+ agents: AgentInfo[];
65
+ hostPlatform?: string;
66
+ onSaved(task: Task): void;
67
+ onRun(taskId: string, runId?: string): void;
68
+ onCancel(): void;
69
+ }
70
+
71
+ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun, onCancel }: TaskFormProps) {
72
+ const { request } = useHostConnection();
73
+
74
+ // Default agent: last used from localStorage, or first available
75
+ const defaultAgent = () => {
76
+ const lastAgent = localStorage.getItem("palmier:lastAgent");
77
+ const agentKeys = agents.map((a) => a.key);
78
+ if (lastAgent && agentKeys.includes(lastAgent)) return lastAgent;
79
+ return agents[0]?.key ?? "";
80
+ };
81
+
82
+ // Editable prompt
83
+ const [userPrompt, setUserPrompt] = useState(initial?.user_prompt ?? "");
84
+ const [agent, setAgent] = useState(initial?.agent ?? defaultAgent());
85
+
86
+ // Plan state — for existing tasks only
87
+ const [body] = useState(initial?.body ?? "");
88
+
89
+ // Track whether prompt or agent diverged from the saved values (for existing tasks)
90
+ const promptChanged = !!initial && userPrompt !== (initial.user_prompt ?? "");
91
+ const agentChanged = !!initial && agent !== (initial.agent ?? "");
92
+ const planInvalidated = promptChanged || agentChanged;
93
+
94
+ // Show plan link for existing tasks that have a plan or permissions and haven't been modified
95
+ const hasPlan = !!initial && (!!body || !!initial.permissions?.length) && !planInvalidated;
96
+
97
+ // Plan dialog (view-only for existing tasks)
98
+ const [planDialogOpen, setPlanDialogOpen] = useState(false);
99
+ const closePlanDialog = useCallback(() => setPlanDialogOpen(false), []);
100
+ useBackClose(planDialogOpen, closePlanDialog);
101
+ const [error, setError] = useState<string | null>(null);
102
+
103
+ // Form state
104
+ const [triggerRows, setTriggerRows] = useState<TriggerRow[]>(
105
+ () => (initial?.triggers ?? []).map(triggerToRow)
106
+ );
107
+ const [triggersEnabled, setTriggersEnabled] = useState(
108
+ initial?.triggers_enabled ?? false
109
+ );
110
+ const [requiresConfirmation, setRequiresConfirmation] = useState(
111
+ initial?.requires_confirmation ?? false
112
+ );
113
+ const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
114
+ const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
115
+ const [savingAction, setSavingAction] = useState<"save" | "run" | null>(null);
116
+ const saving = savingAction !== null;
117
+
118
+ // Command-triggered mode
119
+ const [commandEnabled, setCommandEnabled] = useState(!!initial?.command);
120
+ const [command, setCommand] = useState(initial?.command ?? "");
121
+ const commandInputRef = useRef<HTMLInputElement>(null);
122
+
123
+ const isEdit = !!initial;
124
+ const isDirty = !isEdit
125
+ || userPrompt !== (initial?.user_prompt ?? "")
126
+ || agent !== (initial?.agent ?? "")
127
+ || triggersEnabled !== (initial?.triggers_enabled ?? true)
128
+ || requiresConfirmation !== (initial?.requires_confirmation ?? false)
129
+ || yoloMode !== (initial?.yolo_mode ?? false)
130
+ || foregroundMode !== (initial?.foreground_mode ?? false)
131
+ || commandEnabled !== !!initial?.command
132
+ || (commandEnabled && command !== (initial?.command ?? ""))
133
+ || JSON.stringify(collectTriggers()) !== JSON.stringify(initial?.triggers ?? []);
134
+ const hasInvalidTrigger = triggersEnabled && triggerRows.some((r) =>
135
+ r.schedule === "once" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
136
+ );
137
+ const canSave = isDirty && !!userPrompt.trim() && !hasInvalidTrigger && (!commandEnabled || !!command.trim());
138
+
139
+ function updateRow(index: number, patch: Partial<TriggerRow>) {
140
+ setTriggerRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
141
+ }
142
+
143
+ function removeRow(index: number) {
144
+ setTriggerRows((prev) => {
145
+ const next = prev.filter((_, i) => i !== index);
146
+ if (next.length === 0) {
147
+ setTriggersEnabled(false);
148
+ setRequiresConfirmation(false);
149
+ }
150
+ return next;
151
+ });
152
+ }
153
+
154
+ function addRow() {
155
+ setTriggerRows((prev) => [...prev, newRow(prev.length > 0 ? prev[prev.length - 1].schedule : undefined)]);
156
+ }
157
+
158
+ function collectTriggers(): Trigger[] {
159
+ return triggerRows.flatMap((row) => {
160
+ const t = rowToTrigger(row);
161
+ return t ? [t] : [];
162
+ });
163
+ }
164
+
165
+ function confirmYolo(): boolean {
166
+ if (!yoloMode) return true;
167
+ return confirm(
168
+ "Yolo mode is enabled. The agent will auto-approve all tool calls — it can read, write, delete files, run arbitrary commands, and access the network without asking for permission.\n\nAre you sure you want to continue?"
169
+ );
170
+ }
171
+
172
+ async function handleSave() {
173
+ setSavingAction("save");
174
+ setError(null);
175
+ try {
176
+ const method = isEdit ? "task.update" : "task.create";
177
+ const payload: Record<string, unknown> = {
178
+ user_prompt: userPrompt,
179
+ agent,
180
+ triggers: collectTriggers(),
181
+ triggers_enabled: triggersEnabled,
182
+ requires_confirmation: requiresConfirmation,
183
+ yolo_mode: yoloMode,
184
+ foreground_mode: foregroundMode,
185
+ command: commandEnabled ? command : "",
186
+ };
187
+ if (isEdit) {
188
+ payload.id = initial!.id;
189
+ }
190
+ // Plan generation happens server-side, allow up to 130s
191
+ const result = await request<Task & { error?: string }>(method, payload, { timeout: 130000 });
192
+ if (result.error) {
193
+ setError(result.error);
194
+ return null;
195
+ }
196
+ if (!isEdit) {
197
+ localStorage.setItem("palmier:lastAgent", agent);
198
+ }
199
+ onSaved(result);
200
+ return result;
201
+ } catch (err) {
202
+ setError(err instanceof Error ? err.message : String(err));
203
+ return null;
204
+ } finally {
205
+ setSavingAction(null);
206
+ }
207
+ }
208
+
209
+ async function handleRunOneoff() {
210
+ setSavingAction("run");
211
+ setError(null);
212
+ try {
213
+ const payload: Record<string, unknown> = {
214
+ user_prompt: userPrompt,
215
+ agent,
216
+ requires_confirmation: requiresConfirmation,
217
+ yolo_mode: yoloMode,
218
+ foreground_mode: foregroundMode,
219
+ command: commandEnabled ? command : "",
220
+ };
221
+ const result = await request<{ ok?: boolean; task_id?: string; run_id?: string; error?: string }>("task.run_oneoff", payload);
222
+ if (result.error) {
223
+ setError(result.error);
224
+ return;
225
+ }
226
+ localStorage.setItem("palmier:lastAgent", agent);
227
+ onRun(result.task_id!, result.run_id);
228
+ onCancel();
229
+ } catch (err) {
230
+ setError(err instanceof Error ? err.message : String(err));
231
+ } finally {
232
+ setSavingAction(null);
233
+ }
234
+ }
235
+
236
+
237
+ return (
238
+ <div className="task-form-overlay">
239
+ <div className="task-form">
240
+ {planDialogOpen ? (
241
+ <PlanDialog
242
+ body={body}
243
+ permissions={initial?.permissions}
244
+ />
245
+ ) : (<>
246
+ <div className="task-form-header">
247
+ <h2>{initial ? "Edit Task" : "New Task"}</h2>
248
+ </div>
249
+
250
+ {error && <div className="form-error">{error}</div>}
251
+
252
+ <textarea
253
+ autoFocus={!initial}
254
+ className="form-textarea"
255
+ value={userPrompt}
256
+ onChange={(e) => setUserPrompt(e.target.value)}
257
+ placeholder={commandEnabled
258
+ ? "If the input email contains an event, create a calendar entry for it."
259
+ : "Research today's top AI news and write a summary."}
260
+ rows={4}
261
+ disabled={saving}
262
+ />
263
+
264
+ <div className="plan-actions">
265
+ {hasPlan && (
266
+ <button
267
+ className="btn btn-link"
268
+ onClick={() => setPlanDialogOpen(true)}
269
+ >
270
+ Execution Plan
271
+ </button>
272
+ )}
273
+ <div className="agent-picker-section-inline" style={{ marginLeft: "auto" }}>
274
+ <span className="agent-picker-label">Run with</span>
275
+ <select
276
+ className="form-select form-select-sm"
277
+ value={agent}
278
+ onChange={(e) => setAgent(e.target.value)}
279
+ >
280
+ {agents.map((a) => (
281
+ <option key={a.key} value={a.key}>{a.label}</option>
282
+ ))}
283
+ </select>
284
+ </div>
285
+ </div>
286
+
287
+ <div className="toggles-group">
288
+ <div className={`command-section${commandEnabled ? " command-section-active" : ""}`}>
289
+ <label className="toggle-label">
290
+ <input
291
+ type="checkbox"
292
+ checked={commandEnabled}
293
+ onChange={(e) => {
294
+ setCommandEnabled(e.target.checked);
295
+ if (!e.target.checked) setCommand("");
296
+ else setTimeout(() => commandInputRef.current?.focus(), 0);
297
+ }}
298
+ disabled={saving}
299
+ />
300
+ Reactive
301
+ </label>
302
+ {commandEnabled && (
303
+ <>
304
+ <p className="command-help-text">
305
+ Runs a command and invokes the task for each line of output.
306
+ Use &ldquo;the input&rdquo; in your task description to reference each line.
307
+ </p>
308
+ <input
309
+ ref={commandInputRef}
310
+ className="form-input form-input-mono"
311
+ type="text"
312
+ value={command}
313
+ onChange={(e) => setCommand(e.target.value)}
314
+ placeholder="gws gmail +watch --project my-project"
315
+ disabled={saving}
316
+ />
317
+ </>
318
+ )}
319
+ </div>
320
+
321
+ {hostPlatform === "win32" && (
322
+ <label className="toggle-label">
323
+ <input
324
+ type="checkbox"
325
+ checked={foregroundMode}
326
+ onChange={(e) => setForegroundMode(e.target.checked)}
327
+ disabled={saving}
328
+ />
329
+ Run in the foreground (host must login to Windows)
330
+ </label>
331
+ )}
332
+
333
+ <label className="toggle-label">
334
+ <input
335
+ type="checkbox"
336
+ checked={yoloMode}
337
+ onChange={(e) => setYoloMode(e.target.checked)}
338
+ disabled={saving}
339
+ />
340
+ Yolo mode
341
+ </label>
342
+ {yoloMode && (
343
+ <p className="command-help-text">
344
+ The agent will auto-approve all tool calls without asking for permission.
345
+ </p>
346
+ )}
347
+
348
+ <div className="triggers-section">
349
+ <label className="toggle-label">
350
+ <input
351
+ type="checkbox"
352
+ checked={triggersEnabled}
353
+ onChange={(e) => {
354
+ if (e.target.checked) {
355
+ setTriggersEnabled(true);
356
+ if (triggerRows.length === 0) addRow();
357
+ } else {
358
+ setTriggersEnabled(false);
359
+ setRequiresConfirmation(false);
360
+ setTriggerRows((prev) => {
361
+ const cleaned = prev.filter((r) => {
362
+ if (r.schedule === "once") return r.onceDate && new Date(`${r.onceDate}T${r.onceTime}`) > new Date();
363
+ return true;
364
+ });
365
+ // Remove single untouched default row so check/uncheck round-trips cleanly
366
+ if (cleaned.length === 1 && cleaned[0].schedule === "daily" && cleaned[0].time === "00:00") return [];
367
+ return cleaned;
368
+ });
369
+ }
370
+ }}
371
+ disabled={saving}
372
+ />
373
+ Add schedules
374
+ </label>
375
+ <div className={`triggers-section-body${triggersEnabled ? "" : " disabled"}`}>
376
+ {triggerRows.map((row, i) => (
377
+ <div key={i} className="trigger-row-card">
378
+ <div className="trigger-row-content">
379
+ <div className="trigger-row-top">
380
+ <select
381
+ className="form-select"
382
+ value={row.schedule}
383
+ 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" && (
393
+ <input
394
+ className="form-input"
395
+ type="time"
396
+ value={row.time}
397
+ disabled={!triggersEnabled}
398
+ onChange={(e) => updateRow(i, { time: e.target.value })}
399
+ />
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">
425
+ <select
426
+ className="form-select"
427
+ value={row.dayOfMonth}
428
+ disabled={!triggersEnabled}
429
+ onChange={(e) => updateRow(i, { dayOfMonth: e.target.value })}
430
+ >
431
+ {Array.from({ length: 28 }, (_, n) => n + 1).map((d) => (
432
+ <option key={d} value={String(d)}>Day {d}</option>
433
+ ))}
434
+ </select>
435
+ <input
436
+ className="form-input"
437
+ type="time"
438
+ value={row.time}
439
+ disabled={!triggersEnabled}
440
+ onChange={(e) => updateRow(i, { time: e.target.value })}
441
+ />
442
+ </div>
443
+ )}
444
+ {row.schedule === "once" && (
445
+ <div className="trigger-details">
446
+ <input
447
+ className="form-input"
448
+ type="date"
449
+ value={row.onceDate}
450
+ min={new Date().toISOString().split("T")[0]}
451
+ disabled={!triggersEnabled}
452
+ onChange={(e) => updateRow(i, { onceDate: e.target.value })}
453
+ />
454
+ <input
455
+ className="form-input"
456
+ type="time"
457
+ value={row.onceTime}
458
+ min={row.onceDate === new Date().toISOString().split("T")[0]
459
+ ? new Date().toTimeString().slice(0, 5)
460
+ : undefined}
461
+ disabled={!triggersEnabled}
462
+ onChange={(e) => updateRow(i, { onceTime: e.target.value })}
463
+ />
464
+ </div>
465
+ )}
466
+ </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
+ </div>
476
+ ))}
477
+ {triggerRows.length > 0 && (
478
+ <button className="trigger-add-btn" onClick={addRow} disabled={!triggersEnabled}>
479
+ + Add Schedule
480
+ </button>
481
+ )}
482
+ </div>
483
+ </div>
484
+ {triggersEnabled && triggerRows.length > 0 && (
485
+ <label className="toggle-label">
486
+ <input
487
+ type="checkbox"
488
+ checked={requiresConfirmation}
489
+ onChange={(e) => setRequiresConfirmation(e.target.checked)}
490
+ disabled={saving}
491
+ />
492
+ Confirm before each run
493
+ </label>
494
+ )}
495
+ </div>
496
+
497
+ {!yoloMode && (() => {
498
+ const selected = agents.find((a) => a.key === agent);
499
+ return selected?.supportsPermissions === false && (
500
+ <div className="form-warning">Palmier does not support runtime permission granting for {selected.label}. The task may fail if required permissions are not pre-configured.</div>
501
+ );
502
+ })()}
503
+
504
+ <div className="form-actions">
505
+ {(() => {
506
+ const hasSchedule = triggerRows.length > 0;
507
+ const canRun = !!userPrompt.trim() && (!commandEnabled || !!command.trim());
508
+ if (!isEdit) {
509
+ if (hasSchedule) {
510
+ // New task with schedule: "Schedule" only
511
+ return (
512
+ <button
513
+ className="btn btn-primary"
514
+ onClick={() => confirmYolo() && handleSave()}
515
+ disabled={!canSave || saving}
516
+ >
517
+ {savingAction === "save" && <span className="btn-spinner" />}
518
+ Schedule
519
+ </button>
520
+ );
521
+ }
522
+ // New task, no schedule: "Run" (primary) + "Save"
523
+ return (<>
524
+ <button
525
+ className="btn btn-primary"
526
+ onClick={() => confirmYolo() && handleRunOneoff()}
527
+ disabled={!canRun || saving}
528
+ >
529
+ {savingAction === "run" && <span className="btn-spinner" />}
530
+ Run
531
+ </button>
532
+ <button
533
+ className="btn btn-secondary"
534
+ onClick={() => confirmYolo() && handleSave()}
535
+ disabled={!canSave || saving}
536
+ >
537
+ Save
538
+ </button>
539
+ </>);
540
+ }
541
+ if (isDirty) {
542
+ // Edit, changed: Save only
543
+ return (
544
+ <button
545
+ className="btn btn-primary"
546
+ onClick={() => confirmYolo() && handleSave()}
547
+ disabled={!canSave || saving}
548
+ >
549
+ {savingAction === "save" && <span className="btn-spinner" />}
550
+ Save
551
+ </button>
552
+ );
553
+ }
554
+ // Edit, unchanged: disabled Save
555
+ return (
556
+ <button
557
+ className="btn btn-primary"
558
+ disabled
559
+ >
560
+ Save
561
+ </button>
562
+ );
563
+ })()}
564
+ <button
565
+ className="btn btn-secondary"
566
+ onClick={() => {
567
+ if (isDirty && userPrompt.trim() && !confirm("You have unsaved changes. Discard?")) return;
568
+ onCancel();
569
+ }}
570
+ style={{ marginLeft: "auto" }}
571
+ >
572
+ Cancel
573
+ </button>
574
+ </div>
575
+
576
+ </>)}
577
+ </div>
578
+ </div>
579
+ );
580
+ }