palmier 0.6.6 → 0.6.7

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent-instructions.md +28 -6
  3. package/dist/commands/plan-generation.md +1 -0
  4. package/dist/commands/run.js +3 -3
  5. package/dist/location-device.d.ts +8 -0
  6. package/dist/location-device.js +32 -0
  7. package/dist/mcp-handler.d.ts +8 -0
  8. package/dist/mcp-handler.js +110 -0
  9. package/dist/mcp-tools.d.ts +22 -0
  10. package/dist/mcp-tools.js +152 -0
  11. package/dist/pwa/assets/{index-DhvJN8ie.css → index-DAI3J-jU.css} +1 -1
  12. package/dist/pwa/assets/index-RrJvjqz9.js +118 -0
  13. package/dist/pwa/assets/web-DQteXlI7.js +1 -0
  14. package/dist/pwa/assets/web-EzNEHXEh.js +1 -0
  15. package/dist/pwa/index.html +3 -3
  16. package/dist/pwa/service-worker.js +2 -2
  17. package/dist/rpc-handler.js +20 -7
  18. package/dist/transports/http-transport.js +61 -129
  19. package/package.json +1 -1
  20. package/palmier-server/README.md +6 -1
  21. package/palmier-server/package.json +7 -1
  22. package/palmier-server/pnpm-lock.yaml +1025 -1
  23. package/palmier-server/pwa/index.html +1 -1
  24. package/palmier-server/pwa/package.json +3 -0
  25. package/palmier-server/pwa/src/App.css +55 -0
  26. package/palmier-server/pwa/src/api.ts +8 -2
  27. package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
  28. package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
  29. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
  30. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
  31. package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
  32. package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
  33. package/palmier-server/pwa/src/service-worker.ts +7 -7
  34. package/palmier-server/server/.env.example +4 -0
  35. package/palmier-server/server/package.json +1 -0
  36. package/palmier-server/server/src/db.ts +10 -0
  37. package/palmier-server/server/src/fcm.ts +74 -0
  38. package/palmier-server/server/src/index.ts +101 -21
  39. package/palmier-server/server/src/notify.ts +34 -0
  40. package/palmier-server/server/src/push.ts +1 -1
  41. package/palmier-server/server/src/routes/fcm.ts +64 -0
  42. package/palmier-server/server/src/routes/push.ts +6 -5
  43. package/palmier-server/spec.md +4 -2
  44. package/src/agents/agent-instructions.md +28 -6
  45. package/src/commands/plan-generation.md +1 -0
  46. package/src/commands/run.ts +3 -3
  47. package/src/location-device.ts +35 -0
  48. package/src/mcp-handler.ts +133 -0
  49. package/src/mcp-tools.ts +182 -0
  50. package/src/rpc-handler.ts +21 -7
  51. package/src/transports/http-transport.ts +58 -128
  52. package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
