palmier 1.1.24 → 1.1.25

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
@@ -53,7 +53,6 @@ Every task Palmier runs gets these capabilities automatically. Their usage is wr
53
53
  | Capability | Description |
54
54
  |------------|-------------|
55
55
  | `notify` | Send a push notification to the user's device |
56
- | `request-input` | Request input from the user (blocks until response; aborting the prompt aborts the task) |
57
56
  | `request-choice` | Ask the user a question with a fixed set of options and return the one picked; a single option serves as a confirmation (blocks until response; aborting aborts the task) |
58
57
  | `fill-password` | Fill a saved password or PIN into the active browser session and submit the form, prompting the user if unknown (the secret is never revealed to the agent) |
59
58
  | `device-geolocation` | Get GPS location of the user's mobile device |
@@ -75,6 +74,12 @@ Every task Palmier runs gets these capabilities automatically. Their usage is wr
75
74
 
76
75
  All device tools work while the Palmier Android app is in the background — they communicate via FCM data messages which wake the app's service even when it's not in the foreground. Each host has one **linked device**: the phone the host uses for SMS, contacts, location, and other device capabilities. Choose it at pair time (the "Link the host to this device" checkbox) or later from the drawer. Each capability must be enabled and its Android permission granted via toggles in the linked device's drawer.
77
76
 
77
+ ### Asking the User
78
+
79
+ When an agent needs an answer it can't get from a fixed list, it writes its question as its final message and ends with the `[PALMIER_TASK_NEED_INPUT]` marker. The agent CLI then exits and the run moves to a "needs input" state, with a push notification that opens the run. The user answers in the run view's message box, or aborts from there.
80
+
81
+ `palmier run` stays alive during the wait, so anything the agent left running, such as its browser, stays open. Palmier polls for the reply and resumes the same agent session with it, repeating until the agent reports success or failure. An unanswered question fails the run after 24 hours. The reply is handed over through a file in the run directory, so a daemon restart doesn't lose the wait.
82
+
78
83
  ### Files in Agent Output
79
84
 
80
85
  Agents run with the task's run directory (`~/palmier/tasks/<task-id>/<run-id>/`) as their working directory and are instructed to link any file they produce with a relative markdown link, e.g. `[View report](report.md)` or `![Chart](chart.png)`. The app opens these links over whichever transport is in use (local, relay, or auto-LAN). It fetches the file through the `task.file` RPC in 512 KiB chunks, so large files fit within the relay's message size. Markdown files render in-app, with their own relative links and images resolved from the file's location. Text and images are previewed, and other file types are offered as a browser download. The host only serves files whose real path (after resolving symlinks) is inside that task's directory, up to 25 MB. A run whose output links an existing file triggers a "report ready" push notification.
@@ -41,63 +41,6 @@ const notifyTool = {
41
41
  throw new ToolError(result.error ?? "Push notification failed", 502);
42
42
  },
43
43
  };
44
- const requestInputTool = {
45
- name: "request-input",
46
- description: [
47
- "Request input from the user.",
48
- "The request blocks until the user responds.",
49
- 'Response: `{"values": ["answer1", "answer2"]}` on success, or `{"aborted": true}` if the user declines.',
50
- "When you need information from the user (credentials, answers to questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout, even in a non-interactive environment — use this instead.",
51
- ],
52
- inputSchema: {
53
- type: "object",
54
- properties: {
55
- description: { type: "string", description: "Context or heading for the input request" },
56
- questions: {
57
- type: "array",
58
- items: { type: "string" },
59
- description: "Questions to present to the user",
60
- minItems: 1,
61
- },
62
- },
63
- required: ["questions"],
64
- },
65
- async handler(args, ctx) {
66
- const { description, questions } = args;
67
- if (!questions?.length)
68
- throw new ToolError("questions is required", 400);
69
- const pendingPromise = registerPending(ctx.taskId, "input", questions, {
70
- session_id: ctx.taskId,
71
- description,
72
- input_questions: questions,
73
- });
74
- await ctx.publishEvent("_input", {
75
- event_type: "input-request",
76
- host_id: ctx.config.hostId,
77
- session_id: ctx.taskId,
78
- description,
79
- input_questions: questions,
80
- });
81
- const response = await pendingPromise;
82
- const aborted = response.length === 1 && response[0] === "aborted";
83
- recordUserResponseToRun(ctx, aborted
84
- ? "Aborted"
85
- : questions.map((q, i) => `**${q}**\n\n${response[i] ?? ""}`).join("\n\n"));
86
- if (aborted) {
87
- await ctx.publishEvent("_input", {
88
- event_type: "input-resolved", host_id: ctx.config.hostId,
89
- session_id: ctx.taskId, status: "aborted",
90
- });
91
- await ctx.abortTask();
92
- return { aborted: true };
93
- }
94
- await ctx.publishEvent("_input", {
95
- event_type: "input-resolved", host_id: ctx.config.hostId,
96
- session_id: ctx.taskId, status: "provided",
97
- });
98
- return { values: response };
99
- },
100
- };
101
44
  /** Splice the user's answer into the run file so history shows it even if the agent never echoes it. */
