palmier 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +9 -2
  2. package/dist/agents/agent.d.ts +2 -2
  3. package/dist/agents/aider.d.ts +1 -1
  4. package/dist/agents/aider.js +2 -5
  5. package/dist/agents/claude.d.ts +1 -1
  6. package/dist/agents/claude.js +2 -5
  7. package/dist/agents/cline.d.ts +1 -1
  8. package/dist/agents/cline.js +2 -5
  9. package/dist/agents/codex.d.ts +1 -1
  10. package/dist/agents/codex.js +2 -5
  11. package/dist/agents/copilot.d.ts +1 -1
  12. package/dist/agents/copilot.js +2 -5
  13. package/dist/agents/cursor.d.ts +1 -1
  14. package/dist/agents/cursor.js +2 -5
  15. package/dist/agents/deepagents.d.ts +1 -1
  16. package/dist/agents/deepagents.js +2 -5
  17. package/dist/agents/droid.d.ts +1 -1
  18. package/dist/agents/droid.js +2 -5
  19. package/dist/agents/gemini.d.ts +1 -1
  20. package/dist/agents/gemini.js +2 -5
  21. package/dist/agents/goose.d.ts +1 -1
  22. package/dist/agents/goose.js +2 -5
  23. package/dist/agents/hermes.d.ts +1 -1
  24. package/dist/agents/hermes.js +2 -5
  25. package/dist/agents/kimi.d.ts +1 -1
  26. package/dist/agents/kimi.js +2 -5
  27. package/dist/agents/kiro.d.ts +1 -1
  28. package/dist/agents/kiro.js +2 -5
  29. package/dist/agents/openclaw.d.ts +1 -1
  30. package/dist/agents/openclaw.js +2 -5
  31. package/dist/agents/opencode.d.ts +1 -1
  32. package/dist/agents/opencode.js +2 -5
  33. package/dist/agents/qoder.d.ts +1 -1
  34. package/dist/agents/qoder.js +2 -5
  35. package/dist/agents/qwen.d.ts +1 -1
  36. package/dist/agents/qwen.js +2 -5
  37. package/dist/agents/shared-prompt.js +1 -1
  38. package/dist/commands/run.js +1 -2
  39. package/dist/commands/serve.js +16 -0
  40. package/dist/mcp-handler.d.ts +3 -0
  41. package/dist/mcp-handler.js +59 -3
  42. package/dist/mcp-tools.d.ts +16 -1
  43. package/dist/mcp-tools.js +24 -2
  44. package/dist/notification-store.d.ts +13 -0
  45. package/dist/notification-store.js +19 -0
  46. package/dist/pwa/assets/{index-C8vJwUNi.js → index-DLxrL0hR.js} +42 -42
  47. package/dist/pwa/assets/{web-NxTETXZK.js → web-CBI458eN.js} +1 -1
  48. package/dist/pwa/assets/{web-6UChJFov.js → web-HDs03L2B.js} +1 -1
  49. package/dist/pwa/index.html +1 -1
  50. package/dist/pwa/service-worker.js +1 -1
  51. package/dist/rpc-handler.js +27 -67
  52. package/dist/task.js +2 -3
  53. package/dist/transports/http-transport.js +51 -3
  54. package/dist/types.d.ts +0 -1
  55. package/package.json +2 -2
  56. package/palmier-server/README.md +1 -1
  57. package/palmier-server/pwa/src/components/PlanDialog.tsx +5 -12
  58. package/palmier-server/pwa/src/components/TaskForm.tsx +6 -15
  59. package/palmier-server/pwa/src/constants.ts +1 -1
  60. package/palmier-server/pwa/src/types.ts +0 -1
  61. package/palmier-server/server/src/index.ts +2 -0
  62. package/palmier-server/server/src/routes/device.ts +32 -0
  63. package/palmier-server/spec.md +13 -12
  64. package/src/agents/agent.ts +2 -2
  65. package/src/agents/aider.ts +2 -5
  66. package/src/agents/claude.ts +2 -5
  67. package/src/agents/cline.ts +2 -5
  68. package/src/agents/codex.ts +2 -5
  69. package/src/agents/copilot.ts +2 -5
  70. package/src/agents/cursor.ts +2 -5
  71. package/src/agents/deepagents.ts +2 -5
  72. package/src/agents/droid.ts +2 -5
  73. package/src/agents/gemini.ts +2 -5
  74. package/src/agents/goose.ts +2 -5
  75. package/src/agents/hermes.ts +2 -5
  76. package/src/agents/kimi.ts +2 -5
  77. package/src/agents/kiro.ts +2 -5
  78. package/src/agents/openclaw.ts +2 -5
  79. package/src/agents/opencode.ts +2 -5
  80. package/src/agents/qoder.ts +2 -5
  81. package/src/agents/qwen.ts +2 -5
  82. package/src/agents/shared-prompt.ts +1 -1
  83. package/src/commands/run.ts +1 -2
  84. package/src/commands/serve.ts +16 -1
  85. package/src/mcp-handler.ts +68 -3
  86. package/src/mcp-tools.ts +48 -2
  87. package/src/notification-store.ts +30 -0
  88. package/src/rpc-handler.ts +29 -71
  89. package/src/task.ts +2 -3
  90. package/src/transports/http-transport.ts +49 -3
  91. package/src/types.ts +0 -1
  92. package/test/agent-instructions.test.ts +117 -19
  93. package/test/agent-output-parsing.test.ts +1 -0
  94. package/test/notification-store.test.ts +57 -0
  95. package/test/task-parsing.test.ts +3 -3
  96. package/dist/commands/plan-generation.md +0 -22
  97. package/src/commands/plan-generation.md +0 -22
  98. package/test/fixtures/agent-instructions-snapshot.md +0 -58
