palmier 0.3.0 → 0.3.2
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 +13 -1
- 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.js +7 -28
- 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/pairing.d.ts +3 -0
- package/dist/pairing.js +9 -0
- package/dist/platform/index.d.ts +5 -0
- package/dist/platform/index.js +5 -0
- package/dist/rpc-handler.js +3 -2
- package/dist/types.d.ts +20 -1
- package/dist/update-checker.d.ts +4 -3
- package/dist/update-checker.js +6 -24
- 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 +7 -28
- 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/pairing.ts +10 -0
- package/src/platform/index.ts +6 -0
- package/src/rpc-handler.ts +3 -2
- package/src/types.ts +20 -2
- package/src/update-checker.ts +6 -22
package/README.md
CHANGED
|
@@ -132,6 +132,15 @@ palmier restart
|
|
|
132
132
|
- **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
133
|
- **MCP server** (`palmier mcpserver`) exposes platform tools (e.g., `send-push-notification`) to AI agents like Claude Code over stdio.
|
|
134
134
|
|
|
135
|
+
## NATS Subjects
|
|
136
|
+
|
|
137
|
+
| Subject | Direction | Description |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `host.<hostId>.rpc.<method>` | Client → Host | RPC request/reply (e.g., `task.list`, `task.create`) |
|
|
140
|
+
| `host-event.<hostId>.<taskId>` | Host → Client | Real-time task events (`running-state`, `confirm-request`, `permission-request`, `input-request`) |
|
|
141
|
+
| `host.<hostId>.push.send` | Host → Server | Request server to deliver a push notification |
|
|
142
|
+
| `pair.<code>` | Client → Host | OTP pairing request/reply |
|
|
143
|
+
|
|
135
144
|
## Project Structure
|
|
136
145
|
|
|
137
146
|
```
|
|
@@ -144,16 +153,19 @@ src/
|
|
|
144
153
|
spawn-command.ts # Shared helper for spawning CLI tools
|
|
145
154
|
task.ts # Task file management
|
|
146
155
|
types.ts # Shared type definitions
|
|
156
|
+
pairing.ts # OTP code generation and expiry constant
|
|
157
|
+
lan-lock.ts # LAN lockfile path and port reader
|
|
158
|
+
events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
|
|
147
159
|
agents/
|
|
148
160
|
agent.ts # AgentTool interface, registry, and agent detection
|
|
149
161
|
claude.ts # Claude Code agent implementation
|
|
150
162
|
gemini.ts # Gemini CLI agent implementation
|
|
151
163
|
codex.ts # Codex CLI agent implementation
|
|
152
164
|
openclaw.ts # OpenClaw agent implementation
|
|
153
|
-
events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
|
|
154
165
|
commands/
|
|
155
166
|
init.ts # Interactive setup wizard (auto-pair)
|
|
156
167
|
pair.ts # OTP code generation and pairing handler
|
|
168
|
+
lan.ts # On-demand LAN server
|
|
157
169
|
sessions.ts # Session token management CLI (list, revoke, revoke-all)
|
|
158
170
|
info.ts # Print host connection info
|
|
159
171
|
agents.ts # Re-detect installed agent CLIs
|
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 "../pairing.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.js
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
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";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
11
|
-
const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
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
|
-
}
|
|
6
|
+
import { generatePairingCode, PAIRING_EXPIRY_MS } from "../pairing.js";
|
|
7
|
+
import { getLanPort } from "../lan-lock.js";
|
|
17
8
|
function buildPairResponse(config, label) {
|
|
18
9
|
const session = addSession(label);
|
|
19
10
|
return {
|
|
@@ -25,7 +16,7 @@ function buildPairResponse(config, label) {
|
|
|
25
16
|
* POST to the running LAN server and long-poll until paired or expired.
|
|
26
17
|
*/
|
|
27
18
|
function lanPairRegister(port, code) {
|
|
28
|
-
const body = JSON.stringify({ code, expiryMs:
|
|
19
|
+
const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
|
|
29
20
|
return new Promise((resolve) => {
|
|
30
21
|
const req = http.request({
|
|
31
22
|
hostname: "127.0.0.1",
|
|
@@ -33,7 +24,7 @@ function lanPairRegister(port, code) {
|
|
|
33
24
|
path: "/internal/pair-register",
|
|
34
25
|
method: "POST",
|
|
35
26
|
headers: { "Content-Type": "application/json" },
|
|
36
|
-
timeout:
|
|
27
|
+
timeout: PAIRING_EXPIRY_MS + 5000,
|
|
37
28
|
}, (res) => {
|
|
38
29
|
const chunks = [];
|
|
39
30
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -52,25 +43,13 @@ function lanPairRegister(port, code) {
|
|
|
52
43
|
req.end(body);
|
|
53
44
|
});
|
|
54
45
|
}
|
|
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
46
|
/**
|
|
68
47
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
69
48
|
* Listens on NATS always, and also on the LAN server if `palmier lan` is running.
|
|
70
49
|
*/
|
|
71
50
|
export async function pairCommand() {
|
|
72
51
|
const config = loadConfig();
|
|
73
|
-
const code =
|
|
52
|
+
const code = generatePairingCode();
|
|
74
53
|
let paired = false;
|
|
75
54
|
function onPaired() {
|
|
76
55
|
paired = true;
|
|
@@ -125,7 +104,7 @@ export async function pairCommand() {
|
|
|
125
104
|
const start = Date.now();
|
|
126
105
|
await new Promise((resolve) => {
|
|
127
106
|
const interval = setInterval(() => {
|
|
128
|
-
if (paired || Date.now() - start >=
|
|
107
|
+
if (paired || Date.now() - start >= PAIRING_EXPIRY_MS) {
|
|
129
108
|
clearInterval(interval);
|
|
130
109
|
resolve();
|
|
131
110
|
}
|
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/pairing.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
2
|
+
const CODE_LENGTH = 6;
|
|
3
|
+
export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
4
|
+
export function generatePairingCode() {
|
|
5
|
+
const bytes = new Uint8Array(CODE_LENGTH);
|
|
6
|
+
crypto.getRandomValues(bytes);
|
|
7
|
+
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=pairing.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/rpc-handler.js
CHANGED
|
@@ -9,7 +9,7 @@ import { spawnCommand } from "./spawn-command.js";
|
|
|
9
9
|
import { getAgent } from "./agents/agent.js";
|
|
10
10
|
import { validateSession } from "./session-store.js";
|
|
11
11
|
import { publishHostEvent } from "./events.js";
|
|
12
|
-
import {
|
|
12
|
+
import { currentVersion, getLatestVersion, performUpdate } from "./update-checker.js";
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const PLAN_GENERATION_PROMPT = fs.readFileSync(path.join(__dirname, "commands", "plan-generation.md"), "utf-8");
|
|
15
15
|
/**
|
|
@@ -107,7 +107,8 @@ export function createRpcHandler(config, nc) {
|
|
|
107
107
|
return {
|
|
108
108
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
109
109
|
agents: config.agents ?? [],
|
|
110
|
-
|
|
110
|
+
version: currentVersion,
|
|
111
|
+
latest_version: getLatestVersion(),
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
114
|
case "task.create": {
|
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
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
export declare const currentVersion: string;
|
|
1
2
|
/**
|
|
2
|
-
* Check the npm registry for
|
|
3
|
+
* Check the npm registry for the latest version of palmier.
|
|
3
4
|
*/
|
|
4
5
|
export declare function checkForUpdate(): Promise<void>;
|
|
5
6
|
/**
|
|
6
|
-
* Get the
|
|
7
|
+
* Get the latest version from npm, or null if not yet checked.
|
|
7
8
|
*/
|
|
8
|
-
export declare function
|
|
9
|
+
export declare function getLatestVersion(): string | null;
|
|
9
10
|
/**
|
|
10
11
|
* Run the update and restart the daemon.
|
|
11
12
|
* Returns an error message if the update fails.
|
package/dist/update-checker.js
CHANGED
|
@@ -5,27 +5,12 @@ 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
7
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
|
|
8
|
-
const currentVersion = pkg.version;
|
|
8
|
+
export const currentVersion = pkg.version;
|
|
9
9
|
let latestVersion = null;
|
|
10
10
|
let lastCheckTime = 0;
|
|
11
11
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
* Returns true if b is newer than a.
|
|
15
|
-
*/
|
|
16
|
-
function isNewer(a, b) {
|
|
17
|
-
const pa = a.split(".").map(Number);
|
|
18
|
-
const pb = b.split(".").map(Number);
|
|
19
|
-
for (let i = 0; i < 3; i++) {
|
|
20
|
-
if ((pb[i] ?? 0) > (pa[i] ?? 0))
|
|
21
|
-
return true;
|
|
22
|
-
if ((pb[i] ?? 0) < (pa[i] ?? 0))
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Check the npm registry for a newer version of palmier.
|
|
13
|
+
* Check the npm registry for the latest version of palmier.
|
|
29
14
|
*/
|
|
30
15
|
export async function checkForUpdate() {
|
|
31
16
|
const now = Date.now();
|
|
@@ -39,12 +24,9 @@ export async function checkForUpdate() {
|
|
|
39
24
|
if (!res.ok)
|
|
40
25
|
return;
|
|
41
26
|
const data = (await res.json());
|
|
42
|
-
if (data.version
|
|
27
|
+
if (data.version) {
|
|
43
28
|
latestVersion = data.version;
|
|
44
|
-
console.log(`[update]
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
latestVersion = null;
|
|
29
|
+
console.log(`[update] Latest version: ${data.version} (current: ${currentVersion})`);
|
|
48
30
|
}
|
|
49
31
|
}
|
|
50
32
|
catch {
|
|
@@ -52,9 +34,9 @@ export async function checkForUpdate() {
|
|
|
52
34
|
}
|
|
53
35
|
}
|
|
54
36
|
/**
|
|
55
|
-
* Get the
|
|
37
|
+
* Get the latest version from npm, or null if not yet checked.
|
|
56
38
|
*/
|
|
57
|
-
export function
|
|
39
|
+
export function getLatestVersion() {
|
|
58
40
|
return latestVersion;
|
|
59
41
|
}
|
|
60
42
|
/**
|
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 "../pairing.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,23 +1,12 @@
|
|
|
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 { generatePairingCode, PAIRING_EXPIRY_MS } from "../pairing.js";
|
|
7
|
+
import { getLanPort } from "../lan-lock.js";
|
|
8
8
|
import type { HostConfig } from "../types.js";
|
|
9
9
|
|
|
10
|
-
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
11
|
-
const CODE_LENGTH = 6;
|
|
12
|
-
const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
13
|
-
const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
|
|
14
|
-
|
|
15
|
-
function generateCode(): string {
|
|
16
|
-
const bytes = new Uint8Array(CODE_LENGTH);
|
|
17
|
-
crypto.getRandomValues(bytes);
|
|
18
|
-
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
10
|
function buildPairResponse(config: HostConfig, label?: string) {
|
|
22
11
|
const session = addSession(label);
|
|
23
12
|
return {
|
|
@@ -30,7 +19,7 @@ function buildPairResponse(config: HostConfig, label?: string) {
|
|
|
30
19
|
* POST to the running LAN server and long-poll until paired or expired.
|
|
31
20
|
*/
|
|
32
21
|
function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
33
|
-
const body = JSON.stringify({ code, expiryMs:
|
|
22
|
+
const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
|
|
34
23
|
|
|
35
24
|
return new Promise((resolve) => {
|
|
36
25
|
const req = http.request(
|
|
@@ -40,7 +29,7 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
40
29
|
path: "/internal/pair-register",
|
|
41
30
|
method: "POST",
|
|
42
31
|
headers: { "Content-Type": "application/json" },
|
|
43
|
-
timeout:
|
|
32
|
+
timeout: PAIRING_EXPIRY_MS + 5000,
|
|
44
33
|
},
|
|
45
34
|
(res) => {
|
|
46
35
|
const chunks: Buffer[] = [];
|
|
@@ -62,23 +51,13 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
62
51
|
});
|
|
63
52
|
}
|
|
64
53
|
|
|
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
54
|
/**
|
|
76
55
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
77
56
|
* Listens on NATS always, and also on the LAN server if `palmier lan` is running.
|
|
78
57
|
*/
|
|
79
58
|
export async function pairCommand(): Promise<void> {
|
|
80
59
|
const config = loadConfig();
|
|
81
|
-
const code =
|
|
60
|
+
const code = generatePairingCode();
|
|
82
61
|
|
|
83
62
|
let paired = false;
|
|
84
63
|
|
|
@@ -140,7 +119,7 @@ export async function pairCommand(): Promise<void> {
|
|
|
140
119
|
const start = Date.now();
|
|
141
120
|
await new Promise<void>((resolve) => {
|
|
142
121
|
const interval = setInterval(() => {
|
|
143
|
-
if (paired || Date.now() - start >=
|
|
122
|
+
if (paired || Date.now() - start >= PAIRING_EXPIRY_MS) {
|
|
144
123
|
clearInterval(interval);
|
|
145
124
|
resolve();
|
|
146
125
|
}
|
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/pairing.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
2
|
+
const CODE_LENGTH = 6;
|
|
3
|
+
|
|
4
|
+
export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
5
|
+
|
|
6
|
+
export function generatePairingCode(): string {
|
|
7
|
+
const bytes = new Uint8Array(CODE_LENGTH);
|
|
8
|
+
crypto.getRandomValues(bytes);
|
|
9
|
+
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
10
|
+
}
|
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/rpc-handler.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { spawnCommand } from "./spawn-command.js";
|
|
|
10
10
|
import { getAgent } from "./agents/agent.js";
|
|
11
11
|
import { validateSession } from "./session-store.js";
|
|
12
12
|
import { publishHostEvent } from "./events.js";
|
|
13
|
-
import {
|
|
13
|
+
import { currentVersion, getLatestVersion, performUpdate } from "./update-checker.js";
|
|
14
14
|
import type { HostConfig, ParsedTask, RpcMessage } from "./types.js";
|
|
15
15
|
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -122,7 +122,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
122
122
|
return {
|
|
123
123
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
124
124
|
agents: config.agents ?? [],
|
|
125
|
-
|
|
125
|
+
version: currentVersion,
|
|
126
|
+
latest_version: getLatestVersion(),
|
|
126
127
|
};
|
|
127
128
|
}
|
|
128
129
|
|
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
|
@@ -6,28 +6,14 @@ import { getPlatform } from "./platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")) as { version: string };
|
|
9
|
-
const currentVersion = pkg.version;
|
|
9
|
+
export const currentVersion = pkg.version;
|
|
10
10
|
|
|
11
11
|
let latestVersion: string | null = null;
|
|
12
12
|
let lastCheckTime = 0;
|
|
13
13
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
* Returns true if b is newer than a.
|
|
18
|
-
*/
|
|
19
|
-
function isNewer(a: string, b: string): boolean {
|
|
20
|
-
const pa = a.split(".").map(Number);
|
|
21
|
-
const pb = b.split(".").map(Number);
|
|
22
|
-
for (let i = 0; i < 3; i++) {
|
|
23
|
-
if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
|
|
24
|
-
if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Check the npm registry for a newer version of palmier.
|
|
16
|
+
* Check the npm registry for the latest version of palmier.
|
|
31
17
|
*/
|
|
32
18
|
export async function checkForUpdate(): Promise<void> {
|
|
33
19
|
const now = Date.now();
|
|
@@ -40,11 +26,9 @@ export async function checkForUpdate(): Promise<void> {
|
|
|
40
26
|
});
|
|
41
27
|
if (!res.ok) return;
|
|
42
28
|
const data = (await res.json()) as { version?: string };
|
|
43
|
-
if (data.version
|
|
29
|
+
if (data.version) {
|
|
44
30
|
latestVersion = data.version;
|
|
45
|
-
console.log(`[update]
|
|
46
|
-
} else {
|
|
47
|
-
latestVersion = null;
|
|
31
|
+
console.log(`[update] Latest version: ${data.version} (current: ${currentVersion})`);
|
|
48
32
|
}
|
|
49
33
|
} catch {
|
|
50
34
|
// Network errors are expected (offline, etc.)
|
|
@@ -52,9 +36,9 @@ export async function checkForUpdate(): Promise<void> {
|
|
|
52
36
|
}
|
|
53
37
|
|
|
54
38
|
/**
|
|
55
|
-
* Get the
|
|
39
|
+
* Get the latest version from npm, or null if not yet checked.
|
|
56
40
|
*/
|
|
57
|
-
export function
|
|
41
|
+
export function getLatestVersion(): string | null {
|
|
58
42
|
return latestVersion;
|
|
59
43
|
}
|
|
60
44
|
|