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
@@ -33,32 +33,25 @@ export function scheduleValueToXml(scheduleType: "crons" | "specific_times", val
33
33
  if (parts.length !== 5) throw new Error(`Invalid cron expression: ${value}`);
34
34
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
35
35
  const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
36
- // StartBoundary needs a full date; use a past date as the anchor
36
+ // StartBoundary needs a full date; anchor to a past one.
37
37
  const base = `2000-01-01T${st}`;
38
38
 
39
- // Hourly
40
39
  if (hour === "*") {
41
40
  return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
42
41
  }
43
42
 
44
- // Weekly
45
43
  if (dayOfMonth === "*" && dayOfWeek !== "*") {
46
44
  const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
47
45
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
48
46
  }
49
47
 
50
- // Monthly
51
48
  if (dayOfMonth !== "*" && dayOfWeek === "*") {
52
49
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByMonth><DaysOfMonth><Day>${dayOfMonth}</Day></DaysOfMonth><Months><January /><February /><March /><April /><May /><June /><July /><August /><September /><October /><November /><December /></Months></ScheduleByMonth></CalendarTrigger>`;
53
50
  }
54
51
 
55
- // Daily
56
52
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
57
53
  }
58
54
 
59
- /**
60
- * Build a complete Task Scheduler XML definition.
61
- */
62
55
  export function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string {
63
56
  const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
64
57
  const commandStr = command?.replace(/"/g, "") ?? "";
@@ -98,10 +91,7 @@ export class WindowsPlatform implements PlatformService {
98
91
  installDaemon(config: HostConfig): void {
99
92
  const script = process.argv[1] || "palmier";
100
93
 
101
- // Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
102
94
  this.ensureDaemonTask(script);
103
-
104
- // Start the daemon now
105
95
  this.startDaemonTask();
106
96
 
107
97
  console.log("\nHost initialization complete!");
@@ -110,12 +100,11 @@ export class WindowsPlatform implements PlatformService {
110
100
  uninstallDaemon(): void {
111
101
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
112
102
 
113
- // Stop the daemon via Task Scheduler
114
103
  try {
115
104
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
116
105
  } catch { /* task may not be running */ }
117
106
 
118
- // Remove daemon scheduled task (elevated — S4U task requires elevation to delete)
107
+ // Deleting an S4U task requires elevation.
119
108
  try {
120
109
  execFileSync("powershell", [
121
110
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
@@ -123,7 +112,6 @@ export class WindowsPlatform implements PlatformService {
123
112
  console.log("Daemon task removed.");
124
113
  } catch { /* task may not exist */ }
125
114
 
126
- // Remove all Palmier task timers
127
115
  try {
128
116
  const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
129
117
  for (const line of out.split("\n")) {
@@ -142,16 +130,14 @@ export class WindowsPlatform implements PlatformService {
142
130
  async restartDaemon(): Promise<void> {
143
131
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
144
132
 
145
- // Stop the daemon via Task Scheduler
146
133
  try {
147
134
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
148
135
  } catch { /* task may not be running */ }
149
136
 
150
- // Start it again
151
137
  this.startDaemonTask();
152
138
  }
153
139
 
154
- /** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
140
+ /** S4U LogonType requires elevation to create. */
155
141
  private ensureDaemonTask(script: string): void {
156
142
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
157
143
  const tr = `"${process.execPath}" "${script}" serve`;
@@ -160,7 +146,7 @@ export class WindowsPlatform implements PlatformService {
160
146
  try {
161
147
  const bom = Buffer.from([0xFF, 0xFE]);
162
148
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
163
- // S4U LogonType requires elevation — spawn schtasks via RunAs
149
+ // S4U requires elevation — spawn schtasks via RunAs.
164
150
  const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
165
151
  execFileSync("powershell", [
166
152
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
@@ -174,7 +160,7 @@ export class WindowsPlatform implements PlatformService {
174
160
 
175
161
  }
176
162
 
177
- /** Start the daemon via Task Scheduler (runs outside any session's job object). */
163
+ /** Starting via Task Scheduler runs the daemon outside any session's job object. */
178
164
  private startDaemonTask(): void {
179
165
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
180
166
  try {
@@ -192,9 +178,8 @@ export class WindowsPlatform implements PlatformService {
192
178
  const script = process.argv[1] || "palmier";
193
179
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
194
180
 
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.
181
+ // Event-based schedule types (on_new_notification/on_new_sms) are driven by
182
+ // the run process, not the OS scheduler they fall through to the dummy trigger.
198
183
  const triggerElements: string[] = [];
199
184
  const scheduleType = task.frontmatter.schedule_type;
200
185
  const scheduleValues = task.frontmatter.schedule_values;
@@ -208,19 +193,19 @@ export class WindowsPlatform implements PlatformService {
208
193
  }
209
194
  }
210
195
  }
211
- // Always include a dummy trigger so startTask (/run) works
196
+ // Dummy trigger so schtasks /run still works.
212
197
  if (triggerElements.length === 0) {
213
198
  triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
214
199
  }
215
200
 
216
- // Write XML and register via schtasks gives us full control over
217
- // settings like MultipleInstancesPolicy that schtasks flags don't expose.
218
- // S4U LogonType ensures no console window (unless foreground_mode is set).
219
- // Works without elevation because the daemon (which calls this) runs elevated.
201
+ // XML registration (vs schtasks flags) gives us access to settings like
202
+ // MultipleInstancesPolicy. S4U keeps the console hidden unless
203
+ // foreground_mode is set. Works unelevated because the caller (daemon)
204
+ // runs elevated.
220
205
  const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
221
206
  const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
222
207
  try {
223
- // schtasks /xml requires UTF-16LE with BOM
208
+ // schtasks /xml requires UTF-16LE with BOM.
224
209
  const bom = Buffer.from([0xFF, 0xFE]);
225
210
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
226
211
  execFileSync("schtasks", [
@@ -239,9 +224,7 @@ export class WindowsPlatform implements PlatformService {
239
224
  const tn = schtasksTaskName(taskId);
240
225
  try {
241
226
  execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
242
- } catch {
243
- // Task might not exist — that's fine
244
- }
227
+ } catch { /* task may not exist */ }
245
228
  }
246
229
 
247
230
  async startTask(taskId: string): Promise<void> {
@@ -255,8 +238,8 @@ export class WindowsPlatform implements PlatformService {
255
238
  }
256
239
 
257
240
  async stopTask(taskId: string): Promise<void> {
258
- // Try to kill the entire process tree via the PID recorded in status.json.
259
- // schtasks /end only kills the top-level process, leaving agent children orphaned.
241
+ // schtasks /end leaves agent children orphaned, so kill the process tree
242
+ // via the PID recorded in status.json first.
260
243
  try {
261
244
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
262
245
  const status = readTaskStatus(taskDir);
@@ -265,10 +248,9 @@ export class WindowsPlatform implements PlatformService {
265
248
  return;
266
249
  }
267
250
  } catch {
268
- // PID may be stale or config unavailable; fall through to schtasks /end
251
+ // PID may be stale or config unavailable; fall through to schtasks /end.
269
252
  }
270
253
 
271
- // Fallback: schtasks /end (kills top-level process only)
272
254
  const tn = schtasksTaskName(taskId);
273
255
  try {
274
256
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -279,7 +261,6 @@ export class WindowsPlatform implements PlatformService {
279
261
  }
280
262
 
281
263
  isTaskRunning(taskId: string): boolean {
282
- // Check Task Scheduler first (for scheduled/on-demand runs)
283
264
  const tn = schtasksTaskName(taskId);
284
265
  try {
285
266
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
@@ -289,18 +270,17 @@ export class WindowsPlatform implements PlatformService {
289
270
  if (out.includes('"Running"')) return true;
290
271
  } catch { /* task may not exist in scheduler */ }
291
272
 
292
- // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
273
+ // Follow-up runs are spawned directly (not via schtasks), so check PID too.
293
274
  try {
294
275
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
295
276
  const status = readTaskStatus(taskDir);
296
277
  if (status?.pid) {
297
- // tasklist exits 0 if the PID is found
298
278
  execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
299
279
  encoding: "utf-8",
300
280
  windowsHide: true,
301
281
  stdio: "pipe",
302
282
  });
303
- // tasklist always exits 0; check if output contains the PID
283
+ // tasklist always exits 0, so match the output for the PID.
304
284
  const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
305
285
  encoding: "utf-8",
306
286
  windowsHide: true,
@@ -314,7 +294,6 @@ export class WindowsPlatform implements PlatformService {
314
294
  }
315
295
 
316
296
  getGuiEnv(): Record<string, string> {
317
- // Windows GUI is always available — no special env vars needed
318
297
  return {};
319
298
  }
320
299
  }
@@ -17,9 +17,6 @@ import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./comma
17
17
  import { clearTaskQueue } from "./event-queues.js";
18
18
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
19
19
 
20
- /**
21
- * Parse RESULT frontmatter and conversation messages.
22
- */
23
20
  export function parseResultFrontmatter(raw: string): Record<string, unknown> {
24
21
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
25
22
  if (!fmMatch) return { messages: [] };
@@ -33,19 +30,16 @@ export function parseResultFrontmatter(raw: string): Record<string, unknown> {
33
30
 
34
31
  const messages = parseConversationMessages(fmMatch[2]);
35
32
 
36
- // Derive state from status messages — just look at the last one
37
33
  const statusMessages = messages.filter((m: ConversationMessage) => m.role === "status");
38
34
  const lastStatus = statusMessages[statusMessages.length - 1];
39
35
  const startedMsg = statusMessages.find((m: ConversationMessage) => m.type === "started");
40
36
  const terminalStates = ["finished", "failed", "aborted"];
41
37
  const terminalMsg = [...statusMessages].reverse().find((m: ConversationMessage) => terminalStates.includes(m.type ?? ""));
42
38
 
43
- // If last status is "started" (or continuation like "confirmation"/"monitoring"),
44
- // determine if it's a task run or follow-up
45
39
  const activeStates = ["started", "monitoring", "confirmation"];
46
40
  let runningState: string | undefined;
47
41
  if (lastStatus?.type === "monitoring") {
48
- // Only show monitoring if no assistant/user message came after it
42
+ // Show "monitoring" only if no assistant/user message followed it.
49
43
  const lastStatusIdx = messages.lastIndexOf(lastStatus);
50
44
  const hasMessageAfter = messages.slice(lastStatusIdx + 1).some((m: ConversationMessage) => m.role === "assistant" || m.role === "user");
51
45
  runningState = hasMessageAfter ? "started" : "monitoring";
@@ -65,16 +59,13 @@ export function parseResultFrontmatter(raw: string): Record<string, unknown> {
65
59
  };
66
60
  }
67
61
 
68
- /**
69
- * Parse conversation messages from the body of a RESULT file.
70
- */
71
62
  function parseConversationMessages(body: string): ConversationMessage[] {
72
63
  const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
73
64
  const messages: ConversationMessage[] = [];
74
65
  const matches = [...body.matchAll(delimiterRegex)];
75
66
 
76
67
  if (matches.length === 0) {
77
- // No delimiters — treat entire body as single assistant message if non-empty
68
+ // No delimiters — treat entire body as a single assistant message.
78
69
  const content = body.trim();
79
70
  if (content) {
80
71
  messages.push({ role: "assistant", time: 0, content });
@@ -106,10 +97,6 @@ function parseAttr(attrs: string, name: string): string | undefined {
106
97
  return match ? match[1] : undefined;
107
98
  }
108
99
 
109
- /**
110
- * Generate a concise task name from a user prompt using the given agent.
111
- * Falls back to the raw prompt on failure.
112
- */
113
100
  async function generateName(
114
101
  projectRoot: string,
115
102
  userPrompt: string,
@@ -136,9 +123,6 @@ async function generateName(
136
123
  /** Active follow-up child processes, keyed by "taskId:runId". */
137
124
  const activeFollowups = new Map<string, ChildProcess>();
138
125
 
139
- /**
140
- * Create a transport-agnostic RPC handler bound to the given config.
141
- */
142
126
  export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
143
127
  function flattenTask(task: ParsedTask) {
144
128
  const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
@@ -150,8 +134,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
150
134
  }
151
135
 
152
136
  async function handleRpc(request: RpcMessage): Promise<unknown> {
153
- // Client token validation: skip for trusted localhost requests and
154
- // task.user_input (server-originated push responses; gated by getPending instead)
137
+ // task.user_input comes from server-originated push responses; it's gated
138
+ // by getPending() rather than a client token.
155
139
  const skipAuth = request.method === "task.user_input";
156
140
  if (!skipAuth && !request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
157
141
  return { error: "Unauthorized" };
@@ -159,11 +143,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
159
143
 
160
144
  switch (request.method) {
161
145
  case "host.info": {
162
- // Bootstrap metadata the PWA needs on connect, independent of which tab
163
- // is active. Includes any prompts already waiting so a reconnecting
164
- // PWA can render their modals without replaying events.
165
146
  const capabilities: Record<string, string | null> = {};
166
- for (const capability of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "send-email"] as const) {
147
+ for (const capability of ["location", "notifications", "sms-read", "sms-send", "contacts", "calendar", "alarm", "battery", "dnd", "send-email"] as const) {
167
148
  capabilities[capability] = getCapabilityDevice(capability)?.clientToken ?? null;
168
149
  }
169
150
  return {
@@ -250,11 +231,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
250
231
  const taskDir = getTaskDir(config.projectRoot, params.id);
251
232
  const existing = parseTaskFile(taskDir);
252
233
 
253
- // Detect whether name needs regeneration
254
234
  const promptChanged = params.user_prompt !== undefined && params.user_prompt !== existing.frontmatter.user_prompt;
255
235
  const agentChanged = params.agent !== undefined && params.agent !== existing.frontmatter.agent;
256
236
 
257
- // Merge updates
258
237
  if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
259
238
  if (params.agent !== undefined) existing.frontmatter.agent = params.agent;
260
239
  if (params.schedule_type !== undefined) {
@@ -287,7 +266,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
287
266
  }
288
267
  }
289
268
 
290
- // Regenerate name when prompt or agent changes
291
269
  if (promptChanged || agentChanged) {
292
270
  existing.frontmatter.name = existing.frontmatter.user_prompt.length <= 50
293
271
  ? existing.frontmatter.user_prompt
@@ -296,8 +274,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
296
274
 
297
275
  writeTaskFile(taskDir, existing);
298
276
 
299
- // Update timers — installTaskTimer overwrites in-place (schtasks /f,
300
- // systemd unit rewrite) without killing a running task process.
277
+ // installTaskTimer overwrites in-place (schtasks /f, systemd unit rewrite)
278
+ // without killing a running task process.
301
279
  getPlatform().installTaskTimer(config, existing);
302
280
 
303
281
  return flattenTask(existing);
@@ -341,13 +319,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
341
319
  };
342
320
 
343
321
  writeTaskFile(taskDir, task);
344
- // Do NOT append to tasks.jsonl — this is a one-off run
322
+ // One-off run: do NOT append to tasks.jsonl.
345
323
 
346
- // Create initial result file so it appears in runs list immediately
347
324
  const runId = createRunDir(taskDir, name, Date.now(), params.agent);
348
325
  appendHistory(config.projectRoot, { task_id: id, run_id: runId });
349
326
 
350
- // Spawn `palmier run <id>` directly as a detached process
351
327
  const script = process.argv[1] || "palmier";
352
328
  const child = spawn(process.execPath, [script, "run", id], {
353
329
  detached: true,
@@ -365,13 +341,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
365
341
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
366
342
  const platform = getPlatform();
367
343
 
368
- // If the task is already running, kill the stale process and start fresh
369
344
  if (platform.isTaskRunning(params.id)) {
370
345
  console.log(`[task.run] Task ${params.id} is already running, killing stale process`);
371
346
  await platform.stopTask(params.id);
372
347
  }
373
348
 
374
- // Create initial result file so it appears in runs list immediately
375
349
  const runTask = parseTaskFile(runTaskDir);
376
350
  const taskRunId = createRunDir(runTaskDir, runTask.frontmatter.name, Date.now(), runTask.frontmatter.agent);
377
351
  appendHistory(config.projectRoot, { task_id: params.id, run_id: taskRunId });
@@ -399,7 +373,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
399
373
  const followupTask = parseTaskFile(followupTaskDir);
400
374
  const followupRunDir = getRunDir(followupTaskDir, params.run_id);
401
375
 
402
- // Append user message + started status
403
376
  appendRunMessage(followupTaskDir, params.run_id, {
404
377
  role: "user",
405
378
  time: Date.now(),
@@ -413,13 +386,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
413
386
  });
414
387
  await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
415
388
 
416
- // Fire-and-forget: invoke agent inline as a child of the serve process
417
389
  const followupAgent = getAgent(followupTask.frontmatter.agent);
418
390
  const { command: cmd, args: cmdArgs, stdin, env: followupAgentEnv } = followupAgent.getTaskRunCommandLine(
419
391
  followupTask, params.message, followupTask.frontmatter.yolo_mode ? "yolo" : followupTask.frontmatter.permissions,
420
392
  );
421
393
 
422
- // Spawn directly via crossSpawn so we can track and kill the child
423
394
  const child = crossSpawn(cmd, cmdArgs, {
424
395
  cwd: followupRunDir,
425
396
  stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
@@ -429,14 +400,13 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
429
400
  if (stdin != null) child.stdin!.end(stdin);
430
401
  activeFollowups.set(followupKey, child);
431
402
 
432
- // Collect output
433
403
  const chunks: Buffer[] = [];
434
404
  child.stdout?.on("data", (d: Buffer) => chunks.push(d));
435
405
  child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
436
406
 
437
407
  child.on("close", async (code: number | null) => {
438
408
  activeFollowups.delete(followupKey);
439
- // If killed by stop_followup, the stopped status is already written
409
+ // stop_followup already wrote the stopped status.
440
410
  if (child.killed) return;
441
411
 
442
412
  const output = Buffer.concat(chunks).toString("utf-8");
@@ -484,7 +454,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
484
454
  return { error: "No active follow-up for this run" };
485
455
  }
486
456
 
487
- // Kill the child process tree
488
457
  if (process.platform === "win32" && child.pid) {
489
458
  try {
490
459
  const { execFileSync } = await import("child_process");
@@ -494,7 +463,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
494
463
  child.kill();
495
464
  }
496
465
 
497
- // Append stopped status (child.killed prevents the close handler from writing)
466
+ // child.killed stops the close handler from double-writing the status.
498
467
  const stopTaskDir = getTaskDir(config.projectRoot, params.id);
499
468
  appendRunMessage(stopTaskDir, params.run_id, {
500
469
  role: "status",
@@ -510,17 +479,16 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
510
479
  case "task.abort": {
511
480
  const params = request.params as { id: string };
512
481
  const abortTaskDir = getTaskDir(config.projectRoot, params.id);
513
- // Read the PID before overwriting status — stopTask needs it to
514
- // kill the entire process tree on Windows.
482
+ // Read PID before overwriting — stopTask needs it to kill the
483
+ // process tree on Windows.
515
484
  const abortPrevStatus = readTaskStatus(abortTaskDir);
516
- // Write abort status BEFORE killing so the dying process's signal
517
- // handler can detect this was RPC-initiated and skip publishing.
485
+ // Write abort status before killing so the dying process's signal
486
+ // handler sees this was RPC-initiated and skips publishing.
518
487
  writeTaskStatus(abortTaskDir, {
519
488
  running_state: "aborted",
520
489
  time_stamp: Date.now(),
521
490
  ...(abortPrevStatus?.pid ? { pid: abortPrevStatus.pid } : {}),
522
491
  });
523
- // Append aborted status to the latest run
524
492
  try {
525
493
  const runDirs = fs.readdirSync(abortTaskDir)
526
494
  .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(abortTaskDir, f, "TASKRUN.md")))
@@ -543,7 +511,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
543
511
  console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
544
512
  return { error: `Failed to abort task: ${e.stderr || e.message}` };
545
513
  }
546
- // Notify connected clients (NATS + HTTP SSE if LAN server is running)
547
514
  const abortPayload: Record<string, unknown> = { event_type: "running-state", running_state: "aborted" };
548
515
  await publishHostEvent(nc, config.hostId, params.id, abortPayload);
549
516
  return { ok: true, task_id: params.id };
@@ -18,19 +18,12 @@ export interface SpawnStreamingOptions {
18
18
  }
19
19
 
20
20
  /**
21
- * Spawn a command with shell interpretation, returning the ChildProcess
22
- * with stdout piped for line-by-line reading.
23
- *
24
- * Unlike spawnCommand(), this does NOT collect output into a buffer
25
- * the caller reads from child.stdout directly (e.g. via readline).
26
- *
27
- * shell: true is required so users can write piped commands like
28
- * "tail -f log | grep ERROR".
29
- *
30
- * stdin is "pipe" (kept open, never written to) rather than "ignore"
31
- * (/dev/null). Some long-running commands exit when stdin is closed/EOF.
32
- * This differs from spawnCommand() which uses "ignore" because agent
33
- * CLIs like `claude -p` hang on an open stdin pipe.
21
+ * Spawn with shell interpretation for piped commands like "tail -f log | grep".
22
+ * Returns the ChildProcess with stdout piped so the caller can read it directly
23
+ * (contrast with spawnCommand which buffers). stdin is "pipe" (held open, not
24
+ * written to): some long-running commands exit on stdin EOF. Agent CLIs like
25
+ * `claude -p` conversely hang on an open stdin, which is why spawnCommand
26
+ * defaults to "ignore".
34
27
  */
35
28
  export function spawnStreamingCommand(
36
29
  command: string,
@@ -60,18 +53,10 @@ export interface SpawnCommandOptions {
60
53
  }
61
54
 
62
55
  /**
63
- * Spawn a command with additional arguments.
64
- *
65
- * Uses cross-spawn to correctly resolve .cmd shims and escape arguments
66
- * on Windows without shell: true (which mishandles special characters).
67
- *
68
- * On other platforms the command is executed directly (no shell), so no
69
- * escaping is needed.
70
- *
71
- * stdin is set to "ignore" by default (equivalent to < /dev/null) because
72
- * tools like `claude -p` hang indefinitely on an open stdin pipe.
73
- * When opts.stdin is provided, stdin is set to "pipe" and the string is
74
- * written to the process before closing the pipe.
56
+ * cross-spawn resolves .cmd shims and escapes args on Windows without shell:true
57
+ * (which mishandles special characters). stdin defaults to "ignore" because
58
+ * tools like `claude -p` hang on an open stdin pipe; pass opts.stdin to write
59
+ * a string and then close the pipe.
75
60
  */
76
61
  export interface SpawnCommandResult {
77
62
  output: string;
@@ -84,8 +69,7 @@ export function spawnCommand(
84
69
  opts: SpawnCommandOptions,
85
70
  ): Promise<SpawnCommandResult> {
86
71
  return new Promise<SpawnCommandResult>((resolve, reject) => {
87
- // Collapse newlines to spaces — cmd.exe can't handle literal newlines
88
- // in arguments, and CLI prompts don't need them.
72
+ // cmd.exe can't handle literal newlines in arguments.
89
73
  const finalArgs = process.platform === "win32"
90
74
  ? args.map((a) => a.replace(/[\r\n]+/g, " "))
91
75
  : args;