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.
- package/CLAUDE.md +13 -0
- package/README.md +11 -11
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/app-registry.d.ts +10 -0
- package/dist/app-registry.js +44 -0
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +1 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +33 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +14 -18
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +1 -4
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/index-B0F9mtid.css +1 -0
- package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
- package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
- package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.d.ts +0 -6
- package/dist/rpc-handler.js +19 -48
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +6 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/App.css +170 -20
- package/palmier-server/pwa/src/App.tsx +15 -1
- package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
- package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
- package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +66 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
- package/palmier-server/pwa/src/types.ts +1 -1
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +47 -6
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/app-registry.ts +52 -0
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +1 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +31 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +4 -3
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +14 -20
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +1 -4
- package/src/platform/linux.ts +9 -20
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +20 -48
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +6 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
- 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
|
|
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
|
|
506
|
-
name: "send-
|
|
505
|
+
const sendAlarmTool: ToolDefinition = {
|
|
506
|
+
name: "send-alarm",
|
|
507
507
|
description: [
|
|
508
|
-
"
|
|
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: "
|
|
517
|
-
description: { type: "string", description: "
|
|
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("
|
|
525
|
-
if (!device) throw new ToolError("No device has
|
|
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.
|
|
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}.
|
|
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,
|
|
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] = ["..."];
|
package/src/nats-client.ts
CHANGED
|
@@ -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
|
|
17
|
+
// Do not log — it would pollute stdout for the MCP server.
|
|
21
18
|
return nc;
|
|
22
19
|
}
|
package/src/pending-requests.ts
CHANGED
|
@@ -22,10 +22,9 @@ export interface PendingRequest {
|
|
|
22
22
|
const pending = new Map<string, PendingRequest>();
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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"];
|
package/src/platform/index.ts
CHANGED
|
@@ -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;
|
package/src/platform/linux.ts
CHANGED
|
@@ -22,15 +22,9 @@ function getServiceName(taskId: string): string {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
77
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
152
|
-
//
|
|
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
|
-
//
|
|
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);
|
package/src/platform/platform.ts
CHANGED
|
@@ -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;
|
package/src/platform/windows.ts
CHANGED
|
@@ -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;
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
196
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
-
//
|
|
259
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|