palmier 0.7.9 → 0.8.0
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/dist/commands/run.js +55 -0
- package/dist/commands/serve.js +22 -2
- package/dist/event-queues.d.ts +36 -0
- package/dist/event-queues.js +53 -0
- package/dist/platform/windows.js +5 -2
- package/dist/rpc-handler.js +5 -4
- package/dist/transports/http-transport.js +15 -0
- package/dist/types.d.ts +6 -5
- package/package.json +1 -1
- package/src/commands/run.ts +61 -0
- package/src/commands/serve.ts +22 -2
- package/src/event-queues.ts +56 -0
- package/src/platform/windows.ts +5 -2
- package/src/rpc-handler.ts +7 -6
- package/src/transports/http-transport.ts +14 -0
- package/src/types.ts +6 -5
package/dist/commands/run.js
CHANGED
|
@@ -209,6 +209,15 @@ export async function runCommand(taskId) {
|
|
|
209
209
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
210
210
|
console.log(`Task ${taskId} completed (command-triggered).`);
|
|
211
211
|
}
|
|
212
|
+
else if (task.frontmatter.schedule_type === "on_new_notification"
|
|
213
|
+
|| task.frontmatter.schedule_type === "on_new_sms") {
|
|
214
|
+
// Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
|
|
215
|
+
const result = await runEventTriggeredMode(ctx);
|
|
216
|
+
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
217
|
+
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
218
|
+
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
219
|
+
console.log(`Task ${taskId} completed (event-triggered).`);
|
|
220
|
+
}
|
|
212
221
|
else {
|
|
213
222
|
// Standard execution — add user prompt as first message
|
|
214
223
|
await appendAndNotify(ctx, {
|
|
@@ -380,6 +389,52 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
380
389
|
}
|
|
381
390
|
return { outcome: "finished", endTime };
|
|
382
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Event-triggered execution mode.
|
|
394
|
+
*
|
|
395
|
+
* Drains the daemon-owned per-task event queue via the local /task-event/pop
|
|
396
|
+
* HTTP endpoint, invoking the agent once per event with the payload spliced
|
|
397
|
+
* into the user prompt. The run process itself holds no NATS subscription;
|
|
398
|
+
* the daemon handles that and atomically clears the active flag when we see
|
|
399
|
+
* an empty pop, so it can fire up a fresh run on the next incoming event.
|
|
400
|
+
*/
|
|
401
|
+
async function runEventTriggeredMode(ctx) {
|
|
402
|
+
const scheduleType = ctx.task.frontmatter.schedule_type;
|
|
403
|
+
const label = scheduleType === "on_new_notification" ? "notification" : "SMS";
|
|
404
|
+
const port = ctx.config.httpPort ?? 7256;
|
|
405
|
+
const popUrl = `http://localhost:${port}/task-event/pop?taskId=${encodeURIComponent(ctx.taskId)}`;
|
|
406
|
+
console.log(`[event-triggered] Draining ${label} queue`);
|
|
407
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
408
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
409
|
+
let eventsProcessed = 0;
|
|
410
|
+
try {
|
|
411
|
+
// eslint-disable-next-line no-constant-condition
|
|
412
|
+
while (true) {
|
|
413
|
+
const res = await fetch(popUrl, { method: "POST" });
|
|
414
|
+
if (!res.ok)
|
|
415
|
+
throw new Error(`pop-event failed: ${res.status} ${res.statusText}`);
|
|
416
|
+
const body = await res.json();
|
|
417
|
+
if (body.empty || !body.event)
|
|
418
|
+
break;
|
|
419
|
+
eventsProcessed++;
|
|
420
|
+
console.log(`[event-triggered] Processing ${label} #${eventsProcessed}`);
|
|
421
|
+
const perEventPrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
|
|
422
|
+
const perEventTask = {
|
|
423
|
+
frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
|
|
424
|
+
};
|
|
425
|
+
await invokeAgentWithRetries(ctx, perEventTask);
|
|
426
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
427
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
432
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: errorMsg, type: "error" });
|
|
433
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
434
|
+
return { outcome: "failed", endTime: Date.now() };
|
|
435
|
+
}
|
|
436
|
+
return { outcome: "finished", endTime: Date.now() };
|
|
437
|
+
}
|
|
383
438
|
async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
|
|
384
439
|
writeTaskStatus(taskDir, {
|
|
385
440
|
running_state: eventType,
|
package/dist/commands/serve.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CONFIG_DIR } from "../config.js";
|
|
|
14
14
|
import { StringCodec } from "nats";
|
|
15
15
|
import { addNotification } from "../notification-store.js";
|
|
16
16
|
import { addSmsMessage } from "../sms-store.js";
|
|
17
|
+
import { enqueueEvent } from "../event-queues.js";
|
|
17
18
|
const POLL_INTERVAL_MS = 30_000;
|
|
18
19
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
19
20
|
/**
|
|
@@ -119,28 +120,47 @@ export async function serveCommand() {
|
|
|
119
120
|
startNatsTransport(config, handleRpc, nc);
|
|
120
121
|
// Subscribe to device notifications and SMS from Android
|
|
121
122
|
const sc = StringCodec();
|
|
123
|
+
// Dispatch a raw event payload to every task whose schedule matches.
|
|
124
|
+
function dispatchDeviceEvent(scheduleType, payload) {
|
|
125
|
+
for (const task of listTasks(config.projectRoot)) {
|
|
126
|
+
if (task.frontmatter.schedule_type !== scheduleType)
|
|
127
|
+
continue;
|
|
128
|
+
if (!task.frontmatter.schedule_enabled)
|
|
129
|
+
continue;
|
|
130
|
+
const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
|
|
131
|
+
if (shouldStart) {
|
|
132
|
+
platform.startTask(task.frontmatter.id).catch((err) => {
|
|
133
|
+
console.error(`[event-trigger] Failed to start ${task.frontmatter.id}:`, err);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
122
138
|
const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
|
|
123
139
|
(async () => {
|
|
124
140
|
for await (const msg of notifSub) {
|
|
141
|
+
const raw = sc.decode(msg.data);
|
|
125
142
|
try {
|
|
126
|
-
const data = JSON.parse(
|
|
143
|
+
const data = JSON.parse(raw);
|
|
127
144
|
addNotification({ ...data, receivedAt: Date.now() });
|
|
128
145
|
}
|
|
129
146
|
catch (err) {
|
|
130
147
|
console.error("[nats] Failed to parse device notification:", err);
|
|
131
148
|
}
|
|
149
|
+
dispatchDeviceEvent("on_new_notification", raw);
|
|
132
150
|
}
|
|
133
151
|
})();
|
|
134
152
|
const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
|
|
135
153
|
(async () => {
|
|
136
154
|
for await (const msg of smsSub) {
|
|
155
|
+
const raw = sc.decode(msg.data);
|
|
137
156
|
try {
|
|
138
|
-
const data = JSON.parse(
|
|
157
|
+
const data = JSON.parse(raw);
|
|
139
158
|
addSmsMessage({ ...data, receivedAt: Date.now() });
|
|
140
159
|
}
|
|
141
160
|
catch (err) {
|
|
142
161
|
console.error("[nats] Failed to parse device SMS:", err);
|
|
143
162
|
}
|
|
163
|
+
dispatchDeviceEvent("on_new_sms", raw);
|
|
144
164
|
}
|
|
145
165
|
})();
|
|
146
166
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task in-memory event queues for event-triggered schedules
|
|
3
|
+
* (schedule_type: "on_new_notification" | "on_new_sms").
|
|
4
|
+
*
|
|
5
|
+
* The daemon owns the NATS subscription and populates these queues; the
|
|
6
|
+
* `palmier run` process drains them via the localhost /task-event/pop HTTP
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle invariants:
|
|
11
|
+
* - activeRuns is cleared atomically inside popEvent when the queue is
|
|
12
|
+
* drained. At that point the calling run has already finished its last
|
|
13
|
+
* agent invocation and is only tearing down.
|
|
14
|
+
* - enqueueEvent returns shouldStart=true only if the task transitioned
|
|
15
|
+
* from idle (no active run) to active — callers must then startTask.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Queue a raw (JSON-string) event payload for a task. Returns whether the
|
|
19
|
+
* caller should now start the run process.
|
|
20
|
+
*/
|
|
21
|
+
export declare function enqueueEvent(taskId: string, payload: string): {
|
|
22
|
+
shouldStart: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Pop the oldest queued event for a task. Returns `{ event }` when one is
|
|
26
|
+
* available (keeps the task marked active), or `{ empty: true }` after
|
|
27
|
+
* clearing the active flag atomically.
|
|
28
|
+
*/
|
|
29
|
+
export declare function popEvent(taskId: string): {
|
|
30
|
+
event: string;
|
|
31
|
+
} | {
|
|
32
|
+
empty: true;
|
|
33
|
+
};
|
|
34
|
+
/** Remove any state for a task (called from task.delete). */
|
|
35
|
+
export declare function clearTaskQueue(taskId: string): void;
|
|
36
|
+
//# sourceMappingURL=event-queues.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task in-memory event queues for event-triggered schedules
|
|
3
|
+
* (schedule_type: "on_new_notification" | "on_new_sms").
|
|
4
|
+
*
|
|
5
|
+
* The daemon owns the NATS subscription and populates these queues; the
|
|
6
|
+
* `palmier run` process drains them via the localhost /task-event/pop HTTP
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle invariants:
|
|
11
|
+
* - activeRuns is cleared atomically inside popEvent when the queue is
|
|
12
|
+
* drained. At that point the calling run has already finished its last
|
|
13
|
+
* agent invocation and is only tearing down.
|
|
14
|
+
* - enqueueEvent returns shouldStart=true only if the task transitioned
|
|
15
|
+
* from idle (no active run) to active — callers must then startTask.
|
|
16
|
+
*/
|
|
17
|
+
const MAX_QUEUE_SIZE = 100;
|
|
18
|
+
const queues = new Map();
|
|
19
|
+
const activeRuns = new Set();
|
|
20
|
+
/**
|
|
21
|
+
* Queue a raw (JSON-string) event payload for a task. Returns whether the
|
|
22
|
+
* caller should now start the run process.
|
|
23
|
+
*/
|
|
24
|
+
export function enqueueEvent(taskId, payload) {
|
|
25
|
+
const queue = queues.get(taskId) ?? [];
|
|
26
|
+
if (queue.length >= MAX_QUEUE_SIZE)
|
|
27
|
+
queue.shift();
|
|
28
|
+
queue.push(payload);
|
|
29
|
+
queues.set(taskId, queue);
|
|
30
|
+
if (activeRuns.has(taskId))
|
|
31
|
+
return { shouldStart: false };
|
|
32
|
+
activeRuns.add(taskId);
|
|
33
|
+
return { shouldStart: true };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pop the oldest queued event for a task. Returns `{ event }` when one is
|
|
37
|
+
* available (keeps the task marked active), or `{ empty: true }` after
|
|
38
|
+
* clearing the active flag atomically.
|
|
39
|
+
*/
|
|
40
|
+
export function popEvent(taskId) {
|
|
41
|
+
const queue = queues.get(taskId);
|
|
42
|
+
if (queue && queue.length > 0) {
|
|
43
|
+
return { event: queue.shift() };
|
|
44
|
+
}
|
|
45
|
+
activeRuns.delete(taskId);
|
|
46
|
+
return { empty: true };
|
|
47
|
+
}
|
|
48
|
+
/** Remove any state for a task (called from task.delete). */
|
|
49
|
+
export function clearTaskQueue(taskId) {
|
|
50
|
+
queues.delete(taskId);
|
|
51
|
+
activeRuns.delete(taskId);
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=event-queues.js.map
|
package/dist/platform/windows.js
CHANGED
|
@@ -177,11 +177,14 @@ export class WindowsPlatform {
|
|
|
177
177
|
const tn = schtasksTaskName(taskId);
|
|
178
178
|
const script = process.argv[1] || "palmier";
|
|
179
179
|
const tr = `"${process.execPath}" "${script}" run ${taskId}`;
|
|
180
|
-
// Build trigger XML elements
|
|
180
|
+
// Build trigger XML elements. Event-based schedule types (on_new_notification,
|
|
181
|
+
// on_new_sms) carry no values and are driven by the run process, not the OS
|
|
182
|
+
// scheduler — they intentionally produce only the dummy trigger below.
|
|
181
183
|
const triggerElements = [];
|
|
182
184
|
const scheduleType = task.frontmatter.schedule_type;
|
|
183
185
|
const scheduleValues = task.frontmatter.schedule_values;
|
|
184
|
-
|
|
186
|
+
const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
|
|
187
|
+
if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
|
|
185
188
|
for (const value of scheduleValues) {
|
|
186
189
|
try {
|
|
187
190
|
triggerElements.push(scheduleValueToXml(scheduleType, value));
|
package/dist/rpc-handler.js
CHANGED
|
@@ -13,6 +13,7 @@ import { publishHostEvent } from "./events.js";
|
|
|
13
13
|
import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice } from "./device-capabilities.js";
|
|
14
14
|
import { currentVersion, performUpdate } from "./update-checker.js";
|
|
15
15
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
16
|
+
import { clearTaskQueue } from "./event-queues.js";
|
|
16
17
|
/**
|
|
17
18
|
* Parse RESULT frontmatter and conversation messages.
|
|
18
19
|
*/
|
|
@@ -142,7 +143,7 @@ export function createRpcHandler(config, nc) {
|
|
|
142
143
|
// is active. Includes any prompts already waiting so a reconnecting
|
|
143
144
|
// PWA can render their modals without replaying events.
|
|
144
145
|
const capabilities = {};
|
|
145
|
-
for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd"]) {
|
|
146
|
+
for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "email"]) {
|
|
146
147
|
capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
|
|
147
148
|
}
|
|
148
149
|
return {
|
|
@@ -183,9 +184,8 @@ export function createRpcHandler(config, nc) {
|
|
|
183
184
|
agent: params.agent,
|
|
184
185
|
schedule_enabled: params.schedule_enabled ?? true,
|
|
185
186
|
requires_confirmation: params.requires_confirmation ?? true,
|
|
186
|
-
...(params.schedule_type
|
|
187
|
-
|
|
188
|
-
: {}),
|
|
187
|
+
...(params.schedule_type ? { schedule_type: params.schedule_type } : {}),
|
|
188
|
+
...(params.schedule_values?.length ? { schedule_values: params.schedule_values } : {}),
|
|
189
189
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
190
190
|
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
191
191
|
...(params.command ? { command: params.command } : {}),
|
|
@@ -258,6 +258,7 @@ export function createRpcHandler(config, nc) {
|
|
|
258
258
|
case "task.delete": {
|
|
259
259
|
const params = request.params;
|
|
260
260
|
getPlatform().removeTaskTimer(params.id);
|
|
261
|
+
clearTaskQueue(params.id);
|
|
261
262
|
removeFromTaskList(config.projectRoot, params.id);
|
|
262
263
|
return { ok: true, task_id: params.id };
|
|
263
264
|
}
|
|
@@ -8,6 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import { agentToolMap, agentResources, ToolError } from "../mcp-tools.js";
|
|
9
9
|
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
10
10
|
import { getTaskDir } from "../task.js";
|
|
11
|
+
import { popEvent } from "../event-queues.js";
|
|
11
12
|
const assetCache = new Map();
|
|
12
13
|
const PWA_DIR = path.join(import.meta.dirname, "..", "pwa");
|
|
13
14
|
const CONTENT_TYPES = {
|
|
@@ -247,6 +248,20 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
247
248
|
sendJson(res, 200, result);
|
|
248
249
|
return;
|
|
249
250
|
}
|
|
251
|
+
// ── Event queue pop (used by event-triggered palmier run) ─────────
|
|
252
|
+
if (req.method === "POST" && pathname === "/task-event/pop") {
|
|
253
|
+
if (!isLocalhost(req)) {
|
|
254
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const taskId = url.searchParams.get("taskId");
|
|
258
|
+
if (!taskId) {
|
|
259
|
+
sendJson(res, 400, { error: "taskId query parameter is required" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
sendJson(res, 200, popEvent(taskId));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
250
265
|
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
251
266
|
if (req.method === "POST" && pathname === "/event") {
|
|
252
267
|
if (!isLocalhost(req)) {
|
package/dist/types.d.ts
CHANGED
|
@@ -20,12 +20,13 @@ export interface TaskFrontmatter {
|
|
|
20
20
|
user_prompt: string;
|
|
21
21
|
agent: string;
|
|
22
22
|
/**
|
|
23
|
-
* Task schedule.
|
|
24
|
-
* - `crons`:
|
|
25
|
-
* - `specific_times`:
|
|
26
|
-
*
|
|
23
|
+
* Task schedule.
|
|
24
|
+
* - `crons`: `schedule_values` holds cron expressions (e.g. "0 9 * * *")
|
|
25
|
+
* - `specific_times`: `schedule_values` holds local datetime strings (e.g. "2026-04-20T09:00")
|
|
26
|
+
* - `on_new_notification`: fires on each new Android notification from NATS; no `schedule_values`
|
|
27
|
+
* - `on_new_sms`: fires on each new SMS from NATS; no `schedule_values`
|
|
27
28
|
*/
|
|
28
|
-
schedule_type?: "crons" | "specific_times";
|
|
29
|
+
schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
|
|
29
30
|
schedule_values?: string[];
|
|
30
31
|
schedule_enabled: boolean;
|
|
31
32
|
requires_confirmation: boolean;
|
package/package.json
CHANGED
package/src/commands/run.ts
CHANGED
|
@@ -267,6 +267,14 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
267
267
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
268
268
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
269
269
|
console.log(`Task ${taskId} completed (command-triggered).`);
|
|
270
|
+
} else if (task.frontmatter.schedule_type === "on_new_notification"
|
|
271
|
+
|| task.frontmatter.schedule_type === "on_new_sms") {
|
|
272
|
+
// Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
|
|
273
|
+
const result = await runEventTriggeredMode(ctx);
|
|
274
|
+
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
275
|
+
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
276
|
+
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
277
|
+
console.log(`Task ${taskId} completed (event-triggered).`);
|
|
270
278
|
} else {
|
|
271
279
|
// Standard execution — add user prompt as first message
|
|
272
280
|
await appendAndNotify(ctx, {
|
|
@@ -455,6 +463,59 @@ async function runCommandTriggeredMode(
|
|
|
455
463
|
return { outcome: "finished", endTime };
|
|
456
464
|
}
|
|
457
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Event-triggered execution mode.
|
|
468
|
+
*
|
|
469
|
+
* Drains the daemon-owned per-task event queue via the local /task-event/pop
|
|
470
|
+
* HTTP endpoint, invoking the agent once per event with the payload spliced
|
|
471
|
+
* into the user prompt. The run process itself holds no NATS subscription;
|
|
472
|
+
* the daemon handles that and atomically clears the active flag when we see
|
|
473
|
+
* an empty pop, so it can fire up a fresh run on the next incoming event.
|
|
474
|
+
*/
|
|
475
|
+
async function runEventTriggeredMode(
|
|
476
|
+
ctx: InvocationContext,
|
|
477
|
+
): Promise<{ outcome: TaskRunningState; endTime: number }> {
|
|
478
|
+
const scheduleType = ctx.task.frontmatter.schedule_type!;
|
|
479
|
+
const label = scheduleType === "on_new_notification" ? "notification" : "SMS";
|
|
480
|
+
const port = ctx.config.httpPort ?? 7256;
|
|
481
|
+
const popUrl = `http://localhost:${port}/task-event/pop?taskId=${encodeURIComponent(ctx.taskId)}`;
|
|
482
|
+
|
|
483
|
+
console.log(`[event-triggered] Draining ${label} queue`);
|
|
484
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
485
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
486
|
+
|
|
487
|
+
let eventsProcessed = 0;
|
|
488
|
+
try {
|
|
489
|
+
// eslint-disable-next-line no-constant-condition
|
|
490
|
+
while (true) {
|
|
491
|
+
const res = await fetch(popUrl, { method: "POST" });
|
|
492
|
+
if (!res.ok) throw new Error(`pop-event failed: ${res.status} ${res.statusText}`);
|
|
493
|
+
const body = await res.json() as { event?: string; empty?: true };
|
|
494
|
+
if (body.empty || !body.event) break;
|
|
495
|
+
|
|
496
|
+
eventsProcessed++;
|
|
497
|
+
console.log(`[event-triggered] Processing ${label} #${eventsProcessed}`);
|
|
498
|
+
|
|
499
|
+
const perEventPrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
|
|
500
|
+
const perEventTask: ParsedTask = {
|
|
501
|
+
frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
await invokeAgentWithRetries(ctx, perEventTask);
|
|
505
|
+
|
|
506
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
507
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
508
|
+
}
|
|
509
|
+
} catch (err) {
|
|
510
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
511
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: errorMsg, type: "error" });
|
|
512
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
513
|
+
return { outcome: "failed", endTime: Date.now() };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { outcome: "finished", endTime: Date.now() };
|
|
517
|
+
}
|
|
518
|
+
|
|
458
519
|
async function publishTaskEvent(
|
|
459
520
|
nc: NatsConnection | undefined,
|
|
460
521
|
config: HostConfig,
|
package/src/commands/serve.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { CONFIG_DIR } from "../config.js";
|
|
|
15
15
|
import { StringCodec, type NatsConnection } from "nats";
|
|
16
16
|
import { addNotification } from "../notification-store.js";
|
|
17
17
|
import { addSmsMessage } from "../sms-store.js";
|
|
18
|
+
import { enqueueEvent } from "../event-queues.js";
|
|
18
19
|
|
|
19
20
|
const POLL_INTERVAL_MS = 30_000;
|
|
20
21
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
@@ -135,27 +136,46 @@ export async function serveCommand(): Promise<void> {
|
|
|
135
136
|
|
|
136
137
|
// Subscribe to device notifications and SMS from Android
|
|
137
138
|
const sc = StringCodec();
|
|
139
|
+
|
|
140
|
+
// Dispatch a raw event payload to every task whose schedule matches.
|
|
141
|
+
function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string): void {
|
|
142
|
+
for (const task of listTasks(config.projectRoot)) {
|
|
143
|
+
if (task.frontmatter.schedule_type !== scheduleType) continue;
|
|
144
|
+
if (!task.frontmatter.schedule_enabled) continue;
|
|
145
|
+
const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
|
|
146
|
+
if (shouldStart) {
|
|
147
|
+
platform.startTask(task.frontmatter.id).catch((err) => {
|
|
148
|
+
console.error(`[event-trigger] Failed to start ${task.frontmatter.id}:`, err);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
|
|
139
155
|
(async () => {
|
|
140
156
|
for await (const msg of notifSub) {
|
|
157
|
+
const raw = sc.decode(msg.data);
|
|
141
158
|
try {
|
|
142
|
-
const data = JSON.parse(
|
|
159
|
+
const data = JSON.parse(raw);
|
|
143
160
|
addNotification({ ...data, receivedAt: Date.now() });
|
|
144
161
|
} catch (err) {
|
|
145
162
|
console.error("[nats] Failed to parse device notification:", err);
|
|
146
163
|
}
|
|
164
|
+
dispatchDeviceEvent("on_new_notification", raw);
|
|
147
165
|
}
|
|
148
166
|
})();
|
|
149
167
|
|
|
150
168
|
const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
|
|
151
169
|
(async () => {
|
|
152
170
|
for await (const msg of smsSub) {
|
|
171
|
+
const raw = sc.decode(msg.data);
|
|
153
172
|
try {
|
|
154
|
-
const data = JSON.parse(
|
|
173
|
+
const data = JSON.parse(raw);
|
|
155
174
|
addSmsMessage({ ...data, receivedAt: Date.now() });
|
|
156
175
|
} catch (err) {
|
|
157
176
|
console.error("[nats] Failed to parse device SMS:", err);
|
|
158
177
|
}
|
|
178
|
+
dispatchDeviceEvent("on_new_sms", raw);
|
|
159
179
|
}
|
|
160
180
|
})();
|
|
161
181
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task in-memory event queues for event-triggered schedules
|
|
3
|
+
* (schedule_type: "on_new_notification" | "on_new_sms").
|
|
4
|
+
*
|
|
5
|
+
* The daemon owns the NATS subscription and populates these queues; the
|
|
6
|
+
* `palmier run` process drains them via the localhost /task-event/pop HTTP
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle invariants:
|
|
11
|
+
* - activeRuns is cleared atomically inside popEvent when the queue is
|
|
12
|
+
* drained. At that point the calling run has already finished its last
|
|
13
|
+
* agent invocation and is only tearing down.
|
|
14
|
+
* - enqueueEvent returns shouldStart=true only if the task transitioned
|
|
15
|
+
* from idle (no active run) to active — callers must then startTask.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_QUEUE_SIZE = 100;
|
|
19
|
+
|
|
20
|
+
const queues = new Map<string, string[]>();
|
|
21
|
+
const activeRuns = new Set<string>();
|
|
22
|
+
|
|
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
|
+
export function enqueueEvent(taskId: string, payload: string): { shouldStart: boolean } {
|
|
28
|
+
const queue = queues.get(taskId) ?? [];
|
|
29
|
+
if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
|
|
30
|
+
queue.push(payload);
|
|
31
|
+
queues.set(taskId, queue);
|
|
32
|
+
|
|
33
|
+
if (activeRuns.has(taskId)) return { shouldStart: false };
|
|
34
|
+
activeRuns.add(taskId);
|
|
35
|
+
return { shouldStart: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
export function popEvent(taskId: string): { event: string } | { empty: true } {
|
|
44
|
+
const queue = queues.get(taskId);
|
|
45
|
+
if (queue && queue.length > 0) {
|
|
46
|
+
return { event: queue.shift()! };
|
|
47
|
+
}
|
|
48
|
+
activeRuns.delete(taskId);
|
|
49
|
+
return { empty: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Remove any state for a task (called from task.delete). */
|
|
53
|
+
export function clearTaskQueue(taskId: string): void {
|
|
54
|
+
queues.delete(taskId);
|
|
55
|
+
activeRuns.delete(taskId);
|
|
56
|
+
}
|
package/src/platform/windows.ts
CHANGED
|
@@ -192,11 +192,14 @@ export class WindowsPlatform implements PlatformService {
|
|
|
192
192
|
const script = process.argv[1] || "palmier";
|
|
193
193
|
const tr = `"${process.execPath}" "${script}" run ${taskId}`;
|
|
194
194
|
|
|
195
|
-
// Build trigger XML elements
|
|
195
|
+
// Build trigger XML elements. Event-based schedule types (on_new_notification,
|
|
196
|
+
// on_new_sms) carry no values and are driven by the run process, not the OS
|
|
197
|
+
// scheduler — they intentionally produce only the dummy trigger below.
|
|
196
198
|
const triggerElements: string[] = [];
|
|
197
199
|
const scheduleType = task.frontmatter.schedule_type;
|
|
198
200
|
const scheduleValues = task.frontmatter.schedule_values;
|
|
199
|
-
|
|
201
|
+
const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
|
|
202
|
+
if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
|
|
200
203
|
for (const value of scheduleValues) {
|
|
201
204
|
try {
|
|
202
205
|
triggerElements.push(scheduleValueToXml(scheduleType, value));
|
package/src/rpc-handler.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { publishHostEvent } from "./events.js";
|
|
|
14
14
|
import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type DeviceCapability } from "./device-capabilities.js";
|
|
15
15
|
import { currentVersion, performUpdate } from "./update-checker.js";
|
|
16
16
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
17
|
+
import { clearTaskQueue } from "./event-queues.js";
|
|
17
18
|
import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -162,7 +163,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
162
163
|
// is active. Includes any prompts already waiting so a reconnecting
|
|
163
164
|
// PWA can render their modals without replaying events.
|
|
164
165
|
const capabilities: Record<string, string | null> = {};
|
|
165
|
-
for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd"] as const) {
|
|
166
|
+
for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "email"] as const) {
|
|
166
167
|
capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
|
|
167
168
|
}
|
|
168
169
|
return {
|
|
@@ -194,7 +195,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
194
195
|
const params = request.params as {
|
|
195
196
|
user_prompt: string;
|
|
196
197
|
agent: string;
|
|
197
|
-
schedule_type?: "crons" | "specific_times";
|
|
198
|
+
schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
|
|
198
199
|
schedule_values?: string[];
|
|
199
200
|
schedule_enabled?: boolean;
|
|
200
201
|
requires_confirmation?: boolean;
|
|
@@ -217,9 +218,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
217
218
|
agent: params.agent,
|
|
218
219
|
schedule_enabled: params.schedule_enabled ?? true,
|
|
219
220
|
requires_confirmation: params.requires_confirmation ?? true,
|
|
220
|
-
...(params.schedule_type
|
|
221
|
-
|
|
222
|
-
: {}),
|
|
221
|
+
...(params.schedule_type ? { schedule_type: params.schedule_type } : {}),
|
|
222
|
+
...(params.schedule_values?.length ? { schedule_values: params.schedule_values } : {}),
|
|
223
223
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
224
224
|
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
225
225
|
...(params.command ? { command: params.command } : {}),
|
|
@@ -238,7 +238,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
238
238
|
id: string;
|
|
239
239
|
user_prompt?: string;
|
|
240
240
|
agent?: string;
|
|
241
|
-
schedule_type?: "crons" | "specific_times" | null;
|
|
241
|
+
schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms" | null;
|
|
242
242
|
schedule_values?: string[] | null;
|
|
243
243
|
schedule_enabled?: boolean;
|
|
244
244
|
requires_confirmation?: boolean;
|
|
@@ -307,6 +307,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
307
307
|
const params = request.params as { id: string };
|
|
308
308
|
|
|
309
309
|
getPlatform().removeTaskTimer(params.id);
|
|
310
|
+
clearTaskQueue(params.id);
|
|
310
311
|
removeFromTaskList(config.projectRoot, params.id);
|
|
311
312
|
|
|
312
313
|
return { ok: true, task_id: params.id };
|
|
@@ -9,6 +9,7 @@ import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
|
|
|
9
9
|
import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mcp-tools.js";
|
|
10
10
|
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
11
11
|
import { getTaskDir } from "../task.js";
|
|
12
|
+
import { popEvent } from "../event-queues.js";
|
|
12
13
|
|
|
13
14
|
// ── Bundled PWA asset serving ───────────────────────────────────────────
|
|
14
15
|
|
|
@@ -279,6 +280,19 @@ export async function startHttpTransport(
|
|
|
279
280
|
return;
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
// ── Event queue pop (used by event-triggered palmier run) ─────────
|
|
284
|
+
|
|
285
|
+
if (req.method === "POST" && pathname === "/task-event/pop") {
|
|
286
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
287
|
+
const taskId = url.searchParams.get("taskId");
|
|
288
|
+
if (!taskId) {
|
|
289
|
+
sendJson(res, 400, { error: "taskId query parameter is required" });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
sendJson(res, 200, popEvent(taskId));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
282
296
|
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
283
297
|
|
|
284
298
|
if (req.method === "POST" && pathname === "/event") {
|
package/src/types.ts
CHANGED
|
@@ -22,12 +22,13 @@ export interface TaskFrontmatter {
|
|
|
22
22
|
user_prompt: string;
|
|
23
23
|
agent: string;
|
|
24
24
|
/**
|
|
25
|
-
* Task schedule.
|
|
26
|
-
* - `crons`:
|
|
27
|
-
* - `specific_times`:
|
|
28
|
-
*
|
|
25
|
+
* Task schedule.
|
|
26
|
+
* - `crons`: `schedule_values` holds cron expressions (e.g. "0 9 * * *")
|
|
27
|
+
* - `specific_times`: `schedule_values` holds local datetime strings (e.g. "2026-04-20T09:00")
|
|
28
|
+
* - `on_new_notification`: fires on each new Android notification from NATS; no `schedule_values`
|
|
29
|
+
* - `on_new_sms`: fires on each new SMS from NATS; no `schedule_values`
|
|
29
30
|
*/
|
|
30
|
-
schedule_type?: "crons" | "specific_times";
|
|
31
|
+
schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
|
|
31
32
|
schedule_values?: string[];
|
|
32
33
|
schedule_enabled: boolean;
|
|
33
34
|
requires_confirmation: boolean;
|