palmier 0.7.7 → 0.7.9

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 (106) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent.d.ts +3 -0
  3. package/dist/agents/agent.js +1 -1
  4. package/dist/agents/aider.d.ts +1 -0
  5. package/dist/agents/aider.js +1 -0
  6. package/dist/agents/claude.d.ts +1 -0
  7. package/dist/agents/claude.js +1 -0
  8. package/dist/agents/cline.d.ts +1 -0
  9. package/dist/agents/cline.js +1 -0
  10. package/dist/agents/codex.d.ts +1 -0
  11. package/dist/agents/codex.js +1 -0
  12. package/dist/agents/copilot.d.ts +1 -0
  13. package/dist/agents/copilot.js +1 -0
  14. package/dist/agents/cursor.d.ts +1 -0
  15. package/dist/agents/cursor.js +1 -0
  16. package/dist/agents/deepagents.d.ts +1 -0
  17. package/dist/agents/deepagents.js +1 -0
  18. package/dist/agents/droid.d.ts +1 -0
  19. package/dist/agents/droid.js +1 -0
  20. package/dist/agents/gemini.d.ts +1 -0
  21. package/dist/agents/gemini.js +1 -0
  22. package/dist/agents/goose.d.ts +1 -0
  23. package/dist/agents/goose.js +1 -0
  24. package/dist/agents/hermes.d.ts +1 -0
  25. package/dist/agents/hermes.js +1 -0
  26. package/dist/agents/kimi.d.ts +1 -0
  27. package/dist/agents/kimi.js +1 -0
  28. package/dist/agents/kiro.d.ts +1 -0
  29. package/dist/agents/kiro.js +1 -0
  30. package/dist/agents/openclaw.d.ts +1 -0
  31. package/dist/agents/openclaw.js +2 -2
  32. package/dist/agents/opencode.d.ts +1 -0
  33. package/dist/agents/opencode.js +1 -0
  34. package/dist/agents/qoder.d.ts +1 -0
  35. package/dist/agents/qoder.js +1 -0
  36. package/dist/agents/qwen.d.ts +1 -0
  37. package/dist/agents/qwen.js +1 -0
  38. package/dist/commands/pair.js +2 -2
  39. package/dist/mcp-tools.d.ts +2 -0
  40. package/dist/mcp-tools.js +20 -9
  41. package/dist/pending-requests.d.ts +30 -8
  42. package/dist/pending-requests.js +28 -15
  43. package/dist/platform/linux.js +11 -8
  44. package/dist/platform/windows.d.ts +5 -6
  45. package/dist/platform/windows.js +15 -12
  46. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  47. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  48. package/dist/pwa/assets/{web-CkWrlNwc.js → web-BpM3fNCn.js} +1 -1
  49. package/dist/pwa/assets/{web-lx34oBi7.js → web-CF-N8Di6.js} +1 -1
  50. package/dist/pwa/index.html +2 -2
  51. package/dist/pwa/service-worker.js +1 -1
  52. package/dist/rpc-handler.js +35 -24
  53. package/dist/task.js +1 -1
  54. package/dist/transports/http-transport.js +9 -8
  55. package/dist/types.d.ts +11 -6
  56. package/package.json +1 -1
  57. package/palmier-server/README.md +3 -3
  58. package/palmier-server/pwa/src/App.css +175 -28
  59. package/palmier-server/pwa/src/App.tsx +1 -0
  60. package/palmier-server/pwa/src/components/HostMenu.tsx +7 -2
  61. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  62. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  63. package/palmier-server/pwa/src/components/SessionComposer.tsx +147 -0
  64. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +79 -45
  65. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  66. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  67. package/palmier-server/pwa/src/components/TaskForm.tsx +275 -349
  68. package/palmier-server/pwa/src/components/TasksView.tsx +172 -0
  69. package/palmier-server/pwa/src/constants.ts +1 -1
  70. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  71. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  72. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  73. package/palmier-server/pwa/src/pages/Dashboard.tsx +343 -37
  74. package/palmier-server/pwa/src/types.ts +5 -14
  75. package/palmier-server/spec.md +39 -26
  76. package/src/agents/agent.ts +5 -1
  77. package/src/agents/aider.ts +1 -0
  78. package/src/agents/claude.ts +1 -0
  79. package/src/agents/cline.ts +1 -0
  80. package/src/agents/codex.ts +1 -0
  81. package/src/agents/copilot.ts +1 -0
  82. package/src/agents/cursor.ts +1 -0
  83. package/src/agents/deepagents.ts +1 -0
  84. package/src/agents/droid.ts +1 -0
  85. package/src/agents/gemini.ts +1 -0
  86. package/src/agents/goose.ts +1 -0
  87. package/src/agents/hermes.ts +1 -0
  88. package/src/agents/kimi.ts +1 -0
  89. package/src/agents/kiro.ts +1 -0
  90. package/src/agents/openclaw.ts +2 -2
  91. package/src/agents/opencode.ts +1 -0
  92. package/src/agents/qoder.ts +1 -0
  93. package/src/agents/qwen.ts +1 -0
  94. package/src/commands/pair.ts +2 -2
  95. package/src/mcp-tools.ts +22 -9
  96. package/src/pending-requests.ts +47 -15
  97. package/src/platform/linux.ts +10 -8
  98. package/src/platform/windows.ts +15 -12
  99. package/src/rpc-handler.ts +39 -26
  100. package/src/task.ts +1 -1
  101. package/src/transports/http-transport.ts +9 -8
  102. package/src/types.ts +10 -8
  103. package/test/pairing.test.ts +2 -2
  104. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  105. package/dist/pwa/assets/index-Bt8Hhaw3.js +0 -118
  106. package/palmier-server/pwa/src/components/TaskListView.tsx +0 -431
