palmier 1.1.24 → 1.1.26
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 +6 -2
- package/dist/agent-tools.js +9 -154
- package/dist/agents/agent-instructions.md +9 -2
- package/dist/agents/agent.js +0 -4
- package/dist/agents/shared-prompt.d.ts +3 -0
- package/dist/agents/shared-prompt.js +5 -0
- package/dist/commands/run.d.ts +1 -1
- package/dist/commands/run.js +53 -9
- package/dist/commands/serve.js +4 -2
- package/dist/pending-requests.d.ts +2 -8
- package/dist/pending-requests.js +2 -3
- package/dist/pwa/assets/index-CPtDlrQZ.js +117 -0
- package/dist/pwa/assets/{web-durUDXGk.js → web-BKcnPEQL.js} +1 -1
- package/dist/pwa/assets/{web-DTphq1C9.js → web-CtUISUOo.js} +1 -1
- package/dist/pwa/assets/{web-BIh8c8oo.js → web-CyFdutiL.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/rpc-handler.js +12 -2
- package/dist/task.d.ts +3 -9
- package/dist/task.js +29 -18
- package/dist/types.d.ts +5 -2
- package/package.json +1 -1
- package/dist/agents/aider.d.ts +0 -2
- package/dist/agents/aider.js +0 -13
- package/dist/agents/cline.d.ts +0 -2
- package/dist/agents/cline.js +0 -14
- package/dist/pwa/assets/index-CXiDfnsf.js +0 -117
package/README.md
CHANGED
|
@@ -53,8 +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
|
-
| `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
56
|
| `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
57
|
| `device-geolocation` | Get GPS location of the user's mobile device |
|
|
60
58
|
| `read-contacts` | Read the contact list from the user's device |
|
|
@@ -75,6 +73,12 @@ Every task Palmier runs gets these capabilities automatically. Their usage is wr
|
|
|
75
73
|
|
|
76
74
|
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
75
|
|
|
76
|
+
### Asking the User
|
|
77
|
+
|
|
78
|
+
When an agent needs something from the user, 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.
|
|
79
|
+
|
|
80
|
+
`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.
|
|
81
|
+
|
|
78
82
|
### Files in Agent Output
|
|
79
83
|
|
|
80
84
|
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 ``. 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.
|
package/dist/agent-tools.js
CHANGED
|
@@ -2,7 +2,6 @@ import { registerPending } from "./pending-requests.js";
|
|
|
2
2
|
import { getLinkedDevice } from "./linked-device.js";
|
|
3
3
|
import { getNotifications } from "./notification-store.js";
|
|
4
4
|
import { getSmsMessages } from "./sms-store.js";
|
|
5
|
-
import { getTaskDir, readHistory, spliceUserMessage } from "./task.js";
|
|
6
5
|
import { lookupPassword, savePassword, normalizeOrigin } from "./password-store.js";
|
|
7
6
|
import { fillPasswordInBrowser } from "./playwright-fill.js";
|
|
8
7
|
export class ToolError extends Error {
|
|
@@ -41,84 +40,6 @@ const notifyTool = {
|
|
|
41
40
|
throw new ToolError(result.error ?? "Push notification failed", 502);
|
|
42
41
|
},
|
|
43
42
|
};
|
|
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
|
-
/** Splice the user's answer into the run file so history shows it even if the agent never echoes it. */
|
|
102
|
-
function recordUserResponseToRun(ctx, content) {
|
|
103
|
-
const taskId = ctx.taskId;
|
|
104
|
-
const taskDir = getTaskDir(ctx.config.projectRoot, taskId);
|
|
105
|
-
const { entries } = readHistory(ctx.config.projectRoot, { task_id: taskId, limit: 1 });
|
|
106
|
-
const runId = entries[0]?.run_id;
|
|
107
|
-
if (!runId)
|
|
108
|
-
return;
|
|
109
|
-
try {
|
|
110
|
-
spliceUserMessage(taskDir, runId, {
|
|
111
|
-
role: "user",
|
|
112
|
-
time: Date.now(),
|
|
113
|
-
content,
|
|
114
|
-
type: "input",
|
|
115
|
-
});
|
|
116
|
-
void ctx.publishEvent(taskId, { event_type: "result-updated", run_id: runId });
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// Run file missing or unwritable — best-effort, do not fail the tool call.
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
43
|
export const requestConfirmationTool = {
|
|
123
44
|
name: "request-confirmation",
|
|
124
45
|
description: [
|
|
@@ -137,7 +58,7 @@ export const requestConfirmationTool = {
|
|
|
137
58
|
const { description } = args;
|
|
138
59
|
if (!description)
|
|
139
60
|
throw new ToolError("description is required", 400);
|
|
140
|
-
const pendingPromise = registerPending(ctx.taskId, "confirmation",
|
|
61
|
+
const pendingPromise = registerPending(ctx.taskId, "confirmation", {
|
|
141
62
|
session_id: ctx.taskId,
|
|
142
63
|
description,
|
|
143
64
|
});
|
|
@@ -160,66 +81,6 @@ export const requestConfirmationTool = {
|
|
|
160
81
|
return { confirmed };
|
|
161
82
|
},
|
|
162
83
|
};
|
|
163
|
-
const requestChoiceTool = {
|
|
164
|
-
name: "request-choice",
|
|
165
|
-
description: [
|
|
166
|
-
"Ask the user a question with a fixed set of answers and let them pick one.",
|
|
167
|
-
"The request blocks until the user picks an option or aborts the task.",
|
|
168
|
-
'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.",
|
|
170
|
-
"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
|
-
],
|
|
172
|
-
inputSchema: {
|
|
173
|
-
type: "object",
|
|
174
|
-
properties: {
|
|
175
|
-
question: { type: "string", description: "The question to ask" },
|
|
176
|
-
options: {
|
|
177
|
-
type: "array",
|
|
178
|
-
items: { type: "string" },
|
|
179
|
-
description: 'Unique option labels, in display order (e.g. ["Reply now", "Reply later"])',
|
|
180
|
-
minItems: 1,
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
required: ["question", "options"],
|
|
184
|
-
},
|
|
185
|
-
async handler(args, ctx) {
|
|
186
|
-
const { question, options } = args;
|
|
187
|
-
if (!question)
|
|
188
|
-
throw new ToolError("question is required", 400);
|
|
189
|
-
if (!Array.isArray(options) || options.length < 1 || options.some((o) => typeof o !== "string" || !o.trim())) {
|
|
190
|
-
throw new ToolError("options must contain at least 1 non-empty string", 400);
|
|
191
|
-
}
|
|
192
|
-
if (new Set(options).size !== options.length)
|
|
193
|
-
throw new ToolError("options must be unique", 400);
|
|
194
|
-
const pendingPromise = registerPending(ctx.taskId, "choice", options, {
|
|
195
|
-
session_id: ctx.taskId,
|
|
196
|
-
description: question,
|
|
197
|
-
});
|
|
198
|
-
await ctx.publishEvent("_choice", {
|
|
199
|
-
event_type: "choice-request",
|
|
200
|
-
host_id: ctx.config.hostId,
|
|
201
|
-
session_id: ctx.taskId,
|
|
202
|
-
description: question,
|
|
203
|
-
options,
|
|
204
|
-
});
|
|
205
|
-
// Clients answer with the option index so no label can collide with the "aborted" sentinel.
|
|
206
|
-
const response = await pendingPromise;
|
|
207
|
-
const choice = /^\d+$/.test(response[0] ?? "") ? options[Number(response[0])] : undefined;
|
|
208
|
-
const aborted = choice === undefined;
|
|
209
|
-
recordUserResponseToRun(ctx, aborted ? "Aborted" : `**${question}**\n\n${choice}`);
|
|
210
|
-
await ctx.publishEvent("_choice", {
|
|
211
|
-
event_type: "choice-resolved",
|
|
212
|
-
host_id: ctx.config.hostId,
|
|
213
|
-
session_id: ctx.taskId,
|
|
214
|
-
status: aborted ? "aborted" : "chosen",
|
|
215
|
-
});
|
|
216
|
-
if (aborted) {
|
|
217
|
-
await ctx.abortTask();
|
|
218
|
-
return { aborted: true };
|
|
219
|
-
}
|
|
220
|
-
return { choice };
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
84
|
const fillPasswordTool = {
|
|
224
85
|
name: "fill-password",
|
|
225
86
|
description: [
|
|
@@ -247,36 +108,30 @@ const fillPasswordTool = {
|
|
|
247
108
|
const origin = normalizeOrigin(url);
|
|
248
109
|
let password = lookupPassword(origin, username);
|
|
249
110
|
if (password === undefined) {
|
|
250
|
-
const question = `Password or PIN for ${username} on ${origin}`;
|
|
251
111
|
const description = `Enter the password or PIN to sign in as ${username} on ${origin}.`;
|
|
252
|
-
const pendingPromise = registerPending(ctx.taskId, "
|
|
112
|
+
const pendingPromise = registerPending(ctx.taskId, "password", {
|
|
253
113
|
session_id: ctx.taskId,
|
|
254
114
|
description,
|
|
255
|
-
input_questions: [question],
|
|
256
|
-
secret: true,
|
|
257
115
|
});
|
|
258
|
-
await ctx.publishEvent("
|
|
259
|
-
event_type: "
|
|
116
|
+
await ctx.publishEvent("_password", {
|
|
117
|
+
event_type: "password-request",
|
|
260
118
|
host_id: ctx.config.hostId,
|
|
261
119
|
session_id: ctx.taskId,
|
|
262
120
|
description,
|
|
263
|
-
input_questions: [question],
|
|
264
|
-
secret: true,
|
|
265
121
|
});
|
|
266
122
|
const response = await pendingPromise;
|
|
267
123
|
const aborted = response.length === 1 && response[0] === "aborted";
|
|
268
|
-
await ctx.publishEvent("
|
|
269
|
-
event_type: "
|
|
124
|
+
await ctx.publishEvent("_password", {
|
|
125
|
+
event_type: "password-resolved", host_id: ctx.config.hostId,
|
|
270
126
|
session_id: ctx.taskId, status: aborted ? "aborted" : "provided",
|
|
271
127
|
});
|
|
272
|
-
// Never record the secret to the task run file
|
|
128
|
+
// Never record the secret to the task run file.
|
|
273
129
|
if (aborted) {
|
|
274
130
|
await ctx.abortTask();
|
|
275
131
|
return { aborted: true };
|
|
276
132
|
}
|
|
277
133
|
password = response[0];
|
|
278
|
-
// The PWA appends a "save"/"nosave" flag
|
|
279
|
-
// unless the user opted out via the dialog's checkbox.
|
|
134
|
+
// The PWA appends a "save"/"nosave" flag; persist unless the user opted out.
|
|
280
135
|
if (response[1] !== "nosave")
|
|
281
136
|
savePassword(origin, username, password);
|
|
282
137
|
}
|
|
@@ -779,7 +634,7 @@ const sendEmailTool = {
|
|
|
779
634
|
return result;
|
|
780
635
|
},
|
|
781
636
|
};
|
|
782
|
-
export const agentTools = [notifyTool,
|
|
637
|
+
export const agentTools = [notifyTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
|
|
783
638
|
export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
|
|
784
639
|
const deviceNotificationsResource = {
|
|
785
640
|
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
|
|
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 something from the user (answers to questions, preferences, confirmation before acting, 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, list them in your question. For passwords and PINs, use `fill-password` instead.
|
|
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
|
|
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
|
|
package/dist/agents/agent.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -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
|
|
10
|
+
/** Falls back to "finished" if no outcome marker is found. */
|
|
11
11
|
export declare function parseTaskOutcome(output: string): TaskRunningState;
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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";
|
package/dist/commands/serve.js
CHANGED
|
@@ -42,7 +42,9 @@ async function checkStaleTasks(config) {
|
|
|
42
42
|
catch {
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
|
-
|
|
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
|
}
|
|
@@ -3,15 +3,10 @@ export interface PendingRequestMeta {
|
|
|
3
3
|
session_id?: string;
|
|
4
4
|
session_name?: string;
|
|
5
5
|
description?: string;
|
|
6
|
-
input_questions?: string[];
|
|
7
|
-
/** Render the input field(s) masked (password entry). */
|
|
8
|
-
secret?: boolean;
|
|
9
6
|
}
|
|
10
7
|
export interface PendingRequest {
|
|
11
|
-
type: "confirmation" | "
|
|
8
|
+
type: "confirmation" | "password";
|
|
12
9
|
resolve: (value: string[]) => void;
|
|
13
|
-
/** Input descriptions (for 'input') or option labels (for 'choice'). */
|
|
14
|
-
params?: string[];
|
|
15
10
|
/** Display context for PWAs that connect while this request is already open. */
|
|
16
11
|
meta?: PendingRequestMeta;
|
|
17
12
|
}
|
|
@@ -19,7 +14,7 @@ export interface PendingRequest {
|
|
|
19
14
|
* Keyed by taskId. Only one pending request per key at a time. `meta` is surfaced
|
|
20
15
|
* via host.info so a freshly-connected PWA can render the modal without replaying events.
|
|
21
16
|
*/
|
|
22
|
-
export declare function registerPending(key: string, type: PendingRequest["type"],
|
|
17
|
+
export declare function registerPending(key: string, type: PendingRequest["type"], meta?: PendingRequestMeta): Promise<string[]>;
|
|
23
18
|
export declare function resolvePending(key: string, value: string[]): boolean;
|
|
24
19
|
export declare function getPending(key: string): PendingRequest | undefined;
|
|
25
20
|
export declare function removePending(key: string): void;
|
|
@@ -27,6 +22,5 @@ export declare function removePending(key: string): void;
|
|
|
27
22
|
export declare function listPending(): Array<{
|
|
28
23
|
key: string;
|
|
29
24
|
type: PendingRequest["type"];
|
|
30
|
-
params?: PendingRequest["params"];
|
|
31
25
|
meta?: PendingRequestMeta;
|
|
32
26
|
}>;
|
package/dist/pending-requests.js
CHANGED
|
@@ -3,12 +3,12 @@ const pending = new Map();
|
|
|
3
3
|
* Keyed by taskId. Only one pending request per key at a time. `meta` is surfaced
|
|
4
4
|
* via host.info so a freshly-connected PWA can render the modal without replaying events.
|
|
5
5
|
*/
|
|
6
|
-
export function registerPending(key, type,
|
|
6
|
+
export function registerPending(key, type, meta) {
|
|
7
7
|
if (pending.has(key)) {
|
|
8
8
|
return Promise.reject(new Error(`Key ${key} already has a pending request`));
|
|
9
9
|
}
|
|
10
10
|
return new Promise((resolve) => {
|
|
11
|
-
pending.set(key, { type, resolve,
|
|
11
|
+
pending.set(key, { type, resolve, meta });
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
export function resolvePending(key, value) {
|
|
@@ -30,7 +30,6 @@ export function listPending() {
|
|
|
30
30
|
return [...pending.entries()].map(([key, entry]) => ({
|
|
31
31
|
key,
|
|
32
32
|
type: entry.type,
|
|
33
|
-
params: entry.params,
|
|
34
33
|
meta: entry.meta,
|
|
35
34
|
}));
|
|
36
35
|
}
|