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
@@ -1,14 +1,37 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { spawn } from "child_process";
3
+ import { spawnCommand } from "../spawn-command.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { connectNats } from "../nats-client.js";
6
- import { parseTaskFile, getTaskDir } from "../task.js";
7
- import type { AgentConfig, ParsedTask, ConfirmPayload } from "../types.js";
8
- import type { NatsConnection, KV } from "nats";
6
+ import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory } from "../task.js";
7
+ import { getAgent } from "../agents/agent.js";
8
+ import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
9
+ import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
10
+ import type { NatsConnection } from "nats";
9
11
  import { StringCodec } from "nats";
10
12
 
11
- export type TaskEventType = "start" | "finish" | "abort" | "fail";
13
+ /**
14
+ * Write a time-stamped RESULT file with frontmatter.
15
+ * Always generated, even for abort/fail.
16
+ */
17
+ function writeResult(
18
+ taskDir: string,
19
+ taskName: string,
20
+ taskSnapshotName: string,
21
+ runningState: string,
22
+ startTime: number,
23
+ endTime: number,
24
+ output: string,
25
+ reportFiles: string[],
26
+ requiredPermissions: RequiredPermission[],
27
+ ): string {
28
+ const resultFileName = `RESULT-${endTime}.md`;
29
+ const reportLine = reportFiles.length > 0 ? `\nreport_files: ${reportFiles.join(", ")}` : "";
30
+ const permLines = requiredPermissions.map((p) => `\nrequired_permission: ${p.name} | ${p.description}`).join("");
31
+ const content = `---\ntask_name: ${taskName}\nrunning_state: ${runningState}\nstart_time: ${startTime}\nend_time: ${endTime}\ntask_file: ${taskSnapshotName}${reportLine}${permLines}\n---\n${output}`;
32
+ fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
33
+ return resultFileName;
34
+ }
12
35
 
13
36
  /**
14
37
  * Execute a task by ID.
@@ -17,17 +40,21 @@ export async function runCommand(taskId: string): Promise<void> {
17
40
  const config = loadConfig();
18
41
  const taskDir = getTaskDir(config.projectRoot, taskId);
19
42
  const task = parseTaskFile(taskDir);
43
+ const mode = config.mode ?? "nats";
44
+ const useNats = mode === "nats" || mode === "auto";
45
+ const useHttp = mode === "lan" || mode === "auto";
20
46
 
21
- console.log(`Running task: ${taskId}`);
47
+ console.log(`Running task: ${taskId} (mode: ${mode})`);
22
48
 
23
49
  let nc: NatsConnection | undefined;
24
- let confirmKv: KV | undefined;
25
- const confirmKey = `${config.agentId}.${taskId}`;
50
+ const startTime = Date.now();
51
+ const taskName = task.frontmatter.name;
52
+
53
+ // Snapshot the task file at run time
54
+ const taskSnapshotName = `TASK-${startTime}.md`;
55
+ fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
26
56
 
27
57
  const cleanup = async () => {
28
- if (confirmKv) {
29
- try { await confirmKv.delete(confirmKey); } catch { /* may not exist */ }
30
- }
31
58
  if (nc && !nc.isClosed()) {
32
59
  await nc.drain();
33
60
  }
@@ -36,59 +63,118 @@ export async function runCommand(taskId: string): Promise<void> {
36
63
  // Handle signals
37
64
  const onSignal = async () => {
38
65
  console.log("Received signal, cleaning up...");
39
- if (eventKv) {
40
- await writeTaskEvent(eventKv, `${config.agentId}.${taskId}`, "abort").catch(() => {});
41
- }
66
+ const endTime = Date.now();
67
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "abort", startTime, endTime, "", [], []);
68
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
69
+ await publishTaskEvent(nc, config, taskDir, taskId, "abort", useHttp, taskName);
42
70
  await cleanup();
43
71
  process.exit(1);
44
72
  };
45
73
  process.on("SIGINT", onSignal);
46
74
  process.on("SIGTERM", onSignal);
47
75
 
