palmier 0.4.4 → 0.4.6
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 +32 -33
- package/dist/agents/agent-instructions.md +4 -11
- package/dist/agents/agent.d.ts +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claude.js +6 -6
- package/dist/agents/codex.d.ts +1 -1
- package/dist/agents/codex.js +5 -5
- package/dist/agents/copilot.d.ts +1 -1
- package/dist/agents/copilot.js +5 -5
- package/dist/agents/gemini.d.ts +1 -1
- package/dist/agents/gemini.js +7 -7
- package/dist/agents/openclaw.d.ts +1 -1
- package/dist/agents/openclaw.js +3 -3
- package/dist/agents/shared-prompt.d.ts +2 -4
- package/dist/agents/shared-prompt.js +9 -4
- package/dist/commands/init.js +31 -2
- package/dist/commands/pair.d.ts +1 -1
- package/dist/commands/pair.js +12 -15
- package/dist/commands/run.js +33 -54
- package/dist/commands/serve.d.ts +1 -1
- package/dist/commands/serve.js +9 -2
- package/dist/events.d.ts +2 -2
- package/dist/events.js +15 -16
- package/dist/index.js +0 -25
- package/dist/pending-requests.d.ts +27 -0
- package/dist/pending-requests.js +39 -0
- package/dist/rpc-handler.js +15 -8
- package/dist/transports/http-transport.d.ts +4 -2
- package/dist/transports/http-transport.js +226 -77
- package/dist/types.d.ts +7 -16
- package/package.json +1 -1
- package/src/agents/agent-instructions.md +4 -11
- package/src/agents/agent.ts +2 -2
- package/src/agents/claude.ts +5 -5
- package/src/agents/codex.ts +4 -4
- package/src/agents/copilot.ts +5 -5
- package/src/agents/gemini.ts +6 -6
- package/src/agents/openclaw.ts +3 -3
- package/src/agents/shared-prompt.ts +12 -6
- package/src/commands/init.ts +34 -3
- package/src/commands/pair.ts +11 -14
- package/src/commands/run.ts +31 -68
- package/src/commands/serve.ts +11 -2
- package/src/events.ts +14 -15
- package/src/index.ts +0 -26
- package/src/pending-requests.ts +55 -0
- package/src/rpc-handler.ts +15 -9
- package/src/transports/http-transport.ts +235 -135
- package/src/types.ts +10 -16
- package/test/agent-output-parsing.test.ts +1 -14
- package/dist/commands/lan.d.ts +0 -8
- package/dist/commands/lan.js +0 -44
- package/dist/commands/notify.d.ts +0 -9
- package/dist/commands/notify.js +0 -43
- package/dist/commands/request-input.d.ts +0 -10
- package/dist/commands/request-input.js +0 -49
- package/dist/lan-lock.d.ts +0 -7
- package/dist/lan-lock.js +0 -18
- package/dist/user-input.d.ts +0 -15
- package/dist/user-input.js +0 -50
- package/src/commands/lan.ts +0 -48
- package/src/commands/notify.ts +0 -44
- package/src/commands/request-input.ts +0 -51
- package/src/lan-lock.ts +0 -16
- package/src/user-input.ts +0 -67
package/src/agents/gemini.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
import { SHELL } from "../platform/index.js";
|
|
6
6
|
|
|
7
7
|
export class GeminiAgent implements AgentTool {
|
|
@@ -12,10 +12,10 @@ export class GeminiAgent implements AgentTool {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
getTaskRunCommandLine(task: ParsedTask,
|
|
16
|
-
const prompt =
|
|
17
|
-
const fullPrompt =
|
|
18
|
-
const args = ["--prompt", "-"];
|
|
15
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
+
const prompt = followupPrompt ?? (task.body || task.frontmatter.user_prompt);
|
|
17
|
+
const fullPrompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + prompt;
|
|
18
|
+
const args = ["--prompt", "--allowed-tools", "web_fetch", "-"];
|
|
19
19
|
|
|
20
20
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
21
21
|
if (allPerms.length > 0) {
|
|
@@ -25,7 +25,7 @@ export class GeminiAgent implements AgentTool {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
if (
|
|
28
|
+
if (followupPrompt) {args.push("--resume");} // continue mode for followups
|
|
29
29
|
return { command: "gemini", args, stdin: fullPrompt };
|
|
30
30
|
}
|
|
31
31
|
|
package/src/agents/openclaw.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
|
|
6
6
|
export class OpenClawAgent implements AgentTool {
|
|
7
7
|
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
|
@@ -11,8 +11,8 @@ export class OpenClawAgent implements AgentTool {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
getTaskRunCommandLine(task: ParsedTask,
|
|
15
|
-
const prompt =
|
|
14
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
15
|
+
const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
|
|
16
16
|
// OpenClaw does not support stdin as prompt.
|
|
17
17
|
const args = ["agent", "--local", "--session-id", task.frontmatter.id, "--message", prompt];
|
|
18
18
|
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
4
5
|
|
|
5
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
* Instructions prepended or injected as system prompt for every task invocation.
|
|
9
|
-
* Instructs the agent to output structured markers so palmier can determine
|
|
10
|
-
* the task outcome, report files, and permission/input requests.
|
|
11
|
-
*/
|
|
12
|
-
export const AGENT_INSTRUCTIONS = fs.readFileSync(
|
|
8
|
+
const AGENT_INSTRUCTIONS_TEMPLATE = fs.readFileSync(
|
|
13
9
|
path.join(__dirname, "agent-instructions.md"),
|
|
14
10
|
"utf-8",
|
|
15
11
|
);
|
|
16
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Agent instructions with the serve daemon's HTTP port and task ID baked in.
|
|
15
|
+
*/
|
|
16
|
+
export function getAgentInstructions(taskId: string): string {
|
|
17
|
+
const port = loadConfig().httpPort ?? 7400;
|
|
18
|
+
return AGENT_INSTRUCTIONS_TEMPLATE
|
|
19
|
+
.replace(/\{\{PORT\}\}/g, String(port))
|
|
20
|
+
.replace(/\{\{TASK_ID\}\}/g, taskId);
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export const TASK_SUCCESS_MARKER = "[PALMIER_TASK_SUCCESS]";
|
|
18
24
|
export const TASK_FAILURE_MARKER = "[PALMIER_TASK_FAILURE]";
|
|
19
25
|
export const TASK_REPORT_PREFIX = "[PALMIER_REPORT]";
|
package/src/commands/init.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { loadConfig, saveConfig } from "../config.js";
|
|
|
3
3
|
import { detectAgents } from "../agents/agent.js";
|
|
4
4
|
import { getPlatform } from "../platform/index.js";
|
|
5
5
|
import { pairCommand } from "./pair.js";
|
|
6
|
+
import { detectLanIp } from "../transports/http-transport.js";
|
|
6
7
|
import type { HostConfig } from "../types.js";
|
|
7
8
|
|
|
8
9
|
type AskFn = (q: string) => Promise<string>;
|
|
@@ -39,6 +40,36 @@ export async function initCommand(): Promise<void> {
|
|
|
39
40
|
|
|
40
41
|
console.log(` Found: ${green(agents.map((a) => a.label).join(", "))}\n`);
|
|
41
42
|
|
|
43
|
+
// LAN mode
|
|
44
|
+
const lanAnswer = await ask("Enable LAN access (direct HTTP from local network)? (y/N): ");
|
|
45
|
+
const lanEnabled = lanAnswer.trim().toLowerCase() === "y";
|
|
46
|
+
|
|
47
|
+
let httpPort = 7400;
|
|
48
|
+
const portLabel = lanEnabled ? "HTTP port for local and LAN access" : "HTTP port for local access";
|
|
49
|
+
const portAnswer = await ask(`${portLabel} (default ${httpPort}): `);
|
|
50
|
+
const parsed = parseInt(portAnswer.trim(), 10);
|
|
51
|
+
if (parsed > 0 && parsed < 65536) httpPort = parsed;
|
|
52
|
+
|
|
53
|
+
// Display summary and ask for confirmation before making any changes
|
|
54
|
+
console.log(`\n${bold("Setup summary:")}\n`);
|
|
55
|
+
console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
|
|
56
|
+
console.log(` All tasks and execution data will be stored here.\n`);
|
|
57
|
+
console.log(` ${dim("Local access:")} ${cyan(`http://localhost:${httpPort}`)}`);
|
|
58
|
+
console.log(` Always available — no internet required.\n`);
|
|
59
|
+
if (lanEnabled) {
|
|
60
|
+
const ip = detectLanIp();
|
|
61
|
+
console.log(` ${dim("LAN access:")} ${cyan(`http://${ip}:${httpPort}`)}`);
|
|
62
|
+
console.log(` Accessible from other devices on your local network. Pairing required.\n`);
|
|
63
|
+
}
|
|
64
|
+
console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
|
|
65
|
+
|
|
66
|
+
const confirm = await ask("Proceed? (Y/n): ");
|
|
67
|
+
if (confirm.trim().toLowerCase() === "n") {
|
|
68
|
+
console.log("\nSetup cancelled.");
|
|
69
|
+
rl.close();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
42
73
|
// Register with server
|
|
43
74
|
let existingHostId: string | undefined;
|
|
44
75
|
try { existingHostId = loadConfig().hostId; } catch { /* first init */ }
|
|
@@ -63,7 +94,7 @@ export async function initCommand(): Promise<void> {
|
|
|
63
94
|
}
|
|
64
95
|
}
|
|
65
96
|
|
|
66
|
-
// Build config
|
|
97
|
+
// Build and save config
|
|
67
98
|
const config: HostConfig = {
|
|
68
99
|
hostId: registerResponse.hostId,
|
|
69
100
|
projectRoot: process.cwd(),
|
|
@@ -71,11 +102,11 @@ export async function initCommand(): Promise<void> {
|
|
|
71
102
|
natsWsUrl: registerResponse.natsWsUrl,
|
|
72
103
|
natsToken: registerResponse.natsToken,
|
|
73
104
|
agents,
|
|
105
|
+
httpPort,
|
|
106
|
+
lanEnabled,
|
|
74
107
|
};
|
|
75
108
|
|
|
76
109
|
saveConfig(config);
|
|
77
|
-
|
|
78
|
-
console.log(`\n${green("Host provisioned")} ID: ${cyan(config.hostId)}`);
|
|
79
110
|
console.log(`Config saved to ${dim("~/.config/palmier/host.json")}`);
|
|
80
111
|
|
|
81
112
|
getPlatform().installDaemon(config);
|
package/src/commands/pair.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { StringCodec } from "nats";
|
|
|
3
3
|
import { loadConfig } from "../config.js";
|
|
4
4
|
import { connectNats } from "../nats-client.js";
|
|
5
5
|
import { addSession } from "../session-store.js";
|
|
6
|
-
import { getLanPort } from "../lan-lock.js";
|
|
7
6
|
import type { HostConfig } from "../types.js";
|
|
8
7
|
|
|
9
8
|
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
@@ -26,9 +25,9 @@ function buildPairResponse(config: HostConfig, label?: string) {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
|
-
* POST to the running
|
|
28
|
+
* POST to the running serve daemon and long-poll until paired or expired.
|
|
30
29
|
*/
|
|
31
|
-
function
|
|
30
|
+
function httpPairRegister(port: number, code: string): Promise<boolean> {
|
|
32
31
|
const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
|
|
33
32
|
|
|
34
33
|
return new Promise((resolve) => {
|
|
@@ -36,7 +35,7 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
36
35
|
{
|
|
37
36
|
hostname: "127.0.0.1",
|
|
38
37
|
port,
|
|
39
|
-
path: "/
|
|
38
|
+
path: "/pair-register",
|
|
40
39
|
method: "POST",
|
|
41
40
|
headers: { "Content-Type": "application/json" },
|
|
42
41
|
timeout: PAIRING_EXPIRY_MS + 5000,
|
|
@@ -63,11 +62,12 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
63
62
|
|
|
64
63
|
/**
|
|
65
64
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
66
|
-
* Listens on NATS
|
|
65
|
+
* Listens on NATS (server mode) and HTTP (via serve daemon) in parallel.
|
|
67
66
|
*/
|
|
68
67
|
export async function pairCommand(): Promise<void> {
|
|
69
68
|
const config = loadConfig();
|
|
70
69
|
const code = generatePairingCode();
|
|
70
|
+
const httpPort = config.httpPort ?? 7400;
|
|
71
71
|
|
|
72
72
|
let paired = false;
|
|
73
73
|
|
|
@@ -86,7 +86,7 @@ export async function pairCommand(): Promise<void> {
|
|
|
86
86
|
console.log("");
|
|
87
87
|
console.log("Code expires in 5 minutes.");
|
|
88
88
|
|
|
89
|
-
// NATS pairing (
|
|
89
|
+
// NATS pairing (server mode)
|
|
90
90
|
const nc = await connectNats(config);
|
|
91
91
|
const sc = StringCodec();
|
|
92
92
|
const subject = `pair.${code}`;
|
|
@@ -116,14 +116,11 @@ export async function pairCommand(): Promise<void> {
|
|
|
116
116
|
}
|
|
117
117
|
})();
|
|
118
118
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
(
|
|
123
|
-
|
|
124
|
-
if (result) onPaired();
|
|
125
|
-
})();
|
|
126
|
-
}
|
|
119
|
+
// HTTP pairing — register with serve daemon's /pair-register endpoint
|
|
120
|
+
(async () => {
|
|
121
|
+
const result = await httpPairRegister(httpPort, code);
|
|
122
|
+
if (result) onPaired();
|
|
123
|
+
})();
|
|
127
124
|
|
|
128
125
|
// Wait for pairing or timeout
|
|
129
126
|
const start = Date.now();
|
package/src/commands/run.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { getPlatform } from "../platform/index.js";
|
|
|
10
10
|
import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
|
|
11
11
|
import type { AgentTool } from "../agents/agent.js";
|
|
12
12
|
import { publishHostEvent } from "../events.js";
|
|
13
|
-
import { waitForUserInput } from "../user-input.js";
|
|
14
13
|
import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
|
|
15
14
|
import type { NatsConnection } from "nats";
|
|
16
15
|
|
|
@@ -36,23 +35,23 @@ interface InvocationResult {
|
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
/**
|
|
39
|
-
* Invoke the agent CLI with a
|
|
38
|
+
* Invoke the agent CLI with a continuation loop for permissions and user input.
|
|
40
39
|
*
|
|
41
40
|
* Both standard and command-triggered execution use this.
|
|
42
41
|
* The `invokeTask` is the ParsedTask whose prompt is passed to the agent
|
|
43
42
|
* (for command-triggered mode this is the per-line augmented task).
|
|
44
43
|
*/
|
|
45
|
-
async function
|
|
44
|
+
async function invokeAgentWithContinuation(
|
|
46
45
|
ctx: InvocationContext,
|
|
47
46
|
invokeTask: ParsedTask,
|
|
48
47
|
): Promise<InvocationResult> {
|
|
49
|
-
let
|
|
48
|
+
let followupPrompt: string | undefined;
|
|
50
49
|
// eslint-disable-next-line no-constant-condition
|
|
51
50
|
while (true) {
|
|
52
|
-
const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask,
|
|
51
|
+
const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, followupPrompt, ctx.transientPermissions);
|
|
53
52
|
const result = await spawnCommand(command, args, {
|
|
54
53
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
55
|
-
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
|
|
54
|
+
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7400) },
|
|
56
55
|
echoStdout: true,
|
|
57
56
|
resolveOnFailure: true,
|
|
58
57
|
stdin,
|
|
@@ -70,10 +69,9 @@ async function invokeAgentWithRetry(
|
|
|
70
69
|
attachments: reportFiles.length > 0 ? reportFiles : undefined,
|
|
71
70
|
});
|
|
72
71
|
|
|
73
|
-
// Permission
|
|
74
|
-
if (
|
|
75
|
-
const response = await requestPermission(ctx.
|
|
76
|
-
await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
|
|
72
|
+
// Permission handling — agent requested permissions
|
|
73
|
+
if (requiredPermissions.length > 0) {
|
|
74
|
+
const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
|
|
77
75
|
|
|
78
76
|
if (response === "aborted") {
|
|
79
77
|
await appendAndNotify(ctx, {
|
|
@@ -106,11 +104,14 @@ async function invokeAgentWithRetry(
|
|
|
106
104
|
ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
|
|
107
105
|
}
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// If the agent actually failed, retry with the new permissions
|
|
108
|
+
if (outcome === "failed") {
|
|
109
|
+
followupPrompt = "Permissions granted, please continue.";
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
// Normal completion (success or
|
|
114
|
+
// Normal completion (success or terminal failure)
|
|
114
115
|
return { outcome };
|
|
115
116
|
}
|
|
116
117
|
}
|
|
@@ -191,9 +192,7 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
191
192
|
|
|
192
193
|
// If requires_confirmation, notify clients and wait
|
|
193
194
|
if (task.frontmatter.requires_confirmation) {
|
|
194
|
-
const confirmed = await requestConfirmation(
|
|
195
|
-
const resolvedStatus = confirmed ? "confirmed" : "aborted";
|
|
196
|
-
await publishConfirmResolved(nc, config, taskId, resolvedStatus);
|
|
195
|
+
const confirmed = await requestConfirmation(config, task, taskDir);
|
|
197
196
|
if (!confirmed) {
|
|
198
197
|
console.log("Task aborted by user.");
|
|
199
198
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
|
|
@@ -229,7 +228,7 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
229
228
|
content: task.body || task.frontmatter.user_prompt,
|
|
230
229
|
});
|
|
231
230
|
|
|
232
|
-
const result = await
|
|
231
|
+
const result = await invokeAgentWithContinuation(ctx, task);
|
|
233
232
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
234
233
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
235
234
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
@@ -272,7 +271,7 @@ async function runCommandTriggeredMode(
|
|
|
272
271
|
|
|
273
272
|
const child = spawnStreamingCommand(commandStr, {
|
|
274
273
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
275
|
-
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
|
|
274
|
+
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7400) },
|
|
276
275
|
});
|
|
277
276
|
|
|
278
277
|
let linesProcessed = 0;
|
|
@@ -316,7 +315,7 @@ async function runCommandTriggeredMode(
|
|
|
316
315
|
body: "",
|
|
317
316
|
};
|
|
318
317
|
|
|
319
|
-
const result = await
|
|
318
|
+
const result = await invokeAgentWithContinuation(ctx, perLineTask);
|
|
320
319
|
if (result.outcome === "finished") {
|
|
321
320
|
invocationsSucceeded++;
|
|
322
321
|
} else {
|
|
@@ -405,41 +404,21 @@ async function publishTaskEvent(
|
|
|
405
404
|
await publishHostEvent(nc, config.hostId, taskId, payload);
|
|
406
405
|
}
|
|
407
406
|
|
|
408
|
-
/**
|
|
409
|
-
* Notify clients that a confirmation request has been resolved.
|
|
410
|
-
*/
|
|
411
|
-
async function publishConfirmResolved(
|
|
412
|
-
nc: NatsConnection | undefined,
|
|
413
|
-
config: HostConfig,
|
|
414
|
-
taskId: string,
|
|
415
|
-
status: "confirmed" | "aborted",
|
|
416
|
-
): Promise<void> {
|
|
417
|
-
await publishHostEvent(nc, config.hostId, taskId, {
|
|
418
|
-
event_type: "confirm-resolved",
|
|
419
|
-
host_id: config.hostId,
|
|
420
|
-
status,
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
407
|
|
|
424
408
|
async function requestPermission(
|
|
425
|
-
nc: NatsConnection | undefined,
|
|
426
409
|
config: HostConfig,
|
|
427
410
|
task: ParsedTask,
|
|
428
411
|
taskDir: string,
|
|
429
412
|
requiredPermissions: RequiredPermission[],
|
|
430
413
|
): Promise<"granted" | "granted_all" | "aborted"> {
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
host_id: config.hostId,
|
|
437
|
-
required_permissions: requiredPermissions,
|
|
438
|
-
name: task.frontmatter.name,
|
|
414
|
+
const port = config.httpPort ?? 7400;
|
|
415
|
+
const params = new URLSearchParams({
|
|
416
|
+
taskId: task.frontmatter.id,
|
|
417
|
+
taskName: task.frontmatter.name,
|
|
418
|
+
permissions: JSON.stringify(requiredPermissions),
|
|
439
419
|
});
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const response = userInput[0] as "granted" | "granted_all" | "aborted";
|
|
420
|
+
const res = await fetch(`http://localhost:${port}/request-permission?${params}`);
|
|
421
|
+
const { response } = await res.json() as { response: "granted" | "granted_all" | "aborted" };
|
|
443
422
|
writeTaskStatus(taskDir, {
|
|
444
423
|
running_state: response === "aborted" ? "aborted" : "started",
|
|
445
424
|
time_stamp: Date.now(),
|
|
@@ -447,35 +426,19 @@ async function requestPermission(
|
|
|
447
426
|
return response;
|
|
448
427
|
}
|
|
449
428
|
|
|
450
|
-
async function publishPermissionResolved(
|
|
451
|
-
nc: NatsConnection | undefined,
|
|
452
|
-
config: HostConfig,
|
|
453
|
-
taskId: string,
|
|
454
|
-
status: "granted" | "granted_all" | "aborted",
|
|
455
|
-
): Promise<void> {
|
|
456
|
-
await publishHostEvent(nc, config.hostId, taskId, {
|
|
457
|
-
event_type: "permission-resolved",
|
|
458
|
-
host_id: config.hostId,
|
|
459
|
-
status,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
429
|
|
|
463
430
|
async function requestConfirmation(
|
|
464
|
-
nc: NatsConnection | undefined,
|
|
465
431
|
config: HostConfig,
|
|
466
432
|
task: ParsedTask,
|
|
467
433
|
taskDir: string,
|
|
468
434
|
): Promise<boolean> {
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
event_type: "confirm-request",
|
|
474
|
-
host_id: config.hostId,
|
|
435
|
+
const port = config.httpPort ?? 7400;
|
|
436
|
+
const params = new URLSearchParams({
|
|
437
|
+
taskId: task.frontmatter.id,
|
|
438
|
+
taskName: task.frontmatter.name,
|
|
475
439
|
});
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
const confirmed = userInput[0] === "confirmed";
|
|
440
|
+
const res = await fetch(`http://localhost:${port}/request-confirmation?${params}`);
|
|
441
|
+
const { confirmed } = await res.json() as { confirmed: boolean };
|
|
479
442
|
writeTaskStatus(taskDir, {
|
|
480
443
|
running_state: confirmed ? "started" : "aborted",
|
|
481
444
|
time_stamp: Date.now(),
|
package/src/commands/serve.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { loadConfig } from "../config.js";
|
|
|
4
4
|
import { connectNats } from "../nats-client.js";
|
|
5
5
|
import { createRpcHandler } from "../rpc-handler.js";
|
|
6
6
|
import { startNatsTransport } from "../transports/nats-transport.js";
|
|
7
|
+
import { startHttpTransport } from "../transports/http-transport.js";
|
|
7
8
|
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage } from "../task.js";
|
|
8
9
|
import { publishHostEvent } from "../events.js";
|
|
9
10
|
import { getPlatform } from "../platform/index.js";
|
|
@@ -78,7 +79,7 @@ async function checkStaleTasks(
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
|
-
* Start the persistent RPC handler (NATS
|
|
82
|
+
* Start the persistent RPC handler (NATS + HTTP).
|
|
82
83
|
*/
|
|
83
84
|
export async function serveCommand(): Promise<void> {
|
|
84
85
|
const config = loadConfig();
|
|
@@ -107,5 +108,13 @@ export async function serveCommand(): Promise<void> {
|
|
|
107
108
|
}, POLL_INTERVAL_MS);
|
|
108
109
|
|
|
109
110
|
const handleRpc = createRpcHandler(config, nc);
|
|
110
|
-
|
|
111
|
+
const httpPort = config.httpPort ?? 7400;
|
|
112
|
+
|
|
113
|
+
// Start NATS transport (loops forever, fire-and-forget)
|
|
114
|
+
if (nc) {
|
|
115
|
+
startNatsTransport(config, handleRpc, nc);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Start HTTP transport (loops forever)
|
|
119
|
+
await startHttpTransport(config, handleRpc, httpPort, nc);
|
|
111
120
|
}
|
package/src/events.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { StringCodec, type NatsConnection } from "nats";
|
|
2
|
-
import {
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
3
|
|
|
4
4
|
const sc = StringCodec();
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Broadcast an event to connected clients via NATS and HTTP SSE
|
|
7
|
+
* Broadcast an event to connected clients via NATS and HTTP SSE.
|
|
8
8
|
*
|
|
9
9
|
* - NATS: publishes to `host-event.{hostId}.{taskId}`
|
|
10
|
-
* - HTTP: POSTs to the
|
|
10
|
+
* - HTTP: POSTs to the serve daemon's `/event` endpoint
|
|
11
11
|
*/
|
|
12
12
|
export async function publishHostEvent(
|
|
13
13
|
nc: NatsConnection | undefined,
|
|
@@ -22,17 +22,16 @@ export async function publishHostEvent(
|
|
|
22
22
|
console.log(`[nats] ${subject} →`, payload);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
25
|
+
const config = loadConfig();
|
|
26
|
+
const port = config.httpPort ?? 7400;
|
|
27
|
+
try {
|
|
28
|
+
await fetch(`http://localhost:${port}/event`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ task_id: taskId, ...payload }),
|
|
32
|
+
});
|
|
33
|
+
console.log(`[http] host-event: ${taskId} →`, payload);
|
|
34
|
+
} catch {
|
|
35
|
+
// Serve HTTP may not be ready yet — ignore
|
|
37
36
|
}
|
|
38
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,11 +9,8 @@ import { initCommand } from "./commands/init.js";
|
|
|
9
9
|
import { infoCommand } from "./commands/info.js";
|
|
10
10
|
import { runCommand } from "./commands/run.js";
|
|
11
11
|
import { serveCommand } from "./commands/serve.js";
|
|
12
|
-
import { notifyCommand } from "./commands/notify.js";
|
|
13
|
-
import { requestInputCommand } from "./commands/request-input.js";
|
|
14
12
|
|
|
15
13
|
import { pairCommand } from "./commands/pair.js";
|
|
16
|
-
import { lanCommand } from "./commands/lan.js";
|
|
17
14
|
import { restartCommand } from "./commands/restart.js";
|
|
18
15
|
import { sessionsListCommand, sessionsRevokeCommand, sessionsRevokeAllCommand } from "./commands/sessions.js";
|
|
19
16
|
|
|
@@ -62,22 +59,6 @@ program
|
|
|
62
59
|
await restartCommand();
|
|
63
60
|
});
|
|
64
61
|
|
|
65
|
-
program
|
|
66
|
-
.command("notify")
|
|
67
|
-
.description("Send a push notification to the user")
|
|
68
|
-
.requiredOption("--title <title>", "Notification title")
|
|
69
|
-
.requiredOption("--body <body>", "Notification body text")
|
|
70
|
-
.action(async (opts: { title: string; body: string }) => {
|
|
71
|
-
await notifyCommand(opts);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
program
|
|
75
|
-
.command("request-input")
|
|
76
|
-
.description("Request input from the user (requires PALMIER_TASK_ID env var)")
|
|
77
|
-
.requiredOption("--description <desc...>", "Input descriptions to show the user")
|
|
78
|
-
.action(async (opts: { description: string[] }) => {
|
|
79
|
-
await requestInputCommand(opts);
|
|
80
|
-
});
|
|
81
62
|
|
|
82
63
|
program
|
|
83
64
|
.command("pair")
|
|
@@ -86,13 +67,6 @@ program
|
|
|
86
67
|
await pairCommand();
|
|
87
68
|
});
|
|
88
69
|
|
|
89
|
-
program
|
|
90
|
-
.command("lan")
|
|
91
|
-
.description("Start an on-demand LAN server for direct HTTP connections")
|
|
92
|
-
.option("-p, --port <port>", "Port to listen on", "7400")
|
|
93
|
-
.action(async (opts: { port: string }) => {
|
|
94
|
-
await lanCommand({ port: parseInt(opts.port, 10) });
|
|
95
|
-
});
|
|
96
70
|
|
|
97
71
|
const sessionsCmd = program
|
|
98
72
|
.command("sessions")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RequiredPermission } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface PendingRequest {
|
|
4
|
+
type: "confirmation" | "permission" | "input";
|
|
5
|
+
resolve: (value: string[]) => void;
|
|
6
|
+
/** Permission list (for 'permission') or input descriptions (for 'input'). */
|
|
7
|
+
params?: RequiredPermission[] | string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const pending = new Map<string, PendingRequest>();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register a pending request for a task. Returns a Promise that resolves
|
|
14
|
+
* when `resolvePending` is called with the user's response.
|
|
15
|
+
* Only one pending request per task at a time.
|
|
16
|
+
*/
|
|
17
|
+
export function registerPending(
|
|
18
|
+
taskId: string,
|
|
19
|
+
type: PendingRequest["type"],
|
|
20
|
+
params?: PendingRequest["params"],
|
|
21
|
+
): Promise<string[]> {
|
|
22
|
+
if (pending.has(taskId)) {
|
|
23
|
+
return Promise.reject(new Error(`Task ${taskId} already has a pending request`));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise<string[]>((resolve) => {
|
|
27
|
+
pending.set(taskId, { type, resolve, params });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a pending request with the user's response.
|
|
33
|
+
* Returns true if a pending request was found and resolved.
|
|
34
|
+
*/
|
|
35
|
+
export function resolvePending(taskId: string, value: string[]): boolean {
|
|
36
|
+
const entry = pending.get(taskId);
|
|
37
|
+
if (!entry) return false;
|
|
38
|
+
pending.delete(taskId);
|
|
39
|
+
entry.resolve(value);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the current pending request for a task (if any).
|
|
45
|
+
*/
|
|
46
|
+
export function getPending(taskId: string): PendingRequest | undefined {
|
|
47
|
+
return pending.get(taskId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove a pending request without resolving it.
|
|
52
|
+
*/
|
|
53
|
+
export function removePending(taskId: string): void {
|
|
54
|
+
pending.delete(taskId);
|
|
55
|
+
}
|
package/src/rpc-handler.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn, type ChildProcess } from "child_process";
|
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
7
|
import { type NatsConnection } from "nats";
|
|
8
8
|
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
|
|
9
|
+
import { resolvePending, getPending } from "./pending-requests.js";
|
|
9
10
|
import { getPlatform } from "./platform/index.js";
|
|
10
11
|
import { spawnCommand } from "./spawn-command.js";
|
|
11
12
|
import crossSpawn from "cross-spawn";
|
|
@@ -157,8 +158,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
async function handleRpc(request: RpcMessage): Promise<unknown> {
|
|
160
|
-
// Session token validation:
|
|
161
|
-
if (!request.sessionToken || !validateSession(request.sessionToken)) {
|
|
161
|
+
// Session token validation: skip for trusted localhost requests
|
|
162
|
+
if (!request.localhost && (!request.sessionToken || !validateSession(request.sessionToken))) {
|
|
162
163
|
return { error: "Unauthorized" };
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -521,7 +522,14 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
521
522
|
if (!status) {
|
|
522
523
|
return { task_id: params.id, error: "No status found" };
|
|
523
524
|
}
|
|
524
|
-
|
|
525
|
+
const pending = getPending(params.id);
|
|
526
|
+
return {
|
|
527
|
+
task_id: params.id,
|
|
528
|
+
...status,
|
|
529
|
+
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
530
|
+
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
531
|
+
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
532
|
+
};
|
|
525
533
|
}
|
|
526
534
|
|
|
527
535
|
case "task.result": {
|
|
@@ -570,17 +578,15 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
570
578
|
|
|
571
579
|
case "task.user_input": {
|
|
572
580
|
const params = request.params as { id: string; value: string[] };
|
|
573
|
-
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
574
581
|
|
|
575
|
-
const
|
|
576
|
-
if (!
|
|
582
|
+
const pending = getPending(params.id);
|
|
583
|
+
if (!pending) {
|
|
577
584
|
return { ok: false, error: "not pending" };
|
|
578
585
|
}
|
|
579
586
|
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
const resolved = resolvePending(params.id, params.value);
|
|
582
588
|
console.log(`[task.user_input] ${params.id} → ${params.value}`);
|
|
583
|
-
return { ok:
|
|
589
|
+
return { ok: resolved };
|
|
584
590
|
}
|
|
585
591
|
|
|
586
592
|
case "taskrun.list": {
|