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/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
|
|
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
|
|
437
|
-
name: "send-
|
|
436
|
+
const sendAlarmTool = {
|
|
437
|
+
name: "send-alarm",
|
|
438
438
|
description: [
|
|
439
|
-
"
|
|
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: "
|
|
448
|
-
description: { type: "string", description: "
|
|
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("
|
|
455
|
+
const device = getCapabilityDevice("alarm");
|
|
456
456
|
if (!device)
|
|
457
|
-
throw new ToolError("No device has
|
|
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.
|
|
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}.
|
|
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,
|
|
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")
|
package/dist/nats-client.d.ts
CHANGED
|
@@ -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
|
package/dist/nats-client.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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"];
|
package/dist/pending-requests.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
const pending = new Map();
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
package/dist/platform/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/platform/index.js
CHANGED
|
@@ -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() {
|
package/dist/platform/linux.d.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import type { PlatformService } from "./platform.js";
|
|
2
2
|
import type { HostConfig, ParsedTask } from "../types.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
package/dist/platform/linux.js
CHANGED
|
@@ -15,15 +15,9 @@ function getServiceName(taskId) {
|
|
|
15
15
|
return `palmier-task-${taskId}.service`;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
63
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
146
|
-
//
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
20
|
+
/** S4U LogonType requires elevation to create. */
|
|
24
21
|
private ensureDaemonTask;
|
|
25
|
-
/**
|
|
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;
|
package/dist/platform/windows.js
CHANGED
|
@@ -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;
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
181
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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
|
-
//
|
|
247
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|