palmier 0.8.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -0
- package/README.md +11 -11
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/app-registry.d.ts +10 -0
- package/dist/app-registry.js +44 -0
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +1 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +33 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +14 -18
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +1 -4
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/index-B0F9mtid.css +1 -0
- package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
- package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
- package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.d.ts +0 -6
- package/dist/rpc-handler.js +19 -48
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +6 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/App.css +170 -20
- package/palmier-server/pwa/src/App.tsx +15 -1
- package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
- package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
- package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +66 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
- package/palmier-server/pwa/src/types.ts +1 -1
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +47 -6
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/app-registry.ts +52 -0
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +1 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +31 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +4 -3
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +14 -20
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +1 -4
- package/src/platform/linux.ts +9 -20
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +20 -48
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +6 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
- package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
package/src/commands/run.ts
CHANGED
|
@@ -13,10 +13,6 @@ import { publishHostEvent } from "../events.js";
|
|
|
13
13
|
import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
|
|
14
14
|
import type { NatsConnection } from "nats";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
* Shared context for agent invocation retry loops.
|
|
18
|
-
* Passed around to avoid threading many individual parameters.
|
|
19
|
-
*/
|
|
20
16
|
interface InvocationContext {
|
|
21
17
|
agent: AgentTool;
|
|
22
18
|
task: ParsedTask;
|
|
@@ -35,11 +31,9 @@ interface InvocationResult {
|
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
/**
|
|
38
|
-
* Invoke the agent CLI
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* The `invokeTask` is the ParsedTask whose prompt is passed to the agent
|
|
42
|
-
* (for command-triggered mode this is the per-line augmented task).
|
|
34
|
+
* Invoke the agent CLI in a continuation loop to handle permission requests.
|
|
35
|
+
* `invokeTask` is the ParsedTask whose prompt is passed to the agent (in
|
|
36
|
+
* command-triggered mode this is the per-line augmented task).
|
|
43
37
|
*/
|
|
44
38
|
async function invokeAgentWithRetries(
|
|
45
39
|
ctx: InvocationContext,
|
|
@@ -47,7 +41,6 @@ async function invokeAgentWithRetries(
|
|
|
47
41
|
): Promise<InvocationResult> {
|
|
48
42
|
// eslint-disable-next-line no-constant-condition
|
|
49
43
|
while (true) {
|
|
50
|
-
// Stream agent output to TASKRUN.md in real-time, throttled to 500ms
|
|
51
44
|
const writer = beginStreamingMessage(ctx.taskDir, ctx.runId, Date.now());
|
|
52
45
|
let lineBuf = "";
|
|
53
46
|
let notifyPending = false;
|
|
@@ -89,12 +82,10 @@ async function invokeAgentWithRetries(
|
|
|
89
82
|
const reportFiles = parseReportFiles(result.output);
|
|
90
83
|
const requiredPermissions = parsePermissions(result.output);
|
|
91
84
|
|
|
92
|
-
// Flush remaining buffered content
|
|
93
85
|
if (lineBuf && !lineBuf.startsWith("[PALMIER")) {
|
|
94
86
|
writer.write(lineBuf);
|
|
95
87
|
}
|
|
96
88
|
|
|
97
|
-
// Include permission requests in the assistant message
|
|
98
89
|
if (requiredPermissions.length > 0) {
|
|
99
90
|
const permLines = requiredPermissions.map((p) => `- **${p.name}** ${p.description}`).join("\n");
|
|
100
91
|
writer.write(`\n\n**Permissions requested:**\n${permLines}\n`);
|
|
@@ -112,7 +103,6 @@ async function invokeAgentWithRetries(
|
|
|
112
103
|
});
|
|
113
104
|
}
|
|
114
105
|
|
|
115
|
-
// Permission handling — agent requested permissions
|
|
116
106
|
if (requiredPermissions.length > 0) {
|
|
117
107
|
const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
|
|
118
108
|
|
|
@@ -146,27 +136,20 @@ async function invokeAgentWithRetries(
|
|
|
146
136
|
ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
|
|
147
137
|
}
|
|
148
138
|
|
|
149
|
-
//
|
|
139
|
+
// Retry with the new permissions if the agent failed.
|
|
150
140
|
if (outcome === "failed") {
|
|
151
141
|
continue;
|
|
152
142
|
}
|
|
153
143
|
}
|
|
154
144
|
|
|
155
|
-
// Normal completion (success or terminal failure)
|
|
156
145
|
return { outcome };
|
|
157
146
|
}
|
|
158
147
|
}
|
|
159
148
|
|
|
160
|
-
/**
|
|
161
|
-
* Strip [PALMIER_*] marker lines from agent output.
|
|
162
|
-
*/
|
|
163
149
|
export function stripPalmierMarkers(output: string): string {
|
|
164
150
|
return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
|
|
165
151
|
}
|
|
166
152
|
|
|
167
|
-
/**
|
|
168
|
-
* Append a conversation message to the RESULT file and notify connected clients.
|
|
169
|
-
*/
|
|
170
153
|
async function appendAndNotify(
|
|
171
154
|
ctx: InvocationContext,
|
|
172
155
|
msg: Parameters<typeof appendRunMessage>[2],
|
|
@@ -175,9 +158,7 @@ async function appendAndNotify(
|
|
|
175
158
|
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
176
159
|
}
|
|
177
160
|
|
|
178
|
-
/**
|
|
179
|
-
* Find the latest run dir that has no status messages yet (just created by the RPC handler).
|
|
180
|
-
*/
|
|
161
|
+
/** The latest run dir with no status messages yet — freshly created by the RPC handler. */
|
|
181
162
|
function findLatestPendingRunId(taskDir: string): string | null {
|
|
182
163
|
const dirs = fs.readdirSync(taskDir)
|
|
183
164
|
.filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
|
|
@@ -190,8 +171,8 @@ function findLatestPendingRunId(taskDir: string): string | null {
|
|
|
190
171
|
}
|
|
191
172
|
|
|
192
173
|
/**
|
|
193
|
-
* If the RPC handler already wrote "aborted"
|
|
194
|
-
*
|
|
174
|
+
* If the RPC handler already wrote "aborted" (via task.abort), respect that
|
|
175
|
+
* instead of overwriting with the process's own outcome.
|
|
195
176
|
*/
|
|
196
177
|
function resolveOutcome(taskDir: string, outcome: TaskRunningState): TaskRunningState {
|
|
197
178
|
const current = readTaskStatus(taskDir);
|
|
@@ -199,9 +180,6 @@ function resolveOutcome(taskDir: string, outcome: TaskRunningState): TaskRunning
|
|
|
199
180
|
return outcome;
|
|
200
181
|
}
|
|
201
182
|
|
|
202
|
-
/**
|
|
203
|
-
* Execute a task by ID.
|
|
204
|
-
*/
|
|
205
183
|
export async function runCommand(taskId: string): Promise<void> {
|
|
206
184
|
const config = loadConfig();
|
|
207
185
|
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
@@ -211,7 +189,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
211
189
|
let nc: NatsConnection | undefined;
|
|
212
190
|
const taskName = task.frontmatter.name;
|
|
213
191
|
|
|
214
|
-
// Use existing run dir if just created by RPC, otherwise create a new one
|
|
215
192
|
const existingRunId = findLatestPendingRunId(taskDir);
|
|
216
193
|
const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now(), task.frontmatter.agent);
|
|
217
194
|
if (!existingRunId) {
|
|
@@ -231,7 +208,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
231
208
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
|
|
232
209
|
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
|
|
233
210
|
|
|
234
|
-
// If requires_confirmation, notify clients and wait
|
|
235
211
|
if (task.frontmatter.requires_confirmation) {
|
|
236
212
|
const confirmed = await requestConfirmation(config, task, taskDir);
|
|
237
213
|
const confirmPrompt = `**Task Confirmation**\n\nRun task "${taskName || task.frontmatter.user_prompt}"?`;
|
|
@@ -252,7 +228,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
252
228
|
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
|
|
253
229
|
}
|
|
254
230
|
|
|
255
|
-
// Shared invocation context
|
|
256
231
|
const guiEnv = getPlatform().getGuiEnv();
|
|
257
232
|
const agent = getAgent(task.frontmatter.agent);
|
|
258
233
|
const ctx: InvocationContext = {
|
|
@@ -261,7 +236,6 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
261
236
|
};
|
|
262
237
|
|
|
263
238
|
if (task.frontmatter.command) {
|
|
264
|
-
// Command-triggered mode
|
|
265
239
|
const result = await runCommandTriggeredMode(ctx);
|
|
266
240
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
267
241
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
@@ -269,14 +243,12 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
269
243
|
console.log(`Task ${taskId} completed (command-triggered).`);
|
|
270
244
|
} else if (task.frontmatter.schedule_type === "on_new_notification"
|
|
271
245
|
|| task.frontmatter.schedule_type === "on_new_sms") {
|
|
272
|
-
// Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
|
|
273
246
|
const result = await runEventTriggeredMode(ctx);
|
|
274
247
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
275
248
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
276
249
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
277
250
|
console.log(`Task ${taskId} completed (event-triggered).`);
|
|
278
251
|
} else {
|
|
279
|
-
// Standard execution — add user prompt as first message
|
|
280
252
|
await appendAndNotify(ctx, {
|
|
281
253
|
role: "user",
|
|
282
254
|
time: Date.now(),
|
|
@@ -312,11 +284,9 @@ const MAX_LOG_ENTRIES = 1000;
|
|
|
312
284
|
const MAX_LINE_LENGTH = 200_000;
|
|
313
285
|
|
|
314
286
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* invokes the agent CLI with the user's prompt augmented by that line.
|
|
319
|
-
* Processes lines sequentially with a bounded queue.
|
|
287
|
+
* Spawn a long-running shell command and invoke the agent CLI once per stdout
|
|
288
|
+
* line, with the user's prompt augmented by that line. Sequential with a
|
|
289
|
+
* bounded queue.
|
|
320
290
|
*/
|
|
321
291
|
async function runCommandTriggeredMode(
|
|
322
292
|
ctx: InvocationContext,
|
|
@@ -346,7 +316,6 @@ async function runCommandTriggeredMode(
|
|
|
346
316
|
const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
|
|
347
317
|
fs.appendFileSync(logPath, entry, "utf-8");
|
|
348
318
|
|
|
349
|
-
// Trim log if too large (keep last MAX_LOG_ENTRIES entries)
|
|
350
319
|
try {
|
|
351
320
|
const content = fs.readFileSync(logPath, "utf-8");
|
|
352
321
|
const entries = content.split("\n---\n").filter(Boolean);
|
|
@@ -380,7 +349,7 @@ async function runCommandTriggeredMode(
|
|
|
380
349
|
}
|
|
381
350
|
appendLog(line, "", result.outcome);
|
|
382
351
|
|
|
383
|
-
//
|
|
352
|
+
// Signal "waiting for more input" in the UI.
|
|
384
353
|
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
385
354
|
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
386
355
|
}
|
|
@@ -422,7 +391,6 @@ async function runCommandTriggeredMode(
|
|
|
422
391
|
process.stderr.write(d);
|
|
423
392
|
});
|
|
424
393
|
|
|
425
|
-
// Wait for command to exit
|
|
426
394
|
const exitCode = await new Promise<number | null>((resolve) => {
|
|
427
395
|
child.on("close", (code: number | null) => {
|
|
428
396
|
commandExited = true;
|
|
@@ -438,7 +406,6 @@ async function runCommandTriggeredMode(
|
|
|
438
406
|
});
|
|
439
407
|
});
|
|
440
408
|
|
|
441
|
-
// Wait for any remaining queued lines to finish processing
|
|
442
409
|
if (lineQueue.length > 0 || processing) {
|
|
443
410
|
await new Promise<void>((resolve) => {
|
|
444
411
|
resolveWhenDone = resolve;
|
|
@@ -464,13 +431,10 @@ async function runCommandTriggeredMode(
|
|
|
464
431
|
}
|
|
465
432
|
|
|
466
433
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* into the user prompt. The run process itself holds no NATS subscription;
|
|
472
|
-
* the daemon handles that and atomically clears the active flag when we see
|
|
473
|
-
* an empty pop, so it can fire up a fresh run on the next incoming event.
|
|
434
|
+
* Drain the daemon-owned per-task event queue via /task-event/pop, invoking
|
|
435
|
+
* the agent once per event. The run process holds no NATS subscription — the
|
|
436
|
+
* daemon owns that and atomically clears the active flag on empty pop so it
|
|
437
|
+
* can fire a fresh run on the next incoming event.
|
|
474
438
|
*/
|
|
475
439
|
async function runEventTriggeredMode(
|
|
476
440
|
ctx: InvocationContext,
|
|
@@ -590,10 +554,6 @@ async function requestConfirmation(
|
|
|
590
554
|
return confirmed;
|
|
591
555
|
}
|
|
592
556
|
|
|
593
|
-
/**
|
|
594
|
-
* Extract report file names from agent output.
|
|
595
|
-
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
596
|
-
*/
|
|
597
557
|
const ALLOWED_REPORT_EXT = [".md", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
598
558
|
|
|
599
559
|
export function parseReportFiles(output: string): string[] {
|
|
@@ -602,7 +562,7 @@ export function parseReportFiles(output: string): string[] {
|
|
|
602
562
|
let match;
|
|
603
563
|
while ((match = regex.exec(output)) !== null) {
|
|
604
564
|
const name = match[1].trim();
|
|
605
|
-
// Skip placeholder examples echoed from the prompt (e.g. "<filename>")
|
|
565
|
+
// Skip placeholder examples echoed from the prompt (e.g. "<filename>").
|
|
606
566
|
if (!name || name.startsWith("<")) continue;
|
|
607
567
|
const ext = name.lastIndexOf(".") >= 0 ? name.slice(name.lastIndexOf(".")).toLowerCase() : "";
|
|
608
568
|
if (!ALLOWED_REPORT_EXT.includes(ext)) continue;
|
|
@@ -611,17 +571,13 @@ export function parseReportFiles(output: string): string[] {
|
|
|
611
571
|
return files;
|
|
612
572
|
}
|
|
613
573
|
|
|
614
|
-
/**
|
|
615
|
-
* Extract required permissions from agent output.
|
|
616
|
-
* Looks for lines matching: [PALMIER_PERMISSION] <tool> | <description>
|
|
617
|
-
*/
|
|
618
574
|
export function parsePermissions(output: string): RequiredPermission[] {
|
|
619
575
|
const regex = new RegExp(`^\\${TASK_PERMISSION_PREFIX}\\s+(.+)$`, "gm");
|
|
620
576
|
const perms: RequiredPermission[] = [];
|
|
621
577
|
let match;
|
|
622
578
|
while ((match = regex.exec(output)) !== null) {
|
|
623
579
|
const raw = match[1].trim();
|
|
624
|
-
// Skip placeholder examples echoed from the prompt (e.g. "<tool_name> | <description>")
|
|
580
|
+
// Skip placeholder examples echoed from the prompt (e.g. "<tool_name> | <description>").
|
|
625
581
|
if (raw.startsWith("<")) continue;
|
|
626
582
|
const sep = raw.indexOf("|");
|
|
627
583
|
if (sep !== -1) {
|
|
@@ -633,10 +589,7 @@ export function parsePermissions(output: string): RequiredPermission[] {
|
|
|
633
589
|
return perms;
|
|
634
590
|
}
|
|
635
591
|
|
|
636
|
-
/**
|
|
637
|
-
* Parse the agent's output for success/failure markers.
|
|
638
|
-
* Falls back to "finished" if no marker is found.
|
|
639
|
-
*/
|
|
592
|
+
/** Falls back to "finished" if no success/failure marker is found. */
|
|
640
593
|
export function parseTaskOutcome(output: string): TaskRunningState {
|
|
641
594
|
const lastChunk = output.slice(-500);
|
|
642
595
|
const regex = new RegExp(`^\\${TASK_FAILURE_MARKER}$|^\\${TASK_SUCCESS_MARKER}$`, "gm");
|
package/src/commands/serve.ts
CHANGED
|
@@ -16,16 +16,14 @@ import { StringCodec, type NatsConnection } from "nats";
|
|
|
16
16
|
import { addNotification } from "../notification-store.js";
|
|
17
17
|
import { addSmsMessage } from "../sms-store.js";
|
|
18
18
|
import { enqueueEvent } from "../event-queues.js";
|
|
19
|
+
import { recordApp } from "../app-registry.js";
|
|
19
20
|
|
|
20
21
|
const POLL_INTERVAL_MS = 30_000;
|
|
21
22
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Since run.ts creates the RESULT file and history entry at start, we just need to
|
|
28
|
-
* finalize the existing RESULT file, append a failed status entry, and broadcast.
|
|
25
|
+
* Reconcile tasks stuck in "started" whose process is no longer alive.
|
|
26
|
+
* The system scheduler (Task Scheduler / systemd) is the authoritative source.
|
|
29
27
|
*/
|
|
30
28
|
async function checkStaleTasks(
|
|
31
29
|
config: HostConfig,
|
|
@@ -46,14 +44,12 @@ async function checkStaleTasks(
|
|
|
46
44
|
const status = readTaskStatus(taskDir);
|
|
47
45
|
if (!status || status.running_state !== "started") continue;
|
|
48
46
|
|
|
49
|
-
// Ask the system scheduler if the task is still running
|
|
50
47
|
if (platform.isTaskRunning(taskId)) continue;
|
|
51
48
|
|
|
52
49
|
console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
|
|
53
50
|
const endTime = Date.now();
|
|
54
51
|
writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
|
|
55
52
|
|
|
56
|
-
// Find the latest run directory (created by run.ts at start)
|
|
57
53
|
const runId = fs.readdirSync(taskDir)
|
|
58
54
|
.filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
|
|
59
55
|
.sort()
|
|
@@ -71,7 +67,7 @@ async function checkStaleTasks(
|
|
|
71
67
|
let taskName = taskId;
|
|
72
68
|
try {
|
|
73
69
|
taskName = parseTaskFile(taskDir).frontmatter.name || taskId;
|
|
74
|
-
} catch { /*
|
|
70
|
+
} catch { /* fallback to taskId */ }
|
|
75
71
|
|
|
76
72
|
await publishHostEvent(nc, config.hostId, taskId, {
|
|
77
73
|
event_type: "running-state",
|
|
@@ -81,18 +77,14 @@ async function checkStaleTasks(
|
|
|
81
77
|
}
|
|
82
78
|
}
|
|
83
79
|
|
|
84
|
-
/**
|
|
85
|
-
* Start the persistent RPC handler (NATS + HTTP).
|
|
86
|
-
*/
|
|
87
80
|
export async function serveCommand(): Promise<void> {
|
|
88
81
|
const config = loadConfig();
|
|
89
82
|
|
|
90
|
-
//
|
|
83
|
+
// PID file lets `palmier restart` find us regardless of how we were started
|
|
91
84
|
fs.writeFileSync(DAEMON_PID_FILE, String(process.pid), "utf-8");
|
|
92
85
|
|
|
93
86
|
console.log("Starting...");
|
|
94
87
|
|
|
95
|
-
// Re-detect agents on every daemon start
|
|
96
88
|
const agents = await detectAgents();
|
|
97
89
|
config.agents = agents;
|
|
98
90
|
saveConfig(config);
|
|
@@ -106,10 +98,9 @@ export async function serveCommand(): Promise<void> {
|
|
|
106
98
|
console.warn(`[nats] Connection failed (server mode unavailable): ${err}`);
|
|
107
99
|
}
|
|
108
100
|
|
|
109
|
-
// Reconcile any tasks stuck from before daemon started
|
|
110
101
|
await checkStaleTasks(config, nc);
|
|
111
102
|
|
|
112
|
-
//
|
|
103
|
+
// Reinstall scheduler entries for all tasks (recovery after init/reinstall)
|
|
113
104
|
const platform = getPlatform();
|
|
114
105
|
const allTasks = listTasks(config.projectRoot);
|
|
115
106
|
for (const task of allTasks) {
|
|
@@ -120,7 +111,6 @@ export async function serveCommand(): Promise<void> {
|
|
|
120
111
|
}
|
|
121
112
|
}
|
|
122
113
|
|
|
123
|
-
// Poll for crashed tasks every 30 seconds
|
|
124
114
|
setInterval(() => {
|
|
125
115
|
checkStaleTasks(config, nc).catch((err) => {
|
|
126
116
|
console.error("[monitor] Error checking stale tasks:", err);
|
|
@@ -130,18 +120,29 @@ export async function serveCommand(): Promise<void> {
|
|
|
130
120
|
const handleRpc = createRpcHandler(config, nc);
|
|
131
121
|
const httpPort = config.httpPort ?? 7256;
|
|
132
122
|
|
|
133
|
-
// Start NATS transport (loops forever, fire-and-forget)
|
|
134
123
|
if (nc) {
|
|
135
124
|
startNatsTransport(config, handleRpc, nc);
|
|
136
125
|
|
|
137
|
-
// Subscribe to device notifications and SMS from Android
|
|
138
126
|
const sc = StringCodec();
|
|
139
127
|
|
|
140
|
-
//
|
|
141
|
-
function
|
|
128
|
+
// Match phone numbers regardless of formatting; letters preserved for shortcodes.
|
|
129
|
+
function normalizeSender(raw: string): string {
|
|
130
|
+
return raw.replace(/[\s\-()+]/g, "").toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string, parsed?: unknown): void {
|
|
142
134
|
for (const task of listTasks(config.projectRoot)) {
|
|
143
135
|
if (task.frontmatter.schedule_type !== scheduleType) continue;
|
|
144
136
|
if (!task.frontmatter.schedule_enabled) continue;
|
|
137
|
+
if (scheduleType === "on_new_notification" && task.frontmatter.schedule_values && task.frontmatter.schedule_values.length > 0) {
|
|
138
|
+
const pkg = (parsed as { packageName?: string } | undefined)?.packageName;
|
|
139
|
+
if (!pkg || !task.frontmatter.schedule_values.includes(pkg)) continue;
|
|
140
|
+
}
|
|
141
|
+
if (scheduleType === "on_new_sms" && task.frontmatter.schedule_values && task.frontmatter.schedule_values.length > 0) {
|
|
142
|
+
const sender = (parsed as { sender?: string } | undefined)?.sender;
|
|
143
|
+
const normalizedSender = sender ? normalizeSender(sender) : "";
|
|
144
|
+
if (!normalizedSender || !task.frontmatter.schedule_values.some((s) => normalizeSender(s) === normalizedSender)) continue;
|
|
145
|
+
}
|
|
145
146
|
const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
|
|
146
147
|
if (shouldStart) {
|
|
147
148
|
platform.startTask(task.frontmatter.id).catch((err) => {
|
|
@@ -155,13 +156,16 @@ export async function serveCommand(): Promise<void> {
|
|
|
155
156
|
(async () => {
|
|
156
157
|
for await (const msg of notifSub) {
|
|
157
158
|
const raw = sc.decode(msg.data);
|
|
159
|
+
let parsed: unknown;
|
|
158
160
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
parsed = JSON.parse(raw);
|
|
162
|
+
const data = parsed as { packageName?: string; appName?: string };
|
|
163
|
+
addNotification({ ...(parsed as object), receivedAt: Date.now() } as Parameters<typeof addNotification>[0]);
|
|
164
|
+
if (data.packageName && data.appName) recordApp(data.packageName, data.appName);
|
|
161
165
|
} catch (err) {
|
|
162
166
|
console.error("[nats] Failed to parse device notification:", err);
|
|
163
167
|
}
|
|
164
|
-
dispatchDeviceEvent("on_new_notification", raw);
|
|
168
|
+
dispatchDeviceEvent("on_new_notification", raw, parsed);
|
|
165
169
|
}
|
|
166
170
|
})();
|
|
167
171
|
|
|
@@ -169,17 +173,17 @@ export async function serveCommand(): Promise<void> {
|
|
|
169
173
|
(async () => {
|
|
170
174
|
for await (const msg of smsSub) {
|
|
171
175
|
const raw = sc.decode(msg.data);
|
|
176
|
+
let parsed: unknown;
|
|
172
177
|
try {
|
|
173
|
-
|
|
174
|
-
addSmsMessage({ ...
|
|
178
|
+
parsed = JSON.parse(raw);
|
|
179
|
+
addSmsMessage({ ...(parsed as object), receivedAt: Date.now() } as Parameters<typeof addSmsMessage>[0]);
|
|
175
180
|
} catch (err) {
|
|
176
181
|
console.error("[nats] Failed to parse device SMS:", err);
|
|
177
182
|
}
|
|
178
|
-
dispatchDeviceEvent("on_new_sms", raw);
|
|
183
|
+
dispatchDeviceEvent("on_new_sms", raw, parsed);
|
|
179
184
|
}
|
|
180
185
|
})();
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
// Start HTTP transport (loops forever)
|
|
184
188
|
await startHttpTransport(config, handleRpc, httpPort, nc);
|
|
185
189
|
}
|
package/src/config.ts
CHANGED
|
@@ -6,10 +6,6 @@ import type { HostConfig } from "./types.js";
|
|
|
6
6
|
const CONFIG_DIR = path.join(homedir(), ".config", "palmier");
|
|
7
7
|
const CONFIG_FILE = path.join(CONFIG_DIR, "host.json");
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Load host configuration from ~/.config/palmier/host.json.
|
|
11
|
-
* Throws if the file is missing or invalid.
|
|
12
|
-
*/
|
|
13
9
|
export function loadConfig(): HostConfig {
|
|
14
10
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
15
11
|
throw new Error(
|
|
@@ -32,10 +28,6 @@ export function loadConfig(): HostConfig {
|
|
|
32
28
|
return config;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
/**
|
|
36
|
-
* Persist host configuration to ~/.config/palmier/host.json.
|
|
37
|
-
* Creates parent directories if needed.
|
|
38
|
-
*/
|
|
39
31
|
export function saveConfig(config: HostConfig): void {
|
|
40
32
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
41
33
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
@@ -12,12 +12,13 @@ export interface RegisteredDevice {
|
|
|
12
12
|
export type DeviceCapability =
|
|
13
13
|
| "location"
|
|
14
14
|
| "notifications"
|
|
15
|
-
| "sms"
|
|
15
|
+
| "sms-read"
|
|
16
|
+
| "sms-send"
|
|
16
17
|
| "contacts"
|
|
17
18
|
| "calendar"
|
|
18
|
-
| "
|
|
19
|
+
| "alarm"
|
|
19
20
|
| "battery"
|
|
20
|
-
| "email"
|
|
21
|
+
| "send-email"
|
|
21
22
|
| "dnd";
|
|
22
23
|
|
|
23
24
|
type CapabilityMap = Partial<Record<DeviceCapability, RegisteredDevice>>;
|
package/src/event-queues.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-task in-memory event queues for
|
|
3
|
-
* (schedule_type: "on_new_notification" | "on_new_sms").
|
|
4
|
-
*
|
|
2
|
+
* Per-task in-memory event queues for on_new_notification / on_new_sms schedules.
|
|
5
3
|
* The daemon owns the NATS subscription and populates these queues; the
|
|
6
|
-
* `palmier run` process drains
|
|
7
|
-
* endpoint. `activeRuns` tracks whether a run process is currently draining,
|
|
8
|
-
* so we don't race a fresh startTask with a teardown-phase run.
|
|
4
|
+
* `palmier run` process drains via /task-event/pop.
|
|
9
5
|
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - enqueueEvent returns shouldStart=true only if the task transitioned
|
|
15
|
-
* from idle (no active run) to active — callers must then startTask.
|
|
6
|
+
* Invariants:
|
|
7
|
+
* - popEvent clears activeRuns atomically when the queue empties, so a
|
|
8
|
+
* fresh startTask cannot race the tearing-down run.
|
|
9
|
+
* - enqueueEvent returns shouldStart=true only on the idle→active edge.
|
|
16
10
|
*/
|
|
17
11
|
|
|
18
12
|
const MAX_QUEUE_SIZE = 100;
|
|
@@ -20,10 +14,6 @@ const MAX_QUEUE_SIZE = 100;
|
|
|
20
14
|
const queues = new Map<string, string[]>();
|
|
21
15
|
const activeRuns = new Set<string>();
|
|
22
16
|
|
|
23
|
-
/**
|
|
24
|
-
* Queue a raw (JSON-string) event payload for a task. Returns whether the
|
|
25
|
-
* caller should now start the run process.
|
|
26
|
-
*/
|
|
27
17
|
export function enqueueEvent(taskId: string, payload: string): { shouldStart: boolean } {
|
|
28
18
|
const queue = queues.get(taskId) ?? [];
|
|
29
19
|
if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
|
|
@@ -35,11 +25,6 @@ export function enqueueEvent(taskId: string, payload: string): { shouldStart: bo
|
|
|
35
25
|
return { shouldStart: true };
|
|
36
26
|
}
|
|
37
27
|
|
|
38
|
-
/**
|
|
39
|
-
* Pop the oldest queued event for a task. Returns `{ event }` when one is
|
|
40
|
-
* available (keeps the task marked active), or `{ empty: true }` after
|
|
41
|
-
* clearing the active flag atomically.
|
|
42
|
-
*/
|
|
43
28
|
export function popEvent(taskId: string): { event: string } | { empty: true } {
|
|
44
29
|
const queue = queues.get(taskId);
|
|
45
30
|
if (queue && queue.length > 0) {
|
package/src/events.ts
CHANGED
|
@@ -3,12 +3,6 @@ import { loadConfig } from "./config.js";
|
|
|
3
3
|
|
|
4
4
|
const sc = StringCodec();
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Broadcast an event to connected clients via NATS and HTTP SSE.
|
|
8
|
-
*
|
|
9
|
-
* - NATS: publishes to `host-event.{hostId}.{taskId}`
|
|
10
|
-
* - HTTP: POSTs to the serve daemon's `/event` endpoint
|
|
11
|
-
*/
|
|
12
6
|
export async function publishHostEvent(
|
|
13
7
|
nc: NatsConnection | undefined,
|
|
14
8
|
hostId: string,
|
|
@@ -31,7 +25,5 @@ export async function publishHostEvent(
|
|
|
31
25
|
body: JSON.stringify({ task_id: taskId, ...payload }),
|
|
32
26
|
});
|
|
33
27
|
console.log(`[http] host-event: ${taskId} →`, payload);
|
|
34
|
-
} catch {
|
|
35
|
-
// Serve HTTP may not be ready yet — ignore
|
|
36
|
-
}
|
|
28
|
+
} catch { /* serve HTTP may not be ready yet */ }
|
|
37
29
|
}
|
package/src/index.ts
CHANGED
package/src/mcp-handler.ts
CHANGED
|
@@ -15,14 +15,13 @@ export interface McpResponse {
|
|
|
15
15
|
stream?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** sessionId → subscribed resource URIs */
|
|
19
19
|
const resourceSubscriptions = new Map<string, Set<string>>();
|
|
20
20
|
|
|
21
21
|
export function getResourceSubscriptions(): Map<string, Set<string>> {
|
|
22
22
|
return resourceSubscriptions;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// Session-to-agent name map with 24h TTL
|
|
26
25
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
26
|
const sessionAgents = new Map<string, { agentName: string; expiresAt: number }>();
|
|
28
27
|
|