palmier 0.3.1 → 0.3.3
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 +23 -15
- package/dist/agents/claude.js +1 -3
- package/dist/agents/codex.js +2 -5
- package/dist/agents/gemini.js +1 -3
- package/dist/commands/init.js +0 -1
- package/dist/commands/lan.js +3 -10
- package/dist/commands/pair.d.ts +2 -0
- package/dist/commands/pair.js +8 -22
- package/dist/commands/run.js +40 -65
- package/dist/config.js +0 -1
- package/dist/events.js +1 -16
- package/dist/lan-lock.d.ts +7 -0
- package/dist/lan-lock.js +18 -0
- package/dist/platform/index.d.ts +5 -0
- package/dist/platform/index.js +5 -0
- package/dist/platform/windows.js +3 -2
- package/dist/types.d.ts +20 -1
- package/dist/update-checker.d.ts +2 -0
- package/dist/update-checker.js +7 -2
- package/package.json +1 -1
- package/src/agents/claude.ts +1 -4
- package/src/agents/codex.ts +2 -6
- package/src/agents/gemini.ts +1 -4
- package/src/commands/init.ts +0 -1
- package/src/commands/lan.ts +3 -13
- package/src/commands/pair.ts +9 -20
- package/src/commands/run.ts +40 -64
- package/src/config.ts +0 -1
- package/src/events.ts +1 -14
- package/src/lan-lock.ts +16 -0
- package/src/platform/index.ts +6 -0
- package/src/platform/windows.ts +3 -2
- package/src/types.ts +20 -2
- package/src/update-checker.ts +7 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Website:** [palmier.me](https://www.palmier.me) | **App:** [app.palmier.me](https://app.palmier.me)
|
|
8
8
|
|
|
9
|
-
A Node.js CLI that runs on your machine as a persistent daemon. It
|
|
9
|
+
A Node.js CLI that runs on your machine as a persistent daemon. It lets you create, schedule, and run AI agent tasks from your phone or browser, communicating via a cloud relay (NATS) and/or direct HTTP.
|
|
10
10
|
|
|
11
11
|
> **Important:** By using Palmier, you agree to the [Terms of Service](https://www.palmier.me/terms) and [Privacy Policy](https://www.palmier.me/privacy). See the [Disclaimer](#disclaimer) section below.
|
|
12
12
|
|
|
@@ -16,10 +16,10 @@ The host supports two independent connection modes, enabled during `palmier init
|
|
|
16
16
|
|
|
17
17
|
| Mode | Transport | PWA URL | Features |
|
|
18
18
|
|------|-----------|---------|----------|
|
|
19
|
-
| **Server** |
|
|
19
|
+
| **Server** | Cloud relay (NATS) | `https://app.palmier.me` | Push notifications, remote access |
|
|
20
20
|
| **LAN** | HTTP (direct, on-demand) | `http://<host-ip>:7400` | Low-latency, no external server needed |
|
|
21
21
|
|
|
22
|
-
**Server mode** relays communication through the Palmier server via NATS. All features including push notifications are available. The PWA is served over HTTPS.
|
|
22
|
+
**Server mode** relays communication through the Palmier cloud server (via [NATS](https://nats.io), a lightweight messaging system). All features including push notifications are available. The PWA is served over HTTPS.
|
|
23
23
|
|
|
24
24
|
**LAN mode** is started on-demand via `palmier lan`. It runs a local HTTP server that reverse-proxies PWA assets from `app.palmier.me` and serves API endpoints locally. The browser accesses everything at `http://<host-ip>:<port>` (same-origin). Push notifications are not available in LAN mode.
|
|
25
25
|
|
|
@@ -35,6 +35,8 @@ The host supports two independent connection modes, enabled during `palmier init
|
|
|
35
35
|
npm install -g palmier
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
All `palmier` commands should be run from a dedicated Palmier root directory (e.g., `~/palmier`). This is where tasks, configuration, and execution data are stored.
|
|
39
|
+
|
|
38
40
|
## CLI Commands
|
|
39
41
|
|
|
40
42
|
| Command | Description |
|
|
@@ -57,7 +59,7 @@ npm install -g palmier
|
|
|
57
59
|
### Quick Start
|
|
58
60
|
|
|
59
61
|
1. Install the host: `npm install -g palmier`
|
|
60
|
-
2. Run `palmier init` in your
|
|
62
|
+
2. Run `palmier init` in your Palmier root directory (e.g., `~/palmier`).
|
|
61
63
|
3. The wizard detects installed agents, registers with the Palmier server, installs a background daemon, and generates a pairing code.
|
|
62
64
|
4. Enter the pairing code in the Palmier PWA to connect your device.
|
|
63
65
|
|
|
@@ -132,6 +134,15 @@ palmier restart
|
|
|
132
134
|
- **Real-time updates** — task status changes (started, finished, failed) are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode).
|
|
133
135
|
- **MCP server** (`palmier mcpserver`) exposes platform tools (e.g., `send-push-notification`) to AI agents like Claude Code over stdio.
|
|
134
136
|
|
|
137
|
+
## NATS Subjects
|
|
138
|
+
|
|
139
|
+
| Subject | Direction | Description |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `host.<hostId>.rpc.<method>` | Client → Host | RPC request/reply (e.g., `task.list`, `task.create`) |
|
|
142
|
+
| `host-event.<hostId>.<taskId>` | Host → Client | Real-time task events (`running-state`, `confirm-request`, `permission-request`, `input-request`) |
|
|
143
|
+
| `host.<hostId>.push.send` | Host → Server | Request server to deliver a push notification |
|
|
144
|
+
| `pair.<code>` | Client → Host | OTP pairing request/reply |
|
|
145
|
+
|
|
135
146
|
## Project Structure
|
|
136
147
|
|
|
137
148
|
```
|
|
@@ -144,16 +155,18 @@ src/
|
|
|
144
155
|
spawn-command.ts # Shared helper for spawning CLI tools
|
|
145
156
|
task.ts # Task file management
|
|
146
157
|
types.ts # Shared type definitions
|
|
158
|
+
lan-lock.ts # LAN lockfile path and port reader
|
|
159
|
+
events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
|
|
147
160
|
agents/
|
|
148
161
|
agent.ts # AgentTool interface, registry, and agent detection
|
|
149
162
|
claude.ts # Claude Code agent implementation
|
|
150
163
|
gemini.ts # Gemini CLI agent implementation
|
|
151
164
|
codex.ts # Codex CLI agent implementation
|
|
152
165
|
openclaw.ts # OpenClaw agent implementation
|
|
153
|
-
events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
|
|
154
166
|
commands/
|
|
155
167
|
init.ts # Interactive setup wizard (auto-pair)
|
|
156
168
|
pair.ts # OTP code generation and pairing handler
|
|
169
|
+
lan.ts # On-demand LAN server
|
|
157
170
|
sessions.ts # Session token management CLI (list, revoke, revoke-all)
|
|
158
171
|
info.ts # Print host connection info
|
|
159
172
|
agents.ts # Re-detect installed agent CLIs
|
|
@@ -198,11 +211,11 @@ Requires a provisioned host (`palmier init`) with server mode enabled.
|
|
|
198
211
|
|---|---|---|
|
|
199
212
|
| `send-push-notification` | `title`, `body` (required) | Send a push notification to all paired devices |
|
|
200
213
|
|
|
201
|
-
##
|
|
214
|
+
## Uninstalling
|
|
202
215
|
|
|
203
|
-
To fully remove
|
|
216
|
+
To fully remove Palmier from a machine:
|
|
204
217
|
|
|
205
|
-
1. **Unpair
|
|
218
|
+
1. **Unpair your device** in the PWA (via the host menu).
|
|
206
219
|
|
|
207
220
|
2. **Stop and remove the daemon:**
|
|
208
221
|
|
|
@@ -236,16 +249,11 @@ To fully remove a host from a machine:
|
|
|
236
249
|
schtasks /delete /tn "PalmierTask-*" /f 2>$null
|
|
237
250
|
```
|
|
238
251
|
|
|
239
|
-
4. **Remove
|
|
252
|
+
4. **Remove configuration and task data:**
|
|
240
253
|
|
|
241
254
|
```bash
|
|
242
255
|
rm -rf ~/.config/palmier
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
5. **Remove the tasks directory** from your project root:
|
|
246
|
-
|
|
247
|
-
```bash
|
|
248
|
-
rm -rf tasks/
|
|
256
|
+
rm -rf tasks/ # from your Palmier root directory
|
|
249
257
|
```
|
|
250
258
|
|
|
251
259
|
## Disclaimer
|
package/dist/agents/claude.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
2
|
import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
|
|
3
|
-
|
|
4
|
-
// On Windows we need a shell so .cmd shims resolve correctly.
|
|
5
|
-
const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
3
|
+
import { SHELL } from "../platform/index.js";
|
|
6
4
|
export class ClaudeAgent {
|
|
7
5
|
getPlanGenerationCommandLine(prompt) {
|
|
8
6
|
return {
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
2
|
import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
|
|
3
|
-
|
|
4
|
-
const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
3
|
+
import { SHELL } from "../platform/index.js";
|
|
5
4
|
export class CodexAgent {
|
|
6
5
|
getPlanGenerationCommandLine(prompt) {
|
|
7
|
-
// TODO: fill in
|
|
8
6
|
return {
|
|
9
7
|
command: "codex",
|
|
10
8
|
args: ["exec", "--skip-git-repo-check", prompt],
|
|
@@ -12,8 +10,7 @@ export class CodexAgent {
|
|
|
12
10
|
}
|
|
13
11
|
getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
|
|
14
12
|
const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
|
|
15
|
-
//
|
|
16
|
-
// is fixed.
|
|
13
|
+
// Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
|
|
17
14
|
const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
|
|
18
15
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
19
16
|
for (const p of allPerms) {
|
package/dist/agents/gemini.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
2
|
import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
|
|
3
|
-
|
|
4
|
-
const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
3
|
+
import { SHELL } from "../platform/index.js";
|
|
5
4
|
export class GeminiAgent {
|
|
6
5
|
getPlanGenerationCommandLine(prompt) {
|
|
7
|
-
// TODO: fill in
|
|
8
6
|
return {
|
|
9
7
|
command: "gemini",
|
|
10
8
|
args: ["--approval-mode", "auto_edit", "--prompt", prompt],
|
package/dist/commands/init.js
CHANGED
package/dist/commands/lan.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
2
|
import { loadConfig, CONFIG_DIR } from "../config.js";
|
|
4
3
|
import { createRpcHandler } from "../rpc-handler.js";
|
|
5
4
|
import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
|
|
6
|
-
|
|
5
|
+
import { generatePairingCode } from "./pair.js";
|
|
6
|
+
import { LAN_LOCKFILE } from "../lan-lock.js";
|
|
7
7
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
8
8
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
9
9
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
10
|
-
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
11
|
-
const CODE_LENGTH = 6;
|
|
12
|
-
function generateCode() {
|
|
13
|
-
const bytes = new Uint8Array(CODE_LENGTH);
|
|
14
|
-
crypto.getRandomValues(bytes);
|
|
15
|
-
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
16
|
-
}
|
|
17
10
|
function writeLockfile(port) {
|
|
18
11
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
19
12
|
fs.writeFileSync(LAN_LOCKFILE, JSON.stringify({ port, pid: process.pid }), "utf-8");
|
|
@@ -32,7 +25,7 @@ export async function lanCommand(opts) {
|
|
|
32
25
|
const config = loadConfig();
|
|
33
26
|
const port = opts.port;
|
|
34
27
|
const ip = detectLanIp();
|
|
35
|
-
const code =
|
|
28
|
+
const code = generatePairingCode();
|
|
36
29
|
const handleRpc = createRpcHandler(config);
|
|
37
30
|
// Write lockfile so other palmier processes can discover us
|
|
38
31
|
writeLockfile(port);
|
package/dist/commands/pair.d.ts
CHANGED
package/dist/commands/pair.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
1
|
import * as http from "node:http";
|
|
4
2
|
import { StringCodec } from "nats";
|
|
5
|
-
import { loadConfig
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
6
4
|
import { connectNats } from "../nats-client.js";
|
|
7
5
|
import { addSession } from "../session-store.js";
|
|
6
|
+
import { getLanPort } from "../lan-lock.js";
|
|
8
7
|
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
9
8
|
const CODE_LENGTH = 6;
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
function generateCode() {
|
|
9
|
+
export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
export function generatePairingCode() {
|
|
13
11
|
const bytes = new Uint8Array(CODE_LENGTH);
|
|
14
12
|
crypto.getRandomValues(bytes);
|
|
15
13
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
@@ -25,7 +23,7 @@ function buildPairResponse(config, label) {
|
|
|
25
23
|
* POST to the running LAN server and long-poll until paired or expired.
|
|
26
24
|
*/
|
|
27
25
|
function lanPairRegister(port, code) {
|
|
28
|
-
const body = JSON.stringify({ code, expiryMs:
|
|
26
|
+
const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
|
|
29
27
|
return new Promise((resolve) => {
|
|
30
28
|
const req = http.request({
|
|
31
29
|
hostname: "127.0.0.1",
|
|
@@ -33,7 +31,7 @@ function lanPairRegister(port, code) {
|
|
|
33
31
|
path: "/internal/pair-register",
|
|
34
32
|
method: "POST",
|
|
35
33
|
headers: { "Content-Type": "application/json" },
|
|
36
|
-
timeout:
|
|
34
|
+
timeout: PAIRING_EXPIRY_MS + 5000,
|
|
37
35
|
}, (res) => {
|
|
38
36
|
const chunks = [];
|
|
39
37
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -52,25 +50,13 @@ function lanPairRegister(port, code) {
|
|
|
52
50
|
req.end(body);
|
|
53
51
|
});
|
|
54
52
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Read the LAN lockfile to check if `palmier lan` is running.
|
|
57
|
-
*/
|
|
58
|
-
function getLanPort() {
|
|
59
|
-
try {
|
|
60
|
-
const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
|
|
61
|
-
return JSON.parse(raw).port;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
53
|
/**
|
|
68
54
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
69
55
|
* Listens on NATS always, and also on the LAN server if `palmier lan` is running.
|
|
70
56
|
*/
|
|
71
57
|
export async function pairCommand() {
|
|
72
58
|
const config = loadConfig();
|
|
73
|
-
const code =
|
|
59
|
+
const code = generatePairingCode();
|
|
74
60
|
let paired = false;
|
|
75
61
|
function onPaired() {
|
|
76
62
|
paired = true;
|
|
@@ -125,7 +111,7 @@ export async function pairCommand() {
|
|
|
125
111
|
const start = Date.now();
|
|
126
112
|
await new Promise((resolve) => {
|
|
127
113
|
const interval = setInterval(() => {
|
|
128
|
-
if (paired || Date.now() - start >=
|
|
114
|
+
if (paired || Date.now() - start >= PAIRING_EXPIRY_MS) {
|
|
129
115
|
clearInterval(interval);
|
|
130
116
|
resolve();
|
|
131
117
|
}
|
package/dist/commands/run.js
CHANGED
|
@@ -194,16 +194,13 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
194
194
|
cwd: ctx.taskDir,
|
|
195
195
|
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
|
|
196
196
|
});
|
|
197
|
-
// Stats
|
|
198
197
|
let linesProcessed = 0;
|
|
199
198
|
let invocationsSucceeded = 0;
|
|
200
199
|
let invocationsFailed = 0;
|
|
201
|
-
// Bounded queue for incoming lines
|
|
202
200
|
const lineQueue = [];
|
|
203
201
|
let processing = false;
|
|
204
202
|
let commandExited = false;
|
|
205
203
|
let resolveWhenDone;
|
|
206
|
-
// Rolling log of per-line agent outputs
|
|
207
204
|
const logPath = path.join(ctx.taskDir, "command-output.log");
|
|
208
205
|
function appendLog(line, agentOutput, outcome) {
|
|
209
206
|
const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
|
|
@@ -259,11 +256,10 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
259
256
|
}
|
|
260
257
|
}
|
|
261
258
|
}
|
|
262
|
-
// Read stdout line by line
|
|
263
259
|
const rl = readline.createInterface({ input: child.stdout });
|
|
264
260
|
rl.on("line", (line) => {
|
|
265
261
|
if (!line.trim())
|
|
266
|
-
return;
|
|
262
|
+
return;
|
|
267
263
|
if (lineQueue.length >= MAX_QUEUE_SIZE) {
|
|
268
264
|
console.warn(`[command-triggered] Queue full, dropping oldest line.`);
|
|
269
265
|
lineQueue.shift();
|
|
@@ -274,7 +270,6 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
274
270
|
invocationsFailed++;
|
|
275
271
|
});
|
|
276
272
|
});
|
|
277
|
-
// Log stderr
|
|
278
273
|
child.stderr?.on("data", (d) => process.stderr.write(d));
|
|
279
274
|
// Wait for command to exit
|
|
280
275
|
const exitCode = await new Promise((resolve) => {
|
|
@@ -305,9 +300,7 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
305
300
|
`Agent invocations succeeded: ${invocationsSucceeded}`,
|
|
306
301
|
`Agent invocations failed: ${invocationsFailed}`,
|
|
307
302
|
].join("\n");
|
|
308
|
-
|
|
309
|
-
const outcome = "finished";
|
|
310
|
-
return { outcome, endTime, output: summary };
|
|
303
|
+
return { outcome: "finished", endTime, output: summary };
|
|
311
304
|
}
|
|
312
305
|
async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName) {
|
|
313
306
|
writeTaskStatus(taskDir, {
|
|
@@ -329,32 +322,39 @@ async function publishConfirmResolved(nc, config, taskId, status) {
|
|
|
329
322
|
status,
|
|
330
323
|
});
|
|
331
324
|
}
|
|
332
|
-
|
|
333
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Watch status.json until user_input is populated by an RPC call, then resolve.
|
|
327
|
+
* All interactive request flows (confirmation, permission, user input) share this.
|
|
328
|
+
*/
|
|
329
|
+
function waitForUserInput(taskDir) {
|
|
334
330
|
const statusPath = path.join(taskDir, "status.json");
|
|
335
|
-
const currentStatus = readTaskStatus(taskDir);
|
|
336
|
-
writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
|
|
337
|
-
await publishHostEvent(nc, config.hostId, taskId, {
|
|
338
|
-
event_type: "permission-request",
|
|
339
|
-
host_id: config.hostId,
|
|
340
|
-
required_permissions: requiredPermissions,
|
|
341
|
-
name: task.frontmatter.name,
|
|
342
|
-
});
|
|
343
331
|
return new Promise((resolve) => {
|
|
344
332
|
const watcher = fs.watch(statusPath, () => {
|
|
345
333
|
const status = readTaskStatus(taskDir);
|
|
346
334
|
if (!status || !status.user_input?.length)
|
|
347
335
|
return;
|
|
348
336
|
watcher.close();
|
|
349
|
-
|
|
350
|
-
writeTaskStatus(taskDir, {
|
|
351
|
-
running_state: response === "aborted" ? "aborted" : "started",
|
|
352
|
-
time_stamp: Date.now(),
|
|
353
|
-
});
|
|
354
|
-
resolve(response);
|
|
337
|
+
resolve(status.user_input);
|
|
355
338
|
});
|
|
356
339
|
});
|
|
357
340
|
}
|
|
341
|
+
async function requestPermission(nc, config, task, taskDir, requiredPermissions) {
|
|
342
|
+
const currentStatus = readTaskStatus(taskDir);
|
|
343
|
+
writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
|
|
344
|
+
await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
|
|
345
|
+
event_type: "permission-request",
|
|
346
|
+
host_id: config.hostId,
|
|
347
|
+
required_permissions: requiredPermissions,
|
|
348
|
+
name: task.frontmatter.name,
|
|
349
|
+
});
|
|
350
|
+
const userInput = await waitForUserInput(taskDir);
|
|
351
|
+
const response = userInput[0];
|
|
352
|
+
writeTaskStatus(taskDir, {
|
|
353
|
+
running_state: response === "aborted" ? "aborted" : "started",
|
|
354
|
+
time_stamp: Date.now(),
|
|
355
|
+
});
|
|
356
|
+
return response;
|
|
357
|
+
}
|
|
358
358
|
async function publishPermissionResolved(nc, config, taskId, status) {
|
|
359
359
|
await publishHostEvent(nc, config.hostId, taskId, {
|
|
360
360
|
event_type: "permission-resolved",
|
|
@@ -363,33 +363,21 @@ async function publishPermissionResolved(nc, config, taskId, status) {
|
|
|
363
363
|
});
|
|
364
364
|
}
|
|
365
365
|
async function requestUserInput(nc, config, task, taskDir, inputDescriptions) {
|
|
366
|
-
const taskId = task.frontmatter.id;
|
|
367
|
-
const statusPath = path.join(taskDir, "status.json");
|
|
368
366
|
const currentStatus = readTaskStatus(taskDir);
|
|
369
367
|
writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
|
|
370
|
-
await publishHostEvent(nc, config.hostId,
|
|
368
|
+
await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
|
|
371
369
|
event_type: "input-request",
|
|
372
370
|
host_id: config.hostId,
|
|
373
371
|
input_descriptions: inputDescriptions,
|
|
374
372
|
name: task.frontmatter.name,
|
|
375
373
|
});
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (response.length === 1 && response[0] === "aborted") {
|
|
384
|
-
writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
|
|
385
|
-
resolve("aborted");
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
|
|
389
|
-
resolve(response);
|
|
390
|
-
}
|
|
391
|
-
});
|
|
392
|
-
});
|
|
374
|
+
const userInput = await waitForUserInput(taskDir);
|
|
375
|
+
if (userInput.length === 1 && userInput[0] === "aborted") {
|
|
376
|
+
writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
|
|
377
|
+
return "aborted";
|
|
378
|
+
}
|
|
379
|
+
writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
|
|
380
|
+
return userInput;
|
|
393
381
|
}
|
|
394
382
|
async function publishInputResolved(nc, config, taskId, status) {
|
|
395
383
|
await publishHostEvent(nc, config.hostId, taskId, {
|
|
@@ -399,32 +387,19 @@ async function publishInputResolved(nc, config, taskId, status) {
|
|
|
399
387
|
});
|
|
400
388
|
}
|
|
401
389
|
async function requestConfirmation(nc, config, task, taskDir) {
|
|
402
|
-
const taskId = task.frontmatter.id;
|
|
403
|
-
const statusPath = path.join(taskDir, "status.json");
|
|
404
|
-
// Flag that we're awaiting user confirmation
|
|
405
390
|
const currentStatus = readTaskStatus(taskDir);
|
|
406
391
|
writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
|
|
407
|
-
|
|
408
|
-
await publishHostEvent(nc, config.hostId, taskId, {
|
|
392
|
+
await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
|
|
409
393
|
event_type: "confirm-request",
|
|
410
394
|
host_id: config.hostId,
|
|
411
395
|
});
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return; // still pending
|
|
418
|
-
watcher.close();
|
|
419
|
-
const confirmed = status.user_input[0] === "confirmed";
|
|
420
|
-
// Clear pending_confirmation/user_input and update running_state
|
|
421
|
-
writeTaskStatus(taskDir, {
|
|
422
|
-
running_state: confirmed ? "started" : "aborted",
|
|
423
|
-
time_stamp: Date.now(),
|
|
424
|
-
});
|
|
425
|
-
resolve(confirmed);
|
|
426
|
-
});
|
|
396
|
+
const userInput = await waitForUserInput(taskDir);
|
|
397
|
+
const confirmed = userInput[0] === "confirmed";
|
|
398
|
+
writeTaskStatus(taskDir, {
|
|
399
|
+
running_state: confirmed ? "started" : "aborted",
|
|
400
|
+
time_stamp: Date.now(),
|
|
427
401
|
});
|
|
402
|
+
return confirmed;
|
|
428
403
|
}
|
|
429
404
|
/**
|
|
430
405
|
* Extract report file names from agent output.
|
package/dist/config.js
CHANGED
package/dist/events.js
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
1
|
import { StringCodec } from "nats";
|
|
4
|
-
import {
|
|
2
|
+
import { getLanPort } from "./lan-lock.js";
|
|
5
3
|
const sc = StringCodec();
|
|
6
|
-
const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
7
|
-
/**
|
|
8
|
-
* Read the LAN lockfile to determine if `palmier lan` is running.
|
|
9
|
-
*/
|
|
10
|
-
function getLanPort() {
|
|
11
|
-
try {
|
|
12
|
-
const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
|
|
13
|
-
return JSON.parse(raw).port;
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
4
|
/**
|
|
20
5
|
* Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
|
|
21
6
|
*
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const LAN_LOCKFILE: string;
|
|
2
|
+
/**
|
|
3
|
+
* Read the LAN lockfile to determine if `palmier lan` is running.
|
|
4
|
+
* Returns the port number, or null if not running.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getLanPort(): number | null;
|
|
7
|
+
//# sourceMappingURL=lan-lock.d.ts.map
|
package/dist/lan-lock.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.js";
|
|
4
|
+
export const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
5
|
+
/**
|
|
6
|
+
* Read the LAN lockfile to determine if `palmier lan` is running.
|
|
7
|
+
* Returns the port number, or null if not running.
|
|
8
|
+
*/
|
|
9
|
+
export function getLanPort() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
|
|
12
|
+
return JSON.parse(raw).port;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=lan-lock.js.map
|
package/dist/platform/index.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { PlatformService } from "./platform.js";
|
|
2
|
+
/**
|
|
3
|
+
* On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
|
|
4
|
+
* On Unix, undefined lets Node use the default shell.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SHELL: string | undefined;
|
|
2
7
|
export declare function getPlatform(): PlatformService;
|
|
3
8
|
export type { PlatformService } from "./platform.js";
|
|
4
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/platform/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { LinuxPlatform } from "./linux.js";
|
|
2
2
|
import { WindowsPlatform } from "./windows.js";
|
|
3
|
+
/**
|
|
4
|
+
* On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
|
|
5
|
+
* On Unix, undefined lets Node use the default shell.
|
|
6
|
+
*/
|
|
7
|
+
export const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
3
8
|
let _instance;
|
|
4
9
|
export function getPlatform() {
|
|
5
10
|
if (!_instance) {
|
package/dist/platform/windows.js
CHANGED
|
@@ -63,8 +63,9 @@ function schtasksTaskName(taskId) {
|
|
|
63
63
|
export class WindowsPlatform {
|
|
64
64
|
installDaemon(config) {
|
|
65
65
|
const script = process.argv[1] || "palmier";
|
|
66
|
-
// Write a VBS launcher that starts the daemon with no visible console window
|
|
67
|
-
|
|
66
|
+
// Write a VBS launcher that starts the daemon with no visible console window.
|
|
67
|
+
// VBS doesn't use backslash escaping — only quotes need doubling ("").
|
|
68
|
+
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
68
69
|
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
69
70
|
const regValue = `"${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe" "${DAEMON_VBS_FILE}"`;
|
|
70
71
|
try {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export interface HostConfig {
|
|
2
2
|
hostId: string;
|
|
3
3
|
projectRoot: string;
|
|
4
|
-
nats?: boolean;
|
|
5
4
|
natsUrl?: string;
|
|
6
5
|
natsWsUrl?: string;
|
|
7
6
|
natsToken?: string;
|
|
@@ -29,13 +28,33 @@ export interface ParsedTask {
|
|
|
29
28
|
frontmatter: TaskFrontmatter;
|
|
30
29
|
body: string;
|
|
31
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
|
|
33
|
+
*
|
|
34
|
+
* - `started`: task is actively running
|
|
35
|
+
* - `finished`: agent completed successfully
|
|
36
|
+
* - `aborted`: user declined confirmation, permission, or input
|
|
37
|
+
* - `failed`: agent exited with an error
|
|
38
|
+
*/
|
|
32
39
|
export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
|
|
40
|
+
/**
|
|
41
|
+
* Persisted to `status.json` in the task directory. Updated by the run process
|
|
42
|
+
* and read by the RPC handler + PWA to track live task state.
|
|
43
|
+
*
|
|
44
|
+
* Interactive request flow: the run process sets a `pending_*` field and waits
|
|
45
|
+
* for `user_input` to be populated by an RPC call (task.user_input). Only one
|
|
46
|
+
* `pending_*` field is set at a time.
|
|
47
|
+
*/
|
|
33
48
|
export interface TaskStatus {
|
|
34
49
|
running_state: TaskRunningState;
|
|
35
50
|
time_stamp: number;
|
|
51
|
+
/** Set when the task has `requires_confirmation` and is awaiting user approval. */
|
|
36
52
|
pending_confirmation?: boolean;
|
|
53
|
+
/** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
|
|
37
54
|
pending_permission?: RequiredPermission[];
|
|
55
|
+
/** Set when the agent requests user input. Contains descriptions of each requested value. */
|
|
38
56
|
pending_input?: string[];
|
|
57
|
+
/** Written by the RPC handler to deliver the user's response to the waiting run process. */
|
|
39
58
|
user_input?: string[];
|
|
40
59
|
}
|
|
41
60
|
export interface HistoryEntry {
|
package/dist/update-checker.d.ts
CHANGED
package/dist/update-checker.js
CHANGED
|
@@ -4,8 +4,11 @@ import { fileURLToPath } from "url";
|
|
|
4
4
|
import { spawnCommand } from "./spawn-command.js";
|
|
5
5
|
import { getPlatform } from "./platform/index.js";
|
|
6
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const packageRoot = path.join(__dirname, "..");
|
|
8
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
|
|
9
|
+
/** True when running from a source checkout (has .git) rather than a global npm install. */
|
|
10
|
+
export const isDevBuild = fs.existsSync(path.join(packageRoot, ".git"));
|
|
11
|
+
export const currentVersion = isDevBuild ? `${pkg.version}-dev` : pkg.version;
|
|
9
12
|
let latestVersion = null;
|
|
10
13
|
let lastCheckTime = 0;
|
|
11
14
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -13,6 +16,8 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
13
16
|
* Check the npm registry for the latest version of palmier.
|
|
14
17
|
*/
|
|
15
18
|
export async function checkForUpdate() {
|
|
19
|
+
if (isDevBuild)
|
|
20
|
+
return;
|
|
16
21
|
const now = Date.now();
|
|
17
22
|
if (now - lastCheckTime < CHECK_INTERVAL_MS)
|
|
18
23
|
return;
|
package/package.json
CHANGED
package/src/agents/claude.ts
CHANGED
|
@@ -2,10 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
4
|
import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
|
|
5
|
-
|
|
6
|
-
// execSync's shell option takes a string (shell path), not boolean.
|
|
7
|
-
// On Windows we need a shell so .cmd shims resolve correctly.
|
|
8
|
-
const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
5
|
+
import { SHELL } from "../platform/index.js";
|
|
9
6
|
|
|
10
7
|
export class ClaudeAgent implements AgentTool {
|
|
11
8
|
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
package/src/agents/codex.ts
CHANGED
|
@@ -2,13 +2,10 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
4
|
import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
|
|
5
|
-
|
|
6
|
-
// On Windows we need a shell so .cmd shims resolve correctly.
|
|
7
|
-
const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
5
|
+
import { SHELL } from "../platform/index.js";
|
|
8
6
|
|
|
9
7
|
export class CodexAgent implements AgentTool {
|
|
10
8
|
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
|
11
|
-
// TODO: fill in
|
|
12
9
|
return {
|
|
13
10
|
command: "codex",
|
|
14
11
|
args: ["exec", "--skip-git-repo-check", prompt],
|
|
@@ -17,8 +14,7 @@ export class CodexAgent implements AgentTool {
|
|
|
17
14
|
|
|
18
15
|
getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
19
16
|
const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
|
|
20
|
-
//
|
|
21
|
-
// is fixed.
|
|
17
|
+
// Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
|
|
22
18
|
const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
|
|
23
19
|
|
|
24
20
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
package/src/agents/gemini.ts
CHANGED
|
@@ -2,13 +2,10 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
4
|
import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
|
|
5
|
-
|
|
6
|
-
// On Windows we need a shell so .cmd shims resolve correctly.
|
|
7
|
-
const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
5
|
+
import { SHELL } from "../platform/index.js";
|
|
8
6
|
|
|
9
7
|
export class GeminiAgent implements AgentTool {
|
|
10
8
|
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
|
11
|
-
// TODO: fill in
|
|
12
9
|
return {
|
|
13
10
|
command: "gemini",
|
|
14
11
|
args: ["--approval-mode", "auto_edit", "--prompt", prompt],
|
package/src/commands/init.ts
CHANGED
|
@@ -67,7 +67,6 @@ export async function initCommand(): Promise<void> {
|
|
|
67
67
|
const config: HostConfig = {
|
|
68
68
|
hostId: registerResponse.hostId,
|
|
69
69
|
projectRoot: process.cwd(),
|
|
70
|
-
nats: true,
|
|
71
70
|
natsUrl: registerResponse.natsUrl,
|
|
72
71
|
natsWsUrl: registerResponse.natsWsUrl,
|
|
73
72
|
natsToken: registerResponse.natsToken,
|
package/src/commands/lan.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
2
|
import { loadConfig, CONFIG_DIR } from "../config.js";
|
|
4
3
|
import { createRpcHandler } from "../rpc-handler.js";
|
|
5
4
|
import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { generatePairingCode } from "./pair.js";
|
|
6
|
+
import { LAN_LOCKFILE } from "../lan-lock.js";
|
|
8
7
|
|
|
9
8
|
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
10
9
|
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
|
11
10
|
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
12
11
|
|
|
13
|
-
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
14
|
-
const CODE_LENGTH = 6;
|
|
15
|
-
|
|
16
|
-
function generateCode(): string {
|
|
17
|
-
const bytes = new Uint8Array(CODE_LENGTH);
|
|
18
|
-
crypto.getRandomValues(bytes);
|
|
19
|
-
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
12
|
function writeLockfile(port: number): void {
|
|
23
13
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
24
14
|
fs.writeFileSync(LAN_LOCKFILE, JSON.stringify({ port, pid: process.pid }), "utf-8");
|
|
@@ -36,7 +26,7 @@ export async function lanCommand(opts: { port: number }): Promise<void> {
|
|
|
36
26
|
const config = loadConfig();
|
|
37
27
|
const port = opts.port;
|
|
38
28
|
const ip = detectLanIp();
|
|
39
|
-
const code =
|
|
29
|
+
const code = generatePairingCode();
|
|
40
30
|
|
|
41
31
|
const handleRpc = createRpcHandler(config);
|
|
42
32
|
|
package/src/commands/pair.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
1
|
import * as http from "node:http";
|
|
4
2
|
import { StringCodec } from "nats";
|
|
5
|
-
import { loadConfig
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
6
4
|
import { connectNats } from "../nats-client.js";
|
|
7
5
|
import { addSession } from "../session-store.js";
|
|
6
|
+
import { getLanPort } from "../lan-lock.js";
|
|
8
7
|
import type { HostConfig } from "../types.js";
|
|
9
8
|
|
|
10
9
|
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
11
10
|
const CODE_LENGTH = 6;
|
|
12
|
-
const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
13
|
-
const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
13
|
+
|
|
14
|
+
export function generatePairingCode(): string {
|
|
16
15
|
const bytes = new Uint8Array(CODE_LENGTH);
|
|
17
16
|
crypto.getRandomValues(bytes);
|
|
18
17
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
@@ -30,7 +29,7 @@ function buildPairResponse(config: HostConfig, label?: string) {
|
|
|
30
29
|
* POST to the running LAN server and long-poll until paired or expired.
|
|
31
30
|
*/
|
|
32
31
|
function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
33
|
-
const body = JSON.stringify({ code, expiryMs:
|
|
32
|
+
const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
|
|
34
33
|
|
|
35
34
|
return new Promise((resolve) => {
|
|
36
35
|
const req = http.request(
|
|
@@ -40,7 +39,7 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
40
39
|
path: "/internal/pair-register",
|
|
41
40
|
method: "POST",
|
|
42
41
|
headers: { "Content-Type": "application/json" },
|
|
43
|
-
timeout:
|
|
42
|
+
timeout: PAIRING_EXPIRY_MS + 5000,
|
|
44
43
|
},
|
|
45
44
|
(res) => {
|
|
46
45
|
const chunks: Buffer[] = [];
|
|
@@ -62,23 +61,13 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
62
61
|
});
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/**
|
|
66
|
-
* Read the LAN lockfile to check if `palmier lan` is running.
|
|
67
|
-
*/
|
|
68
|
-
function getLanPort(): number | null {
|
|
69
|
-
try {
|
|
70
|
-
const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
|
|
71
|
-
return (JSON.parse(raw) as { port: number }).port;
|
|
72
|
-
} catch { return null; }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
64
|
/**
|
|
76
65
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
77
66
|
* Listens on NATS always, and also on the LAN server if `palmier lan` is running.
|
|
78
67
|
*/
|
|
79
68
|
export async function pairCommand(): Promise<void> {
|
|
80
69
|
const config = loadConfig();
|
|
81
|
-
const code =
|
|
70
|
+
const code = generatePairingCode();
|
|
82
71
|
|
|
83
72
|
let paired = false;
|
|
84
73
|
|
|
@@ -140,7 +129,7 @@ export async function pairCommand(): Promise<void> {
|
|
|
140
129
|
const start = Date.now();
|
|
141
130
|
await new Promise<void>((resolve) => {
|
|
142
131
|
const interval = setInterval(() => {
|
|
143
|
-
if (paired || Date.now() - start >=
|
|
132
|
+
if (paired || Date.now() - start >= PAIRING_EXPIRY_MS) {
|
|
144
133
|
clearInterval(interval);
|
|
145
134
|
resolve();
|
|
146
135
|
}
|
package/src/commands/run.ts
CHANGED
|
@@ -264,18 +264,15 @@ async function runCommandTriggeredMode(
|
|
|
264
264
|
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
-
// Stats
|
|
268
267
|
let linesProcessed = 0;
|
|
269
268
|
let invocationsSucceeded = 0;
|
|
270
269
|
let invocationsFailed = 0;
|
|
271
270
|
|
|
272
|
-
// Bounded queue for incoming lines
|
|
273
271
|
const lineQueue: string[] = [];
|
|
274
272
|
let processing = false;
|
|
275
273
|
let commandExited = false;
|
|
276
274
|
let resolveWhenDone: (() => void) | undefined;
|
|
277
275
|
|
|
278
|
-
// Rolling log of per-line agent outputs
|
|
279
276
|
const logPath = path.join(ctx.taskDir, "command-output.log");
|
|
280
277
|
function appendLog(line: string, agentOutput: string, outcome: string) {
|
|
281
278
|
const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
|
|
@@ -333,10 +330,9 @@ async function runCommandTriggeredMode(
|
|
|
333
330
|
}
|
|
334
331
|
}
|
|
335
332
|
|
|
336
|
-
// Read stdout line by line
|
|
337
333
|
const rl = readline.createInterface({ input: child.stdout! });
|
|
338
334
|
rl.on("line", (line: string) => {
|
|
339
|
-
if (!line.trim()) return;
|
|
335
|
+
if (!line.trim()) return;
|
|
340
336
|
if (lineQueue.length >= MAX_QUEUE_SIZE) {
|
|
341
337
|
console.warn(`[command-triggered] Queue full, dropping oldest line.`);
|
|
342
338
|
lineQueue.shift();
|
|
@@ -348,7 +344,6 @@ async function runCommandTriggeredMode(
|
|
|
348
344
|
});
|
|
349
345
|
});
|
|
350
346
|
|
|
351
|
-
// Log stderr
|
|
352
347
|
child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
|
|
353
348
|
|
|
354
349
|
// Wait for command to exit
|
|
@@ -383,9 +378,7 @@ async function runCommandTriggeredMode(
|
|
|
383
378
|
`Agent invocations failed: ${invocationsFailed}`,
|
|
384
379
|
].join("\n");
|
|
385
380
|
|
|
386
|
-
|
|
387
|
-
const outcome: TaskRunningState = "finished";
|
|
388
|
-
return { outcome, endTime, output: summary };
|
|
381
|
+
return { outcome: "finished", endTime, output: summary };
|
|
389
382
|
}
|
|
390
383
|
|
|
391
384
|
async function publishTaskEvent(
|
|
@@ -422,6 +415,22 @@ async function publishConfirmResolved(
|
|
|
422
415
|
});
|
|
423
416
|
}
|
|
424
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Watch status.json until user_input is populated by an RPC call, then resolve.
|
|
420
|
+
* All interactive request flows (confirmation, permission, user input) share this.
|
|
421
|
+
*/
|
|
422
|
+
function waitForUserInput(taskDir: string): Promise<string[]> {
|
|
423
|
+
const statusPath = path.join(taskDir, "status.json");
|
|
424
|
+
return new Promise<string[]>((resolve) => {
|
|
425
|
+
const watcher = fs.watch(statusPath, () => {
|
|
426
|
+
const status = readTaskStatus(taskDir);
|
|
427
|
+
if (!status || !status.user_input?.length) return;
|
|
428
|
+
watcher.close();
|
|
429
|
+
resolve(status.user_input);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
425
434
|
async function requestPermission(
|
|
426
435
|
nc: NatsConnection | undefined,
|
|
427
436
|
config: HostConfig,
|
|
@@ -429,32 +438,23 @@ async function requestPermission(
|
|
|
429
438
|
taskDir: string,
|
|
430
439
|
requiredPermissions: RequiredPermission[],
|
|
431
440
|
): Promise<"granted" | "granted_all" | "aborted"> {
|
|
432
|
-
const taskId = task.frontmatter.id;
|
|
433
|
-
const statusPath = path.join(taskDir, "status.json");
|
|
434
|
-
|
|
435
441
|
const currentStatus = readTaskStatus(taskDir)!;
|
|
436
442
|
writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
|
|
437
443
|
|
|
438
|
-
await publishHostEvent(nc, config.hostId,
|
|
444
|
+
await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
|
|
439
445
|
event_type: "permission-request",
|
|
440
446
|
host_id: config.hostId,
|
|
441
447
|
required_permissions: requiredPermissions,
|
|
442
448
|
name: task.frontmatter.name,
|
|
443
449
|
});
|
|
444
450
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const response = status.user_input[0] as "granted" | "granted_all" | "aborted";
|
|
451
|
-
writeTaskStatus(taskDir, {
|
|
452
|
-
running_state: response === "aborted" ? "aborted" : "started",
|
|
453
|
-
time_stamp: Date.now(),
|
|
454
|
-
});
|
|
455
|
-
resolve(response);
|
|
456
|
-
});
|
|
451
|
+
const userInput = await waitForUserInput(taskDir);
|
|
452
|
+
const response = userInput[0] as "granted" | "granted_all" | "aborted";
|
|
453
|
+
writeTaskStatus(taskDir, {
|
|
454
|
+
running_state: response === "aborted" ? "aborted" : "started",
|
|
455
|
+
time_stamp: Date.now(),
|
|
457
456
|
});
|
|
457
|
+
return response;
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
async function publishPermissionResolved(
|
|
@@ -477,34 +477,23 @@ async function requestUserInput(
|
|
|
477
477
|
taskDir: string,
|
|
478
478
|
inputDescriptions: string[],
|
|
479
479
|
): Promise<string[] | "aborted"> {
|
|
480
|
-
const taskId = task.frontmatter.id;
|
|
481
|
-
const statusPath = path.join(taskDir, "status.json");
|
|
482
|
-
|
|
483
480
|
const currentStatus = readTaskStatus(taskDir)!;
|
|
484
481
|
writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
|
|
485
482
|
|
|
486
|
-
await publishHostEvent(nc, config.hostId,
|
|
483
|
+
await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
|
|
487
484
|
event_type: "input-request",
|
|
488
485
|
host_id: config.hostId,
|
|
489
486
|
input_descriptions: inputDescriptions,
|
|
490
487
|
name: task.frontmatter.name,
|
|
491
488
|
});
|
|
492
489
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
|
|
501
|
-
resolve("aborted");
|
|
502
|
-
} else {
|
|
503
|
-
writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
|
|
504
|
-
resolve(response);
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
});
|
|
490
|
+
const userInput = await waitForUserInput(taskDir);
|
|
491
|
+
if (userInput.length === 1 && userInput[0] === "aborted") {
|
|
492
|
+
writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
|
|
493
|
+
return "aborted";
|
|
494
|
+
}
|
|
495
|
+
writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
|
|
496
|
+
return userInput;
|
|
508
497
|
}
|
|
509
498
|
|
|
510
499
|
async function publishInputResolved(
|
|
@@ -526,34 +515,21 @@ async function requestConfirmation(
|
|
|
526
515
|
task: ParsedTask,
|
|
527
516
|
taskDir: string,
|
|
528
517
|
): Promise<boolean> {
|
|
529
|
-
const taskId = task.frontmatter.id;
|
|
530
|
-
const statusPath = path.join(taskDir, "status.json");
|
|
531
|
-
|
|
532
|
-
// Flag that we're awaiting user confirmation
|
|
533
518
|
const currentStatus = readTaskStatus(taskDir)!;
|
|
534
519
|
writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
|
|
535
520
|
|
|
536
|
-
|
|
537
|
-
await publishHostEvent(nc, config.hostId, taskId, {
|
|
521
|
+
await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
|
|
538
522
|
event_type: "confirm-request",
|
|
539
523
|
host_id: config.hostId,
|
|
540
524
|
});
|
|
541
525
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
watcher.close();
|
|
548
|
-
const confirmed = status.user_input[0] === "confirmed";
|
|
549
|
-
// Clear pending_confirmation/user_input and update running_state
|
|
550
|
-
writeTaskStatus(taskDir, {
|
|
551
|
-
running_state: confirmed ? "started" : "aborted",
|
|
552
|
-
time_stamp: Date.now(),
|
|
553
|
-
});
|
|
554
|
-
resolve(confirmed);
|
|
555
|
-
});
|
|
526
|
+
const userInput = await waitForUserInput(taskDir);
|
|
527
|
+
const confirmed = userInput[0] === "confirmed";
|
|
528
|
+
writeTaskStatus(taskDir, {
|
|
529
|
+
running_state: confirmed ? "started" : "aborted",
|
|
530
|
+
time_stamp: Date.now(),
|
|
556
531
|
});
|
|
532
|
+
return confirmed;
|
|
557
533
|
}
|
|
558
534
|
|
|
559
535
|
/**
|
package/src/config.ts
CHANGED
package/src/events.ts
CHANGED
|
@@ -1,20 +1,7 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
1
|
import { StringCodec, type NatsConnection } from "nats";
|
|
4
|
-
import {
|
|
2
|
+
import { getLanPort } from "./lan-lock.js";
|
|
5
3
|
|
|
6
4
|
const sc = StringCodec();
|
|
7
|
-
const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Read the LAN lockfile to determine if `palmier lan` is running.
|
|
11
|
-
*/
|
|
12
|
-
function getLanPort(): number | null {
|
|
13
|
-
try {
|
|
14
|
-
const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
|
|
15
|
-
return (JSON.parse(raw) as { port: number }).port;
|
|
16
|
-
} catch { return null; }
|
|
17
|
-
}
|
|
18
5
|
|
|
19
6
|
/**
|
|
20
7
|
* Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
|
package/src/lan-lock.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read the LAN lockfile to determine if `palmier lan` is running.
|
|
9
|
+
* Returns the port number, or null if not running.
|
|
10
|
+
*/
|
|
11
|
+
export function getLanPort(): number | null {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
|
|
14
|
+
return (JSON.parse(raw) as { port: number }).port;
|
|
15
|
+
} catch { return null; }
|
|
16
|
+
}
|
package/src/platform/index.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { PlatformService } from "./platform.js";
|
|
|
2
2
|
import { LinuxPlatform } from "./linux.js";
|
|
3
3
|
import { WindowsPlatform } from "./windows.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
|
|
7
|
+
* On Unix, undefined lets Node use the default shell.
|
|
8
|
+
*/
|
|
9
|
+
export const SHELL: string | undefined = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
10
|
+
|
|
5
11
|
let _instance: PlatformService | undefined;
|
|
6
12
|
|
|
7
13
|
export function getPlatform(): PlatformService {
|
package/src/platform/windows.ts
CHANGED
|
@@ -78,8 +78,9 @@ export class WindowsPlatform implements PlatformService {
|
|
|
78
78
|
installDaemon(config: HostConfig): void {
|
|
79
79
|
const script = process.argv[1] || "palmier";
|
|
80
80
|
|
|
81
|
-
// Write a VBS launcher that starts the daemon with no visible console window
|
|
82
|
-
|
|
81
|
+
// Write a VBS launcher that starts the daemon with no visible console window.
|
|
82
|
+
// VBS doesn't use backslash escaping — only quotes need doubling ("").
|
|
83
|
+
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
83
84
|
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
84
85
|
|
|
85
86
|
const regValue = `"${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe" "${DAEMON_VBS_FILE}"`;
|
package/src/types.ts
CHANGED
|
@@ -2,8 +2,6 @@ export interface HostConfig {
|
|
|
2
2
|
hostId: string;
|
|
3
3
|
projectRoot: string;
|
|
4
4
|
|
|
5
|
-
// NATS (always enabled)
|
|
6
|
-
nats?: boolean;
|
|
7
5
|
natsUrl?: string;
|
|
8
6
|
natsWsUrl?: string;
|
|
9
7
|
natsToken?: string;
|
|
@@ -34,14 +32,34 @@ export interface ParsedTask {
|
|
|
34
32
|
body: string;
|
|
35
33
|
}
|
|
36
34
|
|
|
35
|
+
/**
|
|
36
|
+
* State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
|
|
37
|
+
*
|
|
38
|
+
* - `started`: task is actively running
|
|
39
|
+
* - `finished`: agent completed successfully
|
|
40
|
+
* - `aborted`: user declined confirmation, permission, or input
|
|
41
|
+
* - `failed`: agent exited with an error
|
|
42
|
+
*/
|
|
37
43
|
export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Persisted to `status.json` in the task directory. Updated by the run process
|
|
47
|
+
* and read by the RPC handler + PWA to track live task state.
|
|
48
|
+
*
|
|
49
|
+
* Interactive request flow: the run process sets a `pending_*` field and waits
|
|
50
|
+
* for `user_input` to be populated by an RPC call (task.user_input). Only one
|
|
51
|
+
* `pending_*` field is set at a time.
|
|
52
|
+
*/
|
|
39
53
|
export interface TaskStatus {
|
|
40
54
|
running_state: TaskRunningState;
|
|
41
55
|
time_stamp: number;
|
|
56
|
+
/** Set when the task has `requires_confirmation` and is awaiting user approval. */
|
|
42
57
|
pending_confirmation?: boolean;
|
|
58
|
+
/** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
|
|
43
59
|
pending_permission?: RequiredPermission[];
|
|
60
|
+
/** Set when the agent requests user input. Contains descriptions of each requested value. */
|
|
44
61
|
pending_input?: string[];
|
|
62
|
+
/** Written by the RPC handler to deliver the user's response to the waiting run process. */
|
|
45
63
|
user_input?: string[];
|
|
46
64
|
}
|
|
47
65
|
|
package/src/update-checker.ts
CHANGED
|
@@ -5,8 +5,12 @@ import { spawnCommand } from "./spawn-command.js";
|
|
|
5
5
|
import { getPlatform } from "./platform/index.js";
|
|
6
6
|
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const
|
|
9
|
-
|
|
8
|
+
const packageRoot = path.join(__dirname, "..");
|
|
9
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8")) as { version: string };
|
|
10
|
+
|
|
11
|
+
/** True when running from a source checkout (has .git) rather than a global npm install. */
|
|
12
|
+
export const isDevBuild = fs.existsSync(path.join(packageRoot, ".git"));
|
|
13
|
+
export const currentVersion = isDevBuild ? `${pkg.version}-dev` : pkg.version;
|
|
10
14
|
|
|
11
15
|
let latestVersion: string | null = null;
|
|
12
16
|
let lastCheckTime = 0;
|
|
@@ -16,6 +20,7 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
16
20
|
* Check the npm registry for the latest version of palmier.
|
|
17
21
|
*/
|
|
18
22
|
export async function checkForUpdate(): Promise<void> {
|
|
23
|
+
if (isDevBuild) return;
|
|
19
24
|
const now = Date.now();
|
|
20
25
|
if (now - lastCheckTime < CHECK_INTERVAL_MS) return;
|
|
21
26
|
lastCheckTime = now;
|