palmier 0.9.3 → 0.9.5
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/dist/commands/run.js +6 -0
- package/dist/commands/serve.js +61 -36
- package/dist/pwa/assets/index-Cvffaohh.js +120 -0
- package/dist/pwa/assets/{index-BsB1tIsn.css → index-DBgOYBrB.css} +1 -1
- package/dist/pwa/assets/{web-Dl9aC-Qr.js → web-ChtbM4nv.js} +1 -1
- package/dist/pwa/assets/{web-DdzXb-jW.js → web-hExASsqW.js} +1 -1
- package/dist/pwa/assets/{web-a9jK1xeo.js → web-qdLcAD7T.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 +61 -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 +6 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +27 -0
- package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -1
- package/palmier-server/pwa/src/components/SessionsView.tsx +2 -1
- package/palmier-server/pwa/src/components/TaskCard.tsx +14 -6
- package/palmier-server/pwa/src/components/TaskForm.tsx +14 -8
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +12 -0
- package/palmier-server/pwa/src/formatTime.ts +38 -4
- package/palmier-server/pwa/src/pages/Dashboard.tsx +4 -2
- package/palmier-server/pwa/src/types.ts +2 -0
- package/palmier-server/pwa/vite.config.ts +1 -1
- package/src/commands/run.ts +3 -0
- package/src/commands/serve.ts +62 -37
- package/src/rpc-handler.ts +48 -18
- package/src/task.ts +21 -0
- package/src/types.ts +4 -0
- package/dist/pwa/assets/index-CknFGshO.js +0 -120
|
@@ -34,6 +34,28 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
34
34
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
35
35
|
const [linkingBusy, setLinkingBusy] = useState(false);
|
|
36
36
|
|
|
37
|
+
const [now, setNow] = useState(() => Date.now());
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const tick = () => setNow(Date.now());
|
|
40
|
+
const msUntilNextMinute = 60_000 - (Date.now() % 60_000);
|
|
41
|
+
const first = setTimeout(() => {
|
|
42
|
+
tick();
|
|
43
|
+
const iv = setInterval(tick, 60_000);
|
|
44
|
+
(first as unknown as { _iv: ReturnType<typeof setInterval> })._iv = iv;
|
|
45
|
+
}, msUntilNextMinute);
|
|
46
|
+
return () => {
|
|
47
|
+
const iv = (first as unknown as { _iv?: ReturnType<typeof setInterval> })._iv;
|
|
48
|
+
if (iv) clearInterval(iv);
|
|
49
|
+
clearTimeout(first);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
const hostClock = activeHost.timezone
|
|
53
|
+
? new Date(now).toLocaleString(undefined, {
|
|
54
|
+
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
|
55
|
+
timeZone: activeHost.timezone,
|
|
56
|
+
})
|
|
57
|
+
: "";
|
|
58
|
+
|
|
37
59
|
async function makeThisLinkedDevice() {
|
|
38
60
|
if (!Device || !request || !activeClientToken) return;
|
|
39
61
|
setLinkingBusy(true);
|
|
@@ -282,6 +304,11 @@ export default function HostMenu({ daemonVersion, linkedClientToken, request, on
|
|
|
282
304
|
)}
|
|
283
305
|
|
|
284
306
|
<div className="drawer-footer">
|
|
307
|
+
{activeHost.timezone && (
|
|
308
|
+
<div className="drawer-host-time">
|
|
309
|
+
Host time: {hostClock} · {activeHost.timezone}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
285
312
|
{daemonVersion && (
|
|
286
313
|
<div className="drawer-version">
|
|
287
314
|
Palmier v{daemonVersion}
|
|
@@ -4,7 +4,7 @@ import Markdown from "react-markdown";
|
|
|
4
4
|
import remarkGfm from "remark-gfm";
|
|
5
5
|
import remarkBreaks from "remark-breaks";
|
|
6
6
|
import { getAgentLabel } from "../agentLabels";
|
|
7
|
-
import {
|
|
7
|
+
import { useFormatTime } from "../formatTime";
|
|
8
8
|
import { useBackClose } from "../hooks/useBackClose";
|
|
9
9
|
import type { ConversationMessage } from "../types";
|
|
10
10
|
|
|
@@ -19,6 +19,7 @@ interface RunDetailViewProps {
|
|
|
19
19
|
|
|
20
20
|
export default function RunDetailView({ connected, hostId, request, subscribeEvents, taskId, runId }: RunDetailViewProps) {
|
|
21
21
|
const navigate = useNavigate();
|
|
22
|
+
const formatTime = useFormatTime();
|
|
22
23
|
const [loading, setLoading] = useState(true);
|
|
23
24
|
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
|
24
25
|
const [runState, setRunState] = useState<string | undefined>();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
|
-
import {
|
|
3
|
+
import { useFormatTime } from "../formatTime";
|
|
4
4
|
import { confirmLeaveDraft } from "../draftGuard";
|
|
5
5
|
import SessionComposer from "./SessionComposer";
|
|
6
6
|
import PullToRefreshIndicator from "./PullToRefreshIndicator";
|
|
@@ -22,6 +22,7 @@ interface SessionsViewProps {
|
|
|
22
22
|
const PAGE_SIZE = 10;
|
|
23
23
|
|
|
24
24
|
export default function SessionsView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, filterTaskId, onClearFilter }: SessionsViewProps) {
|
|
25
|
+
const formatTime = useFormatTime();
|
|
25
26
|
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
|
26
27
|
const [total, setTotal] = useState(0);
|
|
27
28
|
const [loading, setLoading] = useState(false);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
|
-
import {
|
|
4
|
+
import { useFormatTime } from "../formatTime";
|
|
5
5
|
import { getAgentLabel } from "../agentLabels";
|
|
6
6
|
import type { Task, TaskStatus } from "../types";
|
|
7
7
|
|
|
@@ -16,7 +16,9 @@ interface TaskCardProps {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun }: TaskCardProps) {
|
|
19
|
-
const { request } = useHostConnection();
|
|
19
|
+
const { request, activeHost } = useHostConnection();
|
|
20
|
+
const formatTime = useFormatTime();
|
|
21
|
+
const timeZone = activeHost.timezone;
|
|
20
22
|
const [aborting, setAborting] = useState(false);
|
|
21
23
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
22
24
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
@@ -105,7 +107,9 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
|
|
|
105
107
|
function classifyValue(scheduleType: "crons" | "specific_times", value: string): { kind: string; detail: string } {
|
|
106
108
|
if (scheduleType === "specific_times") {
|
|
107
109
|
const d = new Date(value);
|
|
108
|
-
const label = isNaN(d.getTime())
|
|
110
|
+
const label = isNaN(d.getTime())
|
|
111
|
+
? value
|
|
112
|
+
: `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", timeZone })} at ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", timeZone })}`;
|
|
109
113
|
return { kind: "specific_times", detail: label };
|
|
110
114
|
}
|
|
111
115
|
const parts = value.split(" ");
|
|
@@ -113,9 +117,13 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
|
|
|
113
117
|
const [min, hour, dom, , dow] = parts;
|
|
114
118
|
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
115
119
|
if (hour === "*") return { kind: "hourly", detail: "" };
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
120
|
+
// Cron HH:MM is in the host's wall clock. Format directly — no Date
|
|
121
|
+
// conversion — to avoid implicit shifting through the viewer's local zone.
|
|
122
|
+
const h = Number(hour);
|
|
123
|
+
const m = Number(min);
|
|
124
|
+
const hour12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
125
|
+
const ampm = h >= 12 ? "PM" : "AM";
|
|
126
|
+
const time = `${hour12}:${String(m).padStart(2, "0")} ${ampm}`;
|
|
119
127
|
if (dow !== "*") return { kind: "weekly", detail: `${DAYS[Number(dow)] ?? dow} at ${time}` };
|
|
120
128
|
if (dom !== "*") return { kind: "monthly", detail: `day ${dom} at ${time}` };
|
|
121
129
|
return { kind: "daily", detail: time };
|
|
@@ -2,6 +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 { hostNowParts } from "../formatTime";
|
|
5
6
|
import PermissionsDialog from "./PermissionsDialog";
|
|
6
7
|
import { useBackClose } from "../hooks/useBackClose";
|
|
7
8
|
import { Device } from "../native/Device";
|
|
@@ -139,7 +140,9 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
139
140
|
const [requiresConfirmation, setRequiresConfirmation] = useState(
|
|
140
141
|
initial?.requires_confirmation ?? false
|
|
141
142
|
);
|
|
142
|
-
const [scheduleEnabled, setScheduleEnabled] = useState(
|
|
143
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(
|
|
144
|
+
initial?.schedule_type ? (initial.schedule_enabled ?? true) : true,
|
|
145
|
+
);
|
|
143
146
|
const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
|
|
144
147
|
const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
|
|
145
148
|
const [command, setCommand] = useState(initial?.command ?? "");
|
|
@@ -220,7 +223,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
220
223
|
|| agent !== (initial?.agent ?? "")
|
|
221
224
|
|| scheduleMode !== initialMode
|
|
222
225
|
|| requiresConfirmation !== (initial?.requires_confirmation ?? false)
|
|
223
|
-
|| scheduleEnabled !== (initial?.schedule_enabled ?? true)
|
|
226
|
+
|| scheduleEnabled !== (initial?.schedule_type ? (initial.schedule_enabled ?? true) : true)
|
|
224
227
|
|| yoloMode !== (initial?.yolo_mode ?? false)
|
|
225
228
|
|| foregroundMode !== (initial?.foreground_mode ?? false)
|
|
226
229
|
|| (modeIsCommand && command !== (initial?.command ?? ""))
|
|
@@ -232,8 +235,13 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
232
235
|
|| (scheduleMode === "on_new_notification" && notificationApp.trim() !== initialNotificationApp.trim())
|
|
233
236
|
|| (scheduleMode === "on_new_sms" && smsSender.trim() !== initialSmsSender.trim());
|
|
234
237
|
|
|
238
|
+
const hostNow = hostNowParts(activeHost.timezone);
|
|
235
239
|
const hasInvalidTrigger = modeIsScheduled && triggerRows.some((r) =>
|
|
236
|
-
r.schedule === "specific_times" && (
|
|
240
|
+
r.schedule === "specific_times" && (
|
|
241
|
+
!r.onceDate
|
|
242
|
+
|| r.onceDate < hostNow.date
|
|
243
|
+
|| (r.onceDate === hostNow.date && (r.onceTime ?? "") <= hostNow.time)
|
|
244
|
+
)
|
|
237
245
|
);
|
|
238
246
|
const canSave = isDirty
|
|
239
247
|
&& !!userPrompt.trim()
|
|
@@ -313,7 +321,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
313
321
|
agent,
|
|
314
322
|
schedule_type: scheduleType,
|
|
315
323
|
schedule_values: scheduleValues.length > 0 ? scheduleValues : null,
|
|
316
|
-
schedule_enabled: scheduleMode
|
|
324
|
+
schedule_enabled: scheduleMode === "ondemand" ? true : scheduleEnabled,
|
|
317
325
|
requires_confirmation: modeIsScheduled ? requiresConfirmation : false,
|
|
318
326
|
yolo_mode: yoloMode,
|
|
319
327
|
foreground_mode: foregroundMode,
|
|
@@ -592,16 +600,14 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
592
600
|
className="form-input"
|
|
593
601
|
type="date"
|
|
594
602
|
value={row.onceDate}
|
|
595
|
-
min={
|
|
603
|
+
min={hostNow.date}
|
|
596
604
|
onChange={(e) => updateRow(i, { onceDate: e.target.value })}
|
|
597
605
|
/>
|
|
598
606
|
<input
|
|
599
607
|
className="form-input"
|
|
600
608
|
type="time"
|
|
601
609
|
value={row.onceTime}
|
|
602
|
-
min={row.onceDate ===
|
|
603
|
-
? new Date().toTimeString().slice(0, 5)
|
|
604
|
-
: undefined}
|
|
610
|
+
min={row.onceDate === hostNow.date ? hostNow.time : undefined}
|
|
605
611
|
onChange={(e) => updateRow(i, { onceTime: e.target.value })}
|
|
606
612
|
/>
|
|
607
613
|
</div>
|
|
@@ -15,6 +15,7 @@ interface HostStoreContextValue {
|
|
|
15
15
|
renamePairedHost(hostId: string, name: string): void;
|
|
16
16
|
setHostLanUrl(hostId: string, lanUrl: string | undefined): void;
|
|
17
17
|
setHostLastAgent(hostId: string, agent: string): void;
|
|
18
|
+
setHostTimezone(hostId: string, timezone: string | undefined): void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const HostStoreContext = createContext<HostStoreContextValue | null>(null);
|
|
@@ -91,6 +92,16 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
|
91
92
|
);
|
|
92
93
|
}, []);
|
|
93
94
|
|
|
95
|
+
const setHostTimezone = useCallback((hostId: string, timezone: string | undefined) => {
|
|
96
|
+
setPairedHosts((prev) =>
|
|
97
|
+
prev.map((h) => {
|
|
98
|
+
if (h.hostId !== hostId) return h;
|
|
99
|
+
if (h.timezone === timezone) return h;
|
|
100
|
+
return { ...h, timezone };
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
94
105
|
return (
|
|
95
106
|
<HostStoreContext.Provider value={{
|
|
96
107
|
pairedHosts,
|
|
@@ -99,6 +110,7 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
|
99
110
|
renamePairedHost,
|
|
100
111
|
setHostLanUrl,
|
|
101
112
|
setHostLastAgent,
|
|
113
|
+
setHostTimezone,
|
|
102
114
|
}}>
|
|
103
115
|
{children}
|
|
104
116
|
</HostStoreContext.Provider>
|
|
@@ -1,10 +1,44 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useHostConnection } from "./contexts/HostConnectionContext";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Format a timestamp for display. Shows time only if today, otherwise includes the date.
|
|
6
|
+
* If `timeZone` is provided, renders the wall-clock time in that IANA zone.
|
|
3
7
|
*/
|
|
4
|
-
export function formatTime(ms: number): string {
|
|
8
|
+
export function formatTime(ms: number, timeZone?: string): string {
|
|
5
9
|
const d = new Date(ms);
|
|
6
10
|
const now = new Date();
|
|
7
|
-
const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", timeZone });
|
|
12
|
+
// toDateString() would compare in the viewer's local zone, masking day
|
|
13
|
+
// boundaries in the host's zone. Format both dates in the target zone and
|
|
14
|
+
// compare the strings instead.
|
|
15
|
+
const dayOpts: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit", timeZone };
|
|
16
|
+
const sameDay = d.toLocaleDateString(undefined, dayOpts) === now.toLocaleDateString(undefined, dayOpts);
|
|
17
|
+
if (sameDay) return time;
|
|
18
|
+
return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone })} ${time}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns a `formatTime` bound to the active host's timezone. */
|
|
22
|
+
export function useFormatTime(): (ms: number) => string {
|
|
23
|
+
const { activeHost } = useHostConnection();
|
|
24
|
+
const tz = activeHost.timezone;
|
|
25
|
+
return useCallback((ms: number) => formatTime(ms, tz), [tz]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Current wall-clock in the host timezone, as ISO-shaped date + HH:MM strings.
|
|
30
|
+
* Used for "not in the past" validation on scheduled triggers so the floor is
|
|
31
|
+
* the host's now, not the viewer's now.
|
|
32
|
+
*/
|
|
33
|
+
export function hostNowParts(timeZone?: string): { date: string; time: string } {
|
|
34
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
35
|
+
timeZone,
|
|
36
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
37
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
38
|
+
}).formatToParts(new Date());
|
|
39
|
+
const pick = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
|
40
|
+
return {
|
|
41
|
+
date: `${pick("year")}-${pick("month")}-${pick("day")}`,
|
|
42
|
+
time: `${pick("hour")}:${pick("minute")}`,
|
|
43
|
+
};
|
|
10
44
|
}
|
|
@@ -46,7 +46,7 @@ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: st
|
|
|
46
46
|
interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
|
|
47
47
|
|
|
48
48
|
export default function Dashboard() {
|
|
49
|
-
const { removePairedHost, setHostLanUrl } = useHostStore();
|
|
49
|
+
const { removePairedHost, setHostLanUrl, setHostTimezone } = useHostStore();
|
|
50
50
|
const { connected, request, subscribeEvents, unauthorized, activeHost } = useHostConnection();
|
|
51
51
|
const hostId = activeHost.hostId;
|
|
52
52
|
const activeClientToken = activeHost.clientToken || null;
|
|
@@ -100,6 +100,7 @@ export default function Dashboard() {
|
|
|
100
100
|
agents?: AgentInfo[];
|
|
101
101
|
version?: string | null;
|
|
102
102
|
host_platform?: string;
|
|
103
|
+
host_timezone?: string;
|
|
103
104
|
linked_client_token?: string | null;
|
|
104
105
|
pending_prompts?: PendingPrompt[];
|
|
105
106
|
lan_url?: string | null;
|
|
@@ -113,6 +114,7 @@ export default function Dashboard() {
|
|
|
113
114
|
setDaemonVersion(version);
|
|
114
115
|
setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
115
116
|
setHostLanUrl(hostId, result.lan_url ?? undefined);
|
|
117
|
+
setHostTimezone(hostId, result.host_timezone);
|
|
116
118
|
|
|
117
119
|
// Seed modal state from already-pending prompts.
|
|
118
120
|
const confirms = new Map<string, ConfirmPrompt>();
|
|
@@ -146,7 +148,7 @@ export default function Dashboard() {
|
|
|
146
148
|
setInputValues(inputVals);
|
|
147
149
|
})
|
|
148
150
|
.catch(() => { /* silent — update-required prompt guards the broken case */ });
|
|
149
|
-
}, [connected, hostId, request, setHostLanUrl]);
|
|
151
|
+
}, [connected, hostId, request, setHostLanUrl, setHostTimezone]);
|
|
150
152
|
|
|
151
153
|
// Always-on event subscription for modal lifecycle. Independent of which tab
|
|
152
154
|
// is active. Task-card status updates happen inside TasksView while mounted.
|
|
@@ -70,4 +70,6 @@ export interface PairedHost {
|
|
|
70
70
|
lanUrl?: string;
|
|
71
71
|
/** Last-used agent key for this host. Seeds the agent picker on the session composer and task form. */
|
|
72
72
|
lastAgent?: string;
|
|
73
|
+
/** IANA timezone from the host, refreshed on each `host.info`. Drives all time rendering. */
|
|
74
|
+
timezone?: string;
|
|
73
75
|
}
|
|
@@ -24,7 +24,7 @@ export default defineConfig({
|
|
|
24
24
|
manifest: {
|
|
25
25
|
name: "Palmier",
|
|
26
26
|
short_name: "Palmier",
|
|
27
|
-
description: "Bridge your AI agents and your phone.
|
|
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/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/rpc-handler.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
|
-
import {
|
|
4
|
+
import { type ChildProcess } from "child_process";
|
|
5
5
|
import { type NatsConnection } from "nats";
|
|
6
|
-
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
|
|
6
|
+
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir, writeFollowupStatus, readFollowupStatus, deleteFollowupStatus } from "./task.js";
|
|
7
7
|
import { resolvePending, getPending, listPending } from "./pending-requests.js";
|
|
8
8
|
import { getPlatform } from "./platform/index.js";
|
|
9
9
|
import { spawnCommand } from "./spawn-command.js";
|
|
@@ -148,6 +148,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
148
148
|
agents: config.agents ?? [],
|
|
149
149
|
version: currentVersion,
|
|
150
150
|
host_platform: process.platform,
|
|
151
|
+
host_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
151
152
|
linked_client_token: getLinkedDevice()?.clientToken ?? null,
|
|
152
153
|
pending_prompts: listPending(),
|
|
153
154
|
lan_url: buildLanUrl(config.httpPort ?? 7256, config.defaultInterface),
|
|
@@ -310,6 +311,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
310
311
|
agent: params.agent,
|
|
311
312
|
schedule_enabled: false,
|
|
312
313
|
requires_confirmation: params.requires_confirmation ?? false,
|
|
314
|
+
one_off: true,
|
|
313
315
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
314
316
|
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
315
317
|
...(params.command ? { command: params.command } : {}),
|
|
@@ -322,13 +324,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
322
324
|
const runId = createRunDir(taskDir, name, Date.now(), params.agent);
|
|
323
325
|
appendHistory(config.projectRoot, { task_id: id, run_id: runId });
|
|
324
326
|
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
stdio: "ignore",
|
|
329
|
-
windowsHide: true,
|
|
330
|
-
});
|
|
331
|
-
child.unref();
|
|
327
|
+
const platform = getPlatform();
|
|
328
|
+
platform.installTaskTimer(config, task);
|
|
329
|
+
await platform.startTask(id);
|
|
332
330
|
|
|
333
331
|
return { ok: true, task_id: id, run_id: runId };
|
|
334
332
|
}
|
|
@@ -397,6 +395,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
397
395
|
});
|
|
398
396
|
if (stdin != null) child.stdin!.end(stdin);
|
|
399
397
|
activeFollowups.set(followupKey, child);
|
|
398
|
+
if (child.pid) writeFollowupStatus(followupRunDir, { pid: child.pid, spawned_at: Date.now() });
|
|
400
399
|
|
|
401
400
|
const chunks: Buffer[] = [];
|
|
402
401
|
child.stdout?.on("data", (d: Buffer) => chunks.push(d));
|
|
@@ -404,6 +403,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
404
403
|
|
|
405
404
|
child.on("close", async (code: number | null) => {
|
|
406
405
|
activeFollowups.delete(followupKey);
|
|
406
|
+
deleteFollowupStatus(followupRunDir);
|
|
407
407
|
// stop_followup already wrote the stopped status.
|
|
408
408
|
if (child.killed) return;
|
|
409
409
|
|
|
@@ -428,6 +428,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
428
428
|
|
|
429
429
|
child.on("error", async (err: Error) => {
|
|
430
430
|
activeFollowups.delete(followupKey);
|
|
431
|
+
deleteFollowupStatus(followupRunDir);
|
|
431
432
|
console.error(`Follow-up failed for ${followupKey}:`, err);
|
|
432
433
|
appendRunMessage(followupTaskDir, params.run_id, {
|
|
433
434
|
role: "status",
|
|
@@ -447,22 +448,33 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
447
448
|
return { error: "run_id is required" };
|
|
448
449
|
}
|
|
449
450
|
const stopKey = `${params.id}:${params.run_id}`;
|
|
451
|
+
const stopTaskDir = getTaskDir(config.projectRoot, params.id);
|
|
452
|
+
const stopRunDir = getRunDir(stopTaskDir, params.run_id);
|
|
450
453
|
const child = activeFollowups.get(stopKey);
|
|
454
|
+
|
|
455
|
+
let pidToKill: number | undefined = child?.pid;
|
|
451
456
|
if (!child) {
|
|
452
|
-
|
|
457
|
+
// Daemon restarted since spawn — the in-memory handle is gone but
|
|
458
|
+
// the child may still be running. Fall back to the persisted PID.
|
|
459
|
+
const persisted = readFollowupStatus(stopRunDir);
|
|
460
|
+
if (!persisted) return { error: "No active follow-up for this run" };
|
|
461
|
+
pidToKill = persisted.pid;
|
|
453
462
|
}
|
|
454
463
|
|
|
455
|
-
if (
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
child
|
|
464
|
+
if (pidToKill !== undefined) {
|
|
465
|
+
if (process.platform === "win32") {
|
|
466
|
+
try {
|
|
467
|
+
const { execFileSync } = await import("child_process");
|
|
468
|
+
execFileSync("taskkill", ["/pid", String(pidToKill), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
|
|
469
|
+
} catch { /* may have already exited */ }
|
|
470
|
+
} else if (child) {
|
|
471
|
+
child.kill();
|
|
472
|
+
} else {
|
|
473
|
+
try { process.kill(pidToKill, "SIGTERM"); } catch { /* already dead */ }
|
|
474
|
+
}
|
|
462
475
|
}
|
|
463
476
|
|
|
464
477
|
// child.killed stops the close handler from double-writing the status.
|
|
465
|
-
const stopTaskDir = getTaskDir(config.projectRoot, params.id);
|
|
466
478
|
appendRunMessage(stopTaskDir, params.run_id, {
|
|
467
479
|
role: "status",
|
|
468
480
|
time: Date.now(),
|
|
@@ -470,6 +482,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
470
482
|
type: "stopped",
|
|
471
483
|
});
|
|
472
484
|
activeFollowups.delete(stopKey);
|
|
485
|
+
deleteFollowupStatus(stopRunDir);
|
|
473
486
|
await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
|
|
474
487
|
return { ok: true, task_id: params.id, run_id: params.run_id };
|
|
475
488
|
}
|
|
@@ -509,6 +522,10 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
509
522
|
console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
|
|
510
523
|
return { error: `Failed to abort task: ${e.stderr || e.message}` };
|
|
511
524
|
}
|
|
525
|
+
try {
|
|
526
|
+
const aborted = parseTaskFile(abortTaskDir);
|
|
527
|
+
if (aborted.frontmatter.one_off) getPlatform().removeTaskTimer(params.id);
|
|
528
|
+
} catch { /* best-effort cleanup */ }
|
|
512
529
|
const abortPayload: Record<string, unknown> = { event_type: "running-state", running_state: "aborted" };
|
|
513
530
|
await publishHostEvent(nc, config.hostId, params.id, abortPayload);
|
|
514
531
|
return { ok: true, task_id: params.id };
|
|
@@ -618,10 +635,23 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
618
635
|
if (!params.task_id || !params.run_id) {
|
|
619
636
|
return { error: "task_id and run_id are required" };
|
|
620
637
|
}
|
|
638
|
+
const deleteTaskDir = getTaskDir(config.projectRoot, params.task_id);
|
|
639
|
+
let isOneOff = false;
|
|
640
|
+
try { isOneOff = !!parseTaskFile(deleteTaskDir).frontmatter.one_off; } catch { /* ignore */ }
|
|
641
|
+
|
|
621
642
|
const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.run_id);
|
|
622
643
|
if (!deleted) {
|
|
623
644
|
return { error: "History entry not found" };
|
|
624
645
|
}
|
|
646
|
+
|
|
647
|
+
// A one-off task exists only to hold its single run — no run means no
|
|
648
|
+
// reason for the task to stick around.
|
|
649
|
+
if (isOneOff) {
|
|
650
|
+
try { getPlatform().removeTaskTimer(params.task_id); } catch { /* best-effort */ }
|
|
651
|
+
clearTaskQueue(params.task_id);
|
|
652
|
+
try { fs.rmSync(deleteTaskDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
653
|
+
}
|
|
654
|
+
|
|
625
655
|
return { ok: true, task_id: params.task_id, run_id: params.run_id };
|
|
626
656
|
}
|
|
627
657
|
|
package/src/task.ts
CHANGED
|
@@ -116,6 +116,27 @@ export function readTaskStatus(taskDir: string): TaskStatus | undefined {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export interface FollowupStatus {
|
|
120
|
+
pid: number;
|
|
121
|
+
spawned_at: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function writeFollowupStatus(runDir: string, status: FollowupStatus): void {
|
|
125
|
+
fs.writeFileSync(path.join(runDir, "followup.json"), JSON.stringify(status), "utf-8");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readFollowupStatus(runDir: string): FollowupStatus | undefined {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(fs.readFileSync(path.join(runDir, "followup.json"), "utf-8")) as FollowupStatus;
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function deleteFollowupStatus(runDir: string): void {
|
|
137
|
+
try { fs.unlinkSync(path.join(runDir, "followup.json")); } catch { /* ignore */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
/** Returns the run ID (timestamp string used as directory name). */
|
|
120
141
|
export function createRunDir(
|
|
121
142
|
taskDir: string,
|