1- import { appendFile } from "node:fs/promises" ;
1+ import type { Dirent } from "node:fs" ;
2+ import {
3+ appendFile ,
4+ mkdir ,
5+ readFile ,
6+ readdir ,
7+ utimes ,
8+ writeFile ,
9+ } from "node:fs/promises" ;
210import path from "node:path" ;
311import type { ControllerEnv } from "../app/env.js" ;
12+ import { logger } from "../lib/logger.js" ;
13+ import type { OpenClawProcessManager } from "./openclaw-process.js" ;
14+
15+ /**
16+ * Dotfile sentinel inside the skills directory used as the single nudge
17+ * point for OpenClaw's skills chokidar watcher. The dot prefix keeps it
18+ * out of every "list skills" code path, which all filter by
19+ * `isDirectory()` + presence of `SKILL.md`.
20+ */
21+ const SKILLS_NUDGE_MARKER_NAME = ".controller-nudge" ;
22+ const SESSIONS_INDEX_NAME = "sessions.json" ;
23+
24+ type SessionIndexRecord = Record < string , unknown > ;
425
526export class OpenClawWatchTrigger {
6- constructor ( private readonly env : ControllerEnv ) { }
27+ constructor (
28+ private readonly env : ControllerEnv ,
29+ private readonly openclawProcess : OpenClawProcessManager ,
30+ ) { }
731
832 async touchConfig ( ) : Promise < void > {
933 await this . touchFile ( this . env . openclawConfigPath ) ;
1034 }
1135
12- async touchSkill ( slug : string ) : Promise < void > {
13- await this . touchFile (
14- path . join ( this . env . openclawSkillsDir , slug , "SKILL.md" ) ,
36+ /**
37+ * Fire a synthetic chokidar `change` event in OpenClaw's skills watcher
38+ * so it bumps `snapshotVersion`. Live sessions drop their cached skills
39+ * snapshot and rebuild it on the next agent turn, picking up any new
40+ * agent allowlist or on-disk skill content.
41+ *
42+ * This is the single converged nudge primitive for the skills pipeline.
43+ * Every code path that mutates the agent skill allowlist or skill files
44+ * (config push, install, uninstall, edit, …) should funnel through here.
45+ *
46+ * @param reason short identifier of the caller for troubleshooting logs,
47+ * e.g. `"config-pushed"`, `"skill-installed"`, `"skill-uninstalled"`.
48+ */
49+ async nudgeSkillsWatcher ( reason : string ) : Promise < void > {
50+ const marker = path . join (
51+ this . env . openclawSkillsDir ,
52+ SKILLS_NUDGE_MARKER_NAME ,
1553 ) ;
54+ try {
55+ const invalidatedSessions = await this . invalidateSessionSkillSnapshots ( ) ;
56+ await mkdir ( this . env . openclawSkillsDir , { recursive : true } ) ;
57+ // Ensure the marker exists. `flag: "a"` creates on first run, no-op
58+ // afterwards. The dot prefix keeps the marker out of every "list
59+ // skills" reader, all of which filter by isDirectory() + SKILL.md.
60+ await writeFile ( marker , "" , { flag : "a" } ) ;
61+ // Explicit mtime bump. `writeFile(..., "")` writes zero bytes and
62+ // does not reliably update mtime on macOS APFS, which would
63+ // silently skip the chokidar `change` event we depend on.
64+ const now = new Date ( ) ;
65+ await utimes ( marker , now , now ) ;
66+ // Delegate the actual gateway restart to OpenClawProcessManager so the
67+ // dev-vs-launchd branching lives in exactly one place. `restart()`
68+ // throws on launchctl failure; we absorb it here so the nudge stays
69+ // best-effort (the snapshot invalidation + marker bump above are
70+ // already on disk and will be picked up on the next gateway boot).
71+ let gatewayRestarted = false ;
72+ try {
73+ await this . openclawProcess . restart ( reason ) ;
74+ gatewayRestarted = true ;
75+ } catch ( err ) {
76+ logger . warn (
77+ {
78+ reason,
79+ err : err instanceof Error ? err . message : String ( err ) ,
80+ } ,
81+ "openclaw gateway restart failed during skills watcher nudge" ,
82+ ) ;
83+ }
84+ logger . info (
85+ {
86+ reason,
87+ marker,
88+ mtime : now . toISOString ( ) ,
89+ invalidatedSessions,
90+ gatewayRestarted,
91+ } ,
92+ "openclaw skills watcher nudged" ,
93+ ) ;
94+ } catch ( error ) {
95+ logger . warn (
96+ {
97+ reason,
98+ marker,
99+ err : error instanceof Error ? error . message : String ( error ) ,
100+ } ,
101+ "openclaw skills watcher nudge failed" ,
102+ ) ;
103+ }
16104 }
17105
18106 private async touchFile ( filePath : string ) : Promise < void > {
@@ -22,4 +110,92 @@ export class OpenClawWatchTrigger {
22110 return ;
23111 }
24112 }
113+
114+ private async invalidateSessionSkillSnapshots ( ) : Promise < number > {
115+ const agentsDir = path . join ( this . env . openclawStateDir , "agents" ) ;
116+ let invalidatedSessions = 0 ;
117+
118+ let agentEntries : Dirent [ ] ;
119+ try {
120+ agentEntries = await readdir ( agentsDir , { withFileTypes : true } ) ;
121+ } catch {
122+ return 0 ;
123+ }
124+
125+ for ( const agentEntry of agentEntries ) {
126+ if ( ! agentEntry . isDirectory ( ) ) {
127+ continue ;
128+ }
129+
130+ const sessionsIndexPath = path . join (
131+ agentsDir ,
132+ agentEntry . name ,
133+ "sessions" ,
134+ SESSIONS_INDEX_NAME ,
135+ ) ;
136+ const currentIndex = await this . readSessionsIndex ( sessionsIndexPath ) ;
137+ if ( currentIndex == null ) {
138+ continue ;
139+ }
140+
141+ let changed = false ;
142+ const nextIndex = Object . fromEntries (
143+ Object . entries ( currentIndex ) . map ( ( [ sessionKey , sessionValue ] ) => {
144+ if ( ! this . hasSkillsSnapshot ( sessionValue ) ) {
145+ return [ sessionKey , sessionValue ] ;
146+ }
147+
148+ changed = true ;
149+ invalidatedSessions += 1 ;
150+ const { skillsSnapshot : _skillsSnapshot , ...rest } = sessionValue ;
151+ return [ sessionKey , rest ] ;
152+ } ) ,
153+ ) ;
154+
155+ if ( ! changed ) {
156+ continue ;
157+ }
158+
159+ await writeFile (
160+ sessionsIndexPath ,
161+ `${ JSON . stringify ( nextIndex , null , 2 ) } \n` ,
162+ "utf8" ,
163+ ) ;
164+ }
165+
166+ return invalidatedSessions ;
167+ }
168+
169+ private async readSessionsIndex (
170+ sessionsIndexPath : string ,
171+ ) : Promise < Record < string , SessionIndexRecord > | null > {
172+ try {
173+ const raw = await readFile ( sessionsIndexPath , "utf8" ) ;
174+ const parsed = JSON . parse ( raw ) as unknown ;
175+ if (
176+ typeof parsed !== "object" ||
177+ parsed == null ||
178+ Array . isArray ( parsed )
179+ ) {
180+ return null ;
181+ }
182+
183+ return Object . fromEntries (
184+ Object . entries ( parsed ) . filter (
185+ ( entry ) : entry is [ string , SessionIndexRecord ] =>
186+ typeof entry [ 1 ] === "object" &&
187+ entry [ 1 ] != null &&
188+ ! Array . isArray ( entry [ 1 ] ) ,
189+ ) ,
190+ ) ;
191+ } catch {
192+ return null ;
193+ }
194+ }
195+
196+ private hasSkillsSnapshot (
197+ value : SessionIndexRecord ,
198+ ) : value is SessionIndexRecord & { skillsSnapshot : unknown } {
199+ return "skillsSnapshot" in value ;
200+ }
25201}
0 commit comments