omoclaw 2.4.0 → 3.0.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.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // @bun
2
2
  // src/index.ts
3
- import fs3 from "fs";
4
- import os2 from "os";
5
- import path2 from "path";
3
+ import fs5 from "fs";
4
+ import os3 from "os";
5
+ import path4 from "path";
6
6
 
7
7
  // src/config.ts
8
8
  import fs from "fs";
@@ -238,6 +238,344 @@ function handleSessionStatusEvent(event, dedup, state, timers, webhookFn, debugL
238
238
  }
239
239
  }
240
240
 
241
+ // src/project-config.ts
242
+ import fs2 from "fs";
243
+ import path2 from "path";
244
+ var PROJECT_CONFIG_FILE = ".omoclaw.local.json";
245
+ function asObject2(value) {
246
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
247
+ return;
248
+ }
249
+ return value;
250
+ }
251
+ function asString(value) {
252
+ return typeof value === "string" ? value.trim() : "";
253
+ }
254
+ function parseSessionKey(sessionKey) {
255
+ const parts = sessionKey.split(":");
256
+ if (parts.length < 5 || parts[0] !== "agent") {
257
+ return { agentId: "", channelId: "" };
258
+ }
259
+ return {
260
+ agentId: parts[1] ?? "",
261
+ channelId: parts.slice(4).join(":")
262
+ };
263
+ }
264
+ function buildSessionKeyFromConfig(config) {
265
+ const webhook = config.webhook;
266
+ if (!webhook.channel) {
267
+ return "";
268
+ }
269
+ return `agent:${webhook.agentId}:${webhook.channelType}:${webhook.chatType}:${webhook.channel}`;
270
+ }
271
+ function loadProjectConfig(cwd = process.cwd()) {
272
+ const filePath = path2.join(cwd, PROJECT_CONFIG_FILE);
273
+ try {
274
+ const stats = fs2.statSync(filePath);
275
+ const mode = stats.mode & 511;
276
+ if (mode !== 384) {
277
+ try {
278
+ fs2.chmodSync(filePath, 384);
279
+ } catch {}
280
+ }
281
+ const raw = fs2.readFileSync(filePath, "utf8");
282
+ const parsed = JSON.parse(raw);
283
+ const root = asObject2(parsed);
284
+ if (!root) {
285
+ return { sessionKey: "", tmuxSession: "" };
286
+ }
287
+ return {
288
+ sessionKey: asString(root.sessionKey),
289
+ tmuxSession: asString(root.tmuxSession)
290
+ };
291
+ } catch {
292
+ return { sessionKey: "", tmuxSession: "" };
293
+ }
294
+ }
295
+ function resolveSessionRouting(config, projectConfig) {
296
+ const envSessionKey = asString(process.env.OPENCLAW_SESSION_KEY);
297
+ if (envSessionKey) {
298
+ const parsed = parseSessionKey(envSessionKey);
299
+ return {
300
+ sessionKey: envSessionKey,
301
+ source: "env",
302
+ agentId: parsed.agentId || config.webhook.agentId,
303
+ channelId: parsed.channelId || config.webhook.channel
304
+ };
305
+ }
306
+ if (projectConfig.sessionKey) {
307
+ const parsed = parseSessionKey(projectConfig.sessionKey);
308
+ return {
309
+ sessionKey: projectConfig.sessionKey,
310
+ source: "project",
311
+ agentId: parsed.agentId || config.webhook.agentId,
312
+ channelId: parsed.channelId || config.webhook.channel
313
+ };
314
+ }
315
+ if (config.webhook.sessionKey) {
316
+ const parsed = parseSessionKey(config.webhook.sessionKey);
317
+ return {
318
+ sessionKey: config.webhook.sessionKey,
319
+ source: "config",
320
+ agentId: parsed.agentId || config.webhook.agentId,
321
+ channelId: parsed.channelId || config.webhook.channel
322
+ };
323
+ }
324
+ const derivedSessionKey = buildSessionKeyFromConfig(config);
325
+ if (derivedSessionKey) {
326
+ return {
327
+ sessionKey: derivedSessionKey,
328
+ source: "derived",
329
+ agentId: config.webhook.agentId,
330
+ channelId: config.webhook.channel
331
+ };
332
+ }
333
+ return {
334
+ sessionKey: "",
335
+ source: "none",
336
+ agentId: config.webhook.agentId,
337
+ channelId: config.webhook.channel
338
+ };
339
+ }
340
+
341
+ // src/registry.ts
342
+ import { createHash, randomUUID } from "crypto";
343
+ import fs3 from "fs";
344
+ import os2 from "os";
345
+ import path3 from "path";
346
+ var REGISTRY_VERSION = 1;
347
+ var DEFAULT_TTL_MS = 60000;
348
+ var DEFAULT_HEARTBEAT_MS = 20000;
349
+ var REGISTRY_DIR_PATH = path3.join(os2.homedir(), ".local", "state", "omoclaw");
350
+ var REGISTRY_FILE_PATH = path3.join(REGISTRY_DIR_PATH, "registry.json");
351
+ function asObject3(value) {
352
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
353
+ return;
354
+ }
355
+ return value;
356
+ }
357
+ function asString2(value) {
358
+ return typeof value === "string" ? value : "";
359
+ }
360
+ function errorToString(error) {
361
+ if (error instanceof Error) {
362
+ return `${error.name}: ${error.message}`;
363
+ }
364
+ return String(error);
365
+ }
366
+ function parseRegistryEntry(value) {
367
+ const entry = asObject3(value);
368
+ if (!entry) {
369
+ return;
370
+ }
371
+ const pid = typeof entry.pid === "number" ? entry.pid : Number.NaN;
372
+ if (!Number.isInteger(pid) || pid <= 0) {
373
+ return;
374
+ }
375
+ const parsed = {
376
+ instanceId: asString2(entry.instanceId),
377
+ hostname: asString2(entry.hostname),
378
+ hostLabel: asString2(entry.hostLabel),
379
+ pid,
380
+ startedAt: asString2(entry.startedAt),
381
+ leaseExpiresAt: asString2(entry.leaseExpiresAt),
382
+ project: asString2(entry.project),
383
+ projectId: asString2(entry.projectId),
384
+ workspaceRoot: asString2(entry.workspaceRoot),
385
+ tmuxSession: asString2(entry.tmuxSession),
386
+ openclawSessionKey: asString2(entry.openclawSessionKey),
387
+ agentId: asString2(entry.agentId),
388
+ channelId: asString2(entry.channelId)
389
+ };
390
+ if (!parsed.instanceId || !parsed.hostname || !parsed.hostLabel || !parsed.startedAt || !parsed.leaseExpiresAt || !parsed.project || !parsed.projectId || !parsed.workspaceRoot) {
391
+ return;
392
+ }
393
+ return parsed;
394
+ }
395
+
396
+ class Registry {
397
+ instanceId = randomUUID();
398
+ hostname = os2.hostname();
399
+ workspaceRoot;
400
+ project;
401
+ projectId;
402
+ ttlMs;
403
+ heartbeatMs;
404
+ debugLog;
405
+ heartbeatTimer;
406
+ hooksInstalled = false;
407
+ entryTemplate;
408
+ stopOnProcessExit = () => {
409
+ this.stop();
410
+ };
411
+ constructor(options = {}) {
412
+ this.workspaceRoot = options.workspaceRoot ?? process.cwd();
413
+ this.project = path3.basename(this.workspaceRoot);
414
+ this.projectId = createHash("sha256").update(this.workspaceRoot).digest("hex");
415
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
416
+ this.heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
417
+ this.debugLog = options.debugLog;
418
+ }
419
+ start(input) {
420
+ const startedAt = new Date().toISOString();
421
+ this.entryTemplate = {
422
+ instanceId: this.instanceId,
423
+ hostname: this.hostname,
424
+ hostLabel: input.hostLabel,
425
+ pid: process.pid,
426
+ startedAt,
427
+ project: this.project,
428
+ projectId: this.projectId,
429
+ workspaceRoot: this.workspaceRoot,
430
+ tmuxSession: input.tmuxSession,
431
+ openclawSessionKey: input.openclawSessionKey,
432
+ agentId: input.agentId,
433
+ channelId: input.channelId
434
+ };
435
+ this.upsert();
436
+ this.installProcessHooks();
437
+ this.startHeartbeat();
438
+ }
439
+ stop() {
440
+ if (this.heartbeatTimer) {
441
+ clearInterval(this.heartbeatTimer);
442
+ this.heartbeatTimer = undefined;
443
+ }
444
+ this.remove();
445
+ }
446
+ readSessions() {
447
+ const registry = this.readAndGc();
448
+ return registry.sessions;
449
+ }
450
+ startHeartbeat() {
451
+ if (this.heartbeatTimer) {
452
+ clearInterval(this.heartbeatTimer);
453
+ }
454
+ this.heartbeatTimer = setInterval(() => {
455
+ this.upsert();
456
+ }, this.heartbeatMs);
457
+ this.heartbeatTimer.unref?.();
458
+ }
459
+ installProcessHooks() {
460
+ if (this.hooksInstalled) {
461
+ return;
462
+ }
463
+ this.hooksInstalled = true;
464
+ process.once("beforeExit", this.stopOnProcessExit);
465
+ process.once("exit", this.stopOnProcessExit);
466
+ }
467
+ upsert() {
468
+ if (!this.entryTemplate) {
469
+ return;
470
+ }
471
+ const leaseExpiresAt = new Date(Date.now() + this.ttlMs).toISOString();
472
+ const entry = {
473
+ ...this.entryTemplate,
474
+ leaseExpiresAt
475
+ };
476
+ this.updateSessions((sessions) => {
477
+ const existingIndex = sessions.findIndex((session) => session.instanceId === this.instanceId);
478
+ if (existingIndex === -1) {
479
+ return [...sessions, entry];
480
+ }
481
+ const next = [...sessions];
482
+ next[existingIndex] = entry;
483
+ return next;
484
+ });
485
+ }
486
+ remove() {
487
+ this.updateSessions((sessions) => {
488
+ return sessions.filter((session) => session.instanceId !== this.instanceId);
489
+ });
490
+ }
491
+ updateSessions(updater) {
492
+ try {
493
+ const current = this.readAndGc();
494
+ const updatedSessions = updater(current.sessions);
495
+ this.writeRegistryFile({
496
+ version: REGISTRY_VERSION,
497
+ sessions: updatedSessions
498
+ });
499
+ } catch (error) {
500
+ this.debugLog?.(`registry update failed: ${errorToString(error)}`);
501
+ }
502
+ }
503
+ readAndGc() {
504
+ const registry = this.readRegistryFile();
505
+ const now = Date.now();
506
+ const sessions = registry.sessions.filter((session) => {
507
+ const expiresAtMs = Date.parse(session.leaseExpiresAt);
508
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) {
509
+ return false;
510
+ }
511
+ return this.isPidAlive(session.pid);
512
+ });
513
+ if (sessions.length !== registry.sessions.length) {
514
+ this.writeRegistryFile({
515
+ version: REGISTRY_VERSION,
516
+ sessions
517
+ });
518
+ }
519
+ return {
520
+ version: REGISTRY_VERSION,
521
+ sessions
522
+ };
523
+ }
524
+ readRegistryFile() {
525
+ this.ensureRegistryDir();
526
+ try {
527
+ const raw = fs3.readFileSync(REGISTRY_FILE_PATH, "utf8");
528
+ const parsed = JSON.parse(raw);
529
+ const root = asObject3(parsed);
530
+ const items = Array.isArray(root?.sessions) ? root.sessions : [];
531
+ const sessions = items.map((item) => parseRegistryEntry(item)).filter((item) => item !== undefined);
532
+ return {
533
+ version: REGISTRY_VERSION,
534
+ sessions
535
+ };
536
+ } catch {
537
+ return {
538
+ version: REGISTRY_VERSION,
539
+ sessions: []
540
+ };
541
+ }
542
+ }
543
+ writeRegistryFile(registry) {
544
+ this.ensureRegistryDir();
545
+ const payload = JSON.stringify({
546
+ version: REGISTRY_VERSION,
547
+ sessions: registry.sessions
548
+ }, null, 2);
549
+ const tmpPath = `${REGISTRY_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
550
+ fs3.writeFileSync(tmpPath, payload, { mode: 384 });
551
+ fs3.renameSync(tmpPath, REGISTRY_FILE_PATH);
552
+ try {
553
+ fs3.chmodSync(REGISTRY_FILE_PATH, 384);
554
+ } catch {}
555
+ }
556
+ ensureRegistryDir() {
557
+ fs3.mkdirSync(REGISTRY_DIR_PATH, { recursive: true, mode: 448 });
558
+ try {
559
+ fs3.chmodSync(REGISTRY_DIR_PATH, 448);
560
+ } catch {}
561
+ }
562
+ isPidAlive(pid) {
563
+ if (!Number.isInteger(pid) || pid <= 0) {
564
+ return false;
565
+ }
566
+ if (pid === process.pid) {
567
+ return true;
568
+ }
569
+ try {
570
+ process.kill(pid, 0);
571
+ return true;
572
+ } catch (error) {
573
+ const code = error.code;
574
+ return code === "EPERM";
575
+ }
576
+ }
577
+ }
578
+
241
579
  // src/state.ts
242
580
  class SessionStateTracker {
243
581
  states = new Map;
@@ -403,26 +741,54 @@ class TimerManager {
403
741
  }
404
742
 
405
743
  // src/webhook.ts
406
- import fs2 from "fs";
744
+ import { randomUUID as randomUUID2 } from "crypto";
745
+ import fs4 from "fs";
407
746
  var LOG_PATH = "/tmp/opencode-monitor-debug.log";
408
747
  function wlog(msg) {
409
748
  try {
410
- fs2.appendFileSync(LOG_PATH, `[${new Date().toISOString()}] WEBHOOK_IO: ${msg}
749
+ fs4.appendFileSync(LOG_PATH, `[${new Date().toISOString()}] WEBHOOK_IO: ${msg}
411
750
  `);
412
751
  } catch {}
