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/dist/mcp-tools.js CHANGED
@@ -400,9 +400,9 @@ const sendSmsTool = {
400
400
  async handler(args, ctx) {
401
401
  if (!ctx.nc)
402
402
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
403
- const device = getCapabilityDevice("sms");
403
+ const device = getCapabilityDevice("sms-send");
404
404
  if (!device)
405
- throw new ToolError("No device has SMS access enabled", 400);
405
+ throw new ToolError("No device has SMS Send enabled", 400);
406
406
  const { to, body } = args;
407
407
  if (!to || !body)
408
408
  throw new ToolError("to and body are required", 400);
@@ -433,10 +433,10 @@ const sendSmsTool = {
433
433
  return result;
434
434
  },
435
435
  };
436
- const sendAlertTool = {
437
- name: "send-alert",
436
+ const sendAlarmTool = {
437
+ name: "send-alarm",
438
438
  description: [
439
- "Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
439
+ "Trigger an alarm on the user's mobile device with an alarm sound and full-screen popup.",
440
440
  "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.",
441
441
  "Blocks until the device responds (up to 30 seconds).",
442
442
  'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
@@ -444,17 +444,17 @@ const sendAlertTool = {
444
444
  inputSchema: {
445
445
  type: "object",
446
446
  properties: {
447
- title: { type: "string", description: "Alert title" },
448
- description: { type: "string", description: "Alert description/details" },
447
+ title: { type: "string", description: "Alarm title" },
448
+ description: { type: "string", description: "Alarm description/details" },
449
449
  },
450
450
  required: ["title"],
451
451
  },
452
452
  async handler(args, ctx) {
453
453
  if (!ctx.nc)
454
454
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
455
- const device = getCapabilityDevice("alert");
455
+ const device = getCapabilityDevice("alarm");
456
456
  if (!device)
457
- throw new ToolError("No device has alert access enabled", 400);
457
+ throw new ToolError("No device has alarm access enabled", 400);
458
458
  const { title, description } = args;
459
459
  if (!title)
460
460
  throw new ToolError("title is required", 400);
@@ -465,12 +465,12 @@ const sendAlertTool = {
465
465
  };
466
466
  if (description)
467
467
  payload.description = description;
468
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alert`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
468
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alarm`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
469
469
  const ack = JSON.parse(sc.decode(ackReply.data));
470
470
  if (ack.error)
471
471
  throw new ToolError(ack.error, 502);
472
472
  const responsePromise = new Promise((resolve, reject) => {
473
- const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
473
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.sessionId}`, { max: 1 });
474
474
  const timer = setTimeout(() => {
475
475
  sub.unsubscribe();
476
476
  reject(new ToolError("Device did not respond within 30 seconds", 504));
@@ -597,9 +597,9 @@ const sendEmailTool = {
597
597
  async handler(args, ctx) {
598
598
  if (!ctx.nc)
599
599
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
600
- const device = getCapabilityDevice("email");
600
+ const device = getCapabilityDevice("send-email");
601
601
  if (!device)
602
- throw new ToolError("No device has email access enabled", 400);
602
+ throw new ToolError("No device has send-email access enabled", 400);
603
603
  const { to, subject, body, cc, bcc } = args;
604
604
  if (!to)
605
605
  throw new ToolError("to is required", 400);
@@ -639,7 +639,7 @@ const sendEmailTool = {
639
639
  return result;
640
640
  },
641
641
  };
642
- export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlertTool, readBatteryTool, setRingerModeTool];
642
+ export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
643
643
  export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
644
644
  const deviceNotificationsResource = {
645
645
  uri: "notifications://device",
@@ -667,9 +667,6 @@ const deviceSmsResource = {
667
667
  };
668
668
  export const agentResources = [deviceNotificationsResource, deviceSmsResource];
669
669
  export const agentResourceMap = new Map(agentResources.map((r) => [r.uri, r]));
670
- /**
671
- * Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
672
- */
673
670
  export function generateEndpointDocs(port, taskId, tools = agentTools, resources = agentResources) {
674
671
  const baseUrl = `http://localhost:${port}`;
675
672
  const lines = [
@@ -680,7 +677,6 @@ export function generateEndpointDocs(port, taskId, tools = agentTools, resources
680
677
  const schema = tool.inputSchema;
681
678
  const props = schema.properties ?? {};
682
679
  const required = new Set(schema.required ?? []);
683
- // Build example JSON (body only, no taskId)
684
680
  const example = {};
685
681
  for (const [key, prop] of Object.entries(props)) {
686
682
  if (prop.type === "array")
@@ -1,7 +1,4 @@
1
1
  import { type NatsConnection } from "nats";
2
2
  import type { HostConfig } from "./types.js";
3
- /**
4
- * Connect to NATS using the host config's JWT credentials.
5
- */
6
3
  export declare function connectNats(config: HostConfig): Promise<NatsConnection>;
7
4
  //# sourceMappingURL=nats-client.d.ts.map
@@ -1,7 +1,4 @@
1
1
  import { connect, jwtAuthenticator } from "nats";
2
- /**
3
- * Connect to NATS using the host config's JWT credentials.
4
- */
5
2
  export async function connectNats(config) {
6
3
  if (!config.natsJwt || !config.natsNkeySeed) {
7
4
  throw new Error("NATS JWT credentials not configured. Re-run palmier init.");
@@ -10,7 +7,7 @@ export async function connectNats(config) {
10
7
  servers: config.natsUrl,
11
8
  authenticator: jwtAuthenticator(config.natsJwt, new TextEncoder().encode(config.natsNkeySeed)),
12
9
  });
13
- // Do not log anything as that will pollute stdout for mcp server.
10
+ // Do not log it would pollute stdout for the MCP server.
14
11
  return nc;
15
12
  }
16
13
  //# sourceMappingURL=nats-client.js.map
@@ -17,29 +17,15 @@ export interface PendingRequest {
17
17
  meta?: PendingRequestMeta;
18
18
  }
19
19
  /**
20
- * Register a pending request keyed by either a sessionId (confirmation / input)
21
- * or a taskId (permission). The `meta` is surfaced to PWAs that connect after
22
- * the request was opened, so their modals can render without replaying events.
23
- * Only one pending request per key at a time.
20
+ * Key is sessionId for confirmation/input, taskId for permission. Only one
21
+ * pending request per key at a time. `meta` is surfaced via host.info so a
22
+ * freshly-connected PWA can render the modal without replaying events.
24
23
  */
25
24
  export declare function registerPending(key: string, type: PendingRequest["type"], params?: PendingRequest["params"], meta?: PendingRequestMeta): Promise<string[]>;
26
- /**
27
- * Resolve a pending request with the user's response.
28
- * Returns true if a pending request was found and resolved.
29
- */
30
25
  export declare function resolvePending(key: string, value: string[]): boolean;
31
- /**
32
- * Get the current pending request for a key (if any).
33
- */
34
26
  export declare function getPending(key: string): PendingRequest | undefined;
35
- /**
36
- * Remove a pending request without resolving it.
37
- */
38
27
  export declare function removePending(key: string): void;
39
- /**
40
- * List all currently-pending requests, stripped of the unserializable `resolve`
41
- * callback. Used by `host.info` so the PWA can seed its modal state on connect.
42
- */
28
+ /** Pending requests stripped of the unserializable `resolve` callback. */
43
29
  export declare function listPending(): Array<{
44
30
  key: string;
45
31
  type: PendingRequest["type"];
@@ -1,9 +1,8 @@
1
1
  const pending = new Map();
2
2
  /**
3
- * Register a pending request keyed by either a sessionId (confirmation / input)
4
- * or a taskId (permission). The `meta` is surfaced to PWAs that connect after
5
- * the request was opened, so their modals can render without replaying events.
6
- * Only one pending request per key at a time.
3
+ * Key is sessionId for confirmation/input, taskId for permission. Only one
4
+ * pending request per key at a time. `meta` is surfaced via host.info so a
5
+ * freshly-connected PWA can render the modal without replaying events.
7
6
  */
8
7
  export function registerPending(key, type, params, meta) {
9
8
  if (pending.has(key)) {
@@ -13,10 +12,6 @@ export function registerPending(key, type, params, meta) {
13
12
  pending.set(key, { type, resolve, params, meta });
14
13
  });
15
14
  }
16
- /**
17
- * Resolve a pending request with the user's response.
18
- * Returns true if a pending request was found and resolved.
19
- */
20
15
  export function resolvePending(key, value) {
21
16
  const entry = pending.get(key);
22
17
  if (!entry)
@@ -25,22 +20,13 @@ export function resolvePending(key, value) {
25
20
  entry.resolve(value);
26
21
  return true;
27
22
  }
28
- /**
29
- * Get the current pending request for a key (if any).
30
- */
31
23
  export function getPending(key) {
32
24
  return pending.get(key);
33
25
  }
34
- /**
35
- * Remove a pending request without resolving it.
36
- */
37
26
  export function removePending(key) {
38
27
  pending.delete(key);
39
28
  }
40
- /**
41
- * List all currently-pending requests, stripped of the unserializable `resolve`
42
- * callback. Used by `host.info` so the PWA can seed its modal state on connect.
43
- */
29
+ /** Pending requests stripped of the unserializable `resolve` callback. */
44
30
  export function listPending() {
45
31
  return [...pending.entries()].map(([key, entry]) => ({
46
32
  key,
@@ -1,8 +1,5 @@
1
1
  import type { PlatformService } from "./platform.js";
2
- /**
3
- * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
4
- * On Unix, undefined lets Node use the default shell.
5
- */
2
+ /** Windows needs an explicit shell for execSync to resolve .cmd shims. */
6
3
  export declare const SHELL: string | undefined;
7
4
  export declare function getPlatform(): PlatformService;
8
5
  export type { PlatformService } from "./platform.js";
@@ -1,9 +1,6 @@
1
1
  import { LinuxPlatform } from "./linux.js";
2
2
  import { WindowsPlatform } from "./windows.js";
3
- /**
4
- * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
5
- * On Unix, undefined lets Node use the default shell.
6
- */
3
+ /** Windows needs an explicit shell for execSync to resolve .cmd shims. */
7
4
  export const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
8
5
  let _instance;
9
6
  export function getPlatform() {
@@ -1,15 +1,9 @@
1
1
  import type { PlatformService } from "./platform.js";
2
2
  import type { HostConfig, ParsedTask } from "../types.js";
3
3
  /**
4
- * Convert a cron expression to a systemd OnCalendar string.
5
- *
6
- * Only the 4 cron patterns the PWA UI can produce are supported:
7
- * hourly: "0 * * * *"
8
- * daily: "MM HH * * *"
9
- * weekly: "MM HH * * D"
10
- * monthly: "MM HH D * *"
11
- * Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
12
- * handled because the UI never generates them.
4
+ * Only the 4 cron patterns the PWA UI produces are supported:
5
+ * hourly "0 * * * *", daily "MM HH * * *", weekly "MM HH * * D", monthly "MM HH D * *".
6
+ * Arbitrary expressions (ranges, lists, sub-hour steps) are not handled.
13
7
  */
14
8
  export declare function cronToOnCalendar(cron: string): string;
15
9
  export declare class LinuxPlatform implements PlatformService {
@@ -15,15 +15,9 @@ function getServiceName(taskId) {
15
15
  return `palmier-task-${taskId}.service`;
16
16
  }
17
17
  /**
18
- * Convert a cron expression to a systemd OnCalendar string.
19
- *
20
- * Only the 4 cron patterns the PWA UI can produce are supported:
21
- * hourly: "0 * * * *"
22
- * daily: "MM HH * * *"
23
- * weekly: "MM HH * * D"
24
- * monthly: "MM HH D * *"
25
- * Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
26
- * handled because the UI never generates them.
18
+ * Only the 4 cron patterns the PWA UI produces are supported:
19
+ * hourly "0 * * * *", daily "MM HH * * *", weekly "MM HH * * D", monthly "MM HH D * *".
20
+ * Arbitrary expressions (ranges, lists, sub-hour steps) are not handled.
27
21
  */
28
22
  export function cronToOnCalendar(cron) {
29
23
  const parts = cron.trim().split(/\s+/);
@@ -31,7 +25,6 @@ export function cronToOnCalendar(cron) {
31
25
  throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
32
26
  }
33
27
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
34
- // Map cron day-of-week numbers to systemd abbreviated names
35
28
  const dowMap = {
36
29
  "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
37
30
  "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
@@ -59,8 +52,8 @@ export class LinuxPlatform {
59
52
  installDaemon(config) {
60
53
  fs.mkdirSync(UNIT_DIR, { recursive: true });
61
54
  const palmierBin = process.argv[1] || "palmier";
62
- // Save the user's shell PATH so restartDaemon can use it later
63
- // (the daemon itself runs under systemd with a limited PATH).
55
+ // Save the user's shell PATH so restartDaemon can reuse it later — under
56
+ // systemd the daemon itself runs with a limited PATH.
64
57
  const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
65
58
  fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
66
59
  fs.writeFileSync(PATH_FILE, userPath, "utf-8");
@@ -93,7 +86,7 @@ WantedBy=default.target
93
86
  console.error(`Warning: failed to enable systemd service: ${err}`);
94
87
  console.error("You may need to start it manually: systemctl --user enable --now palmier.service");
95
88
  }
96
- // Enable lingering so service runs without active login session
89
+ // Lingering lets the service run without an active login session.
97
90
  try {
98
91
  execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
99
92
  console.log("Login lingering enabled.");
@@ -109,13 +102,11 @@ WantedBy=default.target
109
102
  execSync("systemctl --user disable palmier.service 2>/dev/null", { stdio: "pipe" });
110
103
  }
111
104
  catch { /* service may not exist */ }
112
- // Remove daemon service file
113
105
  const servicePath = path.join(UNIT_DIR, "palmier.service");
114
106
  try {
115
107
  fs.unlinkSync(servicePath);
116
108
  }
117
109
  catch { /* ignore */ }
118
- // Remove all task timers and services
119
110
  try {
120
111
  const files = fs.readdirSync(UNIT_DIR).filter((f) => f.startsWith("palmier-task-"));
121
112
  for (const f of files) {
@@ -142,8 +133,8 @@ WantedBy=default.target
142
133
  console.log("Palmier daemon and tasks uninstalled.");
143
134
  }
144
135
  async restartDaemon() {
145
- // If called from a user's terminal, save the current PATH for future use.
146
- // If called from the daemon (auto-update), read the saved PATH instead.
136
+ // From a TTY, snapshot the current PATH; from the daemon (auto-update),
137
+ // reuse whatever was last saved.
147
138
  if (process.stdin.isTTY) {
148
139
  fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
149
140
  fs.writeFileSync(PATH_FILE, process.env.PATH || "", "utf-8");
@@ -181,7 +172,6 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
181
172
  `;
182
173
  fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
183
174
  daemonReload();
184
- // Only create and enable a timer if the schedule exists and is enabled
185
175
  if (!task.frontmatter.schedule_enabled)
186
176
  return;
187
177
  const scheduleType = task.frontmatter.schedule_type;
@@ -248,7 +238,6 @@ WantedBy=timers.target
248
238
  await execAsync(`systemctl --user stop ${serviceName}`);
249
239
  }
250
240
  isTaskRunning(taskId) {
251
- // Check systemd first (for scheduled/on-demand runs)
252
241
  const serviceName = getServiceName(taskId);
253
242
  try {
254
243
  const out = execSync(`systemctl --user show -p ActiveState --value ${serviceName}`, { encoding: "utf-8" });
@@ -257,7 +246,7 @@ WantedBy=timers.target
257
246
  return true;
258
247
  }
259
248
  catch { /* service may not exist */ }
260
- // Fall back to PID check (for follow-up runs spawned directly)
249
+ // Follow-up runs are spawned directly, so check PID too.
261
250
  try {
262
251
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
263
252
  const status = readTaskStatus(taskDir);
@@ -1,8 +1,5 @@
1
1
  import type { HostConfig, ParsedTask } from "../types.js";
2
- /**
3
- * Abstracts OS-specific daemon, scheduling, and process management.
4
- * Linux uses systemd; Windows uses Task Scheduler; macOS will use launchd.
5
- */
2
+ /** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
6
3
  export interface PlatformService {
7
4
  /** Install the main `palmier serve` daemon to start at boot. */
8
5
  installDaemon(config: HostConfig): void;
@@ -12,17 +12,14 @@ import type { HostConfig, ParsedTask } from "../types.js";
12
12
  * monthly: "MM HH D * *"
13
13
  */
14
14
  export declare function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string;
15
- /**
16
- * Build a complete Task Scheduler XML definition.
17
- */
18
15
  export declare function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string;
19
16
  export declare class WindowsPlatform implements PlatformService {
20
17
  installDaemon(config: HostConfig): void;
21
18
  uninstallDaemon(): void;
22
19
  restartDaemon(): Promise<void>;
23
- /** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
20
+ /** S4U LogonType requires elevation to create. */
24
21
  private ensureDaemonTask;
25
- /** Start the daemon via Task Scheduler (runs outside any session's job object). */
22
+ /** Starting via Task Scheduler runs the daemon outside any session's job object. */
26
23
  private startDaemonTask;
27
24
  installTaskTimer(config: HostConfig, task: ParsedTask): void;
28
25
  removeTaskTimer(taskId: string): void;
@@ -26,27 +26,20 @@ export function scheduleValueToXml(scheduleType, value) {
26
26
  throw new Error(`Invalid cron expression: ${value}`);
27
27
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
28
28
  const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
29
- // StartBoundary needs a full date; use a past date as the anchor
29
+ // StartBoundary needs a full date; anchor to a past one.
30
30
  const base = `2000-01-01T${st}`;
31
- // Hourly
32
31
  if (hour === "*") {
33
32
  return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
34
33
  }
35
- // Weekly
36
34
  if (dayOfMonth === "*" && dayOfWeek !== "*") {
37
35
  const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
38
36
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
39
37
  }
40
- // Monthly
41
38
  if (dayOfMonth !== "*" && dayOfWeek === "*") {
42
39
  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>`;
43
40
  }
44
- // Daily
45
41
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
46
42
  }
47
- /**
48
- * Build a complete Task Scheduler XML definition.
49
- */
50
43
  export function buildTaskXml(tr, triggers, foreground) {
51
44
  const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
52
45
  const commandStr = command?.replace(/"/g, "") ?? "";
@@ -82,20 +75,17 @@ function schtasksTaskName(taskId) {
82
75
  export class WindowsPlatform {
83
76
  installDaemon(config) {
84
77
  const script = process.argv[1] || "palmier";
85
- // Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
86
78
  this.ensureDaemonTask(script);
87
- // Start the daemon now
88
79
  this.startDaemonTask();
89
80
  console.log("\nHost initialization complete!");
90
81
  }
91
82
  uninstallDaemon() {
92
83
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
93
- // Stop the daemon via Task Scheduler
94
84
  try {
95
85
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
96
86
  }
97
87
  catch { /* task may not be running */ }
98
- // Remove daemon scheduled task (elevated — S4U task requires elevation to delete)
88
+ // Deleting an S4U task requires elevation.
99
89
  try {
100
90
  execFileSync("powershell", [
101
91
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
@@ -103,7 +93,6 @@ export class WindowsPlatform {
103
93
  console.log("Daemon task removed.");
104
94
  }
105
95
  catch { /* task may not exist */ }
106
- // Remove all Palmier task timers
107
96
  try {
108
97
  const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
109
98
  for (const line of out.split("\n")) {
@@ -126,15 +115,13 @@ export class WindowsPlatform {
126
115
  }
127
116
  async restartDaemon() {
128
117
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
129
- // Stop the daemon via Task Scheduler
130
118
  try {
131
119
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
132
120
  }
133
121
  catch { /* task may not be running */ }
134
- // Start it again
135
122
  this.startDaemonTask();
136
123
  }
137
- /** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
124
+ /** S4U LogonType requires elevation to create. */
138
125
  ensureDaemonTask(script) {
139
126
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
140
127
  const tr = `"${process.execPath}" "${script}" serve`;
@@ -143,7 +130,7 @@ export class WindowsPlatform {
143
130
  try {
144
131
  const bom = Buffer.from([0xFF, 0xFE]);
145
132
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
146
- // S4U LogonType requires elevation — spawn schtasks via RunAs
133
+ // S4U requires elevation — spawn schtasks via RunAs.
147
134
  const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
148
135
  execFileSync("powershell", [
149
136
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
@@ -160,7 +147,7 @@ export class WindowsPlatform {
160
147
  catch { /* ignore */ }
161
148
  }
162
149
  }
163
- /** Start the daemon via Task Scheduler (runs outside any session's job object). */
150
+ /** Starting via Task Scheduler runs the daemon outside any session's job object. */
164
151
  startDaemonTask() {
165
152
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
166
153
  try {
@@ -177,9 +164,8 @@ export class WindowsPlatform {
177
164
  const tn = schtasksTaskName(taskId);
178
165
  const script = process.argv[1] || "palmier";
179
166
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
180
- // Build trigger XML elements. Event-based schedule types (on_new_notification,
181
- // on_new_sms) carry no values and are driven by the run process, not the OS
182
- // scheduler — they intentionally produce only the dummy trigger below.
167
+ // Event-based schedule types (on_new_notification/on_new_sms) are driven by
168
+ // the run process, not the OS scheduler they fall through to the dummy trigger.
183
169
  const triggerElements = [];
184
170
  const scheduleType = task.frontmatter.schedule_type;
185
171
  const scheduleValues = task.frontmatter.schedule_values;
@@ -194,18 +180,18 @@ export class WindowsPlatform {
194
180
  }
195
181
  }
196
182
  }
197
- // Always include a dummy trigger so startTask (/run) works
183
+ // Dummy trigger so schtasks /run still works.
198
184
  if (triggerElements.length === 0) {
199
185
  triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
200
186
  }
201
- // Write XML and register via schtasks gives us full control over
202
- // settings like MultipleInstancesPolicy that schtasks flags don't expose.
203
- // S4U LogonType ensures no console window (unless foreground_mode is set).
204
- // Works without elevation because the daemon (which calls this) runs elevated.
187
+ // XML registration (vs schtasks flags) gives us access to settings like
188
+ // MultipleInstancesPolicy. S4U keeps the console hidden unless
189
+ // foreground_mode is set. Works unelevated because the caller (daemon)
190
+ // runs elevated.
205
191
  const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
206
192
  const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
207
193
  try {
208
- // schtasks /xml requires UTF-16LE with BOM
194
+ // schtasks /xml requires UTF-16LE with BOM.
209
195
  const bom = Buffer.from([0xFF, 0xFE]);
210
196
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
211
197
  execFileSync("schtasks", [
@@ -228,9 +214,7 @@ export class WindowsPlatform {
228
214
  try {
229
215
  execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
230
216
  }
231
- catch {
232
- // Task might not exist — that's fine
233
- }
217
+ catch { /* task may not exist */ }
234
218
  }
235
219
  async startTask(taskId) {
236
220
  const tn = schtasksTaskName(taskId);
@@ -243,8 +227,8 @@ export class WindowsPlatform {
243
227
  }
244
228
  }
245
229
  async stopTask(taskId) {
246
- // Try to kill the entire process tree via the PID recorded in status.json.
247
- // schtasks /end only kills the top-level process, leaving agent children orphaned.
230
+ // schtasks /end leaves agent children orphaned, so kill the process tree
231
+ // via the PID recorded in status.json first.
248
232
  try {
249
233
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
250
234
  const status = readTaskStatus(taskDir);
@@ -254,9 +238,8 @@ export class WindowsPlatform {
254
238
  }
255
239
  }
256
240
  catch {
257
- // PID may be stale or config unavailable; fall through to schtasks /end
241
+ // PID may be stale or config unavailable; fall through to schtasks /end.
258
242
  }
259
- // Fallback: schtasks /end (kills top-level process only)
260
243
  const tn = schtasksTaskName(taskId);
261
244
  try {
262
245
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -267,7 +250,6 @@ export class WindowsPlatform {
267
250
  }
268
251
  }
269
252
  isTaskRunning(taskId) {
270
- // Check Task Scheduler first (for scheduled/on-demand runs)
271
253
  const tn = schtasksTaskName(taskId);
272
254
  try {
273
255
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
@@ -278,18 +260,17 @@ export class WindowsPlatform {
278
260
  return true;
279
261
  }
280
262
  catch { /* task may not exist in scheduler */ }
281
- // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
263
+ // Follow-up runs are spawned directly (not via schtasks), so check PID too.
282
264
  try {
283
265
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
284
266
  const status = readTaskStatus(taskDir);
285
267
  if (status?.pid) {
286
- // tasklist exits 0 if the PID is found
287
268
  execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
288
269
  encoding: "utf-8",
289
270
  windowsHide: true,
290
271
  stdio: "pipe",
291
272
  });
292
- // tasklist always exits 0; check if output contains the PID
273
+ // tasklist always exits 0, so match the output for the PID.
293
274
  const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
294
275
  encoding: "utf-8",
295
276
  windowsHide: true,
@@ -303,7 +284,6 @@ export class WindowsPlatform {
303
284
  return false;
304
285
  }
305
286
  getGuiEnv() {
306
- // Windows GUI is always available — no special env vars needed
307
287
  return {};
308
288
  }
309
289
  }