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.
Files changed (133) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +16 -14
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/commands/info.d.ts +0 -3
  17. package/dist/commands/info.js +0 -5
  18. package/dist/commands/init.d.ts +0 -3
  19. package/dist/commands/init.js +2 -11
  20. package/dist/commands/pair.d.ts +1 -4
  21. package/dist/commands/pair.js +3 -12
  22. package/dist/commands/restart.d.ts +0 -3
  23. package/dist/commands/restart.js +0 -3
  24. package/dist/commands/run.d.ts +1 -14
  25. package/dist/commands/run.js +18 -61
  26. package/dist/commands/serve.d.ts +0 -3
  27. package/dist/commands/serve.js +29 -27
  28. package/dist/config.d.ts +0 -8
  29. package/dist/config.js +0 -8
  30. package/dist/device-capabilities.d.ts +1 -1
  31. package/dist/event-queues.d.ts +6 -21
  32. package/dist/event-queues.js +6 -21
  33. package/dist/events.d.ts +0 -6
  34. package/dist/events.js +1 -9
  35. package/dist/index.js +0 -1
  36. package/dist/mcp-handler.js +1 -2
  37. package/dist/mcp-tools.d.ts +0 -3
  38. package/dist/mcp-tools.js +12 -16
  39. package/dist/nats-client.d.ts +0 -3
  40. package/dist/nats-client.js +1 -4
  41. package/dist/pending-requests.d.ts +4 -18
  42. package/dist/pending-requests.js +4 -18
  43. package/dist/platform/index.d.ts +1 -4
  44. package/dist/platform/index.js +8 -7
  45. package/dist/platform/linux.d.ts +3 -9
  46. package/dist/platform/linux.js +9 -20
  47. package/dist/platform/macos.d.ts +32 -0
  48. package/dist/platform/macos.js +287 -0
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-499vYQvR.js +120 -0
  53. package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
  54. package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
  55. package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +14 -47
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +7 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/pwa/src/App.css +325 -22
  73. package/palmier-server/pwa/src/App.tsx +2 -0
  74. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  78. package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
  79. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  80. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  81. package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
  82. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  83. package/palmier-server/pwa/src/constants.ts +1 -1
  84. package/palmier-server/pwa/src/native/Device.ts +18 -2
  85. package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
  86. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  87. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  88. package/palmier-server/server/src/index.ts +7 -7
  89. package/palmier-server/server/src/routes/device.ts +4 -4
  90. package/palmier-server/spec.md +38 -7
  91. package/src/agents/agent.ts +0 -4
  92. package/src/agents/claude.ts +1 -1
  93. package/src/agents/codex.ts +2 -2
  94. package/src/agents/cursor.ts +1 -1
  95. package/src/agents/deepagents.ts +1 -1
  96. package/src/agents/gemini.ts +3 -2
  97. package/src/agents/goose.ts +1 -1
  98. package/src/agents/hermes.ts +1 -1
  99. package/src/agents/kiro.ts +1 -1
  100. package/src/agents/opencode.ts +1 -1
  101. package/src/agents/qoder.ts +1 -1
  102. package/src/agents/shared-prompt.ts +0 -3
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +3 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +28 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +3 -2
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +12 -18
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +5 -7
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/macos.ts +310 -0
  121. package/src/platform/platform.ts +1 -4
  122. package/src/platform/windows.ts +19 -40
  123. package/src/rpc-handler.ts +14 -47
  124. package/src/spawn-command.ts +11 -27
  125. package/src/task.ts +7 -70
  126. package/src/transports/http-transport.ts +7 -39
  127. package/src/transports/nats-transport.ts +3 -9
  128. package/src/types.ts +3 -10
  129. package/src/update-checker.ts +2 -5
  130. package/test/macos-plist.test.ts +112 -0
  131. package/test/task-parsing.test.ts +2 -3
  132. package/test/windows-xml.test.ts +11 -12
  133. package/dist/pwa/assets/index-DQfOEB03.js +0 -120
@@ -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 with a continuation loop for permissions and user input.
39
- *
40
- * Both standard and command-triggered execution use this.
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
- // If the agent actually failed, retry with the new permissions
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" to status.json (e.g. via task.abort),
194
- * respect that instead of overwriting with the process's own outcome.
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
- * Command-triggered execution mode.
316
- *
317
- * Spawns a long-running shell command and, for each line of stdout,
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
- // Append monitoring status so the UI shows the task is waiting for more input
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
- * 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.
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");
@@ -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
- * Scan all tasks for any stuck in "started" state whose process is no longer alive.
25
- * Uses the system scheduler (Task Scheduler / systemd) as the authoritative source.
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 { /* use taskId as fallback */ }
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
- // Write PID so `palmier restart` can find us regardless of how we were started
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
- // Ensure all tasks have their scheduler entries (recovery after init/reinstall)
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
- // Dispatch a raw event payload to every task whose schedule matches.
141
- function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string): void {
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
- const data = JSON.parse(raw);
160
- addNotification({ ...data, receivedAt: Date.now() });
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
- const data = JSON.parse(raw);
174
- addSmsMessage({ ...data, receivedAt: Date.now() });
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
- | "alert"
19
+ | "alarm"
19
20
  | "battery"
20
21
  | "send-email"
21
22
  | "dnd";
@@ -1,18 +1,12 @@
1
1
  /**
2
- * Per-task in-memory event queues for event-triggered schedules
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 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.
4
+ * `palmier run` process drains via /task-event/pop.
9
5
  *
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.
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
@@ -101,7 +101,6 @@ program
101
101
  await uninstallCommand();
102
102
  });
103
103
 
104
- // No subcommand → default to serve
105
104
  if (process.argv.length <= 2) {
106
105
  process.argv.push("serve");
107
106
  }
@@ -15,14 +15,13 @@ export interface McpResponse {
15
15
  stream?: boolean;
16
16
  }
17
17
 
18
- // Resource subscriptions: sessionId → Set of resource URIs
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 access enabled", 400);
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 sendAlertTool: ToolDefinition = {
506
- name: "send-alert",
505
+ const sendAlarmTool: ToolDefinition = {
506
+ name: "send-alarm",
507
507
  description: [
508
- "Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
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: "Alert title" },
517
- description: { type: "string", description: "Alert description/details" },
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("alert");
525
- if (!device) throw new ToolError("No device has alert access enabled", 400);
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.alert`,
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}.alert.${ctx.sessionId}`, { max: 1 });
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, sendAlertTool, readBatteryTool, setRingerModeTool];
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] = ["..."];
@@ -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 anything as that will pollute stdout for mcp server.
17
+ // Do not log it would pollute stdout for the MCP server.
21
18
  return nc;
22
19
  }