palmier 1.1.20 → 1.1.22

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 CHANGED
@@ -182,7 +182,7 @@ The wizard:
182
182
  - Offers to install the **Playwright CLI** for browser automation (see [Browser Automation](#browser-automation)). Skipped on re-init if it's already managed.
183
183
  - Asks for the HTTP port
184
184
  - Detects the default network interface (used for auto-LAN)
185
- - Shows a summary (including any existing scheduled tasks to recover) and asks for confirmation
185
+ - Shows a summary (including the Palmier daemon version and any existing scheduled tasks to recover) and asks for confirmation
186
186
  - Registers with the Palmier server, saves configuration to `~/.config/palmier/host.json`
187
187
  - Installs a background daemon (systemd user service on Linux, LaunchAgent on macOS, Task Scheduler on Windows). On re-init, also restarts the daemon so the running process picks up the new agents/versions for `host.info`.
188
188
  - Auto-enters pair mode to connect your first device
@@ -1,4 +1,4 @@
1
- import { type NatsConnection } from "nats";
1
+ import { type NatsConnection } from "@nats-io/transport-node";
2
2
  import type { HostConfig } from "./types.js";
3
3
  export declare class ToolError extends Error {
4
4
  statusCode: number;
@@ -1,4 +1,3 @@
1
- import { StringCodec } from "nats";
2
1
  import { registerPending } from "./pending-requests.js";
3
2
  import { getLinkedDevice } from "./linked-device.js";
4
3
  import { getNotifications } from "./notification-store.js";
@@ -33,11 +32,10 @@ const notifyTool = {
33
32
  throw new ToolError("title and body are required", 400);
34
33
  if (!ctx.nc)
35
34
  throw new ToolError("NATS not connected — push notifications require server mode", 503);
36
- const sc = StringCodec();
37
35
  const payload = { hostId: ctx.config.hostId, title, body, session_id: ctx.taskId };
38
36
  const subject = `host.${ctx.config.hostId}.push.send`;
39
- const reply = await ctx.nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
40
- const result = JSON.parse(sc.decode(reply.data));
37
+ const reply = await ctx.nc.request(subject, JSON.stringify(payload), { timeout: 15_000 });
38
+ const result = JSON.parse(reply.string());
41
39
  if (result.ok)
42
40
  return { ok: true };
43
41
  throw new ToolError(result.error ?? "Push notification failed", 502);
@@ -309,9 +307,8 @@ const deviceGeolocationTool = {
309
307
  const device = getLinkedDevice();
310
308
  if (!device)
311
309
  throw new ToolError("No linked device configured", 400);
312
- const sc = StringCodec();
313
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken })), { timeout: 5_000 });
314
- const ack = JSON.parse(sc.decode(ackReply.data));
310
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken }), { timeout: 5_000 });
311
+ const ack = JSON.parse(ackReply.string());
315
312
  if (ack.error)
316
313
  throw new ToolError(ack.error, 502);
317
314
  const locationPromise = new Promise((resolve, reject) => {
@@ -323,7 +320,7 @@ const deviceGeolocationTool = {
323
320
  (async () => {
324
321
  for await (const msg of sub) {
325
322
  clearTimeout(timer);
326
- resolve(sc.decode(msg.data));
323
+ resolve(msg.string());
327
324
  }
328
325
  })();
329
326
  });
@@ -350,9 +347,8 @@ const readContactsTool = {
350
347
  const device = getLinkedDevice();
351
348
  if (!device)
352
349
  throw new ToolError("No linked device configured", 400);
353
- const sc = StringCodec();
354
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken, action: "read" })), { timeout: 5_000 });
355
- const ack = JSON.parse(sc.decode(ackReply.data));
350
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken, action: "read" }), { timeout: 5_000 });
351
+ const ack = JSON.parse(ackReply.string());
356
352
  if (ack.error)
357
353
  throw new ToolError(ack.error, 502);
358
354
  const responsePromise = new Promise((resolve, reject) => {
@@ -364,7 +360,7 @@ const readContactsTool = {
364
360
  (async () => {
365
361
  for await (const msg of sub) {
366
362
  clearTimeout(timer);
367
- resolve(sc.decode(msg.data));
363
+ resolve(msg.string());
368
364
  }
369
365
  })();
370
366
  });
@@ -399,12 +395,11 @@ const createContactTool = {
399
395
  const { name, phone, email } = args;
400
396
  if (!name)
401
397
  throw new ToolError("name is required", 400);
402
- const sc = StringCodec();
403
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({
398
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, JSON.stringify({
404
399
  hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
405
400
  action: "create", name, phone, email,
406
- })), { timeout: 5_000 });
407
- const ack = JSON.parse(sc.decode(ackReply.data));
401
+ }), { timeout: 5_000 });
402
+ const ack = JSON.parse(ackReply.string());
408
403
  if (ack.error)
