openclaw-node-harness 2.0.4 → 2.1.1

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 (134) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/lane-watchdog.js +23 -2
  4. package/bin/mesh-agent.js +439 -28
  5. package/bin/mesh-bridge.js +69 -3
  6. package/bin/mesh-health-publisher.js +41 -1
  7. package/bin/mesh-task-daemon.js +821 -26
  8. package/bin/mesh.js +411 -20
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +296 -10
  29. package/lib/agent-activity.js +2 -2
  30. package/lib/circling-parser.js +119 -0
  31. package/lib/exec-safety.js +105 -0
  32. package/lib/hyperagent-store.mjs +652 -0
  33. package/lib/kanban-io.js +24 -31
  34. package/lib/llm-providers.js +16 -0
  35. package/lib/mcp-knowledge/bench.mjs +118 -0
  36. package/lib/mcp-knowledge/core.mjs +530 -0
  37. package/lib/mcp-knowledge/package.json +25 -0
  38. package/lib/mcp-knowledge/server.mjs +252 -0
  39. package/lib/mcp-knowledge/test.mjs +802 -0
  40. package/lib/memory-budget.mjs +261 -0
  41. package/lib/mesh-collab.js +483 -165
  42. package/lib/mesh-harness.js +427 -0
  43. package/lib/mesh-plans.js +79 -50
  44. package/lib/mesh-tasks.js +132 -49
  45. package/lib/nats-resolve.js +4 -4
  46. package/lib/plan-templates.js +226 -0
  47. package/lib/pre-compression-flush.mjs +322 -0
  48. package/lib/role-loader.js +292 -0
  49. package/lib/rule-loader.js +358 -0
  50. package/lib/session-store.mjs +461 -0
  51. package/lib/transcript-parser.mjs +292 -0
  52. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  53. package/mission-control/drizzle.config.ts +1 -4
  54. package/mission-control/package-lock.json +1571 -83
  55. package/mission-control/package.json +6 -2
  56. package/mission-control/scripts/gen-chronology.js +3 -3
  57. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  58. package/mission-control/scripts/import-pipeline.js +0 -15
  59. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  60. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  61. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  62. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  63. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  64. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  65. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  66. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  67. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  68. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  69. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  70. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  71. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  72. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  73. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  74. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  75. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  76. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  77. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
  78. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  79. package/mission-control/src/app/api/tasks/route.ts +21 -30
  80. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  81. package/mission-control/src/app/cowork/page.tsx +261 -0
  82. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  83. package/mission-control/src/app/graph/page.tsx +26 -0
  84. package/mission-control/src/app/memory/page.tsx +1 -1
  85. package/mission-control/src/app/obsidian/page.tsx +36 -6
  86. package/mission-control/src/app/roadmap/page.tsx +24 -0
  87. package/mission-control/src/app/souls/page.tsx +2 -2
  88. package/mission-control/src/components/board/execution-config.tsx +431 -0
  89. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  90. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  91. package/mission-control/src/components/board/task-card.tsx +55 -2
  92. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  93. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  94. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  95. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  96. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  97. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  98. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  99. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  100. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  101. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  102. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  103. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  104. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  105. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  106. package/mission-control/src/lib/config.ts +67 -0
  107. package/mission-control/src/lib/db/index.ts +85 -1
  108. package/mission-control/src/lib/db/schema.ts +61 -3
  109. package/mission-control/src/lib/hooks.ts +309 -0
  110. package/mission-control/src/lib/memory/entities.ts +3 -2
  111. package/mission-control/src/lib/memory/extract.ts +2 -1
  112. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  113. package/mission-control/src/lib/nats.ts +66 -1
  114. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  115. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  116. package/mission-control/src/lib/scheduler.ts +12 -11
  117. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  118. package/mission-control/src/lib/sync/tasks.ts +23 -1
  119. package/mission-control/src/lib/task-id.ts +32 -0
  120. package/mission-control/src/lib/tts/index.ts +33 -9
  121. package/mission-control/src/middleware.ts +82 -0
  122. package/mission-control/tsconfig.json +2 -1
  123. package/mission-control/vitest.config.ts +14 -0
  124. package/package.json +15 -2
  125. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  126. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  127. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  128. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  129. package/services/service-manifest.json +1 -1
  130. package/skills/cc-godmode/references/agents.md +8 -8
  131. package/uninstall.sh +37 -9
  132. package/workspace-bin/memory-daemon.mjs +199 -5
  133. package/workspace-bin/session-search.mjs +204 -0
  134. package/workspace-bin/web-fetch.mjs +65 -0
