palmier 0.7.7 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/agents/agent.d.ts +3 -0
  2. package/dist/agents/agent.js +1 -1
  3. package/dist/agents/aider.d.ts +1 -0
  4. package/dist/agents/aider.js +1 -0
  5. package/dist/agents/claude.d.ts +1 -0
  6. package/dist/agents/claude.js +1 -0
  7. package/dist/agents/cline.d.ts +1 -0
  8. package/dist/agents/cline.js +1 -0
  9. package/dist/agents/codex.d.ts +1 -0
  10. package/dist/agents/codex.js +1 -0
  11. package/dist/agents/copilot.d.ts +1 -0
  12. package/dist/agents/copilot.js +1 -0
  13. package/dist/agents/cursor.d.ts +1 -0
  14. package/dist/agents/cursor.js +1 -0
  15. package/dist/agents/deepagents.d.ts +1 -0
  16. package/dist/agents/deepagents.js +1 -0
  17. package/dist/agents/droid.d.ts +1 -0
  18. package/dist/agents/droid.js +1 -0
  19. package/dist/agents/gemini.d.ts +1 -0
  20. package/dist/agents/gemini.js +1 -0
  21. package/dist/agents/goose.d.ts +1 -0
  22. package/dist/agents/goose.js +1 -0
  23. package/dist/agents/hermes.d.ts +1 -0
  24. package/dist/agents/hermes.js +1 -0
  25. package/dist/agents/kimi.d.ts +1 -0
  26. package/dist/agents/kimi.js +1 -0
  27. package/dist/agents/kiro.d.ts +1 -0
  28. package/dist/agents/kiro.js +1 -0
  29. package/dist/agents/openclaw.d.ts +1 -0
  30. package/dist/agents/openclaw.js +2 -2
  31. package/dist/agents/opencode.d.ts +1 -0
  32. package/dist/agents/opencode.js +1 -0
  33. package/dist/agents/qoder.d.ts +1 -0
  34. package/dist/agents/qoder.js +1 -0
  35. package/dist/agents/qwen.d.ts +1 -0
  36. package/dist/agents/qwen.js +1 -0
  37. package/dist/commands/pair.js +2 -2
  38. package/dist/mcp-tools.js +16 -7
  39. package/dist/pending-requests.d.ts +30 -8
  40. package/dist/pending-requests.js +28 -15
  41. package/dist/pwa/assets/index-8cTctVnD.js +120 -0
  42. package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
  43. package/dist/pwa/assets/{web-CkWrlNwc.js → web-BNr628AV.js} +1 -1
  44. package/dist/pwa/assets/{web-lx34oBi7.js → web-DyQPewAi.js} +1 -1
  45. package/dist/pwa/index.html +2 -2
  46. package/dist/pwa/service-worker.js +1 -1
  47. package/dist/rpc-handler.js +12 -16
  48. package/dist/transports/http-transport.js +6 -3
  49. package/dist/types.d.ts +2 -0
  50. package/package.json +1 -1
  51. package/palmier-server/pwa/src/App.css +66 -0
  52. package/palmier-server/pwa/src/App.tsx +1 -0
  53. package/palmier-server/pwa/src/components/HostMenu.tsx +7 -2
  54. package/palmier-server/pwa/src/components/RunsView.tsx +48 -22
  55. package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
  56. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  57. package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
  58. package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
  59. package/palmier-server/pwa/src/constants.ts +1 -1
  60. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  61. package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
  62. package/palmier-server/pwa/src/types.ts +1 -6
  63. package/palmier-server/spec.md +22 -9
  64. package/src/agents/agent.ts +5 -1
  65. package/src/agents/aider.ts +1 -0
  66. package/src/agents/claude.ts +1 -0
  67. package/src/agents/cline.ts +1 -0
  68. package/src/agents/codex.ts +1 -0
  69. package/src/agents/copilot.ts +1 -0
  70. package/src/agents/cursor.ts +1 -0
  71. package/src/agents/deepagents.ts +1 -0
  72. package/src/agents/droid.ts +1 -0
  73. package/src/agents/gemini.ts +1 -0
  74. package/src/agents/goose.ts +1 -0
  75. package/src/agents/hermes.ts +1 -0
  76. package/src/agents/kimi.ts +1 -0
  77. package/src/agents/kiro.ts +1 -0
  78. package/src/agents/openclaw.ts +2 -2
  79. package/src/agents/opencode.ts +1 -0
  80. package/src/agents/qoder.ts +1 -0
  81. package/src/agents/qwen.ts +1 -0
  82. package/src/commands/pair.ts +2 -2
  83. package/src/mcp-tools.ts +16 -7
  84. package/src/pending-requests.ts +47 -15
  85. package/src/rpc-handler.ts +13 -16
  86. package/src/transports/http-transport.ts +6 -3
  87. package/src/types.ts +1 -1
  88. package/test/pairing.test.ts +2 -2
  89. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  90. package/dist/pwa/assets/index-Bt8Hhaw3.js +0 -118
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { useNavigate, useLocation, useParams } from "react-router-dom";
3
4
 