@@ -1,15 +1,47 @@
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";
5
6
  import { useHostConnection } from "../contexts/HostConnectionContext";
6
- import TaskListView from "../components/TaskListView";
7
+ import TasksView from "../components/TasksView";
7
8
  import TabBar from "../components/TabBar";
8
9
  import HostMenu from "../components/HostMenu";
9
- import RunsView from "../components/RunsView";
10
+ import SessionsView from "../components/SessionsView";
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,45 +51,217 @@ 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
- // 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");
57
+ // "latest" is passed through to RunDetailView, which does its own resolution
58
+ // and renders an empty state if the task has no runs.
59
+ const isRunDetail = !!(params.taskId && params.runId);
45
60
 
46
61
  const [updateRequired, setUpdateRequired] = useState(false);
47
62
  const [updating, setUpdating] = useState(false);
48
63
  const [updateError, setUpdateError] = useState<string | null>(null);
49
64
  const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
50
65
  const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
66
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
67
+ const [hostPlatform, setHostPlatform] = useState<string | undefined>();
68
+
69
+ // Pending prompt state — owned by Dashboard because these modals must show
70
+ // regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
71
+ const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
72
+ const [pendingPermissions, setPendingPermissions] = useState<Map<string, PermissionPrompt>>(new Map());
73
+ const [pendingInputs, setPendingInputs] = useState<Map<string, InputPrompt>>(new Map());
74
+ const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
51
75
 
52
- // Register push subscription for the active host
53
76
  usePushSubscription();
54
77
 
55
- // Reset scroll when switching hosts
56
78
  useEffect(() => {
57
79
  window.scrollTo(0, 0);
58
80
  }, [activeHostId]);
59
81
 
