palmier 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +11 -11
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/app-registry.d.ts +10 -0
  17. package/dist/app-registry.js +44 -0
  18. package/dist/commands/info.d.ts +0 -3
  19. package/dist/commands/info.js +0 -5
  20. package/dist/commands/init.d.ts +0 -3
  21. package/dist/commands/init.js +2 -11
  22. package/dist/commands/pair.d.ts +1 -4
  23. package/dist/commands/pair.js +1 -12
  24. package/dist/commands/restart.d.ts +0 -3
  25. package/dist/commands/restart.js +0 -3
  26. package/dist/commands/run.d.ts +1 -14
  27. package/dist/commands/run.js +18 -61
  28. package/dist/commands/serve.d.ts +0 -3
  29. package/dist/commands/serve.js +33 -27
  30. package/dist/config.d.ts +0 -8
  31. package/dist/config.js +0 -8
  32. package/dist/device-capabilities.d.ts +1 -1
  33. package/dist/event-queues.d.ts +6 -21
  34. package/dist/event-queues.js +6 -21
  35. package/dist/events.d.ts +0 -6
  36. package/dist/events.js +1 -9
  37. package/dist/index.js +0 -1
  38. package/dist/mcp-handler.js +1 -2
  39. package/dist/mcp-tools.d.ts +0 -3
  40. package/dist/mcp-tools.js +14 -18
  41. package/dist/nats-client.d.ts +0 -3
  42. package/dist/nats-client.js +1 -4
  43. package/dist/pending-requests.d.ts +4 -18
  44. package/dist/pending-requests.js +4 -18
  45. package/dist/platform/index.d.ts +1 -4
  46. package/dist/platform/index.js +1 -4
  47. package/dist/platform/linux.d.ts +3 -9
  48. package/dist/platform/linux.js +9 -20
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-B0F9mtid.css +1 -0
  53. package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
  54. package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
  55. package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +19 -48
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +6 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/README.md +1 -1
  73. package/palmier-server/pwa/src/App.css +170 -20
  74. package/palmier-server/pwa/src/App.tsx +15 -1
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
  78. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  79. package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
  80. package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
  81. package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
  82. package/palmier-server/pwa/src/constants.ts +1 -1
  83. package/palmier-server/pwa/src/native/Device.ts +66 -0
  84. package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
  85. package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
  86. package/palmier-server/pwa/src/types.ts +1 -1
  87. package/palmier-server/server/src/index.ts +7 -7
  88. package/palmier-server/server/src/routes/device.ts +4 -4
  89. package/palmier-server/spec.md +47 -6
  90. package/src/agents/agent.ts +0 -4
  91. package/src/agents/claude.ts +1 -1
  92. package/src/agents/codex.ts +2 -2
  93. package/src/agents/cursor.ts +1 -1
  94. package/src/agents/deepagents.ts +1 -1
  95. package/src/agents/gemini.ts +3 -2
  96. package/src/agents/goose.ts +1 -1
  97. package/src/agents/hermes.ts +1 -1
  98. package/src/agents/kiro.ts +1 -1
  99. package/src/agents/opencode.ts +1 -1
  100. package/src/agents/qoder.ts +1 -1
  101. package/src/agents/shared-prompt.ts +0 -3
  102. package/src/app-registry.ts +52 -0
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +1 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +31 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +4 -3
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +14 -20
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +1 -4
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/platform.ts +1 -4
  121. package/src/platform/windows.ts +19 -40
  122. package/src/rpc-handler.ts +20 -48
  123. package/src/spawn-command.ts +11 -27
  124. package/src/task.ts +7 -70
  125. package/src/transports/http-transport.ts +6 -39
  126. package/src/transports/nats-transport.ts +3 -9
  127. package/src/types.ts +3 -10
  128. package/src/update-checker.ts +2 -5
  129. package/test/task-parsing.test.ts +2 -3
  130. package/test/windows-xml.test.ts +11 -12
  131. package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
  132. package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
