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
@@ -0,0 +1,279 @@
1
+ /**
2
+ * mesh-kv.ts — Sync engine for distributed Mission Control (Phase 2).
3
+ *
4
+ * Bridges NATS KV (mesh truth) ↔ local SQLite (MC database).
5
+ *
6
+ * Responsibilities:
7
+ * - Watch MESH_TASKS KV bucket for real-time updates
8
+ * - Mirror KV entries into local SQLite (upsert with revision tracking)
9
+ * - Push local task changes to KV via CAS writes
10
+ * - Handle conflict resolution (KV wins on workers, SQLite wins on lead)
11
+ *
12
+ * This module is imported by the sync/tasks.ts module and the hooks layer.
13
+ * It does NOT run as a standalone daemon — it's part of the MC process.
14
+ */
15
+
16
+ import { getTasksKv, sc } from "@/lib/nats";
17
+ import { NODE_ID, NODE_ROLE } from "@/lib/config";
18
+
19
+ // ── Types ──
20
+
21
+ export interface MeshTaskEntry {
22
+ task_id: string;
23
+ title: string;
24
+ description: string;
25
+ status: string;
26
+ origin: string;
27
+ owner: string | null;
28
+ priority: number;
29
+ budget_minutes: number;
30
+ metric: string | null;
31
+ success_criteria: string[];
32
+ scope: string[];
33
+ tags: string[];
34
+ preferred_nodes: string[];
35
+ exclude_nodes: string[];
36
+ created_at: string;
37
+ claimed_at: string | null;
38
+ started_at: string | null;
39
+ completed_at: string | null;
40
+ last_activity: string | null;
41
+ result: Record<string, unknown> | null;
42
+ attempts: Array<Record<string, unknown>>;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ // ── KV Read Operations ──
47
+
48
+ /**
49
+ * List all tasks from MESH_TASKS KV bucket.
50
+ * Returns empty array if NATS is unavailable.
51
+ */
52
+ export async function listMeshTasks(): Promise<MeshTaskEntry[]> {
53
+ const kv = await getTasksKv();
54
+ if (!kv) return [];
55
+
56
+ const tasks: MeshTaskEntry[] = [];
57
+ const keys = await kv.keys();
58
+
59
+ for await (const key of keys) {
60
+ const entry = await kv.get(key);
61
+ if (!entry?.value) continue;
62
+ try {
63
+ tasks.push(JSON.parse(sc.decode(entry.value)));
64
+ } catch {
65
+ // skip malformed
66
+ }
67
+ }
68
+
69
+ return tasks;
70
+ }
71
+
72
+ /**
73
+ * Get a single task from KV with its revision.
74
+ */
75
+ export async function getMeshTask(
76
+ taskId: string
77
+ ): Promise<{ task: MeshTaskEntry; revision: number } | null> {
78
+ const kv = await getTasksKv();
79
+ if (!kv) return null;
80
+
81
+ const entry = await kv.get(taskId);
82
+ if (!entry?.value) return null;
83
+
84
+ return {
85
+ task: JSON.parse(sc.decode(entry.value)),
86
+ revision: entry.revision,
87
+ };
88
+ }
89
+
90
+ // ── KV Write Operations (CAS) ──
91
+
92
+ /**
93
+ * Write a task to KV. Used by the lead to publish task state.
94
+ * No CAS — overwrites unconditionally. Use updateMeshTaskCAS for safe updates.
95
+ */
96
+ export async function putMeshTask(task: MeshTaskEntry): Promise<number> {
97
+ const kv = await getTasksKv();
98
+ if (!kv) throw new Error("NATS KV unavailable");
99
+
100
+ const rev = await kv.put(task.task_id, sc.encode(JSON.stringify(task)));
101
+ return rev;
102
+ }
103
+
104
+ /**
105
+ * Update a task with CAS (Compare-And-Swap).
106
+ * Fails if the revision doesn't match (another node wrote since our read).
107
+ *
108
+ * @returns New revision number on success
109
+ * @throws On revision mismatch (caller should re-read and retry)
110
+ */
111
+ export async function updateMeshTaskCAS(
112
+ taskId: string,
113
+ updates: Partial<MeshTaskEntry>,
114
+ expectedRevision: number
115
+ ): Promise<number> {
116
+ const kv = await getTasksKv();
117
+ if (!kv) throw new Error("NATS KV unavailable");
118
+
119
+ // Read current
120
+ const entry = await kv.get(taskId);
121
+ if (!entry?.value) throw new Error(`Task ${taskId} not found in KV`);
122
+
123
+ const current = JSON.parse(sc.decode(entry.value));
124
+
125
+ // Authority check
126
+ if (NODE_ROLE !== "lead" && current.origin !== NODE_ID) {
127
+ throw new Error(
128
+ `Authority denied: worker ${NODE_ID} cannot update task from ${current.origin}`
129
+ );
130
+ }
131
+
132
+ // Merge and write
133
+ const updated = { ...current, ...updates };
134
+ const rev = await kv.update(
135
+ taskId,
136
+ sc.encode(JSON.stringify(updated)),
137
+ expectedRevision
138
+ );
139
+ return rev;
140
+ }
141
+
142
+ /**
143
+ * Propose a new task from a worker node.
144
+ * Task is created with status "proposed" — the lead daemon validates.
145
+ */
146
+ export async function proposeMeshTask(
147
+ task: Omit<MeshTaskEntry, "status" | "origin">
148
+ ): Promise<MeshTaskEntry> {
149
+ const kv = await getTasksKv();
150
+ if (!kv) throw new Error("NATS KV unavailable");
151
+
152
+ const proposed: MeshTaskEntry = {
153
+ ...task,
154
+ status: NODE_ROLE === "lead" ? "queued" : "proposed",
155
+ origin: NODE_ID,
156
+ } as MeshTaskEntry;
157
+
158
+ await kv.put(proposed.task_id, sc.encode(JSON.stringify(proposed)));
159
+ return proposed;
160
+ }
161
+
162
+ // ── KV Watcher ──
163
+
164
+ export interface KvWatchEvent {
165
+ key: string;
166
+ operation: "PUT" | "DEL";
167
+ task: MeshTaskEntry | null;
168
+ revision: number;
169
+ }
170
+
171
+ /**
172
+ * Start watching the MESH_TASKS KV bucket.
173
+ * Returns an async iterator of KvWatchEvent and a stop() function.
174
+ *
175
+ * Call stop() on cleanup to prevent zombie watchers leaking NATS connections.
176
+ */
177
+ export async function watchMeshTasks(): Promise<{
178
+ events: AsyncIterable<KvWatchEvent>;
179
+ stop: () => void;
180
+ } | null> {
181
+ const kv = await getTasksKv();
182
+ if (!kv) return null;
183
+
184
+ const watcher = await kv.watch();
185
+
186
+ const events: AsyncIterable<KvWatchEvent> = {
187
+ [Symbol.asyncIterator]() {
188
+ return {
189
+ async next() {
190
+ const result = await (
191
+ watcher as AsyncIterable<any>
192
+ )[Symbol.asyncIterator]().next();
193
+ if (result.done) return { value: undefined, done: true };
194
+
195
+ const entry = result.value;
196
+ let task: MeshTaskEntry | null = null;
197
+ if (entry.value) {
198
+ try {
199
+ task = JSON.parse(sc.decode(entry.value));
200
+ } catch {
201
+ // malformed
202
+ }
203
+ }
204
+
205
+ return {
206
+ value: {
207
+ key: entry.key,
208
+ operation: entry.value ? "PUT" : ("DEL" as const),
209
+ task,
210
+ revision: entry.revision,
211
+ },
212
+ done: false,
213
+ };
214
+ },
215
+ };
216
+ },
217
+ };
218
+
219
+ return {
220
+ events,
221
+ stop: () => {
222
+ if (typeof (watcher as any).stop === "function") {
223
+ (watcher as any).stop();
224
+ }
225
+ },
226
+ };
227
+ }
228
+
229
+ // ── Merge Logic (for UI layer) ──
230
+
231
+ export interface MergedTask {
232
+ id: string;
233
+ title: string;
234
+ source: "sqlite" | "kv" | "merged";
235
+ [key: string]: unknown;
236
+ }
237
+
238
+ /**
239
+ * Merge local SQLite tasks with KV mesh tasks.
240
+ * Deduplicates by task ID.
241
+ *
242
+ * On lead: SQLite wins (has richer fields like kanbanColumn, sortOrder)
243
+ * On worker: KV wins (more up-to-date for mesh-coordinated tasks)
244
+ */
245
+ export function mergeTasks(
246
+ sqliteTasks: Array<{ id: string; [key: string]: unknown }>,
247
+ kvTasks: Array<{ task_id: string; [key: string]: unknown }>,
248
+ nodeRole: "lead" | "worker" = NODE_ROLE
249
+ ): MergedTask[] {
250
+ const merged = new Map<string, MergedTask>();
251
+
252
+ // SQLite tasks first
253
+ for (const t of sqliteTasks) {
254
+ merged.set(t.id, { ...t, id: t.id, title: String(t.title || ""), source: "sqlite" });
255
+ }
256
+
257
+ // KV tasks: on worker, KV wins for mesh tasks; on lead, SQLite wins
258
+ for (const t of kvTasks) {
259
+ const existing = merged.get(t.task_id);
260
+ if (!existing) {
261
+ merged.set(t.task_id, {
262
+ ...t,
263
+ id: t.task_id,
264
+ title: String(t.title || ""),
265
+ source: "kv",
266
+ });
267
+ } else if (nodeRole === "worker") {
268
+ merged.set(t.task_id, {
269
+ ...t,
270
+ id: t.task_id,
271
+ title: String(t.title || ""),
272
+ source: "kv",
273
+ });
274
+ }
275
+ // Lead: SQLite wins (don't overwrite)
276
+ }
277
+
278
+ return Array.from(merged.values());
279
+ }
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import { eq } from "drizzle-orm";
4
4
  import { tasks } from "@/lib/db/schema";
5
5
  import { ACTIVE_TASKS_MD } from "@/lib/config";
6
+ import { NODE_ROLE } from "@/lib/config";
6
7
  import {
7
8
  parseTasksMarkdown,
8
9
  serializeTasksMarkdown,
@@ -118,6 +119,11 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
118
119
  metric: task.metric || null,
119
120
  budgetMinutes: task.budgetMinutes || 30,
120
121
  scope: task.scope?.length ? JSON.stringify(task.scope) : null,
122
+ // Collab routing fields
123
+ collaboration: task.collaboration ? JSON.stringify(task.collaboration) : null,
124
+ preferredNodes: task.preferredNodes?.length ? JSON.stringify(task.preferredNodes) : null,
125
+ excludeNodes: task.excludeNodes?.length ? JSON.stringify(task.excludeNodes) : null,
126
+ clusterId: task.clusterId || null,
121
127
  updatedAt: task.updatedAt || now,
122
128
  };
123
129
 
@@ -167,6 +173,11 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
167
173
  metric: task.metric || null,
168
174
  budgetMinutes: task.budgetMinutes || 30,
169
175
  scope: task.scope?.length ? JSON.stringify(task.scope) : null,
176
+ // Collab routing fields
177
+ collaboration: task.collaboration ? JSON.stringify(task.collaboration) : null,
178
+ preferredNodes: task.preferredNodes?.length ? JSON.stringify(task.preferredNodes) : null,
179
+ excludeNodes: task.excludeNodes?.length ? JSON.stringify(task.excludeNodes) : null,
180
+ clusterId: task.clusterId || null,
170
181
  updatedAt: task.updatedAt || now,
171
182
  createdAt: now,
172
183
  })