@@ -1,9 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
- import { fileURLToPath } from "url";
5
4
  import { spawn, type ChildProcess } from "child_process";
6
- import { parse as parseYaml } from "yaml";
7
5
  import { type NatsConnection } from "nats";
8
6
  import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
9
7
  import { resolvePending, getPending } from "./pending-requests.js";
@@ -18,13 +16,6 @@ import { currentVersion, performUpdate } from "./update-checker.js";
18
16
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
19
17
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
20
18
 
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
-
23
- const PLAN_GENERATION_PROMPT = fs.readFileSync(
24
- path.join(__dirname, "commands", "plan-generation.md"),
25
- "utf-8",
26
- );
27
-
28
19
  /**
29
20
  * Parse RESULT frontmatter and conversation messages.
30
21
  */
@@ -115,39 +106,30 @@ function parseAttr(attrs: string, name: string): string | undefined {
115
106
  }
116
107
 
117
108
  /**
118
- * Run plan generation for a task prompt using the given agent.
119
- * Returns the generated plan body and task name.
109
+ * Generate a concise task name from a user prompt using the given agent.
110
+ * Falls back to the raw prompt on failure.
120
111
  */
121
- async function generatePlan(
112
+ async function generateName(
122
113
  projectRoot: string,
123
114
  userPrompt: string,
124
115
  agentName: string,
125
- ): Promise<{ name: string; body: string }> {
126
- const fullPrompt = PLAN_GENERATION_PROMPT + userPrompt;
127
- const planAgent = getAgent(agentName);
128
- const { command, args, stdin, env: agentEnv } = planAgent.getPlanGenerationCommandLine(fullPrompt);
129
-
130
- const { output } = await spawnCommand(command, args, {
131
- cwd: projectRoot,
132
- timeout: 120_000,
133
- stdin,
134
- ...(agentEnv ? { env: agentEnv } : {}),
135
- });
136
-
137
- let name = "";
138
- const trimmed = output.trim();
139
- let body = trimmed;
140
- const fmMatch = trimmed.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
141
- if (fmMatch) {
142
- try {
143
- const fm = parseYaml(fmMatch[1]) as { task_name?: string };
144
- name = fm.task_name ?? "";
145
- } catch {
146
- // If frontmatter parsing fails, treat entire output as body
147
- }
148
- body = fmMatch[2].trimStart();
116
+ ): Promise<string> {
117
+ const prompt = `Generate a concise 3-6 word name for this task. Reply with ONLY the name, nothing else.\n\nTask: ${userPrompt}`;
118
+ const agent = getAgent(agentName);
119
+ const { command, args, stdin, env: agentEnv } = agent.getPromptCommandLine(prompt);
120
+
121
+ try {
122
+ const { output } = await spawnCommand(command, args, {
123
+ cwd: projectRoot,
124
+ timeout: 30_000,
125
+ stdin,
126
+ ...(agentEnv ? { env: agentEnv } : {}),
127
+ });
128
+ const name = output.trim().replace(/^["']|["']$/g, "").slice(0, 80);
129
+ return name || userPrompt;
130
+ } catch {
131
+ return userPrompt;
149
132
  }
150
- return { name, body };
151
133
  }
152
134
 
153
135
  /** Active follow-up child processes, keyed by "taskId:runId". */
@@ -163,7 +145,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
163
145
  const pending = getPending(task.frontmatter.id);
164
146
  return {
165
147
  ...task.frontmatter,
166
- body: task.body,
167
148
  status: status ? {
168
149
  ...status,
169
150
  ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
@@ -215,25 +196,13 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
215
196
  command?: string;
216
197
  };
217
198
 
218
- // Only generate a plan for longer prompts that benefit from it
219
- let name = "";
220
- let body = "";
221
- if (params.user_prompt.length <= 50) {
222
- name = params.user_prompt;
223
- } else {
224
- try {
225
- const plan = await generatePlan(config.projectRoot, params.user_prompt, params.agent);
226
- name = plan.name;
227
- body = plan.body;
228
- } catch (err: unknown) {
229
- const error = err as { stdout?: string; stderr?: string };
230
- return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
231
- }
232
- }
199
+ const name = params.user_prompt.length <= 50
200
+ ? params.user_prompt
201
+ : await generateName(config.projectRoot, params.user_prompt, params.agent);
233
202
 
234
203
  const id = randomUUID();
235
204
  const taskDir = getTaskDir(config.projectRoot, id);
236
- const task = {
205
+ const task: ParsedTask = {
237
206
  frontmatter: {
238
207
  id,
239
208
  name,
@@ -246,7 +215,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
246
215
  ...(params.foreground_mode ? { foreground_mode: true } : {}),
247
216
  ...(params.command ? { command: params.command } : {}),
248
217
  },
249
- body,
250
218
  };
251
219
 
252
220
  writeTaskFile(taskDir, task);
@@ -272,10 +240,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
272
240
  const taskDir = getTaskDir(config.projectRoot, params.id);
273
241
  const existing = parseTaskFile(taskDir);
274
242
 
275
- // Detect whether plan needs regeneration
243
+ // Detect whether name needs regeneration
276
244
  const promptChanged = params.user_prompt !== undefined && params.user_prompt !== existing.frontmatter.user_prompt;
277
245
  const agentChanged = params.agent !== undefined && params.agent !== existing.frontmatter.agent;
278
- const needsRegeneration = promptChanged || agentChanged || !existing.body;
279
246
 
280
247
  // Merge updates
281
248
  if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
@@ -297,19 +264,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
297
264
  }
298
265
  }
299
266
 
300
- // Regenerate plan if needed (only for longer prompts)
301
- if (existing.frontmatter.user_prompt.length <= 50) {
302
- existing.frontmatter.name = existing.frontmatter.user_prompt;
303
- existing.body = "";
304
- } else if (needsRegeneration) {
305
- try {
306
- const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
307
- existing.frontmatter.name = plan.name;
308
- existing.body = plan.body;
309
- } catch (err: unknown) {
310
- const error = err as { stdout?: string; stderr?: string };
311
- return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
312
- }
267
+ // Regenerate name when prompt or agent changes
268
+ if (promptChanged || agentChanged) {
269
+ existing.frontmatter.name = existing.frontmatter.user_prompt.length <= 50
270
+ ? existing.frontmatter.user_prompt
271
+ : await generateName(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
313
272
  }
314
273
 
315
274
  writeTaskFile(taskDir, existing);
@@ -356,7 +315,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
356
315
  ...(params.foreground_mode ? { foreground_mode: true } : {}),
357
316
  ...(params.command ? { command: params.command } : {}),
358
317
  },
359
- body: "",
360
318
  };
361
319
 
362
320
  writeTaskFile(taskDir, task);
package/src/task.ts CHANGED
@@ -29,7 +29,6 @@ export function parseTaskContent(content: string): ParsedTask {
29
29
  }
30
30
 
31
31
  const frontmatter = parseYaml(match[1]) as TaskFrontmatter;
32
- const body = (match[2] || "").trim();
33
32
 
34
33
  if (!frontmatter.id) {
35
34
  throw new Error("TASK.md frontmatter must include at least: id");
@@ -39,7 +38,7 @@ export function parseTaskContent(content: string): ParsedTask {
39
38
  frontmatter.agent ??= "claude";
40
39
  frontmatter.triggers_enabled ??= true;
41
40
 
42
- return { frontmatter, body };
41
+ return { frontmatter };
43
42
  }
44
43
 
45
44
  /**
@@ -50,7 +49,7 @@ export function writeTaskFile(taskDir: string, task: ParsedTask): void {
50
49
  fs.mkdirSync(taskDir, { recursive: true });
51
50
 
52
51
  const yamlStr = stringifyYaml(task.frontmatter).trim();
53
- const content = `---\n${yamlStr}\n---\n${task.body}\n`;
52
+ const content = `---\n${yamlStr}\n---\n`;
54
53
 
55
54
  const filePath = path.join(taskDir, "TASK.md");
56
55
  fs.writeFileSync(filePath, content, "utf-8");
@@ -6,9 +6,10 @@ import { validateClient, addClient } from "../client-store.js";
6
6
  import { registerPending } from "../pending-requests.js";
7
7
  import * as fs from "node:fs";
8
8
  import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
9
- import { agentToolMap, ToolError, type ToolContext } from "../mcp-tools.js";
10
- import { handleMcpRequest, getAgentName } from "../mcp-handler.js";
9
+ import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mcp-tools.js";
10
+ import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
11
11
  import { getTaskDir } from "../task.js";
12
+ import { onNotificationsChanged } from "../notification-store.js";
12
13
 
13
14
  // ── Bundled PWA asset serving ───────────────────────────────────────────
14
15
 
@@ -102,9 +103,28 @@ export async function startHttpTransport(
102
103
  onReady?: () => void,
103
104
  ): Promise<void> {
104
105
  const sseClients = new Set<SseClient>();
106
+ const mcpStreams = new Map<string, http.ServerResponse>();
105
107
  const lanEnabled = config.lanEnabled ?? false;
106
108
  const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
107
109
 
110
+ /** Push notifications/resources/updated to all MCP clients subscribed to the given URI. */
111
+ function broadcastResourceUpdated(uri: string) {
112
+ const subs = getResourceSubscriptions();
113
+ for (const [sessionId, uris] of subs) {
114
+ if (!uris.has(uri)) continue;
115
+ const stream = mcpStreams.get(sessionId);
116
+ if (!stream) continue;
117
+ stream.write(`data: ${JSON.stringify({
118
+ jsonrpc: "2.0",
119
+ method: "notifications/resources/updated",
120
+ params: { uri },
121
+ })}\n\n`);
122
+ }
123
+ }
124
+
125
+ // Wire up resource change listeners
126
+ onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
127
+
108
128
  // If a pairing code is provided, pre-register it
109
129
  if (pairingCode) {
110
130
  const EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -182,7 +202,24 @@ export async function startHttpTransport(
182
202
  if (result.sessionId) {
183
203
  res.setHeader("Mcp-Session-Id", result.sessionId);
184
204
  }
185
- sendJson(res, 200, result.body);
205
+ if (result.stream && sessionId) {
206
+ // Keep response open as SSE stream for server-initiated notifications
207
+ res.writeHead(200, {
208
+ "Content-Type": "text/event-stream",
209
+ "Cache-Control": "no-cache",
210
+ "Connection": "keep-alive",
211
+ });
212
+ res.write(`data: ${JSON.stringify(result.body)}\n\n`);
213
+ mcpStreams.set(sessionId, res);
214
+ const heartbeat = setInterval(() => { res.write(":heartbeat\n\n"); }, 15_000);
215
+ req.on("close", () => {
216
+ clearInterval(heartbeat);
217
+ mcpStreams.delete(sessionId);
218
+ getResourceSubscriptions().delete(sessionId);
219
+ });
220
+ } else {
221
+ sendJson(res, 200, result.body);
222
+ }
186
223
  } catch (err) {
187
224
  sendJson(res, 500, { error: String(err) });
188
225
  }
@@ -220,6 +257,15 @@ export async function startHttpTransport(
220
257
  return;
221
258
  }
222
259
 
260
+ // ── Auto-generated REST endpoints from MCP resource registry ────
261
+
262
+ const matchedResource = req.method === "GET" && agentResources.find((r) => r.restPath === pathname);
263
+ if (matchedResource) {
264
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
265
+ sendJson(res, 200, matchedResource.read());
266
+ return;
267
+ }
268
+
223
269
  // ── Localhost-only endpoints (no auth) ─────────────────────────────
224
270
 
225
271
  if (req.method === "POST" && pathname === "/event") {
package/src/types.ts CHANGED
@@ -36,7 +36,6 @@ export interface Trigger {
36
36
 
37
37
  export interface ParsedTask {
38
38
  frontmatter: TaskFrontmatter;
39
- body: string;
40
39
  }
41
40
 
42
41
  /**
@@ -3,18 +3,72 @@ import assert from "node:assert/strict";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { fileURLToPath } from "url";
6
- import { generateEndpointDocs, agentTools } from "../src/mcp-tools.js";
6
+ import { generateEndpointDocs, type ToolDefinition, type ResourceDefinition } from "../src/mcp-tools.js";
7
7
 
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const templatePath = path.join(__dirname, "..", "src", "agents", "agent-instructions.md");
10
10
  const template = fs.readFileSync(templatePath, "utf-8");
11
11
 
12
+ /** Mock tools with a known, stable shape for testing */
13
+ const mockTools: ToolDefinition[] = [
14
+ {
15
+ name: "mock-action",
16
+ description: [
17
+ "Perform a mock action.",
18
+ 'Response: `{"ok": true}` on success.',
19
+ ],
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ title: { type: "string", description: "Action title" },
24
+ detail: { type: "string", description: "Optional detail" },
25
+ },
26
+ required: ["title"],
27
+ },
28
+ handler: async () => ({ ok: true }),
29
+ },
30
+ {
31
+ name: "mock-query",
32
+ description: [
33
+ "Query mock data from the device.",
34
+ "Blocks until the device responds.",
35
+ 'Response: `{"data": ...}` on success.',
36
+ ],
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ tags: {
41
+ type: "array",
42
+ items: { type: "string" },
43
+ description: "Filter tags",
44
+ },
45
+ },
46
+ },
47
+ handler: async () => ({ data: [] }),
48
+ },
49
+ ];
50
+
51
+ /** Mock resources with a known, stable shape for testing */
52
+ const mockResources: ResourceDefinition[] = [
53
+ {
54
+ uri: "mock://data",
55
+ name: "Mock Data",
56
+ description: [
57
+ "Get mock data from the device.",
58
+ "Response: JSON array of data objects.",
59
+ ],
60
+ mimeType: "application/json",
61
+ restPath: "/mock-data",
62
+ read: () => [],
63
+ },
64
+ ];
65
+
12
66
  /** Minimal replica of getAgentInstructions that doesn't need host.json */
13
- function buildInstructions(taskId: string, skipPermissions?: boolean): string {
67
+ function buildInstructions(taskId: string, opts?: { skipPermissions?: boolean }): string {
14
68
  let instructions = template
15
- .replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(9966, taskId))
69
+ .replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(9966, taskId, mockTools, mockResources))
16
70
  .replace(/\{\{TASK_DESCRIPTION\}\}/g, "Test task prompt");
17
- if (skipPermissions) {
71
+ if (opts?.skipPermissions) {
18
72
  instructions = instructions.replace(/## Permissions\r?\n[\s\S]*?(?=## |\r?\n---)/m, "");
19
73
  }
20
74
  return instructions;
@@ -28,13 +82,13 @@ describe("getAgentInstructions", () => {
28
82
  });
29
83
 
30
84
  it("strips Permissions section when skipPermissions is true", () => {
31
- const result = buildInstructions("test-task-id", true);
85
+ const result = buildInstructions("test-task-id", { skipPermissions: true });
32
86
  assert.doesNotMatch(result, /## Permissions/);
33
87
  assert.doesNotMatch(result, /PALMIER_PERMISSION/);
34
88
  });
35
89
 
36
90
  it("preserves other sections when Permissions is stripped", () => {
37
- const result = buildInstructions("test-task-id", true);
91
+ const result = buildInstructions("test-task-id", { skipPermissions: true });
38
92
  assert.match(result, /## Reporting Output/);
39
93
  assert.match(result, /## Completion/);
40
94
  assert.match(result, /## HTTP Endpoints/);
@@ -60,22 +114,41 @@ describe("getAgentInstructions", () => {
60
114
  const result = buildInstructions("test");
61
115
  assert.match(result, /Test task prompt/);
62
116
  });
63
- });
64
117
 
65
- describe("full agent instruction snapshot", () => {
66
- it("matches the expected full text exactly", () => {
67
- const result = buildInstructions("test-task-id").replace(/\r\n/g, "\n").trimEnd();
68
- const snapshotPath = path.join(__dirname, "fixtures", "agent-instructions-snapshot.md");
69
- const expected = fs.readFileSync(snapshotPath, "utf-8").replace(/\r\n/g, "\n").trimEnd();
70
- assert.equal(result, expected);
71
- });
72
118
  });
73
119
 
120
+
74
121
  describe("generateEndpointDocs", () => {
75
- const docs = generateEndpointDocs(9966, "test-id");
122
+ const docs = generateEndpointDocs(9966, "test-id", mockTools, mockResources);
123
+
124
+ it("matches expected full output", () => {
125
+ const expected = [
126
+ "The following HTTP endpoints are available during task execution. Use curl to call them.",
127
+ "",
128
+ "**`POST http://localhost:9966/mock-action?taskId=test-id`** — Perform a mock action.",
129
+ "```json",
130
+ '{"title":"...","detail":"..."}',
131
+ "```",
132
+ "- `title` (required, string): Action title",
133
+ "- `detail` (optional, string): Optional detail",
134
+ '- Response: `{"ok": true}` on success.',
135
+ "",
136
+ "**`POST http://localhost:9966/mock-query?taskId=test-id`** — Query mock data from the device.",
137
+ "```json",
138
+ '{"tags":["..."]}',
139
+ "```",
140
+ "- `tags` (optional, string array): Filter tags",
141
+ "- Blocks until the device responds.",
142
+ '- Response: `{"data": ...}` on success.',
143
+ "",
144
+ "**`GET http://localhost:9966/mock-data`** — Get mock data from the device.",
145
+ "- Response: JSON array of data objects.",
146
+ ].join("\n");
147
+ assert.equal(docs, expected);
148
+ });
76
149
 
77
- it("generates docs for all MCP tools", () => {
78
- for (const tool of agentTools) {
150
+ it("generates docs for all provided tools", () => {
151
+ for (const tool of mockTools) {
79
152
  assert.match(docs, new RegExp(`POST http://localhost:9966/${tool.name}\\?taskId=`), `Missing endpoint for ${tool.name}`);
80
153
  }
81
154
  });
@@ -97,8 +170,8 @@ describe("generateEndpointDocs", () => {
97
170
  });
98
171
 
99
172
  it("includes response descriptions", () => {
100
- for (const tool of agentTools) {
101
- if (tool.responseDescription) {
173
+ for (const tool of mockTools) {
174
+ if (tool.description.length > 1) {
102
175
  assert.match(docs, /Response:/, `Missing response description for ${tool.name}`);
103
176
  }
104
177
  }
@@ -106,6 +179,31 @@ describe("generateEndpointDocs", () => {
106
179
 
107
180
  it("marks required and optional parameters correctly", () => {
108
181
  assert.match(docs, /\(required, string\)/);
182
+ // "detail" has no required entry, so it should be optional
109
183
  assert.match(docs, /\(optional, string\)/);
110
184
  });
185
+
186
+ it("handles array-type parameters", () => {
187
+ assert.match(docs, /\(optional, string array\)/);
188
+ assert.match(docs, /Filter tags/);
189
+ });
190
+
191
+ it("renders multi-line descriptions as bullet points", () => {
192
+ assert.match(docs, /- Blocks until the device responds\./);
193
+ });
194
+
195
+ it("generates GET endpoints for all provided resources", () => {
196
+ for (const resource of mockResources) {
197
+ assert.match(docs, new RegExp(`GET http://localhost:9966${resource.restPath}`), `Missing endpoint for ${resource.uri}`);
198
+ }
199
+ });
200
+
201
+ it("includes resource description as bullet points", () => {
202
+ assert.match(docs, /- Response: JSON array of data objects\./);
203
+ });
204
+
205
+ it("generates no resource endpoints when resources array is empty", () => {
206
+ const docsNoResources = generateEndpointDocs(9966, "test-id", mockTools, []);
207
+ assert.doesNotMatch(docsNoResources, /GET http/);
208
+ });
111
209
  });
@@ -71,3 +71,4 @@ describe("parsePermissions", () => {
71
71
  });
72
72
  });
73
73
 
74
+
@@ -0,0 +1,57 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ // Re-import fresh module state for each test file run
5
+ // Since the store is module-level state, we test the exported functions directly
6
+ import { addNotification, getNotifications, onNotificationsChanged, type DeviceNotification } from "../src/notification-store.js";
7
+
8
+ function makeNotification(id: string, overrides?: Partial<DeviceNotification>): DeviceNotification {
9
+ return {
10
+ id,
11
+ packageName: "com.example.app",
12
+ appName: "Example",
13
+ title: `Title ${id}`,
14
+ text: `Text ${id}`,
15
+ timestamp: Date.now(),
16
+ receivedAt: Date.now(),
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("notification-store", () => {
22
+ it("stores and retrieves notifications", () => {
23
+ const before = getNotifications().length;
24
+ addNotification(makeNotification("test-1"));
25
+ const after = getNotifications();
26
+ assert.equal(after.length, before + 1);
27
+ assert.equal(after[after.length - 1].id, "test-1");
28
+ });
29
+
30
+ it("returns a defensive copy", () => {
31
+ const a = getNotifications();
32
+ const b = getNotifications();
33
+ assert.notStrictEqual(a, b);
34
+ });
35
+
36
+ it("evicts oldest when exceeding max", () => {
37
+ const before = getNotifications().length;
38
+ // Add enough to exceed 50
39
+ for (let i = 0; i < 60; i++) {
40
+ addNotification(makeNotification(`evict-${i}`));
41
+ }
42
+ const result = getNotifications();
43
+ assert.ok(result.length <= 50, `Expected <= 50, got ${result.length}`);
44
+ });
45
+
46
+ it("notifies listeners on add", () => {
47
+ let called = 0;
48
+ const unsub = onNotificationsChanged(() => { called++; });
49
+ addNotification(makeNotification("listener-1"));
50
+ assert.equal(called, 1);
51
+ addNotification(makeNotification("listener-2"));
52
+ assert.equal(called, 2);
53
+ unsub();
54
+ addNotification(makeNotification("listener-3"));
55
+ assert.equal(called, 2); // no longer called after unsubscribe
56
+ });
57
+ });
@@ -19,7 +19,7 @@ This is the task body.`;
19
19
  assert.equal(result.frontmatter.id, "abc123");
20
20
  assert.equal(result.frontmatter.name, "Test Task");
21
21
  assert.equal(result.frontmatter.agent, "claude");
22
- assert.equal(result.body, "This is the task body.");
22
+ assert.equal(result.frontmatter.user_prompt, "Do something");
23
23
  });
24
24
 
25
25
  it("defaults agent to claude when not specified", () => {
@@ -68,7 +68,7 @@ requires_confirmation: false
68
68
  assert.throws(() => parseTaskContent("---\nname: test\n---\n"), /must include at least: id/);
69
69
  });
70
70
 
71
- it("handles empty body", () => {
71
+ it("handles empty body gracefully", () => {
72
72
  const content = `---
73
73
  id: abc123
74
74
  user_prompt: test
@@ -78,6 +78,6 @@ requires_confirmation: false
78
78
  ---`;
79
79
 
80
80
  const result = parseTaskContent(content);
81
- assert.equal(result.body, "");
81
+ assert.equal(result.frontmatter.id, "abc123");
82
82
  });
83
83
  });
@@ -1,22 +0,0 @@
1
- You are a task planning assistant. Given a task description, produce a Markdown execution plan for an AI agent to follow. Do not execute any part of the plan yourself.
2
-
3
- ## Output Format
4
-
5
- Start with a YAML frontmatter block (no code fences), then the plan body:
6
-
7
- ---
8
- task_name: <concise label, 3-6 words>
9
- ---
10
-
11
- <plan body>
12
-
13
- ## Plan Body Guidelines
14
-
15
- - Write a numbered sequence of concrete, actionable steps.
16
- - If the task produces formatted output (report, email, summary, etc.), specify the structure, sections, and tone.
17
- - When a step requires user input, simply state what information is needed from the user. Do **not** specify how to obtain it — the agent has its own tool for requesting user input.
18
- - Preserve relative time expressions (e.g., "today", "yesterday", "last week") exactly as written — do **not** resolve them to specific dates. The plan may be executed on a different day than it was generated.
19
- - If the task involves opening a web browser or application, include a final step to close it before finishing.
20
-
21
- ## Task Description
22
-
@@ -1,22 +0,0 @@
1
- You are a task planning assistant. Given a task description, produce a Markdown execution plan for an AI agent to follow. Do not execute any part of the plan yourself.
2
-
3
- ## Output Format
4
-
5
- Start with a YAML frontmatter block (no code fences), then the plan body:
6
-
7
- ---
8
- task_name: <concise label, 3-6 words>
9
- ---
10
-
11
- <plan body>
12
-
13
- ## Plan Body Guidelines
14
-
15
- - Write a numbered sequence of concrete, actionable steps.
16
- - If the task produces formatted output (report, email, summary, etc.), specify the structure, sections, and tone.
17
- - When a step requires user input, simply state what information is needed from the user. Do **not** specify how to obtain it — the agent has its own tool for requesting user input.
18
- - Preserve relative time expressions (e.g., "today", "yesterday", "last week") exactly as written — do **not** resolve them to specific dates. The plan may be executed on a different day than it was generated.
19
- - If the task involves opening a web browser or application, include a final step to close it before finishing.
20
-
21
- ## Task Description
22
-
@@ -1,58 +0,0 @@
1
- You are an AI agent executing a task on behalf of the user. Follow these instructions carefully.
2
-
3
- ## Reporting Output
4
-
5
- If you generate report or output files, print each file path on its own line using this exact format:
6
- [PALMIER_REPORT] <filename>
7
-
8
- ## Completion
9
-
10
- When you are done, output exactly one of these markers as the very last line (no other text on the same line):
11
- [PALMIER_TASK_SUCCESS]
12
- [PALMIER_TASK_FAILURE]
13
-
14
- ## Permissions
15
-
16
- Whenever a tool you are trying to use is denied or you lack the required permissions, print each required permission on its own line using this exact format:
17
- [PALMIER_PERMISSION] <tool_name> | <description>
18
-
19
- ## HTTP Endpoints
20
-
21
- The following HTTP endpoints are available during task execution. Use curl to call them.
22
-
23
- **`POST http://localhost:9966/notify?taskId=test-task-id`** — Send a push notification to the user's device.
24
- ```json
25
- {"title":"...","body":"..."}
26
- ```
27
- - `title` (required, string): Notification title
28
- - `body` (required, string): Notification body
29
- - Response: `{"ok": true}` on success.
30
-
31
- **`POST http://localhost:9966/request-input?taskId=test-task-id`** — Request input from the user.
32
- ```json
33
- {"description":"...","questions":["..."]}
34
- ```
35
- - `description` (optional, string): Context or heading for the input request
36
- - `questions` (required, string array): Questions to present to the user
37
- - The request blocks until the user responds.
38
- - Response: `{"values": ["answer1", "answer2"]}` on success, or `{"aborted": true}` if the user declines.
39
- - When you need information from the user (credentials, answers to questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout, even in a non-interactive environment — use this endpoint instead.
40
-
41
- **`POST http://localhost:9966/request-confirmation?taskId=test-task-id`** — Request confirmation from the user.
42
- ```json
43
- {"description":"..."}
44
- ```
45
- - `description` (required, string): What the user is confirming
46
- - The request blocks until the user confirms or aborts.
47
- - Response: `{"confirmed": true}` or `{"confirmed": false}`.
48
-
49
- **`POST http://localhost:9966/device-geolocation?taskId=test-task-id`** — Get the GPS location of the user's mobile device.
50
- - When you need the user's real-time location, use this endpoint.
51
- - Blocks until the device responds (up to 30 seconds).
52
- - Response: `{"latitude": ..., "longitude": ..., "accuracy": ..., "timestamp": ...}` on success, or `{"error": "..."}` on failure.
53
-
54
- The task to execute follows below:
55
-
56
- ---
57
-
58
- Test task prompt