@@ -29,14 +29,17 @@ function applyScoreDecay(
29
29
  * Daedalus does deeper semantic rewriting inline during his own searches.
30
30
  */
31
31
  function expandQuery(query: string): string {
32
+ // Escape double quotes and strip FTS5 operators to prevent query injection
33
+ const safeQuery = query.replace(/"/g, '""').replace(/[*(){}^]/g, '').trim();
34
+ if (!safeQuery) return '""';
32
35
  // Split into terms and add OR variants for common patterns
33
- const terms = query.trim().split(/\s+/);
36
+ const terms = safeQuery.split(/\s+/);
34
37
  if (terms.length === 1) {
35
38
  // Single term: use prefix matching
36
- return `"${query.replace(/"/g, '""')}"*`;
39
+ return `"${safeQuery}"*`;
37
40
  }
38
41
  // Multi-term: quote as phrase + add individual terms with OR for broader recall
39
- const phrase = `"${query.replace(/"/g, '""')}"`;
42
+ const phrase = `"${safeQuery}"`;
40
43
  const individual = terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(" OR ");
41
44
  return `(${phrase}) OR (${individual})`;
42
45
  }
@@ -1,10 +1,34 @@
1
- import { getNats, sc } from "@/lib/nats";
1
+ import { getNats, getTasksKv, sc } from "@/lib/nats";
2
2
 
3
3
  export const dynamic = "force-dynamic";
4
4
 
5
5
  /**
6
- * SSE endpoint that subscribes to mesh.events.> and pushes to connected browsers.
7
- * Falls back gracefully if NATS is unavailable.
6
+ * MANUAL VERIFICATION PROTOCOL (SSE dual-iterator cleanup):
7
+ *
8
+ * The zombie watcher leak is the highest-risk bug in this route.
9
+ * It cannot be unit tested (no real HTTP server in Vitest).
10
+ *
11
+ * To verify after deployment:
12
+ * 1. Open MC on worker node in browser
13
+ * 2. Open DevTools > Network tab > filter "events" (SSE stream)
14
+ * 3. Trigger a task state change on the lead (create/complete a task)
15
+ * 4. Verify the SSE event arrives in the stream ("kv.task.updated" event type)
16
+ * 5. Close the browser tab
17
+ * 6. Check NATS connections: curl http://localhost:8222/connz | jq '.connections | length'
18
+ * -> Connection count should drop by 1 (the SSE watcher + sub are cleaned up)
19
+ * 7. Repeat with 3 tabs open -> close all -> verify count drops by 3
20
+ *
21
+ * If connection count doesn't drop: the cancel() handler isn't calling watcher.stop()
22
+ * or sub.unsubscribe(). Check the dual-iterator lifecycle in this file.
23
+ */
24
+
25
+ /**
26
+ * SSE endpoint: dual-iterator (NATS pub/sub + KV watcher).
27
+ *
28
+ * Iterator 1: NATS subscription on mesh.events.> (task lifecycle events)
29
+ * Iterator 2: KV watcher on MESH_TASKS (real-time task state changes)
30
+ *
31
+ * Both feed into a single SSE stream. On client disconnect, both are cleaned up.
8
32
  */
9
33
  export async function GET() {
10
34
  const nc = await getNats();
@@ -13,16 +37,17 @@ export async function GET() {
13
37
  }
14
38
 
15
39
  const sub = nc.subscribe("mesh.events.>");
40
+ const kv = await getTasksKv();
41
+ let watcher: any = null;
16
42
  let closed = false;
17
43
 
18
44
  const stream = new ReadableStream({
19
45
  async start(controller) {
20
46
  const encoder = new TextEncoder();
21
47
 
22
- // Send initial keepalive
23
48
  controller.enqueue(encoder.encode(": connected\n\n"));
24
49
 
25
- // Keepalive every 30s to prevent proxy/browser timeout
50
+ // Keepalive every 30s
26
51
  const keepalive = setInterval(() => {
27
52
  if (!closed) {
28
53
  try {
@@ -33,26 +58,77 @@ export async function GET() {
33
58
  }
34
59
  }, 30000);
35
60
 
36
- try {
37
- for await (const msg of sub) {
38
- if (closed) break;
39
- try {
40
- const data = sc.decode(msg.data);
41
- const eventType = msg.subject.replace("mesh.events.", "");
42
- controller.enqueue(
43
- encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`)
44
- );
45
- } catch {
46
- // skip malformed messages
61
+ // Iterator 1: NATS pub/sub events
62
+ (async () => {
63
+ try {
64
+ for await (const msg of sub) {
65
+ if (closed) break;
66
+ try {
67
+ const data = sc.decode(msg.data);
68
+ const eventType = msg.subject.replace("mesh.events.", "");
69
+ controller.enqueue(
70
+ encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`)
71
+ );
72
+ } catch {
73
+ // skip malformed
74
+ }
47
75
  }
76
+ } catch {
77
+ // subscription ended
78
+ }
79
+ })();
80
+
81
+ // Iterator 2: KV watcher (task state changes)
82
+ if (kv) {
83
+ try {
84
+ watcher = await kv.watch();
85
+ (async () => {
86
+ try {
87
+ for await (const entry of watcher) {
88
+ if (closed) break;
89
+ try {
90
+ const task = entry.value
91
+ ? JSON.parse(sc.decode(entry.value))
92
+ : null;
93
+ const payload = JSON.stringify({
94
+ key: entry.key,
95
+ operation: entry.value ? "PUT" : "DEL",
96
+ task,
97
+ revision: entry.revision,
98
+ });
99
+ controller.enqueue(
100
+ encoder.encode(`event: kv.task.updated\ndata: ${payload}\n\n`)
101
+ );
102
+ } catch {
103
+ // skip malformed
104
+ }
105
+ }
106
+ } catch {
107
+ // watcher ended
108
+ }
109
+ })();
110
+ } catch {
111
+ // KV watch failed — continue with pub/sub only
48
112
  }
49
- } finally {
50
- clearInterval(keepalive);
51
113
  }
114
+
115
+ // Wait for cancel (client disconnect)
116
+ await new Promise<void>((resolve) => {
117
+ const check = setInterval(() => {
118
+ if (closed) {
119
+ clearInterval(check);
120
+ clearInterval(keepalive);
121
+ resolve();
122
+ }
123
+ }, 1000);
124
+ });
52
125
  },
53
126
  cancel() {
54
127
  closed = true;
55
128
  sub.unsubscribe();
129
+ if (watcher && typeof watcher.stop === "function") {
130
+ watcher.stop();
131
+ }
56
132
  },
57
133
  });
58
134
 
@@ -61,7 +137,7 @@ export async function GET() {
61
137
  "Content-Type": "text/event-stream",
62
138
  "Cache-Control": "no-cache, no-transform",
63
139
  Connection: "keep-alive",
64
- "X-Accel-Buffering": "no", // disable nginx buffering
140
+ "X-Accel-Buffering": "no",
65
141
  },
66
142
  });