@@ -0,0 +1,133 @@
1
+ import { randomUUID } from "crypto";
2
+ import { agentTools, agentToolMap, ToolError, type ToolContext } from "./mcp-tools.js";
3
+
4
+ interface JsonRpcRequest {
5
+ jsonrpc: string;
6
+ id?: string | number | null;
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }
10
+
11
+ export interface McpResponse {
12
+ body: object;
13
+ sessionId?: string;
14
+ }
15
+
16
+ // Session-to-agent name map with 24h TTL
17
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
18
+ const sessionAgents = new Map<string, { agentName: string; expiresAt: number }>();
19
+
20
+ export function getAgentName(sessionId: string): string | undefined {
21
+ const entry = sessionAgents.get(sessionId);
22
+ if (!entry) return undefined;
23
+ if (Date.now() > entry.expiresAt) {
24
+ sessionAgents.delete(sessionId);
25
+ return undefined;
26
+ }
27
+ return entry.agentName;
28
+ }
29
+
30
+ function pruneExpiredSessions(): void {
31
+ const now = Date.now();
32
+ for (const [id, entry] of sessionAgents) {
33
+ if (now > entry.expiresAt) sessionAgents.delete(id);
34
+ }
35
+ }
36
+
37
+ function rpcError(id: string | number | null, code: number, message: string): object {
38
+ return { jsonrpc: "2.0", id, error: { code, message } };
39
+ }
40
+
41
+ function rpcResult(id: string | number | null, result: unknown): object {
42
+ return { jsonrpc: "2.0", id, result };
43
+ }
44
+
45
+ export async function handleMcpRequest(body: string, sessionId: string | undefined, ctx: ToolContext): Promise<McpResponse> {
46
+ let req: JsonRpcRequest;
47
+ try {
48
+ req = JSON.parse(body);
49
+ } catch {
50
+ return { body: rpcError(null, -32700, "Parse error") };
51
+ }
52
+
53
+ const id = req.id ?? null;
54
+
55
+ if (req.jsonrpc !== "2.0") {
56
+ return { body: rpcError(id, -32600, "Invalid Request: missing jsonrpc 2.0") };
57
+ }
58
+
59
+ const agent = sessionId ? getAgentName(sessionId) : undefined;
60
+ const sid = sessionId?.slice(0, 8) ?? "none";
61
+ const logPrefix = agent ? `[mcp] [${sid}] [${agent}]` : `[mcp] [${sid}]`;
62
+ console.log(`${logPrefix} ${req.method}${req.method === "tools/call" ? ` → ${req.params?.name}` : ""}`);
63
+
64
+ switch (req.method) {
65
+ case "initialize": {
66
+ const newSessionId = randomUUID();
67
+ const clientInfo = req.params?.clientInfo as { name?: string; version?: string } | undefined;
68
+ const agentName = clientInfo
69
+ ? `${clientInfo.name || "unknown"}${clientInfo.version ? ` ${clientInfo.version}` : ""}`
70
+ : undefined;
71
+
72
+ if (agentName) {
73
+ sessionAgents.set(newSessionId, { agentName, expiresAt: Date.now() + SESSION_TTL_MS });
74
+ pruneExpiredSessions();
75
+ }
76
+
77
+ console.log(`[mcp] [${newSessionId.slice(0, 8)}] Session initialized${agentName ? ` (${agentName})` : ""}`);
78
+ return {
79
+ body: rpcResult(id, {
80
+ protocolVersion: "2025-03-26",
81
+ capabilities: { tools: {} },
82
+ serverInfo: { name: "palmier", version: "1.0.0" },
83
+ }),
84
+ sessionId: newSessionId,
85
+ };
86
+ }
87
+
88
+ case "tools/list": {
89
+ return {
90
+ body: rpcResult(id, {
91
+ tools: agentTools.map((t) => ({
92
+ name: t.name,
93
+ description: t.description,
94
+ inputSchema: t.inputSchema,
95
+ })),
96
+ }),
97
+ };
98
+ }
99
+
100
+ case "tools/call": {
101
+ const name = req.params?.name as string | undefined;
102
+ const args = (req.params?.arguments ?? {}) as Record<string, unknown>;
103
+
104
+ if (!name) return { body: rpcError(id, -32602, "Missing params.name") };
105
+
106
+ const tool = agentToolMap.get(name);
107
+ if (!tool) return { body: rpcError(id, -32602, `Unknown tool: ${name}`) };
108
+
109
+ try {
110
+ const result = await tool.handler(args, ctx);
111
+ console.log(`${logPrefix} tools/call ${name} done:`, JSON.stringify(result).slice(0, 200));
112
+ return {
113
+ body: rpcResult(id, {
114
+ content: [{ type: "text", text: JSON.stringify(result) }],
115
+ }),
116
+ };
117
+ } catch (err: any) {
118
+ const message = err instanceof ToolError ? err.message : String(err);
119
+ console.error(`${logPrefix} tools/call ${name} error:`, message);
120
+ return {
121
+ body: rpcResult(id, {
122
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
123
+ isError: true,
124
+ }),
125
+ };
126
+ }
127
+ }
128
+
129
+ default:
130
+ console.warn(`${logPrefix} Unknown method: ${req.method}`);
131
+ return { body: rpcError(id, -32601, `Method not found: ${req.method}`) };
132
+ }
133
+ }
@@ -0,0 +1,182 @@
1
+ import { StringCodec, type NatsConnection } from "nats";
2
+ import { registerPending } from "./pending-requests.js";
3
+ import { getLocationDevice } from "./location-device.js";
4
+ import type { HostConfig } from "./types.js";
5
+
6
+ export class ToolError extends Error {
7
+ constructor(message: string, public statusCode: number = 500) {
8
+ super(message);
9
+ }
10
+ }
11
+
12
+ export interface ToolContext {
13
+ config: HostConfig;
14
+ nc: NatsConnection | undefined;
15
+ publishEvent: (id: string, payload: Record<string, unknown>) => Promise<void>;
16
+ sessionId: string;
17
+ agentName?: string;
18
+ }
19
+
20
+ export interface ToolDefinition {
21
+ name: string;
22
+ description: string;
23
+ inputSchema: object;
24
+ handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
25
+ }
26
+
27
+ const notifyTool: ToolDefinition = {
28
+ name: "notify",
29
+ description: "Send a push notification to the user's device.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ title: { type: "string", description: "Notification title" },
34
+ body: { type: "string", description: "Notification body" },
35
+ },
36
+ required: ["title", "body"],
37
+ },
38
+ async handler(args, ctx) {
39
+ const { title, body } = args as { title: string; body: string };
40
+ if (!title || !body) throw new ToolError("title and body are required", 400);
41
+ if (!ctx.nc) throw new ToolError("NATS not connected — push notifications require server mode", 503);
42
+
43
+ const sc = StringCodec();
44
+ const payload: Record<string, string> = { hostId: ctx.config.hostId, title, body };
45
+ if (ctx.sessionId) payload.session_id = ctx.sessionId;
46
+ if (ctx.agentName) payload.agent_name = ctx.agentName;
47
+ const subject = `host.${ctx.config.hostId}.push.send`;
48
+ const reply = await ctx.nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
49
+ const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
50
+
51
+ if (result.ok) return { ok: true };
52
+ throw new ToolError(result.error ?? "Push notification failed", 502);
53
+ },
54
+ };
55
+
56
+ const requestInputTool: ToolDefinition = {
57
+ name: "request-input",
58
+ description: "Request input from the user. The request blocks until the user responds.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ description: { type: "string", description: "Context or heading for the input request" },
63
+ questions: {
64
+ type: "array",
65
+ items: { type: "string" },
66
+ description: "Questions to present to the user",
67
+ minItems: 1,
68
+ },
69
+ },
70
+ required: ["questions"],
71
+ },
72
+ async handler(args, ctx) {
73
+ const { description, questions } = args as { description?: string; questions: string[] };
74
+ if (!questions?.length) throw new ToolError("questions is required", 400);
75
+
76
+ const pendingPromise = registerPending(ctx.sessionId, "input", questions);
77
+
78
+ await ctx.publishEvent("_input", {
79
+ event_type: "input-request",
80
+ host_id: ctx.config.hostId,
81
+ session_id: ctx.sessionId,
82
+ agent_name: ctx.agentName,
83
+ description,
84
+ input_questions: questions,
85
+ });
86
+
87
+ const response = await pendingPromise;
88
+
89
+ if (response.length === 1 && response[0] === "aborted") {
90
+ await ctx.publishEvent("_input", { event_type: "input-resolved", host_id: ctx.config.hostId, session_id: ctx.sessionId, status: "aborted" });
91
+ return { aborted: true };
92
+ }
93
+
94
+ await ctx.publishEvent("_input", { event_type: "input-resolved", host_id: ctx.config.hostId, session_id: ctx.sessionId, status: "provided" });
95
+ return { values: response };
96
+ },
97
+ };
98
+
99
+ const requestConfirmationTool: ToolDefinition = {
100
+ name: "request-confirmation",
101
+ description: "Request confirmation from the user. The request blocks until the user confirms or aborts.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ description: { type: "string", description: "What the user is confirming" },
106
+ },
107
+ required: ["description"],
108
+ },
109
+ async handler(args, ctx) {
110
+ const { description } = args as { description: string };
111
+ if (!description) throw new ToolError("description is required", 400);
112
+
113
+ const pendingPromise = registerPending(ctx.sessionId, "confirmation");
114
+
115
+ await ctx.publishEvent("_confirm", {
116
+ event_type: "confirm-request",
117
+ host_id: ctx.config.hostId,
118
+ session_id: ctx.sessionId,
119
+ agent_name: ctx.agentName,
120
+ description,
121
+ });
122
+
123
+ const response = await pendingPromise;
124
+ const confirmed = response[0] === "confirmed";
125
+
126
+ await ctx.publishEvent("_confirm", {
127
+ event_type: "confirm-resolved",
128
+ host_id: ctx.config.hostId,
129
+ session_id: ctx.sessionId,
130
+ status: confirmed ? "confirmed" : "aborted",
131
+ });
132
+
133
+ return { confirmed };
134
+ },
135
+ };
136
+
137
+ const deviceGeolocationTool: ToolDefinition = {
138
+ name: "device-geolocation",
139
+ description: "Get the GPS location of the user's mobile device. Blocks until the device responds (up to 30 seconds).",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {},
143
+ },
144
+ async handler(_args, ctx) {
145
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
146
+
147
+ const locDevice = getLocationDevice();
148
+ if (!locDevice) throw new ToolError("No device has location access enabled", 400);
149
+
150
+ const sc = StringCodec();
151
+
152
+ const ackReply = await ctx.nc.request(
153
+ `host.${ctx.config.hostId}.fcm.geolocation`,
154
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })),
155
+ { timeout: 5_000 },
156
+ );
157
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
158
+ if (ack.error) throw new ToolError(ack.error, 502);
159
+
160
+ const locationPromise = new Promise<string>((resolve, reject) => {
161
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.geolocation.${ctx.sessionId}`, { max: 1 });
162
+ const timer = setTimeout(() => {
163
+ sub.unsubscribe();
164
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
165
+ }, 30_000);
166
+
167
+ (async () => {
168
+ for await (const msg of sub) {
169
+ clearTimeout(timer);
170
+ resolve(sc.decode(msg.data));
171
+ }
172
+ })();
173
+ });
174
+
175
+ const locationData = JSON.parse(await locationPromise);
176
+ if (locationData.error) return { error: locationData.error };
177
+ return locationData;
178
+ },
179
+ };
180
+
181
+ export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
182
+ export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
@@ -13,6 +13,7 @@ import crossSpawn from "cross-spawn";
13
13
  import { getAgent } from "./agents/agent.js";
14
14
  import { validateClient } from "./client-store.js";
15
15
  import { publishHostEvent } from "./events.js";
16
+ import { getLocationDevice, setLocationDevice, clearLocationDevice } from "./location-device.js";
16
17
  import { currentVersion, performUpdate } from "./update-checker.js";
17
18
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
18
19
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
@@ -166,27 +167,29 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
166
167
  body: task.body,
167
168
  status: status ? {
168
169
  ...status,
169
- ...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
170
170
  ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
171
- ...(pending?.type === "input" ? { pending_input: pending.params } : {}),
172
171
  } : undefined,
173
172
  };
174
173
  }
175
174
 
176
175
  async function handleRpc(request: RpcMessage): Promise<unknown> {
177
- // Client token validation: skip for trusted localhost requests
178
- if (!request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
176
+ // Client token validation: skip for trusted localhost requests and
177
+ // task.user_input (server-originated push responses; gated by getPending instead)
178
+ const skipAuth = request.method === "task.user_input";
179
+ if (!skipAuth && !request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
179
180
  return { error: "Unauthorized" };
180
181
  }
181
182
 
182
183
  switch (request.method) {
183
184
  case "task.list": {
184
185
  const tasks = listTasks(config.projectRoot);
186
+ const locDevice = getLocationDevice();
185
187
  return {
186
188
  tasks: tasks.map((task) => flattenTask(task)),
187
189
  agents: config.agents ?? [],
188
190
  version: currentVersion,
189
191
  host_platform: process.platform,
192
+ location_client_token: locDevice?.clientToken ?? null,
190
193
  };
191
194
  }
192
195
 
@@ -440,7 +443,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
440
443
  const child = crossSpawn(cmd, cmdArgs, {
441
444
  cwd: followupRunDir,
442
445
  stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
443
- env: { ...process.env, ...followupAgentEnv, PALMIER_TASK_ID: params.id },
446
+ env: { ...process.env, ...followupAgentEnv },
444
447
  windowsHide: true,
445
448
  });
446
449
  if (stdin != null) child.stdin!.end(stdin);
@@ -577,9 +580,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
577
580
  return {
578
581
  task_id: params.id,
579
582
  ...status,
580
- ...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
581
583
  ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
582
- ...(pending?.type === "input" ? { pending_input: pending.params } : {}),
583
584
  };
584
585
  }
585
586
 
@@ -690,6 +691,19 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
690
691
  return { ok: true };
691
692
  }
692
693
 
694
+ case "device.location.enable": {
695
+ const params = request.params as { fcmToken: string };
696
+ if (!params.fcmToken) return { error: "fcmToken is required" };
697
+ const clientToken = request.clientToken ?? "";
698
+ setLocationDevice(clientToken, params.fcmToken);
699
+ return { ok: true };
700
+ }
701
+
702
+ case "device.location.disable": {
703
+ clearLocationDevice();
704
+ return { ok: true };
705
+ }
706
+
693
707
  default:
694
708
  return { error: `Unknown method: ${request.method}` };
695
709
  }
@@ -5,8 +5,10 @@ import { StringCodec, type NatsConnection } from "nats";
5
5
  import { validateClient, addClient } from "../client-store.js";
6
6
  import { registerPending } from "../pending-requests.js";
7
7
  import * as fs from "node:fs";
8
- import { getTaskDir, parseTaskFile, spliceUserMessage } from "../task.js";
9
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";
11
+ import { getTaskDir } from "../task.js";
10
12
 
11
13
  // ── Bundled PWA asset serving ───────────────────────────────────────────
12
14
 
@@ -87,18 +89,6 @@ export function detectLanIp(): string {
87
89
  return "127.0.0.1";
88
90
  }
89
91
 
90
- /** Find the latest (highest-numbered) run directory for a task. */
91
- function findLatestRunId(taskDir: string): string | null {
92
- try {
93
- const dirs = fs.readdirSync(taskDir)
94
- .filter((f) => /^\d+$/.test(f) && fs.statSync(`${taskDir}/${f}`).isDirectory())
95
- .sort();
96
- return dirs.length > 0 ? dirs[dirs.length - 1] : null;
97
- } catch {
98
- return null;
99
- }
100
- }
101
-
102
92
  /**
103
93
  * Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
104
94
  * localhost-only agent endpoints (notify, request-input, confirmation, permission).
@@ -172,10 +162,65 @@ export async function startHttpTransport(
172
162
  broadcastSseEvent({ task_id: taskId, ...payload });
173
163
  }
174
164
 
165
+ function makeToolContext(sessionId: string): ToolContext {
166
+ return { config, nc, publishEvent, sessionId, agentName: getAgentName(sessionId) };
167
+ }
168
+
175
169
  const server = http.createServer(async (req, res) => {
176
170
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
177
171
  const pathname = url.pathname;
178
172
 
173
+ // ── MCP streamable HTTP endpoint ──────────────────────────────────
174
+
175
+ if (req.method === "POST" && pathname === "/mcp") {
176
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
177
+ try {
178
+ const body = await readBody(req);
179
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
180
+ const ctx = makeToolContext(sessionId ?? "");
181
+ const result = await handleMcpRequest(body, sessionId, ctx);
182
+ if (result.sessionId) {
183
+ res.setHeader("Mcp-Session-Id", result.sessionId);
184
+ }
185
+ sendJson(res, 200, result.body);
186
+ } catch (err) {
187
+ sendJson(res, 500, { error: String(err) });
188
+ }
189
+ return;
190
+ }
191
+
192
+ // ── Auto-generated REST endpoints from MCP tool registry ──────────
193
+
194
+ if (req.method === "POST" && agentToolMap.has(pathname.slice(1))) {
195
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
196
+ const tool = agentToolMap.get(pathname.slice(1))!;
197
+ try {
198
+ const body = await readBody(req);
199
+ const args = body.trim() ? JSON.parse(body) : {};
200
+ const { taskId } = args as { taskId?: string };
201
+ if (!taskId) {
202
+ sendJson(res, 400, { error: "taskId is required" });
203
+ return;
204
+ }
205
+ const taskDir = getTaskDir(config.projectRoot, taskId);
206
+ if (!fs.existsSync(taskDir)) {
207
+ sendJson(res, 404, { error: `Task not found: ${taskId}` });
208
+ return;
209
+ }
210
+ delete args.taskId;
211
+ const ctx = makeToolContext(taskId);
212
+ console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${tool.name}`);
213
+ const result = await tool.handler(args, ctx);
214
+ console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${tool.name} done:`, JSON.stringify(result).slice(0, 200));
215
+ sendJson(res, 200, result);
216
+ } catch (err: any) {
217
+ const status = err instanceof ToolError ? err.statusCode : 500;
218
+ console.error(`[mcp] REST ${tool.name} error:`, err.message ?? String(err));
219
+ sendJson(res, status, { error: err.message ?? String(err) });
220
+ }
221
+ return;
222
+ }
223
+
179
224
  // ── Localhost-only endpoints (no auth) ─────────────────────────────
180
225
 
181
226
  if (req.method === "POST" && pathname === "/event") {
@@ -217,121 +262,6 @@ export async function startHttpTransport(
217
262
  return;
218
263
  }
219
264
 
220
- // ── POST /notify — send push notification via NATS ─────────────────
221
-
222
- if (req.method === "POST" && pathname === "/notify") {
223
- if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
224
- if (!nc) { sendJson(res, 503, { error: "NATS not connected — push notifications require server mode" }); return; }
225
-
226
- try {
227
- const body = await readBody(req);
228
- const { taskId: notifTaskId, title, body: notifBody } = JSON.parse(body) as { taskId?: string; title: string; body: string };
229
- if (!title || !notifBody) { sendJson(res, 400, { error: "title and body are required" }); return; }
230
-
231
- const sc = StringCodec();
232
- const payload: Record<string, string> = { hostId: config.hostId, title, body: notifBody };
233
- if (notifTaskId) payload.task_id = notifTaskId;
234
- const subject = `host.${config.hostId}.push.send`;
235
- const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
236
- const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
237
-
238
- if (result.ok) {
239
- sendJson(res, 200, { ok: true });
240
- } else {
241
- sendJson(res, 502, { error: result.error ?? "Push notification failed" });
242
- }
243
- } catch (err) {
244
- sendJson(res, 500, { error: `Failed to send notification: ${err}` });
245
- }
246
- return;
247
- }
248
-
249
- // ── POST /request-input — held connection until user responds ────────
250
-
251
- if (req.method === "POST" && pathname === "/request-input") {
252
- if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
253
- try {
254
- const body = await readBody(req);
255
- const { taskId, runId, descriptions } = JSON.parse(body) as {
256
- taskId: string; runId?: string; descriptions: string[];
257
- };
258
- if (!taskId || !descriptions?.length) {
259
- sendJson(res, 400, { error: "taskId and descriptions are required" });
260
- return;
261
- }
262
-
263
- const taskDir = getTaskDir(config.projectRoot, taskId);
264
- const task = parseTaskFile(taskDir);
265
-
266
- // Resolve runId: use provided value, otherwise find the latest run directory
267
- const effectiveRunId = runId ?? findLatestRunId(taskDir);
268
-
269
- const pendingPromise = registerPending(taskId, "input", descriptions);
270
-
271
- await publishEvent(taskId, {
272
- event_type: "input-request",
273
- host_id: config.hostId,
274
- input_descriptions: descriptions,
275
- name: task.frontmatter.name,
276
- });
277
-
278
- const response = await pendingPromise;
279
-
280
- const questionsBlock = "\n\n" + descriptions.map((d) => `**${d}**`).join("\n");
281
-
282
- if (response.length === 1 && response[0] === "aborted") {
283
- await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
284
- if (effectiveRunId) {
285
- spliceUserMessage(taskDir, effectiveRunId, { role: "user", time: Date.now(), content: "Aborted", type: "input" }, questionsBlock);
286
- await publishEvent(taskId, { event_type: "result-updated", run_id: effectiveRunId });
287
- }
288
- sendJson(res, 200, { aborted: true });
289
- } else {
290
- await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
291
- if (effectiveRunId) {
292
- spliceUserMessage(taskDir, effectiveRunId, { role: "user", time: Date.now(), content: response.join("\n"), type: "input" }, questionsBlock);
293
- await publishEvent(taskId, { event_type: "result-updated", run_id: effectiveRunId });
294
- }
295
- sendJson(res, 200, { values: response });
296
- }
297
- } catch (err) {
298
- sendJson(res, 500, { error: String(err) });
299
- }
300
- return;
301
- }
302
-
303
- // ── POST /request-confirmation — held connection ────────────────────
304
-
305
- if (req.method === "POST" && pathname === "/request-confirmation") {
306
- if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
307
- try {
308
- const body = await readBody(req);
309
- const { taskId } = JSON.parse(body) as { taskId: string };
310
- if (!taskId) { sendJson(res, 400, { error: "taskId is required" }); return; }
311
-
312
- const pendingPromise = registerPending(taskId, "confirmation");
313
-
314
- await publishEvent(taskId, {
315
- event_type: "confirm-request",
316
- host_id: config.hostId,
317
- });
318
-
319
- const response = await pendingPromise;
320
- const confirmed = response[0] === "confirmed";
321
-
322
- await publishEvent(taskId, {
323
- event_type: "confirm-resolved",
324
- host_id: config.hostId,
325
- status: confirmed ? "confirmed" : "aborted",
326
- });
327
-
328
- sendJson(res, 200, { confirmed });
329
- } catch (err) {
330
- sendJson(res, 500, { error: String(err) });
331
- }
332
- return;
333
- }
334
-
335
265
  // ── POST /request-permission — held connection ──────────────────────
336
266
 
337
267
  if (req.method === "POST" && pathname === "/request-permission") {