48
- let eventKv: KV | undefined;
49
-
50
76
  try {
51
- nc = await connectNats(config);
52
- const js = nc.jetstream();
77
+ if (useNats) {
78
+ nc = await connectNats(config);
79
+ }
53
80
 
54
- // Set up task-event KV and mark as started immediately
55
- eventKv = await js.views.kv("task-event", { history: 1 });
56
- const eventKey = `${config.agentId}.${taskId}`;
57
- await writeTaskEvent(eventKv, eventKey, "start");
81
+ // Mark as started immediately
82
+ await publishTaskEvent(nc, config, taskDir, taskId, "start", useHttp, taskName);
58
83
 
59
- // If requires_confirmation, ask user via NATS KV
84
+ // If requires_confirmation, notify clients and wait
60
85
  if (task.frontmatter.requires_confirmation) {
61
- confirmKv = await js.views.kv("pending-confirmation");
62
-
63
- const confirmed = await requestConfirmation(config, task, confirmKv);
86
+ const confirmed = await requestConfirmation(nc, config, task, taskDir, useHttp);
87
+ const resolvedStatus = confirmed ? "confirmed" : "aborted";
88
+ await publishConfirmResolved(nc, config, taskId, resolvedStatus, useHttp);
64
89
  if (!confirmed) {
65
90
  console.log("Task aborted by user.");
66
- await writeTaskEvent(eventKv, eventKey, "abort");
91
+ const endTime = Date.now();
92
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "abort", startTime, endTime, "", [], []);
93
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
94
+ await publishTaskEvent(nc, config, taskDir, taskId, "abort", useHttp, taskName);
67
95
  await cleanup();
68
96
  return;
69
97
  }
70
98
  console.log("Task confirmed by user.");
71
99
  }
72
100
 
73
- // Spawn task process
74
- const startTime = Date.now();
75
- const output = await spawnTask(config, task);
76
- const endTime = Date.now();
77
-
78
- // Save result with frontmatter to task directory
79
- const resultPath = path.join(taskDir, "RESULT.md");
80
- const resultContent = `---\nstart_time: ${startTime}\nend_time: ${endTime}\n---\n${output}`;
81
- fs.writeFileSync(resultPath, resultContent, "utf-8");
101
+ // Execution loop: retry on permission failure if user grants
102
+ const guiEnv = getGuiEnv();
103
+ const agent = getAgent(task.frontmatter.agent);
104
+
105
+ let lastOutput = "";
106
+ let lastOutcome: TaskRunningState = "fail";
107
+ let lastReportFiles: string[] = [];
108
+ let lastRequiredPermissions: RequiredPermission[] = [];
109
+ let lastEndTime = Date.now();
110
+
111
+ let retryPrompt: string | undefined;
112
+ let transientPermissions: RequiredPermission[] = [];
113
+ // eslint-disable-next-line no-constant-condition
114
+ while (true) {
115
+ const { command, args } = agent.getTaskRunCommandLine(task, retryPrompt, transientPermissions);
116
+ const result = await spawnCommand(command, args, {
117
+ cwd: taskDir,
118
+ env: {
119
+ ...guiEnv,
120
+ PALMIER_TASK_ID: task.frontmatter.id,
121
+ },
122
+ echoStdout: true,
123
+ forwardSignals: true,
124
+ resolveOnFailure: true,
125
+ });
126
+ lastOutput = result.output;
127
+ lastEndTime = Date.now();
128
+
129
+ lastOutcome = result.exitCode !== 0 ? "fail" : parseTaskOutcome(lastOutput);
130
+ lastReportFiles = parseReportFiles(lastOutput);
131
+ lastRequiredPermissions = parsePermissions(lastOutput);
132
+
133
+ // If failed with permission requirements, ask user to grant
134
+ if (lastOutcome === "fail" && lastRequiredPermissions.length > 0) {
135
+ const response = await requestPermission(nc, config, task, taskDir, lastRequiredPermissions, useHttp);
136
+ await publishPermissionResolved(nc, config, taskId, response, useHttp);
137
+
138
+ if (response === "aborted") {
139
+ console.log("Permission request aborted by user.");
140
+ break;
141
+ }
142
+
143
+ const newPerms = lastRequiredPermissions.filter(
144
+ (rp) => !task.frontmatter.permissions?.some((ep) => ep.name === rp.name)
145
+ && !transientPermissions.some((ep) => ep.name === rp.name),
146
+ );
147
+
148
+ if (response === "granted_all") {
149
+ // Persist permissions to task frontmatter for all future runs
150
+ task.frontmatter.permissions = [...(task.frontmatter.permissions ?? []), ...newPerms];
151
+ writeTaskFile(taskDir, task);
152
+ } else {
153
+ // "granted" — allow for this run only
154
+ transientPermissions = [...transientPermissions, ...newPerms];
155
+ }
156
+
157
+ console.log(`Permissions granted, retrying task ${taskId}...`);
158
+ retryPrompt = "Permissions granted, please continue.";
159
+ continue;
160
+ }
82
161
 
83
- // Set event to finish on completion
84
- await writeTaskEvent(eventKv, eventKey, "finish");
162
+ // Normal completion (success or non-permission failure)
163
+ break;
164
+ }
85
165
 
