palmier 0.9.2 → 0.9.4
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.
- package/README.md +21 -20
- package/dist/commands/run.js +6 -0
- package/dist/commands/serve.js +61 -36
- package/dist/platform/linux.js +14 -0
- package/dist/pwa/assets/index-BsB1tIsn.css +1 -0
- package/dist/pwa/assets/index-DX5qJgHZ.js +120 -0
- package/dist/pwa/assets/{web-C2AU9S9n.js → web-Dcldtodb.js} +1 -1
- package/dist/pwa/assets/{web-CfD_ah7K.js → web-DdVpqhvX.js} +1 -1
- package/dist/pwa/assets/{web-DugGj1t8.js → web-Eg0A6HEi.js} +1 -1
- package/dist/pwa/index.html +3 -3
- package/dist/pwa/manifest.webmanifest +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +41 -19
- package/dist/task.d.ts +7 -0
- package/dist/task.js +17 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/palmier-server/pwa/index.html +1 -1
- package/palmier-server/pwa/src/App.css +42 -81
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +51 -9
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +5 -1
- package/palmier-server/pwa/src/components/HostMenu.tsx +10 -10
- package/palmier-server/pwa/src/components/{PlanDialog.tsx → PermissionsDialog.tsx} +6 -6
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -1
- package/palmier-server/pwa/src/components/SessionsView.tsx +1 -0
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +13 -3
- package/palmier-server/pwa/src/components/TaskForm.tsx +7 -5
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +23 -8
- package/palmier-server/pwa/src/pages/PairHost.tsx +11 -4
- package/palmier-server/pwa/src/pages/PairSetup.tsx +8 -6
- package/palmier-server/pwa/vite.config.ts +1 -1
- package/palmier-server/spec.md +2 -2
- package/src/commands/run.ts +3 -0
- package/src/commands/serve.ts +62 -37
- package/src/platform/linux.ts +9 -0
- package/src/rpc-handler.ts +34 -18
- package/src/task.ts +21 -0
- package/src/types.ts +4 -0
- package/dist/pwa/assets/index-BLCVzS_l.js +0 -120
- package/dist/pwa/assets/index-Cjjw24Ok.css +0 -1
|
@@ -245,7 +245,7 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
245
245
|
className="btn btn-primary btn-full"
|
|
246
246
|
onClick={() => { if (!confirmLeaveDraft()) return; navigate("/pair"); if (!isDesktop) close(); }}
|
|
247
247
|
>
|
|
248
|
-
|
|
248
|
+
Add New Host
|
|
249
249
|
</button>
|
|
250
250
|
</div>
|
|
251
251
|
</>)}
|
|
@@ -256,11 +256,11 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
256
256
|
<div className="drawer-section">
|
|
257
257
|
<h3 className="drawer-section-label">Device Capabilities</h3>
|
|
258
258
|
{isLinkedDevice ? (
|
|
259
|
-
<CapabilityToggles onChange={onEnabledCapabilitiesChange} />
|
|
259
|
+
<CapabilityToggles onChange={onEnabledCapabilitiesChange} confirmDisable />
|
|
260
260
|
) : (
|
|
261
261
|
<>
|
|
262
262
|
<p className="drawer-section-hint">
|
|
263
|
-
This device isn't the linked device for
|
|
263
|
+
This device isn't the linked device for the host, so it can't provide capabilities (SMS, contacts, location, etc.).
|
|
264
264
|
</p>
|
|
265
265
|
<button
|
|
266
266
|
className="btn btn-secondary btn-full"
|
|
@@ -273,7 +273,7 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
273
273
|
}}
|
|
274
274
|
disabled={linkingBusy}
|
|
275
275
|
>
|
|
276
|
-
{linkingBusy ? "Linking…" : "Link this device"}
|
|
276
|
+
{linkingBusy ? "Linking…" : "Link the host to this device"}
|
|
277
277
|
</button>
|
|
278
278
|
</>
|
|
279
279
|
)}
|
|
@@ -300,11 +300,11 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
300
300
|
const deleteModal = confirmingDeleteId && createPortal(
|
|
301
301
|
<div className="confirm-modal-overlay" onClick={() => setConfirmingDeleteId(null)}>
|
|
302
302
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
303
|
-
<h2 className="confirm-modal-title">
|
|
303
|
+
<h2 className="confirm-modal-title">Remove host?</h2>
|
|
304
304
|
<p className="confirm-modal-message">
|
|
305
|
-
"{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be
|
|
305
|
+
"{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be {deletingIsLinked ? "removed and unlinked" : "removed"}.
|
|
306
306
|
{deletingIsLinked && (
|
|
307
|
-
<> This device is currently linked to the host —
|
|
307
|
+
<> This device is currently linked to the host — unlinking will revoke its access to all device capabilities (SMS, contacts, location, etc.) until another device is linked.</>
|
|
308
308
|
)}
|
|
309
309
|
</p>
|
|
310
310
|
<div className="confirm-modal-actions">
|
|
@@ -318,7 +318,7 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
318
318
|
className="btn btn-danger"
|
|
319
319
|
onClick={() => handleDelete(confirmingDeleteId)}
|
|
320
320
|
>
|
|
321
|
-
|
|
321
|
+
Remove
|
|
322
322
|
</button>
|
|
323
323
|
</div>
|
|
324
324
|
</div>
|
|
@@ -329,9 +329,9 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
329
329
|
const linkModal = confirmingLink && createPortal(
|
|
330
330
|
<div className="confirm-modal-overlay" onClick={() => setConfirmingLink(false)}>
|
|
331
331
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
332
|
-
<h2 className="confirm-modal-title">Link this device?</h2>
|
|
332
|
+
<h2 className="confirm-modal-title">Link the host to this device?</h2>
|
|
333
333
|
<p className="confirm-modal-message">
|
|
334
|
-
Only one device can be linked
|
|
334
|
+
Another device is already linked to this host. Only one device can be linked to the host — switching will disable those capabilities on the currently linked device.
|
|
335
335
|
</p>
|
|
336
336
|
<div className="confirm-modal-actions">
|
|
337
337
|
<button
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { RequiredPermission } from "../types";
|
|
2
2
|
|
|
3
|
-
interface
|
|
3
|
+
interface PermissionsDialogProps {
|
|
4
4
|
permissions?: RequiredPermission[];
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export default function
|
|
7
|
+
export default function PermissionsDialog({ permissions }: PermissionsDialogProps) {
|
|
8
8
|
return (
|
|
9
|
-
<div className="
|
|
9
|
+
<div className="permissions-dialog">
|
|
10
10
|
<h2>Granted Permissions</h2>
|
|
11
|
-
<div className="
|
|
11
|
+
<div className="permissions-dialog-scroll">
|
|
12
12
|
{permissions && permissions.length > 0 ? (
|
|
13
13
|
<div className="permissions-section">
|
|
14
14
|
<ul className="permissions-list">
|
|
@@ -21,10 +21,10 @@ export default function PlanDialog({ permissions }: PlanDialogProps) {
|
|
|
21
21
|
</ul>
|
|
22
22
|
</div>
|
|
23
23
|
) : (
|
|
24
|
-
<p className="
|
|
24
|
+
<p className="permissions-empty">No permissions have been granted for this task.</p>
|
|
25
25
|
)}
|
|
26
26
|
</div>
|
|
27
|
-
<div className="
|
|
27
|
+
<div className="permissions-dialog-actions">
|
|
28
28
|
<button className="btn btn-secondary" onClick={() => history.back()}>
|
|
29
29
|
Back
|
|
30
30
|
</button>
|
|
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
|
|
|
5
5
|
import remarkBreaks from "remark-breaks";
|
|
6
6
|
import { getAgentLabel } from "../agentLabels";
|
|
7
7
|
import { formatTime } from "../formatTime";
|
|
8
|
+
import { useBackClose } from "../hooks/useBackClose";
|
|
8
9
|
import type { ConversationMessage } from "../types";
|
|
9
10
|
|
|
10
11
|
interface RunDetailViewProps {
|
|
@@ -26,6 +27,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
|
|
|
26
27
|
const isFollowupRunning = runState === "followup";
|
|
27
28
|
const isAgentGenerating = runState === "started" || runState === "followup";
|
|
28
29
|
const [reportDialog, setReportDialog] = useState<{ file: string; content?: string; data_url?: string } | null>(null);
|
|
30
|
+
useBackClose(reportDialog !== null, () => setReportDialog(null));
|
|
29
31
|
const [aborting, setAborting] = useState(false);
|
|
30
32
|
const [followupText, setFollowupText] = useState("");
|
|
31
33
|
const [sendingFollowup, setSendingFollowup] = useState(false);
|
|
@@ -298,7 +300,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
|
|
|
298
300
|
ref={followupInputRef}
|
|
299
301
|
className="chat-input"
|
|
300
302
|
type="text"
|
|
301
|
-
placeholder="Follow
|
|
303
|
+
placeholder="Follow up"
|
|
302
304
|
value={followupText}
|
|
303
305
|
onChange={(e) => setFollowupText(e.target.value)}
|
|
304
306
|
disabled={sendingFollowup}
|
|
@@ -278,6 +278,7 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
|
|
|
278
278
|
revealedId={revealedKey}
|
|
279
279
|
setRevealedId={setRevealedKey}
|
|
280
280
|
onDelete={() => deleteEntry(entry)}
|
|
281
|
+
confirmMessage="Delete this session? Its run history will be removed from the host."
|
|
281
282
|
onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
|
|
282
283
|
>
|
|
283
284
|
<div className="sessions-card">
|
|
@@ -11,9 +11,11 @@ interface SwipeToDeleteRowProps {
|
|
|
11
11
|
children: ReactNode;
|
|
12
12
|
/** Label for the action button (default "Delete"). */
|
|
13
13
|
actionLabel?: string;
|
|
14
|
+
/** Message shown in the native confirm() dialog (default "Delete this item? This can't be undone."). */
|
|
15
|
+
confirmMessage?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
const REVEAL_WIDTH =
|
|
18
|
+
const REVEAL_WIDTH = 72; // px width of the action button
|
|
17
19
|
const OPEN_THRESHOLD = REVEAL_WIDTH / 2;
|
|
18
20
|
const AXIS_LOCK_THRESHOLD = 6; // px of horizontal travel before we claim the gesture
|
|
19
21
|
|
|
@@ -33,6 +35,7 @@ export default function SwipeToDeleteRow({
|
|
|
33
35
|
onClick,
|
|
34
36
|
children,
|
|
35
37
|
actionLabel = "Delete",
|
|
38
|
+
confirmMessage = "Delete this item? This can't be undone.",
|
|
36
39
|
}: SwipeToDeleteRowProps) {
|
|
37
40
|
const revealed = revealedId === id;
|
|
38
41
|
const [dragOffset, setDragOffset] = useState(0);
|
|
@@ -137,11 +140,18 @@ export default function SwipeToDeleteRow({
|
|
|
137
140
|
type="button"
|
|
138
141
|
className="swipe-row-action"
|
|
139
142
|
style={{ width: REVEAL_WIDTH }}
|
|
140
|
-
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
|
143
|
+
onClick={(e) => { e.stopPropagation(); if (window.confirm(confirmMessage)) onDelete(); }}
|
|
141
144
|
tabIndex={revealed ? 0 : -1}
|
|
142
145
|
aria-hidden={!revealed}
|
|
146
|
+
aria-label={actionLabel}
|
|
143
147
|
>
|
|
144
|
-
|
|
148
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
149
|
+
<polyline points="3 6 5 6 21 6" />
|
|
150
|
+
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
151
|
+
<path d="M10 11v6M14 11v6" />
|
|
152
|
+
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
|
|
153
|
+
</svg>
|
|
154
|
+
<span className="swipe-row-action-label">{actionLabel}</span>
|
|
145
155
|
</button>
|
|
146
156
|
<div
|
|
147
157
|
className={`swipe-row-content ${dragging ? "swipe-row-content-dragging" : ""}`}
|
|
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import { Capacitor } from "@capacitor/core";
|
|
3
3
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
4
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
5
|
-
import
|
|
5
|
+
import PermissionsDialog from "./PermissionsDialog";
|
|
6
6
|
import { useBackClose } from "../hooks/useBackClose";
|
|
7
7
|
import { Device } from "../native/Device";
|
|
8
8
|
import type { AgentInfo, Task } from "../types";
|
|
@@ -139,7 +139,9 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
139
139
|
const [requiresConfirmation, setRequiresConfirmation] = useState(
|
|
140
140
|
initial?.requires_confirmation ?? false
|
|
141
141
|
);
|
|
142
|
-
const [scheduleEnabled, setScheduleEnabled] = useState(
|
|
142
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(
|
|
143
|
+
initial?.schedule_type ? (initial.schedule_enabled ?? true) : true,
|
|
144
|
+
);
|
|
143
145
|
const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
|
|
144
146
|
const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
|
|
145
147
|
const [command, setCommand] = useState(initial?.command ?? "");
|
|
@@ -220,7 +222,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
220
222
|
|| agent !== (initial?.agent ?? "")
|
|
221
223
|
|| scheduleMode !== initialMode
|
|
222
224
|
|| requiresConfirmation !== (initial?.requires_confirmation ?? false)
|
|
223
|
-
|| scheduleEnabled !== (initial?.schedule_enabled ?? true)
|
|
225
|
+
|| scheduleEnabled !== (initial?.schedule_type ? (initial.schedule_enabled ?? true) : true)
|
|
224
226
|
|| yoloMode !== (initial?.yolo_mode ?? false)
|
|
225
227
|
|| foregroundMode !== (initial?.foreground_mode ?? false)
|
|
226
228
|
|| (modeIsCommand && command !== (initial?.command ?? ""))
|
|
@@ -313,7 +315,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
313
315
|
agent,
|
|
314
316
|
schedule_type: scheduleType,
|
|
315
317
|
schedule_values: scheduleValues.length > 0 ? scheduleValues : null,
|
|
316
|
-
schedule_enabled: scheduleMode
|
|
318
|
+
schedule_enabled: scheduleMode === "ondemand" ? true : scheduleEnabled,
|
|
317
319
|
requires_confirmation: modeIsScheduled ? requiresConfirmation : false,
|
|
318
320
|
yolo_mode: yoloMode,
|
|
319
321
|
foreground_mode: foregroundMode,
|
|
@@ -362,7 +364,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
362
364
|
<div className="task-form-overlay">
|
|
363
365
|
<div className="task-form">
|
|
364
366
|
{planDialogOpen ? (
|
|
365
|
-
<
|
|
367
|
+
<PermissionsDialog permissions={initial?.permissions} />
|
|
366
368
|
) : (<>
|
|
367
369
|
<div className="task-form-header">
|
|
368
370
|
<h2>{initial ? "Edit Task" : "New Task"}</h2>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.9.2";
|
|
@@ -173,6 +173,14 @@ export function HostConnectionProvider({ children, activeHost }: { children: Rea
|
|
|
173
173
|
config.natsJwt,
|
|
174
174
|
new TextEncoder().encode(config.natsNkeySeed),
|
|
175
175
|
),
|
|
176
|
+
// Default is 2 min — too slow to notice a silently-dead WebSocket
|
|
177
|
+
// when the user has the page foregrounded but the network drops.
|
|
178
|
+
// The resume-probe useEffect below catches dead conns within 2s
|
|
179
|
+
// when the user returns to the app, so this background heartbeat
|
|
180
|
+
// can stay conservative (maxPingOut: 2 → ~60s) and avoid false
|
|
181
|
+
// positives from transient network jitter.
|
|
182
|
+
pingInterval: 30_000,
|
|
183
|
+
maxPingOut: 2,
|
|
176
184
|
});
|
|
177
185
|
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
178
186
|
console.log("[NATS] Connected");
|
|
@@ -211,17 +219,24 @@ export function HostConnectionProvider({ children, activeHost }: { children: Rea
|
|
|
211
219
|
};
|
|
212
220
|
}, [isDirectHost, activeHost]);
|
|
213
221
|
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
222
|
+
// WebSockets can die silently — mobile WebViews suspend them on background,
|
|
223
|
+
// laptops on sleep, networks on switch. On any resume (visibility change on
|
|
224
|
+
// web, foreground on native), probe the connection with a short-timeout
|
|
225
|
+
// flush (PING/PONG round-trip); close only if it doesn't respond. nats.ws's
|
|
226
|
+
// own pingInterval catches it eventually, but probing on resume gets us
|
|
227
|
+
// back faster when the user immediately fires an RPC.
|
|
218
228
|
useEffect(() => {
|
|
219
229
|
if (isDirectHost) return;
|
|
220
230
|
const handle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
|
|
221
|
-
if (state.isActive
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
231
|
+
if (!state.isActive || !ncRef.current) return;
|
|
232
|
+
const conn = ncRef.current;
|
|
233
|
+
Promise.race([
|
|
234
|
+
conn.flush(),
|
|
235
|
+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("ping timeout")), 2_000)),
|
|
236
|
+
]).catch(() => {
|
|
237
|
+
console.log("[NATS] Resume probe failed — cycling conn");
|
|
238
|
+
conn.close().catch(() => {});
|
|
239
|
+
});
|
|
225
240
|
});
|
|
226
241
|
return () => { handle.then((h) => h.remove()); };
|
|
227
242
|
}, [isDirectHost]);
|
|
@@ -141,8 +141,11 @@ export default function PairHost() {
|
|
|
141
141
|
<h3 className="pair-instruction-heading">Setting up a new host?</h3>
|
|
142
142
|
<ol className="pair-steps">
|
|
143
143
|
<li>Install at least one agent CLI (e.g., <a href="https://www.palmier.me/agents" target="_blank" rel="noopener noreferrer">Claude Code, Gemini CLI, Codex CLI</a>)</li>
|
|
144
|
-
<li>Install Palmier on your host machine
|
|
145
|
-
<
|
|
144
|
+
<li>Install Palmier on your host machine.
|
|
145
|
+
<span className="pair-platform-label">Linux / macOS:</span>
|
|
146
|
+
<code className="pair-command">curl -fsSL https://palmier.me/install.sh | bash</code>
|
|
147
|
+
<span className="pair-platform-label">Windows (PowerShell):</span>
|
|
148
|
+
<code className="pair-command">irm https://palmier.me/install.ps1 | iex</code>
|
|
146
149
|
</li>
|
|
147
150
|
<li>Run the setup wizard:
|
|
148
151
|
<code className="pair-command">palmier init</code>
|
|
@@ -189,8 +192,12 @@ export default function PairHost() {
|
|
|
189
192
|
disabled={pairing}
|
|
190
193
|
/>
|
|
191
194
|
<span className="pair-checkbox-text">
|
|
192
|
-
<span className="pair-checkbox-title">Link this device</span>
|
|
193
|
-
<span className="pair-checkbox-hint">
|
|
195
|
+
<span className="pair-checkbox-title">Link the host to this device</span>
|
|
196
|
+
<span className="pair-checkbox-hint">
|
|
197
|
+
{makeLinked
|
|
198
|
+
? "The host will use this device for SMS, contacts, calendar, location, and alarms. Only one device can be linked to the host."
|
|
199
|
+
: "This device won't provide SMS, contacts, calendar, location, or alarms to the host. You can link it later from the menu."}
|
|
200
|
+
</span>
|
|
194
201
|
</span>
|
|
195
202
|
</label>
|
|
196
203
|
)}
|
|
@@ -74,9 +74,9 @@ export default function PairSetup() {
|
|
|
74
74
|
const linkModal = phase === "confirming" && createPortal(
|
|
75
75
|
<div className="confirm-modal-overlay" onClick={cancelLink}>
|
|
76
76
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
77
|
-
<h2 className="confirm-modal-title">Link this device?</h2>
|
|
77
|
+
<h2 className="confirm-modal-title">Link the host to this device?</h2>
|
|
78
78
|
<p className="confirm-modal-message">
|
|
79
|
-
Only one device can be linked
|
|
79
|
+
Another device is already linked to this host. Only one device can be linked to the host — switching will disable those capabilities on the currently linked device.
|
|
80
80
|
</p>
|
|
81
81
|
<div className="confirm-modal-actions">
|
|
82
82
|
<button className="btn btn-secondary" onClick={cancelLink}>Cancel</button>
|
|
@@ -89,19 +89,21 @@ export default function PairSetup() {
|
|
|
89
89
|
|
|
90
90
|
if (phase === "loading" || phase === "confirming" || phase === "linking" || phase === "linkError") {
|
|
91
91
|
const isWizardCandidate = isFirstHost;
|
|
92
|
+
const showSpinner = phase === "loading" || phase === "confirming" || phase === "linking";
|
|
92
93
|
return (
|
|
93
94
|
<div className="pair-setup">
|
|
94
95
|
<div className="pair-setup-inner">
|
|
95
96
|
{isWizardCandidate && <h1 className="pair-setup-title">Device Capabilities</h1>}
|
|
96
97
|
<div className="pair-setup-loading">
|
|
97
|
-
{
|
|
98
|
-
{phase === "
|
|
99
|
-
{phase === "
|
|
98
|
+
{showSpinner && <span className="spinner spinner-lg" />}
|
|
99
|
+
{phase === "loading" && <span>Connecting to host…</span>}
|
|
100
|
+
{phase === "confirming" && <span>Awaiting confirmation…</span>}
|
|
101
|
+
{phase === "linking" && <span>Linking device…</span>}
|
|
100
102
|
{phase === "linkError" && (
|
|
101
103
|
<>
|
|
102
104
|
<p className="pair-error">{linkError}</p>
|
|
103
105
|
<button className="btn btn-primary" onClick={retryLink}>Retry</button>
|
|
104
|
-
<button className="btn btn-secondary" onClick={goToHost}
|
|
106
|
+
<button className="btn btn-secondary" onClick={goToHost}>Skip linking</button>
|
|
105
107
|
</>
|
|
106
108
|
)}
|
|
107
109
|
</div>
|
|
@@ -24,7 +24,7 @@ export default defineConfig({
|
|
|
24
24
|
manifest: {
|
|
25
25
|
name: "Palmier",
|
|
26
26
|
short_name: "Palmier",
|
|
27
|
-
description: "
|
|
27
|
+
description: "Bridge your AI agents and your phone. Your AI agents use your phone as a tool — GPS, email, calendar, contacts — and you also use your phone as an agent remote.",
|
|
28
28
|
start_url: "/",
|
|
29
29
|
display: "standalone",
|
|
30
30
|
background_color: "#ffffff",
|
package/palmier-server/spec.md
CHANGED
|
@@ -144,8 +144,8 @@ If no clients exist, the host skips client validation (backward compatibility fo
|
|
|
144
144
|
|
|
145
145
|
Each host tracks a single **linked device** — the one paired device responsible for answering device capability requests (SMS, contacts, calendar, location, alarm, ringer, email, battery). The host stores only `{ clientToken, fcmToken }` in `~/.config/palmier/linked-device.json`; it has no knowledge of which capabilities are actually enabled on the device. That set lives in Android SharedPreferences on the linked device itself and is consulted by the FCM handlers as a local kill-switch.
|
|
146
146
|
|
|
147
|
-
- **Opt-in at pair time.** The PWA shows a "Link this device" checkbox during pairing (native only, default on). If checked, the pair flow continues to a setup step that calls `device.link` with the FCM token. On the very first host pair (when the device has no other paired hosts), that step also shows a one-time "Device Capabilities" screen with all toggles default OFF; the user opts into each one, and Finish writes the set to SharedPreferences. On subsequent host pairs the capability screen is skipped — the device-wide enabled set is host-agnostic and set once.
|
|
148
|
-
- **Reassignment.** Any paired device can take over as the linked device from the drawer's "Link this device" button. This displaces the previous linked device (its drawer toggles go dark).
|
|
147
|
+
- **Opt-in at pair time.** The PWA shows a "Link the host to this device" checkbox during pairing (native only, default on). If checked, the pair flow continues to a setup step that calls `device.link` with the FCM token. On the very first host pair (when the device has no other paired hosts), that step also shows a one-time "Device Capabilities" screen with all toggles default OFF; the user opts into each one, and Finish writes the set to SharedPreferences. On subsequent host pairs the capability screen is skipped — the device-wide enabled set is host-agnostic and set once.
|
|
148
|
+
- **Reassignment.** Any paired device can take over as the linked device from the drawer's "Link the host to this device" button. This displaces the previous linked device (its drawer toggles go dark).
|
|
149
149
|
- **Loss.** If the linked device is unpaired (via `clients.revoke_self` or CLI `palmier clients revoke`), `linked-device.json` is cleared and capability tools return "No linked device configured" until the user picks a new one.
|
|
150
150
|
- **Routing.** MCP capability tools look up the linked device once per invocation and publish FCM to its token (via `host.<host_id>.fcm.<capability>` relayed by the server). Non-linked devices aren't woken and don't receive capability FCMs.
|
|
151
151
|
|
package/src/commands/run.ts
CHANGED
|
@@ -199,6 +199,9 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
199
199
|
if (nc && !nc.isClosed()) {
|
|
200
200
|
await nc.drain();
|
|
201
201
|
}
|
|
202
|
+
if (task.frontmatter.one_off) {
|
|
203
|
+
try { getPlatform().removeTaskTimer(taskId); } catch { /* best-effort */ }
|
|
204
|
+
}
|
|
202
205
|
};
|
|
203
206
|
|
|
204
207
|
try {
|
package/src/commands/serve.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { connectNats } from "../nats-client.js";
|
|
|
5
5
|
import { createRpcHandler } from "../rpc-handler.js";
|
|
6
6
|
import { startNatsTransport } from "../transports/nats-transport.js";
|
|
7
7
|
import { startHttpTransport } from "../transports/http-transport.js";
|
|
8
|
-
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage, listTasks } from "../task.js";
|
|
8
|
+
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage, listTasks, readFollowupStatus, deleteFollowupStatus } from "../task.js";
|
|
9
9
|
import { publishHostEvent } from "../events.js";
|
|
10
10
|
import { getPlatform } from "../platform/index.js";
|
|
11
11
|
import { detectAgents } from "../agents/agent.js";
|
|
@@ -21,58 +21,83 @@ const POLL_INTERVAL_MS = 30_000;
|
|
|
21
21
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Reconcile tasks stuck in "started" whose process is no longer alive
|
|
24
|
+
* Reconcile tasks stuck in "started" whose process is no longer alive, and
|
|
25
|
+
* clean up OS scheduler units for one-off tasks that have already terminated.
|
|
25
26
|
* The system scheduler (Task Scheduler / systemd) is the authoritative source.
|
|
26
27
|
*/
|
|
27
28
|
async function checkStaleTasks(
|
|
28
29
|
config: HostConfig,
|
|
29
30
|
nc: NatsConnection | undefined,
|
|
30
31
|
): Promise<void> {
|
|
31
|
-
const
|
|
32
|
-
if (!fs.existsSync(
|
|
32
|
+
const tasksRoot = path.join(config.projectRoot, "tasks");
|
|
33
|
+
if (!fs.existsSync(tasksRoot)) return;
|
|
33
34
|
|
|
34
35
|
const platform = getPlatform();
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
taskId = (JSON.parse(line) as { task_id: string }).task_id;
|
|
40
|
-
} catch { continue; }
|
|
36
|
+
const taskIds = fs.readdirSync(tasksRoot).filter((f) =>
|
|
37
|
+
fs.statSync(path.join(tasksRoot, f)).isDirectory()
|
|
38
|
+
);
|
|
41
39
|
|
|
40
|
+
for (const taskId of taskIds) {
|
|
42
41
|
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
43
42
|
const status = readTaskStatus(taskDir);
|
|
44
|
-
if (!status
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
|
|
43
|
+
if (!status) continue;
|
|
44
|
+
|
|
45
|
+
let task;
|
|
46
|
+
try { task = parseTaskFile(taskDir); } catch { continue; }
|
|
47
|
+
|
|
48
|
+
if (status.running_state === "started" && !platform.isTaskRunning(taskId)) {
|
|
49
|
+
console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
|
|
50
|
+
const endTime = Date.now();
|
|
51
|
+
writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
|
|
52
|
+
|
|
53
|
+
const runId = fs.readdirSync(taskDir)
|
|
54
|
+
.filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
|
|
55
|
+
.sort()
|
|
56
|
+
.pop();
|
|
57
|
+
|
|
58
|
+
if (runId) {
|
|
59
|
+
appendRunMessage(taskDir, runId, {
|
|
60
|
+
role: "status",
|
|
61
|
+
time: endTime,
|
|
62
|
+
content: "",
|
|
63
|
+
type: "failed",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
content: "",
|
|
62
|
-
type: "failed",
|
|
67
|
+
await publishHostEvent(nc, config.hostId, taskId, {
|
|
68
|
+
event_type: "running-state",
|
|
69
|
+
running_state: "failed",
|
|
70
|
+
name: task.frontmatter.name || taskId,
|
|
63
71
|
});
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} catch { /* fallback to taskId */ }
|
|
74
|
+
if (task.frontmatter.one_off && status.running_state !== "started") {
|
|
75
|
+
try { platform.removeTaskTimer(taskId); } catch { /* best-effort */ }
|
|
76
|
+
}
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
// Reconcile orphaned follow-ups: if a run has a persisted follow-up PID
|
|
79
|
+
// but that process is no longer alive, clear the file and mark the run
|
|
80
|
+
// as failed so the UI doesn't claim it's still running.
|
|
81
|
+
const runIds = fs.readdirSync(taskDir).filter((f) =>
|
|
82
|
+
/^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md"))
|
|
83
|
+
);
|
|
84
|
+
for (const runId of runIds) {
|
|
85
|
+
const runDir = path.join(taskDir, runId);
|
|
86
|
+
const followup = readFollowupStatus(runDir);
|
|
87
|
+
if (!followup) continue;
|
|
88
|
+
try {
|
|
89
|
+
process.kill(followup.pid, 0);
|
|
90
|
+
} catch {
|
|
91
|
+
deleteFollowupStatus(runDir);
|
|
92
|
+
appendRunMessage(taskDir, runId, {
|
|
93
|
+
role: "status",
|
|
94
|
+
time: Date.now(),
|
|
95
|
+
content: "",
|
|
96
|
+
type: "failed",
|
|
97
|
+
});
|
|
98
|
+
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
76
101
|
}
|
|
77
102
|
}
|
|
78
103
|
|
package/src/platform/linux.ts
CHANGED
|
@@ -185,6 +185,15 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
|
|
|
185
185
|
`;
|
|
186
186
|
|
|
187
187
|
fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
|
|
188
|
+
|
|
189
|
+
// Tear down any previously installed timer unit so on-demand tasks don't
|
|
190
|
+
// keep firing on the old schedule. Service unit stays so startTask works.
|
|
191
|
+
const timerPath = path.join(UNIT_DIR, timerName);
|
|
192
|
+
if (fs.existsSync(timerPath)) {
|
|
193
|
+
try { execSync(`systemctl --user stop ${timerName}`, { encoding: "utf-8" }); } catch { /* not running */ }
|
|
194
|
+
try { execSync(`systemctl --user disable ${timerName}`, { encoding: "utf-8" }); } catch { /* not enabled */ }
|
|
195
|
+
fs.unlinkSync(timerPath);
|
|
196
|
+
}
|
|
188
197
|
daemonReload();
|
|
189
198
|
|
|
190
199
|
if (!task.frontmatter.schedule_enabled) return;
|