vibeusage 0.2.20 → 0.2.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -9,6 +9,7 @@
9
9
  "type": "commonjs",
10
10
  "scripts": {
11
11
  "test": "node --test test/*.test.js",
12
+ "ci:local": "npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && node --test test/architecture-guardrails.test.js && npm run build:insforge:check && npm --prefix dashboard run build",
12
13
  "smoke": "node scripts/smoke/insforge-smoke.cjs",
13
14
  "build:insforge": "node scripts/build-insforge-functions.cjs",
14
15
  "build:insforge:check": "node scripts/build-insforge-functions.cjs --check",
@@ -24,6 +25,7 @@
24
25
  "architecture:canvas:focus": "node scripts/ops/architecture-canvas.cjs --focus",
25
26
  "architecture:canvas:list-modules": "node scripts/ops/architecture-canvas.cjs --list-modules",
26
27
  "validate:guardrails": "node scripts/validate-architecture-guardrails.cjs",
28
+ "validate:retros": "node scripts/validate-retros.cjs",
27
29
  "validate:insforge2-db": "node scripts/ops/insforge2-db-validate.cjs",
28
30
  "graph:scip": "node scripts/graph/generate-scip.cjs",
29
31
  "graph:auto-index": "node scripts/graph/auto-index.cjs"
@@ -22,7 +22,11 @@ const {
22
22
  isGeminiHookConfigured
23
23
  } = require('../lib/gemini-config');
24
24
  const { resolveOpencodeConfigDir, upsertOpencodePlugin, isOpencodePluginInstalled } = require('../lib/opencode-config');
25
- const { installOpenclawHook, probeOpenclawHookState } = require('../lib/openclaw-hook');
25
+ const { removeOpenclawHookConfig, probeOpenclawHookState } = require('../lib/openclaw-hook');
26
+ const {
27
+ installOpenclawSessionPlugin,
28
+ probeOpenclawSessionPluginState
29
+ } = require('../lib/openclaw-session-plugin');
26
30
  const { beginBrowserAuth, openInBrowser } = require('../lib/browser-auth');
27
31
  const {
28
32
  issueDeviceTokenWithPassword,
@@ -408,32 +412,47 @@ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOrigi
408
412
  summary.push({ label: 'Opencode Plugin', status: opencodeResult?.changed ? 'installed' : 'set', detail: 'Plugin installed' });
409
413
  }
410
414
 
411
- const openclawBefore = await probeOpenclawHookState({ home, trackerDir, env: process.env });
412
- const openclawInstall = await installOpenclawHook({ home, trackerDir, packageName: 'vibeusage', env: process.env });
415
+ const openclawBefore = await probeOpenclawSessionPluginState({ home, trackerDir, env: process.env });
416
+ const openclawInstall = await installOpenclawSessionPlugin({
417
+ home,
418
+ trackerDir,
419
+ packageName: 'vibeusage',
420
+ env: process.env
421
+ });
413
422
  if (openclawInstall?.skippedReason === 'openclaw-cli-missing') {
414
- summary.push({ label: 'OpenClaw Hook', status: 'skipped', detail: 'OpenClaw CLI not found' });
415
- } else if (openclawInstall?.skippedReason === 'openclaw-hooks-install-failed') {
423
+ summary.push({ label: 'OpenClaw Session Plugin', status: 'skipped', detail: 'OpenClaw CLI not found' });
424
+ } else if (openclawInstall?.skippedReason === 'openclaw-plugins-install-failed') {
416
425
  summary.push({
417
- label: 'OpenClaw Hook',
426
+ label: 'OpenClaw Session Plugin',
418
427
  status: 'skipped',
419
428
  detail: `Install failed${openclawInstall.error ? `: ${openclawInstall.error}` : ''}`
420
429
  });
421
430
  } else if (openclawInstall?.skippedReason === 'openclaw-config-unreadable') {
422
431
  summary.push({
423
- label: 'OpenClaw Hook',
432
+ label: 'OpenClaw Session Plugin',
424
433
  status: 'skipped',
425
434
  detail: openclawInstall.error ? `OpenClaw config unreadable: ${openclawInstall.error}` : 'OpenClaw config unreadable'
426
435
  });
427
436
  } else if (openclawInstall?.configured) {
428
437
  summary.push({
429
- label: 'OpenClaw Hook',
438
+ label: 'OpenClaw Session Plugin',
430
439
  status: openclawBefore?.configured ? 'set' : 'installed',
431
440
  detail: openclawBefore?.configured
432
- ? 'Hook already linked'
433
- : 'Hook linked (restart OpenClaw gateway to activate)'
441
+ ? 'Session plugin already linked'
442
+ : 'Session plugin linked (restart OpenClaw gateway to activate)'
434
443
  });
435
444
  } else {
436
- summary.push({ label: 'OpenClaw Hook', status: 'skipped', detail: 'OpenClaw hook unavailable' });
445
+ summary.push({ label: 'OpenClaw Session Plugin', status: 'skipped', detail: 'OpenClaw session plugin unavailable' });
446
+ }
447
+
448
+ const legacyHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
449
+ if (legacyHookState?.configured || legacyHookState?.linked || legacyHookState?.enabled) {
450
+ await removeOpenclawHookConfig({ home, trackerDir, env: process.env });
451
+ summary.push({
452
+ label: 'OpenClaw Hook (legacy)',
453
+ status: 'updated',
454
+ detail: 'Removed legacy command hook (migrated to session plugin)'
455
+ });
437
456
  }
438
457
 
439
458
  const codeProbe = await probeFile(context.codeConfigPath);
@@ -515,22 +534,31 @@ async function previewIntegrations({ context }) {
515
534
  detail: opencodeDetail
516
535
  });
