palmier 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/agents/agent-instructions.md +6 -14
- package/dist/agents/aider.js +1 -1
- package/dist/agents/claude.js +1 -1
- package/dist/agents/cline.js +1 -1
- package/dist/agents/codex.js +1 -1
- package/dist/agents/copilot.js +1 -1
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/droid.js +1 -1
- package/dist/agents/gemini.js +1 -1
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kimi.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/openclaw.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/qwen.js +1 -1
- package/dist/agents/shared-prompt.d.ts +3 -2
- package/dist/agents/shared-prompt.js +6 -4
- package/dist/commands/plan-generation.md +1 -0
- package/dist/commands/run.js +4 -7
- package/dist/location-device.d.ts +8 -0
- package/dist/location-device.js +32 -0
- package/dist/mcp-handler.d.ts +8 -0
- package/dist/mcp-handler.js +110 -0
- package/dist/mcp-tools.d.ts +27 -0
- package/dist/mcp-tools.js +218 -0
- package/dist/pwa/assets/{index-DhvJN8ie.css → index-C6Lz09EY.css} +1 -1
- package/dist/pwa/assets/index-C8vJwUNi.js +118 -0
- package/dist/pwa/assets/web-6UChJFov.js +1 -0
- package/dist/pwa/assets/web-NxTETXZK.js +1 -0
- package/dist/pwa/index.html +3 -3
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +20 -8
- package/dist/spawn-command.js +3 -1
- package/dist/transports/http-transport.js +60 -129
- package/package.json +1 -1
- package/palmier-server/README.md +6 -1
- package/palmier-server/package.json +7 -1
- package/palmier-server/pnpm-lock.yaml +1025 -1
- package/palmier-server/pwa/index.html +1 -1
- package/palmier-server/pwa/package.json +3 -0
- package/palmier-server/pwa/src/App.css +64 -0
- package/palmier-server/pwa/src/api.ts +8 -2
- package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
- package/palmier-server/pwa/src/components/TaskCard.tsx +36 -8
- package/palmier-server/pwa/src/components/TaskForm.tsx +63 -53
- package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
- package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
- package/palmier-server/pwa/src/service-worker.ts +7 -7
- package/palmier-server/server/.env.example +4 -0
- package/palmier-server/server/package.json +1 -0
- package/palmier-server/server/src/db.ts +10 -0
- package/palmier-server/server/src/fcm.ts +74 -0
- package/palmier-server/server/src/index.ts +101 -21
- package/palmier-server/server/src/notify.ts +34 -0
- package/palmier-server/server/src/push.ts +1 -1
- package/palmier-server/server/src/routes/fcm.ts +64 -0
- package/palmier-server/server/src/routes/push.ts +6 -5
- package/palmier-server/spec.md +4 -2
- package/src/agents/agent-instructions.md +6 -14
- package/src/agents/aider.ts +1 -1
- package/src/agents/claude.ts +1 -1
- package/src/agents/cline.ts +1 -1
- package/src/agents/codex.ts +1 -1
- package/src/agents/copilot.ts +1 -1
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/droid.ts +1 -1
- package/src/agents/gemini.ts +1 -1
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kimi.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/openclaw.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/qwen.ts +1 -1
- package/src/agents/shared-prompt.ts +7 -4
- package/src/commands/plan-generation.md +1 -0
- package/src/commands/run.ts +4 -7
- package/src/location-device.ts +35 -0
- package/src/mcp-handler.ts +133 -0
- package/src/mcp-tools.ts +253 -0
- package/src/rpc-handler.ts +21 -8
- package/src/spawn-command.ts +3 -1
- package/src/transports/http-transport.ts +57 -128
- package/test/agent-instructions.test.ts +68 -5
- package/test/fixtures/agent-instructions-snapshot.md +58 -0
- package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
package/src/commands/run.ts
CHANGED
|
@@ -65,12 +65,9 @@ async function invokeAgentWithRetries(
|
|
|
65
65
|
const { command, args, stdin, env: agentEnv } = ctx.agent.getTaskRunCommandLine(
|
|
66
66
|
invokeTask, undefined, ctx.task.frontmatter.yolo_mode ? "yolo" : ctx.transientPermissions,
|
|
67
67
|
);
|
|
68
|
-
const truncate = (s: string, max = 100) => s.length > max ? s.slice(0, max) + "…" : s;
|
|
69
|
-
const displayArgs = args.map((a) => truncate(a));
|
|
70
|
-
console.log(`[invoke] ${command} ${displayArgs.join(" ")}${stdin ? ` (stdin: ${truncate(stdin, 100)})` : ""}`);
|
|
71
68
|
const result = await spawnCommand(command, args, {
|
|
72
69
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
73
|
-
env: { ...ctx.guiEnv, ...agentEnv,
|
|
70
|
+
env: { ...ctx.guiEnv, ...agentEnv, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
|
|
74
71
|
echoStdout: true,
|
|
75
72
|
resolveOnFailure: true,
|
|
76
73
|
stdin,
|
|
@@ -324,7 +321,7 @@ async function runCommandTriggeredMode(
|
|
|
324
321
|
|
|
325
322
|
const child = spawnStreamingCommand(commandStr, {
|
|
326
323
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
327
|
-
env: { ...ctx.guiEnv,
|
|
324
|
+
env: { ...ctx.guiEnv, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
|
|
328
325
|
});
|
|
329
326
|
|
|
330
327
|
let linesProcessed = 0;
|
|
@@ -516,10 +513,10 @@ async function requestConfirmation(
|
|
|
516
513
|
taskDir: string,
|
|
517
514
|
): Promise<boolean> {
|
|
518
515
|
const port = config.httpPort ?? 9966;
|
|
519
|
-
const res = await fetch(`http://localhost:${port}/request-confirmation`, {
|
|
516
|
+
const res = await fetch(`http://localhost:${port}/request-confirmation?taskId=${encodeURIComponent(task.frontmatter.id)}`, {
|
|
520
517
|
method: "POST",
|
|
521
518
|
headers: { "Content-Type": "application/json" },
|
|
522
|
-
body: JSON.stringify({
|
|
519
|
+
body: JSON.stringify({ description: `Run task "${task.frontmatter.name || task.frontmatter.id}"?` }),
|
|
523
520
|
});
|
|
524
521
|
const body = await res.json() as { confirmed?: boolean; error?: string };
|
|
525
522
|
if (typeof body.confirmed !== "boolean") {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const LOCATION_FILE = path.join(CONFIG_DIR, "location-device.json");
|
|
6
|
+
|
|
7
|
+
export interface LocationDevice {
|
|
8
|
+
clientToken: string;
|
|
9
|
+
fcmToken: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getLocationDevice(): LocationDevice | null {
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(LOCATION_FILE)) return null;
|
|
15
|
+
const raw = fs.readFileSync(LOCATION_FILE, "utf-8");
|
|
16
|
+
const data = JSON.parse(raw) as LocationDevice;
|
|
17
|
+
if (!data.clientToken || !data.fcmToken) return null;
|
|
18
|
+
return data;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setLocationDevice(clientToken: string, fcmToken: string): void {
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
fs.writeFileSync(LOCATION_FILE, JSON.stringify({ clientToken, fcmToken }, null, 2), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clearLocationDevice(): void {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(LOCATION_FILE)) fs.unlinkSync(LOCATION_FILE);
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -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.join(" "),
|
|
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
|
+
}
|
package/src/mcp-tools.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
/** First line is the summary (used as endpoint header). Remaining lines become bullet points in docs. */
|
|
23
|
+
description: string[];
|
|
24
|
+
inputSchema: object;
|
|
25
|
+
handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const notifyTool: ToolDefinition = {
|
|
29
|
+
name: "notify",
|
|
30
|
+
description: [
|
|
31
|
+
"Send a push notification to the user's device.",
|
|
32
|
+
'Response: `{"ok": true}` on success.',
|
|
33
|
+
],
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
title: { type: "string", description: "Notification title" },
|
|
38
|
+
body: { type: "string", description: "Notification body" },
|
|
39
|
+
},
|
|
40
|
+
required: ["title", "body"],
|
|
41
|
+
},
|
|
42
|
+
async handler(args, ctx) {
|
|
43
|
+
const { title, body } = args as { title: string; body: string };
|
|
44
|
+
if (!title || !body) throw new ToolError("title and body are required", 400);
|
|
45
|
+
if (!ctx.nc) throw new ToolError("NATS not connected — push notifications require server mode", 503);
|
|
46
|
+
|
|
47
|
+
const sc = StringCodec();
|
|
48
|
+
const payload: Record<string, string> = { hostId: ctx.config.hostId, title, body };
|
|
49
|
+
if (ctx.sessionId) payload.session_id = ctx.sessionId;
|
|
50
|
+
if (ctx.agentName) payload.agent_name = ctx.agentName;
|
|
51
|
+
const subject = `host.${ctx.config.hostId}.push.send`;
|
|
52
|
+
const reply = await ctx.nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
53
|
+
const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
|
|
54
|
+
|
|
55
|
+
if (result.ok) return { ok: true };
|
|
56
|
+
throw new ToolError(result.error ?? "Push notification failed", 502);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const requestInputTool: ToolDefinition = {
|
|
61
|
+
name: "request-input",
|
|
62
|
+
description: [
|
|
63
|
+
"Request input from the user.",
|
|
64
|
+
"The request blocks until the user responds.",
|
|
65
|
+
'Response: `{"values": ["answer1", "answer2"]}` on success, or `{"aborted": true}` if the user declines.',
|
|
66
|
+
"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.",
|
|
67
|
+
],
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
description: { type: "string", description: "Context or heading for the input request" },
|
|
72
|
+
questions: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: { type: "string" },
|
|
75
|
+
description: "Questions to present to the user",
|
|
76
|
+
minItems: 1,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ["questions"],
|
|
80
|
+
},
|
|
81
|
+
async handler(args, ctx) {
|
|
82
|
+
const { description, questions } = args as { description?: string; questions: string[] };
|
|
83
|
+
if (!questions?.length) throw new ToolError("questions is required", 400);
|
|
84
|
+
|
|
85
|
+
const pendingPromise = registerPending(ctx.sessionId, "input", questions);
|
|
86
|
+
|
|
87
|
+
await ctx.publishEvent("_input", {
|
|
88
|
+
event_type: "input-request",
|
|
89
|
+
host_id: ctx.config.hostId,
|
|
90
|
+
session_id: ctx.sessionId,
|
|
91
|
+
agent_name: ctx.agentName,
|
|
92
|
+
description,
|
|
93
|
+
input_questions: questions,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const response = await pendingPromise;
|
|
97
|
+
|
|
98
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
99
|
+
await ctx.publishEvent("_input", {
|
|
100
|
+
event_type: "input-resolved", host_id: ctx.config.hostId,
|
|
101
|
+
session_id: ctx.sessionId, status: "aborted",
|
|
102
|
+
});
|
|
103
|
+
return { aborted: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await ctx.publishEvent("_input", {
|
|
107
|
+
event_type: "input-resolved", host_id: ctx.config.hostId,
|
|
108
|
+
session_id: ctx.sessionId, status: "provided",
|
|
109
|
+
});
|
|
110
|
+
return { values: response };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const requestConfirmationTool: ToolDefinition = {
|
|
115
|
+
name: "request-confirmation",
|
|
116
|
+
description: [
|
|
117
|
+
"Request confirmation from the user.",
|
|
118
|
+
"The request blocks until the user confirms or aborts.",
|
|
119
|
+
'Response: `{"confirmed": true}` or `{"confirmed": false}`.',
|
|
120
|
+
],
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
description: { type: "string", description: "What the user is confirming" },
|
|
125
|
+
},
|
|
126
|
+
required: ["description"],
|
|
127
|
+
},
|
|
128
|
+
async handler(args, ctx) {
|
|
129
|
+
const { description } = args as { description: string };
|
|
130
|
+
if (!description) throw new ToolError("description is required", 400);
|
|
131
|
+
|
|
132
|
+
const pendingPromise = registerPending(ctx.sessionId, "confirmation");
|
|
133
|
+
|
|
134
|
+
await ctx.publishEvent("_confirm", {
|
|
135
|
+
event_type: "confirm-request",
|
|
136
|
+
host_id: ctx.config.hostId,
|
|
137
|
+
session_id: ctx.sessionId,
|
|
138
|
+
agent_name: ctx.agentName,
|
|
139
|
+
description,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const response = await pendingPromise;
|
|
143
|
+
const confirmed = response[0] === "confirmed";
|
|
144
|
+
|
|
145
|
+
await ctx.publishEvent("_confirm", {
|
|
146
|
+
event_type: "confirm-resolved",
|
|
147
|
+
host_id: ctx.config.hostId,
|
|
148
|
+
session_id: ctx.sessionId,
|
|
149
|
+
status: confirmed ? "confirmed" : "aborted",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { confirmed };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const deviceGeolocationTool: ToolDefinition = {
|
|
157
|
+
name: "device-geolocation",
|
|
158
|
+
description: [
|
|
159
|
+
"Get the GPS location of the user's mobile device.",
|
|
160
|
+
"When you need the user's real-time location, use this endpoint.",
|
|
161
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
162
|
+
'Response: `{"latitude": ..., "longitude": ..., "accuracy": ..., "timestamp": ...}` on success, or `{"error": "..."}` on failure.',
|
|
163
|
+
],
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {},
|
|
167
|
+
},
|
|
168
|
+
async handler(_args, ctx) {
|
|
169
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
170
|
+
|
|
171
|
+
const locDevice = getLocationDevice();
|
|
172
|
+
if (!locDevice) throw new ToolError("No device has location access enabled", 400);
|
|
173
|
+
|
|
174
|
+
const sc = StringCodec();
|
|
175
|
+
|
|
176
|
+
const ackReply = await ctx.nc.request(
|
|
177
|
+
`host.${ctx.config.hostId}.fcm.geolocation`,
|
|
178
|
+
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })),
|
|
179
|
+
{ timeout: 5_000 },
|
|
180
|
+
);
|
|
181
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
182
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
183
|
+
|
|
184
|
+
const locationPromise = new Promise<string>((resolve, reject) => {
|
|
185
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.geolocation.${ctx.sessionId}`, { max: 1 });
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
sub.unsubscribe();
|
|
188
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
189
|
+
}, 30_000);
|
|
190
|
+
|
|
191
|
+
(async () => {
|
|
192
|
+
for await (const msg of sub) {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
resolve(sc.decode(msg.data));
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const locationData = JSON.parse(await locationPromise);
|
|
200
|
+
if (locationData.error) return { error: locationData.error };
|
|
201
|
+
return locationData;
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
|
|
206
|
+
export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
|
|
210
|
+
*/
|
|
211
|
+
export function generateEndpointDocs(port: number, taskId: string): string {
|
|
212
|
+
const baseUrl = `http://localhost:${port}`;
|
|
213
|
+
const lines: string[] = [
|
|
214
|
+
`The following HTTP endpoints are available during task execution. Use curl to call them.`,
|
|
215
|
+
"",
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
for (const tool of agentTools) {
|
|
219
|
+
const schema = tool.inputSchema as { properties?: Record<string, { type?: string; description?: string; items?: { type?: string } }>; required?: string[] };
|
|
220
|
+
const props = schema.properties ?? {};
|
|
221
|
+
const required = new Set(schema.required ?? []);
|
|
222
|
+
|
|
223
|
+
// Build example JSON (body only, no taskId)
|
|
224
|
+
const example: Record<string, unknown> = {};
|
|
225
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
226
|
+
if (prop.type === "array") example[key] = ["..."];
|
|
227
|
+
else example[key] = "...";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const queryUrl = `${baseUrl}/${tool.name}?taskId=${taskId}`;
|
|
231
|
+
const [header, ...details] = tool.description;
|
|
232
|
+
|
|
233
|
+
lines.push(`**\`POST ${queryUrl}\`** — ${header}`);
|
|
234
|
+
if (Object.keys(example).length > 0) {
|
|
235
|
+
lines.push("```json");
|
|
236
|
+
lines.push(JSON.stringify(example));
|
|
237
|
+
lines.push("```");
|
|
238
|
+
}
|
|
239
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
240
|
+
const req = required.has(key) ? "required" : "optional";
|
|
241
|
+
let typeStr = prop.type ?? "unknown";
|
|
242
|
+
if (prop.type === "array" && prop.items?.type) typeStr = `${prop.items.type} array`;
|
|
243
|
+
lines.push(`- \`${key}\` (${req}, ${typeStr}): ${prop.description ?? ""}`);
|
|
244
|
+
}
|
|
245
|
+
for (const detail of details) {
|
|
246
|
+
lines.push(`- ${detail}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
lines.push("");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return lines.join("\n").trimEnd();
|
|
253
|
+
}
|
package/src/rpc-handler.ts
CHANGED
|
@@ -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";
|
|
@@ -125,7 +126,6 @@ async function generatePlan(
|
|
|
125
126
|
const fullPrompt = PLAN_GENERATION_PROMPT + userPrompt;
|
|
126
127
|
const planAgent = getAgent(agentName);
|
|
127
128
|
const { command, args, stdin, env: agentEnv } = planAgent.getPlanGenerationCommandLine(fullPrompt);
|
|
128
|
-
console.log(`[generatePlan] Running: ${command} ${args.join(" ")}`);
|
|
129
129
|
|
|
130
130
|
const { output } = await spawnCommand(command, args, {
|
|
131
131
|
cwd: projectRoot,
|
|
@@ -166,27 +166,29 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
166
166
|
body: task.body,
|
|
167
167
|
status: status ? {
|
|
168
168
|
...status,
|
|
169
|
-
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
170
169
|
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
171
|
-
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
172
170
|
} : undefined,
|
|
173
171
|
};
|
|
174
172
|
}
|
|
175
173
|
|
|
176
174
|
async function handleRpc(request: RpcMessage): Promise<unknown> {
|
|
177
|
-
// Client token validation: skip for trusted localhost requests
|
|
178
|
-
|
|
175
|
+
// Client token validation: skip for trusted localhost requests and
|
|
176
|
+
// task.user_input (server-originated push responses; gated by getPending instead)
|
|
177
|
+
const skipAuth = request.method === "task.user_input";
|
|
178
|
+
if (!skipAuth && !request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
|
|
179
179
|
return { error: "Unauthorized" };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
switch (request.method) {
|
|
183
183
|
case "task.list": {
|
|
184
184
|
const tasks = listTasks(config.projectRoot);
|
|
185
|
+
const locDevice = getLocationDevice();
|
|
185
186
|
return {
|
|
186
187
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
187
188
|
agents: config.agents ?? [],
|
|
188
189
|
version: currentVersion,
|
|
189
190
|
host_platform: process.platform,
|
|
191
|
+
location_client_token: locDevice?.clientToken ?? null,
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
@@ -440,7 +442,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
440
442
|
const child = crossSpawn(cmd, cmdArgs, {
|
|
441
443
|
cwd: followupRunDir,
|
|
442
444
|
stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
443
|
-
env: { ...process.env, ...followupAgentEnv
|
|
445
|
+
env: { ...process.env, ...followupAgentEnv },
|
|
444
446
|
windowsHide: true,
|
|
445
447
|
});
|
|
446
448
|
if (stdin != null) child.stdin!.end(stdin);
|
|
@@ -577,9 +579,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
577
579
|
return {
|
|
578
580
|
task_id: params.id,
|
|
579
581
|
...status,
|
|
580
|
-
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
581
582
|
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
582
|
-
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
583
583
|
};
|
|
584
584
|
}
|
|
585
585
|
|
|
@@ -690,6 +690,19 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
690
690
|
return { ok: true };
|
|
691
691
|
}
|
|
692
692
|
|
|
693
|
+
case "device.location.enable": {
|
|
694
|
+
const params = request.params as { fcmToken: string };
|
|
695
|
+
if (!params.fcmToken) return { error: "fcmToken is required" };
|
|
696
|
+
const clientToken = request.clientToken ?? "";
|
|
697
|
+
setLocationDevice(clientToken, params.fcmToken);
|
|
698
|
+
return { ok: true };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
case "device.location.disable": {
|
|
702
|
+
clearLocationDevice();
|
|
703
|
+
return { ok: true };
|
|
704
|
+
}
|
|
705
|
+
|
|
693
706
|
default:
|
|
694
707
|
return { error: `Unknown method: ${request.method}` };
|
|
695
708
|
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -89,8 +89,10 @@ export function spawnCommand(
|
|
|
89
89
|
const finalArgs = process.platform === "win32"
|
|
90
90
|
? args.map((a) => a.replace(/[\r\n]+/g, " "))
|
|
91
91
|
: args;
|
|
92
|
+
const truncate = (s: string, max = 100) => s.length > max ? s.slice(0, max) + "..." : s;
|
|
93
|
+
const displayArgs = finalArgs.map((arg) => truncate(arg));
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
console.log(`[spawn] ${command} ${displayArgs.join(" ")}`);
|
|
94
96
|
|
|
95
97
|
const child = crossSpawn(command, finalArgs, {
|
|
96
98
|
cwd: opts.cwd,
|