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.
Files changed (33) hide show
  1. package/dist/commands/run.js +6 -0
  2. package/dist/commands/serve.js +61 -36
  3. package/dist/pwa/assets/index-Cvffaohh.js +120 -0
  4. package/dist/pwa/assets/{index-BsB1tIsn.css → index-DBgOYBrB.css} +1 -1
  5. package/dist/pwa/assets/{web-Dl9aC-Qr.js → web-ChtbM4nv.js} +1 -1
  6. package/dist/pwa/assets/{web-DdzXb-jW.js → web-hExASsqW.js} +1 -1
  7. package/dist/pwa/assets/{web-a9jK1xeo.js → web-qdLcAD7T.js} +1 -1
  8. package/dist/pwa/index.html +3 -3
  9. package/dist/pwa/manifest.webmanifest +1 -1
  10. package/dist/pwa/service-worker.js +1 -1
  11. package/dist/rpc-handler.js +61 -19
  12. package/dist/task.d.ts +7 -0
  13. package/dist/task.js +17 -0
  14. package/dist/types.d.ts +4 -0
  15. package/package.json +1 -1
  16. package/palmier-server/pwa/index.html +1 -1
  17. package/palmier-server/pwa/src/App.css +6 -0
  18. package/palmier-server/pwa/src/components/HostMenu.tsx +27 -0
  19. package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -1
  20. package/palmier-server/pwa/src/components/SessionsView.tsx +2 -1
  21. package/palmier-server/pwa/src/components/TaskCard.tsx +14 -6
  22. package/palmier-server/pwa/src/components/TaskForm.tsx +14 -8
  23. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +12 -0
  24. package/palmier-server/pwa/src/formatTime.ts +38 -4
  25. package/palmier-server/pwa/src/pages/Dashboard.tsx +4 -2
  26. package/palmier-server/pwa/src/types.ts +2 -0
  27. package/palmier-server/pwa/vite.config.ts +1 -1
  28. package/src/commands/run.ts +3 -0
  29. package/src/commands/serve.ts +62 -37
  30. package/src/rpc-handler.ts +48 -18
  31. package/src/task.ts +21 -0
  32. package/src/types.ts +4 -0
  33. 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 { formatTime } from "../formatTime";
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 { formatTime } from "../formatTime";
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 { formatTime } from "../formatTime";
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()) ? value : `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} at ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
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
- const d = new Date();
117
- d.setHours(Number(hour), Number(min), 0, 0);
118
- const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
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(initial?.schedule_enabled ?? true);
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" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
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 !== "ondemand" && scheduleEnabled,
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={new Date().toISOString().split("T")[0]}
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 === new Date().toISOString().split("T")[0]
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
- if (d.toDateString() === now.toDateString()) return time;
9
- return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${time}`;
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. Agents on your machine get your phone as a tool — SMS, calendar, GPS, alarms, approvals — and your phone as the remote to run them from anywhere.",
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",
@@ -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 {
@@ -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 tasksJsonl = path.join(config.projectRoot, "tasks.jsonl");
32
- if (!fs.existsSync(tasksJsonl)) return;
32
+ const tasksRoot = path.join(config.projectRoot, "tasks");
33
+ if (!fs.existsSync(tasksRoot)) return;
33
34
 
34
35
  const platform = getPlatform();
35
- const lines = fs.readFileSync(tasksJsonl, "utf-8").split("\n").filter(Boolean);
36
- for (const line of lines) {
37
- let taskId: string;
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 || status.running_state !== "started") continue;
45
-
46
- if (platform.isTaskRunning(taskId)) continue;
47
-
48
- console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
49
- const endTime = Date.now();
50
- writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
51
-
52
- const runId = fs.readdirSync(taskDir)
53
- .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
54
- .sort()
55
- .pop();
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
- if (runId) {
58
- appendRunMessage(taskDir, runId, {
59
- role: "status",
60
- time: endTime,
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
- let taskName = taskId;
67
- try {
68
- taskName = parseTaskFile(taskDir).frontmatter.name || taskId;
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
- await publishHostEvent(nc, config.hostId, taskId, {
72
- event_type: "running-state",
73
- running_state: "failed",
74
- name: taskName,
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
 
@@ -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 { spawn, type ChildProcess } from "child_process";
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 script = process.argv[1] || "palmier";
326
- const child = spawn(process.execPath, [script, "run", id], {
327
- detached: true,
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
- return { error: "No active follow-up for this run" };
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 (process.platform === "win32" && child.pid) {
456
- try {
457
- const { execFileSync } = await import("child_process");
458
- execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
459
- } catch { /* may have already exited */ }
460
- } else {
461
- child.kill();
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,