517
536
 
518
- const openclawState = await probeOpenclawHookState({ home, trackerDir: context.trackerDir, env: process.env });
537
+ const openclawState = await probeOpenclawSessionPluginState({ home, trackerDir: context.trackerDir, env: process.env });
519
538
  if (openclawState?.skippedReason === 'openclaw-config-missing') {
520
- summary.push({ label: 'OpenClaw Hook', status: 'skipped', detail: 'OpenClaw config not found' });
539
+ summary.push({ label: 'OpenClaw Session Plugin', status: 'skipped', detail: 'OpenClaw config not found' });
521
540
  } else if (openclawState?.skippedReason === 'openclaw-config-unreadable') {
522
541
  summary.push({
523
- label: 'OpenClaw Hook',
542
+ label: 'OpenClaw Session Plugin',
524
543
  status: 'skipped',
525
544
  detail: openclawState.error ? `OpenClaw config unreadable: ${openclawState.error}` : 'OpenClaw config unreadable'
526
545
  });
527
546
  } else {
528
547
  summary.push({
529
- label: 'OpenClaw Hook',
548
+ label: 'OpenClaw Session Plugin',
530
549
  status: openclawState?.configured ? 'set' : 'installed',
531
550
  detail: openclawState?.configured
532
- ? 'Hook already linked'
533
- : 'Will link hook (restart OpenClaw gateway to activate)'
551
+ ? 'Session plugin already linked'
552
+ : 'Will link session plugin (restart OpenClaw gateway to activate)'
553
+ });
554
+ }
555
+
556
+ const legacyHookState = await probeOpenclawHookState({ home, trackerDir: context.trackerDir, env: process.env });
557
+ if (legacyHookState?.configured || legacyHookState?.linked || legacyHookState?.enabled) {
558
+ summary.push({
559
+ label: 'OpenClaw Hook (legacy)',
560
+ status: 'updated',
561
+ detail: 'Will remove legacy command hook during migration'
534
562
  });
535
563
  }
536
564
 
