openclaw-node-harness 2.1.0 → 2.2.0

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 (52) hide show
  1. package/bin/lane-watchdog.js +54 -23
  2. package/bin/mesh-agent.js +49 -18
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-deploy.js +4 -0
  5. package/bin/mesh-health-publisher.js +41 -1
  6. package/bin/mesh-task-daemon.js +14 -4
  7. package/bin/mesh.js +17 -43
  8. package/install.sh +3 -2
  9. package/lib/agent-activity.js +2 -2
  10. package/lib/exec-safety.js +163 -0
  11. package/lib/kanban-io.js +20 -33
  12. package/lib/llm-providers.js +27 -0
  13. package/lib/mcp-knowledge/core.mjs +7 -5
  14. package/lib/mcp-knowledge/server.mjs +8 -1
  15. package/lib/mesh-collab.js +274 -250
  16. package/lib/mesh-harness.js +6 -0
  17. package/lib/mesh-plans.js +84 -45
  18. package/lib/mesh-tasks.js +113 -81
  19. package/lib/nats-resolve.js +4 -4
  20. package/lib/pre-compression-flush.mjs +2 -0
  21. package/lib/session-store.mjs +6 -3
  22. package/mission-control/package-lock.json +4188 -3698
  23. package/mission-control/package.json +2 -2
  24. package/mission-control/src/app/api/diagnostics/route.ts +8 -0
  25. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +8 -0
  26. package/mission-control/src/app/api/memory/graph/route.ts +34 -18
  27. package/mission-control/src/app/api/memory/search/route.ts +9 -5
  28. package/mission-control/src/app/api/mesh/identity/route.ts +13 -5
  29. package/mission-control/src/app/api/mesh/nodes/route.ts +8 -0
  30. package/mission-control/src/app/api/settings/gateway/route.ts +62 -0
  31. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +49 -12
  32. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  33. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +24 -5
  34. package/mission-control/src/app/api/souls/route.ts +6 -4
  35. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  36. package/mission-control/src/app/api/tasks/[id]/route.ts +20 -4
  37. package/mission-control/src/app/api/tasks/route.ts +68 -9
  38. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  39. package/mission-control/src/lib/config.ts +11 -2
  40. package/mission-control/src/lib/db/index.ts +16 -1
  41. package/mission-control/src/lib/memory/extract.ts +2 -1
  42. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  43. package/mission-control/src/lib/sync/tasks.ts +4 -1
  44. package/mission-control/src/middleware.ts +82 -0
  45. package/package.json +1 -1
  46. package/services/launchd/ai.openclaw.lane-watchdog.plist +1 -1
  47. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  48. package/services/launchd/ai.openclaw.mesh-agent.plist +4 -0
  49. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  50. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  51. package/services/launchd/ai.openclaw.mission-control.plist +5 -4
  52. package/uninstall.sh +37 -9
@@ -22,6 +22,7 @@ const fs = require('fs');
22
22
  const path = require('path');
23
23
  const { execSync } = require('child_process');
24
24
  const { globMatch } = require('./rule-loader');
25
+ const { validateExecCommand } = require('./exec-safety');
25
26
 
26
27
  // ── Rule Loading ─────────────────────────────────────
27
28
 