409
404
  throw new ToolError(ack.error, 502);
410
405
  const responsePromise = new Promise((resolve, reject) => {
@@ -416,7 +411,7 @@ const createContactTool = {
416
411
  (async () => {
417
412
  for await (const msg of sub) {
418
413
  clearTimeout(timer);
419
- resolve(sc.decode(msg.data));
414
+ resolve(msg.string());
420
415
  }
421
416
  })();
422
417
  });
@@ -448,14 +443,13 @@ const readCalendarTool = {
448
443
  if (!device)
449
444
  throw new ToolError("No linked device configured", 400);
450
445
  const { startDate, endDate } = args;
451
- const sc = StringCodec();
452
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
446
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, JSON.stringify({
453
447
  hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
454
448
  action: "read",
455
449
  ...(startDate ? { startDate: String(startDate) } : {}),
456
450
  ...(endDate ? { endDate: String(endDate) } : {}),
457
- })), { timeout: 5_000 });
458
- const ack = JSON.parse(sc.decode(ackReply.data));
451
+ }), { timeout: 5_000 });
452
+ const ack = JSON.parse(ackReply.string());
459
453
  if (ack.error)
460
454
  throw new ToolError(ack.error, 502);
461
455
  const responsePromise = new Promise((resolve, reject) => {
@@ -467,7 +461,7 @@ const readCalendarTool = {
467
461
  (async () => {
468
462
  for await (const msg of sub) {
469
463
  clearTimeout(timer);
470
- resolve(sc.decode(msg.data));
464
+ resolve(msg.string());
471
465
  }
472
466
  })();
473
467
  });
@@ -504,15 +498,14 @@ const createCalendarEventTool = {
504
498
  const { title, startTime, endTime, location, description } = args;
505
499
  if (!title || !startTime || !endTime)
506
500
  throw new ToolError("title, startTime, and endTime are required", 400);
507
- const sc = StringCodec();
508
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
501
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, JSON.stringify({
509
502
  hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
510
503
  action: "create",
511
504
  title, startTime: String(startTime), endTime: String(endTime),
512
505
  ...(location ? { location } : {}),
513
506
  ...(description ? { description } : {}),
514
- })), { timeout: 5_000 });
515
- const ack = JSON.parse(sc.decode(ackReply.data));
507
+ }), { timeout: 5_000 });
508
+ const ack = JSON.parse(ackReply.string());
516
509
  if (ack.error)
517
510
  throw new ToolError(ack.error, 502);
518
511
  const responsePromise = new Promise((resolve, reject) => {
@@ -524,7 +517,7 @@ const createCalendarEventTool = {
524
517
  (async () => {
525
518
  for await (const msg of sub) {
526
519
  clearTimeout(timer);
527
- resolve(sc.decode(msg.data));
520
+ resolve(msg.string());
528
521
  }
529
522
  })();
530
523
  });
@@ -558,12 +551,11 @@ const sendSmsTool = {
558
551
  const { to, body } = args;
559
552
  if (!to || !body)
560
553
  throw new ToolError("to and body are required", 400);
561
- const sc = StringCodec();
562
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.sms`, sc.encode(JSON.stringify({
554
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.sms`, JSON.stringify({
563
555
  hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
564
556
  action: "send", to, body,
565
- })), { timeout: 5_000 });
566
- const ack = JSON.parse(sc.decode(ackReply.data));
557
+ }), { timeout: 5_000 });
558
+ const ack = JSON.parse(ackReply.string());
567
559
  if (ack.error)
568
560
  throw new ToolError(ack.error, 502);
