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.
Files changed (132) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +11 -11
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/app-registry.d.ts +10 -0
  17. package/dist/app-registry.js +44 -0
  18. package/dist/commands/info.d.ts +0 -3
  19. package/dist/commands/info.js +0 -5
  20. package/dist/commands/init.d.ts +0 -3
  21. package/dist/commands/init.js +2 -11
  22. package/dist/commands/pair.d.ts +1 -4
  23. package/dist/commands/pair.js +1 -12
  24. package/dist/commands/restart.d.ts +0 -3
  25. package/dist/commands/restart.js +0 -3
  26. package/dist/commands/run.d.ts +1 -14
  27. package/dist/commands/run.js +18 -61
  28. package/dist/commands/serve.d.ts +0 -3
  29. package/dist/commands/serve.js +33 -27
  30. package/dist/config.d.ts +0 -8
  31. package/dist/config.js +0 -8
  32. package/dist/device-capabilities.d.ts +1 -1
  33. package/dist/event-queues.d.ts +6 -21
  34. package/dist/event-queues.js +6 -21
  35. package/dist/events.d.ts +0 -6
  36. package/dist/events.js +1 -9
  37. package/dist/index.js +0 -1
  38. package/dist/mcp-handler.js +1 -2
  39. package/dist/mcp-tools.d.ts +0 -3
  40. package/dist/mcp-tools.js +14 -18
  41. package/dist/nats-client.d.ts +0 -3
  42. package/dist/nats-client.js +1 -4
  43. package/dist/pending-requests.d.ts +4 -18
  44. package/dist/pending-requests.js +4 -18
  45. package/dist/platform/index.d.ts +1 -4
  46. package/dist/platform/index.js +1 -4
  47. package/dist/platform/linux.d.ts +3 -9
  48. package/dist/platform/linux.js +9 -20
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-B0F9mtid.css +1 -0
  53. package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
  54. package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
  55. package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +19 -48
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +6 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/README.md +1 -1
  73. package/palmier-server/pwa/src/App.css +170 -20
  74. package/palmier-server/pwa/src/App.tsx +15 -1
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
  78. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  79. package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
  80. package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
  81. package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
  82. package/palmier-server/pwa/src/constants.ts +1 -1
  83. package/palmier-server/pwa/src/native/Device.ts +66 -0
  84. package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
  85. package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
  86. package/palmier-server/pwa/src/types.ts +1 -1
  87. package/palmier-server/server/src/index.ts +7 -7
  88. package/palmier-server/server/src/routes/device.ts +4 -4
  89. package/palmier-server/spec.md +47 -6
  90. package/src/agents/agent.ts +0 -4
  91. package/src/agents/claude.ts +1 -1
  92. package/src/agents/codex.ts +2 -2
  93. package/src/agents/cursor.ts +1 -1
  94. package/src/agents/deepagents.ts +1 -1
  95. package/src/agents/gemini.ts +3 -2
  96. package/src/agents/goose.ts +1 -1
  97. package/src/agents/hermes.ts +1 -1
  98. package/src/agents/kiro.ts +1 -1
  99. package/src/agents/opencode.ts +1 -1
  100. package/src/agents/qoder.ts +1 -1
  101. package/src/agents/shared-prompt.ts +0 -3
  102. package/src/app-registry.ts +52 -0
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +1 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +31 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +4 -3
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +14 -20
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +1 -4
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/platform.ts +1 -4
  121. package/src/platform/windows.ts +19 -40
  122. package/src/rpc-handler.ts +20 -48
  123. package/src/spawn-command.ts +11 -27
  124. package/src/task.ts +7 -70
  125. package/src/transports/http-transport.ts +6 -39
  126. package/src/transports/nats-transport.ts +3 -9
  127. package/src/types.ts +3 -10
  128. package/src/update-checker.ts +2 -5
  129. package/test/task-parsing.test.ts +2 -3
  130. package/test/windows-xml.test.ts +11 -12
  131. package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
  132. package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
@@ -12,14 +12,12 @@ import { getAgent } from "./agents/agent.js";
12
12
  import { validateClient } from "./client-store.js";
13
13
  import { publishHostEvent } from "./events.js";
14
14
  import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type DeviceCapability } from "./device-capabilities.js";