@@ -241,6 +242,11 @@ function preCommitSecretScan(worktreePath) {
241
242
  function postCommitValidate(worktreePath, command) {
242
243
  if (!worktreePath || !command) return { passed: true, output: '' };
243
244
 
245
+ const validation = validateExecCommand(command);
246
+ if (!validation.allowed) {
247
+ return { passed: false, output: `Validation command blocked: ${validation.reason}` };
248
+ }
249
+
244
250
  try {
245
251
  const output = execSync(command, {
246
252
  cwd: worktreePath, timeout: 10000, encoding: 'utf-8', stdio: 'pipe',
package/lib/mesh-plans.js CHANGED
@@ -38,6 +38,13 @@ const SUBTASK_STATUS = {
38
38
 
39
39
  // ── Delegation Modes ───────────────────────────────
40
40
 
41
+ const PLAN_TRANSITIONS = {
42
+ approve: new Set(['review', 'draft']),
43
+ startExecuting: new Set(['approved']),
44
+ markCompleted: new Set(['executing']),
45
+ markAborted: new Set(['draft', 'review', 'approved', 'executing']),
46
+ };
47
+
41
48
  const DELEGATION_MODE = {
42
49
  SOLO_MESH: 'solo_mesh',
43
50
  COLLAB_MESH: 'collab_mesh',
@@ -127,6 +134,11 @@ function createPlan({
127
134
  // Compute wave assignments
128
135
  assignWaves(enriched);
129
136
 
137
+ // Mark cycle-blocked subtasks (wave === -1) so they don't prevent plan completion
138
+ for (const st of enriched) {
139
+ if (st.wave === -1 && st.status === 'pending') st.status = 'blocked';
140
+ }
141
+
130
142
  const totalBudget = enriched.reduce((sum, st) => sum + st.budget_minutes, 0);
131
143
  const maxWave = enriched.reduce((max, st) => Math.max(max, st.wave), 0);
132
144
 
@@ -210,6 +222,14 @@ function assignWaves(subtasks) {
210
222
  wave++;
211
223
  currentWave = nextWave;
212
224
  }
225
+
226
+ // Detect cycles: any node with remaining in-degree > 0 is in a cycle
227
+ for (const [taskId, degree] of inDegree.entries()) {
228
+ if (degree > 0) {
229
+ const subtask = idMap.get(taskId);
230
+ if (subtask) subtask.wave = -1; // blocked by cycle
231
+ }
232
+ }
213
233
  }
214
234
 
215
235
  // ── Delegation Decision Tree ───────────────────────
@@ -351,6 +371,29 @@ class PlanStore {
351
371
  return JSON.parse(sc.decode(entry.value));
352
372
  }
353
373
 
374
+ /**
375
+ * Compare-and-swap helper: read → mutate → write with optimistic concurrency.
376
+ * Re-reads and retries on conflict (up to maxRetries).
377
+ * mutateFn receives the parsed data and must return the updated object, or falsy to skip.
378
+ */
379
+ async _updateWithCAS(key, mutateFn, maxRetries = 3) {
380
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
381
+ const entry = await this.kv.get(key);
382
+ if (!entry) return null;
383
+ const data = JSON.parse(sc.decode(entry.value));
384
+ const updated = mutateFn(data);
385
+ if (!updated) return null;
386
+ try {
387
+ await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
388
+ return updated;
389
+ } catch (err) {
390
+ const isCasConflict = err.code === '10071' || (err.message && err.message.includes('wrong last sequence'));
391
+ if (!isCasConflict || attempt === maxRetries - 1) throw err;
392
+ // CAS conflict — retry
393
+ }
394
+ }
395
+ }
396
+
354
397
  async delete(planId) {
355
398
  await this.kv.delete(planId);
356
399
  }
@@ -389,69 +432,65 @@ class PlanStore {
389
432
  // ── Lifecycle ───────────────────────────────────
390
433
 
391
434
  async submitForReview(planId) {
392
- const plan = await this.get(planId);
393
- if (!plan) return null;
394
- plan.status = PLAN_STATUS.REVIEW;
395
- await this.put(plan);
396
- return plan;
435
+ return this._updateWithCAS(planId, (plan) => {
436
+ if (plan.status !== 'draft') return null;
437
+ plan.status = PLAN_STATUS.REVIEW;
438
+ return plan;
439
+ });
397
440
  }
398
441
 
399
442
  async approve(planId, approvedBy = 'gui') {
400
- const plan = await this.get(planId);
401
- if (!plan) return null;
402
- plan.status = PLAN_STATUS.APPROVED;
403
- plan.approved_by = approvedBy;
404
- plan.approved_at = new Date().toISOString();
405
- await this.put(plan);
406
- return plan;
443
+ return this._updateWithCAS(planId, (plan) => {
444
+ if (!PLAN_TRANSITIONS.approve.has(plan.status)) return null;
445
+ plan.status = PLAN_STATUS.APPROVED;
446
+ plan.approved_by = approvedBy;
447
+ plan.approved_at = new Date().toISOString();
448
+ return plan;
449
+ });
407
450
  }
408
451
 
409
452
  async startExecuting(planId) {
410
- const plan = await this.get(planId);
411
- if (!plan) return null;
412
- plan.status = PLAN_STATUS.EXECUTING;
413
- plan.started_at = new Date().toISOString();
414
- await this.put(plan);
415
- return plan;
453
+ return this._updateWithCAS(planId, (plan) => {
454
+ if (!PLAN_TRANSITIONS.startExecuting.has(plan.status)) return null;
455
+ plan.status = PLAN_STATUS.EXECUTING;
456
+ plan.started_at = new Date().toISOString();
457
+ return plan;
458
+ });
416
459
  }
417
460
 
418
461
  async markCompleted(planId) {
419
- const plan = await this.get(planId);
420
- if (!plan) return null;
421
- plan.status = PLAN_STATUS.COMPLETED;
422
- plan.completed_at = new Date().toISOString();
423
- await this.put(plan);
424
- return plan;
462
+ return this._updateWithCAS(planId, (plan) => {
463
+ if (!PLAN_TRANSITIONS.markCompleted.has(plan.status)) return null;
464
+ plan.status = PLAN_STATUS.COMPLETED;
465
+ plan.completed_at = new Date().toISOString();
466
+ return plan;
467
+ });
425
468
  }
426
469
 
427
470
  async markAborted(planId, reason) {
428
- const plan = await this.get(planId);
429
- if (!plan) return null;
430
- plan.status = PLAN_STATUS.ABORTED;
431
- plan.completed_at = new Date().toISOString();
432
- // Mark all pending subtasks as blocked
433
- for (const st of plan.subtasks) {
434
- if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
435
- st.status = SUBTASK_STATUS.BLOCKED;
436
- st.result = { success: false, summary: `Plan aborted: ${reason}` };
471
+ return this._updateWithCAS(planId, (plan) => {
472
+ if (!PLAN_TRANSITIONS.markAborted.has(plan.status)) return null;
473
+ plan.status = PLAN_STATUS.ABORTED;
474
+ plan.completed_at = new Date().toISOString();
475
+ for (const st of plan.subtasks) {
476
+ if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
477
+ st.status = SUBTASK_STATUS.BLOCKED;
478
+ st.result = { success: false, summary: `Plan aborted: ${reason}` };
479
+ }
437
480
  }
438
- }
439
- await this.put(plan);
440
- return plan;
481
+ return plan;
482
+ });
441
483
  }
442
484
 
443
485
  // ── Subtask Management ──────────────────────────
444
486
 
445
487
  async updateSubtask(planId, subtaskId, updates) {
446
- const plan = await this.get(planId);
447
- if (!plan) return null;
448
-
449
- const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
450
- if (!st) return null;
451
-
452
- Object.assign(st, updates);
453
- await this.put(plan);
454
- return plan;
488
+ return this._updateWithCAS(planId, (plan) => {
489
+ const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
490
+ if (!st) return null;
491
+ Object.assign(st, updates);
492
+ return plan;
493
+ });
455
494
  }
456
495
 
457
496
  /**
package/lib/mesh-tasks.js CHANGED
@@ -46,6 +46,14 @@ const TASK_STATUS = {
46
46
  REJECTED: 'rejected',
47
47
  };
48
48
 
49
+ const TERMINAL_STATES = new Set([
50
+ TASK_STATUS.COMPLETED,
51
+ TASK_STATUS.FAILED,
52
+ TASK_STATUS.RELEASED,
53
+ TASK_STATUS.CANCELLED,
54
+ TASK_STATUS.REJECTED,
55
+ ]);
56
+
49
57
  /**
50
58
  * Create a new task with the enriched schema.
51
59
  * Karpathy-inspired fields: budget_minutes, metric, on_fail, scope.
@@ -142,6 +150,29 @@ class TaskStore {
142
150
  return JSON.parse(sc.decode(entry.value));
143
151
  }
144
152
 
153
+ /**
154
+ * Compare-and-swap helper: read → mutate → write with optimistic concurrency.
155
+ * Re-reads and retries on conflict (up to maxRetries).
156
+ * mutateFn receives the parsed data and must return the updated object, or falsy to skip.
157
+ */
158
+ async _updateWithCAS(key, mutateFn, maxRetries = 3) {
159
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
160
+ const entry = await this.kv.get(key);
161
+ if (!entry) return null;
162
+ const data = JSON.parse(sc.decode(entry.value));
163
+ const updated = mutateFn(data);
164
+ if (!updated) return null;
165
+ try {
166
+ await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
167
+ return updated;
168
+ } catch (err) {
169
+ const isCasConflict = err.code === '10071' || (err.message && err.message.includes('wrong last sequence'));
170
+ if (!isCasConflict || attempt === maxRetries - 1) throw err;
171
+ // CAS conflict — retry
172
+ }
173
+ }
174
+ }
175
+
145
176
  async delete(taskId) {
146
177
  await this.kv.delete(taskId);
147
178
  }
@@ -213,39 +244,42 @@ class TaskStore {
213
244
  if (claimable.length === 0) return null;
214
245
 
215
246
  const task = claimable[0];
216
- task.status = TASK_STATUS.CLAIMED;
217
- task.owner = nodeId;
218
- task.claimed_at = new Date().toISOString();
219
- const budgetMs = (task.budget_minutes || 30) * 60 * 1000;
220
- task.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
221
-
222
- await this.put(task);
223
- return task;
247
+ const result = await this._updateWithCAS(task.task_id, (t) => {
248
+ // Re-check status under CAS — another node may have claimed it
249
+ if (t.status !== TASK_STATUS.QUEUED) return null;
250
+ t.status = TASK_STATUS.CLAIMED;
251
+ t.owner = nodeId;
252
+ t.claimed_at = new Date().toISOString();
253
+ const budgetMs = (t.budget_minutes || 30) * 60 * 1000;
254
+ t.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
255
+ return t;
256
+ });
257
+ return result;
224
258
  }
225
259
 
226
260
  /**
227
261
  * Mark a task as running (agent started work).
228
262
  */
229
263
  async markRunning(taskId) {
230
- const task = await this.get(taskId);
231
- if (!task) return null;
232
- task.status = TASK_STATUS.RUNNING;
233
- task.started_at = new Date().toISOString();
234
- await this.put(task);
235
- return task;
264
+ return this._updateWithCAS(taskId, (task) => {
265
+ if (TERMINAL_STATES.has(task.status)) return null;
266
+ task.status = TASK_STATUS.RUNNING;
267
+ task.started_at = new Date().toISOString();
268
+ return task;
269
+ });
236
270
  }
237
271
 
238
272
  /**
239
273
  * Mark a task as completed with result.
240
274
  */
241
275
  async markCompleted(taskId, result) {
242
- const task = await this.get(taskId);
243
- if (!task) return null;
244
- task.status = TASK_STATUS.COMPLETED;
245
- task.completed_at = new Date().toISOString();
246
- task.result = result;
247
- await this.put(task);
248
- return task;
276
+ return this._updateWithCAS(taskId, (task) => {
277
+ if (TERMINAL_STATES.has(task.status)) return null;
278
+ task.status = TASK_STATUS.COMPLETED;
279
+ task.completed_at = new Date().toISOString();
280
+ task.result = result;
281
+ return task;
282
+ });
249
283
  }
250
284
 
251
285
  /**
@@ -253,70 +287,68 @@ class TaskStore {
253
287
  * Stores the result but doesn't transition to completed.
254
288
  */
255
289
  async markPendingReview(taskId, result) {
256
- const task = await this.get(taskId);
257
- if (!task) return null;
258
- task.status = TASK_STATUS.PENDING_REVIEW;
259
- task.result = result;
260
- task.review_requested_at = new Date().toISOString();
261
- await this.put(task);
262
- return task;
290
+ return this._updateWithCAS(taskId, (task) => {
291
+ if (TERMINAL_STATES.has(task.status)) return null;
292
+ task.status = TASK_STATUS.PENDING_REVIEW;
293
+ task.result = result;
294
+ task.review_requested_at = new Date().toISOString();
295
+ return task;
296
+ });
263
297
  }
264
298
 
265
299
  /**
266
300
  * Approve a pending_review task → completed.
267
301
  */
268
302
  async markApproved(taskId) {
269
- const task = await this.get(taskId);
270
- if (!task) return null;
271
- if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
272
- task.status = TASK_STATUS.COMPLETED;
273
- task.completed_at = new Date().toISOString();
274
- task.reviewed_by = 'human';
275
- await this.put(task);
276
- return task;
303
+ return this._updateWithCAS(taskId, (task) => {
304
+ if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
305
+ task.status = TASK_STATUS.COMPLETED;
306
+ task.completed_at = new Date().toISOString();
307
+ task.reviewed_by = 'human';
308
+ return task;
309
+ });
277
310
  }
278
311
 
279
312
  /**
280
313
  * Reject a pending_review task → re-queue with reason.
281
314
  */
282
315
  async markRejected(taskId, reason) {
283
- const task = await this.get(taskId);
284
- if (!task) return null;
285
- if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
286
- task.status = TASK_STATUS.QUEUED;
287
- task.rejection_reason = reason;
288
- task.result = null; // clear previous result
289
- task.review_requested_at = null;
290
- await this.put(task);
291
- return task;
316
+ return this._updateWithCAS(taskId, (task) => {
317
+ if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
318
+ task.status = TASK_STATUS.QUEUED;
319
+ task.rejection_reason = reason;
320
+ task.result = null;
321
+ task.review_requested_at = null;
322
+ return task;
323
+ });
292
324
  }
293
325
 
294
326
  /**
295
327
  * Mark a task as failed with reason.
296
328
  */
297
329
  async markFailed(taskId, reason, attempts = []) {
298
- const task = await this.get(taskId);
299
- if (!task) return null;
300
- task.status = TASK_STATUS.FAILED;
301
- task.completed_at = new Date().toISOString();
302
- task.result = { success: false, summary: reason };
303
- task.attempts = attempts;
304
- await this.put(task);
305
- return task;
330
+ return this._updateWithCAS(taskId, (task) => {
331
+ if (TERMINAL_STATES.has(task.status)) return null;
332
+ task.status = TASK_STATUS.FAILED;
333
+ task.completed_at = new Date().toISOString();
334
+ task.result = { success: false, summary: reason };
335
+ task.attempts = attempts;
336
+ return task;
337
+ });
306
338
  }
307
339
 
308
340
  /**
309
341
  * Log an attempt on a task (agent tried something, may or may not have worked).
310
342
  */
311
343
  async logAttempt(taskId, attempt) {
312
- const task = await this.get(taskId);
313
- if (!task) return null;
314
- task.attempts.push({
315
- ...attempt,
316
- timestamp: new Date().toISOString(),
344
+ return this._updateWithCAS(taskId, (task) => {
345
+ if (TERMINAL_STATES.has(task.status)) return null;
346
+ task.attempts.push({
347
+ ...attempt,
348
+ timestamp: new Date().toISOString(),
349
+ });
350
+ return task;
317
351
  });
318
- await this.put(task);
319
- return task;
320
352
  }
321
353
 
322
354
  /**
@@ -324,25 +356,25 @@ class TaskStore {
324
356
  * Distinct from failed: failed = "didn't work", released = "we tried everything."
325
357
  */
326
358
  async markReleased(taskId, reason, attempts = []) {
327
- const task = await this.get(taskId);
328
- if (!task) return null;
329
- task.status = TASK_STATUS.RELEASED;
330
- task.completed_at = new Date().toISOString();
331
- task.result = { success: false, summary: reason, released: true };
332
- if (attempts.length > 0) task.attempts = attempts;
333
- await this.put(task);
334
- return task;
359
+ return this._updateWithCAS(taskId, (task) => {
360
+ if (TERMINAL_STATES.has(task.status)) return null;
361
+ task.status = TASK_STATUS.RELEASED;
362
+ task.completed_at = new Date().toISOString();
363
+ task.result = { success: false, summary: reason, released: true };
364
+ if (attempts.length > 0) task.attempts = attempts;
365
+ return task;
366
+ });
335
367
  }
336
368
 
337
369
  /**
338
370
  * Update last_activity timestamp (agent heartbeat).
339
371
  */
340
372
  async touchActivity(taskId) {
341
- const task = await this.get(taskId);
342
- if (!task) return null;
343
- task.last_activity = new Date().toISOString();
344
- await this.put(task);
345
- return task;
373
+ return this._updateWithCAS(taskId, (task) => {
374
+ if (TERMINAL_STATES.has(task.status)) return null;
375
+ task.last_activity = new Date().toISOString();
376
+ return task;
377
+ });
346
378
  }
347
379
 
348
380
  /**
@@ -363,25 +395,25 @@ class TaskStore {
363
395
  }
364
396
 
365
397
  /**
366
- * Find running tasks with no activity for `stallMinutes`.
398
+ * Find running or claimed tasks with no activity for `stallMinutes`.
367
399
  * Stall detection is separate from budget — a task can be within budget
368
400
  * but the agent process may have died silently.
401
+ * Claimed tasks that never transition to running (agent crashed after claim)
402
+ * are also detected and released back to queued.
369
403
  */
370
404
  async findStalled(stallMinutes = 5) {
371
405
  const running = await this.list({ status: TASK_STATUS.RUNNING });
406
+ const claimed = await this.list({ status: TASK_STATUS.CLAIMED });
372
407
  const cutoff = Date.now() - stallMinutes * 60 * 1000;
373
- return running.filter(t => {
374
- const lastSignal = t.last_activity || t.started_at;
408
+ return [...running, ...claimed].filter(t => {
409
+ const lastSignal = t.last_activity || t.started_at || t.claimed_at;
375
410
  return lastSignal && new Date(lastSignal) < cutoff;
376
411
  });
377
412
  }
378
413
 
379
414
  async _checkDeps(depIds) {
380
- for (const depId of depIds) {
381
- const dep = await this.get(depId);
382
- if (!dep || dep.status !== TASK_STATUS.COMPLETED) return false;
383
- }
384
- return true;
415
+ const deps = await Promise.all(depIds.map(id => this.get(id)));
416
+ return deps.every(dep => dep && dep.status === TASK_STATUS.COMPLETED);
385
417
  }
386
418
  }
387
419
 
@@ -37,7 +37,7 @@ function resolveNatsUrl() {
37
37
  if (fs.existsSync(envFile)) {
38
38
  const content = fs.readFileSync(envFile, 'utf8');
39
39
  const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
40
- if (match && match[1].trim()) return match[1].trim();
40
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
41
41
  }
42
42
  } catch {
43
43
  // File unreadable — fall through silently
@@ -49,7 +49,7 @@ function resolveNatsUrl() {
49
49
  if (fs.existsSync(meshConfig)) {
50
50
  const content = fs.readFileSync(meshConfig, 'utf8');
51
51
  const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
52
- if (match && match[1].trim()) return match[1].trim();
52
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
53
53
  }
54
54
  } catch {
55
55
  // File unreadable — fall through silently
@@ -73,7 +73,7 @@ function resolveNatsToken() {
73
73
  if (fs.existsSync(envFile)) {
74
74
  const content = fs.readFileSync(envFile, 'utf8');
75
75
  const match = content.match(/^\s*OPENCLAW_NATS_TOKEN\s*=\s*(.+)/m);
76
- if (match && match[1].trim()) return match[1].trim();
76
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
77
77
  }
78
78
  } catch {}
79
79
 
@@ -83,7 +83,7 @@ function resolveNatsToken() {
83
83
  if (fs.existsSync(meshConfig)) {
84
84
  const content = fs.readFileSync(meshConfig, 'utf8');
85
85
  const match = content.match(/^\s*OPENCLAW_NATS_TOKEN\s*=\s*(.+)/m);
86
- if (match && match[1].trim()) return match[1].trim();
86
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
87
87
  }
88
88
  } catch {}
89
89
 
@@ -74,6 +74,8 @@ export async function shouldFlush(jsonlPath, opts = {}) {
74
74
  const { contextWindowTokens = 200000, flushPct = 0.75 } = opts;
75
75
  const threshold = Math.floor(contextWindowTokens * flushPct);
76
76
 
77
+ if (!fs.existsSync(jsonlPath)) return { shouldFlush: false, estimatedTokens: 0, pctUsed: 0, threshold };
78
+
77
79
  const stat = fs.statSync(jsonlPath);
78
80
  // Quick estimate from file size — ~4 chars/token, but JSONL has overhead (~2x)
79
81
  const quickEstimate = Math.ceil(stat.size / (CHARS_PER_TOKEN * 2));
@@ -398,10 +398,13 @@ export class SessionStore {
398
398
  * - Escapes quotes
399
399
  */
400
400
  #buildFtsQuery(query) {
401
- const cleaned = query.replace(/"/g, '""').trim();
402
- if (!cleaned) return null;
401
+ if (!query || !query.trim()) return null;
402
+ // Escape double quotes, then strip FTS5 operators to prevent query injection
403
+ const escaped = query.replace(/"/g, '""').trim();
404
+ const safe = escaped.replace(/[*(){}^]/g, '');
405
+ if (!safe) return null;
403
406
 
404
- const words = cleaned.split(/\s+/).filter(w => w.length > 0);
407
+ const words = safe.split(/\s+/).filter(w => w.length > 0);
405
408
  if (words.length === 0) return null;
406
409
 
407
410
  if (words.length === 1) {