569
561
  const responsePromise = new Promise((resolve, reject) => {
@@ -575,7 +567,7 @@ const sendSmsTool = {
575
567
  (async () => {
576
568
  for await (const msg of sub) {
577
569
  clearTimeout(timer);
578
- resolve(sc.decode(msg.data));
570
+ resolve(msg.string());
579
571
  }
580
572
  })();
581
573
  });
@@ -610,15 +602,14 @@ const sendAlarmTool = {
610
602
  const { title, description } = args;
611
603
  if (!title)
612
604
  throw new ToolError("title is required", 400);
613
- const sc = StringCodec();
614
605
  const payload = {
615
606
  hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
616
607
  title,
617
608
  };
618
609
  if (description)
619
610
  payload.description = description;
620
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alarm`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
621
- const ack = JSON.parse(sc.decode(ackReply.data));
611
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alarm`, JSON.stringify(payload), { timeout: 5_000 });
612
+ const ack = JSON.parse(ackReply.string());
622
613
  if (ack.error)
623
614
  throw new ToolError(ack.error, 502);
624
615
  const responsePromise = new Promise((resolve, reject) => {
@@ -630,7 +621,7 @@ const sendAlarmTool = {
630
621
  (async () => {
631
622
  for await (const msg of sub) {
632
623
  clearTimeout(timer);
633
- resolve(sc.decode(msg.data));
624
+ resolve(msg.string());
634
625
  }
635
626
  })();
636
627
  });
@@ -657,9 +648,8 @@ const readBatteryTool = {
657
648
  const device = getLinkedDevice();
658
649
  if (!device)
659
650
  throw new ToolError("No linked device configured", 400);
660
- const sc = StringCodec();
661
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken })), { timeout: 5_000 });
662
- const ack = JSON.parse(sc.decode(ackReply.data));
651
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken }), { timeout: 5_000 });
652
+ const ack = JSON.parse(ackReply.string());
663
653
  if (ack.error)
664
654
  throw new ToolError(ack.error, 502);
665
655
  const responsePromise = new Promise((resolve, reject) => {
@@ -671,7 +661,7 @@ const readBatteryTool = {
671
661
  (async () => {
672
662
  for await (const msg of sub) {
673
663
  clearTimeout(timer);
674
- resolve(sc.decode(msg.data));
664
+ resolve(msg.string());
675
665
  }
676
666
  })();
677
667
  });
@@ -704,9 +694,8 @@ const setRingerModeTool = {
704
694
  const { mode } = args;
705
695
  if (!["normal", "vibrate", "silent"].includes(mode))
706
696
  throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
707
- const sc = StringCodec();
708
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken, mode })), { timeout: 5_000 });
709
- const ack = JSON.parse(sc.decode(ackReply.data));
697
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken, mode }), { timeout: 5_000 });
698
+ const ack = JSON.parse(ackReply.string());
710
699
  if (ack.error)
711
700
  throw new ToolError(ack.error, 502);