166
+ // Write result and history once after the loop
167
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, lastOutcome, startTime, lastEndTime, lastOutput, lastReportFiles, lastRequiredPermissions);
168
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
169
+ await publishTaskEvent(nc, config, taskDir, taskId, lastOutcome, useHttp, taskName);
86
170
  console.log(`Task ${taskId} completed.`);
87
171
  } catch (err) {
88
172
  console.error(`Task ${taskId} failed:`, err);
89
- if (eventKv) {
90
- await writeTaskEvent(eventKv, `${config.agentId}.${taskId}`, "fail").catch(() => {});
91
- }
173
+ const endTime = Date.now();
174
+ const errorMsg = err instanceof Error ? err.message : String(err);
175
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "fail", startTime, endTime, errorMsg, [], []);
176
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
177
+ await publishTaskEvent(nc, config, taskDir, taskId, "fail", useHttp, taskName);
92
178
  process.exitCode = 1;
93
179
  } finally {
94
180
  await cleanup();
@@ -97,109 +183,217 @@ export async function runCommand(taskId: string): Promise<void> {
97
183
 
98
184
  const sc = StringCodec();
99
185
 
100
- async function writeTaskEvent(kv: KV, key: string, eventType: TaskEventType): Promise<void> {
101
- const event = { event_type: eventType, time_stamp: Date.now() };
102
- console.log(`[task-event] ${key} → ${eventType}`);
103
- await kv.put(key, sc.encode(JSON.stringify(event)));
104
- }
105
-
106
- async function requestConfirmation(
107
- config: AgentConfig,
108
- task: ParsedTask,
109
- kv: KV,
110
- ): Promise<boolean> {
111
- const kvKey = `${config.agentId}.${task.frontmatter.id}`;
112
-
113
- // Write confirmation payload to KV — the server watches this bucket and sends push notifications
114
- const payload: ConfirmPayload = {
115
- type: "confirm",
116
- task_id: task.frontmatter.id,
117
- agent_id: config.agentId,
118
- user_id: config.userId,
119
- details: {
120
- prompt: task.frontmatter.user_prompt,
121
- },
122
- status: "pending",
123
- };
124
-
125
- await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
126
-
127
- // Watch AFTER writing — the initial history replay delivers the "pending" entry (skipped),
128
- // then the iterator stays open for live updates (confirmed/aborted).
129
- const watch = await kv.watch({ key: kvKey });
130
-
131
- for await (const entry of watch) {
132
- if (entry.operation === "DEL" || entry.operation === "PURGE") {
133
- return false;
134
- }
186
+ /**
187
+ * Write status.json and notify connected clients via NATS and/or HTTP SSE.
188
+ */
189
+ async function publishHostEvent(
190
+ nc: NatsConnection | undefined,
191
+ config: HostConfig,
192
+ taskId: string,
193
+ payload: Record<string, unknown>,
194
+ useHttp: boolean,
195
+ ): Promise<void> {
196
+ const subject = `host-event.${config.hostId}.${taskId}`;
197
+
198
+ if (nc) {
199
+ nc.publish(subject, sc.encode(JSON.stringify(payload)));
200
+ console.log(`[nats] ${subject} →`, payload);
201
+ }
135
202
 
203
+ if (useHttp && config.directPort) {
136
204
  try {
137
- const updated = JSON.parse(sc.decode(entry.value)) as ConfirmPayload;
138
- if (updated.status === "confirmed") return true;
139
- if (updated.status === "aborted") return false;
140
- // Still pending, keep watching
141
- } catch {
142
- // Couldn't parse, keep watching
205
+ await fetch(`http://localhost:${config.directPort}/internal/event`, {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify({ task_id: taskId, ...payload }),
209
+ });
210
+ console.log(`[http] host-event: ${taskId} →`, payload);
211
+ } catch (err) {
212
+ console.error(`[http] Failed to push event:`, err);
143
213
  }
144
214
  }
215
+ }
216
+
217
+ async function publishTaskEvent(
218
+ nc: NatsConnection | undefined,
219
+ config: HostConfig,
220
+ taskDir: string,
221
+ taskId: string,
222
+ eventType: TaskRunningState,
223
+ useHttp: boolean,
224
+ taskName?: string,
225
+ ): Promise<void> {
226
+ writeTaskStatus(taskDir, {
227
+ running_state: eventType,
228
+ time_stamp: Date.now(),
229
+ });
145
230
 
146
- return false;
231
+ const payload: Record<string, unknown> = { event_type: "running-state", running_state: eventType };
232
+ if (taskName) payload.name = taskName;
233
+ await publishHostEvent(nc, config, taskId, payload, useHttp);
147
234
  }
148
235
 
149
- function shellEscape(arg: string): string {
150
- return "'" + arg.replace(/'/g, "'\\''") + "'";
236
+ /**
237
+ * Notify clients that a confirmation request has been resolved.
238
+ */
239
+ async function publishConfirmResolved(
240
+ nc: NatsConnection | undefined,
241
+ config: HostConfig,
242
+ taskId: string,
243
+ status: "confirmed" | "aborted",
244
+ useHttp: boolean,
245
+ ): Promise<void> {
246
+ await publishHostEvent(nc, config, taskId, {
247
+ event_type: "confirm-resolved",
248
+ host_id: config.hostId,
249
+ status,
250
+ }, useHttp);
151
251
  }
152
252
 
153
- async function spawnTask(
154
- config: AgentConfig,
253
+ async function requestPermission(
254
+ nc: NatsConnection | undefined,
255
+ config: HostConfig,
155
256
  task: ParsedTask,
156
- ): Promise<string> {
157
- return new Promise<string>((resolve, reject) => {
158
- const prompt = task.body
159
- ? `${task.body}\n\n${task.frontmatter.user_prompt}`
160
- : task.frontmatter.user_prompt;
161
-
162
- const command = `${task.frontmatter.command_line} ${shellEscape(prompt)}`;
163
-
164
- const child = spawn(command, {
165
- cwd: config.projectRoot,
166
- shell: true,
167
- stdio: ["ignore", "pipe", "pipe"],
168
- env: {
169
- ...process.env,
170
- PALMIER_TASK_ID: task.frontmatter.id,
171
- },
257
+ taskDir: string,
258
+ requiredPermissions: RequiredPermission[],
259
+ useHttp: boolean,
260
+ ): Promise<"granted" | "granted_all" | "aborted"> {
261
+ const taskId = task.frontmatter.id;
262
+ const statusPath = path.join(taskDir, "status.json");
263
+
264
+ const currentStatus = readTaskStatus(taskDir)!;
265
+ writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
266
+
267
+ await publishHostEvent(nc, config, taskId, {
268
+ event_type: "permission-request",
269
+ host_id: config.hostId,
270
+ required_permissions: requiredPermissions,
271
+ name: task.frontmatter.name,
272
+ }, useHttp);
273
+
274
+ return new Promise<"granted" | "granted_all" | "aborted">((resolve) => {
275
+ const watcher = fs.watch(statusPath, () => {
276
+ const status = readTaskStatus(taskDir);
277
+ if (!status || status.user_input === undefined) return;
278
+ watcher.close();
279
+ const response = status.user_input as "granted" | "granted_all" | "aborted";
280
+ writeTaskStatus(taskDir, {
281
+ running_state: response === "aborted" ? "abort" : "start",
282
+ time_stamp: Date.now(),
283
+ });
284
+ resolve(response);
172
285
  });
286
+ });
287
+ }
173
288
 
174
- const stdoutChunks: Buffer[] = [];
289
+ async function publishPermissionResolved(
290
+ nc: NatsConnection | undefined,
291
+ config: HostConfig,
292
+ taskId: string,
293
+ status: "granted" | "granted_all" | "aborted",
294
+ useHttp: boolean,
295
+ ): Promise<void> {
296
+ await publishHostEvent(nc, config, taskId, {
297
+ event_type: "permission-resolved",
298
+ host_id: config.hostId,
299
+ status,
300
+ }, useHttp);
301
+ }
175
302
 
176
- child.stdout?.on("data", (data: Buffer) => {
177
- stdoutChunks.push(data);
178
- process.stdout.write(data);
303
+ async function requestConfirmation(
304
+ nc: NatsConnection | undefined,
305
+ config: HostConfig,
306
+ task: ParsedTask,
307
+ taskDir: string,
308
+ useHttp: boolean,
309
+ ): Promise<boolean> {
310
+ const taskId = task.frontmatter.id;
311
+ const statusPath = path.join(taskDir, "status.json");
312
+
313
+ // Flag that we're awaiting user confirmation
314
+ const currentStatus = readTaskStatus(taskDir)!;
315
+ writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
316
+
317
+ // Publish confirmation request via NATS and/or HTTP SSE
318
+ await publishHostEvent(nc, config, taskId, {
319
+ event_type: "confirm-request",
320
+ host_id: config.hostId,
321
+ }, useHttp);
322
+
323
+ // Wait for task.user_input RPC to set user_input in status.json
324
+ return new Promise<boolean>((resolve) => {
325
+ const watcher = fs.watch(statusPath, () => {
326
+ const status = readTaskStatus(taskDir);
327
+ if (!status || status.user_input === undefined) return; // still pending
328
+ watcher.close();
329
+ const confirmed = status.user_input === "confirmed";
330
+ // Clear pending_confirmation/user_input and update running_state
331
+ writeTaskStatus(taskDir, {
332
+ running_state: confirmed ? "start" : "abort",
333
+ time_stamp: Date.now(),
334
+ });
335
+ resolve(confirmed);
179
336
  });
337
+ });
338
+ }
180
339
 
181
- child.stderr?.on("data", (data: Buffer) => {
182
- process.stderr.write(data);
183
- });
340
+ /**
341
+ * Extract report file names from agent output.
342
+ * Looks for lines matching: [PALMIER_REPORT] <filename>
343
+ */
344
+ function parseReportFiles(output: string): string[] {
345
+ const regex = new RegExp(`^\\${TASK_REPORT_PREFIX}\\s+(.+)$`, "gm");
346
+ const files: string[] = [];
347
+ let match;
348
+ while ((match = regex.exec(output)) !== null) {
349
+ const name = match[1].trim();
350
+ if (name) files.push(name);
351
+ }
352
+ return files;
353
+ }
184
354
 
185
- let stopping = false;
186
- const killChild = () => {
187
- stopping = true;
188
- child.kill("SIGTERM");
189
- };
190
- process.on("SIGINT", killChild);
191
- process.on("SIGTERM", killChild);
192
-
193
- child.on("close", (exitCode) => {
194
- if (exitCode === 0 || stopping) {
195
- resolve(Buffer.concat(stdoutChunks).toString("utf-8"));
196
- } else {
197
- reject(new Error(`Task process exited with code ${exitCode}`));
198
- }
199
- });
355
+ /**
356
+ * Extract required permissions from agent output.
357
+ * Looks for lines matching: [PALMIER_PERMISSION] <tool> | <description>
358
+ */
359
+ function parsePermissions(output: string): RequiredPermission[] {
360
+ const regex = new RegExp(`^\\${TASK_PERMISSION_PREFIX}\\s+(.+)$`, "gm");
361
+ const perms: RequiredPermission[] = [];
362
+ let match;
363
+ while ((match = regex.exec(output)) !== null) {
364
+ const raw = match[1].trim();
365
+ const sep = raw.indexOf("|");
366
+ if (sep !== -1) {
367
+ perms.push({ name: raw.slice(0, sep).trim(), description: raw.slice(sep + 1).trim() });
368
+ } else {
369
+ perms.push({ name: raw, description: "" });
370
+ }
371
+ }
372
+ return perms;
373
+ }
200
374
 
201
- child.on("error", (err) => {
202
- reject(err);
203
- });
204
- });
375
+ /**
376
+ * Parse the agent's output for success/failure markers.
377
+ * Falls back to "finish" if no marker is found.
378
+ */
379
+ function parseTaskOutcome(output: string): TaskRunningState {
380
+ const lastChunk = output.slice(-500);
381
+ if (lastChunk.includes(TASK_FAILURE_MARKER)) return "fail";
382
+ if (lastChunk.includes(TASK_SUCCESS_MARKER)) return "finish";
383
+ return "finish";
384
+ }
385
+
386
+ /**
387
+ * Return env vars for the default physical GUI session (:0).
388
+ */
389
+ function getGuiEnv(): Record<string, string> {
390
+ const uid = process.getuid?.();
391
+ const runtimeDir =
392
+ process.env.XDG_RUNTIME_DIR ||
393
+ (uid !== undefined ? `/run/user/${uid}` : "");
394
+
395
+ return {
396
+ DISPLAY: ":0",
397
+ ...(runtimeDir ? { XDG_RUNTIME_DIR: runtimeDir } : {}),
398
+ };
205
399
  }