@@ -178,7 +189,7 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
178
189
  // but PRESERVE roadmap/pipeline tasks (project, phase, pipeline types)
179
190
  // and any tasks tagged with a project (they live in the DB, not markdown).
180
191
  const allDbTasks = db
181
- .select({ id: tasks.id, type: tasks.type, project: tasks.project })
192
+ .select({ id: tasks.id, type: tasks.type, project: tasks.project, execution: tasks.execution, status: tasks.status })
182
193
  .from(tasks)
183
194
  .all();
184
195
  for (const row of allDbTasks) {
@@ -187,6 +198,8 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
187
198
  if (row.type === "project" || row.type === "phase" || row.type === "pipeline") continue;
188
199
  // Preserve tasks owned by a project (imported via pipeline, not markdown-managed)
189
200
  if (row.project) continue;
201
+ // Preserve in-flight mesh tasks (only clean up terminal ones)
202
+ if (row.execution === "mesh" && row.status !== "done" && row.status !== "cancelled") continue;
190
203
  if (!incomingIds.has(row.id)) {
191
204
  db.delete(tasks).where(eq(tasks.id, row.id)).run();
192
205
  }
@@ -210,6 +223,10 @@ export function syncTasksFromMarkdownIfChanged(db: DrizzleDb): void {
210
223
  * Tracks the mtime after write to avoid re-importing our own changes.
211
224
  */
212
225
  export function syncTasksToMarkdown(db: DrizzleDb): void {
226
+ // Workers must NOT write to active-tasks.md — the lead owns this file.
227
+ // Writing from workers would cause conflict loops in the mesh sync.
228
+ if (NODE_ROLE === "worker") return;
229
+
213
230
  const allTasks = db
214
231
  .select()
215
232
  .from(tasks)
@@ -250,6 +267,11 @@ export function syncTasksToMarkdown(db: DrizzleDb): void {
250
267
  metric: t.metric || null,
251
268
  budgetMinutes: t.budgetMinutes || 30,
252
269
  scope: t.scope ? JSON.parse(t.scope) : [],
270
+ // Collab routing fields
271
+ collaboration: t.collaboration ? JSON.parse(t.collaboration) : null,
272
+ preferredNodes: t.preferredNodes ? JSON.parse(t.preferredNodes) : [],
273
+ excludeNodes: t.excludeNodes ? JSON.parse(t.excludeNodes) : [],
274
+ clusterId: t.clusterId || null,
253
275
  updatedAt: t.updatedAt,
254
276
  }));
255
277
 
@@ -0,0 +1,32 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { tasks } from "@/lib/db/schema";
3
+
4
+ /**
5
+ * Generate a task ID in the format T-YYYYMMDD-NNN.
6
+ * NNN is a zero-padded sequence number based on existing tasks for that date.
7
+ */
8
+ export function generateTaskId(
9
+ db: ReturnType<typeof getDb>,
10
+ date: Date
11
+ ): string {
12
+ const dateStr =
13
+ date.getFullYear().toString() +
14
+ (date.getMonth() + 1).toString().padStart(2, "0") +
15
+ date.getDate().toString().padStart(2, "0");
16
+
17
+ const prefix = `T-${dateStr}-`;
18
+
19
+ // Find highest existing sequence number for this date prefix
20
+ const existing = db
21
+ .select({ id: tasks.id })
22
+ .from(tasks)
23
+ .all()
24
+ .filter((t) => t.id.startsWith(prefix))
25
+ .map((t) => {
26
+ const seq = parseInt(t.id.slice(prefix.length), 10);
27
+ return isNaN(seq) ? 0 : seq;
28
+ });
29
+
30
+ const nextSeq = existing.length > 0 ? Math.max(...existing) + 1 : 1;
31
+ return `${prefix}${nextSeq.toString().padStart(3, "0")}`;
32
+ }
@@ -4,14 +4,28 @@ import { createEdgeTtsProvider } from "./edge";
4
4
 
5
5
  export type { TtsRequest, TtsResponse, TtsProvider } from "./types";
6
6
 
7
+ // ── Pluggable TTS Provider Registry ──
8
+ // Built-in providers are registered by default. External code can register
9
+ // additional providers via registerTtsProvider() without modifying this file.
10
+
7
11
  const providers: Record<string, () => TtsProvider> = {
8
12
  google: createGoogleTtsProvider,
9
13
  edge: createEdgeTtsProvider,
10
14
  };
11
15
 
12
- export function getProvider(name: "google" | "edge"): TtsProvider {
16
+ /** Register a custom TTS provider factory (e.g. "elevenlabs", "openai-tts"). */
17
+ export function registerTtsProvider(name: string, factory: () => TtsProvider): void {
18
+ providers[name] = factory;
19
+ }
20
+
21
+ /** List all registered TTS provider names. */
22
+ export function listTtsProviders(): string[] {
23
+ return Object.keys(providers);
24
+ }
25
+
26
+ export function getProvider(name: string): TtsProvider {
13
27
  const factory = providers[name];
14
- if (!factory) throw new Error(`Unknown TTS provider: ${name}`);
28
+ if (!factory) throw new Error(`Unknown TTS provider: ${name}. Available: ${Object.keys(providers).join(", ")}`);
15
29
  return factory();
16
30
  }
17
31
 
@@ -22,18 +36,28 @@ export interface SynthesisResult extends TtsResponse {
22
36
 
23
37
  export async function synthesizeWithFallback(
24
38
  req: TtsRequest,
25
- preferred: "google" | "edge" = "google"
39
+ preferred: string = "google"
26
40
  ): Promise<SynthesisResult> {
27
41
  try {
28
42
  const result = await getProvider(preferred).synthesize(req);
29
43
  return { ...result, actualProvider: preferred };
30
44
  } catch (err) {
31
- const fallback = preferred === "google" ? "edge" : "google";
45
+ // Find first available fallback that isn't the preferred provider
46
+ const fallbackNames = Object.keys(providers).filter((n) => n !== preferred);
32
47
  const reason = err instanceof Error ? err.message : String(err);
33
- console.warn(
34
- `TTS provider "${preferred}" failed, falling back to "${fallback}": ${reason}`
35
- );
36
- const result = await getProvider(fallback).synthesize(req);
37
- return { ...result, actualProvider: fallback, fallbackReason: reason };
48
+
49
+ for (const fallback of fallbackNames) {
50
+ try {
51
+ console.warn(
52
+ `TTS provider "${preferred}" failed, trying "${fallback}": ${reason}`
53
+ );
54
+ const result = await getProvider(fallback).synthesize(req);
55
+ return { ...result, actualProvider: fallback, fallbackReason: reason };
56
+ } catch {
57
+ // try next fallback
58
+ }
59
+ }
60
+
61
+ throw new Error(`All TTS providers failed. Last error: ${reason}`);
38
62
  }
39
63
  }
@@ -0,0 +1,82 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ /**
4
+ * API authentication middleware.
5
+ *
6
+ * Protects all /api/* routes with a Bearer token check.
7
+ * Token is read from MC_AUTH_TOKEN env var. If unset, auth is disabled
8
+ * (localhost-only deployments). When set, every API request must include:
9
+ * Authorization: Bearer <token>
10
+ *
11
+ * Page routes (non-API) are not gated — the dashboard is a local UI.
12
+ */
13
+
14
+ const AUTH_TOKEN = process.env.MC_AUTH_TOKEN || "";
15
+
16
+ export function middleware(request: NextRequest) {
17
+ // Only gate API routes
18
+ if (!request.nextUrl.pathname.startsWith("/api/")) {
19
+ return NextResponse.next();
20
+ }
21
+
22
+ // Body size limit (1MB) for mutation requests
23
+ const contentLength = parseInt(request.headers.get("content-length") || "0", 10);
24
+ const MAX_BODY_SIZE = 1024 * 1024; // 1MB
25
+ if (contentLength > MAX_BODY_SIZE) {
26
+ return NextResponse.json(
27
+ { error: `Request body too large (${contentLength} bytes, max ${MAX_BODY_SIZE})` },
28
+ { status: 413 }
29
+ );
30
+ }
31
+
32
+ // SSE endpoints use EventSource which can't set headers — allow if
33
+ // the token is passed as a query param instead.
34
+ const tokenFromQuery = request.nextUrl.searchParams.get("token");
35
+
36
+ // If no token is configured, auth is disabled (backwards-compatible)
37
+ if (!AUTH_TOKEN) {
38
+ return NextResponse.next();
39
+ }
40
+
41
+ const authHeader = request.headers.get("authorization") || "";
42
+ const bearer = authHeader.startsWith("Bearer ")
43
+ ? authHeader.slice(7).trim()
44
+ : "";
45
+
46
+ const providedToken = bearer || tokenFromQuery || "";
47
+
48
+ if (!providedToken) {
49
+ return NextResponse.json(
50
+ { error: "Missing Authorization header" },
51
+ { status: 401 }
52
+ );
53
+ }
54
+
55
+ // Constant-time comparison to prevent timing attacks
56
+ if (!timingSafeEqual(providedToken, AUTH_TOKEN)) {
57
+ return NextResponse.json({ error: "Invalid token" }, { status: 403 });
58
+ }
59
+
60
+ return NextResponse.next();
61
+ }
62
+
63
+ /** Constant-time string comparison (Edge Runtime compatible). */
64
+ function timingSafeEqual(a: string, b: string): boolean {
65
+ if (a.length !== b.length) {
66
+ // Still do a full comparison to avoid length-based timing leak
67
+ let result = a.length ^ b.length;
68
+ for (let i = 0; i < a.length; i++) {
69
+ result |= a.charCodeAt(i) ^ (b.charCodeAt(i % b.length) || 0);
70
+ }
71
+ return result === 0;
72
+ }
73
+ let result = 0;
74
+ for (let i = 0; i < a.length; i++) {
75
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
76
+ }
77
+ return result === 0;
78
+ }
79
+
80
+ export const config = {
81
+ matcher: "/api/:path*",
82
+ };
@@ -34,7 +34,8 @@
34
34
  "**/*.tsx",
35
35
  ".next/types/**/*.ts",
36
36
  ".next/dev/types/**/*.ts",
37
- "**/*.mts"
37
+ "**/*.mts",
38
+ ".next/dev/dev/types/**/*.ts"
38
39
  ],
39
40
  "exclude": [
40
41
  "node_modules"
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import path from "path";
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ "@": path.resolve(__dirname, "src"),
8
+ },
9
+ },
10
+ test: {
11
+ include: ["src/**/*.test.ts"],
12
+ environment: "node",
13
+ },
14
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-node-harness",
3
- "version": "2.0.4",
3
+ "version": "2.1.1",
4
4
  "description": "One-command installer for the OpenClaw node layer — identity, skills, souls, daemon, and Mission Control.",
5
5
  "bin": {
6
6
  "openclaw-node": "./cli.js"
@@ -39,10 +39,23 @@
39
39
  "type": "git",
40
40
  "url": "https://github.com/moltyguibros-design/openclaw-node.git"
41
41
  },
42
+ "scripts": {
43
+ "test": "node --test test/*.test.js test/*.test.mjs",
44
+ "test:unit": "node --test test/*.test.js test/*.test.mjs",
45
+ "test:mc": "cd mission-control && npx vitest run src/lib/__tests__/",
46
+ "test:integration": "node test/distributed-mc.test.js",
47
+ "test:all": "npm run test:unit && npm run test:mc && npm run test:integration"
48
+ },
42
49
  "engines": {
43
50
  "node": ">=18"
44
51
  },
45
52
  "dependencies": {
46
- "nats": "^2.28.2"
53
+ "@huggingface/transformers": "^3.0.0",
54
+ "@modelcontextprotocol/sdk": "^1.0.0",
55
+ "better-sqlite3": "^11.7.0",
56
+ "js-yaml": "^4.1.1",
57
+ "nats": "^2.28.2",
58
+ "playwright": "^1.51.0",
59
+ "sqlite-vec": "^0.1.0"
47
60
  }
48
61
  }