67
143
  }
@@ -0,0 +1,11 @@
1
+ import { NODE_ID, NODE_ROLE, NODE_PLATFORM } from "@/lib/config";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ export async function GET() {
6
+ return Response.json({
7
+ nodeId: NODE_ID,
8
+ role: NODE_ROLE,
9
+ platform: NODE_PLATFORM,
10
+ });
11
+ }
@@ -0,0 +1,92 @@
1
+ import { getTasksKv, sc } from "@/lib/nats";
2
+ import { NODE_ID, NODE_ROLE } from "@/lib/config";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ /**
7
+ * GET /api/mesh/tasks/:id — Get a single task from KV.
8
+ */
9
+ export async function GET(
10
+ _req: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const { id } = await params;
14
+ const kv = await getTasksKv();
15
+ if (!kv) {
16
+ return Response.json({ error: "NATS unavailable" }, { status: 503 });
17
+ }
18
+
19
+ const entry = await kv.get(id);
20
+ if (!entry?.value) {
21
+ return Response.json({ error: "not found" }, { status: 404 });
22
+ }
23
+
24
+ const task = JSON.parse(sc.decode(entry.value));
25
+ return Response.json({ task, revision: entry.revision });
26
+ }
27
+
28
+ /**
29
+ * PATCH /api/mesh/tasks/:id — Update a task with CAS.
30
+ *
31
+ * Authority rules:
32
+ * - Lead can update any task
33
+ * - Workers can only update tasks they originated
34
+ * - Revision must match (CAS) to prevent stale writes
35
+ */
36
+ export async function PATCH(
37
+ req: Request,
38
+ { params }: { params: Promise<{ id: string }> }
39
+ ) {
40
+ const { id } = await params;
41
+ const kv = await getTasksKv();
42
+ if (!kv) {
43
+ return Response.json({ error: "NATS unavailable" }, { status: 503 });
44
+ }
45
+
46
+ const body = await req.json();
47
+ const { revision, ...updates } = body;
48
+
49
+ if (!revision) {
50
+ return Response.json({ error: "revision is required for CAS update" }, { status: 400 });
51
+ }
52
+
53
+ // Read current state
54
+ const entry = await kv.get(id);
55
+ if (!entry?.value) {
56
+ return Response.json({ error: "not found" }, { status: 404 });
57
+ }
58
+
59
+ const current = JSON.parse(sc.decode(entry.value));
60
+
61
+ // Authority check
62
+ if (NODE_ROLE !== "lead" && current.origin !== NODE_ID) {
63
+ return Response.json(
64
+ { error: "workers can only update tasks they originated" },
65
+ { status: 403 }
66
+ );
67
+ }
68
+
69
+ // Merge updates
70
+ const updated = { ...current, ...updates };
71
+
72
+ // CAS write
73
+ try {
74
+ await kv.update(id, sc.encode(JSON.stringify(updated)), revision);
75
+ } catch (err: any) {
76
+ // Revision mismatch — return current state so client can retry
77
+ const freshEntry = await kv.get(id);
78
+ const freshTask = freshEntry?.value
79
+ ? JSON.parse(sc.decode(freshEntry.value))
80
+ : null;
81
+ return Response.json(
82
+ {
83
+ error: `revision mismatch: expected ${revision}, got ${entry.revision}`,
84
+ currentTask: freshTask,
85
+ currentRevision: freshEntry?.revision,
86
+ },
87
+ { status: 409 }
88
+ );
89
+ }
90
+
91
+ return Response.json({ ok: true, task: updated });
92
+ }
@@ -0,0 +1,91 @@
1
+ import { getTasksKv, sc } from "@/lib/nats";
2
+ import { NODE_ID, NODE_ROLE } from "@/lib/config";
3
+ import crypto from "crypto";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ /**
8
+ * GET /api/mesh/tasks — List all tasks from NATS KV.
9
+ * Available on all nodes (read from shared KV).
10
+ */
11
+ export async function GET() {
12
+ const kv = await getTasksKv();
13
+ if (!kv) {
14
+ return Response.json({ tasks: [], natsAvailable: false });
15
+ }
16
+
17
+ const tasks: any[] = [];
18
+ const keys = await kv.keys();
19
+ for await (const key of keys) {
20
+ const entry = await kv.get(key);
21
+ if (!entry?.value) continue;
22
+ try {
23
+ tasks.push(JSON.parse(sc.decode(entry.value)));
24
+ } catch {
25
+ // skip malformed
26
+ }
27
+ }
28
+
29
+ tasks.sort((a, b) => {
30
+ if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
31
+ return new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime();
32
+ });
33
+
34
+ return Response.json({ tasks, natsAvailable: true });
35
+ }
36
+
37
+ /**
38
+ * POST /api/mesh/tasks — Propose a new task.
39
+ *
40
+ * On lead: creates directly with status "queued".
41
+ * On worker: creates with status "proposed" — daemon validates within 30s.
42
+ */
43
+ export async function POST(req: Request) {
44
+ const kv = await getTasksKv();
45
+ if (!kv) {
46
+ return Response.json({ error: "NATS unavailable" }, { status: 503 });
47
+ }
48
+
49
+ const body = await req.json();
50
+ if (!body.title) {
51
+ return Response.json({ error: "title is required" }, { status: 400 });
52
+ }
53
+
54
+ const suffix = crypto.randomBytes(3).toString("hex");
55
+ const now = new Date();
56
+ const dateStr =
57
+ now.getFullYear().toString() +
58
+ (now.getMonth() + 1).toString().padStart(2, "0") +
59
+ now.getDate().toString().padStart(2, "0");
60
+ const taskId = body.task_id || `T-${dateStr}-${suffix}`;
61
+
62
+ const status = NODE_ROLE === "lead" ? "queued" : "proposed";
63
+
64
+ const task = {
65
+ task_id: taskId,
66
+ title: body.title,
67
+ description: body.description || "",
68
+ status,
69
+ origin: NODE_ID,
70
+ owner: null,
71
+ priority: body.priority || 0,
72
+ budget_minutes: body.budget_minutes || 30,
73
+ metric: body.metric || null,
74
+ success_criteria: body.success_criteria || [],
75
+ scope: body.scope || [],
76
+ tags: body.tags || [],
77
+ preferred_nodes: body.preferred_nodes || [],
78
+ exclude_nodes: body.exclude_nodes || [],
79
+ created_at: now.toISOString(),
80
+ claimed_at: null,
81
+ started_at: null,
82
+ completed_at: null,
83
+ last_activity: null,
84
+ result: null,
85
+ attempts: [],
86
+ };
87
+
88
+ await kv.put(taskId, sc.encode(JSON.stringify(task)));
89
+
90
+ return Response.json({ ok: true, task }, { status: 201 });
91
+ }
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { soulEvolutionLog } from "@/lib/db/schema";
4
5
  import { eq, desc } from "drizzle-orm";
