palmier 0.8.1 → 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 +12 -16
- 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-CQxcuDhM.css → index-B0F9mtid.css} +1 -1
- package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
- package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-C6lkQj9J.js} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.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 +18 -47
- 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/pwa/src/App.css +165 -20
- package/palmier-server/pwa/src/components/HostMenu.tsx +159 -49
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionsView.tsx +57 -31
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskForm.tsx +152 -2
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +20 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +38 -7
- 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 +3 -2
- 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 +12 -18
- 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 +19 -47
- 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-DQfOEB03.js +0 -120
package/src/commands/serve.ts
CHANGED
|
@@ -16,16 +16,14 @@ import { StringCodec, type NatsConnection } from "nats";
|
|
|
16
16
|
import { addNotification } from "../notification-store.js";
|
|
17
17
|
import { addSmsMessage } from "../sms-store.js";
|
|
18
18
|
import { enqueueEvent } from "../event-queues.js";
|
|
19
|
+
import { recordApp } from "../app-registry.js";
|
|
19
20
|
|
|
20
21
|
const POLL_INTERVAL_MS = 30_000;
|
|
21
22
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Since run.ts creates the RESULT file and history entry at start, we just need to
|
|
28
|
-
* finalize the existing RESULT file, append a failed status entry, and broadcast.
|
|
25
|
+
* Reconcile tasks stuck in "started" whose process is no longer alive.
|
|
26
|
+
* The system scheduler (Task Scheduler / systemd) is the authoritative source.
|
|
29
27
|
*/
|
|
30
28
|
async function checkStaleTasks(
|
|
31
29
|
config: HostConfig,
|
|
@@ -46,14 +44,12 @@ async function checkStaleTasks(
|
|
|
46
44
|
const status = readTaskStatus(taskDir);
|
|
47
45
|
if (!status || status.running_state !== "started") continue;
|
|
48
46
|
|
|
49
|
-
// Ask the system scheduler if the task is still running
|
|
50
47
|
if (platform.isTaskRunning(taskId)) continue;
|
|
51
48
|
|
|
52
49
|
console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
|
|
53
50
|
const endTime = Date.now();
|
|
54
51
|
writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
|
|
55
52
|
|
|
56
|
-
// Find the latest run directory (created by run.ts at start)
|
|
57
53
|
const runId = fs.readdirSync(taskDir)
|
|
58
54
|
.filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
|
|
59
55
|
.sort()
|
|
@@ -71,7 +67,7 @@ async function checkStaleTasks(
|
|
|
71
67
|
let taskName = taskId;
|
|
72
68
|
try {
|
|
73
69
|
taskName = parseTaskFile(taskDir).frontmatter.name || taskId;
|
|
74
|
-
} catch { /*
|
|
70
|
+
} catch { /* fallback to taskId */ }
|
|
75
71
|
|
|
76
72
|
await publishHostEvent(nc, config.hostId, taskId, {
|
|
77
73
|
event_type: "running-state",
|
|
@@ -81,18 +77,14 @@ async function checkStaleTasks(
|
|
|
81
77
|
}
|
|
82
78
|
}
|
|
83
79
|
|
|
84
|
-
/**
|
|
85
|
-
* Start the persistent RPC handler (NATS + HTTP).
|
|
86
|
-
*/
|
|
87
80
|
export async function serveCommand(): Promise<void> {
|
|
88
81
|
const config = loadConfig();
|
|
89
82
|
|
|
90
|
-
//
|
|
83
|
+
// PID file lets `palmier restart` find us regardless of how we were started
|
|
91
84
|
fs.writeFileSync(DAEMON_PID_FILE, String(process.pid), "utf-8");
|
|
92
85
|
|
|
93
86
|
console.log("Starting...");
|
|
94
87
|
|
|
95
|
-
// Re-detect agents on every daemon start
|
|
96
88
|
const agents = await detectAgents();
|
|
97
89
|
config.agents = agents;
|
|
98
90
|
saveConfig(config);
|
|
@@ -106,10 +98,9 @@ export async function serveCommand(): Promise<void> {
|
|
|
106
98
|
console.warn(`[nats] Connection failed (server mode unavailable): ${err}`);
|
|
107
99
|
}
|
|
108
100
|
|
|
109
|
-
// Reconcile any tasks stuck from before daemon started
|
|
110
101
|
await checkStaleTasks(config, nc);
|
|
111
102
|
|
|
112
|
-
//
|
|
103
|
+
// Reinstall scheduler entries for all tasks (recovery after init/reinstall)
|
|
113
104
|
const platform = getPlatform();
|
|
114
105
|
const allTasks = listTasks(config.projectRoot);
|
|
115
106
|
for (const task of allTasks) {
|
|
@@ -120,7 +111,6 @@ export async function serveCommand(): Promise<void> {
|
|
|
120
111
|
}
|
|
121
112
|
}
|
|
122
113
|
|
|
123
|
-
// Poll for crashed tasks every 30 seconds
|
|
124
114
|
setInterval(() => {
|
|
125
115
|
checkStaleTasks(config, nc).catch((err) => {
|
|
126
116
|
console.error("[monitor] Error checking stale tasks:", err);
|
|
@@ -130,18 +120,29 @@ export async function serveCommand(): Promise<void> {
|
|
|
130
120
|
const handleRpc = createRpcHandler(config, nc);
|
|
131
121
|
const httpPort = config.httpPort ?? 7256;
|
|
132
122
|
|
|
133
|
-
// Start NATS transport (loops forever, fire-and-forget)
|
|
134
123
|
if (nc) {
|
|
135
124
|
startNatsTransport(config, handleRpc, nc);
|
|
136
125
|
|
|
137
|
-
// Subscribe to device notifications and SMS from Android
|
|
138
126
|
const sc = StringCodec();
|
|
139
127
|
|
|
140
|
-
//
|
|
141
|
-
function
|
|
128
|
+
// Match phone numbers regardless of formatting; letters preserved for shortcodes.
|
|
129
|
+
function normalizeSender(raw: string): string {
|
|
130
|
+
return raw.replace(/[\s\-()+]/g, "").toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string, parsed?: unknown): void {
|
|
142
134
|
for (const task of listTasks(config.projectRoot)) {
|
|
143
135
|
if (task.frontmatter.schedule_type !== scheduleType) continue;
|
|
144
136
|
if (!task.frontmatter.schedule_enabled) continue;
|
|
137
|
+
if (scheduleType === "on_new_notification" && task.frontmatter.schedule_values && task.frontmatter.schedule_values.length > 0) {
|
|
138
|
+
const pkg = (parsed as { packageName?: string } | undefined)?.packageName;
|
|
139
|
+
if (!pkg || !task.frontmatter.schedule_values.includes(pkg)) continue;
|
|
140
|
+
}
|
|
141
|
+
if (scheduleType === "on_new_sms" && task.frontmatter.schedule_values && task.frontmatter.schedule_values.length > 0) {
|
|
142
|
+
const sender = (parsed as { sender?: string } | undefined)?.sender;
|
|
143
|
+
const normalizedSender = sender ? normalizeSender(sender) : "";
|
|
144
|
+
if (!normalizedSender || !task.frontmatter.schedule_values.some((s) => normalizeSender(s) === normalizedSender)) continue;
|
|
145
|
+
}
|
|
145
146
|
const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
|
|
146
147
|
if (shouldStart) {
|
|
147
148
|
platform.startTask(task.frontmatter.id).catch((err) => {
|
|
@@ -155,13 +156,16 @@ export async function serveCommand(): Promise<void> {
|
|
|
155
156
|
(async () => {
|
|
156
157
|
for await (const msg of notifSub) {
|
|
157
158
|
const raw = sc.decode(msg.data);
|
|
159
|
+
let parsed: unknown;
|
|
158
160
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
parsed = JSON.parse(raw);
|
|
162
|
+
const data = parsed as { packageName?: string; appName?: string };
|
|
163
|
+
addNotification({ ...(parsed as object), receivedAt: Date.now() } as Parameters<typeof addNotification>[0]);
|
|
164
|
+
if (data.packageName && data.appName) recordApp(data.packageName, data.appName);
|
|
161
165
|
} catch (err) {
|
|
162
166
|
console.error("[nats] Failed to parse device notification:", err);
|
|
163
167
|
}
|
|
164
|
-
dispatchDeviceEvent("on_new_notification", raw);
|
|
168
|
+
dispatchDeviceEvent("on_new_notification", raw, parsed);
|
|
165
169
|
}
|
|
166
170
|
})();
|
|
167
171
|
|
|
@@ -169,17 +173,17 @@ export async function serveCommand(): Promise<void> {
|
|
|
169
173
|
(async () => {
|
|
170
174
|
for await (const msg of smsSub) {
|
|
171
175
|
const raw = sc.decode(msg.data);
|
|
176
|
+
let parsed: unknown;
|
|
172
177
|
try {
|
|
173
|
-
|
|
174
|
-
addSmsMessage({ ...
|
|
178
|
+
parsed = JSON.parse(raw);
|
|
179
|
+
addSmsMessage({ ...(parsed as object), receivedAt: Date.now() } as Parameters<typeof addSmsMessage>[0]);
|
|
175
180
|
} catch (err) {
|
|
176
181
|
console.error("[nats] Failed to parse device SMS:", err);
|
|
177
182
|
}
|
|
178
|
-
dispatchDeviceEvent("on_new_sms", raw);
|
|
183
|
+
dispatchDeviceEvent("on_new_sms", raw, parsed);
|
|
179
184
|
}
|
|
180
185
|
})();
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
// Start HTTP transport (loops forever)
|
|
184
188
|
await startHttpTransport(config, handleRpc, httpPort, nc);
|
|
185
189
|
}
|
package/src/config.ts
CHANGED
|
@@ -6,10 +6,6 @@ import type { HostConfig } from "./types.js";
|
|
|
6
6
|
const CONFIG_DIR = path.join(homedir(), ".config", "palmier");
|
|
7
7
|
const CONFIG_FILE = path.join(CONFIG_DIR, "host.json");
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Load host configuration from ~/.config/palmier/host.json.
|
|
11
|
-
* Throws if the file is missing or invalid.
|
|
12
|
-
*/
|
|
13
9
|
export function loadConfig(): HostConfig {
|
|
14
10
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
15
11
|
throw new Error(
|
|
@@ -32,10 +28,6 @@ export function loadConfig(): HostConfig {
|
|
|
32
28
|
return config;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
/**
|
|
36
|
-
* Persist host configuration to ~/.config/palmier/host.json.
|
|
37
|
-
* Creates parent directories if needed.
|
|
38
|
-
*/
|
|
39
31
|
export function saveConfig(config: HostConfig): void {
|
|
40
32
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
41
33
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
@@ -12,10 +12,11 @@ export interface RegisteredDevice {
|
|
|
12
12
|
export type DeviceCapability =
|
|
13
13
|
| "location"
|
|
14
14
|
| "notifications"
|
|
15
|
-
| "sms"
|
|
15
|
+
| "sms-read"
|
|
16
|
+
| "sms-send"
|
|
16
17
|
| "contacts"
|
|
17
18
|
| "calendar"
|
|
18
|
-
| "
|
|
19
|
+
| "alarm"
|
|
19
20
|
| "battery"
|
|
20
21
|
| "send-email"
|
|
21
22
|
| "dnd";
|
package/src/event-queues.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-task in-memory event queues for
|
|
3
|
-
* (schedule_type: "on_new_notification" | "on_new_sms").
|
|
4
|
-
*
|
|
2
|
+
* Per-task in-memory event queues for on_new_notification / on_new_sms schedules.
|
|
5
3
|
* The daemon owns the NATS subscription and populates these queues; the
|
|
6
|
-
* `palmier run` process drains
|
|
7
|
-
* endpoint. `activeRuns` tracks whether a run process is currently draining,
|
|
8
|
-
* so we don't race a fresh startTask with a teardown-phase run.
|
|
4
|
+
* `palmier run` process drains via /task-event/pop.
|
|
9
5
|
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - enqueueEvent returns shouldStart=true only if the task transitioned
|
|
15
|
-
* from idle (no active run) to active — callers must then startTask.
|
|
6
|
+
* Invariants:
|
|
7
|
+
* - popEvent clears activeRuns atomically when the queue empties, so a
|
|
8
|
+
* fresh startTask cannot race the tearing-down run.
|
|
9
|
+
* - enqueueEvent returns shouldStart=true only on the idle→active edge.
|
|
16
10
|
*/
|
|
17
11
|
|
|
18
12
|
const MAX_QUEUE_SIZE = 100;
|
|
@@ -20,10 +14,6 @@ const MAX_QUEUE_SIZE = 100;
|
|
|
20
14
|
const queues = new Map<string, string[]>();
|
|
21
15
|
const activeRuns = new Set<string>();
|
|
22
16
|
|
|
23
|
-
/**
|
|
24
|
-
* Queue a raw (JSON-string) event payload for a task. Returns whether the
|
|
25
|
-
* caller should now start the run process.
|
|
26
|
-
*/
|
|
27
17
|
export function enqueueEvent(taskId: string, payload: string): { shouldStart: boolean } {
|
|
28
18
|
const queue = queues.get(taskId) ?? [];
|
|
29
19
|
if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
|
|
@@ -35,11 +25,6 @@ export function enqueueEvent(taskId: string, payload: string): { shouldStart: bo
|
|
|
35
25
|
return { shouldStart: true };
|
|
36
26
|
}
|
|
37
27
|
|
|
38
|
-
/**
|
|
39
|
-
* Pop the oldest queued event for a task. Returns `{ event }` when one is
|
|
40
|
-
* available (keeps the task marked active), or `{ empty: true }` after
|
|
41
|
-
* clearing the active flag atomically.
|
|
42
|
-
*/
|
|
43
28
|
export function popEvent(taskId: string): { event: string } | { empty: true } {
|
|
44
29
|
const queue = queues.get(taskId);
|
|
45
30
|
if (queue && queue.length > 0) {
|
package/src/events.ts
CHANGED
|
@@ -3,12 +3,6 @@ import { loadConfig } from "./config.js";
|
|
|
3
3
|
|
|
4
4
|
const sc = StringCodec();
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Broadcast an event to connected clients via NATS and HTTP SSE.
|
|
8
|
-
*
|
|
9
|
-
* - NATS: publishes to `host-event.{hostId}.{taskId}`
|
|
10
|
-
* - HTTP: POSTs to the serve daemon's `/event` endpoint
|
|
11
|
-
*/
|
|
12
6
|
export async function publishHostEvent(
|
|
13
7
|
nc: NatsConnection | undefined,
|
|
14
8
|
hostId: string,
|
|
@@ -31,7 +25,5 @@ export async function publishHostEvent(
|
|
|
31
25
|
body: JSON.stringify({ task_id: taskId, ...payload }),
|
|
32
26
|
});
|
|
33
27
|
console.log(`[http] host-event: ${taskId} →`, payload);
|
|
34
|
-
} catch {
|
|
35
|
-
// Serve HTTP may not be ready yet — ignore
|
|
36
|
-
}
|
|
28
|
+
} catch { /* serve HTTP may not be ready yet */ }
|
|
37
29
|
}
|
package/src/index.ts
CHANGED
package/src/mcp-handler.ts
CHANGED
|
@@ -15,14 +15,13 @@ export interface McpResponse {
|
|
|
15
15
|
stream?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** sessionId → subscribed resource URIs */
|
|
19
19
|
const resourceSubscriptions = new Map<string, Set<string>>();
|
|
20
20
|
|
|
21
21
|
export function getResourceSubscriptions(): Map<string, Set<string>> {
|
|
22
22
|
return resourceSubscriptions;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// Session-to-agent name map with 24h TTL
|
|
26
25
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
26
|
const sessionAgents = new Map<string, { agentName: string; expiresAt: number }>();
|
|
28
27
|
|
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));
|
|
@@ -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;
|