palmier 0.6.0 → 0.6.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 (110) hide show
  1. package/.github/workflows/publish.yml +15 -2
  2. package/CLAUDE.md +2 -2
  3. package/DISCLAIMER.md +36 -0
  4. package/README.md +76 -87
  5. package/dist/agents/agent-instructions.md +1 -1
  6. package/dist/agents/agent.d.ts +2 -0
  7. package/dist/agents/agent.js +21 -0
  8. package/dist/agents/aider.d.ts +9 -0
  9. package/dist/agents/aider.js +32 -0
  10. package/dist/agents/cursor.d.ts +9 -0
  11. package/dist/agents/cursor.js +35 -0
  12. package/dist/agents/deepagents.d.ts +9 -0
  13. package/dist/agents/deepagents.js +35 -0
  14. package/dist/agents/droid.d.ts +9 -0
  15. package/dist/agents/droid.js +32 -0
  16. package/dist/agents/goose.d.ts +9 -0
  17. package/dist/agents/goose.js +32 -0
  18. package/dist/agents/opencode.d.ts +9 -0
  19. package/dist/agents/opencode.js +35 -0
  20. package/dist/agents/openhands.d.ts +9 -0
  21. package/dist/agents/openhands.js +35 -0
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +1 -1
  24. package/dist/commands/run.js +2 -2
  25. package/dist/pwa/apple-touch-icon.png +0 -0
  26. package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
  27. package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
  28. package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  29. package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  30. package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  31. package/dist/pwa/favicon.ico +0 -0
  32. package/dist/pwa/index.html +17 -0
  33. package/dist/pwa/manifest.webmanifest +1 -0
  34. package/dist/pwa/pwa-192x192.png +0 -0
  35. package/dist/pwa/pwa-512x512.png +0 -0
  36. package/dist/pwa/registerSW.js +1 -0
  37. package/dist/pwa/service-worker.js +2 -0
  38. package/dist/rpc-handler.d.ts +4 -0
  39. package/dist/rpc-handler.js +5 -4
  40. package/dist/transports/http-transport.js +29 -41
  41. package/package.json +2 -2
  42. package/palmier-server/.github/workflows/ci.yml +21 -0
  43. package/palmier-server/.github/workflows/deploy.yml +38 -0
  44. package/palmier-server/CLAUDE.md +13 -0
  45. package/palmier-server/PRODUCTION.md +355 -0
  46. package/palmier-server/README.md +187 -0
  47. package/palmier-server/nats.conf +15 -0
  48. package/palmier-server/package.json +8 -0
  49. package/palmier-server/pnpm-lock.yaml +6597 -0
  50. package/palmier-server/pnpm-workspace.yaml +3 -0
  51. package/palmier-server/pwa/index.html +16 -0
  52. package/palmier-server/pwa/logo/logo-prompt.md +28 -0
  53. package/palmier-server/pwa/logo/logo_20260330.png +0 -0
  54. package/palmier-server/pwa/package.json +30 -0
  55. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  56. package/palmier-server/pwa/public/favicon.ico +0 -0
  57. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  58. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  59. package/palmier-server/pwa/src/App.css +2387 -0
  60. package/palmier-server/pwa/src/App.tsx +21 -0
  61. package/palmier-server/pwa/src/agentLabels.ts +11 -0
  62. package/palmier-server/pwa/src/api.ts +61 -0
  63. package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
  64. package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
  65. package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
  66. package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
  67. package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
  68. package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
  69. package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
  70. package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
  71. package/palmier-server/pwa/src/constants.ts +2 -0
  72. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
  73. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
  74. package/palmier-server/pwa/src/formatTime.ts +10 -0
  75. package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
  76. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
  77. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
  78. package/palmier-server/pwa/src/main.tsx +14 -0
  79. package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
  80. package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
  81. package/palmier-server/pwa/src/service-worker.ts +139 -0
  82. package/palmier-server/pwa/src/types.ts +79 -0
  83. package/palmier-server/pwa/src/vite-env.d.ts +11 -0
  84. package/palmier-server/pwa/tsconfig.json +21 -0
  85. package/palmier-server/pwa/tsconfig.node.json +19 -0
  86. package/palmier-server/pwa/vite.config.ts +47 -0
  87. package/palmier-server/server/.env.example +16 -0
  88. package/palmier-server/server/package.json +33 -0
  89. package/palmier-server/server/src/db.ts +34 -0
  90. package/palmier-server/server/src/index.ts +219 -0
  91. package/palmier-server/server/src/nats.ts +25 -0
  92. package/palmier-server/server/src/push.ts +68 -0
  93. package/palmier-server/server/src/routes/hosts.ts +45 -0
  94. package/palmier-server/server/src/routes/push.ts +100 -0
  95. package/palmier-server/server/tsconfig.json +20 -0
  96. package/palmier-server/spec.md +415 -0
  97. package/src/agents/agent-instructions.md +1 -1
  98. package/src/agents/agent.ts +23 -0
  99. package/src/agents/aider.ts +37 -0
  100. package/src/agents/cursor.ts +38 -0
  101. package/src/agents/deepagents.ts +38 -0
  102. package/src/agents/droid.ts +37 -0
  103. package/src/agents/goose.ts +35 -0
  104. package/src/agents/opencode.ts +38 -0
  105. package/src/agents/openhands.ts +38 -0
  106. package/src/commands/pair.ts +1 -1
  107. package/src/commands/run.ts +2 -2
  108. package/src/rpc-handler.ts +5 -4
  109. package/src/transports/http-transport.ts +31 -43
  110. package/test/result-state.test.ts +110 -0