5
6
  import fs from "fs/promises";
@@ -31,7 +32,12 @@ export async function GET(
31
32
  { params }: { params: Promise<{ id: string }> }
32
33
  ) {
33
34
  try {
34
- const { id: soulId } = await params;
35
+ let soulId: string;
36
+ try {
37
+ soulId = validatePathParam((await params).id);
38
+ } catch {
39
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
40
+ }
35
41
  const { searchParams } = new URL(request.url);
36
42
  const status = searchParams.get("status") || "pending";
37
43
 
@@ -93,7 +99,12 @@ export async function POST(
93
99
  { params }: { params: Promise<{ id: string }> }
94
100
  ) {
95
101
  try {
96
- const { id: soulId } = await params;
102
+ let soulId: string;
103
+ try {
104
+ soulId = validatePathParam((await params).id);
105
+ } catch {
106
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
107
+ }
97
108
  const event: EvolutionEvent = await request.json();
98
109
 
99
110
  const db = getDb();
@@ -133,7 +144,12 @@ export async function PATCH(
133
144
  { params }: { params: Promise<{ id: string }> }
134
145
  ) {
135
146
  try {
136
- const { id: soulId } = await params;
147
+ let soulId: string;
148
+ try {
149
+ soulId = validatePathParam((await params).id);
150
+ } catch {
151
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
152
+ }
137
153
  const { searchParams } = new URL(request.url);
138
154
  const eventId = searchParams.get("eventId");
139
155
 
@@ -170,11 +186,12 @@ export async function PATCH(
170
186
  }
171
187
 
172
188
  // Apply change (e.g., update genes.json)
189
+ const safeTarget = validatePathParam(event.proposedChange.target);
173
190
  const targetPath = path.join(
174
191
  SOULS_DIR,
175
192
  soulId,
176
193
  "evolution",
177
- event.proposedChange.target
194
+ safeTarget
178
195
  );
179
196
 
180
197
  if (event.proposedChange.action === "add") {
@@ -189,7 +206,6 @@ export async function PATCH(
189
206
  // Sanitize all user-derived inputs: eventId, soulId, reviewedBy, event.summary
190
207
  // could all contain shell metacharacters if crafted maliciously.
191
208
  const safeBranch = `evolution/${eventId.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
192
- const safeTarget = event.proposedChange.target.replace(/[^a-zA-Z0-9._/-]/g, "_");
193
209
  const commitMessage = [
194
210
  `evolution(${eventId}): ${event.summary}`,
195
211
  "",
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { soulSpawns } from "@/lib/db/schema";
4
5
  import fs from "fs/promises";
5
6
  import path from "path";
@@ -38,7 +39,12 @@ export async function POST(
38
39
  { params }: { params: Promise<{ id: string }> }
39
40
  ) {
40
41
  try {
41
- const { id: soulId } = await params;
42
+ let soulId: string;
43
+ try {
44
+ soulId = validatePathParam((await params).id);
45
+ } catch {
46
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
47
+ }
42
48
  const body: PromptRequest = await request.json();
43
49
 
44
50
  const soulDir = path.join(SOULS_DIR, soulId);
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { soulEvolutionLog } from "@/lib/db/schema";
4
5
  import { eq } from "drizzle-orm";
5
6
  import fs from "fs/promises";
@@ -19,9 +20,20 @@ export async function POST(
19
20
  { params }: { params: Promise<{ id: string }> }
20
21
  ) {
21
22
  try {
22
- const { id: sourceSoulId } = await params;
23
+ let sourceSoulId: string;
24
+ try {
25
+ sourceSoulId = validatePathParam((await params).id);
26
+ } catch {
27
+ return NextResponse.json({ error: "Invalid source soul ID" }, { status: 400 });
28
+ }
23
29
  const body: PropagateRequest = await request.json();
24
- const { sourceEventId, targetSoulId } = body;
30
+ let targetSoulId: string;
31
+ try {
32
+ targetSoulId = validatePathParam(body.targetSoulId);
33
+ } catch {
34
+ return NextResponse.json({ error: "Invalid target soul ID" }, { status: 400 });
35
+ }
36
+ const { sourceEventId } = body;
25
37
 
26
38
  // Validate target soul exists
27
39
  const targetSoulDir = path.join(SOULS_DIR, targetSoulId);
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { tasks, soulHandoffs } from "@/lib/db/schema";
4
5
  import { eq } from "drizzle-orm";
5
6
  import fs from "fs/promises";
@@ -27,7 +28,12 @@ export async function POST(
27
28
  { params }: { params: Promise<{ id: string }> }
28
29
  ) {
29
30
  try {
30
- const { id: taskId } = await params;
31
+ let taskId: string;
32
+ try {
33
+ taskId = validatePathParam((await params).id);
34
+ } catch {
35
+ return NextResponse.json({ error: "Invalid task ID" }, { status: 400 });
36
+ }
31
37
  const body: HandoffRequest = await request.json();
32
38
 
33
39
  const db = getDb();
@@ -39,7 +45,7 @@ export async function POST(
39
45
  return NextResponse.json({ error: "Task not found" }, { status: 404 });
40
46
  }
41
47
 
42
- const fromSoul = task.soulId || "daedalus";
48
+ const fromSoul = task.soulId || "main-agent";
43
49
 
44
50
  // Create handoff document in ClawVault
45
51
  await fs.mkdir(HANDOFFS_DIR, { recursive: true });
@@ -6,6 +6,8 @@ import { statusToKanban, kanbanToStatus } from "@/lib/parsers/task-markdown";
6
6
  import { syncTasksToMarkdown } from "@/lib/sync/tasks";
7
7
  import { logActivity } from "@/lib/activity";
8
8
  import { gatewayNotify } from "@/lib/gateway-notify";
9
+ import { AGENT_NAME, HUMAN_NAME } from "@/lib/config";
10
+ import { getNats, sc } from "@/lib/nats";
9
11
 
10
12
  /**
11
13
  * Push a notification message to the OpenClaw TUI via gateway chat.send + abort.
@@ -17,6 +19,22 @@ function notifyAgent(text: string) {
17
19
  });
18
20
  }
19
21
 
22
+ /**
23
+ * Cancel a running mesh task via NATS. Best-effort — ignores failures.
24
+ */
25
+ async function cancelMeshTask(taskId: string, execution: string | null, status: string) {
26
+ if (execution === "mesh" && status !== "done" && status !== "cancelled") {
27
+ const nc = await getNats();
28
+ if (nc) {
29
+ nc.request(
30
+ "mesh.tasks.cancel",
31
+ sc.encode(JSON.stringify({ task_id: taskId })),
32
+ { timeout: 5000 }
33
+ ).catch(() => {});
34
+ }
35
+ }
36
+ }
37
+
20
38
  /**
21
39
  * DELETE /api/tasks/[id]
22
40
  * Delete a task (and its children) by ID.
@@ -58,6 +76,9 @@ export async function DELETE(
58
76
  collectDescendants(id);
59
77
  idsToDelete.push(id); // add the root task last
60
78
 
79
+ // Cancel mesh task if running
80
+ await cancelMeshTask(id, existing.execution, existing.status);
81
+
61
82
  // Delete all collected tasks + their dependency edges
62
83
  for (const delId of idsToDelete) {
63
84
  db.delete(dependencies)
@@ -109,6 +130,22 @@ export async function PATCH(
109
130
  const body = await request.json();
110
131
  const now = new Date().toISOString();
111
132
 
133
+ // Transition guard: block execution mode changes on in-flight mesh tasks
134
+ if (body.execution !== undefined && body.execution !== existing.execution) {
135
+ if (existing.meshTaskId) {
136
+ return NextResponse.json(
137
+ { error: "Cannot change execution mode after task has been submitted to mesh" },
138
+ { status: 400 }
139
+ );
140
+ }
141
+ if (existing.status !== "queued" && existing.status !== "ready") {
142
+ return NextResponse.json(
143
+ { error: `Cannot change execution mode while task is ${existing.status}` },
144
+ { status: 400 }
145
+ );
146
+ }
147
+ }
148
+
112
149
  // Build the update set from provided fields
113
150
  const update: Record<string, unknown> = { updatedAt: now };
114
151
 
@@ -130,6 +167,17 @@ export async function PATCH(
130
167
  update.status = kanbanToStatus(body.kanbanColumn);
131
168
  }
132
169
  }
170
+
171
+ // Done-gate: only the human operator can mark tasks as done (via force_done flag).
172
+ // Without force_done, redirect done→review so tasks land in waiting-user.
173
+ const targetStatus = update.status as string | undefined;
174
+ const targetColumn = update.kanbanColumn as string | undefined;
175
+ if (!body.force_done) {
176
+ if (targetStatus === "done" || targetColumn === "done") {
177
+ update.status = "waiting-user";
178
+ update.kanbanColumn = "review";
179
+ }
180
+ }
133
181
  if (body.owner !== undefined) {
134
182
  update.owner = body.owner || null;
135
183
  }
@@ -203,18 +251,56 @@ export async function PATCH(
203
251
  if (body.acknowledged_at !== undefined) {
204
252
  update.acknowledgedAt = body.acknowledged_at || null;
205
253
  }
254
+ // Mesh execution fields
255
+ if (body.execution !== undefined) {
256
+ update.execution = body.execution || null;
257
+ }
258
+ if (body.collaboration !== undefined) {
259
+ update.collaboration = body.collaboration ? JSON.stringify(body.collaboration) : null;
260
+ }
261
+ if (body.preferred_nodes !== undefined) {
262
+ update.preferredNodes = body.preferred_nodes?.length ? JSON.stringify(body.preferred_nodes) : null;
263
+ }
264
+ if (body.exclude_nodes !== undefined) {
265
+ update.excludeNodes = body.exclude_nodes?.length ? JSON.stringify(body.exclude_nodes) : null;
266
+ }
267
+ if (body.cluster_id !== undefined) {
268
+ update.clusterId = body.cluster_id || null;
269
+ }
270
+ if (body.metric !== undefined) {
271
+ update.metric = body.metric || null;
272
+ }
273
+ if (body.budget_minutes !== undefined) {
274
+ update.budgetMinutes = body.budget_minutes;
275
+ }
276
+ if (body.scope !== undefined) {
277
+ update.scope = body.scope?.length ? JSON.stringify(body.scope) : null;
278
+ }
206
279
 
207
280
  // Auto-set owner when task moves to running manually (no explicit owner change)
208
281
  const effectiveStatus = (update.status as string) ?? existing.status;
209
282
  if (effectiveStatus === "running" && body.owner === undefined && !existing.owner) {
210
- update.owner = "Gui";
283
+ update.owner = HUMAN_NAME;
211
284
  }
212
285
 
213
286
  db.update(tasks).set(update).where(eq(tasks.id, id)).run();
214
287
 
288
+ // Cancel mesh task if status changed to cancelled
289
+ if (update.status === "cancelled") {
290
+ await cancelMeshTask(id, existing.execution, existing.status);
291
+ }
292
+
215
293
  // Sync back to markdown
216
294
  syncTasksToMarkdown(db);
217
295
 
296
+ // Wake bridge when execution is set to mesh (for immediate pickup)
297
+ const effectiveExecution = (update.execution as string) ?? existing.execution;
298
+ const effectiveApprovalNum = (update.needsApproval as number) ?? existing.needsApproval;
299
+ if (effectiveExecution === "mesh" && effectiveApprovalNum === 0) {
300
+ const nc = await getNats();
301
+ if (nc) nc.publish("mesh.bridge.wake", sc.encode(""));
302
+ }
303
+
218
304
  // Log activity
219
305
  const movedTo = body.kanban_column || body.kanbanColumn;
220
306
  const parts: string[] = [];
@@ -229,14 +315,14 @@ export async function PATCH(
229
315
  id
230
316
  );
231
317
 
232
- // Push notification to TUI only for Daedalus autostart tasks
318
+ // Push notification to TUI only for agent autostart tasks
233
319
  if (movedTo || body.status) {
234
320
  const effectiveOwner = (update.owner as string) ?? existing.owner;
235
321
  const effectiveApproval = (update.needsApproval as number) ?? existing.needsApproval;
236
- const isDaedalus = effectiveOwner === "Daedalus";
322
+ const isAgent = effectiveOwner === AGENT_NAME;
237
323
  const isAutostart = effectiveApproval === 0;
238
324
 
239
- if (isDaedalus && isAutostart) {
325
+ if (isAgent && isAutostart) {
240
326
  const fromCol = existing.kanbanColumn || existing.status;
241
327
  const toCol = movedTo || body.status;
242
328
  notifyAgent(`MC-Kanban: "${existing.title}" has been moved from ${fromCol} to ${toCol}`);