102
45
  function recordUserResponseToRun(ctx, content) {
103
46
  const taskId = ctx.taskId;
@@ -166,7 +109,7 @@ const requestChoiceTool = {
166
109
  "Ask the user a question with a fixed set of answers and let them pick one.",
167
110
  "The request blocks until the user picks an option or aborts the task.",
168
111
  'Response: `{"choice": "<option>"}` with the selected option, or `{"aborted": true}` if the user aborts.',
169
- "Use this instead of request-input whenever the possible answers are known in advance.",
112
+ "Use this whenever the possible answers are known in advance.",
170
113
  "An abort option that ends the task is always shown, so to ask for confirmation before continuing, pass a single option naming the action (e.g. [\"Send email\"]). Add a decline option only if declining should let the task continue differently.",
171
114
  ],
172
115
  inputSchema: {
@@ -269,7 +212,7 @@ const fillPasswordTool = {
269
212
  event_type: "input-resolved", host_id: ctx.config.hostId,
270
213
  session_id: ctx.taskId, status: aborted ? "aborted" : "provided",
271
214
  });
272
- // Never record the secret to the task run file (unlike request-input).
215
+ // Never record the secret to the task run file.
273
216
  if (aborted) {
274
217
  await ctx.abortTask();
275
218
  return { aborted: true };
@@ -779,7 +722,7 @@ const sendEmailTool = {
779
722
  return result;
780
723
  },
781
724
  };
782
- export const agentTools = [notifyTool, requestInputTool, requestChoiceTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
725
+ export const agentTools = [notifyTool, requestChoiceTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
783
726
  export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
784
727
  const deviceNotificationsResource = {
785
728
  name: "Device Notifications",
@@ -13,22 +13,29 @@ The user reads your output on a different device and can only open files you lin
13
13
 
14
14
  ## Completion
15
15
 
16
- When you are done, output exactly one of these markers as the very last line on stdout (no other text on the same line):
16
+ When you stop, output exactly one of these markers as the very last line on stdout (no other text on the same line):
17
17
  [PALMIER_TASK_SUCCESS]
18
18
  [PALMIER_TASK_FAILURE]
19
+ [PALMIER_TASK_NEED_INPUT]
19
20
 
20
21
  On the line right before that marker, in the same final message, print the model you are running as, using the exact model name or ID from your system prompt (skip this line if you do not know it rather than guessing):
21
22
  [PALMIER_AGENT_MODEL] <model>
22
23
 
23
24
  Do this at the end of every response, including responses to follow-up messages.
24
25
 
26
+ ## Asking the User
27
+
28
+ When you need information from the user (answers to questions, preferences, clarifications, etc.), do not guess or fail. Write your questions in full as your final message, then end it with `[PALMIER_TASK_NEED_INPUT]`. Your process will exit, and the user's reply will arrive as the next message in this same session. Anything you left running, such as a browser, stays open, so leave it as it is.
29
+
30
+ If the possible answers are known in advance, use the `request-choice` endpoint instead. For passwords and PINs, use `fill-password`.
31
+
25
32
  ## Browsers
26
33
 
27
34
  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.
28
35
 
29
36
  When launching, always pass `--headed` (never run headless) and `--persistent` (reuse a persistent profile so logins and cookies survive across runs).
30
37
 
31
- 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.
38
+ When the task has succeeded or failed, 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. Do not close it when you are stopping to ask the user for input.
32
39
 
33
40
  ## HTTP Endpoints
34
41
 
@@ -10,10 +10,8 @@ import { kimiAgent } from "./kimi.js";
10
10
  import { gooseAgent } from "./goose.js";
11
11
  import { openCodeAgent } from "./opencode.js";
12
12
  import { deepAgentsAgent } from "./deepagents.js";
13
- import { aiderAgent } from "./aider.js";
14
13
  import { cursorAgent } from "./cursor.js";
15
14
  import { kiroAgent } from "./kiro.js";
16
- import { clineAgent } from "./cline.js";
17
15
  import { qoderAgent } from "./qoder.js";
18
16
  import { hermesAgent } from "./hermes.js";
19
17
  import { grokAgent } from "./grok.js";
@@ -66,10 +64,8 @@ const agentRegistry = {
66
64
  goose: gooseAgent,
67
65
  opencode: openCodeAgent,
68
66
  deepagents: deepAgentsAgent,
69
- aider: aiderAgent,
70
67
  cursor: cursorAgent,
71
68
  kiro: kiroAgent,
72
- cline: clineAgent,
73
69
  openclaw: openClawAgent,
74
70
  hermes: hermesAgent,
75
71
  };
@@ -2,4 +2,7 @@ import type { ParsedTask } from "../types.js";
2
2
  export declare function getAgentInstructions(task: ParsedTask): string;
3
3
  export declare const TASK_SUCCESS_MARKER = "[PALMIER_TASK_SUCCESS]";
4
4
  export declare const TASK_FAILURE_MARKER = "[PALMIER_TASK_FAILURE]";
5
+ export declare const TASK_NEED_INPUT_MARKER = "[PALMIER_TASK_NEED_INPUT]";
5
6
  export declare const AGENT_MODEL_PREFIX = "[PALMIER_AGENT_MODEL]";
7
+ /** A resumed session may have lost the original instructions to compaction, and a missing marker silently ends the run. */
8
+ export declare function getResumePrompt(userMessage: string): string;
@@ -14,4 +14,9 @@ export function getAgentInstructions(task) {
14
14
  }
15
15
  export const TASK_SUCCESS_MARKER = "[PALMIER_TASK_SUCCESS]";
16
16
  export const TASK_FAILURE_MARKER = "[PALMIER_TASK_FAILURE]";
17
+ export const TASK_NEED_INPUT_MARKER = "[PALMIER_TASK_NEED_INPUT]";
17
18
  export const AGENT_MODEL_PREFIX = "[PALMIER_AGENT_MODEL]";
19
+ /** A resumed session may have lost the original instructions to compaction, and a missing marker silently ends the run. */
20
+ export function getResumePrompt(userMessage) {
21
+ return `${userMessage}\n\n---\nEnd your response with exactly one of these as the very last line on stdout: ${TASK_SUCCESS_MARKER}, ${TASK_FAILURE_MARKER}, or ${TASK_NEED_INPUT_MARKER} if you need more from the user.`;
22
+ }
@@ -7,5 +7,5 @@ export declare function listLinkedTaskFiles(taskDir: string, runId: string, outp
7
7
  export declare function parseAgentModel(output: string): string | undefined;
8
8
  /** Returns a function that accumulates stderr chunks and yields the model once the CLI banner reveals it. */
9
9
  export declare function createCliModelCapture(agent: AgentTool): (stderrChunk: string) => string | undefined;
10
- /** Falls back to "finished" if no success/failure marker is found. */
10
+ /** Falls back to "finished" if no outcome marker is found. */
11
11
  export declare function parseTaskOutcome(output: string): TaskRunningState;
@@ -2,17 +2,17 @@ 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, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, claimPendingRun, appendRunMessage, getRunDir, beginStreamingMessage, formatAgentModelComment } from "../task.js";
5
+ import { parseTaskFile, taskDisplayName, getTaskDir, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, claimPendingRun, appendRunMessage, getRunDir, beginStreamingMessage, formatAgentModelComment, takeUserReply } from "../task.js";
6
6
  import { getAgent } from "../agents/agent.js";
7
7
  import { getPlatform } from "../platform/index.js";
8
- import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, AGENT_MODEL_PREFIX } from "../agents/shared-prompt.js";
8
+ import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_NEED_INPUT_MARKER, AGENT_MODEL_PREFIX, getResumePrompt } from "../agents/shared-prompt.js";
9
9
  import { extractFileLinks, resolveTaskFile } from "../task-files.js";
10
10
  import { publishHostEvent } from "../events.js";
11
11
  /**
12
12
  * `invokeTask` is the ParsedTask whose prompt is passed to the agent (for
13
13
  * triggered tasks this is the per-trigger augmented task).
14
14
  */
15
- async function invokeAgent(ctx, invokeTask) {
15
+ async function invokeAgent(ctx, invokeTask, followupPrompt) {
16
16
  let writer;
17
17
  let activeStream;
18
18
  const lineBufs = { stdout: "", stderr: "" };
@@ -77,7 +77,7 @@ async function invokeAgent(ctx, invokeTask) {
77
77
  ensureWriter(stream).write(trailing);
78
78
  }
79
79
  }
80
- const { args, stdin, env: agentEnv, files } = ctx.agent.getTaskRunCommandLine(invokeTask);
80
+ const { args, stdin, env: agentEnv, files } = ctx.agent.getTaskRunCommandLine(invokeTask, followupPrompt);
81
81
  const command = ctx.agent.command;
82
82
  const runDir = getRunDir(ctx.taskDir, ctx.runId);
83
83
  if (files) {
@@ -130,6 +130,47 @@ function resolveOutcome(taskDir, outcome) {
130
130
  return "aborted";
131
131
  return outcome;
132
132
  }
133
+ const USER_REPLY_POLL_MS = 500;
134
+ // A recurring task can't fire again while this run is waiting, so an unanswered question can't hold it forever.
135
+ const USER_REPLY_TIMEOUT_MS = 24 * 60 * 60 * 1000;
136
+ async function waitForUserReply(runDir) {
137
+ const deadline = Date.now() + USER_REPLY_TIMEOUT_MS;
138
+ while (Date.now() < deadline) {
139
+ const reply = takeUserReply(runDir);
140
+ if (reply)
141
+ return reply;
142
+ await new Promise((resolve) => setTimeout(resolve, USER_REPLY_POLL_MS));
143
+ }
144
+ return undefined;
145
+ }
146
+ /**
147
+ * Only the agent CLI exits while the user answers. This process stays alive because
148
+ * the OS scheduler would otherwise clean up what the agent left running, like its browser.
149
+ */
150
+ async function invokeAgentUntilDone(ctx, invokeTask) {
151
+ const taskName = taskDisplayName(ctx.task.frontmatter);
152
+ const runDir = getRunDir(ctx.taskDir, ctx.runId);
153
+ let result = await invokeAgent(ctx, invokeTask);
154
+ while (resolveOutcome(ctx.taskDir, result.outcome) === "needs_input") {
155
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "needs_input" });
156
+ await publishTaskEvent(ctx.taskDir, ctx.taskId, "needs_input", taskName, ctx.runId);
157
+ const reply = await waitForUserReply(runDir);
158
+ if (!reply) {
159
+ await appendAndNotify(ctx, {
160
+ role: "assistant",
161
+ time: Date.now(),
162
+ content: "The task stopped because no reply arrived within 24 hours.",
163
+ });
164
+ return { outcome: "failed" };
165
+ }
166
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "user", time: Date.now(), content: reply, type: "input" });
167
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "started" });
168
+ await publishTaskEvent(ctx.taskDir, ctx.taskId, "started", taskName, ctx.runId);
169
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
170
+ result = await invokeAgent(ctx, invokeTask, getResumePrompt(reply));
171
+ }
172
+ return result;
173
+ }
133
174
  export async function runCommand(taskId) {
134
175
  const config = loadConfig();
135
176
  const taskDir = getTaskDir(config.projectRoot, taskId);
@@ -196,7 +237,7 @@ export async function runCommand(taskId) {
196
237
  time: Date.now(),
197
238
  content: task.frontmatter.user_prompt,
198
239
  });