@@ -787,6 +815,12 @@ async function installLocalTrackerApp({ appDir }) {
787
815
  const binFrom = path.join(packageRoot, 'bin', 'tracker.js');
788
816
  const nodeModulesFrom = path.join(packageRoot, 'node_modules');
789
817
 
818
+ // When running from the installed local runtime (or when appDir is symlinked to this package),
819
+ // source and destination resolve to the same place. Do not delete appDir in that case.
820
+ if (await pathsPointToSameLocation(packageRoot, appDir)) {
821
+ return;
822
+ }
823
+
790
824
  const srcTo = path.join(appDir, 'src');
791
825
  const binToDir = path.join(appDir, 'bin');
792
826
  const binTo = path.join(binToDir, 'tracker.js');
@@ -801,6 +835,21 @@ async function installLocalTrackerApp({ appDir }) {
801
835
  await copyRuntimeDependencies({ from: nodeModulesFrom, to: nodeModulesTo });
802
836
  }
803
837
 
838
+ async function pathsPointToSameLocation(a, b) {
839
+ const aReal = await safeRealpath(a);
840
+ const bReal = await safeRealpath(b);
841
+ if (aReal && bReal) return aReal === bReal;
842
+ return path.resolve(a) === path.resolve(b);
843
+ }
844
+
845
+ async function safeRealpath(p) {
846
+ try {
847
+ return await fs.realpath(p);
848
+ } catch (_err) {
849
+ return null;
850
+ }
851
+ }
852
+
804
853
  function spawnInitSync({ trackerBinPath, packageName }) {
805
854
  const fallbackPkg = packageName || 'vibeusage';
806
855
  const argv = ['sync', '--drain'];
@@ -16,6 +16,7 @@ const { collectLocalSubscriptions } = require('../lib/subscriptions');
16
16
  const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
17
17
  const { collectTrackerDiagnostics } = require('../lib/diagnostics');
18
18
  const { probeOpenclawHookState } = require('../lib/openclaw-hook');
19
+ const { probeOpenclawSessionPluginState } = require('../lib/openclaw-session-plugin');
19
20
  const { resolveTrackerPaths } = require('../lib/tracker-paths');
20
21
 
21
22
  async function cmdStatus(argv = []) {
@@ -75,6 +76,7 @@ async function cmdStatus(argv = []) {
75
76
  hookCommand: geminiHookCommand
76
77
  });
77
78
  const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
79
+ const openclawSessionPluginState = await probeOpenclawSessionPluginState({ home, trackerDir, env: process.env });
78
80
  const openclawHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
79
81
 
80
82
  const lastUpload = uploadThrottle.lastSuccessMs
@@ -125,7 +127,8 @@ async function cmdStatus(argv = []) {
125
127
  `- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
126
128
  `- Gemini hooks: ${geminiHookConfigured ? 'set' : 'unset'}`,
127
129
  `- Opencode plugin: ${opencodePluginConfigured ? 'set' : 'unset'}`,
128
- `- OpenClaw hook: ${openclawHookState?.configured ? 'set' : 'unset'}`,
130
+ `- OpenClaw session plugin: ${openclawSessionPluginState?.configured ? 'set' : 'unset'}`,
131
+ `- OpenClaw hook (legacy): ${openclawHookState?.configured ? 'set' : 'unset'}`,
129
132
  ...subscriptionLines,
130
133
  ''
131
134
  ]
@@ -12,6 +12,7 @@ const {
12
12
  } = require('../lib/gemini-config');
13
13
  const { resolveOpencodeConfigDir, removeOpencodePlugin } = require('../lib/opencode-config');
14
14
  const { removeOpenclawHookConfig } = require('../lib/openclaw-hook');
15
+ const { removeOpenclawSessionPluginConfig } = require('../lib/openclaw-session-plugin');
15
16
  const { resolveTrackerPaths } = require('../lib/tracker-paths');
16
17
 
17
18
  async function cmdUninstall(argv) {
@@ -62,6 +63,7 @@ async function cmdUninstall(argv) {
62
63
  const opencodeRemove = opencodeConfigExists
63
64
  ? await removeOpencodePlugin({ configDir: opencodeConfigDir })
64
65
  : { removed: false, skippedReason: 'config-missing' };
66
+ const openclawSessionPluginRemove = await removeOpenclawSessionPluginConfig({ home, trackerDir, env: process.env });
65
67
  const openclawHookRemove = await removeOpenclawHookConfig({ home, trackerDir, env: process.env });
66
68
 
67
69
  // Remove installed notify handler.
@@ -114,11 +116,16 @@ async function cmdUninstall(argv) {
114
116
  ? '- Opencode plugin: skipped (unexpected content)'
115
117
  : '- Opencode plugin: skipped'
116
118
  : `- Opencode plugin: skipped (${opencodeConfigDir} not found)`,
119
+ openclawSessionPluginRemove?.removed
120
+ ? `- OpenClaw session plugin removed: ${openclawSessionPluginRemove.openclawConfigPath}`
121
+ : openclawSessionPluginRemove?.skippedReason === 'openclaw-config-missing'
122
+ ? '- OpenClaw session plugin: skipped (openclaw config not found)'
123
+ : '- OpenClaw session plugin: no change',
117
124
  openclawHookRemove?.removed
118
- ? `- OpenClaw hook removed: ${openclawHookRemove.openclawConfigPath}`
125
+ ? `- OpenClaw hook (legacy) removed: ${openclawHookRemove.openclawConfigPath}`
119
126
  : openclawHookRemove?.skippedReason === 'openclaw-config-missing'
120
- ? '- OpenClaw hook: skipped (openclaw config not found)'
121
- : '- OpenClaw hook: no change',
127
+ ? '- OpenClaw hook (legacy): skipped (openclaw config not found)'
128
+ : '- OpenClaw hook (legacy): no change',
122
129
  opts.purge ? `- Purged: ${path.join(home, '.vibeusage')}` : '- Purge: skipped (use --purge)',
123
130
  ''
124
131
  ].join('\n')
@@ -14,6 +14,7 @@ const {
14
14
  const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('./opencode-config');
15
15
  const { normalizeState: normalizeUploadState } = require('./upload-throttle');
16
16
  const { probeOpenclawHookState } = require('./openclaw-hook');
17
+ const { probeOpenclawSessionPluginState } = require('./openclaw-session-plugin');
17
18
  const { resolveTrackerPaths } = require('./tracker-paths');
18
19
 
19
20
  async function collectTrackerDiagnostics({
@@ -69,6 +70,7 @@ async function collectTrackerDiagnostics({
69
70
  hookCommand: geminiHookCommand
70
71
  });
71
72
  const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
73
+ const openclawSessionPluginState = await probeOpenclawSessionPluginState({ home, trackerDir, env: process.env });
72
74
  const openclawHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
73
75
 
74
76
  const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
@@ -120,6 +122,9 @@ async function collectTrackerDiagnostics({
120
122
  claude_hook_configured: claudeHookConfigured,
121
123
  gemini_hook_configured: geminiHookConfigured,
122
124
  opencode_plugin_configured: opencodePluginConfigured,
125
+ openclaw_session_plugin_configured: Boolean(openclawSessionPluginState?.configured),
126
+ openclaw_session_plugin_linked: Boolean(openclawSessionPluginState?.linked),
127
+ openclaw_session_plugin_enabled: Boolean(openclawSessionPluginState?.enabled),
123
128
  openclaw_hook_configured: Boolean(openclawHookState?.configured),
124
129
  openclaw_hook_linked: Boolean(openclawHookState?.linked),
125
130
  openclaw_hook_enabled: Boolean(openclawHookState?.enabled)
@@ -337,7 +337,8 @@ function buildHookHandler({ trackerDir, packageName = 'vibeusage', openclawHome
337
337
  `\n` +
338
338
  `function resolveSessionEntry(event) {\n` +
339
339
  ` const ctx = (event && event.context && typeof event.context === 'object') ? event.context : {};\n` +
340
- ` if (event && event.action === 'stop') return (ctx.sessionEntry && typeof ctx.sessionEntry === 'object') ? ctx.sessionEntry : null;\n` +
340
+ ` if (!event || event.type !== 'command') return null;\n` +
341
+ ` if (event.action === 'stop') return (ctx.sessionEntry && typeof ctx.sessionEntry === 'object') ? ctx.sessionEntry : null;\n` +
341
342
  ` if (ctx.previousSessionEntry && typeof ctx.previousSessionEntry === 'object') return ctx.previousSessionEntry;\n` +
342
343
  ` if (ctx.sessionEntry && typeof ctx.sessionEntry === 'object') return ctx.sessionEntry;\n` +
343
344
  ` return null;\n` +
@@ -0,0 +1,496 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const fs = require('node:fs/promises');
4
+ const fssync = require('node:fs');
5
+ const cp = require('node:child_process');
6
+
7
+ const OPENCLAW_SESSION_PLUGIN_ID = 'openclaw-session-sync';
8
+ const OPENCLAW_SESSION_PLUGIN_DIRNAME = 'openclaw-plugin';
9
+
10
+ function resolveOpenclawSessionPluginPaths({ home = os.homedir(), trackerDir, env = process.env } = {}) {
11
+ if (!trackerDir) throw new Error('trackerDir is required');
12
+
13
+ const openclawConfigPath =
14
+ normalizeString(env.OPENCLAW_CONFIG_PATH) || path.join(home, '.openclaw', 'openclaw.json');
15
+
16
+ const openclawHome =
17
+ normalizeString(env.VIBEUSAGE_OPENCLAW_HOME) ||
18
+ normalizeString(env.OPENCLAW_STATE_DIR) ||
19
+ path.join(home, '.openclaw');
20
+
21
+ const pluginDir = path.join(trackerDir, OPENCLAW_SESSION_PLUGIN_DIRNAME);
22
+ const pluginEntryDir = path.join(pluginDir, OPENCLAW_SESSION_PLUGIN_ID);
23
+
24
+ return {
25
+ pluginId: OPENCLAW_SESSION_PLUGIN_ID,
26
+ pluginDir,
27
+ pluginEntryDir,
28
+ openclawConfigPath,
29
+ openclawHome
30
+ };
31
+ }
32
+
33
+ async function installOpenclawSessionPlugin({
34
+ home = os.homedir(),
35
+ trackerDir,
36
+ packageName = 'vibeusage',
37
+ env = process.env
38
+ } = {}) {
39
+ const paths = resolveOpenclawSessionPluginPaths({ home, trackerDir, env });
40
+
41
+ await ensureOpenclawSessionPluginFiles({
42
+ pluginDir: paths.pluginDir,
43
+ trackerDir,
44
+ packageName,
45
+ openclawHome: paths.openclawHome
46
+ });
47
+
48
+ const installResult = runOpenclawCli(['plugins', 'install', '--link', paths.pluginEntryDir], env);
49
+ if (installResult.skippedReason) {
50
+ return { configured: false, ...paths, ...installResult };
51
+ }
52
+
53
+ const enableResult = runOpenclawCli(['plugins', 'enable', paths.pluginId], env);
54
+ if (enableResult.skippedReason) {
55
+ return {
56
+ configured: false,
57
+ ...paths,
58
+ skippedReason: enableResult.skippedReason,
59
+ error: enableResult.error,
60
+ stdout: `${installResult.stdout || ''}\n${enableResult.stdout || ''}`.trim(),
61
+ stderr: `${installResult.stderr || ''}\n${enableResult.stderr || ''}`.trim(),
62
+ code: enableResult.code
63
+ };
64
+ }
65
+
66
+ const state = await probeOpenclawSessionPluginState({ home, trackerDir, env });
67
+ return {
68
+ configured: state.configured,
69
+ changed:
70
+ /Linked plugin path:/i.test(installResult.stdout || '') ||
71
+ /Enabled plugin/i.test(enableResult.stdout || '') ||
72
+ /already enabled/i.test(enableResult.stdout || ''),
73
+ ...paths,
74
+ stdout: `${installResult.stdout || ''}\n${enableResult.stdout || ''}`.trim(),
75
+ stderr: `${installResult.stderr || ''}\n${enableResult.stderr || ''}`.trim(),
76
+ code: enableResult.code
77
+ };
78
+ }
79
+
80
+ async function ensureOpenclawSessionPluginFiles({ pluginDir, trackerDir, packageName = 'vibeusage', openclawHome } = {}) {
81
+ if (!pluginDir || !trackerDir) throw new Error('pluginDir and trackerDir are required');
82
+
83
+ const pluginEntryDir = path.join(pluginDir, OPENCLAW_SESSION_PLUGIN_ID);
84
+ await fs.mkdir(pluginEntryDir, { recursive: true });
85
+
86
+ const packageJsonPath = path.join(pluginEntryDir, 'package.json');
87
+ const pluginMetaPath = path.join(pluginEntryDir, 'openclaw.plugin.json');
88
+ const indexPath = path.join(pluginEntryDir, 'index.js');
89
+
90
+ await fs.writeFile(packageJsonPath, buildSessionPluginPackageJson(), 'utf8');
91
+ await fs.writeFile(pluginMetaPath, buildSessionPluginMeta(), 'utf8');
92
+ await fs.writeFile(
93
+ indexPath,
94
+ buildSessionPluginIndex({
95
+ trackerDir,
96
+ packageName,
97
+ openclawHome: openclawHome || path.join(os.homedir(), '.openclaw')
98
+ }),
99
+ 'utf8'
100
+ );
101
+ }
102
+
103
+ async function probeOpenclawSessionPluginState({ home = os.homedir(), trackerDir, env = process.env } = {}) {
104
+ const paths = resolveOpenclawSessionPluginPaths({ home, trackerDir, env });
105
+ const { openclawConfigPath, pluginEntryDir, pluginId } = paths;
106
+
107
+ const pluginFilesReady =
108
+ fssync.existsSync(path.join(pluginEntryDir, 'package.json')) &&
109
+ fssync.existsSync(path.join(pluginEntryDir, 'index.js'));
110
+
111
+ let cfg = null;
112
+ try {
113
+ const raw = await fs.readFile(openclawConfigPath, 'utf8');
114
+ cfg = JSON.parse(raw);
115
+ } catch (err) {
116
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
117
+ return {
118
+ configured: false,
119
+ enabled: false,
120
+ linked: false,
121
+ installed: false,
122
+ pluginFilesReady,
123
+ skippedReason: 'openclaw-config-missing',
124
+ ...paths
125
+ };
126
+ }
127
+ return {
128
+ configured: false,
129
+ enabled: false,
130
+ linked: false,
131
+ installed: false,
132
+ pluginFilesReady,
133
+ skippedReason: 'openclaw-config-unreadable',
134
+ error: err?.message || String(err),
135
+ ...paths
136
+ };
137
+ }
138
+
139
+ const pluginEntry = cfg?.plugins?.entries?.[pluginId];
140
+ const enabled = pluginEntry ? pluginEntry.enabled !== false : false;
141
+
142
+ const loadPaths = Array.isArray(cfg?.plugins?.load?.paths) ? cfg.plugins.load.paths : [];
143
+ const normalizedPluginEntryDir = path.resolve(pluginEntryDir);
144
+ const linked = loadPaths.some((entry) => path.resolve(String(entry || '')) === normalizedPluginEntryDir);
145
+
146
+ const installs = cfg?.plugins?.installs && typeof cfg.plugins.installs === 'object' ? cfg.plugins.installs : {};
147
+ const installEntry = installs[pluginId];
148
+ const installed = Boolean(installEntry);
149
+
150
+ return {
151
+ configured: enabled && linked && pluginFilesReady,
152
+ enabled,
153
+ linked,
154
+ installed,
155
+ pluginFilesReady,
156
+ ...paths
157
+ };
158
+ }
159
+
160
+ async function removeOpenclawSessionPluginConfig({ home = os.homedir(), trackerDir, env = process.env } = {}) {
161
+ const paths = resolveOpenclawSessionPluginPaths({ home, trackerDir, env });
162
+ const { openclawConfigPath, pluginEntryDir, pluginId } = paths;
163
+
164
+ let cfg;
165
+ try {
166
+ cfg = JSON.parse(await fs.readFile(openclawConfigPath, 'utf8'));
167
+ } catch (err) {
168
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
169
+ return { removed: false, skippedReason: 'openclaw-config-missing', ...paths };
170
+ }
171
+ return {
172
+ removed: false,
173
+ skippedReason: 'openclaw-config-unreadable',
174
+ error: err?.message || String(err),
175
+ ...paths
176
+ };
177
+ }
178
+
179
+ let changed = false;
180
+ const plugins = cfg?.plugins;
181
+
182
+ if (plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, pluginId)) {
183
+ delete plugins.entries[pluginId];
184
+ changed = true;
185
+ if (Object.keys(plugins.entries).length === 0) delete plugins.entries;
186
+ }
187
+
188
+ if (plugins?.load && Array.isArray(plugins.load.paths)) {
189
+ const target = path.resolve(pluginEntryDir);
190
+ const after = plugins.load.paths.filter((entry) => path.resolve(String(entry || '')) !== target);
191
+ if (after.length !== plugins.load.paths.length) {
192
+ plugins.load.paths = after;
193
+ changed = true;
194
+ if (after.length === 0) delete plugins.load.paths;
195
+ if (Object.keys(plugins.load).length === 0) delete plugins.load;
196
+ }
197
+ }
198
+
199
+ if (plugins?.installs && typeof plugins.installs === 'object') {
200
+ const installs = plugins.installs;
201
+ if (Object.prototype.hasOwnProperty.call(installs, pluginId)) {
202
+ delete installs[pluginId];
203
+ changed = true;
204
+ }
205
+
206
+ const target = path.resolve(pluginEntryDir);
207
+ for (const [id, entry] of Object.entries(installs)) {
208
+ const sourcePath = normalizeString(entry?.sourcePath);
209
+ const installPath = normalizeString(entry?.installPath);
210
+ if (
211
+ (sourcePath && path.resolve(sourcePath) === target) ||
212
+ (installPath && path.resolve(installPath) === target)
213
+ ) {
214
+ delete installs[id];
215
+ changed = true;
216
+ }
217
+ }
218
+
219
+ if (Object.keys(installs).length === 0) delete plugins.installs;
220
+ }
221
+
222
+ if (plugins && Object.keys(plugins).length === 0) {
223
+ delete cfg.plugins;
224
+ changed = true;
225
+ }
226
+
227
+ if (changed) {
228
+ await fs.writeFile(openclawConfigPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
229
+ }
230
+
231
+ const hadFiles = await fs
232
+ .stat(pluginEntryDir)
233
+ .then((st) => st.isDirectory())
234
+ .catch(() => false);
235
+ await fs.rm(pluginEntryDir, { recursive: true, force: true }).catch(() => {});
236
+
237
+ return { removed: changed || hadFiles, ...paths };
238
+ }
239
+
240
+ function runOpenclawCli(args, env = process.env) {
241
+ let res;
242
+ try {
243
+ res = cp.spawnSync('openclaw', args, {
244
+ env,
245
+ encoding: 'utf8',
246
+ timeout: 30_000
247
+ });
248
+ } catch (err) {
249
+ return {
250
+ code: 1,
251
+ skippedReason: err?.code === 'ENOENT' ? 'openclaw-cli-missing' : 'openclaw-cli-error',
252
+ error: err?.message || String(err),
253
+ stdout: '',
254
+ stderr: ''
255
+ };
256
+ }
257
+
258
+ if (res.error?.code === 'ENOENT') {
259
+ return {
260
+ code: 1,
261
+ skippedReason: 'openclaw-cli-missing',
262
+ error: res.error.message,
263
+ stdout: res.stdout || '',
264
+ stderr: res.stderr || ''
265
+ };
266
+ }
267
+
268
+ if ((res.status || 0) !== 0) {
269
+ return {
270
+ code: Number(res.status || 1),
271
+ skippedReason: 'openclaw-plugins-install-failed',
272
+ error: (res.stderr || res.stdout || '').trim() || 'openclaw plugins install failed',
273
+ stdout: res.stdout || '',
274
+ stderr: res.stderr || ''
275
+ };
276
+ }
277
+
278
+ return {
279
+ code: 0,
280
+ stdout: res.stdout || '',
281
+ stderr: res.stderr || ''
282
+ };
283
+ }
284
+
285
+ function buildSessionPluginPackageJson() {
286
+ return `${JSON.stringify(
287
+ {
288
+ name: '@vibeusage/openclaw-session-sync',
289
+ version: '0.0.0',
290
+ private: true,
291
+ type: 'module',
292
+ openclaw: {
293
+ extensions: ['./index.js']
294
+ }
295
+ },
296
+ null,
297
+ 2
298
+ )}\n`;
299
+ }
300
+
301
+ function buildSessionPluginMeta() {
302
+ return `${JSON.stringify(
303
+ {
304
+ id: OPENCLAW_SESSION_PLUGIN_ID,
305
+ name: 'VibeUsage OpenClaw Session Sync',
306
+ description: 'Trigger vibeusage sync on OpenClaw agent/session lifecycle events.',
307
+ configSchema: {
308
+ type: 'object',
309
+ additionalProperties: false,
310
+ properties: {}
311
+ }
312
+ },
313
+ null,
314
+ 2
315
+ )}\n`;
316
+ }
317
+
318
+ function buildSessionPluginIndex({ trackerDir, packageName = 'vibeusage', openclawHome }) {
319
+ const trackerBinPath = path.join(trackerDir, 'app', 'bin', 'tracker.js');
320
+ const fallbackPkg = packageName || 'vibeusage';
321
+ const safeOpenclawHome = openclawHome || path.join(os.homedir(), '.openclaw');
322
+
323
+ return `import fs from 'node:fs';\n` +
324
+ `import path from 'node:path';\n` +
325
+ `import cp from 'node:child_process';\n` +
326
+ `\n` +
327
+ `const trackerDir = ${JSON.stringify(trackerDir)};\n` +
328
+ `const trackerBinPath = ${JSON.stringify(trackerBinPath)};\n` +
329
+ `const fallbackPkg = ${JSON.stringify(fallbackPkg)};\n` +
330
+ `const openclawHome = ${JSON.stringify(safeOpenclawHome)};\n` +
331
+ `const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');\n` +
332
+ `const triggerStatePath = path.join(trackerDir, 'openclaw.session-sync.trigger-state.json');\n` +
333
+ `const SESSION_TRIGGER_THROTTLE_MS = 15_000;\n` +
334
+ `\n` +
335
+ `export default function register(api) {\n` +
336
+ ` api.on('agent_end', async (_event, ctx) => {\n` +
337
+ ` try {\n` +
338
+ ` const sessionKey = normalize(ctx && ctx.sessionKey);\n` +
339
+ ` if (!sessionKey) return;\n` +
340
+ `\n` +
341
+ ` const agentId = normalize(ctx && ctx.agentId) || parseAgentId(sessionKey);\n` +
342
+ ` if (!agentId) return;\n` +
343
+ `\n` +
344
+ ` const sessionInfo = resolveSessionInfo(agentId, sessionKey);\n` +
345
+ ` const sessionId = normalize(sessionInfo && sessionInfo.sessionId);\n` +
346
+ ` if (!sessionId) return;\n` +
347
+ `\n` +
348
+ ` if (!allowTrigger('agent_end', agentId, sessionId)) return;\n` +
349
+ `\n` +
350
+ ` spawnSync({\n` +
351
+ ` args: ['sync', '--auto', '--from-openclaw'],\n` +
352
+ ` env: buildSessionEnv({\n` +
353
+ ` agentId,\n` +
354
+ ` sessionId,\n` +
355
+ ` sessionKey,\n` +
356
+ ` sessionEntry: sessionInfo && sessionInfo.entry\n` +
357
+ ` })\n` +
358
+ ` });\n` +
359
+ ` } catch (_) {}\n` +
360
+ ` });\n` +
361
+ `\n` +
362
+ ` api.on('gateway_start', async () => {\n` +
363
+ ` try {\n` +
364
+ ` if (!allowTrigger('gateway_start', 'gateway', 'startup')) return;\n` +
365
+ ` spawnSync({ args: ['sync', '--auto'] });\n` +
366
+ ` } catch (_) {}\n` +
367
+ ` });\n` +
368
+ `\n` +
369
+ ` api.on('gateway_stop', async () => {\n` +
370
+ ` try {\n` +
371
+ ` if (!allowTrigger('gateway_stop', 'gateway', 'stop')) return;\n` +
372
+ ` spawnSync({ args: ['sync', '--auto'] });\n` +
373
+ ` } catch (_) {}\n` +
374
+ ` });\n` +
375
+ `}\n` +
376
+ `\n` +
377
+ `function spawnSync({ args, env = {} }) {\n` +
378
+ ` const hasLocalRuntime = fs.existsSync(trackerBinPath);\n` +
379
+ ` const hasLocalDeps = fs.existsSync(depsMarkerPath);\n` +
380
+ ` const argv = Array.isArray(args) && args.length > 0 ? args : ['sync', '--auto'];\n` +
381
+ ` const cmd = hasLocalRuntime && hasLocalDeps\n` +
382
+ ` ? [process.execPath, trackerBinPath, ...argv]\n` +
383
+ ` : ['npx', '--yes', fallbackPkg, ...argv];\n` +
384
+ ` const child = cp.spawn(cmd[0], cmd.slice(1), {\n` +
385
+ ` detached: true,\n` +
386
+ ` stdio: 'ignore',\n` +
387
+ ` env: { ...process.env, ...env }\n` +
388
+ ` });\n` +
389
+ ` child.unref();\n` +
390
+ `}\n` +
391
+ `\n` +
392
+ `function buildSessionEnv({ agentId, sessionId, sessionKey, sessionEntry }) {\n` +
393
+ ` const out = {\n` +
394
+ ` VIBEUSAGE_OPENCLAW_AGENT_ID: agentId,\n` +
395
+ ` VIBEUSAGE_OPENCLAW_PREV_SESSION_ID: sessionId,\n` +
396
+ ` VIBEUSAGE_OPENCLAW_HOME: openclawHome\n` +
397
+ ` };\n` +
398
+ ` const key = normalize(sessionKey);\n` +
399
+ ` if (key) out.VIBEUSAGE_OPENCLAW_SESSION_KEY = key;\n` +
400
+ ` const prevTotalTokens = toNonNegativeInt(sessionEntry && sessionEntry.totalTokens);\n` +
401
+ ` const prevInputTokens = toNonNegativeInt(sessionEntry && sessionEntry.inputTokens);\n` +
402
+ ` const prevOutputTokens = toNonNegativeInt(sessionEntry && sessionEntry.outputTokens);\n` +
403
+ ` const prevModel = normalize(sessionEntry && sessionEntry.model);\n` +
404
+ ` const prevUpdatedAt = toIso(sessionEntry && sessionEntry.updatedAt);\n` +
405
+ ` if (prevTotalTokens != null) out.VIBEUSAGE_OPENCLAW_PREV_TOTAL_TOKENS = String(prevTotalTokens);\n` +
406
+ ` if (prevInputTokens != null) out.VIBEUSAGE_OPENCLAW_PREV_INPUT_TOKENS = String(prevInputTokens);\n` +
407
+ ` if (prevOutputTokens != null) out.VIBEUSAGE_OPENCLAW_PREV_OUTPUT_TOKENS = String(prevOutputTokens);\n` +
408
+ ` if (prevModel) out.VIBEUSAGE_OPENCLAW_PREV_MODEL = prevModel;\n` +
409
+ ` if (prevUpdatedAt) out.VIBEUSAGE_OPENCLAW_PREV_UPDATED_AT = prevUpdatedAt;\n` +
410
+ ` return out;\n` +
411
+ `}\n` +
412
+ `\n` +
413
+ `function resolveSessionInfo(agentId, sessionKey) {\n` +
414
+ ` const key = normalize(sessionKey);\n` +
415
+ ` if (!key) return null;\n` +
416
+ ` const sessionsPath = path.join(openclawHome, 'agents', agentId, 'sessions', 'sessions.json');\n` +
417
+ ` try {\n` +
418
+ ` const raw = fs.readFileSync(sessionsPath, 'utf8');\n` +
419
+ ` const parsed = JSON.parse(raw);\n` +
420
+ ` if (!parsed || typeof parsed !== 'object') return null;\n` +
421
+ ` const entry = parsed[key];\n` +
422
+ ` if (!entry || typeof entry !== 'object') return null;\n` +
423
+ ` return {\n` +
424
+ ` sessionKey: key,\n` +
425
+ ` sessionId: normalize(entry.sessionId),\n` +
426
+ ` entry\n` +
427
+ ` };\n` +
428
+ ` } catch (_) {}\n` +
429
+ ` return null;\n` +
430
+ `}\n` +
431
+ `\n` +
432
+ `function parseAgentId(sessionKey) {\n` +
433
+ ` const s = normalize(sessionKey);\n` +
434
+ ` if (!s || !s.startsWith('agent:')) return null;\n` +
435
+ ` const parts = s.split(':');\n` +
436
+ ` return parts.length >= 2 ? normalize(parts[1]) : null;\n` +
437
+ `}\n` +
438
+ `\n` +
439
+ `function allowTrigger(kind, scope, target) {\n` +
440
+ ` const key = [kind, scope || 'na', target || 'na'].join(':');\n` +
441
+ ` const now = Date.now();\n` +
442
+ ` let state = {};\n` +
443
+ ` try {\n` +
444
+ ` state = JSON.parse(fs.readFileSync(triggerStatePath, 'utf8'));\n` +
445
+ ` if (!state || typeof state !== 'object') state = {};\n` +
446
+ ` } catch (_) {}\n` +
447
+ ` const last = Number(state[key] || 0);\n` +
448
+ ` if (Number.isFinite(last) && now - last < SESSION_TRIGGER_THROTTLE_MS) return false;\n` +
449
+ ` state[key] = now;\n` +
450
+ ` try {\n` +
451
+ ` fs.mkdirSync(path.dirname(triggerStatePath), { recursive: true });\n` +
452
+ ` fs.writeFileSync(triggerStatePath, JSON.stringify(state), 'utf8');\n` +
453
+ ` } catch (_) {}\n` +
454
+ ` return true;\n` +
455
+ `}\n` +
456
+ `\n` +
457
+ `function normalize(v) {\n` +
458
+ ` if (typeof v !== 'string') return null;\n` +
459
+ ` const s = v.trim();\n` +
460
+ ` return s.length > 0 ? s : null;\n` +
461
+ `}\n` +
462
+ `\n` +
463
+ `function toNonNegativeInt(v) {\n` +
464
+ ` const n = Number(v);\n` +
465
+ ` if (!Number.isFinite(n) || n < 0) return null;\n` +
466
+ ` return Math.floor(n);\n` +
467
+ `}\n` +
468
+ `\n` +
469
+ `function toIso(v) {\n` +
470
+ ` if (typeof v === 'string') {\n` +
471
+ ` const s = normalize(v);\n` +
472
+ ` if (s && !Number.isNaN(Date.parse(s))) return s;\n` +
473
+ ` }\n` +
474
+ ` const n = Number(v);\n` +
475
+ ` if (!Number.isFinite(n) || n <= 0) return null;\n` +
476
+ ` const ms = n < 1e12 ? Math.floor(n * 1000) : Math.floor(n);\n` +
477
+ ` const d = new Date(ms);\n` +
478
+ ` return Number.isNaN(d.getTime()) ? null : d.toISOString();\n` +
479
+ `}\n`;
480
+ }
481
+
482
+ function normalizeString(value) {
483
+ if (typeof value !== 'string') return null;
484
+ const trimmed = value.trim();
485
+ return trimmed.length > 0 ? trimmed : null;
486
+ }
487
+
488
+ module.exports = {
489
+ OPENCLAW_SESSION_PLUGIN_ID,
490
+ OPENCLAW_SESSION_PLUGIN_DIRNAME,
491
+ resolveOpenclawSessionPluginPaths,
492
+ ensureOpenclawSessionPluginFiles,
493
+ installOpenclawSessionPlugin,
494
+ probeOpenclawSessionPluginState,
495
+ removeOpenclawSessionPluginConfig
496
+ };