4
5
  import { useHostStore } from "../contexts/HostStoreContext";
@@ -10,6 +11,37 @@ import RunsView from "../components/RunsView";
10
11
  import RunDetailView from "../components/RunDetailView";
11
12
  import { usePushSubscription } from "../hooks/usePushSubscription";
12
13
  import { useMediaQuery } from "../hooks/useMediaQuery";
14
+ import { setAgentLabels } from "../agentLabels";
15
+ import { confirmLeaveDraft } from "../draftGuard";
16
+ import { MIN_HOST_VERSION } from "../constants";
17
+ import type { AgentInfo, RequiredPermission } from "../types";
18
+
19
+ function isOlderThan(current: string, minimum: string): boolean {
20
+ if (current.includes("-")) return false;
21
+ const a = current.split(".").map(Number);
22
+ const b = minimum.split(".").map(Number);
23
+ for (let i = 0; i < 3; i++) {
24
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return true;
25
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return false;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ interface PendingPrompt {
31
+ key: string;
32
+ type: "confirmation" | "permission" | "input";
33
+ params?: RequiredPermission[] | string[];
34
+ meta?: {
35
+ session_id?: string;
36
+ session_name?: string;
37
+ description?: string;
38
+ input_questions?: string[];
39
+ };
40
+ }
41
+
42
+ interface ConfirmPrompt { description: string; sessionName?: string }
43
+ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: string }
44
+ interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
13
45
 
14
46
  export default function Dashboard() {
15
47
  const { pairedHosts, activeHostId, removePairedHost } = useHostStore();
@@ -19,7 +51,7 @@ export default function Dashboard() {
19
51
  const params = useParams<{ taskId?: string; runId?: string }>();
20
52
  const isDesktop = useMediaQuery("(min-width: 768px)");
21
53
 
22
- const isRunsTab = location.pathname.startsWith("/runs");
54
+ const isTasksTab = location.pathname.startsWith("/tasks");
23
55
  const runsFilterTaskId = params.runId ? undefined : params.taskId;
24
56
 
25
57
  // Resolve "latest" run ID to the actual run ID
@@ -48,16 +80,205 @@ export default function Dashboard() {
48
80
  const [updateError, setUpdateError] = useState<string | null>(null);
49
81
  const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
50
82
  const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
83
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
84
+ const [hostPlatform, setHostPlatform] = useState<string | undefined>();
85
+
86
+ // Pending prompt state — owned by Dashboard because these modals must show
87
+ // regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
88
+ const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
89
+ const [pendingPermissions, setPendingPermissions] = useState<Map<string, PermissionPrompt>>(new Map());
90
+ const [pendingInputs, setPendingInputs] = useState<Map<string, InputPrompt>>(new Map());
91
+ const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
51
92
 
52
- // Register push subscription for the active host
53
93
  usePushSubscription();
54
94
 
55
- // Reset scroll when switching hosts
56
95
  useEffect(() => {
57
96
  window.scrollTo(0, 0);
58
97
  }, [activeHostId]);
59
98
 
99
+ // host.info bootstrap: agents/version/platform + any prompts that were already
100
+ // pending when this PWA connected. Runs once per (host, connection).
101
+ useEffect(() => {
102
+ if (!connected) return;
103
+ request<{
104
+ agents?: AgentInfo[];
105
+ version?: string | null;
106
+ host_platform?: string;
107
+ capability_tokens?: Record<string, string | null>;
108
+ pending_prompts?: PendingPrompt[];
109
+ }>("host.info")
110
+ .then((result) => {
111
+ setAgents(result.agents ?? []);
112
+ setHostPlatform(result.host_platform);
113
+ setCapabilityTokens(result.capability_tokens ?? {});
114
+ setAgentLabels(result.agents ?? []);
115
+ const version = result.version ?? null;
116
+ setDaemonVersion(version);
117
+ setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
118
+
119
+ // Seed modal state from already-pending prompts.
120
+ const confirms = new Map<string, ConfirmPrompt>();
121
+ const perms = new Map<string, PermissionPrompt>();
122
+ const inputs = new Map<string, InputPrompt>();
123
+ const inputVals = new Map<string, string[]>();
124
+ for (const p of result.pending_prompts ?? []) {
125
+ if (p.type === "confirmation") {
126
+ confirms.set(p.key, {
127
+ description: p.meta?.description ?? "",
128
+ sessionName: p.meta?.session_name,
129
+ });
130
+ } else if (p.type === "permission") {
131
+ perms.set(p.key, {
132
+ permissions: (p.params as RequiredPermission[]) ?? [],
133
+ sessionName: p.meta?.session_name,
134
+ });
135
+ } else if (p.type === "input") {
136
+ const questions = (p.params as string[]) ?? p.meta?.input_questions ?? [];
137
+ inputs.set(p.key, {
138
+ questions,
139
+ description: p.meta?.description,
140
+ sessionName: p.meta?.session_name,
141
+ });
142
+ inputVals.set(p.key, new Array(questions.length).fill(""));
143
+ }
144
+ }
145
+ setPendingConfirms(confirms);
146
+ setPendingPermissions(perms);
147
+ setPendingInputs(inputs);
148
+ setInputValues(inputVals);
149
+ })
150
+ .catch(() => { /* silent — update-required prompt guards the broken case */ });
151
+ }, [connected, activeHostId, request]);
152
+
153
+ // Always-on event subscription for modal lifecycle. Independent of which tab
154
+ // is active. Task-card status updates happen inside TaskListView while mounted.
155
+ useEffect(() => {
156
+ if (!connected || !activeHostId) return;
157
+ const unsubscribe = subscribeEvents(activeHostId, (msg) => {
158
+ const tokens = msg.subject.split(".");
159
+ if (tokens.length < 3) return;
160
+ const taskId = tokens.slice(2).join(".");
161
+
162
+ let parsed: Record<string, unknown> = {};
163
+ try {
164
+ parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
165
+ } catch { return; }
166
+ const eventType = parsed.event_type as string | undefined;
167
+ const sessionId = parsed.session_id as string | undefined;
168
+
169
+ if (eventType === "input-request" && sessionId) {
170
+ const questions = parsed.input_questions as string[] | undefined;
171
+ const sessionName = parsed.session_name as string | undefined;
172
+ const description = parsed.description as string | undefined;
173
+ if (questions?.length) {
174
+ setPendingInputs((prev) => {
175
+ if (prev.has(sessionId)) return prev;
176
+ const next = new Map(prev);
177
+ next.set(sessionId, { questions, description, sessionName });
178
+ return next;
179
+ });
180
+ setInputValues((prev) => {
181
+ if (prev.has(sessionId)) return prev;
182
+ const next = new Map(prev);
183
+ next.set(sessionId, new Array(questions.length).fill(""));
184
+ return next;
185
+ });
186
+ }
187
+ return;
188
+ }
189
+
190
+ if (eventType === "input-resolved" && sessionId) {
191
+ setPendingInputs((prev) => {
192
+ if (!prev.has(sessionId)) return prev;
193
+ const next = new Map(prev);
194
+ next.delete(sessionId);
195
+ return next;
196
+ });
197
+ setInputValues((prev) => {
198
+ const next = new Map(prev);
199
+ next.delete(sessionId);
200
+ return next;
201
+ });
202
+ return;
203
+ }
204
+
205
+ if (eventType === "confirm-request" && sessionId) {
206
+ const description = parsed.description as string | undefined;
207
+ const sessionName = parsed.session_name as string | undefined;
208
+ if (description) {
209
+ setPendingConfirms((prev) => {
210
+ if (prev.has(sessionId)) return prev;
211
+ const next = new Map(prev);
212
+ next.set(sessionId, { description, sessionName });
213
+ return next;
214
+ });
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (eventType === "confirm-resolved" && sessionId) {
220
+ setPendingConfirms((prev) => {
221
+ if (!prev.has(sessionId)) return prev;
222
+ const next = new Map(prev);
223
+ next.delete(sessionId);
224
+ return next;
225
+ });
226
+ return;
227
+ }
228
+
229
+ if (eventType === "permission-request") {
230
+ const permissions = parsed.required_permissions as RequiredPermission[] | undefined;
231
+ const sessionName = parsed.session_name as string | undefined;
232
+ if (permissions?.length) {
233
+ setPendingPermissions((prev) => {
234
+ if (prev.has(taskId)) return prev;
235
+ const next = new Map(prev);
236
+ next.set(taskId, { permissions, sessionName });
237
+ return next;
238
+ });
239
+ }
240
+ return;
241
+ }
242
+
243
+ if (eventType === "permission-resolved") {
244
+ setPendingPermissions((prev) => {
245
+ if (!prev.has(taskId)) return prev;
246
+ const next = new Map(prev);
247
+ next.delete(taskId);
248
+ return next;
249
+ });
250
+ return;
251
+ }
252
+ });
253
+ return unsubscribe;
254
+ }, [connected, activeHostId, subscribeEvents]);
255
+
256
+ async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
257
+ try {
258
+ await request("task.user_input", { id: sessionId, value: [response] });
259
+ } catch (err) {
260
+ console.error("[Dashboard] Failed to respond to confirmation:", err);
261
+ }
262
+ }
263
+
264
+ async function respondToPermission(taskId: string, response: "granted" | "granted_all" | "aborted") {
265
+ try {
266
+ await request("task.user_input", { id: taskId, value: [response] });
267
+ } catch (err) {
268
+ console.error("[Dashboard] Failed to respond to permission request:", err);
269
+ }
270
+ }
271
+
272
+ async function respondToInput(sessionId: string, values: string[]) {
273
+ try {
274
+ await request("task.user_input", { id: sessionId, value: values });
275
+ } catch (err) {
276
+ console.error("[Dashboard] Failed to respond to input request:", err);
277
+ }
278
+ }
279
+
60
280
  function handleViewRun(taskId: string, runId?: string) {
281
+ if (!confirmLeaveDraft()) return;
61
282
  if (runId) {
62
283
  navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
63
284
  } else {
@@ -78,7 +299,6 @@ export default function Dashboard() {
78
299
  } catch {
79
300
  // Expected: connection drops during daemon restart
80
301
  }
81
- // Daemon will restart; reload after it has time to come back up.
82
302
  setTimeout(() => window.location.reload(), 10000);
83
303
  }
84
304
 
@@ -116,7 +336,7 @@ export default function Dashboard() {
116
336
  <div className="revoked-actions">
117
337
  <button
118
338
  className="btn btn-primary"
119
- onClick={() => navigate("/pair")}
339
+ onClick={() => { if (confirmLeaveDraft()) navigate("/pair"); }}
120
340
  >
121
341
  Re-pair Device
122
342
  </button>
@@ -132,18 +352,17 @@ export default function Dashboard() {
132
352
  </div>
133
353
  ) : showTaskContent ? (
134
354
  <>
135
- <div style={{ display: !isRunsTab ? "contents" : "none" }}>
355
+ {isTasksTab && !isRunDetail && (
136
356
  <TaskListView
137
357
  connected={connected}
138
358
  hostId={activeHostId}
139
359
  request={request}
140
360
  subscribeEvents={subscribeEvents}
361
+ agents={agents}
362
+ hostPlatform={hostPlatform}
141
363
  onViewRun={handleViewRun}
142
- onUpdateRequired={setUpdateRequired}
143
- onVersion={setDaemonVersion}
144
- onCapabilityTokens={setCapabilityTokens}
145
364
  />
146
- </div>
365
+ )}
147
366
  {isRunDetail ? (
148
367
  <RunDetailView
149
368
  connected={connected}
@@ -153,14 +372,15 @@ export default function Dashboard() {
153
372
  taskId={params.taskId!}
154
373
  runId={decodeURIComponent(effectiveRunId!)}
155
374
  />
156
- ) : isRunsTab ? (
375
+ ) : !isTasksTab ? (
157
376
  <RunsView
158
377
  connected={connected}
159
378
  hostId={activeHostId}
160
379
  request={request}
161
380
  subscribeEvents={subscribeEvents}
381
+ agents={agents}
162
382
  filterTaskId={runsFilterTaskId}
163
- onClearFilter={() => navigate("/runs")}
383
+ onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
164
384
  />
165
385
  ) : null}
166
386
  </>
@@ -221,6 +441,109 @@ export default function Dashboard() {
221
441
  )}
222
442
 
223
443
  </div>
444
+
445
+ {createPortal(<>
446
+ {[...pendingConfirms.entries()].map(([sessionId, { description, sessionName }]) => (
447
+ <div key={sessionId} className="confirm-modal-overlay">
448
+ <div className="confirm-modal">
449
+ <h2 className="confirm-modal-title">Confirmation Required</h2>
450
+ {sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
451
+ <p className="confirm-modal-message">{description}</p>
452
+ <div className="confirm-modal-actions">
453
+ <button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
454
+ Confirm
455
+ </button>
456
+ <button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
457
+ Abort
458
+ </button>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ ))}
463
+
464
+ {[...pendingPermissions.entries()].map(([taskId, { permissions, sessionName }]) => (
465
+ <div key={taskId} className="confirm-modal-overlay">
466
+ <div className="confirm-modal permission-modal">
467
+ <h2 className="confirm-modal-title">Permission Required</h2>
468
+ <p className="confirm-modal-message">
469
+ <strong>{sessionName || taskId}</strong>
470
+ </p>
471
+ <div className="permission-list">
472
+ {permissions.map((p, i) => (
473
+ <div key={i} className="permission-item">
474
+ <span className="permission-name">{p.name}</span>
475
+ {p.description && <span className="permission-desc">{p.description}</span>}
476
+ </div>
477
+ ))}
478
+ </div>
479
+ <div className="permission-actions">
480
+ <button className="btn btn-primary" onClick={() => respondToPermission(taskId, "granted")}>
481
+ Allow Once
482
+ </button>
483
+ <button className="btn btn-secondary" onClick={() => respondToPermission(taskId, "granted_all")}>
484
+ Allow Always
485
+ </button>
486
+ </div>
487
+ <button
488
+ className="permission-abort-link"
489
+ onClick={() => respondToPermission(taskId, "aborted")}
490
+ >
491
+ Deny & Abort Task
492
+ </button>
493
+ </div>
494
+ </div>
495
+ ))}
496
+
497
+ {[...pendingInputs.entries()].map(([sessionId, { questions, description, sessionName }]) => {
498
+ const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
499
+ return (
500
+ <div key={sessionId} className="confirm-modal-overlay">
501
+ <div className="confirm-modal input-modal">
502
+ <h2 className="confirm-modal-title">Input Required</h2>
503
+ {sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
504
+ {description && <p className="confirm-modal-message">{description}</p>}
505
+ <div className="input-list">
506
+ {questions.map((desc: string, i: number) => (
507
+ <div key={i} className="input-item">
508
+ <label className="input-label">{desc}</label>
509
+ <input
510
+ type="text"
511
+ className="input-field"
512
+ value={values[i] ?? ""}
513
+ onChange={(e) => {
514
+ setInputValues((prev) => {
515
+ const next = new Map(prev);
516
+ const arr = [...(next.get(sessionId) ?? [])];
517
+ arr[i] = e.target.value;
518
+ next.set(sessionId, arr);
519
+ return next;
520
+ });
521
+ }}
522
+ autoFocus={i === 0}
523
+ />
524
+ </div>
525
+ ))}
526
+ </div>
527
+ <div className="input-actions">
528
+ <button
529
+ className="btn btn-primary"
530
+ disabled={values.some((v) => !v.trim())}
531
+ onClick={() => respondToInput(sessionId, values)}
532
+ >
533
+ Submit
534
+ </button>
535
+ </div>
536
+ <button
537
+ className="permission-abort-link"
538
+ onClick={() => respondToInput(sessionId, ["aborted"])}
539
+ >
540
+ Cancel
541
+ </button>
542
+ </div>
543
+ </div>
544
+ );
545
+ })}
546
+ </>, document.body)}
224
547
  </div>
225
548
  );
226
549
  }
@@ -2,6 +2,7 @@ export interface AgentInfo {
2
2
  key: string;
3
3
  label: string;
4
4
  supportsPermissions?: boolean;
5
+ supportsYolo?: boolean;
5
6
  }
6
7
 
7
8
 
@@ -41,12 +42,6 @@ export interface TaskStatus {
41
42
  running_state: TaskRunningState;
42
43
  /** UTC time in milliseconds since epoch */
43
44
  time_stamp: number;
44
- /** Whether this task is awaiting user confirmation */
45
- pending_confirmation?: boolean;
46
- /** Permissions the task needs granted to continue */
47
- pending_permission?: RequiredPermission[];
48
- pending_input?: string[];
49
- user_input?: string[];
50
45
  }
51
46
 
52
47
  export interface HistoryEntry {
@@ -116,7 +116,8 @@ 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
122
  | `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
123
  | `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. |
@@ -275,19 +276,30 @@ 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 `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload. The PWA uses the response for bootstrap metadata (agents, host platform, version, capability tokens) that both the Sessions tab's composer and the Tasks tab depend on.
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`. If the host responds to `task.list`, it returns `{ tasks: [...] }` — an array of **flat task objects** (frontmatter fields spread to the top level) used by the Tasks tab. If the request 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
285
  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
286
 
286
- ### 4.2 Task Creation & Update
287
+ ### 4.2 UI Layout: Sessions & Tasks Tabs
287
288
 
288
- 1. User clicks the "Describe your new task..." placeholder in the task list view, which opens the task form directly.
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.
289
290
 
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).
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). The "Describe your new task..." card opens the full 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 triggers are 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 (`description`, `agent_name`, `task_name`) needed to render the modal cold, since the task list is no longer available at bootstrap.
297
+
298
+ ### 4.3 Task Creation & Update
299
+
300
+ 1. User clicks the "Describe your new task..." placeholder in the Tasks tab, which opens the task form directly.
301
+
302
+ 2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Save" (no schedule) or "Schedule" (with triggers).
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
 
@@ -299,16 +311,17 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
299
311
 
300
312
  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.
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
 
@@ -44,6 +44,9 @@ export interface AgentTool {
44
44
  * If false, the permissions section is omitted from agent instructions. */
45
45
  supportsPermissions: boolean;
46
46
 
47
+ /** Whether this agent supports yolo mode (auto-approve all tools). */
48
+ supportsYolo: boolean;
49
+
47
50
  /** Detect whether the agent CLI is available and perform any agent-specific
48
51
  * initialization. Returns true if the agent was detected and initialized successfully. */
49
52
  init(): Promise<boolean>;
@@ -93,6 +96,7 @@ export interface DetectedAgent {
93
96
  key: string;
94
97
  label: string;
95
98
  supportsPermissions: boolean;
99
+ supportsYolo: boolean;
96
100
  }
97
101
 
98
102
  export async function detectAgents(): Promise<DetectedAgent[]> {
@@ -100,7 +104,7 @@ export async function detectAgents(): Promise<DetectedAgent[]> {
100
104
  for (const [key, agent] of Object.entries(agentRegistry)) {
101
105
  const label = agentLabels[key] ?? key;
102
106
  const ok = await agent.init();
103
- if (ok) detected.push({ key, label, supportsPermissions: agent.supportsPermissions });
107
+ if (ok) detected.push({ key, label, supportsPermissions: agent.supportsPermissions, supportsYolo: agent.supportsYolo });
104
108
  }
105
109
  return detected;
106
110
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Aider implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "aider", args: ["--message", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class ClaudeAgent implements AgentTool {
8
8
  supportsPermissions = true;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "claude", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Cline implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "cline ", args: ["--yolo", "-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class CodexAgent implements AgentTool {
8
8
  supportsPermissions = true;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "codex", args: ["exec", "--skip-git-repo-check", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class CopilotAgent implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "copilot", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Cursor implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "cursor", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class DeepAgents implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "deepagents", args: ["--non-interactive", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class DroidAgent implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "droid", args: ["exec", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class GeminiAgent implements AgentTool {
8
8
  supportsPermissions = true;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "gemini", args: ["--prompt", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class GooseAgent implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "goose", args: ["run", "--text", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Hermes implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "hermes", args: ["chat", "-q", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class KimiAgent implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "kimi", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Kiro implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "kiro-cli", args: ["--no-interactive", prompt] };
11
12
  }