@@ -0,0 +1,35 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { getAgentInstructions } from "./shared-prompt.js";
5
+ import { SHELL } from "../platform/index.js";
6
+
7
+ export class GooseAgent implements AgentTool {
8
+ supportsPermissions = false;
9
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
10
+ return {
11
+ command: "goose",
12
+ args: ["run", "--text", prompt],
13
+ };
14
+ }
15
+
16
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[] | "yolo"): CommandLine {
17
+ const yolo = extraPermissions === "yolo";
18
+ const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id, yolo || !this.supportsPermissions) + "\n\n" + (task.body || task.frontmatter.user_prompt));
19
+ const args = ["run"];
20
+
21
+ if (followupPrompt) {args.push("--resume");} // continue mode for followups
22
+ args.push("--text", prompt);
23
+
24
+ return { command: "goose", args, ...(yolo ? { env: { GOOSE_MODE: "auto" } } : {}) };
25
+ }
26
+
27
+ async init(): Promise<boolean> {
28
+ try {
29
+ execSync("goose --version", { stdio: "ignore", shell: SHELL });
30
+ } catch {
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+ }
@@ -0,0 +1,38 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { getAgentInstructions } from "./shared-prompt.js";
5
+ import { SHELL } from "../platform/index.js";
6
+
7
+ export class OpenCodeAgent implements AgentTool {
8
+ supportsPermissions = false;
9
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
10
+ return {
11
+ command: "opencode",
12
+ args: ["run", prompt],
13
+ };
14
+ }
15
+
16
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[] | "yolo"): CommandLine {
17
+ const yolo = extraPermissions === "yolo";
18
+ const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id, yolo || !this.supportsPermissions) + "\n\n" + (task.body || task.frontmatter.user_prompt));
19
+ const args = ["run"];
20
+
21
+ if (yolo) {
22
+ args.push("--dangerously-skip-permissions");
23
+ }
24
+ if (followupPrompt) {args.push("--continue");} // continue mode for followups
25
+ args.push(prompt);
26
+
27
+ return { command: "opencode", args};
28
+ }
29
+
30
+ async init(): Promise<boolean> {
31
+ try {
32
+ execSync("opencode --version", { stdio: "ignore", shell: SHELL });
33
+ } catch {
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ }
@@ -0,0 +1,38 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { getAgentInstructions } from "./shared-prompt.js";
5
+ import { SHELL } from "../platform/index.js";
6
+
7
+ export class OpenHands implements AgentTool {
8
+ supportsPermissions = false;
9
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
10
+ return {
11
+ command: "openhands",
12
+ args: ["--headless", "-t", prompt],
13
+ };
14
+ }
15
+
16
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[] | "yolo"): CommandLine {
17
+ const yolo = extraPermissions === "yolo";
18
+ const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id, yolo || !this.supportsPermissions) + "\n\n" + (task.body || task.frontmatter.user_prompt));
19
+ const args = ["--headless"];
20
+
21
+ if (yolo) {
22
+ args.push("--always-approve");
23
+ }
24
+ if (followupPrompt) {args.push("--resume", "--last");} // continue mode for followups
25
+ args.push("-t", prompt);
26
+
27
+ return { command: "openhands", args};
28
+ }
29
+
30
+ async init(): Promise<boolean> {
31
+ try {
32
+ execSync("openhands --version", { stdio: "ignore", shell: SHELL });
33
+ } catch {
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ }
@@ -61,7 +61,7 @@ function httpPairRegister(port: number, code: string): Promise<boolean> {
61
61
  }
62
62
 
63
63
  /**
64
- * Generate an OTP code and wait for a PWA client to pair.
64
+ * Generate a pairing code and wait for a PWA client to pair.
65
65
  * Listens on NATS (server mode) and HTTP (via serve daemon) in parallel.
66
66
  */
67
67
  export async function pairCommand(): Promise<void> {
@@ -62,7 +62,7 @@ async function invokeAgentWithRetries(
62
62
  }, 500);
63
63
  }
