palmier 0.8.1 → 0.8.4
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 +16 -14
- 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/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 +3 -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 +29 -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 +8 -7
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- 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-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.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 +14 -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 +7 -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 +325 -22
- package/palmier-server/pwa/src/App.tsx +2 -0
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +18 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- 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/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +3 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +28 -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 +5 -7
- package/src/platform/linux.ts +9 -20
- package/src/platform/macos.ts +310 -0
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +14 -47
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +7 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/macos-plist.test.ts +112 -0
- 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/run.ts
CHANGED
|
@@ -13,10 +13,6 @@ import { publishHostEvent } from "../events.js";
|
|
|
13
13
|
import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
|
|
14
14
|
import type { NatsConnection } from "nats";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
* Shared context for agent invocation retry loops.
|
|
18
|
-
* Passed around to avoid threading many individual parameters.
|
|
19
|
-
*/
|
|
20
16
|
interface InvocationContext {
|
|
21
17
|
agent: AgentTool;
|
|
22
18
|
task: ParsedTask;
|
|
@@ -35,11 +31,9 @@ interface InvocationResult {
|
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
/**
|
|
38
|
-
* Invoke the agent CLI
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* The `invokeTask` is the ParsedTask whose prompt is passed to the agent
|
|
42
|
-
* (for command-triggered mode this is the per-line augmented task).
|
|
34
|
+
* Invoke the agent CLI in a continuation loop to handle permission requests.
|
|
35
|
+
* `invokeTask` is the ParsedTask whose prompt is passed to the agent (in
|
|
36
|
+
* command-triggered mode this is the per-line augmented task).
|
|
43
37
|
*/
|
|
44
38
|
async function invokeAgentWithRetries(
|
|
45
39
|
ctx: InvocationContext,
|
|
@@ -47,7 +41,6 @@ async function invokeAgentWithRetries(
|
|
|
47
41
|
): Promise<InvocationResult> {
|
|
48
42
|
// eslint-disable-next-line no-constant-condition
|
|
49
43
|
while (true) {
|
|
50
|
-
// Stream agent output to TASKRUN.md in real-time, throttled to 500ms
|
|
51
44
|
const writer = beginStreamingMessage(ctx.taskDir, ctx.runId, Date.now());
|
|
52
45
|
let lineBuf = "";
|
|
53
46
|
let notifyPending = false;
|
|
@@ -89,12 +82,10 @@ async function invokeAgentWithRetries(
|
|
|
89
82
|
const reportFiles = parseReportFiles(result.output);
|
|
90
83
|
const requiredPermissions = parsePermissions(result.output);
|
|
91
84
|
|
|
92
|
-
// Flush remaining buffered content
|
|
93
85
|
if (lineBuf && !lineBuf.startsWith("[PALMIER")) {
|
|
94
86
|
writer.write(lineBuf);
|
|
95
87
|
}
|
|
96
88
|
|
|
97
|
-
// Include permission requests in the assistant message
|
|
98
89
|
if (requiredPermissions.length > 0) {
|
|
99
90
|
const permLines = requiredPermissions.map((p) => `- **${p.name}** ${p.description}`).join("\n");
|
|
100
91
|
writer.write(`\n\n**Permissions requested:**\n${permLines}\n`);
|
|
@@ -112,7 +103,6 @@ async function invokeAgentWithRetries(
|
|
|
112
103
|
});
|
|
113
104
|
}
|
|
114
105
|
|
|
115
|
-
// Permission handling — agent requested permissions
|
|
116
106
|
if (requiredPermissions.length > 0) {
|
|
117
107
|
const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
|
|
118
108
|
|
|
@@ -146,27 +136,20 @@ async function invokeAgentWithRetries(
|
|
|
146
136
|
ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
|
|
147
137
|
}
|
|
148
138
|
|
|
149
|
-
//
|
|
139
|
+
// Retry with the new permissions if the agent failed.
|
|
150
140
|
if (outcome === "failed") {
|
|
151
141
|
continue;
|
|
152
142
|
}
|
|
153
143
|
}
|
|
154
144
|
|
|
155
|
-
// Normal completion (success or terminal failure)
|
|
156
145
|
return { outcome };
|
|
157
146
|
}
|
|
158
147
|
}
|
|
159
148
|
|
|
160
|
-
/**
|
|
161
|
-
* Strip [PALMIER_*] marker lines from agent output.
|
|
162
|
-
*/
|
|
163
149
|
export function stripPalmierMarkers(output: string): string {
|
|
164
150
|
return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
|
|
165
151
|
}
|
|
166
152
|
|
|
167
|
-
/**
|
|
168
|
-
* Append a conversation message to the RESULT file and notify connected clients.
|
|
169
|
-
*/
|
|
170
153
|
async function appendAndNotify(
|
|
171
154
|
ctx: InvocationContext,
|
|
172
155
|
msg: Parameters<typeof appendRunMessage>[2],
|
|
@@ -175,9 +158,7 @@ async function appendAndNotify(
|
|
|
175
158
|
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
176
159
|
}
|
|
177
160
|
|
|
178
|
-
/**
|
|
179
|
-
* Find the latest run dir that has no status messages yet (just created by the RPC handler).
|
|
180
|
-
*/
|
|
161
|
+
/** The latest run dir with no status messages yet — freshly created by the RPC handler. */
|
|
181
162
|
function findLatestPendingRunId(taskDir: string): string | null {
|
|
182
163
|
const dirs = fs.readdirSync(taskDir)
|
|
183
164
|
.filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
|
|
@@ -190,8 +171,8 @@ function findLatestPendingRunId(taskDir: string): string | null {
|
|
|
190
171
|
}
|
|
191
172
|
|
|
192
173
|
/**
|
|
193
|
-
* If the RPC handler already wrote "aborted"
|
|
194
|
-
*
|
|
174
|
+
* If the RPC handler already wrote "aborted" (via task.abort), respect that
|
|
175
|
+
* instead of overwriting with the process's own outcome.
|
|
195
176
|
*/
|
|
196
177
|
function resolveOutcome(taskDir: string, outcome: TaskRunningState): TaskRunningState {
|
|
197
178
|
const current = readTaskStatus(taskDir);
|
|
@@ -199,9 +180,6 @@ function resolveOutcome(taskDir: string, outcome: TaskRunningState): TaskRunning
|
|
|
199
180
|
return outcome;
|
|
200
181
|
}
|
|
201
182
|
|
|
202
|
-
/**
|
|
203
|
-
* Execute a task by ID.
|
|
204
|
-
*/
|
|
205
183
|
export async function runCommand(taskId: string): Promise<void> {
|
|
206
184
|
const config = loadConfig();
|
|
207
185
|
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
@@ -211,7 +189,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
211
189
|
let nc: NatsConnection | undefined;
|
|
212
190
|
const taskName = task.frontmatter.name;
|
|
213
191
|
|
|
214
|
-
// Use existing run dir if just created by RPC, otherwise create a new one
|
|
215
192
|
const existingRunId = findLatestPendingRunId(taskDir);
|
|
216
193
|
const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now(), task.frontmatter.agent);
|
|
217
194
|
if (!existingRunId) {
|
|
@@ -231,7 +208,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
231
208
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
|
|
232
209
|
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
|
|
233
210
|
|
|
234
|
-
// If requires_confirmation, notify clients and wait
|
|
235
211
|
if (task.frontmatter.requires_confirmation) {
|
|
236
212
|
const confirmed = await requestConfirmation(config, task, taskDir);
|
|
237
213
|
const confirmPrompt = `**Task Confirmation**\n\nRun task "${taskName || task.frontmatter.user_prompt}"?`;
|
|
@@ -252,7 +228,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
252
228
|
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
|
|
253
229
|
}
|
|
254
230
|
|
|
255
|
-
// Shared invocation context
|
|
256
231
|
const guiEnv = getPlatform().getGuiEnv();
|
|
257
232
|
const agent = getAgent(task.frontmatter.agent);
|
|
258
233
|
const ctx: InvocationContext = {
|
|
@@ -261,7 +236,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
261
236
|
};
|
|
262
237
|
|
|
263
238
|
if (task.frontmatter.command) {
|
|
264
|
-
// Command-triggered mode
|
|
265
239
|
const result = await runCommandTriggeredMode(ctx);
|
|
266
240
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
267
241
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
@@ -269,14 +243,12 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
269
243
|
console.log(`Task ${taskId} completed (command-triggered).`);
|
|
270
244
|
} else if (task.frontmatter.schedule_type === "on_new_notification"
|
|
271
245
|
|| task.frontmatter.schedule_type === "on_new_sms") {
|
|
272
|
-
// Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
|
|
273
246
|
const result = await runEventTriggeredMode(ctx);
|
|
274
247
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
275
248
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
276
249
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
277
250
|
console.log(`Task ${taskId} completed (event-triggered).`);
|
|
278
251
|
} else {
|
|
279
|
-
// Standard execution — add user prompt as first message
|
|
280
252
|
await appendAndNotify(ctx, {
|
|
281
253
|
role: "user",
|
|
282
254
|
time: Date.now(),
|
|
@@ -312,11 +284,9 @@ const MAX_LOG_ENTRIES = 1000;
|
|
|
312
284
|
const MAX_LINE_LENGTH = 200_000;
|
|
313
285
|
|
|
314
286
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* invokes the agent CLI with the user's prompt augmented by that line.
|
|
319
|
-
* Processes lines sequentially with a bounded queue.
|
|
287
|
+
* Spawn a long-running shell command and invoke the agent CLI once per stdout
|
|
288
|
+
* line, with the user's prompt augmented by that line. Sequential with a
|
|
289
|
+
* bounded queue.
|
|
320
290
|
*/
|
|
321
291
|
async function runCommandTriggeredMode(
|
|
322
292
|
ctx: InvocationContext,
|
|
@@ -346,7 +316,6 @@ async function runCommandTriggeredMode(
|
|
|
346
316
|
const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
|
|
347
317
|
fs.appendFileSync(logPath, entry, "utf-8");
|
|
348
318
|
|
|
349
|
-
// Trim log if too large (keep last MAX_LOG_ENTRIES entries)
|
|
350
319
|
try {
|
|
351
320
|
const content = fs.readFileSync(logPath, "utf-8");
|
|
352
321
|
const entries = content.split("\n---\n").filter(Boolean);
|
|
@@ -380,7 +349,7 @@ async function runCommandTriggeredMode(
|
|
|
380
349
|
}
|
|
381
350
|
appendLog(line, "", result.outcome);
|
|
382
351
|
|
|
383
|
-
//
|
|
352
|
+
// Signal "waiting for more input" in the UI.
|
|
384
353
|
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
385
354
|
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
386
355
|
}
|
|
@@ -422,7 +391,6 @@ async function runCommandTriggeredMode(
|
|
|
422
391
|
process.stderr.write(d);
|
|
423
392
|
});
|
|
424
393
|
|
|
425
|
-
// Wait for command to exit
|
|
426
394
|
const exitCode = await new Promise<number | null>((resolve) => {
|
|
427
395
|
child.on("close", (code: number | null) => {
|
|
428
396
|
commandExited = true;
|
|
@@ -438,7 +406,6 @@ async function runCommandTriggeredMode(
|
|
|
438
406
|
});
|
|
439
407
|
});
|
|
440
408
|
|
|
441
|
-
// Wait for any remaining queued lines to finish processing
|
|
442
409
|
if (lineQueue.length > 0 || processing) {
|
|
443
410
|
await new Promise<void>((resolve) => {
|
|
444
411
|
resolveWhenDone = resolve;
|
|
@@ -464,13 +431,10 @@ async function runCommandTriggeredMode(
|
|
|
464
431
|
}
|
|
465
432
|
|
|
466
433
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
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.
|
|
434
|
+
* Drain the daemon-owned per-task event queue via /task-event/pop, invoking
|
|
435
|
+
* the agent once per event. The run process holds no NATS subscription — the
|
|
436
|
+
* daemon owns that and atomically clears the active flag on empty pop so it
|
|
437
|
+
* can fire a fresh run on the next incoming event.
|
|
474
438
|
*/
|
|
475
439
|
async function runEventTriggeredMode(
|
|
476
440
|
ctx: InvocationContext,
|
|
@@ -590,10 +554,6 @@ async function requestConfirmation(
|
|
|
590
554
|
return confirmed;
|
|
591
555
|
}
|
|
592
556
|
|
|
593
|
-
/**
|
|
594
|
-
* Extract report file names from agent output.
|
|
595
|
-
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
596
|
-
*/
|
|
597
557
|
const ALLOWED_REPORT_EXT = [".md", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
598
558
|
|
|
599
559
|
export function parseReportFiles(output: string): string[] {
|
|
@@ -602,7 +562,7 @@ export function parseReportFiles(output: string): string[] {
|
|
|
602
562
|
let match;
|
|
603
563
|
while ((match = regex.exec(output)) !== null) {
|
|
604
564
|
const name = match[1].trim();
|
|
605
|
-
// Skip placeholder examples echoed from the prompt (e.g. "<filename>")
|
|
565
|
+
// Skip placeholder examples echoed from the prompt (e.g. "<filename>").
|
|
606
566
|
if (!name || name.startsWith("<")) continue;
|
|
607
567
|
const ext = name.lastIndexOf(".") >= 0 ? name.slice(name.lastIndexOf(".")).toLowerCase() : "";
|
|
608
568
|
if (!ALLOWED_REPORT_EXT.includes(ext)) continue;
|
|
@@ -611,17 +571,13 @@ export function parseReportFiles(output: string): string[] {
|
|
|
611
571
|
return files;
|
|
612
572
|
}
|
|
613
573
|
|
|
614
|
-
/**
|
|
615
|
-
* Extract required permissions from agent output.
|
|
616
|
-
* Looks for lines matching: [PALMIER_PERMISSION] <tool> | <description>
|
|
617
|
-
*/
|
|
618
574
|
export function parsePermissions(output: string): RequiredPermission[] {
|
|
619
575
|
const regex = new RegExp(`^\\${TASK_PERMISSION_PREFIX}\\s+(.+)$`, "gm");
|
|
620
576
|
const perms: RequiredPermission[] = [];
|
|
621
577
|
let match;
|
|
622
578
|
while ((match = regex.exec(output)) !== null) {
|
|
623
579
|
const raw = match[1].trim();
|
|
624
|
-
// Skip placeholder examples echoed from the prompt (e.g. "<tool_name> | <description>")
|
|
580
|
+
// Skip placeholder examples echoed from the prompt (e.g. "<tool_name> | <description>").
|
|
625
581
|
if (raw.startsWith("<")) continue;
|
|
626
582
|
const sep = raw.indexOf("|");
|
|
627
583
|
if (sep !== -1) {
|
|
@@ -633,10 +589,7 @@ export function parsePermissions(output: string): RequiredPermission[] {
|
|
|
633
589
|
return perms;
|
|
634
590
|
}
|
|
635
591
|
|
|
636
|
-
/**
|
|
637
|
-
* Parse the agent's output for success/failure markers.
|
|
638
|
-
* Falls back to "finished" if no marker is found.
|
|
639
|
-
*/
|
|
592
|
+
/** Falls back to "finished" if no success/failure marker is found. */
|
|
640
593
|
export function parseTaskOutcome(output: string): TaskRunningState {
|
|
641
594
|
const lastChunk = output.slice(-500);
|
|
642
595
|
const regex = new RegExp(`^\\${TASK_FAILURE_MARKER}$|^\\${TASK_SUCCESS_MARKER}$`, "gm");
|
package/src/commands/serve.ts
CHANGED
|
@@ -21,11 +21,8 @@ const POLL_INTERVAL_MS = 30_000;
|
|
|
21
21
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
22
22
|
|
|
23
23
|
/**
|
|
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.
|
|
24
|
+
* Reconcile tasks stuck in "started" whose process is no longer alive.
|
|
25
|
+
* The system scheduler (Task Scheduler / systemd) is the authoritative source.
|
|
29
26
|
*/
|
|
30
27
|
async function checkStaleTasks(
|
|
31
28
|
config: HostConfig,
|
|
@@ -46,14 +43,12 @@ async function checkStaleTasks(
|
|
|
46
43
|
const status = readTaskStatus(taskDir);
|
|
47
44
|
if (!status || status.running_state !== "started") continue;
|
|
48
45
|
|
|
49
|
-
// Ask the system scheduler if the task is still running
|
|
50
46
|
if (platform.isTaskRunning(taskId)) continue;
|
|
51
47
|
|
|
52
48
|
console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
|
|
53
49
|
const endTime = Date.now();
|
|
54
50
|
writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
|
|
55
51
|
|
|
56
|
-
// Find the latest run directory (created by run.ts at start)
|
|
57
52
|
const runId = fs.readdirSync(taskDir)
|
|
58
53
|
.filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
|
|
59
54
|
.sort()
|
|
@@ -71,7 +66,7 @@ async function checkStaleTasks(
|
|
|
71
66
|
let taskName = taskId;
|
|
72
67
|
try {
|
|
73
68
|
taskName = parseTaskFile(taskDir).frontmatter.name || taskId;
|
|
74
|
-
} catch { /*
|
|
69
|
+
} catch { /* fallback to taskId */ }
|
|
75
70
|
|
|
76
71
|
await publishHostEvent(nc, config.hostId, taskId, {
|
|
77
72
|
event_type: "running-state",
|
|
@@ -81,18 +76,14 @@ async function checkStaleTasks(
|
|
|
81
76
|
}
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
/**
|
|
85
|
-
* Start the persistent RPC handler (NATS + HTTP).
|
|
86
|
-
*/
|
|
87
79
|
export async function serveCommand(): Promise<void> {
|
|
88
80
|
const config = loadConfig();
|
|
89
81
|
|
|
90
|
-
//
|
|
82
|
+
// PID file lets `palmier restart` find us regardless of how we were started
|
|
91
83
|
fs.writeFileSync(DAEMON_PID_FILE, String(process.pid), "utf-8");
|
|
92
84
|
|
|
93
85
|
console.log("Starting...");
|
|
94
86
|
|
|
95
|
-
// Re-detect agents on every daemon start
|
|
96
87
|
const agents = await detectAgents();
|
|
97
88
|
config.agents = agents;
|
|
98
89
|
saveConfig(config);
|
|
@@ -106,10 +97,9 @@ export async function serveCommand(): Promise<void> {
|
|
|
106
97
|
console.warn(`[nats] Connection failed (server mode unavailable): ${err}`);
|
|
107
98
|
}
|
|
108
99
|
|
|
109
|
-
// Reconcile any tasks stuck from before daemon started
|
|
110
100
|
await checkStaleTasks(config, nc);
|
|
111
101
|
|
|
112
|
-
//
|
|
102
|
+
// Reinstall scheduler entries for all tasks (recovery after init/reinstall)
|
|
113
103
|
const platform = getPlatform();
|
|
114
104
|
const allTasks = listTasks(config.projectRoot);
|
|
115
105
|
for (const task of allTasks) {
|
|
@@ -120,7 +110,6 @@ export async function serveCommand(): Promise<void> {
|
|
|
120
110
|
}
|
|
121
111
|
}
|
|
122
112
|
|
|
123
|
-
// Poll for crashed tasks every 30 seconds
|
|
124
113
|
setInterval(() => {
|
|
125
114
|
checkStaleTasks(config, nc).catch((err) => {
|
|
126
115
|
console.error("[monitor] Error checking stale tasks:", err);
|
|
@@ -130,18 +119,29 @@ export async function serveCommand(): Promise<void> {
|
|
|
130
119
|
const handleRpc = createRpcHandler(config, nc);
|
|
131
120
|
const httpPort = config.httpPort ?? 7256;
|
|
132
121
|
|
|
133
|
-
// Start NATS transport (loops forever, fire-and-forget)
|
|
134
122
|
if (nc) {
|
|
135
123
|
startNatsTransport(config, handleRpc, nc);
|
|
136
124
|
|
|
137
|
-
// Subscribe to device notifications and SMS from Android
|
|
138
125
|
const sc = StringCodec();
|
|
139
126
|
|
|
140
|
-
//
|
|
141
|
-
function
|
|
127
|
+
// Match phone numbers regardless of formatting; letters preserved for shortcodes.
|
|
128
|
+
function normalizeSender(raw: string): string {
|
|
129
|
+
return raw.replace(/[\s\-()+]/g, "").toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string, parsed?: unknown): void {
|
|
142
133
|
for (const task of listTasks(config.projectRoot)) {
|
|
143
134
|
if (task.frontmatter.schedule_type !== scheduleType) continue;
|
|
144
135
|
if (!task.frontmatter.schedule_enabled) continue;
|
|
136
|
+
if (scheduleType === "on_new_notification" && task.frontmatter.schedule_values && task.frontmatter.schedule_values.length > 0) {
|
|
137
|
+
const pkg = (parsed as { packageName?: string } | undefined)?.packageName;
|
|
138
|
+
if (!pkg || !task.frontmatter.schedule_values.includes(pkg)) continue;
|
|
139
|
+
}
|
|
140
|
+
if (scheduleType === "on_new_sms" && task.frontmatter.schedule_values && task.frontmatter.schedule_values.length > 0) {
|
|
141
|
+
const sender = (parsed as { sender?: string } | undefined)?.sender;
|
|
142
|
+
const normalizedSender = sender ? normalizeSender(sender) : "";
|
|
143
|
+
if (!normalizedSender || !task.frontmatter.schedule_values.some((s) => normalizeSender(s) === normalizedSender)) continue;
|
|
144
|
+
}
|
|
145
145
|
const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
|
|
146
146
|
if (shouldStart) {
|
|
147
147
|
platform.startTask(task.frontmatter.id).catch((err) => {
|
|
@@ -155,13 +155,14 @@ export async function serveCommand(): Promise<void> {
|
|
|
155
155
|
(async () => {
|
|
156
156
|
for await (const msg of notifSub) {
|
|
157
157
|
const raw = sc.decode(msg.data);
|
|
158
|
+
let parsed: unknown;
|
|
158
159
|
try {
|
|
159
|
-
|
|
160
|
-
addNotification({ ...
|
|
160
|
+
parsed = JSON.parse(raw);
|
|
161
|
+
addNotification({ ...(parsed as object), receivedAt: Date.now() } as Parameters<typeof addNotification>[0]);
|
|
161
162
|
} catch (err) {
|
|
162
163
|
console.error("[nats] Failed to parse device notification:", err);
|
|
163
164
|
}
|
|
164
|
-
dispatchDeviceEvent("on_new_notification", raw);
|
|
165
|
+
dispatchDeviceEvent("on_new_notification", raw, parsed);
|
|
165
166
|
}
|
|
166
167
|
})();
|
|
167
168
|
|
|
@@ -169,17 +170,17 @@ export async function serveCommand(): Promise<void> {
|
|
|
169
170
|
(async () => {
|
|
170
171
|
for await (const msg of smsSub) {
|
|
171
172
|
const raw = sc.decode(msg.data);
|
|
173
|
+
let parsed: unknown;
|
|
172
174
|
try {
|
|
173
|
-
|
|
174
|
-
addSmsMessage({ ...
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
addSmsMessage({ ...(parsed as object), receivedAt: Date.now() } as Parameters<typeof addSmsMessage>[0]);
|
|
175
177
|
} catch (err) {
|
|
176
178
|
console.error("[nats] Failed to parse device SMS:", err);
|
|
177
179
|
}
|
|
178
|
-
dispatchDeviceEvent("on_new_sms", raw);
|
|
180
|
+
dispatchDeviceEvent("on_new_sms", raw, parsed);
|
|
179
181
|
}
|
|
180
182
|
})();
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
// Start HTTP transport (loops forever)
|
|
184
185
|
await startHttpTransport(config, handleRpc, httpPort, nc);
|
|
185
186
|
}
|
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
|
}
|