712
701
  const responsePromise = new Promise((resolve, reject) => {
@@ -718,7 +707,7 @@ const setRingerModeTool = {
718
707
  (async () => {
719
708
  for await (const msg of sub) {
720
709
  clearTimeout(timer);
721
- resolve(sc.decode(msg.data));
710
+ resolve(msg.string());
722
711
  }
723
712
  })();
724
713
  });
@@ -755,7 +744,6 @@ const sendEmailTool = {
755
744
  const { to, subject, body, cc, bcc } = args;
756
745
  if (!to)
757
746
  throw new ToolError("to is required", 400);
758
- const sc = StringCodec();
759
747
  const payload = {
760
748
  hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
761
749
  to,
@@ -768,8 +756,8 @@ const sendEmailTool = {
768
756
  payload.cc = cc;
769
757
  if (bcc)
770
758
  payload.bcc = bcc;
771
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.email`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
772
- const ack = JSON.parse(sc.decode(ackReply.data));
759
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.email`, JSON.stringify(payload), { timeout: 5_000 });
760
+ const ack = JSON.parse(ackReply.string());
773
761
  if (ack.error)
774
762
  throw new ToolError(ack.error, 502);
775
763
  const responsePromise = new Promise((resolve, reject) => {
@@ -781,7 +769,7 @@ const sendEmailTool = {
781
769
  (async () => {
782
770
  for await (const msg of sub) {
783
771
  clearTimeout(timer);
784
- resolve(sc.decode(msg.data));
772
+ resolve(msg.string());
785
773
  }
786
774
  })();
787
775
  });
@@ -29,7 +29,9 @@ Whenever a tool you are trying to use is denied or you lack the required permiss
29
29
 
30
30
  ## Browsers
31
31
 
32
- When launching a browser with the Playwright CLI `open` command, always pass `--headed` (never run headless) and `--persistent` (reuse a persistent profile so logins and cookies survive across runs).
32
+ Whenever the task needs a browser, launch it with the Playwright CLI (`playwright-cli open`). Do not use any other browser tool, automation library, or launch method.
33
+
34
+ When launching, always pass `--headed` (never run headless) and `--persistent` (reuse a persistent profile so logins and cookies survive across runs).
33
35
 
34
36
  When the task is finished, close any browser you opened with the Playwright CLI `close` command (pass the same `-s=<session>` if you used one) before printing the completion marker.
35
37
 
@@ -30,7 +30,7 @@ export interface AgentTool {
30
30
  supportsPermissions?: boolean;
31
31
  /** Whether this agent supports yolo mode (auto-approve all tools). */
32
32
  supportsYolo?: boolean;
33
- /** When true, the run loop will not persist the agent's stderr output. */
33
+ /** When true, the run loop persists the agent's stderr output only if the invocation fails. */
34
34
  suppressStdErr?: boolean;
35
35
  /** Matches the model name (capture group 1) in the banner the CLI prints on stderr.
36
36
  * Takes precedence over the agent's self-reported model marker. */
@@ -10,6 +10,7 @@ import { getPlatform } from "../platform/index.js";
10
10
  import { pairCommand } from "./pair.js";
11
11
  import { detectDefaultInterface, getInterfaceIpv4 } from "../network.js";
12
12
  import { listTasks, taskDisplayName } from "../task.js";
13
+ import { currentVersion } from "../update-checker.js";
13
14
  const { bold, dim, green, cyan, red } = colors;
14
15
  export async function initCommand() {
15
16
  console.log(`\n${bold("=== Palmier Host Setup ===")}\n`);
@@ -74,6 +75,7 @@ export async function initCommand() {
74
75
  const defaultInterface = (await detectDefaultInterface()) ?? undefined;
75
76
  const lanIp = defaultInterface ? getInterfaceIpv4(defaultInterface) : null;
76
77
  console.log(`\n${bold("Setup summary:")}\n`);
78
+ console.log(` ${dim("Daemon:")} Palmier v${currentVersion}\n`);
77
79
  console.log(` ${dim("Task storage:")} ${bold(projectRoot)}`);
78
80
  console.log(` All tasks and execution data will be stored here.\n`);
79
81
  console.log(` ${dim("Local:")} ${cyan(`http://localhost:${httpPort}`)}`);
@@ -1,6 +1,5 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "node:os";
3
- import { StringCodec } from "nats";
4
3
  import { loadConfig } from "../config.js";
5
4
  import { connectNats } from "../nats-client.js";
6
5
  import { addClient } from "../client-store.js";
@@ -66,7 +65,6 @@ export async function pairCommand() {
66
65
  console.log("");
67
66
  console.log("Code expires in 1 minute.");
68
67
  const nc = await connectNats(config);
69
- const sc = StringCodec();
70
68
  const subject = `pair.${code}`;
71
69
  const sub = nc.subscribe(subject, { max: 1 });
72
70
  cleanups.push(() => {
@@ -80,14 +78,14 @@ export async function pairCommand() {
80
78
  let label;
81
79
  try {
82
80
  if (msg.data && msg.data.length > 0) {
83
- const body = JSON.parse(sc.decode(msg.data));
81
+ const body = JSON.parse(msg.string());
84
82
  label = body.label;
85
83
  }
86
84
  }
87
85
  catch { /* empty body is fine */ }
88
86
  const response = buildPairResponse(config, label);
89
87
  if (msg.reply) {
90
- msg.respond(sc.encode(JSON.stringify(response)));
88
+ msg.respond(JSON.stringify(response));
91
89
  }
92
90
  onPaired();
93
91
  }
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { spawnCommand } from "../spawn-command.js";
4
4
  import { loadConfig } from "../config.js";
5
- import { parseTaskFile, taskDisplayName, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir, beginStreamingMessage, formatAgentModelComment } from "../task.js";
5
+ import { parseTaskFile, taskDisplayName, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, claimPendingRun, appendRunMessage, getRunDir, beginStreamingMessage, formatAgentModelComment } from "../task.js";
6
6
  import { getAgent } from "../agents/agent.js";
7
7
  import { getPlatform } from "../platform/index.js";
8
8
  import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_PERMISSION_PREFIX, AGENT_MODEL_PREFIX } from "../agents/shared-prompt.js";
@@ -19,6 +19,7 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
19
19
  let writer;
20
20
  let activeStream;
21
21
  const lineBufs = { stdout: "", stderr: "" };
22
+ let suppressedStderr = "";
22
23
  let notifyPending = false;
23
24
  let notifyTimer;
24
25
  let agentModel;
@@ -50,7 +51,9 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
50
51
  throttledNotify();
51
52
  }
52
53
  function onStderr(chunk) {
53
- if (!ctx.agent.suppressStdErr)
54
+ if (ctx.agent.suppressStdErr)
55
+ suppressedStderr += chunk;
56
+ else
54
57
  emit("stderr", chunk);
55
58
  const cliModel = captureCliModel(chunk);
56
59
  if (cliModel && !agentModelWritten)
@@ -71,6 +74,12 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
71
74
  }
72
75
  flushAgentModel();
73
76
  }
77
+ function flushTrailingLine(stream) {
78
+ const trailing = lineBufs[stream];
79
+ if (trailing && !trailing.startsWith("[PALMIER")) {
80
+ ensureWriter(stream).write(trailing);
81
+ }
82
+ }
74
83
  const { args, stdin, env: agentEnv, files } = ctx.agent.getTaskRunCommandLine(invokeTask, undefined, ctx.task.frontmatter.yolo_mode ? "yolo" : ctx.transientPermissions);
75
84
  const command = ctx.agent.command;
76
85
  const runDir = getRunDir(ctx.taskDir, ctx.runId);
@@ -87,21 +96,19 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
87
96
  onStdout: (chunk) => emit("stdout", chunk),
88
97
  onStderr,
89
98
  });
90
- if (notifyTimer)
91
- clearTimeout(notifyTimer);
92
99
  const outcome = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
93
100
  const linkedFiles = listLinkedTaskFiles(ctx.taskDir, ctx.runId, result.output);
94
101
  const requiredPermissions = parsePermissions(result.output);
95
- for (const stream of ["stdout", "stderr"]) {
96
- const trailing = lineBufs[stream];
97
- if (trailing && !trailing.startsWith("[PALMIER")) {
98
- ensureWriter(stream).write(trailing);
99
- }
100
- }
102
+ flushTrailingLine("stdout");
103
+ if (outcome === "failed")
104
+ emit("stderr", suppressedStderr);
105
+ flushTrailingLine("stderr");
101
106
  if (requiredPermissions.length > 0) {
102
107
  const permLines = requiredPermissions.map((p) => `- **${p.name}** ${p.description}`).join("\n");
103
108
  ensureWriter("stdout").write(`\n\n**Permissions requested:**\n${permLines}\n`);
104
109
  }
110
+ if (notifyTimer)
111
+ clearTimeout(notifyTimer);
105
112
  writer?.end();
106
113
  await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
107
114
  if (linkedFiles.length > 0) {
@@ -154,18 +161,6 @@ async function appendAndNotify(ctx, msg) {
154
161
  appendRunMessage(ctx.taskDir, ctx.runId, msg);
155
162
  await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
156
163
  }
157
- /** The latest run dir with no status messages yet — freshly created by the RPC handler. */
158
- function findLatestPendingRunId(taskDir) {
159
- const dirs = fs.readdirSync(taskDir)
160
- .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
161
- .sort();
162
- if (dirs.length === 0)
163
- return null;
164
- const latest = dirs[dirs.length - 1];
165
- const messages = readRunMessages(taskDir, latest);
166
- const hasStatus = messages.some((m) => m.role === "status");
167
- return hasStatus ? null : latest;
168
- }
169
164
  /**
170
165
  * If the RPC handler already wrote "aborted" (via task.abort), respect that
171
166
  * instead of overwriting with the process's own outcome.
@@ -182,7 +177,7 @@ export async function runCommand(taskId) {
182
177
  const task = parseTaskFile(taskDir);
183
178
  console.log(`Running task: ${taskId}`);
184
179
  const taskName = taskDisplayName(task.frontmatter);
185
- const existingRunId = findLatestPendingRunId(taskDir);
180
+ const existingRunId = claimPendingRun(taskDir);
186
181
  const agentVersion = config.agents?.find((a) => a.key === task.frontmatter.agent)?.version;
187
182
  const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now(), task.frontmatter.agent, agentVersion);
188
183
  if (!existingRunId) {
@@ -12,7 +12,6 @@ import { detectAgents } from "../agents/agent.js";
12
12
  import { getPlaywrightCliVersion } from "../playwright-cli.js";
13
13
  import { saveConfig } from "../config.js";
14
14
  import { CONFIG_DIR } from "../config.js";
15
- import { StringCodec } from "nats";
16
15
  import { addNotification } from "../notification-store.js";
17
16
  import { addSmsMessage } from "../sms-store.js";
18
17
  import { dispatchTrigger } from "../trigger-dispatch.js";
@@ -118,13 +117,13 @@ export async function serveCommand() {
118
117
  console.log("[nats] Connected");
119
118
  (async () => {
120
119
  for await (const s of conn.status()) {
121
- if (s.type === "pingTimer")
120
+ if (s.type === "ping")
122
121
  continue;
123
- console.log(`[nats] Status: ${s.type}${typeof s.data === "string" ? ` (${s.data})` : ""}`);
122
+ const detail = "server" in s ? s.server : "error" in s ? s.error.message : undefined;
123
+ console.log(`[nats] Status: ${s.type}${detail ? ` (${detail})` : ""}`);
124
124
  }
125
125
  })();
126
126
  startNatsTransport(config, handleRpc, conn);
127
- const sc = StringCodec();
128
127
  // Match phone numbers regardless of formatting; letters preserved for shortcodes.
129
128
  function normalizeSender(raw) {
130
129
  return raw.replace(/[\s\-()+]/g, "").toLowerCase();
@@ -152,7 +151,7 @@ export async function serveCommand() {
152
151
  const notifSub = conn.subscribe(`host.${config.hostId}.device.notifications`);
153
152
  (async () => {
154
153
  for await (const msg of notifSub) {
155
- const raw = sc.decode(msg.data);
154
+ const raw = msg.string();
156
155
  let parsed;
157
156
  try {
158
157
  parsed = JSON.parse(raw);
@@ -167,7 +166,7 @@ export async function serveCommand() {
167
166
  const smsSub = conn.subscribe(`host.${config.hostId}.device.sms`);
168
167
  (async () => {
169
168
  for await (const msg of smsSub) {
170
- const raw = sc.decode(msg.data);
169
+ const raw = msg.string();
171
170
  let parsed;
172
171
  try {
173
172
  parsed = JSON.parse(raw);
@@ -1,3 +1,3 @@
1
- import { type NatsConnection } from "nats";
1
+ import { type NatsConnection } from "@nats-io/transport-node";
2
2
  import type { HostConfig } from "./types.js";
3
3
  export declare function connectNats(config: HostConfig): Promise<NatsConnection>;
@@ -1,4 +1,4 @@
1
- import { connect, jwtAuthenticator } from "nats";
1
+ import { connect, jwtAuthenticator } from "@nats-io/transport-node";
2
2
  export async function connectNats(config) {
3
3
  if (!config.natsJwt || !config.natsNkeySeed) {
4
4
  throw new Error("NATS JWT credentials not configured. Re-run palmier init.");
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
- import { listTasks, parseTaskFile, taskDisplayName, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, isTaskInList, appendHistory, createRunDir, appendRunMessage, getRunDir, writeFollowupStatus, readFollowupStatus, deleteFollowupStatus, isRunStarred, setRunStarred, formatAgentModelComment, extractAgentModelComment } from "./task.js";
4
+ import { listTasks, parseTaskFile, taskDisplayName, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, isTaskInList, createPendingRun, readPendingRun, appendRunMessage, getRunDir, writeFollowupStatus, readFollowupStatus, deleteFollowupStatus, isRunStarred, setRunStarred, formatAgentModelComment, extractAgentModelComment } from "./task.js";
5
5
  import { resolvePending, getPending, listPending } from "./pending-requests.js";
6
6
  import { getPlatform } from "./platform/index.js";
7
7
  import { onChildDone, resolveCommand, spawnCommand } from "./spawn-command.js";
@@ -136,6 +136,8 @@ async function generateName(projectRoot, userPrompt, agentName) {
136
136
  return userPrompt;
137
137
  }
138
138
  }
139
+ // Longer than the slowest observed launch (~5s on Windows) before the run process claims its run.
140
+ const DUPLICATE_RUN_WINDOW_MS = 15_000;
139
141
  /** Active follow-up child processes, keyed by "taskId:runId". */
140
142
  const activeFollowups = new Map();
141
143
  /**
@@ -364,8 +366,7 @@ export function createRpcHandler(config) {
364
366
  writeTaskFile(taskDir, task);
365
367
  // One-off run: do NOT append to tasks.jsonl.
366
368
  const oneoffAgentVersion = config.agents?.find((a) => a.key === params.agent)?.version;
367
- const runId = createRunDir(taskDir, name, Date.now(), params.agent, oneoffAgentVersion);
368
- appendHistory(config.projectRoot, { task_id: id, run_id: runId });
369
+ const runId = createPendingRun(config.projectRoot, taskDir, id, name, params.agent, oneoffAgentVersion);
369
370
  const platform = getPlatform();
370
371
  platform.installTaskTimer(config, task);
371
372
  try {
@@ -382,17 +383,22 @@ export function createRpcHandler(config) {
382
383
  case "task.run": {
383
384
  const params = request.params;
384
385
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
386
+ const launchingRunId = readPendingRun(runTaskDir);
387
+ if (launchingRunId && Date.now() - Number(launchingRunId) < DUPLICATE_RUN_WINDOW_MS) {
388
+ console.log(`[task.run] Task ${params.id} run ${launchingRunId} is still launching, ignoring duplicate request`);
389
+ return { ok: true, task_id: params.id, run_id: launchingRunId };
390
+ }
385
391
  let taskRunId;
386
392
  try {
387
393
  const platform = getPlatform();
394
+ // Must stay synchronous up to here so a concurrent duplicate sees the pending run.
395
+ const runTask = parseTaskFile(runTaskDir);
396
+ const taskRunAgentVersion = config.agents?.find((a) => a.key === runTask.frontmatter.agent)?.version;
397
+ taskRunId = createPendingRun(config.projectRoot, runTaskDir, params.id, taskDisplayName(runTask.frontmatter), runTask.frontmatter.agent, taskRunAgentVersion);
388
398
  if (platform.isTaskRunning(params.id)) {
389
399
  console.log(`[task.run] Task ${params.id} is already running, killing stale process`);
390
400
  await platform.stopTask(params.id);
391
401
  }
392
- const runTask = parseTaskFile(runTaskDir);
393
- const taskRunAgentVersion = config.agents?.find((a) => a.key === runTask.frontmatter.agent)?.version;
394
- taskRunId = createRunDir(runTaskDir, taskDisplayName(runTask.frontmatter), Date.now(), runTask.frontmatter.agent, taskRunAgentVersion);
395
- appendHistory(config.projectRoot, { task_id: params.id, run_id: taskRunId });
396
402
  await platform.startTask(params.id);
397
403
  return { ok: true, task_id: params.id, run_id: taskRunId };
398
404
  }
package/dist/task.d.ts CHANGED
@@ -21,6 +21,16 @@ export declare function isRunStarred(runDir: string): boolean;
21
21
  export declare function setRunStarred(runDir: string, starred: boolean): void;
22
22
  /** Returns the run ID (timestamp string used as directory name). */
23
23
  export declare function createRunDir(taskDir: string, taskName: string, startTime: number, agent?: string, agentVersion?: string): string;
24
+ /** The run dir created for the next run process that it has not claimed yet, or null. */
25
+ export declare function readPendingRun(taskDir: string): string | null;
26
+ /**
27
+ * Create a run dir (plus its history entry) for the run process about to be
28
+ * launched to claim. A previous pending run that was never claimed is discarded
29
+ * so it doesn't linger as an empty session.
30
+ */
31
+ export declare function createPendingRun(projectRoot: string, taskDir: string, taskId: string, taskName: string, agent?: string, agentVersion?: string): string;
32
+ /** Take ownership of the pending run; null if there is none or another process took it first. */
33
+ export declare function claimPendingRun(taskDir: string): string | null;
24
34
  export declare function getRunDir(taskDir: string, runId: string): string;
25
35
  export declare function appendRunMessage(taskDir: string, runId: string, msg: ConversationMessage): void;
26
36
  export declare function beginStreamingMessage(taskDir: string, runId: string, time: number, stream?: "stdout" | "stderr"): StreamingMessageWriter;
package/dist/task.js CHANGED
@@ -155,6 +155,48 @@ export function createRunDir(taskDir, taskName, startTime, agent, agentVersion)
155
155
  fs.writeFileSync(path.join(runDir, "TASKRUN.md"), content, "utf-8");
156
156
  return runId;
157
157
  }
158
+ const PENDING_RUN_FILE = "pending-run";
159
+ function isUnclaimedRun(taskDir, runId) {
160
+ if (!/^\d+$/.test(runId) || !fs.existsSync(path.join(taskDir, runId, "TASKRUN.md")))
161
+ return false;
162
+ return !readRunMessages(taskDir, runId).some((m) => m.role === "status");
163
+ }
164
+ /** The run dir created for the next run process that it has not claimed yet, or null. */
165
+ export function readPendingRun(taskDir) {
166
+ let runId;
167
+ try {
168
+ runId = fs.readFileSync(path.join(taskDir, PENDING_RUN_FILE), "utf-8").trim();
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ return isUnclaimedRun(taskDir, runId) ? runId : null;
174
+ }
175
+ /**
176
+ * Create a run dir (plus its history entry) for the run process about to be
177
+ * launched to claim. A previous pending run that was never claimed is discarded
178
+ * so it doesn't linger as an empty session.
179
+ */
180
+ export function createPendingRun(projectRoot, taskDir, taskId, taskName, agent, agentVersion) {
181
+ const stale = readPendingRun(taskDir);
182
+ if (stale)
183
+ deleteHistoryEntry(projectRoot, taskId, stale);
184
+ const runId = createRunDir(taskDir, taskName, Date.now(), agent, agentVersion);
185
+ appendHistory(projectRoot, { task_id: taskId, run_id: runId });
186
+ fs.writeFileSync(path.join(taskDir, PENDING_RUN_FILE), runId, "utf-8");
187
+ return runId;
188
+ }
189
+ /** Take ownership of the pending run; null if there is none or another process took it first. */
190
+ export function claimPendingRun(taskDir) {
191
+ const runId = readPendingRun(taskDir);
192
+ try {
193
+ fs.unlinkSync(path.join(taskDir, PENDING_RUN_FILE));
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ return runId;
199
+ }
158
200
  export function getRunDir(taskDir, runId) {
159
201
  return path.join(taskDir, runId);
160
202
  }
@@ -1,3 +1,3 @@
1
- import { type NatsConnection } from "nats";
1
+ import { type NatsConnection } from "@nats-io/transport-node";
2
2
  import type { HostConfig, RpcMessage } from "../types.js";
3
3
  export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, port: number, getNc: () => NatsConnection | undefined, pairingCode?: string, onReady?: () => void): Promise<void>;
@@ -1,7 +1,6 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "os";
3
3
  import * as path from "node:path";
4
- import { StringCodec } from "nats";
5
4
  import { validateClient, addClient } from "../client-store.js";
6
5
  import { registerPending } from "../pending-requests.js";
7
6
  import * as fs from "node:fs";
@@ -106,9 +105,8 @@ export async function startHttpTransport(config, handleRpc, port, getNc, pairing
106
105
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
107
106
  }
108
107
  async function publishEvent(taskId, payload) {
109
- const sc = StringCodec();
110
108
  const subject = `host-event.${config.hostId}.${taskId}`;
111
- getNc()?.publish(subject, sc.encode(JSON.stringify(payload)));
109
+ getNc()?.publish(subject, JSON.stringify(payload));
112
110
  broadcastSseEvent({ task_id: taskId, ...payload });
113
111
  }
114
112
  function makeToolContext(taskId) {
@@ -1,3 +1,3 @@
1
- import { type NatsConnection } from "nats";
1
+ import { type NatsConnection } from "@nats-io/transport-node";
2
2
  import type { HostConfig, RpcMessage } from "../types.js";
3
3
  export declare function startNatsTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, nc: NatsConnection): Promise<void>;
@@ -1,6 +1,4 @@
1
- import { StringCodec } from "nats";
2
1
  export async function startNatsTransport(config, handleRpc, nc) {
3
- const sc = StringCodec();
4
2
  const subject = `host.${config.hostId}.rpc.>`;
5
3
  console.log(`[nats] Subscribing to: ${subject}`);
6
4
  const sub = nc.subscribe(subject);
@@ -19,7 +17,7 @@ export async function startNatsTransport(config, handleRpc, nc) {
19
17
  const method = rpcIdx >= 0 ? subjectTokens.slice(rpcIdx + 1).join(".") : "";
20
18
  let params = {};
21
19
  if (msg.data && msg.data.length > 0) {
22
- const raw = sc.decode(msg.data).trim();
20
+ const raw = msg.string().trim();
23
21
  if (raw.length > 0) {
24
22
  try {
25
23
  params = JSON.parse(raw);
@@ -27,7 +25,7 @@ export async function startNatsTransport(config, handleRpc, nc) {
27
25
  catch {
28
26
  console.error(`[nats] Failed to parse RPC params for ${method}`);
29
27
  if (msg.reply) {
30
- msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
28
+ msg.respond(JSON.stringify({ error: "Invalid JSON" }));
31
29
  }
32
30
  return;
33
31
  }
@@ -47,7 +45,7 @@ export async function startNatsTransport(config, handleRpc, nc) {
47
45
  }
48
46
  console.log(`[nats] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
49
47
  if (msg.reply) {
50
- msg.respond(sc.encode(JSON.stringify(response)));
48
+ msg.respond(JSON.stringify(response));
51
49
  }
52
50
  }
53
51
  async function consumeSubscription(subscription) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "1.1.20",
3
+ "version": "1.1.22",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -36,10 +36,10 @@
36
36
  "start": "node dist/index.js"
37
37
  },
38
38
  "dependencies": {
39
+ "@nats-io/transport-node": "^3.4.0",
39
40
  "commander": "^13.1.0",
40
41
  "cross-spawn": "^7.0.6",
41
42
  "dotenv": "^16.4.7",
42
- "nats": "^2.29.1",
43
43
  "yaml": "^2.7.0"
44
44
  },
45
45
  "devDependencies": {