package/src/mcp-tools.ts CHANGED
@@ -462,8 +462,8 @@ const sendSmsTool: ToolDefinition = {
462
462
  async handler(args, ctx) {
463
463
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
464
464
 
465
- const device = getCapabilityDevice("sms");
466
- if (!device) throw new ToolError("No device has SMS access enabled", 400);
465
+ const device = getCapabilityDevice("sms-send");
466
+ if (!device) throw new ToolError("No device has SMS Send enabled", 400);
467
467
 
468
468
  const { to, body } = args as { to: string; body: string };
469
469
  if (!to || !body) throw new ToolError("to and body are required", 400);
@@ -502,10 +502,10 @@ const sendSmsTool: ToolDefinition = {
502
502
  },
503
503
  };
504
504
 
505
- const sendAlertTool: ToolDefinition = {
506
- name: "send-alert",
505
+ const sendAlarmTool: ToolDefinition = {
506
+ name: "send-alarm",
507
507
  description: [
508
- "Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
508
+ "Trigger an alarm on the user's mobile device with an alarm sound and full-screen popup.",
509
509
  "Use this to urgently get the user's attention. The device will play an alarm sound and show a full-screen dialog even on the lock screen.",
510
510
  "Blocks until the device responds (up to 30 seconds).",
511
511
  'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
@@ -513,16 +513,16 @@ const sendAlertTool: ToolDefinition = {
513
513
  inputSchema: {
514
514
  type: "object",
515
515
  properties: {
516
- title: { type: "string", description: "Alert title" },
517
- description: { type: "string", description: "Alert description/details" },
516
+ title: { type: "string", description: "Alarm title" },
517
+ description: { type: "string", description: "Alarm description/details" },
518
518
  },
519
519
  required: ["title"],
520
520
  },
521
521
  async handler(args, ctx) {
522
522
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
523
523
 
524
- const device = getCapabilityDevice("alert");
525
- if (!device) throw new ToolError("No device has alert access enabled", 400);
524
+ const device = getCapabilityDevice("alarm");
525
+ if (!device) throw new ToolError("No device has alarm access enabled", 400);
526
526
 
527
527
  const { title, description } = args as { title: string; description?: string };
528
528
  if (!title) throw new ToolError("title is required", 400);
@@ -536,7 +536,7 @@ const sendAlertTool: ToolDefinition = {
536
536
  if (description) payload.description = description;
537
537
 
538
538
  const ackReply = await ctx.nc.request(
539
- `host.${ctx.config.hostId}.fcm.alert`,
539
+ `host.${ctx.config.hostId}.fcm.alarm`,
540
540
  sc.encode(JSON.stringify(payload)),
541
541
  { timeout: 5_000 },
542
542
  );
@@ -544,7 +544,7 @@ const sendAlertTool: ToolDefinition = {
544
544
  if (ack.error) throw new ToolError(ack.error, 502);
545
545
 
546
546
  const responsePromise = new Promise<string>((resolve, reject) => {
547
- const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
547
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.sessionId}`, { max: 1 });
548
548
  const timer = setTimeout(() => {
549
549
  sub.unsubscribe();
550
550
  reject(new ToolError("Device did not respond within 30 seconds", 504));
@@ -687,8 +687,8 @@ const sendEmailTool: ToolDefinition = {
687
687
  async handler(args, ctx) {
688
688
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
689
689
 
690
- const device = getCapabilityDevice("email");
691
- if (!device) throw new ToolError("No device has email access enabled", 400);
690
+ const device = getCapabilityDevice("send-email");
691
+ if (!device) throw new ToolError("No device has send-email access enabled", 400);
692
692
 
693
693
  const { to, subject, body, cc, bcc } = args as { to: string; subject?: string; body?: string; cc?: string; bcc?: string };
694
694
  if (!to) throw new ToolError("to is required", 400);
@@ -733,11 +733,9 @@ const sendEmailTool: ToolDefinition = {
733
733
  },
734
734
  };
735
735
 
736
- export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlertTool, readBatteryTool, setRingerModeTool];
736
+ export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
737
737
  export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
738
738
 
739
- // ── MCP Resources ─────────────────────────────────────────────────────
740
-
741
739
  export interface ResourceDefinition {
742
740
  /** MCP resource URI (e.g. "notifications://device"). */
743
741
  uri: string;
@@ -783,9 +781,6 @@ const deviceSmsResource: ResourceDefinition = {
783
781
  export const agentResources: ResourceDefinition[] = [deviceNotificationsResource, deviceSmsResource];
784
782
  export const agentResourceMap = new Map<string, ResourceDefinition>(agentResources.map((r) => [r.uri, r]));
785
783
 
786
- /**
787
- * Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
788
- */
789
784
  export function generateEndpointDocs(
790
785
  port: number,
791
786
  taskId: string,
@@ -803,7 +798,6 @@ export function generateEndpointDocs(
803
798
  const props = schema.properties ?? {};
804
799
  const required = new Set(schema.required ?? []);
805
800
 
806
- // Build example JSON (body only, no taskId)
807
801
  const example: Record<string, unknown> = {};
808
802
  for (const [key, prop] of Object.entries(props)) {
809
803
  if (prop.type === "array") example[key] = ["..."];
@@ -1,9 +1,6 @@
1
1
  import { connect, jwtAuthenticator, type NatsConnection } from "nats";
2
2
  import type { HostConfig } from "./types.js";
3
3
 
4
- /**
5
- * Connect to NATS using the host config's JWT credentials.
6
- */
7
4
  export async function connectNats(config: HostConfig): Promise<NatsConnection> {
8
5
  if (!config.natsJwt || !config.natsNkeySeed) {
9
6
  throw new Error("NATS JWT credentials not configured. Re-run palmier init.");
@@ -17,6 +14,6 @@ export async function connectNats(config: HostConfig): Promise<NatsConnection> {
17
14
  ),
18
15
  });
19
16
 
20
- // Do not log anything as that will pollute stdout for mcp server.
17
+ // Do not log it would pollute stdout for the MCP server.
21
18
  return nc;
22
19
  }
@@ -22,10 +22,9 @@ export interface PendingRequest {
22
22
  const pending = new Map<string, PendingRequest>();
23
23
 
24
24
  /**
25
- * Register a pending request keyed by either a sessionId (confirmation / input)
26
- * or a taskId (permission). The `meta` is surfaced to PWAs that connect after
27
- * the request was opened, so their modals can render without replaying events.
28
- * Only one pending request per key at a time.
25
+ * Key is sessionId for confirmation/input, taskId for permission. Only one
26
+ * pending request per key at a time. `meta` is surfaced via host.info so a
27
+ * freshly-connected PWA can render the modal without replaying events.
29
28
  */
30
29
  export function registerPending(
31
30
  key: string,
@@ -42,10 +41,6 @@ export function registerPending(
42
41
  });
43
42
  }
44
43
 
45
- /**
46
- * Resolve a pending request with the user's response.
47
- * Returns true if a pending request was found and resolved.
48
- */
49
44
  export function resolvePending(key: string, value: string[]): boolean {
50
45
  const entry = pending.get(key);
51
46
  if (!entry) return false;
@@ -54,24 +49,15 @@ export function resolvePending(key: string, value: string[]): boolean {
54
49
  return true;
55
50
  }
56
51
 
57
- /**
58
- * Get the current pending request for a key (if any).
59
- */
60
52
  export function getPending(key: string): PendingRequest | undefined {
61
53
  return pending.get(key);
62
54
  }
63
55
 
64
- /**
65
- * Remove a pending request without resolving it.
66
- */
67
56
  export function removePending(key: string): void {
68
57
  pending.delete(key);
69
58
  }
70
59
 
71
- /**
72
- * List all currently-pending requests, stripped of the unserializable `resolve`
73
- * callback. Used by `host.info` so the PWA can seed its modal state on connect.
74
- */
60
+ /** Pending requests stripped of the unserializable `resolve` callback. */
75
61
  export function listPending(): Array<{
76
62
  key: string;
77
63
  type: PendingRequest["type"];
@@ -2,10 +2,7 @@ import type { PlatformService } from "./platform.js";
2
2
  import { LinuxPlatform } from "./linux.js";
3
3
  import { WindowsPlatform } from "./windows.js";
4
4
 
5
- /**
6
- * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
7
- * On Unix, undefined lets Node use the default shell.
8
- */
5
+ /** Windows needs an explicit shell for execSync to resolve .cmd shims. */
9
6
  export const SHELL: string | undefined = process.platform === "win32" ? "cmd.exe" : undefined;
10
7
 
11
8
  let _instance: PlatformService | undefined;
@@ -22,15 +22,9 @@ function getServiceName(taskId: string): string {
22
22
  }
23
23
 
24
24
  /**
25
- * Convert a cron expression to a systemd OnCalendar string.
26
- *
27
- * Only the 4 cron patterns the PWA UI can produce are supported:
28
- * hourly: "0 * * * *"
29
- * daily: "MM HH * * *"
30
- * weekly: "MM HH * * D"
31
- * monthly: "MM HH D * *"
32
- * Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
33
- * handled because the UI never generates them.
25
+ * Only the 4 cron patterns the PWA UI produces are supported:
26
+ * hourly "0 * * * *", daily "MM HH * * *", weekly "MM HH * * D", monthly "MM HH D * *".
27
+ * Arbitrary expressions (ranges, lists, sub-hour steps) are not handled.
34
28
  */
35
29
  export function cronToOnCalendar(cron: string): string {
36
30
  const parts = cron.trim().split(/\s+/);
@@ -40,7 +34,6 @@ export function cronToOnCalendar(cron: string): string {
40
34
 
41
35
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
42
36
 
43
- // Map cron day-of-week numbers to systemd abbreviated names
44
37
  const dowMap: Record<string, string> = {
45
38
  "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
46
39
  "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
@@ -73,8 +66,8 @@ export class LinuxPlatform implements PlatformService {
73
66
  fs.mkdirSync(UNIT_DIR, { recursive: true });
74
67
 
75
68
  const palmierBin = process.argv[1] || "palmier";
76
- // Save the user's shell PATH so restartDaemon can use it later
77
- // (the daemon itself runs under systemd with a limited PATH).
69
+ // Save the user's shell PATH so restartDaemon can reuse it later — under
70
+ // systemd the daemon itself runs with a limited PATH.
78
71
  const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
79
72
  fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
80
73
  fs.writeFileSync(PATH_FILE, userPath, "utf-8");
@@ -110,7 +103,7 @@ WantedBy=default.target
110
103
  console.error("You may need to start it manually: systemctl --user enable --now palmier.service");
111
104
  }
112
105
 
113
- // Enable lingering so service runs without active login session
106
+ // Lingering lets the service run without an active login session.
114
107
  try {
115
108
  execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
116
109
  console.log("Login lingering enabled.");
@@ -127,11 +120,9 @@ WantedBy=default.target
127
120
  execSync("systemctl --user disable palmier.service 2>/dev/null", { stdio: "pipe" });
128
121
  } catch { /* service may not exist */ }
129
122
 
130
- // Remove daemon service file
131
123
  const servicePath = path.join(UNIT_DIR, "palmier.service");
132
124
  try { fs.unlinkSync(servicePath); } catch { /* ignore */ }
133
125
 
134
- // Remove all task timers and services
135
126
  try {
136
127
  const files = fs.readdirSync(UNIT_DIR).filter((f) => f.startsWith("palmier-task-"));
137
128
  for (const f of files) {
@@ -148,8 +139,8 @@ WantedBy=default.target
148
139
  }
149
140
 
150
141
  async restartDaemon(): Promise<void> {
151
- // If called from a user's terminal, save the current PATH for future use.
152
- // If called from the daemon (auto-update), read the saved PATH instead.
142
+ // From a TTY, snapshot the current PATH; from the daemon (auto-update),
143
+ // reuse whatever was last saved.
153
144
  if (process.stdin.isTTY) {
154
145
  fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
155
146
  fs.writeFileSync(PATH_FILE, process.env.PATH || "", "utf-8");
@@ -196,7 +187,6 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
196
187
  fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
197
188
  daemonReload();
198
189
 
199
- // Only create and enable a timer if the schedule exists and is enabled
200
190
  if (!task.frontmatter.schedule_enabled) return;
201
191
  const scheduleType = task.frontmatter.schedule_type;
202
192
  const scheduleValues = task.frontmatter.schedule_values;
@@ -260,7 +250,6 @@ WantedBy=timers.target
260
250
  }
261
251
 
262
252
  isTaskRunning(taskId: string): boolean {
263
- // Check systemd first (for scheduled/on-demand runs)
264
253
  const serviceName = getServiceName(taskId);
265
254
  try {
266
255
  const out = execSync(
@@ -271,7 +260,7 @@ WantedBy=timers.target
271
260
  if (state === "active" || state === "activating") return true;
272
261
  } catch { /* service may not exist */ }
273
262
 
274
- // Fall back to PID check (for follow-up runs spawned directly)
263
+ // Follow-up runs are spawned directly, so check PID too.
275
264
  try {
276
265
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
277
266
  const status = readTaskStatus(taskDir);
@@ -1,9 +1,6 @@
1
1
  import type { HostConfig, ParsedTask } from "../types.js";
2
2
 
3
- /**
4
- * Abstracts OS-specific daemon, scheduling, and process management.
5
- * Linux uses systemd; Windows uses Task Scheduler; macOS will use launchd.
6
- */
3
+ /** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
7
4
  export interface PlatformService {
8
5
  /** Install the main `palmier serve` daemon to start at boot. */
9
6
  installDaemon(config: HostConfig): void;
@@ -33,32 +33,25 @@ export function scheduleValueToXml(scheduleType: "crons" | "specific_times", val
33
33
  if (parts.length !== 5) throw new Error(`Invalid cron expression: ${value}`);
34
34
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
35
35
  const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
36
- // StartBoundary needs a full date; use a past date as the anchor
36
+ // StartBoundary needs a full date; anchor to a past one.
37
37
  const base = `2000-01-01T${st}`;
38
38
 
39
- // Hourly
40
39
  if (hour === "*") {
41
40
  return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
42
41
  }
43
42
 
44
- // Weekly
45
43
  if (dayOfMonth === "*" && dayOfWeek !== "*") {
46
44
  const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
47
45
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
48
46
  }
49
47
 
50
- // Monthly
51
48
  if (dayOfMonth !== "*" && dayOfWeek === "*") {
52
49
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByMonth><DaysOfMonth><Day>${dayOfMonth}</Day></DaysOfMonth><Months><January /><February /><March /><April /><May /><June /><July /><August /><September /><October /><November /><December /></Months></ScheduleByMonth></CalendarTrigger>`;
53
50
  }
54
51
 
55
- // Daily
56
52
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
57
53
  }
58
54
 
59
- /**
60
- * Build a complete Task Scheduler XML definition.
61
- */
62
55
  export function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string {
63
56
  const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
64
57
  const commandStr = command?.replace(/"/g, "") ?? "";
@@ -98,10 +91,7 @@ export class WindowsPlatform implements PlatformService {
98
91
  installDaemon(config: HostConfig): void {
99
92
  const script = process.argv[1] || "palmier";
100
93
 
101
- // Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
102
94
  this.ensureDaemonTask(script);
103
-
104
- // Start the daemon now
105
95
  this.startDaemonTask();
106
96
 
107
97
  console.log("\nHost initialization complete!");
@@ -110,12 +100,11 @@ export class WindowsPlatform implements PlatformService {
110
100
  uninstallDaemon(): void {
111
101
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
112
102
 
113
- // Stop the daemon via Task Scheduler
114
103
  try {
115
104
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
116
105
  } catch { /* task may not be running */ }
117
106
 
118
- // Remove daemon scheduled task (elevated — S4U task requires elevation to delete)
107
+ // Deleting an S4U task requires elevation.
119
108
  try {
120
109
  execFileSync("powershell", [
121
110
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
@@ -123,7 +112,6 @@ export class WindowsPlatform implements PlatformService {
123
112
  console.log("Daemon task removed.");
124
113
  } catch { /* task may not exist */ }
125
114
 
126
- // Remove all Palmier task timers
127
115
  try {
128
116
  const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
129
117
  for (const line of out.split("\n")) {
@@ -142,16 +130,14 @@ export class WindowsPlatform implements PlatformService {
142
130
  async restartDaemon(): Promise<void> {
143
131
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
144
132
 
145
- // Stop the daemon via Task Scheduler
146
133
  try {
147
134
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
148
135
  } catch { /* task may not be running */ }
149
136
 
150
- // Start it again
151
137
  this.startDaemonTask();
152
138
  }
153
139
 
154
- /** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
140
+ /** S4U LogonType requires elevation to create. */
155
141
  private ensureDaemonTask(script: string): void {
156
142
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
157
143
  const tr = `"${process.execPath}" "${script}" serve`;
@@ -160,7 +146,7 @@ export class WindowsPlatform implements PlatformService {
160
146
  try {
161
147
  const bom = Buffer.from([0xFF, 0xFE]);
162
148
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
163
- // S4U LogonType requires elevation — spawn schtasks via RunAs
149
+ // S4U requires elevation — spawn schtasks via RunAs.
164
150
  const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
165
151
  execFileSync("powershell", [
166
152
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
@@ -174,7 +160,7 @@ export class WindowsPlatform implements PlatformService {
174
160
 
175
161
  }
176
162
 
177
- /** Start the daemon via Task Scheduler (runs outside any session's job object). */
163
+ /** Starting via Task Scheduler runs the daemon outside any session's job object. */
178
164
  private startDaemonTask(): void {
179
165
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
180
166
  try {
@@ -192,9 +178,8 @@ export class WindowsPlatform implements PlatformService {
192
178
  const script = process.argv[1] || "palmier";
193
179
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
194
180
 
195
- // Build trigger XML elements. Event-based schedule types (on_new_notification,
196
- // on_new_sms) carry no values and are driven by the run process, not the OS
197
- // scheduler — they intentionally produce only the dummy trigger below.
181
+ // Event-based schedule types (on_new_notification/on_new_sms) are driven by
182
+ // the run process, not the OS scheduler they fall through to the dummy trigger.
198
183
  const triggerElements: string[] = [];
199
184
  const scheduleType = task.frontmatter.schedule_type;
200
185
  const scheduleValues = task.frontmatter.schedule_values;
@@ -208,19 +193,19 @@ export class WindowsPlatform implements PlatformService {
208
193
  }
209
194
  }
210
195
  }
211
- // Always include a dummy trigger so startTask (/run) works
196
+ // Dummy trigger so schtasks /run still works.
212
197
  if (triggerElements.length === 0) {
213
198
  triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
214
199
  }
215
200
 
216
- // Write XML and register via schtasks gives us full control over
217
- // settings like MultipleInstancesPolicy that schtasks flags don't expose.
218
- // S4U LogonType ensures no console window (unless foreground_mode is set).
219
- // Works without elevation because the daemon (which calls this) runs elevated.
201
+ // XML registration (vs schtasks flags) gives us access to settings like
202
+ // MultipleInstancesPolicy. S4U keeps the console hidden unless
203
+ // foreground_mode is set. Works unelevated because the caller (daemon)
204
+ // runs elevated.
220
205
  const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
221
206
  const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
222
207
  try {
223
- // schtasks /xml requires UTF-16LE with BOM
208
+ // schtasks /xml requires UTF-16LE with BOM.
224
209
  const bom = Buffer.from([0xFF, 0xFE]);
225
210
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
226
211
  execFileSync("schtasks", [
@@ -239,9 +224,7 @@ export class WindowsPlatform implements PlatformService {
239
224
  const tn = schtasksTaskName(taskId);
240
225
  try {
241
226
  execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
242
- } catch {
243
- // Task might not exist — that's fine
244
- }
227
+ } catch { /* task may not exist */ }
245
228
  }
246
229
 
247
230
  async startTask(taskId: string): Promise<void> {
@@ -255,8 +238,8 @@ export class WindowsPlatform implements PlatformService {
255
238
  }
256
239
 
257
240
  async stopTask(taskId: string): Promise<void> {
258
- // Try to kill the entire process tree via the PID recorded in status.json.
259
- // schtasks /end only kills the top-level process, leaving agent children orphaned.
241
+ // schtasks /end leaves agent children orphaned, so kill the process tree
242
+ // via the PID recorded in status.json first.
260
243
  try {
261
244
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
262
245
  const status = readTaskStatus(taskDir);
@@ -265,10 +248,9 @@ export class WindowsPlatform implements PlatformService {
265
248
  return;
266
249
  }
267
250
  } catch {
268
- // PID may be stale or config unavailable; fall through to schtasks /end
251
+ // PID may be stale or config unavailable; fall through to schtasks /end.
269
252
  }
270
253
 
271
- // Fallback: schtasks /end (kills top-level process only)
272
254
  const tn = schtasksTaskName(taskId);
273
255
  try {
274
256
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -279,7 +261,6 @@ export class WindowsPlatform implements PlatformService {
279
261
  }
280
262
 
281
263
  isTaskRunning(taskId: string): boolean {
282
- // Check Task Scheduler first (for scheduled/on-demand runs)
283
264
  const tn = schtasksTaskName(taskId);
284
265
  try {
285
266
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
@@ -289,18 +270,17 @@ export class WindowsPlatform implements PlatformService {
289
270
  if (out.includes('"Running"')) return true;
290
271
  } catch { /* task may not exist in scheduler */ }
291
272
 
292
- // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
273
+ // Follow-up runs are spawned directly (not via schtasks), so check PID too.
293
274
  try {
294
275
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
295
276
  const status = readTaskStatus(taskDir);
296
277
  if (status?.pid) {
297
- // tasklist exits 0 if the PID is found
298
278
  execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
299
279
  encoding: "utf-8",
300
280
  windowsHide: true,
301
281
  stdio: "pipe",
302
282
  });
303
- // tasklist always exits 0; check if output contains the PID
283
+ // tasklist always exits 0, so match the output for the PID.
304
284
  const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
305
285
  encoding: "utf-8",
306
286
  windowsHide: true,
@@ -314,7 +294,6 @@ export class WindowsPlatform implements PlatformService {
314
294
  }
315
295
 
316
296
  getGuiEnv(): Record<string, string> {
317
- // Windows GUI is always available — no special env vars needed
318
297
  return {};
319
298
  }
320
299
  }