palmier 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CLAUDE.md +5 -1
  2. package/README.md +135 -45
  3. package/dist/agents/agent.d.ts +26 -0
  4. package/dist/agents/agent.js +32 -0
  5. package/dist/agents/claude.d.ts +8 -0
  6. package/dist/agents/claude.js +35 -0
  7. package/dist/agents/codex.d.ts +8 -0
  8. package/dist/agents/codex.js +41 -0
  9. package/dist/agents/gemini.d.ts +8 -0
  10. package/dist/agents/gemini.js +39 -0
  11. package/dist/agents/openclaw.d.ts +8 -0
  12. package/dist/agents/openclaw.js +25 -0
  13. package/dist/agents/shared-prompt.d.ts +11 -0
  14. package/dist/agents/shared-prompt.js +26 -0
  15. package/dist/commands/agents.d.ts +2 -0
  16. package/dist/commands/agents.js +19 -0
  17. package/dist/commands/info.d.ts +5 -0
  18. package/dist/commands/info.js +40 -0
  19. package/dist/commands/init.d.ts +7 -2
  20. package/dist/commands/init.js +139 -49
  21. package/dist/commands/mcpserver.d.ts +2 -0
  22. package/dist/commands/mcpserver.js +75 -0
  23. package/dist/commands/pair.d.ts +6 -0
  24. package/dist/commands/pair.js +140 -0
  25. package/dist/commands/plan-generation.md +32 -0
  26. package/dist/commands/run.d.ts +0 -1
  27. package/dist/commands/run.js +258 -114
  28. package/dist/commands/serve.d.ts +1 -1
  29. package/dist/commands/serve.js +16 -228
  30. package/dist/commands/sessions.d.ts +4 -0
  31. package/dist/commands/sessions.js +30 -0
  32. package/dist/commands/task-generation.md +1 -1
  33. package/dist/config.d.ts +5 -5
  34. package/dist/config.js +24 -6
  35. package/dist/index.js +58 -5
  36. package/dist/nats-client.d.ts +3 -3
  37. package/dist/nats-client.js +2 -2
  38. package/dist/rpc-handler.d.ts +6 -0
  39. package/dist/rpc-handler.js +367 -0
  40. package/dist/session-store.d.ts +12 -0
  41. package/dist/session-store.js +57 -0
  42. package/dist/spawn-command.d.ts +26 -0
  43. package/dist/spawn-command.js +48 -0
  44. package/dist/systemd.d.ts +2 -2
  45. package/dist/task.d.ts +45 -2
  46. package/dist/task.js +155 -14
  47. package/dist/transports/http-transport.d.ts +6 -0
  48. package/dist/transports/http-transport.js +243 -0
  49. package/dist/transports/nats-transport.d.ts +6 -0
  50. package/dist/transports/nats-transport.js +69 -0
  51. package/dist/types.d.ts +30 -13
  52. package/package.json +4 -3
  53. package/src/agents/agent.ts +62 -0
  54. package/src/agents/claude.ts +39 -0
  55. package/src/agents/codex.ts +46 -0
  56. package/src/agents/gemini.ts +43 -0
  57. package/src/agents/openclaw.ts +29 -0
  58. package/src/agents/shared-prompt.ts +26 -0
  59. package/src/commands/agents.ts +20 -0
  60. package/src/commands/info.ts +44 -0
  61. package/src/commands/init.ts +229 -121
  62. package/src/commands/mcpserver.ts +92 -0
  63. package/src/commands/pair.ts +163 -0
  64. package/src/commands/plan-generation.md +32 -0
  65. package/src/commands/run.ts +323 -129
  66. package/src/commands/serve.ts +26 -287
  67. package/src/commands/sessions.ts +32 -0
  68. package/src/config.ts +30 -10
  69. package/src/index.ts +67 -6
  70. package/src/nats-client.ts +4 -4
  71. package/src/rpc-handler.ts +421 -0
  72. package/src/session-store.ts +68 -0
  73. package/src/spawn-command.ts +78 -0
  74. package/src/systemd.ts +2 -2
  75. package/src/task.ts +166 -16
  76. package/src/transports/http-transport.ts +290 -0
  77. package/src/transports/nats-transport.ts +82 -0
  78. package/src/types.ts +36 -13
  79. package/src/commands/task-generation.md +0 -28