413
752
  }
414
- function sendWebhook(config, text) {
415
- const { webhook } = config;
416
- const body = { text, mode: "now" };
753
+ function resolveFallbackSessionKey(config) {
417
754
  const envSessionKey = process.env.OPENCLAW_SESSION_KEY;
418
755
  if (envSessionKey) {
419
- body.sessionKey = envSessionKey;
420
- } else if (webhook.sessionKey) {
421
- body.sessionKey = webhook.sessionKey;
422
- } else if (webhook.channel) {
423
- body.sessionKey = `agent:${webhook.agentId}:${webhook.channelType}:${webhook.chatType}:${webhook.channel}`;
756
+ return envSessionKey;
757
+ }
758
+ if (config.webhook.sessionKey) {
759
+ return config.webhook.sessionKey;
760
+ }
761
+ if (config.webhook.channel) {
762
+ return `agent:${config.webhook.agentId}:${config.webhook.channelType}:${config.webhook.chatType}:${config.webhook.channel}`;
763
+ }
764
+ return "";
765
+ }
766
+ function buildCorrelationId(text, provided) {
767
+ if (provided && provided.trim()) {
768
+ return provided.trim();
424
769
  }
425
- wlog(`Sending /hooks/wake to ${webhook.url} (session: ${body.sessionKey ?? "main"}): ${text}`);
770
+ const sessionMatch = text.match(/\((ses_[A-Za-z0-9_-]+)\)/);
771
+ if (sessionMatch?.[1]) {
772
+ return `${sessionMatch[1]}:${Date.now()}`;
773
+ }
774
+ return randomUUID2();
775
+ }
776
+ function sendWebhook(config, text, options = {}) {
777
+ const { webhook } = config;
778
+ const envSessionKey = process.env.OPENCLAW_SESSION_KEY?.trim();
779
+ const sessionKey = envSessionKey || options.sessionKey?.trim() || resolveFallbackSessionKey(config);
780
+ const correlationId = buildCorrelationId(text, options.correlationId);
781
+ const body = {
782
+ text,
783
+ mode: "now",
784
+ metadata: {
785
+ correlationId
786
+ }
787
+ };
788
+ if (sessionKey) {
789
+ body.sessionKey = sessionKey;
790
+ }
791
+ wlog(`Sending /hooks/wake to ${webhook.url} (session: ${sessionKey || "main"}, correlationId: ${correlationId}): ${text}`);
426
792
  fetch(webhook.url, {
427
793
  method: "POST",
428
794
  headers: {
@@ -442,26 +808,37 @@ function sendWebhook(config, text) {
442
808
  // src/index.ts
443
809
  var DEBUG_LOG_PATH = "/tmp/opencode-monitor-debug.log";
444
810
  function buildPrefix(config) {
445
- const host = config.hostLabel || os2.hostname();
446
- const project = path2.basename(process.cwd());
811
+ const host = config.hostLabel || os3.hostname();
812
+ const project = path4.basename(process.cwd());
447
813
  return `[OpenCode@${host}:${project}]`;
448
814
  }
449
815
  var SessionMonitorPlugin = async (_ctx) => {
450
816
  const config = loadConfig();
817
+ const projectConfig = loadProjectConfig();
818
+ const sessionRouting = resolveSessionRouting(config, projectConfig);
451
819
  const prefix = buildPrefix(config);
452
820
  const debugLog = config.debug ? (msg) => {
453
821
  const line = `[${new Date().toISOString()}] ${msg}
454
822
  `;
455
823
  try {
456
- fs3.appendFileSync(DEBUG_LOG_PATH, line);
824
+ fs5.appendFileSync(DEBUG_LOG_PATH, line);
457
825
  } catch {}
458
826
  } : undefined;
459
827
  debugLog?.("Plugin initializing...");
460
828
  debugLog?.(`Config loaded: enabled=${config.enabled}, url=${config.webhook.url}`);
829
+ debugLog?.(`Routing session source=${sessionRouting.source}`);
461
830
  if (!config.enabled) {
462
831
  debugLog?.("Plugin disabled, exiting.");
463
832
  return {};
464
833
  }
834
+ const registry = new Registry({ debugLog });
835
+ registry.start({
836
+ hostLabel: config.hostLabel || os3.hostname(),
837
+ tmuxSession: projectConfig.tmuxSession,
838
+ openclawSessionKey: sessionRouting.sessionKey,
839
+ agentId: sessionRouting.agentId,
840
+ channelId: sessionRouting.channelId
841
+ });
465
842
  if (!config.webhook.url || !config.webhook.token) {
466
843
  console.log("[monitor] Webhook not configured. Set webhook.url and webhook.token in ~/.config/opencode/opencode-monitor.json");
467
844
  return {};
@@ -471,7 +848,9 @@ var SessionMonitorPlugin = async (_ctx) => {
471
848
  const webhookFn = (text) => {
472
849
  const tagged = text.startsWith("[OpenCode]") ? prefix + text.slice("[OpenCode]".length) : `${prefix} ${text}`;
473
850
  debugLog?.(`WEBHOOK: ${tagged}`);
474
- sendWebhook(config, tagged);
851
+ sendWebhook(config, tagged, {
852
+ sessionKey: sessionRouting.sessionKey
853
+ });
475
854
  };
476
855
  const timers = new TimerManager(config, webhookFn);
477
856
  debugLog?.("Plugin loaded successfully. Waiting for events...");
@@ -0,0 +1,13 @@
1
+ import type { MonitorConfig } from "./config";
2
+ export interface ProjectConfig {
3
+ sessionKey: string;
4
+ tmuxSession: string;
5
+ }
6
+ export interface SessionRouting {
7
+ sessionKey: string;
8
+ source: "env" | "project" | "config" | "derived" | "none";
9
+ agentId: string;
10
+ channelId: string;
11
+ }
12
+ export declare function loadProjectConfig(cwd?: string): ProjectConfig;
13
+ export declare function resolveSessionRouting(config: MonitorConfig, projectConfig: ProjectConfig): SessionRouting;
@@ -0,0 +1,57 @@
1
+ export declare const REGISTRY_FILE_PATH: string;
2
+ export interface RegistryEntry {
3
+ instanceId: string;
4
+ hostname: string;
5
+ hostLabel: string;
6
+ pid: number;
7
+ startedAt: string;
8
+ leaseExpiresAt: string;
9
+ project: string;
10
+ projectId: string;
11
+ workspaceRoot: string;
12
+ tmuxSession: string;
13
+ openclawSessionKey: string;
14
+ agentId: string;
15
+ channelId: string;
16
+ }
17
+ export interface RegistryStartInput {
18
+ hostLabel: string;
19
+ tmuxSession: string;
20
+ openclawSessionKey: string;
21
+ agentId: string;
22
+ channelId: string;
23
+ }
24
+ export interface RegistryOptions {
25
+ workspaceRoot?: string;
26
+ ttlMs?: number;
27
+ heartbeatMs?: number;
28
+ debugLog?: (msg: string) => void;
29
+ }
30
+ export declare class Registry {
31
+ private readonly instanceId;
32
+ private readonly hostname;
33
+ private readonly workspaceRoot;
34
+ private readonly project;
35
+ private readonly projectId;
36
+ private readonly ttlMs;
37
+ private readonly heartbeatMs;
38
+ private readonly debugLog;
39
+ private heartbeatTimer;
40
+ private hooksInstalled;
41
+ private entryTemplate;
42
+ private readonly stopOnProcessExit;
43
+ constructor(options?: RegistryOptions);
44
+ start(input: RegistryStartInput): void;
45
+ stop(): void;
46
+ readSessions(): RegistryEntry[];
47
+ private startHeartbeat;
48
+ private installProcessHooks;
49
+ private upsert;
50
+ private remove;
51
+ private updateSessions;
52
+ private readAndGc;
53
+ private readRegistryFile;
54
+ private writeRegistryFile;
55
+ private ensureRegistryDir;
56
+ private isPidAlive;
57
+ }
package/dist/webhook.d.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  import type { MonitorConfig } from "./config";
2
- export declare function sendWebhook(config: MonitorConfig, text: string): void;
2
+ interface SendWebhookOptions {
3
+ sessionKey?: string;
4
+ correlationId?: string;
5
+ }
6
+ export declare function sendWebhook(config: MonitorConfig, text: string, options?: SendWebhookOptions): void;
7
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omoclaw",
3
- "version": "2.4.0",
3
+ "version": "3.0.0",
4
4
  "description": "OpenCode session monitor plugin \u2014 native event-driven webhook notifications for session state changes",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",