@@ -20,6 +20,17 @@
20
20
  <key>Minute</key>
21
21
  <integer>0</integer>
22
22
  </dict>
23
+ <key>RunAtLoad</key>
24
+ <false/>
25
+ <key>KeepAlive</key>
26
+ <false/>
27
+ <key>EnvironmentVariables</key>
28
+ <dict>
29
+ <key>HOME</key>
30
+ <string>${HOME}</string>
31
+ <key>PATH</key>
32
+ <string>${HOME}/.bun/bin:${HOME}/.local/bin:${HOME}/.npm-global/bin:${HOME}/bin:${HOME}/.volta/bin:${HOME}/.asdf/shims:${HOME}/Library/Application Support/fnm/aliases/default/bin:${HOME}/.fnm/aliases/default/bin:${HOME}/Library/pnpm:${HOME}/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
33
+ </dict>
23
34
  <key>StandardOutPath</key>
24
35
  <string>${HOME}/.openclaw/logs/log-rotate.log</string>
25
36
  <key>StandardErrorPath</key>
@@ -12,6 +12,10 @@
12
12
  </array>
13
13
  <key>EnvironmentVariables</key>
14
14
  <dict>
15
+ <key>HOME</key>
16
+ <string>${HOME}</string>
17
+ <key>PATH</key>
18
+ <string>${HOME}/.bun/bin:${HOME}/.local/bin:${HOME}/.npm-global/bin:${HOME}/bin:${HOME}/.volta/bin:${HOME}/.asdf/shims:${HOME}/Library/Application Support/fnm/aliases/default/bin:${HOME}/.fnm/aliases/default/bin:${HOME}/Library/pnpm:${HOME}/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
15
19
  <key>OPENCLAW_NODE_ID</key>