@@ -0,0 +1,421 @@
1
+ import { randomUUID } from "crypto";
2
+ import * as os from "os";
3
+ import { execSync, exec } from "child_process";
4
+ import { promisify } from "util";
5
+
6
+ const execAsync = promisify(exec);
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { parse as parseYaml } from "yaml";
11
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, getTaskCreatedAt, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList } from "./task.js";
12
+ import { installTaskTimer, removeTaskTimer } from "./systemd.js";
13
+ import { spawnCommand } from "./spawn-command.js";
14
+ import { getAgent } from "./agents/agent.js";
15
+ import { hasSessions, validateSession } from "./session-store.js";
16
+ import type { HostConfig, ParsedTask, RpcMessage } from "./types.js";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ const PLAN_GENERATION_PROMPT = fs.readFileSync(
21
+ path.join(__dirname, "commands", "plan-generation.md"),
22
+ "utf-8",
23
+ );
24
+
25
+ function detectLanIp(): string {
26
+ const interfaces = os.networkInterfaces();
27
+ for (const name of Object.keys(interfaces)) {
28
+ for (const iface of interfaces[name] ?? []) {
29
+ if (iface.family === "IPv4" && !iface.internal) {
30
+ return iface.address;
31
+ }
32
+ }
33
+ }
34
+ return "127.0.0.1";
35
+ }
36
+
37
+ /**
38
+ * Parse RESULT frontmatter into a metadata object.
39
+ */
40
+ function parseResultFrontmatter(raw: string): Record<string, unknown> {
41
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
42
+ if (!fmMatch) return { content: raw };
43
+
44
+ const meta: Record<string, string> = {};
45
+ const requiredPermissions: Array<{ name: string; description: string }> = [];
46
+ for (const line of fmMatch[1].split("\n")) {
47
+ const sep = line.indexOf(": ");
48
+ if (sep === -1) continue;
49
+ const key = line.slice(0, sep).trim();
50
+ const value = line.slice(sep + 2).trim();
51
+ if (key === "required_permission") {
52
+ const pipeSep = value.indexOf("|");
53
+ if (pipeSep !== -1) {
54
+ requiredPermissions.push({ name: value.slice(0, pipeSep).trim(), description: value.slice(pipeSep + 1).trim() });
55
+ } else {
56
+ requiredPermissions.push({ name: value, description: "" });
57
+ }
58
+ } else {
59
+ meta[key] = value;
60
+ }
61
+ }
62
+ const reportFiles = meta.report_files
63
+ ? meta.report_files.split(",").map((f: string) => f.trim()).filter(Boolean)
64
+ : [];
65
+
66
+ return {
67
+ content: fmMatch[2],
68
+ task_name: meta.task_name,
69
+ running_state: meta.running_state,
70
+ start_time: meta.start_time ? Number(meta.start_time) : undefined,
71
+ end_time: meta.end_time ? Number(meta.end_time) : undefined,
72
+ task_file: meta.task_file,
73
+ report_files: reportFiles.length > 0 ? reportFiles : undefined,
74
+ required_permissions: requiredPermissions.length > 0 ? requiredPermissions : undefined,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Run plan generation for a task prompt using the given agent.
80
+ * Returns the generated plan body and task name.
81
+ */
82
+ async function generatePlan(
83
+ projectRoot: string,
84
+ userPrompt: string,
85
+ agentName: string,
86
+ ): Promise<{ name: string; body: string }> {
87
+ const fullPrompt = PLAN_GENERATION_PROMPT + userPrompt;
88
+ const planAgent = getAgent(agentName);
89
+ const { command, args } = planAgent.getPlanGenerationCommandLine(fullPrompt);
90
+ console.log(`[generatePlan] Running: ${command} ${args.join(" ")}`);
91
+
92
+ const { output } = await spawnCommand(command, args, {
93
+ cwd: projectRoot,
94
+ timeout: 120_000,
95
+ });
96
+
97
+ let name = "";
98
+ const trimmed = output.trim();
99
+ let body = trimmed;
100
+ const fmMatch = trimmed.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
101
+ if (fmMatch) {
102
+ try {
103
+ const fm = parseYaml(fmMatch[1]) as { task_name?: string };
104
+ name = fm.task_name ?? "";
105
+ } catch {
106
+ // If frontmatter parsing fails, treat entire output as body
107
+ }
108
+ body = fmMatch[2].trimStart();
109
+ }
110
+ return { name, body };
111
+ }
112
+
113
+ /**
114
+ * Create a transport-agnostic RPC handler bound to the given config.
115
+ */
116
+ export function createRpcHandler(config: HostConfig) {
117
+ function flattenTask(task: ParsedTask) {
118
+ const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
119
+ return {
120
+ ...task.frontmatter,
121
+ body: task.body,
122
+ created_at: getTaskCreatedAt(taskDir),
123
+ status: readTaskStatus(taskDir),
124
+ };
125
+ }
126
+
127
+ async function handleRpc(request: RpcMessage): Promise<unknown> {
128
+ // Session token validation: if any sessions exist, require a valid token
129
+ if (hasSessions()) {
130
+ if (!request.sessionToken || !validateSession(request.sessionToken)) {
131
+ return { error: "Unauthorized" };
132
+ }
133
+ }
134
+
135
+ switch (request.method) {
136
+ case "task.list": {
137
+ const tasks = listTasks(config.projectRoot);
138
+ return {
139
+ tasks: tasks.map((task) => flattenTask(task)),
140
+ agents: config.agents ?? [],
141
+ };
142
+ }
143
+
144
+ case "task.create": {
145
+ const params = request.params as {
146
+ user_prompt: string;
147
+ agent: string;
148
+ triggers?: Array<{ type: "cron" | "once"; value: string }>;
149
+ triggers_enabled?: boolean;
150
+ requires_confirmation?: boolean;
151
+ };
152
+
153
+ // Short descriptions skip plan generation and use the description as-is
154
+ let name = "";
155
+ let body = "";
156
+ if (params.user_prompt.length < 50) {
157
+ name = params.user_prompt;
158
+ } else {
159
+ try {
160
+ const plan = await generatePlan(config.projectRoot, params.user_prompt, params.agent);
161
+ name = plan.name;
162
+ body = plan.body;
163
+ } catch (err: unknown) {
164
+ const error = err as { stdout?: string; stderr?: string };
165
+ return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
166
+ }
167
+ }
168
+
169
+ const id = randomUUID();
170
+ const taskDir = getTaskDir(config.projectRoot, id);
171
+ const task = {
172
+ frontmatter: {
173
+ id,
174
+ name,
175
+ user_prompt: params.user_prompt,
176
+ agent: params.agent,
177
+ triggers: params.triggers ?? [],
178
+ triggers_enabled: params.triggers_enabled ?? true,
179
+ requires_confirmation: params.requires_confirmation ?? true,
180
+ },
181
+ body,
182
+ };
183
+
184
+ writeTaskFile(taskDir, task);
185
+ appendTaskList(config.projectRoot, id);
186
+ if (task.frontmatter.triggers_enabled) {
187
+ installTaskTimer(config, task);
188
+ }
189
+
190
+ return flattenTask(task);
191
+ }
192
+
193
+ case "task.update": {
194
+ const params = request.params as {
195
+ id: string;
196
+ user_prompt?: string;
197
+ agent?: string;
198
+ triggers?: Array<{ type: "cron" | "once"; value: string }>;
199
+ triggers_enabled?: boolean;
200
+ requires_confirmation?: boolean;
201
+ };
202
+
203
+ const taskDir = getTaskDir(config.projectRoot, params.id);
204
+ const existing = parseTaskFile(taskDir);
205
+
206
+ // Detect whether plan needs regeneration
207
+ const promptChanged = params.user_prompt !== undefined && params.user_prompt !== existing.frontmatter.user_prompt;
208
+ const agentChanged = params.agent !== undefined && params.agent !== existing.frontmatter.agent;
209
+ const needsRegeneration = promptChanged || agentChanged || !existing.body;
210
+
211
+ // Merge updates
212
+ if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
213
+ if (params.agent !== undefined) existing.frontmatter.agent = params.agent;
214
+ if (params.triggers !== undefined) existing.frontmatter.triggers = params.triggers;
215
+ if (params.triggers_enabled !== undefined) existing.frontmatter.triggers_enabled = params.triggers_enabled;
216
+ if (params.requires_confirmation !== undefined)
217
+ existing.frontmatter.requires_confirmation = params.requires_confirmation;
218
+
219
+ // Regenerate plan if needed
220
+ if (needsRegeneration) {
221
+ if (existing.frontmatter.user_prompt.length < 50) {
222
+ existing.frontmatter.name = existing.frontmatter.user_prompt;
223
+ existing.body = "";
224
+ } else {
225
+ try {
226
+ const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
227
+ existing.frontmatter.name = plan.name;
228
+ existing.body = plan.body;
229
+ } catch (err: unknown) {
230
+ const error = err as { stdout?: string; stderr?: string };
231
+ return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
232
+ }
233
+ }
234
+ }
235
+
236
+ writeTaskFile(taskDir, existing);
237
+
238
+ // Reinstall or remove timers based on triggers_enabled
239
+ removeTaskTimer(params.id);
240
+ if (existing.frontmatter.triggers_enabled) {
241
+ installTaskTimer(config, existing);
242
+ }
243
+
244
+ return flattenTask(existing);
245
+ }
246
+
247
+ case "task.delete": {
248
+ const params = request.params as { id: string };
249
+
250
+ removeTaskTimer(params.id);
251
+ removeFromTaskList(config.projectRoot, params.id);
252
+
253
+ return { ok: true, task_id: params.id };
254
+ }
255
+
256
+ case "task.run": {
257
+ const params = request.params as { id: string };
258
+ const serviceName = `palmier-task-${params.id}.service`;
259
+
260
+ try {
261
+ await execAsync(`systemctl --user start --no-block ${serviceName}`);
262
+ return { ok: true, task_id: params.id };
263
+ } catch (err: unknown) {
264
+ const e = err as { stderr?: string; message?: string };
265
+ console.error(`task.run failed for ${params.id}: ${e.stderr || e.message}`);
266
+ return { error: `Failed to start task: ${e.stderr || e.message}` };
267
+ }
268
+ }
269
+
270
+ case "task.abort": {
271
+ const params = request.params as { id: string };
272
+ const serviceName = `palmier-task-${params.id}.service`;
273
+
274
+ try {
275
+ await execAsync(`systemctl --user stop ${serviceName}`);
276
+ return { ok: true, task_id: params.id };
277
+ } catch (err: unknown) {
278
+ const e = err as { stderr?: string; message?: string };
279
+ console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
280
+ return { error: `Failed to abort task: ${e.stderr || e.message}` };
281
+ }
282
+ }
283
+
284
+ case "task.logs": {
285
+ const params = request.params as { id: string };
286
+ const serviceName = `palmier-task-${params.id}.service`;
287
+
288
+ try {
289
+ const logs = execSync(
290
+ `journalctl --user -u ${serviceName} -n 100 --no-pager`,
291
+ { encoding: "utf-8" }
292
+ );
293
+ return { task_id: params.id, logs };
294
+ } catch (err: unknown) {
295
+ const error = err as { stdout?: string; stderr?: string };
296
+ return { task_id: params.id, logs: error.stdout || "", error: error.stderr };
297
+ }
298
+ }
299
+
300
+ case "task.status": {
301
+ const params = request.params as { id: string };
302
+ const taskDir = getTaskDir(config.projectRoot, params.id);
303
+ const status = readTaskStatus(taskDir);
304
+ if (!status) {
305
+ return { task_id: params.id, error: "No status found" };
306
+ }
307
+ return { task_id: params.id, ...status };
308
+ }
309
+
310
+ case "task.result": {
311
+ const params = request.params as { id: string; result_file: string };
312
+ if (!params.result_file) {
313
+ return { error: "result_file is required" };
314
+ }
315
+ const resultPath = path.join(config.projectRoot, "tasks", params.id, params.result_file);
316
+
317
+ try {
318
+ const raw = fs.readFileSync(resultPath, "utf-8");
319
+ const meta = parseResultFrontmatter(raw);
320
+ return { task_id: params.id, ...meta };
321
+ } catch {
322
+ return { task_id: params.id, error: "No result file found" };
323
+ }
324
+ }
325
+
326
+ case "task.reports": {
327
+ const params = request.params as { id: string; report_files: string[] };
328
+ if (!Array.isArray(params.report_files) || params.report_files.length === 0) {
329
+ return { error: "report_files must be a non-empty array" };
330
+ }
331
+ const reports: Array<{ file: string; content?: string; error?: string }> = [];
332
+ for (const file of params.report_files) {
333
+ if (!file.endsWith(".md")) {
334
+ reports.push({ file, error: "must end with .md" });
335
+ continue;
336
+ }
337
+ const basename = path.basename(file);
338
+ if (basename !== file) {
339
+ reports.push({ file, error: "must be a plain filename" });
340
+ continue;
341
+ }
342
+ const reportPath = path.join(config.projectRoot, "tasks", params.id, basename);
343
+ try {
344
+ const content = fs.readFileSync(reportPath, "utf-8");
345
+ reports.push({ file, content });
346
+ } catch {
347
+ reports.push({ file, error: "Report file not found" });
348
+ }
349
+ }
350
+ return { task_id: params.id, reports };
351
+ }
352
+
353
+ case "task.user_input": {
354
+ const params = request.params as { id: string; value: string };
355
+ const taskDir = getTaskDir(config.projectRoot, params.id);
356
+
357
+ const currentStatus = readTaskStatus(taskDir);
358
+ if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length) {
359
+ return { ok: false, error: "not pending" };
360
+ }
361
+
362
+ writeTaskStatus(taskDir, { ...currentStatus, user_input: params.value });
363
+
364
+ console.log(`[task.user_input] ${params.id} → ${params.value}`);
365
+ return { ok: true };
366
+ }
367
+
368
+ case "activity.list": {
369
+ const params = request.params as { offset?: number; limit?: number; task_id?: string };
370
+ const { entries, total } = readHistory(config.projectRoot, {
371
+ offset: params.offset ?? 0,
372
+ limit: params.limit ?? 10,
373
+ task_id: params.task_id,
374
+ });
375
+
376
+ const enriched = entries.map((entry) => {
377
+ const resultPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.result_file);
378
+ try {
379
+ const raw = fs.readFileSync(resultPath, "utf-8");
380
+ const meta = parseResultFrontmatter(raw);
381
+ // Exclude full content from list response
382
+ const { content: _, ...rest } = meta;
383
+ return { ...entry, ...rest };
384
+ } catch {
385
+ return { ...entry, error: "Result file not found" };
386
+ }
387
+ });
388
+
389
+ return { entries: enriched, total };
390
+ }
391
+
392
+ case "activity.delete": {
393
+ const params = request.params as { task_id: string; result_file: string };
394
+ if (!params.task_id || !params.result_file) {
395
+ return { error: "task_id and result_file are required" };
396
+ }
397
+ const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.result_file);
398
+ if (!deleted) {
399
+ return { error: "History entry not found" };
400
+ }
401
+ return { ok: true, task_id: params.task_id, result_file: params.result_file };
402
+ }
403
+
404
+ case "host.directInfo": {
405
+ if (config.mode === "lan" || config.mode === "auto") {
406
+ const ip = detectLanIp();
407
+ return {
408
+ directUrl: `http://${ip}:${config.directPort ?? 7400}`,
409
+ directToken: config.directToken,
410
+ };
411
+ }
412
+ return { directUrl: null, directToken: null };
413
+ }
414
+
415
+ default:
416
+ return { error: `Unknown method: ${request.method}` };
417
+ }
418
+ }
419
+
420
+ return handleRpc;
421
+ }
@@ -0,0 +1,68 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { CONFIG_DIR } from "./config.js";
5
+
6
+ const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
7
+
8
+ export interface SessionEntry {
9
+ token: string;
10
+ createdAt: string;
11
+ label?: string;
12
+ }
13
+
14
+ function readFile(): SessionEntry[] {
15
+ try {
16
+ if (!fs.existsSync(SESSIONS_FILE)) return [];
17
+ const raw = fs.readFileSync(SESSIONS_FILE, "utf-8");
18
+ return JSON.parse(raw) as SessionEntry[];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function writeFile(sessions: SessionEntry[]): void {
25
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), "utf-8");
27
+ }
28
+
29
+ export function loadSessions(): SessionEntry[] {
30
+ return readFile();
31
+ }
32
+
33
+ export function addSession(label?: string): SessionEntry {
34
+ const sessions = readFile();
35
+ const entry: SessionEntry = {
36
+ token: randomBytes(32).toString("hex"),
37
+ createdAt: new Date().toISOString(),
38
+ ...(label ? { label } : {}),
39
+ };
40
+ sessions.push(entry);
41
+ writeFile(sessions);
42
+ return entry;
43
+ }
44
+
45
+ export function revokeSession(token: string): boolean {
46
+ const sessions = readFile();
47
+ const idx = sessions.findIndex((s) => s.token === token);
48
+ if (idx === -1) return false;
49
+ sessions.splice(idx, 1);
50
+ writeFile(sessions);
51
+ return true;
52
+ }
53
+
54
+ export function revokeAllSessions(): number {
55
+ const sessions = readFile();
56
+ const count = sessions.length;
57
+ writeFile([]);
58
+ return count;
59
+ }
60
+
61
+ export function validateSession(token: string): boolean {
62
+ const sessions = readFile();
63
+ return sessions.some((s) => s.token === token);
64
+ }
65
+
66
+ export function hasSessions(): boolean {
67
+ return readFile().length > 0;
68
+ }
@@ -0,0 +1,78 @@
1
+ import { spawn } from "child_process";
2
+
3
+ export interface SpawnCommandOptions {
4
+ cwd: string;
5
+ env?: Record<string, string>;
6
+ timeout?: number;
7
+ /** Echo stdout to process.stdout (useful for journald logging). */
8
+ echoStdout?: boolean;
9
+ /** Forward SIGINT/SIGTERM to the child and resolve on stop. */
10
+ forwardSignals?: boolean;
11
+ /** Resolve with output even on non-zero exit (instead of rejecting). */
12
+ resolveOnFailure?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Spawn a command with additional arguments.
17
+ *
18
+ * Runs without a shell — the command and args are passed directly to the
19
+ * child process (no escaping needed).
20
+ *
21
+ * stdin is set to "ignore" (equivalent to < /dev/null) because tools like
22
+ * `claude -p` hang indefinitely on an open stdin pipe.
23
+ */
24
+ export interface SpawnCommandResult {
25
+ output: string;
26
+ exitCode: number | null;
27
+ }
28
+
29
+ export function spawnCommand(
30
+ command: string,
31
+ args: string[],
32
+ opts: SpawnCommandOptions,
33
+ ): Promise<SpawnCommandResult> {
34
+ return new Promise<SpawnCommandResult>((resolve, reject) => {
35
+ const child = spawn(command, args, {
36
+ cwd: opts.cwd,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
39
+ });
40
+
41
+ const chunks: Buffer[] = [];
42
+ child.stdout.on("data", (d: Buffer) => {
43
+ chunks.push(d);
44
+ if (opts.echoStdout) process.stdout.write(d);
45
+ });
46
+ child.stderr.on("data", (d: Buffer) => process.stderr.write(d));
47
+
48
+ let stopping = false;
49
+ if (opts.forwardSignals) {
50
+ const killChild = () => {
51
+ stopping = true;
52
+ child.kill("SIGTERM");
53
+ };
54
+ process.on("SIGINT", killChild);
55
+ process.on("SIGTERM", killChild);
56
+ }
57
+
58
+ let timer: ReturnType<typeof setTimeout> | undefined;
59
+ if (opts.timeout) {
60
+ timer = setTimeout(() => {
61
+ child.kill();
62
+ reject(new Error("command timed out"));
63
+ }, opts.timeout);
64
+ }
65
+
66
+ child.on("close", (code) => {
67
+ if (timer) clearTimeout(timer);
68
+ const output = Buffer.concat(chunks).toString("utf-8");
69
+ if (code === 0 || stopping || opts.resolveOnFailure) resolve({ output, exitCode: code });
70
+ else reject(new Error(`process exited with code ${code}`));
71
+ });
72
+
73
+ child.on("error", (err) => {
74
+ if (timer) clearTimeout(timer);
75
+ reject(err);
76
+ });
77
+ });
78
+ }
package/src/systemd.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { homedir } from "os";
4
4
  import { execSync } from "child_process";
5
- import type { AgentConfig } from "./types.js";
5
+ import type { HostConfig } from "./types.js";
6
6
  import type { ParsedTask } from "./types.js";
7
7
 
8
8
  const UNIT_DIR = path.join(homedir(), ".config", "systemd", "user");
@@ -62,7 +62,7 @@ export function cronToOnCalendar(cron: string): string {
62
62
  /**
63
63
  * Install a systemd user timer + service for a task.
64
64
  */
65
- export function installTaskTimer(config: AgentConfig, task: ParsedTask): void {
65
+ export function installTaskTimer(config: HostConfig, task: ParsedTask): void {
66
66
  fs.mkdirSync(UNIT_DIR, { recursive: true });
67
67
 
68
68
  const taskId = task.frontmatter.id;