199
- const result = await invokeAgent(ctx, task);
240
+ const result = await invokeAgentUntilDone(ctx, task);
200
241
  const outcome = resolveOutcome(taskDir, result.outcome);
201
242
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
202
243
  await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
@@ -257,7 +298,7 @@ async function runEventTriggeredMode(ctx) {
257
298
  const perEventTask = {
258
299
  frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
259
300
  };
260
- const result = await invokeAgent(ctx, perEventTask);
301
+ const result = await invokeAgentUntilDone(ctx, perEventTask);
261
302
  lastOutcome = result.outcome;
262
303
  }
263
304
  }
@@ -273,7 +314,8 @@ async function publishTaskEvent(taskDir, taskId, eventType, taskName, runId) {
273
314
  writeTaskStatus(taskDir, {
274
315
  running_state: eventType,
275
316
  time_stamp: Date.now(),
276
- ...(eventType === "started" ? { pid: process.pid } : {}),
317
+ ...(eventType === "started" || eventType === "needs_input" ? { pid: process.pid } : {}),
318
+ ...(eventType === "needs_input" ? { run_id: runId } : {}),
277
319
  });
278
320
  const payload = { event_type: "running-state", running_state: eventType };
279
321
  if (taskName)
@@ -344,10 +386,10 @@ function sanitizeAgentModel(raw) {
344
386
  return undefined;
345
387
  return model;
346
388
  }
347
- /** Falls back to "finished" if no success/failure marker is found. */
389
+ /** Falls back to "finished" if no outcome marker is found. */
348
390
  export function parseTaskOutcome(output) {
349
391
  const lastChunk = output.slice(-500);
350
- const regex = new RegExp(`^\\${TASK_FAILURE_MARKER}$|^\\${TASK_SUCCESS_MARKER}$`, "gm");
392
+ const regex = new RegExp(`^\\${TASK_FAILURE_MARKER}$|^\\${TASK_SUCCESS_MARKER}$|^\\${TASK_NEED_INPUT_MARKER}$`, "gm");
351
393
  let last = null;
352
394
  let match;
353
395
  while ((match = regex.exec(lastChunk)) !== null) {
@@ -355,6 +397,8 @@ export function parseTaskOutcome(output) {
355
397
  }
356
398
  if (last === TASK_FAILURE_MARKER)
357
399
  return "failed";
400
+ if (last === TASK_NEED_INPUT_MARKER)
401
+ return "needs_input";
358
402
  if (last === TASK_SUCCESS_MARKER)
359
403
  return "finished";
360
404
  return "finished";
@@ -42,7 +42,9 @@ async function checkStaleTasks(config) {
42
42
  catch {
43
43
  continue;
44
44
  }
45
- if (status.running_state === "started" && !platform.isTaskRunning(taskId)) {
45
+ // Follow-up turns also end in needs_input, but no process is held open for them.
46
+ const isWaitingRun = status.running_state === "needs_input" && status.pid !== undefined;
47
+ if ((status.running_state === "started" || isWaitingRun) && !platform.isTaskRunning(taskId)) {
46
48
  console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
47
49
  const endTime = Date.now();
48
50
  writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
@@ -64,7 +66,7 @@ async function checkStaleTasks(config) {
64
66
  name: taskDisplayName(task.frontmatter) || taskId,
65
67
  });
66
68
  }
67
- if (task.frontmatter.one_off && status.running_state !== "started") {
69
+ if (task.frontmatter.one_off && status.running_state !== "started" && !isWaitingRun) {
68
70
  try {
69
71
  platform.removeTaskTimer(taskId);
70
72
  }