64
64
 
65
- const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(
65
+ const { command, args, stdin, env: agentEnv } = ctx.agent.getTaskRunCommandLine(
66
66
  invokeTask, undefined, ctx.task.frontmatter.yolo_mode ? "yolo" : ctx.transientPermissions,
67
67
  );
68
68
  const truncate = (s: string, max = 100) => s.length > max ? s.slice(0, max) + "…" : s;
@@ -70,7 +70,7 @@ async function invokeAgentWithRetries(
70
70
  console.log(`[invoke] ${command} ${displayArgs.join(" ")}${stdin ? ` (stdin: ${truncate(stdin, 100)})` : ""}`);
71
71
  const result = await spawnCommand(command, args, {
72
72
  cwd: getRunDir(ctx.taskDir, ctx.runId),
73
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
73
+ env: { ...ctx.guiEnv, ...agentEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
74
74
  echoStdout: true,
75
75
  resolveOnFailure: true,
76
76
  stdin,
@@ -27,7 +27,7 @@ const PLAN_GENERATION_PROMPT = fs.readFileSync(
27
27
  /**
28
28
  * Parse RESULT frontmatter and conversation messages.
29
29
  */
30
- function parseResultFrontmatter(raw: string): Record<string, unknown> {
30
+ export function parseResultFrontmatter(raw: string): Record<string, unknown> {
31
31
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
32
32
  if (!fmMatch) return { messages: [] };
33
33
 
@@ -124,13 +124,14 @@ async function generatePlan(
124
124
  ): Promise<{ name: string; body: string }> {
125
125
  const fullPrompt = PLAN_GENERATION_PROMPT + userPrompt;
126
126
  const planAgent = getAgent(agentName);
127
- const { command, args, stdin } = planAgent.getPlanGenerationCommandLine(fullPrompt);
127
+ const { command, args, stdin, env: agentEnv } = planAgent.getPlanGenerationCommandLine(fullPrompt);
128
128
  console.log(`[generatePlan] Running: ${command} ${args.join(" ")}`);
129
129
 
130
130
  const { output } = await spawnCommand(command, args, {
131
131
  cwd: projectRoot,
132
132
  timeout: 120_000,
133
133
  stdin,
134
+ ...(agentEnv ? { env: agentEnv } : {}),
134
135
  });
135
136
 
136
137
  let name = "";
@@ -423,7 +424,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
423
424
 
424
425
  // Fire-and-forget: invoke agent inline as a child of the serve process
425
426
  const followupAgent = getAgent(followupTask.frontmatter.agent);
426
- const { command: cmd, args: cmdArgs, stdin } = followupAgent.getTaskRunCommandLine(
427
+ const { command: cmd, args: cmdArgs, stdin, env: followupAgentEnv } = followupAgent.getTaskRunCommandLine(
427
428
  followupTask, params.message, followupTask.frontmatter.yolo_mode ? "yolo" : followupTask.frontmatter.permissions,
428
429
  );
429
430
 
@@ -431,7 +432,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
431
432
  const child = crossSpawn(cmd, cmdArgs, {
432
433
  cwd: followupRunDir,
433
434
  stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
434
- env: { ...process.env, PALMIER_TASK_ID: params.id },
435
+ env: { ...process.env, ...followupAgentEnv, PALMIER_TASK_ID: params.id },
435
436
  windowsHide: true,
436
437
  });
437
438
  if (stdin != null) child.stdin!.end(stdin);
@@ -1,5 +1,6 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "os";
3
+ import * as path from "node:path";
3
4
  import { StringCodec, type NatsConnection } from "nats";
4
5
  import { validateClient, addClient } from "../client-store.js";
5
6
  import { registerPending } from "../pending-requests.js";
@@ -7,9 +8,7 @@ import * as fs from "node:fs";
7
8
  import { getTaskDir, parseTaskFile, spliceUserMessage } from "../task.js";
8
9
  import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
9
10
 
10
- const PWA_ORIGIN = "https://app.palmier.me";
11
-
12
- // ── On-the-fly PWA asset cache ──────────────────────────────────────────
11
+ // ── Bundled PWA asset serving ───────────────────────────────────────────
13
12
 
14
13
  interface CachedAsset {
15
14
  data: Buffer;
@@ -17,8 +16,8 @@ interface CachedAsset {
17
16
  }
18
17
 
19
18
  const assetCache = new Map<string, CachedAsset>();
20
- /** Paths currently being fetched (dedup concurrent requests). */
21
- const assetInflight = new Map<string, Promise<CachedAsset | null>>();
19
+
20
+ const PWA_DIR = path.join(import.meta.dirname, "..", "pwa");
22
21
 
23
22
  const CONTENT_TYPES: Record<string, string> = {
24
23
  ".html": "text/html; charset=utf-8",
@@ -30,6 +29,7 @@ const CONTENT_TYPES: Record<string, string> = {
30
29
  ".woff2": "font/woff2",
31
30
  ".woff": "font/woff",
32
31
  ".svg": "image/svg+xml",
32
+ ".webmanifest": "application/manifest+json",
33
33
  };
34
34
 
35
35
  function guessContentType(urlPath: string): string {
@@ -38,45 +38,32 @@ function guessContentType(urlPath: string): string {
38
38
  return CONTENT_TYPES[ext] ?? "application/octet-stream";
39
39
  }
40
40
 
41
- async function fetchBuffer(url: string): Promise<Buffer> {
42
- const res = await fetch(url);
43
- if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
44
- return Buffer.from(await res.arrayBuffer());
45
- }
46
-
47
41
  /**
48
- * Fetch a PWA asset on-the-fly, caching in memory.
49
- * Returns null if the asset cannot be fetched.
42
+ * Read a PWA asset from the bundled pwa/ directory, caching in memory.
43
+ * Returns null if the file does not exist.
50
44
  */
51
- async function getAsset(urlPath: string): Promise<CachedAsset | null> {
45
+ function getAsset(urlPath: string): CachedAsset | null {
52
46
  const cached = assetCache.get(urlPath);
53
47
  if (cached) return cached;
54
48
 
55
- // Dedup concurrent requests for the same path
56
- const inflight = assetInflight.get(urlPath);
57
- if (inflight) return inflight;
58
-
59
- const promise = (async () => {
60
- try {
61
- let data = await fetchBuffer(`${PWA_ORIGIN}${urlPath}`);
62
- // Inject LAN mode marker into index HTML so the PWA can detect it's served by palmier
63
- if (urlPath === "/") {
64
- const html = data.toString("utf-8").replace("</head>", "<script>window.__PALMIER_SERVE__=true</script></head>");
65
- data = Buffer.from(html, "utf-8");
66
- }
67
- const asset: CachedAsset = { data, contentType: guessContentType(urlPath) };
68
- assetCache.set(urlPath, asset);
69
- return asset;
70
- } catch (err) {
71
- console.warn(`[pwa] Failed to fetch ${urlPath}: ${err}`);
72
- return null;
73
- } finally {
74
- assetInflight.delete(urlPath);
75
- }
76
- })();
49
+ const filePath = path.join(PWA_DIR, urlPath === "/" ? "index.html" : urlPath);
77
50
 
78
- assetInflight.set(urlPath, promise);
79
- return promise;
51
+ // Prevent path traversal
52
+ if (!filePath.startsWith(PWA_DIR)) return null;
53
+
54
+ try {
55
+ let data = fs.readFileSync(filePath);
56
+ // Inject marker into index HTML so the PWA can detect it's served by palmier
57
+ if (urlPath === "/") {
58
+ const html = data.toString("utf-8").replace("</head>", "<script>window.__PALMIER_SERVE__=true</script></head>");
59
+ data = Buffer.from(html, "utf-8");
60
+ }
61
+ const asset: CachedAsset = { data, contentType: guessContentType(urlPath) };
62
+ assetCache.set(urlPath, asset);
63
+ return asset;
64
+ } catch {
65
+ return null;
66
+ }
80
67
  }
81
68
 
82
69
  type SseClient = http.ServerResponse;
@@ -238,11 +225,12 @@ export async function startHttpTransport(
238
225
 
239
226
  try {
240
227
  const body = await readBody(req);
241
- const { title, body: notifBody } = JSON.parse(body) as { title: string; body: string };
228
+ const { taskId: notifTaskId, title, body: notifBody } = JSON.parse(body) as { taskId?: string; title: string; body: string };
242
229
  if (!title || !notifBody) { sendJson(res, 400, { error: "title and body are required" }); return; }
243
230
 
244
231
  const sc = StringCodec();
245
- const payload = { hostId: config.hostId, title, body: notifBody };
232
+ const payload: Record<string, string> = { hostId: config.hostId, title, body: notifBody };
233
+ if (notifTaskId) payload.task_id = notifTaskId;
246
234
  const subject = `host.${config.hostId}.push.send`;
247
235
  const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
248
236
  const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
@@ -383,7 +371,7 @@ export async function startHttpTransport(
383
371
  return;
384
372
  }
385
373
 
386
- // ── Public pair endpoint — no auth, PWA posts OTP code here ────────
374
+ // ── Public pair endpoint — no auth, PWA posts pairing code here ────────
387
375
 
388
376
  if (req.method === "POST" && pathname === "/pair") {
389
377
  try {
@@ -421,9 +409,9 @@ export async function startHttpTransport(
421
409
  if (SKIP.has(pathname)) { sendJson(res, 404, { error: "Not found" }); return; }
422
410
 
423
411
  // Try exact path, then fall back to index.html (SPA routing)
424
- let asset = await getAsset(pathname);
412
+ let asset = getAsset(pathname);
425
413
  if (!asset && pathname !== "/") {
426
- asset = await getAsset("/");
414
+ asset = getAsset("/");
427
415
  }
428
416
 
429
417
  if (asset) {
@@ -0,0 +1,110 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ import {
7
+ createRunDir,
8
+ appendRunMessage,
9
+ beginStreamingMessage,
10
+ } from "../src/task.js";
11
+ import { parseResultFrontmatter } from "../src/rpc-handler.js";
12
+
13
+ let taskDir: string;
14
+ let runId: string;
15
+
16
+ function setup() {
17
+ taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "palmier-test-"));
18
+ runId = createRunDir(taskDir, "Test Task", 1000, "claude");
19
+ }
20
+
21
+ function readRaw(): string {
22
+ return fs.readFileSync(path.join(taskDir, runId, "TASKRUN.md"), "utf-8");
23
+ }
24
+
25
+ describe("parseResultFrontmatter — monitoring state", () => {
26
+ beforeEach(setup);
27
+
28
+ it("returns 'monitoring' when monitoring is the last message", () => {
29
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
30
+ appendRunMessage(taskDir, runId, { role: "status", time: 1001, content: "", type: "monitoring" });
31
+
32
+ const result = parseResultFrontmatter(readRaw());
33
+ assert.equal(result.running_state, "monitoring");
34
+ });
35
+
36
+ it("returns 'started' when an assistant message follows monitoring", () => {
37
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
38
+ appendRunMessage(taskDir, runId, { role: "status", time: 1001, content: "", type: "monitoring" });
39
+ const writer = beginStreamingMessage(taskDir, runId, 1002);
40
+ writer.write("Working on it...");
41
+ writer.end();
42
+
43
+ const result = parseResultFrontmatter(readRaw());
44
+ assert.equal(result.running_state, "started");
45
+ });
46
+
47
+ it("returns 'monitoring' after agent finishes and monitoring resumes", () => {
48
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
49
+ appendRunMessage(taskDir, runId, { role: "status", time: 1001, content: "", type: "monitoring" });
50
+ // Agent processes a line
51
+ const writer = beginStreamingMessage(taskDir, runId, 1002);
52
+ writer.write("Done processing line.");
53
+ writer.end();
54
+ // Back to monitoring
55
+ appendRunMessage(taskDir, runId, { role: "status", time: 1003, content: "", type: "monitoring" });
56
+
57
+ const result = parseResultFrontmatter(readRaw());
58
+ assert.equal(result.running_state, "monitoring");
59
+ });
60
+
61
+ it("returns 'started' when a user message follows monitoring", () => {
62
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
63
+ appendRunMessage(taskDir, runId, { role: "status", time: 1001, content: "", type: "monitoring" });
64
+ appendRunMessage(taskDir, runId, { role: "user", time: 1002, content: "some input" });
65
+
66
+ const result = parseResultFrontmatter(readRaw());
67
+ assert.equal(result.running_state, "started");
68
+ });
69
+ });
70
+
71
+ describe("parseResultFrontmatter — standard states", () => {
72
+ beforeEach(setup);
73
+
74
+ it("returns 'started' for a running task", () => {
75
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
76
+ appendRunMessage(taskDir, runId, { role: "user", time: 1001, content: "Do something" });
77
+
78
+ const result = parseResultFrontmatter(readRaw());
79
+ assert.equal(result.running_state, "started");
80
+ });
81
+
82
+ it("returns 'finished' for a completed task", () => {
83
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
84
+ appendRunMessage(taskDir, runId, { role: "user", time: 1001, content: "Do something" });
85
+ const writer = beginStreamingMessage(taskDir, runId, 1002);
86
+ writer.write("Done.");
87
+ writer.end();
88
+ appendRunMessage(taskDir, runId, { role: "status", time: 1003, content: "", type: "finished" });
89
+
90
+ const result = parseResultFrontmatter(readRaw());
91
+ assert.equal(result.running_state, "finished");
92
+ });
93
+
94
+ it("returns 'failed' for a failed task", () => {
95
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
96
+ appendRunMessage(taskDir, runId, { role: "status", time: 1001, content: "", type: "failed" });
97
+
98
+ const result = parseResultFrontmatter(readRaw());
99
+ assert.equal(result.running_state, "failed");
100
+ });
101
+
102
+ it("returns 'followup' when started again after terminal state", () => {
103
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
104
+ appendRunMessage(taskDir, runId, { role: "status", time: 1001, content: "", type: "finished" });
105
+ appendRunMessage(taskDir, runId, { role: "status", time: 1002, content: "", type: "started" });
106
+
107
+ const result = parseResultFrontmatter(readRaw());
108
+ assert.equal(result.running_state, "followup");
109
+ });
110
+ });