82
+ // host.info bootstrap: agents/version/platform + any prompts that were already
83
+ // pending when this PWA connected. Runs once per (host, connection).
84
+ useEffect(() => {
85
+ if (!connected) return;
86
+ request<{
87
+ agents?: AgentInfo[];
88
+ version?: string | null;
89
+ host_platform?: string;
90
+ capability_tokens?: Record<string, string | null>;
91
+ pending_prompts?: PendingPrompt[];
92
+ }>("host.info")
93
+ .then((result) => {
94
+ setAgents(result.agents ?? []);
95
+ setHostPlatform(result.host_platform);
96
+ setCapabilityTokens(result.capability_tokens ?? {});
97
+ setAgentLabels(result.agents ?? []);
98
+ const version = result.version ?? null;
99
+ setDaemonVersion(version);
100
+ setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
101
+
102
+ // Seed modal state from already-pending prompts.
103
+ const confirms = new Map<string, ConfirmPrompt>();
104
+ const perms = new Map<string, PermissionPrompt>();
105
+ const inputs = new Map<string, InputPrompt>();
106
+ const inputVals = new Map<string, string[]>();
107
+ for (const p of result.pending_prompts ?? []) {
108
+ if (p.type === "confirmation") {
109
+ confirms.set(p.key, {
110
+ description: p.meta?.description ?? "",
111
+ sessionName: p.meta?.session_name,
112
+ });
113
+ } else if (p.type === "permission") {
114
+ perms.set(p.key, {
115
+ permissions: (p.params as RequiredPermission[]) ?? [],
116
+ sessionName: p.meta?.session_name,
117
+ });
118
+ } else if (p.type === "input") {
119
+ const questions = (p.params as string[]) ?? p.meta?.input_questions ?? [];
120
+ inputs.set(p.key, {
121
+ questions,
122
+ description: p.meta?.description,
123
+ sessionName: p.meta?.session_name,
124
+ });
125
+ inputVals.set(p.key, new Array(questions.length).fill(""));
126
+ }
127
+ }
128
+ setPendingConfirms(confirms);
129
+ setPendingPermissions(perms);
130
+ setPendingInputs(inputs);
131
+ setInputValues(inputVals);
132
+ })
133
+ .catch(() => { /* silent — update-required prompt guards the broken case */ });
134
+ }, [connected, activeHostId, request]);
135
+
136
+ // Always-on event subscription for modal lifecycle. Independent of which tab
137
+ // is active. Task-card status updates happen inside TasksView while mounted.
138
+ useEffect(() => {
139
+ if (!connected || !activeHostId) return;
140
+ const unsubscribe = subscribeEvents(activeHostId, (msg) => {
141
+ const tokens = msg.subject.split(".");
142
+ if (tokens.length < 3) return;
143
+ const taskId = tokens.slice(2).join(".");
144
+
145
+ let parsed: Record<string, unknown> = {};
146
+ try {
147
+ parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
148
+ } catch { return; }
149
+ const eventType = parsed.event_type as string | undefined;
150
+ const sessionId = parsed.session_id as string | undefined;
151
+
152
+ if (eventType === "input-request" && sessionId) {
153
+ const questions = parsed.input_questions as string[] | undefined;
154
+ const sessionName = parsed.session_name as string | undefined;
155
+ const description = parsed.description as string | undefined;
156
+ if (questions?.length) {
157
+ setPendingInputs((prev) => {
158
+ if (prev.has(sessionId)) return prev;
159
+ const next = new Map(prev);
160
+ next.set(sessionId, { questions, description, sessionName });
161
+ return next;
162
+ });
163
+ setInputValues((prev) => {
164
+ if (prev.has(sessionId)) return prev;
165
+ const next = new Map(prev);
166
+ next.set(sessionId, new Array(questions.length).fill(""));
167
+ return next;
168
+ });
169
+ }
170
+ return;
171
+ }
172
+
173
+ if (eventType === "input-resolved" && sessionId) {
174
+ setPendingInputs((prev) => {
175
+ if (!prev.has(sessionId)) return prev;
176
+ const next = new Map(prev);
177
+ next.delete(sessionId);
178
+ return next;
179
+ });
180
+ setInputValues((prev) => {
181
+ const next = new Map(prev);
182
+ next.delete(sessionId);
183
+ return next;
184
+ });
185
+ return;
186
+ }
187
+
188
+ if (eventType === "confirm-request" && sessionId) {
189
+ const description = parsed.description as string | undefined;
190
+ const sessionName = parsed.session_name as string | undefined;
191
+ if (description) {
192
+ setPendingConfirms((prev) => {
193
+ if (prev.has(sessionId)) return prev;
194
+ const next = new Map(prev);
195
+ next.set(sessionId, { description, sessionName });
196
+ return next;
197
+ });
198
+ }
199
+ return;
200
+ }
201
+
202
+ if (eventType === "confirm-resolved" && sessionId) {
203
+ setPendingConfirms((prev) => {
204
+ if (!prev.has(sessionId)) return prev;
205
+ const next = new Map(prev);
206
+ next.delete(sessionId);
207
+ return next;
208
+ });
209
+ return;
210
+ }
211
+
212
+ if (eventType === "permission-request") {
213
+ const permissions = parsed.required_permissions as RequiredPermission[] | undefined;
214
+ const sessionName = parsed.session_name as string | undefined;
215
+ if (permissions?.length) {
216
+ setPendingPermissions((prev) => {
217
+ if (prev.has(taskId)) return prev;
218
+ const next = new Map(prev);
219
+ next.set(taskId, { permissions, sessionName });
220
+ return next;
221
+ });
222
+ }
223
+ return;
224
+ }
225
+
226
+ if (eventType === "permission-resolved") {
227
+ setPendingPermissions((prev) => {
228
+ if (!prev.has(taskId)) return prev;
229
+ const next = new Map(prev);
230
+ next.delete(taskId);
231
+ return next;
232
+ });
233
+ return;
234
+ }
235
+ });
236
+ return unsubscribe;
237
+ }, [connected, activeHostId, subscribeEvents]);
238
+
239
+ async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
240
+ try {
241
+ await request("task.user_input", { id: sessionId, value: [response] });
242
+ } catch (err) {
243
+ console.error("[Dashboard] Failed to respond to confirmation:", err);
244
+ }
245
+ }
246
+
247
+ async function respondToPermission(taskId: string, response: "granted" | "granted_all" | "aborted") {
248
+ try {
249
+ await request("task.user_input", { id: taskId, value: [response] });
250
+ } catch (err) {
251
+ console.error("[Dashboard] Failed to respond to permission request:", err);
252
+ }
253
+ }
254
+
255
+ async function respondToInput(sessionId: string, values: string[]) {
256
+ try {
257
+ await request("task.user_input", { id: sessionId, value: values });
258
+ } catch (err) {
259
+ console.error("[Dashboard] Failed to respond to input request:", err);
260
+ }
261
+ }
262
+
60
263
  function handleViewRun(taskId: string, runId?: string) {
264
+ if (!confirmLeaveDraft()) return;
61
265
  if (runId) {
62
266
  navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
63
267
  } else {
@@ -78,7 +282,6 @@ export default function Dashboard() {
78
282
  } catch {
79
283
  // Expected: connection drops during daemon restart
80
284
  }
81
- // Daemon will restart; reload after it has time to come back up.
82
285
  setTimeout(() => window.location.reload(), 10000);
83
286
  }
84
287
 
@@ -116,7 +319,7 @@ export default function Dashboard() {
116
319
  <div className="revoked-actions">
117
320
  <button
118
321
  className="btn btn-primary"
119
- onClick={() => navigate("/pair")}
322
+ onClick={() => { if (confirmLeaveDraft()) navigate("/pair"); }}
120
323
  >
121
324
  Re-pair Device
122
325
  </button>
@@ -132,18 +335,17 @@ export default function Dashboard() {
132
335
  </div>
133
336
  ) : showTaskContent ? (
134
337
  <>
135
- <div style={{ display: !isRunsTab ? "contents" : "none" }}>
136
- <TaskListView
338
+ {isTasksTab && !isRunDetail && (
339
+ <TasksView
137
340
  connected={connected}
138
341
  hostId={activeHostId}
139
342
  request={request}
140
343
  subscribeEvents={subscribeEvents}
344
+ agents={agents}
345
+ hostPlatform={hostPlatform}
141
346
  onViewRun={handleViewRun}
142
- onUpdateRequired={setUpdateRequired}
143
- onVersion={setDaemonVersion}
144
- onCapabilityTokens={setCapabilityTokens}
145
347
  />
146
- </div>
348
+ )}
147
349
  {isRunDetail ? (
148
350
  <RunDetailView
149
351
  connected={connected}
@@ -151,16 +353,17 @@ export default function Dashboard() {
151
353
  request={request}
152
354
  subscribeEvents={subscribeEvents}
153
355
  taskId={params.taskId!}
154
- runId={decodeURIComponent(effectiveRunId!)}
356
+ runId={decodeURIComponent(params.runId!)}
155
357
  />
156
- ) : isRunsTab ? (
157
- <RunsView
358
+ ) : !isTasksTab ? (
359
+ <SessionsView
158
360
  connected={connected}
159
361
  hostId={activeHostId}
160
362
  request={request}
161
363
  subscribeEvents={subscribeEvents}
364
+ agents={agents}
162
365
  filterTaskId={runsFilterTaskId}
163
- onClearFilter={() => navigate("/runs")}
366
+ onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
164
367
  />
165
368
  ) : null}
166
369
  </>
@@ -221,6 +424,109 @@ export default function Dashboard() {
221
424
  )}
222
425
 
223
426
  </div>
427
+
428
+ {createPortal(<>
429
+ {[...pendingConfirms.entries()].map(([sessionId, { description, sessionName }]) => (
430
+ <div key={sessionId} className="confirm-modal-overlay">
431
+ <div className="confirm-modal">
432
+ <h2 className="confirm-modal-title">Confirmation Required</h2>
433
+ {sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
434
+ <p className="confirm-modal-message">{description}</p>
435
+ <div className="confirm-modal-actions">
436
+ <button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
437
+ Confirm
438
+ </button>
439
+ <button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
440
+ Abort
441
+ </button>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ ))}
446
+
447
+ {[...pendingPermissions.entries()].map(([taskId, { permissions, sessionName }]) => (
448
+ <div key={taskId} className="confirm-modal-overlay">
449
+ <div className="confirm-modal permission-modal">
450
+ <h2 className="confirm-modal-title">Permission Required</h2>
451
+ <p className="confirm-modal-message">
452
+ <strong>{sessionName || taskId}</strong>
453
+ </p>
454
+ <div className="permission-list">
455
+ {permissions.map((p, i) => (
456
+ <div key={i} className="permission-item">
457
+ <span className="permission-name">{p.name}</span>
458
+ {p.description && <span className="permission-desc">{p.description}</span>}
459
+ </div>
460
+ ))}
461
+ </div>
462
+ <div className="permission-actions">
463
+ <button className="btn btn-primary" onClick={() => respondToPermission(taskId, "granted")}>
464
+ Allow Once
465
+ </button>
466
+ <button className="btn btn-secondary" onClick={() => respondToPermission(taskId, "granted_all")}>
467
+ Allow Always
468
+ </button>
469
+ </div>
470
+ <button
471
+ className="permission-abort-link"
472
+ onClick={() => respondToPermission(taskId, "aborted")}
473
+ >
474
+ Deny & Abort Task
475
+ </button>
476
+ </div>
477
+ </div>
478
+ ))}
479
+
480
+ {[...pendingInputs.entries()].map(([sessionId, { questions, description, sessionName }]) => {
481
+ const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
482
+ return (
483
+ <div key={sessionId} className="confirm-modal-overlay">
484
+ <div className="confirm-modal input-modal">
485
+ <h2 className="confirm-modal-title">Input Required</h2>
486
+ {sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
487
+ {description && <p className="confirm-modal-message">{description}</p>}
488
+ <div className="input-list">
489
+ {questions.map((desc: string, i: number) => (
490
+ <div key={i} className="input-item">
491
+ <label className="input-label">{desc}</label>
492
+ <input
493
+ type="text"
494
+ className="input-field"
495
+ value={values[i] ?? ""}
496
+ onChange={(e) => {
497
+ setInputValues((prev) => {
498
+ const next = new Map(prev);
499
+ const arr = [...(next.get(sessionId) ?? [])];
500
+ arr[i] = e.target.value;
501
+ next.set(sessionId, arr);
502
+ return next;
503
+ });
504
+ }}
505
+ autoFocus={i === 0}
506
+ />
507
+ </div>
508
+ ))}
509
+ </div>
510
+ <div className="input-actions">
511
+ <button
512
+ className="btn btn-primary"
513
+ disabled={values.some((v) => !v.trim())}
514
+ onClick={() => respondToInput(sessionId, values)}
515
+ >
516
+ Submit
517
+ </button>
518
+ </div>
519
+ <button
520
+ className="permission-abort-link"
521
+ onClick={() => respondToInput(sessionId, ["aborted"])}
522
+ >
523
+ Cancel
524
+ </button>
525
+ </div>
526
+ </div>
527
+ );
528
+ })}
529
+ </>, document.body)}
224
530
  </div>
225
531
  );
226
532
  }
@@ -1,7 +1,8 @@
1
1
  export interface AgentInfo {
2
2
  key: string;
3
3
  label: string;
4
- supportsPermissions?: boolean;
4
+ supportsPermissions: boolean;
5
+ supportsYolo: boolean;
5
6
  }
6
7
 
7
8
 
@@ -10,8 +11,9 @@ export interface Task {
10
11
  name: string;
11
12
  user_prompt: string;
12
13
  agent?: string;
13
- triggers: Trigger[];
14
- triggers_enabled: boolean;
14
+ schedule_type?: "crons" | "specific_times";
15
+ schedule_values?: string[];
16
+ schedule_enabled: boolean;
15
17
  requires_confirmation: boolean;
16
18
  yolo_mode?: boolean;
17
19
  foreground_mode?: boolean;
@@ -19,11 +21,6 @@ export interface Task {
19
21
  command?: string;
20
22
  }
21
23
 
22
- export interface Trigger {
23
- type: "cron" | "once";
24
- value: string;
25
- }
26
-
27
24
  export interface RequiredPermission {
28
25
  name: string;
29
26
  description: string;
@@ -41,12 +38,6 @@ export interface TaskStatus {
41
38
  running_state: TaskRunningState;
42
39
  /** UTC time in milliseconds since epoch */
43
40
  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
41
  }
51
42
 
52
43
  export interface HistoryEntry {
@@ -116,10 +116,11 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
116
116
 
117
117
  | Method | Params | Description |
118
118
  |---|---|---|
119
- | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. Returns `agents` array of detected CLIs, `host_platform`, and `version`. |
119
+ | `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform, capability_tokens, pending_prompts }`. `pending_prompts` is an array of prompts already waiting when the PWA reconnects (each `{ key, type, params?, meta? }`), so modals can render without replaying events. |
120
+ | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
120
121
  | `task.get` | `id` | Get a single task with frontmatter and current status. |
121
- | `task.create` | `user_prompt`, `agent`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if triggers present. |
122
- | `task.update` | `id`, `user_prompt?`, `agent?`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. |
122
+ | `task.create` | `user_prompt`, `agent`, `schedule_type?`, `schedule_values?`, `schedule_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if a schedule is present. |
123
+ | `task.update` | `id`, `user_prompt?`, `agent?`, `schedule_type?`, `schedule_values?`, `schedule_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. Pass `null` for `schedule_type` or `schedule_values` to clear them. |
123
124
  | `task.delete` | `id` | Delete a task and its systemd timers |
124
125
  | `task.run` | `id` | Start a task via system scheduler (`systemctl --user start` / `schtasks /run`) |
125
126
  | `task.abort` | `id` | Stop a running task via system scheduler (`systemctl --user stop` / `schtasks /end`) |
@@ -214,27 +215,27 @@ Messages are appended incrementally during execution.
214
215
  id: "uuid-v4"
215
216
  user_prompt: "Run a system audit and summarize large files..."
216
217
  agent: "claude"
217
- triggers:
218
- - type: "cron"
219
- value: "0 9 * * 1"
220
- - type: "once"
221
- value: "2026-03-20T15:00:00Z"
222
- triggers_enabled: true
218
+ schedule_type: "crons"
219
+ schedule_values:
220
+ - "0 9 * * 1"
221
+ schedule_enabled: true
223
222
  requires_confirmation: true
224
223
  ---
225
224
  ```
226
225
 
226
+ `schedule_type` is either `"crons"` (cron expressions) or `"specific_times"` (local datetime strings like `"2026-04-20T09:00"`). `schedule_values` is a homogeneous array whose elements match the type. Both fields are present together or absent together; absence means the task has no schedule and can only be run manually.
227
+
227
228
  The `name` field is auto-generated by spawning the configured agent CLI with a short prompt (for prompts > 50 chars). For shorter prompts, the `user_prompt` is used directly as the name.
228
229
 
229
230
  The `agent` field stores the agent name (e.g., `"claude"`, `"codex"`). The corresponding `AgentTool` implementation is responsible for constructing the full command and arguments at execution time.
230
231
 
231
232
  The optional `command` field stores a shell command for command-triggered tasks. When set, the task runs in command-triggered mode: the command is spawned with `shell: true`, and each line of its stdout triggers a separate agent invocation with `user_prompt + "\n\nProcess this input:\n" + line`.
232
233
 
233
- #### Trigger Lifecycle
234
+ #### Schedule Lifecycle
234
235
 
235
- * **`triggers_enabled`:** Controls whether systemd timers are installed for the task's triggers. When `false`, all timers are removed; when toggled back to `true`, timers are reinstalled. Defaults to `true`. The task can still be run manually via "Run Now" regardless of this setting. The "Enable Triggers" checkbox only appears in the UI when the task has at least one trigger.
236
- * **`cron` triggers:** Persist indefinitely. The systemd timer remains active until the task is deleted or triggers are disabled.
237
- * **`once` triggers:** After firing, the trigger is removed from the `TASK.md` frontmatter and its corresponding systemd timer/service files are cleaned up. The task itself remains in the `tasks/` directory as a manual task (can still be executed on-demand via the PWA or CLI, but will not fire automatically again).
236
+ * **`schedule_enabled`:** Controls whether systemd timers are installed for the task's schedule. When `false`, all timers are removed; when toggled back to `true`, timers are reinstalled. Defaults to `true`. The task can still be run manually via "Run Now" regardless of this setting. The "Enable Schedule" checkbox only appears in the UI when the task has a schedule.
237
+ * **`crons` schedules:** Persist indefinitely. The systemd timer remains active until the task is deleted or the schedule is disabled.
238
+ * **`specific_times` schedules:** After a value fires, it is removed from `schedule_values` and its corresponding systemd timer/service files are cleaned up. Once all values have fired the schedule is cleared, and the task remains in the `tasks/` directory as a manual task (can still be executed on-demand via the PWA or CLI, but will not fire automatically again).
238
239
 
239
240
  ### Task Events
240
241
 
@@ -249,7 +250,7 @@ Task lifecycle status is persisted to a `status.json` file in the task directory
249
250
 
250
251
  The `task.list` RPC includes each task's current status (read from `status.json`). The `task.status` RPC returns the status for a single task.
251
252
 
252
- The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<activeHostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when triggers are disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The "once" trigger date/time picker only allows selecting future dates and times.
253
+ The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<activeHostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when the schedule is disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The `specific_times` date/time picker only allows selecting future dates and times.
253
254
 
254
255
  The Web Server subscribes to `host-event.>` and sends push notifications based on `event_type`: confirmation pushes for `confirm-request`, dismiss pushes for `confirm-resolved`, permission pushes for `permission-request`, dismiss pushes for `permission-resolved`, and report-ready/failure pushes for `report-generated` events.
255
256
 
@@ -275,40 +276,52 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
275
276
 
276
277
  2. If hosts are paired, PWA fetches host-scoped NATS credentials from `GET /api/nats-credentials/<hostId>` (returns `{ natsWsUrl, natsJwt, natsNkeySeed }`) and connects to NATS via WebSocket using JWT auth. The credentials are scoped to the paired host's subjects only.
277
278
 
278
- 3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload.
279
+ 3. PWA sends a `host.info` request using NATS request-reply, including the `clientToken` in the payload. The response carries bootstrap metadata (`agents`, `host_platform`, `version`, `capability_tokens`) and `pending_prompts` — any prompts already open on the host when the PWA connected. The Dashboard consumes this once per connection; both tabs read from it.
279
280
 
280
- 4. If the host responds, it returns `{ tasks: [...] }` an array of **flat task objects** (frontmatter fields spread to the top level) and displays the task list. If the request fails with NATS 503 ("no responders"), the PWA shows an empty task list — this is not treated as an error.
281
+ 4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. `task.list` is not called at startup it fires lazily the first time the user opens the Tasks tab. If either RPC fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
281
282
 
282
283
  5. PWA registers the service worker and subscribes the browser for Web Push notifications (via `pushManager.subscribe` with the server's VAPID public key). The push subscription is sent to `POST /api/push/subscribe` with the `hostId` so the server can relay notifications to the device.
283
284
 
284
- 6. PWA discovers pending confirmations from the `task.list` RPC response tasks with a pending confirmation, permission, or input request are shown as interactive modals. The PWA responds by calling the `task.user_input` RPC on the host, which resolves the in-memory pending request held by the serve daemon. The `run` process (blocked on an HTTP call to the serve daemon) receives the response and proceeds or exits accordingly.
285
+ 6. Initial pending-prompt discovery uses `host.info.pending_prompts`: each entry is `{ key, type, params?, meta? }` where `meta` carries the display context (`session_id`, `session_name`, `description`, `input_questions`) needed to render the modal cold. The PWA seeds its modal state maps from this array. After connection, live arrivals come through the NATS event subscription. The PWA responds by calling the `task.user_input` RPC on the host, which resolves the in-memory pending request held by the serve daemon. The `run` process (blocked on an HTTP call to the serve daemon) receives the response and proceeds or exits accordingly.
286
+
287
+ ### 4.2 UI Layout: Sessions & Tasks Tabs
288
+
289
+ The PWA has two tabs: **Sessions** (default, at `/`) and **Tasks** (secondary, at `/tasks`). Sessions is the primary workflow — it lists all run history across tasks (a "session" is a single run) and includes a session composer at the top of the list.
290
+
291
+ * **Session composer:** An inline textarea with an agent picker, a yolo-mode toggle, and a round play button. Entering text and clicking play dispatches `task.run_oneoff`, starting an immediate unsaved session. The composer never opens a dialog; typing is direct. When the textarea has content, navigating away (tab switch, host switch, browser reload, clicking a session row) triggers a confirmation dialog so the draft isn't lost silently.
292
+ * **Tasks tab:** Lists saved tasks (scheduled or reusable). A floating round `+` button in the bottom-right of the screen opens the task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when a schedule is configured).
293
+
294
+ Bootstrap data (agents, host version, host platform, capability tokens) is fetched once per connection at the Dashboard level via `host.info` — independent of which tab is active. `task.list` is called lazily on the Tasks tab mount; `taskrun.list` is called lazily on the Sessions tab mount. Neither list RPC carries bootstrap metadata.
295
+
296
+ Dashboard owns the always-on NATS event subscription and renders pending `confirm-request` / `permission-request` / `input-request` modals via React portal, so prompts surface regardless of which tab is active. Initial pending prompts (those already open when the PWA connects) are seeded from `host.info`'s `pending_prompts` field — each entry carries the display context (`session_name`, `description`, `input_questions`) needed to render the modal cold, since the task list is no longer available at bootstrap. `session_name` is a unified label: agent name for confirm/input, task name for permission.
285
297
 
286
- ### 4.2 Task Creation & Update
298
+ ### 4.3 Task Creation & Update
287
299
 
288
- 1. User clicks the "Describe your new task..." placeholder in the task list view, which opens the task form directly.
300
+ 1. User taps the floating `+` button on the Tasks tab, which opens the task form.
289
301
 
290
- 2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Create" (or "Update" for existing tasks).
302
+ 2. User enters a prompt, selects an agent, configures the schedule (UI translates human-readable times to cron expressions or ISO datetime strings) and confirmation settings, and clicks "Save" (no schedule) or "Schedule" (with a schedule).
291
303
 
292
304
  3. PWA sends `task.create` (or `task.update`) via NATS request-reply to Host (45s timeout). For prompts > 50 chars, the host generates a concise task name by running the configured agent CLI in non-interactive mode (e.g., `claude -p "Generate a concise 3-6 word name for this task..."`). For shorter prompts, the prompt is used directly as the name.
293
305
 
294
306
  4. For updates: if the user changes the `user_prompt` or `agent`, the name is regenerated. If neither changed, the existing name is preserved. Existing tasks with granted permissions show a clickable "Granted Permissions" link to view them.
295
307
 
296
- 5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. The `triggers` field defaults to `[]` if omitted or undefined.
308
+ 5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. `schedule_type` and `schedule_values` are omitted when the task has no schedule.
297
309
 
298
310
  6. Host creates/updates the `tasks/<task-id>/TASK.md` file and returns the **full flat task object** (all frontmatter fields at the top level). The PWA uses this response directly to update the UI.
299
311
 
300
- 7. **OS Integration:** Host translates triggers into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
312
+ 7. **OS Integration:** Host translates the schedule into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
301
313
 
302
- ### 4.3 On-Demand Execution
314
+ ### 4.4 On-Demand Execution
303
315
 
304
316
  Any task that is not currently running can be executed immediately:
305
317
 
306
- * **PWA:** A "Run Now" button is shown on each task card when the task is not already running. Clicking it sends a `task.run` request via NATS request-reply to the Host, which starts execution via the system scheduler (`systemctl --user start` on Linux, `schtasks /run` on Windows).
318
+ * **PWA (saved task):** A "Run Now" button is shown on each task card when the task is not already running. Clicking it sends a `task.run` request via NATS request-reply to the Host, which starts execution via the system scheduler (`systemctl --user start` on Linux, `schtasks /run` on Windows).
319
+ * **PWA (one-off session):** The session composer on the Sessions tab sends `task.run_oneoff` with `{ user_prompt, agent, yolo_mode }`. The host creates an ephemeral task, runs it, and returns `{ task_id, run_id }`; the PWA navigates to the run detail view.
307
320
  * **CLI:** `palmier run <task-id>` executes the task directly (outside the system scheduler).
308
321
 
309
322
  Both paths follow the same execution loop described in §5.2 (including confirmation checks if configured). The system scheduler prevents concurrent runs of the same task — if the service/task is already active, the start command is a no-op.
310
323
 
311
- ### 4.4 Task Deletion
324
+ ### 4.5 Task Deletion
312
325
 
313
326
  1. PWA sends `task.delete` via NATS request-reply.
314
327
 
@@ -366,7 +379,7 @@ When a task has a `command` field set, `palmier run` enters command-triggered mo
366
379
 
367
380
  5. **On command exit or signal**: each agent invocation is written as a conversation entry in the RESULT file. Per-line agent outputs are also logged to `command-output.log` in the task directory.
368
381
 
369
- 6. **Composable with triggers**: cron/once triggers start `palmier run` on schedule, which spawns the command. The command runs until it exits or the task is aborted.
382
+ 6. **Composable with schedules**: the configured schedule starts `palmier run` at the scheduled time, which spawns the command. The command runs until it exits or the task is aborted.
370
383
 
371
384
  ### 5.4 Failsafes & Constraints
372
385