16
20
  <string>${OPENCLAW_NODE_ID}</string>
17
21
  <key>OPENCLAW_NATS</key>
@@ -12,6 +12,10 @@
12
12
  </array>
13
13
  <key>EnvironmentVariables</key>
14
14
  <dict>
15
+ <key>HOME</key>
16
+ <string>${HOME}</string>
17
+ <key>PATH</key>
18
+ <string>${HOME}/.bun/bin:${HOME}/.local/bin:${HOME}/.npm-global/bin:${HOME}/bin:${HOME}/.volta/bin:${HOME}/.asdf/shims:${HOME}/Library/Application Support/fnm/aliases/default/bin:${HOME}/.fnm/aliases/default/bin:${HOME}/Library/pnpm:${HOME}/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
15
19
  <key>OPENCLAW_NODE_ID</key>
16
20
  <string>${OPENCLAW_NODE_ID}</string>
17
21
  <key>OPENCLAW_NATS</key>
@@ -20,7 +20,7 @@
20
20
  <key>PATH</key>
21
21
  <string>/usr/local/bin:/usr/bin:/bin</string>
22
22
  <key>NODE_ENV</key>
23
- <string>development</string>
23
+ <string>production</string>
24
24
  </dict>
25
25
 
26
26
  <key>RunAtLoad</key>
@@ -5,7 +5,7 @@
5
5
  { "name": "openclaw-mesh-health-publisher", "role": "both", "autostart": true },
6
6
  { "name": "openclaw-mesh-deploy-listener", "role": "both", "autostart": true },
7
7
  { "name": "openclaw-mesh-tool-discord", "role": "lead", "autostart": true },
8
- { "name": "openclaw-mission-control", "role": "lead", "autostart": true },
8
+ { "name": "openclaw-mission-control", "role": "both", "autostart": true },
9
9
  { "name": "openclaw-gateway", "role": "both", "autostart": true },
10
10
  { "name": "openclaw-lane-watchdog", "role": "both", "autostart": true },
11
11
  { "name": "openclaw-memory-daemon", "role": "both", "autostart": true },