15
+ import { listApps } from "./app-registry.js";
15
16
  import { currentVersion, performUpdate } from "./update-checker.js";
16
17
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
17
18
  import { clearTaskQueue } from "./event-queues.js";
18
19
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
19
20
 
20
- /**
21
- * Parse RESULT frontmatter and conversation messages.
22
- */
23
21
  export function parseResultFrontmatter(raw: string): Record<string, unknown> {
24
22
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
25
23
  if (!fmMatch) return { messages: [] };
@@ -33,19 +31,16 @@ export function parseResultFrontmatter(raw: string): Record<string, unknown> {
33
31
 
34
32
  const messages = parseConversationMessages(fmMatch[2]);
35
33
 
36
- // Derive state from status messages — just look at the last one
37
34
  const statusMessages = messages.filter((m: ConversationMessage) => m.role === "status");
38
35
  const lastStatus = statusMessages[statusMessages.length - 1];
39
36
  const startedMsg = statusMessages.find((m: ConversationMessage) => m.type === "started");
40
37
  const terminalStates = ["finished", "failed", "aborted"];
41
38
  const terminalMsg = [...statusMessages].reverse().find((m: ConversationMessage) => terminalStates.includes(m.type ?? ""));
42
39
 
43
- // If last status is "started" (or continuation like "confirmation"/"monitoring"),
44
- // determine if it's a task run or follow-up
45
40
  const activeStates = ["started", "monitoring", "confirmation"];
46
41
  let runningState: string | undefined;
47
42
  if (lastStatus?.type === "monitoring") {
48
- // Only show monitoring if no assistant/user message came after it
43
+ // Show "monitoring" only if no assistant/user message followed it.
49
44
  const lastStatusIdx = messages.lastIndexOf(lastStatus);
50
45
  const hasMessageAfter = messages.slice(lastStatusIdx + 1).some((m: ConversationMessage) => m.role === "assistant" || m.role === "user");
51
46
  runningState = hasMessageAfter ? "started" : "monitoring";
@@ -65,16 +60,13 @@ export function parseResultFrontmatter(raw: string): Record<string, unknown> {
65
60
  };
66
61
  }
67
62
 
68
- /**
69
- * Parse conversation messages from the body of a RESULT file.
70
- */
71
63
  function parseConversationMessages(body: string): ConversationMessage[] {
72
64
  const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
73
65
  const messages: ConversationMessage[] = [];
74
66
  const matches = [...body.matchAll(delimiterRegex)];
75
67
 
76
68
  if (matches.length === 0) {
77
- // No delimiters — treat entire body as single assistant message if non-empty
69
+ // No delimiters — treat entire body as a single assistant message.
78
70
  const content = body.trim();
79
71
  if (content) {
80
72
  messages.push({ role: "assistant", time: 0, content });
@@ -106,10 +98,6 @@ function parseAttr(attrs: string, name: string): string | undefined {
106
98
  return match ? match[1] : undefined;
107
99
  }
108
100
 
109
- /**
110
- * Generate a concise task name from a user prompt using the given agent.
111
- * Falls back to the raw prompt on failure.
112
- */
113
101
  async function generateName(
114
102
  projectRoot: string,
115
103
  userPrompt: string,
@@ -136,9 +124,6 @@ async function generateName(
136
124
  /** Active follow-up child processes, keyed by "taskId:runId". */
137
125
  const activeFollowups = new Map<string, ChildProcess>();
138
126
 
139
- /**
140
- * Create a transport-agnostic RPC handler bound to the given config.
141
- */
142
127
  export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
143
128
  function flattenTask(task: ParsedTask) {
144
129
  const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
@@ -150,8 +135,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
150
135
  }
151
136
 
152
137
  async function handleRpc(request: RpcMessage): Promise<unknown> {
153
- // Client token validation: skip for trusted localhost requests and
154
- // task.user_input (server-originated push responses; gated by getPending instead)
138
+ // task.user_input comes from server-originated push responses; it's gated
139
+ // by getPending() rather than a client token.
155
140
  const skipAuth = request.method === "task.user_input";
156
141
  if (!skipAuth && !request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
157
142
  return { error: "Unauthorized" };
@@ -159,12 +144,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
159
144
 
160
145
  switch (request.method) {
161
146
  case "host.info": {
162
- // Bootstrap metadata the PWA needs on connect, independent of which tab
163
- // is active. Includes any prompts already waiting so a reconnecting
164
- // PWA can render their modals without replaying events.
165
147
  const capabilities: Record<string, string | null> = {};
166
- for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "email"] as const) {
167
- capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
148
+ for (const capability of ["location", "notifications", "sms-read", "sms-send", "contacts", "calendar", "alarm", "battery", "dnd", "send-email"] as const) {
149
+ capabilities[capability] = getCapabilityDevice(capability)?.clientToken ?? null;
168
150
  }
169
151
  return {
170
152
  agents: config.agents ?? [],
@@ -250,11 +232,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
250
232
  const taskDir = getTaskDir(config.projectRoot, params.id);
251
233
  const existing = parseTaskFile(taskDir);
252
234
 
253
- // Detect whether name needs regeneration
254
235
  const promptChanged = params.user_prompt !== undefined && params.user_prompt !== existing.frontmatter.user_prompt;
255
236
  const agentChanged = params.agent !== undefined && params.agent !== existing.frontmatter.agent;
256
237
 
257
- // Merge updates
258
238
  if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
259
239
  if (params.agent !== undefined) existing.frontmatter.agent = params.agent;
260
240
  if (params.schedule_type !== undefined) {
@@ -287,7 +267,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
287
267
  }
288
268
  }
289
269
 
290
- // Regenerate name when prompt or agent changes
291
270
  if (promptChanged || agentChanged) {
292
271
  existing.frontmatter.name = existing.frontmatter.user_prompt.length <= 50
293
272
  ? existing.frontmatter.user_prompt
@@ -296,8 +275,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
296
275
 
297
276
  writeTaskFile(taskDir, existing);
298
277
 
299
- // Update timers — installTaskTimer overwrites in-place (schtasks /f,
300
- // systemd unit rewrite) without killing a running task process.
278
+ // installTaskTimer overwrites in-place (schtasks /f, systemd unit rewrite)
279
+ // without killing a running task process.
301
280
  getPlatform().installTaskTimer(config, existing);
302
281
 
303
282
  return flattenTask(existing);
@@ -341,13 +320,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
341
320
  };
342
321
 
343
322
  writeTaskFile(taskDir, task);
344
- // Do NOT append to tasks.jsonl — this is a one-off run
323
+ // One-off run: do NOT append to tasks.jsonl.
345
324
 
346
- // Create initial result file so it appears in runs list immediately
347
325
  const runId = createRunDir(taskDir, name, Date.now(), params.agent);
348
326
  appendHistory(config.projectRoot, { task_id: id, run_id: runId });
349
327
 
350
- // Spawn `palmier run <id>` directly as a detached process
351
328
  const script = process.argv[1] || "palmier";
352
329
  const child = spawn(process.execPath, [script, "run", id], {
353
330
  detached: true,
@@ -365,13 +342,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
365
342
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
366
343
  const platform = getPlatform();
367
344
 
368
- // If the task is already running, kill the stale process and start fresh
369
345
  if (platform.isTaskRunning(params.id)) {
370
346
  console.log(`[task.run] Task ${params.id} is already running, killing stale process`);
371
347
  await platform.stopTask(params.id);
372
348
  }
373
349
 
374
- // Create initial result file so it appears in runs list immediately
375
350
  const runTask = parseTaskFile(runTaskDir);
376
351
  const taskRunId = createRunDir(runTaskDir, runTask.frontmatter.name, Date.now(), runTask.frontmatter.agent);
377
352
  appendHistory(config.projectRoot, { task_id: params.id, run_id: taskRunId });
@@ -399,7 +374,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
399
374
  const followupTask = parseTaskFile(followupTaskDir);
400
375
  const followupRunDir = getRunDir(followupTaskDir, params.run_id);
401
376
 
402
- // Append user message + started status
403
377
  appendRunMessage(followupTaskDir, params.run_id, {
404
378
  role: "user",
405
379
  time: Date.now(),
@@ -413,13 +387,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
413
387
  });
414
388
  await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
415
389
 
416
- // Fire-and-forget: invoke agent inline as a child of the serve process
417
390
  const followupAgent = getAgent(followupTask.frontmatter.agent);
418
391
  const { command: cmd, args: cmdArgs, stdin, env: followupAgentEnv } = followupAgent.getTaskRunCommandLine(
419
392
  followupTask, params.message, followupTask.frontmatter.yolo_mode ? "yolo" : followupTask.frontmatter.permissions,
420
393
  );
421
394
 
422
- // Spawn directly via crossSpawn so we can track and kill the child
423
395
  const child = crossSpawn(cmd, cmdArgs, {
424
396
  cwd: followupRunDir,
425
397
  stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
@@ -429,14 +401,13 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
429
401
  if (stdin != null) child.stdin!.end(stdin);
430
402
  activeFollowups.set(followupKey, child);
431
403
 
432
- // Collect output
433
404
  const chunks: Buffer[] = [];
434
405
  child.stdout?.on("data", (d: Buffer) => chunks.push(d));
435
406
  child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
436
407
 
437
408
  child.on("close", async (code: number | null) => {
438
409
  activeFollowups.delete(followupKey);
439
- // If killed by stop_followup, the stopped status is already written
410
+ // stop_followup already wrote the stopped status.
440
411
  if (child.killed) return;
441
412
 
442
413
  const output = Buffer.concat(chunks).toString("utf-8");
@@ -484,7 +455,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
484
455
  return { error: "No active follow-up for this run" };
485
456
  }
486
457
 
487
- // Kill the child process tree
488
458
  if (process.platform === "win32" && child.pid) {
489
459
  try {
490
460
  const { execFileSync } = await import("child_process");
@@ -494,7 +464,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
494
464
  child.kill();
495
465
  }
496
466
 
497
- // Append stopped status (child.killed prevents the close handler from writing)
467
+ // child.killed stops the close handler from double-writing the status.
498
468
  const stopTaskDir = getTaskDir(config.projectRoot, params.id);
499
469
  appendRunMessage(stopTaskDir, params.run_id, {
500
470
  role: "status",
@@ -510,17 +480,16 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
510
480
  case "task.abort": {
511
481
  const params = request.params as { id: string };
512
482
  const abortTaskDir = getTaskDir(config.projectRoot, params.id);
513
- // Read the PID before overwriting status — stopTask needs it to
514
- // kill the entire process tree on Windows.
483
+ // Read PID before overwriting — stopTask needs it to kill the
484
+ // process tree on Windows.
515
485
  const abortPrevStatus = readTaskStatus(abortTaskDir);
516
- // Write abort status BEFORE killing so the dying process's signal
517
- // handler can detect this was RPC-initiated and skip publishing.
486
+ // Write abort status before killing so the dying process's signal
487
+ // handler sees this was RPC-initiated and skips publishing.
518
488
  writeTaskStatus(abortTaskDir, {
519
489
  running_state: "aborted",
520
490
  time_stamp: Date.now(),
521
491
  ...(abortPrevStatus?.pid ? { pid: abortPrevStatus.pid } : {}),
522
492
  });
523
- // Append aborted status to the latest run
524
493
  try {
525
494
  const runDirs = fs.readdirSync(abortTaskDir)
526
495
  .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(abortTaskDir, f, "TASKRUN.md")))
@@ -543,7 +512,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
543
512
  console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
544
513
  return { error: `Failed to abort task: ${e.stderr || e.message}` };
545
514
  }
546
- // Notify connected clients (NATS + HTTP SSE if LAN server is running)
547
515
  const abortPayload: Record<string, unknown> = { event_type: "running-state", running_state: "aborted" };
548
516
  await publishHostEvent(nc, config.hostId, params.id, abortPayload);
549
517
  return { ok: true, task_id: params.id };
@@ -694,6 +662,10 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
694
662
  return { ok: true };
695
663
  }
696
664
 
665
+ case "device.notifications.apps": {
666
+ return { apps: listApps() };
667
+ }
668
+
697
669
  default:
698
670
  return { error: `Unknown method: ${request.method}` };
699
671
  }
@@ -18,19 +18,12 @@ export interface SpawnStreamingOptions {
18
18
  }
19
19
 
20
20
  /**
21
- * Spawn a command with shell interpretation, returning the ChildProcess
22
- * with stdout piped for line-by-line reading.
23
- *
24
- * Unlike spawnCommand(), this does NOT collect output into a buffer
25
- * the caller reads from child.stdout directly (e.g. via readline).
26
- *
27
- * shell: true is required so users can write piped commands like
28
- * "tail -f log | grep ERROR".
29
- *
30
- * stdin is "pipe" (kept open, never written to) rather than "ignore"
31
- * (/dev/null). Some long-running commands exit when stdin is closed/EOF.
32
- * This differs from spawnCommand() which uses "ignore" because agent
33
- * CLIs like `claude -p` hang on an open stdin pipe.
21
+ * Spawn with shell interpretation for piped commands like "tail -f log | grep".
22
+ * Returns the ChildProcess with stdout piped so the caller can read it directly
23
+ * (contrast with spawnCommand which buffers). stdin is "pipe" (held open, not
24
+ * written to): some long-running commands exit on stdin EOF. Agent CLIs like
25
+ * `claude -p` conversely hang on an open stdin, which is why spawnCommand
26
+ * defaults to "ignore".
34
27
  */
35
28
  export function spawnStreamingCommand(
36
29
  command: string,
@@ -60,18 +53,10 @@ export interface SpawnCommandOptions {
60
53
  }
61
54
 
62
55
  /**
63
- * Spawn a command with additional arguments.
64
- *
65
- * Uses cross-spawn to correctly resolve .cmd shims and escape arguments
66
- * on Windows without shell: true (which mishandles special characters).
67
- *
68
- * On other platforms the command is executed directly (no shell), so no
69
- * escaping is needed.
70
- *
71
- * stdin is set to "ignore" by default (equivalent to < /dev/null) because
72
- * tools like `claude -p` hang indefinitely on an open stdin pipe.
73
- * When opts.stdin is provided, stdin is set to "pipe" and the string is
74
- * written to the process before closing the pipe.
56
+ * cross-spawn resolves .cmd shims and escapes args on Windows without shell:true
57
+ * (which mishandles special characters). stdin defaults to "ignore" because
58
+ * tools like `claude -p` hang on an open stdin pipe; pass opts.stdin to write
59
+ * a string and then close the pipe.
75
60
  */
76
61
  export interface SpawnCommandResult {
77
62
  output: string;
@@ -84,8 +69,7 @@ export function spawnCommand(
84
69
  opts: SpawnCommandOptions,
85
70
  ): Promise<SpawnCommandResult> {
86
71
  return new Promise<SpawnCommandResult>((resolve, reject) => {
87
- // Collapse newlines to spaces — cmd.exe can't handle literal newlines
88
- // in arguments, and CLI prompts don't need them.
72
+ // cmd.exe can't handle literal newlines in arguments.
89
73
  const finalArgs = process.platform === "win32"
90
74
  ? args.map((a) => a.replace(/[\r\n]+/g, " "))
91
75
  : args;
package/src/task.ts CHANGED
@@ -3,9 +3,6 @@ import * as path from "path";
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
4
  import type { ParsedTask, TaskFrontmatter, TaskStatus, HistoryEntry, ConversationMessage } from "./types.js";
5
5
 
6
- /**
7
- * Parse a TASK.md file from the given task directory.
8
- */
9
6
  export function parseTaskFile(taskDir: string): ParsedTask {
10
7
  const filePath = path.join(taskDir, "TASK.md");
11
8
 
@@ -17,9 +14,6 @@ export function parseTaskFile(taskDir: string): ParsedTask {
17
14
  return parseTaskContent(content);
18
15
  }
19
16
 
20
- /**
21
- * Parse TASK.md content string into frontmatter + body.
22
- */
23
17
  export function parseTaskContent(content: string): ParsedTask {
24
18
  const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
25
19
  const match = content.match(fmRegex);
@@ -41,10 +35,6 @@ export function parseTaskContent(content: string): ParsedTask {
41
35
  return { frontmatter };
42
36
  }
43
37
 
44
- /**
45
- * Write a TASK.md file to the given task directory.
46
- * Creates the directory if it doesn't exist.
47
- */
48
38
  export function writeTaskFile(taskDir: string, task: ParsedTask): void {
49
39
  fs.mkdirSync(taskDir, { recursive: true });
50
40
 
@@ -55,18 +45,11 @@ export function writeTaskFile(taskDir: string, task: ParsedTask): void {
55
45
  fs.writeFileSync(filePath, content, "utf-8");
56
46
  }
57
47
 
58
- /**
59
- * Append a task ID to the project-level tasks.jsonl file.
60
- */
61
48
  export function appendTaskList(projectRoot: string, taskId: string): void {
62
49
  const listPath = path.join(projectRoot, "tasks.jsonl");
63
50
  fs.appendFileSync(listPath, JSON.stringify({ task_id: taskId }) + "\n", "utf-8");
64
51
  }
65
52
 
66
- /**
67
- * Remove a task ID from the project-level tasks.jsonl file.
68
- * Returns true if the entry was found and removed.
69
- */
70
53
  export function removeFromTaskList(projectRoot: string, taskId: string): boolean {
71
54
  const listPath = path.join(projectRoot, "tasks.jsonl");
72
55
  if (!fs.existsSync(listPath)) return false;
@@ -91,9 +74,6 @@ export function removeFromTaskList(projectRoot: string, taskId: string): boolean
91
74
  return true;
92
75
  }
93
76
 
94
- /**
95
- * List all tasks referenced in tasks.jsonl.
96
- */
97
77
  export function listTasks(projectRoot: string): ParsedTask[] {
98
78
  const listPath = path.join(projectRoot, "tasks.jsonl");
99
79
  if (!fs.existsSync(listPath)) return [];
@@ -118,25 +98,15 @@ export function listTasks(projectRoot: string): ParsedTask[] {
118
98
  return tasks.reverse();
119
99
  }
120
100
 
121
- /**
122
- * Get the directory path for a task by its ID.
123
- */
124
101
  export function getTaskDir(projectRoot: string, taskId: string): string {
125
102
  return path.join(projectRoot, "tasks", taskId);
126
103
  }
127
104
 
128
- /**
129
- * Write task status to status.json in the task directory.
130
- */
131
105
  export function writeTaskStatus(taskDir: string, status: TaskStatus): void {
132
106
  const filePath = path.join(taskDir, "status.json");
133
107
  fs.writeFileSync(filePath, JSON.stringify(status), "utf-8");
134
108
  }
135
109
 
136
- /**
137
- * Read task status from status.json in the task directory.
138
- * Returns undefined if the file doesn't exist.
139
- */
140
110
  export function readTaskStatus(taskDir: string): TaskStatus | undefined {
141
111
  const filePath = path.join(taskDir, "status.json");
142
112
  try {
@@ -146,10 +116,7 @@ export function readTaskStatus(taskDir: string): TaskStatus | undefined {
146
116
  }
147
117
  }
148
118
 
149
- /**
150
- * Create a run directory with an initial TASKRUN.md file.
151
- * Returns the run ID (timestamp string used as directory name).
152
- */
119
+ /** Returns the run ID (timestamp string used as directory name). */
153
120
  export function createRunDir(
154
121
  taskDir: string,
155
122
  taskName: string,
@@ -165,16 +132,10 @@ export function createRunDir(
165
132
  return runId;
166
133
  }
167
134
 
168
- /**
169
- * Get the path to a run directory.
170
- */
171
135
  export function getRunDir(taskDir: string, runId: string): string {
172
136
  return path.join(taskDir, runId);
173
137
  }
174
138
 
175
- /**
176
- * Append a conversation message to a run's TASKRUN.md file.
177
- */
178
139
  export function appendRunMessage(
179
140
  taskDir: string,
180
141
  runId: string,
@@ -189,10 +150,6 @@ export function appendRunMessage(
189
150
  fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
190
151
  }
191
152
 
192
- /**
193
- * Begin a streaming assistant message — writes the delimiter only.
194
- * Returns a writer that appends content chunks and finalizes the message.
195
- */
196
153
  export function beginStreamingMessage(
197
154
  taskDir: string,
198
155
  runId: string,
@@ -217,7 +174,7 @@ export class StreamingMessageWriter {
217
174
  fs.appendFileSync(this.filePath, "\n\n", "utf-8");
218
175
  if (attachments?.length) {
219
176
  const raw = fs.readFileSync(this.filePath, "utf-8");
220
- // Find the last assistant delimiter (may differ from the original if spliceUserMessage created a new one)
177
+ // spliceUserMessage may have created a newer assistant delimiter.
221
178
  const pattern = /<!-- palmier:message role="assistant" time="\d+" -->/g;
222
179
  let lastMatch: RegExpExecArray | null = null;
223
180
  let m;
@@ -233,12 +190,10 @@ export class StreamingMessageWriter {
233
190
  }
234
191
 
235
192
  /**
236
- * Splice a user message into a running assistant stream.
237
- * Ends the current assistant block, writes the user message,
238
- * then opens a new assistant block all as direct file appends.
239
- * The existing StreamingMessageWriter keeps working because its
240
- * write() is just appendFileSync, so subsequent chunks land in
241
- * the new assistant block.
193
+ * Splice a user message into a running assistant stream: close the current
194
+ * assistant block, write the user message, open a new assistant block. Direct
195
+ * appends only, so an existing StreamingMessageWriter keeps working its
196
+ * subsequent chunks land in the new block.
242
197
  */
243
198
  export function spliceUserMessage(
244
199
  taskDir: string,
@@ -248,22 +203,15 @@ export function spliceUserMessage(
248
203
  assistantAppend?: string,
249
204
  ): void {
250
205
  const filePath = path.join(taskDir, runId, "TASKRUN.md");
251
- // 1. Optionally append to the current assistant block (e.g. the input questions)
252
206
  if (assistantAppend) {
253
207
  fs.appendFileSync(filePath, assistantAppend, "utf-8");
254
208
  }
255
- // 2. End the current assistant block
256
209
  fs.appendFileSync(filePath, "\n\n", "utf-8");
257
- // 3. Write the user message
258
210
  appendRunMessage(taskDir, runId, userMsg);
259
- // 4. Open a new assistant block for subsequent agent output
260
211
  const delimiter = `<!-- palmier:message role="assistant" time="${Date.now()}" -->`;
261
212
  fs.appendFileSync(filePath, `${delimiter}\n\n`, "utf-8");
262
213
  }
263
214
 
264
- /**
265
- * Read conversation messages from a run's TASKRUN.md file.
266
- */
267
215
  export function readRunMessages(taskDir: string, runId: string): ConversationMessage[] {
268
216
  const raw = fs.readFileSync(path.join(taskDir, runId, "TASKRUN.md"), "utf-8");
269
217
  const fmMatch = raw.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
@@ -298,18 +246,11 @@ export function readRunMessages(taskDir: string, runId: string): ConversationMes
298
246
  return messages;
299
247
  }
300
248
 
301
- /**
302
- * Append a history entry to the project-level history.jsonl file.
303
- */
304
249
  export function appendHistory(projectRoot: string, entry: HistoryEntry): void {
305
250
  const historyPath = path.join(projectRoot, "history.jsonl");
306
251
  fs.appendFileSync(historyPath, JSON.stringify(entry) + "\n", "utf-8");
307
252
  }
308
253
 
309
- /**
310
- * Delete a history entry and its associated run directory.
311
- * Returns true if the entry was found and removed.
312
- */
313
254
  export function deleteHistoryEntry(
314
255
  projectRoot: string,
315
256
  taskId: string,
@@ -337,7 +278,6 @@ export function deleteHistoryEntry(
337
278
 
338
279
  fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
339
280
 
340
- // Delete the run directory
341
281
  const runDir = path.join(projectRoot, "tasks", taskId, runId);
342
282
  if (fs.existsSync(runDir)) {
343
283
  fs.rmSync(runDir, { recursive: true, force: true });
@@ -346,10 +286,7 @@ export function deleteHistoryEntry(
346
286
  return true;
347
287
  }
348
288
 
349
- /**
350
- * Read history entries from history.jsonl with pagination.
351
- * Returns entries sorted most-recent-first.
352
- */
289
+ /** Returns entries most-recent-first. */
353
290
  export function readHistory(
354
291
  projectRoot: string,
355
292
  opts: { offset?: number; limit?: number; task_id?: string },