11import { execFileSync } from "node:child_process" ;
22import { homedir } from "node:os" ;
33import { join } from "node:path" ;
4- import type { OpenClawPluginApi } from "openclaw/plugin-sdk" ;
4+ import type { OpenClawPluginApi , PluginHookAgentEndEvent , PluginHookAgentContext , PluginHookSubagentEndedEvent , PluginHookSubagentContext , PluginHookSessionStartEvent , PluginHookSessionEndEvent , PluginHookSessionContext , PluginHookAfterCompactionEvent , PluginHookBeforeResetEvent } from "openclaw/plugin-sdk" ;
55import { registerLinearProvider } from "./src/api/auth.js" ;
66import { registerCli } from "./src/infra/cli.js" ;
77import { createLinearTools } from "./src/tools/tools.js" ;
@@ -20,7 +20,6 @@ import { createDispatchHistoryTool } from "./src/tools/dispatch-history-tool.js"
2020import { readDispatchState as readStateForHook , listActiveDispatches as listActiveForHook } from "./src/pipeline/dispatch-state.js" ;
2121import { startTokenRefreshTimer , stopTokenRefreshTimer } from "./src/infra/token-refresh-timer.js" ;
2222
23- const COMPLETION_HOOK_NAMES = [ "agent_end" , "task_completed" , "task_completion" ] as const ;
2423const SUCCESS_STATUSES = new Set ( [ "ok" , "success" , "completed" , "complete" , "done" , "pass" , "passed" ] ) ;
2524const FAILURE_STATUSES = new Set ( [ "error" , "failed" , "failure" , "timeout" , "timed_out" , "cancelled" , "canceled" , "aborted" , "unknown" ] ) ;
2625
@@ -66,7 +65,7 @@ function extractCompletionOutput(event: any): string {
6665}
6766
6867export default function register ( api : OpenClawPluginApi ) {
69- const pluginConfig = ( api as any ) . pluginConfig as Record < string , unknown > | undefined ;
68+ const pluginConfig = api . pluginConfig ;
7069
7170 // Check token availability (config → env → auth profile store)
7271 const tokenInfo = resolveLinearToken ( pluginConfig ) ;
@@ -152,110 +151,196 @@ export default function register(api: OpenClawPluginApi) {
152151 // Instantiate notifier (Discord, Slack, or both — config-driven)
153152 const notify : NotifyFn = createNotifierFromConfig ( pluginConfig , api . runtime , api ) ;
154153
155- // Register completion hooks — safety net for sessions_spawn sub-agents.
156- // In the current implementation, the worker->audit->verdict flow runs inline
157- // via spawnWorker() in pipeline.ts. These hooks catch sessions_spawn agents
158- // (future upgrade path) and serve as a recovery mechanism.
159- const onAnyHook = api . on as unknown as ( hookName : string , handler : ( event : any , ctx : any ) => Promise < void > | void ) => void ;
154+ // ---------------------------------------------------------------------------
155+ // Typed dispatch completion handler (shared by agent_end + subagent_ended)
156+ // ---------------------------------------------------------------------------
157+ const handleDispatchCompletion = async (
158+ sessionKey : string ,
159+ success : boolean ,
160+ output : string ,
161+ hookName : string ,
162+ ) => {
163+ const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
164+ const state = await readDispatchState ( statePath ) ;
165+ const mapping = lookupSessionMapping ( state , sessionKey ) ;
166+ if ( ! mapping ) return ; // Not a dispatch sub-agent
167+
168+ const dispatch = getActiveDispatch ( state , mapping . dispatchId ) ;
169+ if ( ! dispatch ) {
170+ api . logger . info ( `${ hookName } : dispatch ${ mapping . dispatchId } no longer active` ) ;
171+ return ;
172+ }
173+
174+ // Stale event rejection — only process if attempt matches
175+ if ( dispatch . attempt !== mapping . attempt ) {
176+ api . logger . info (
177+ `${ hookName } : stale event for ${ mapping . dispatchId } ` +
178+ `(event attempt=${ mapping . attempt } , current=${ dispatch . attempt } )`
179+ ) ;
180+ return ;
181+ }
182+
183+ // Create Linear API for hook context
184+ const tokenInfo = resolveLinearToken ( pluginConfig ) ;
185+ if ( ! tokenInfo . accessToken ) {
186+ api . logger . error ( `${ hookName } : no Linear access token — cannot process dispatch event` ) ;
187+ return ;
188+ }
189+ const linearApi = new LinearAgentApi ( tokenInfo . accessToken , {
190+ refreshToken : tokenInfo . refreshToken ,
191+ expiresAt : tokenInfo . expiresAt ,
192+ } ) ;
160193
161- const handleCompletionEvent = async ( event : any , ctx : any , hookName : string ) => {
194+ const hookCtx : HookContext = {
195+ api,
196+ linearApi,
197+ notify,
198+ pluginConfig,
199+ configPath : statePath ,
200+ } ;
201+
202+ if ( mapping . phase === "worker" ) {
203+ api . logger . info ( `${ hookName } : worker completed for ${ mapping . dispatchId } - triggering audit` ) ;
204+ await triggerAudit ( hookCtx , dispatch , { success, output } , sessionKey ) ;
205+ } else if ( mapping . phase === "audit" ) {
206+ api . logger . info ( `${ hookName } : audit completed for ${ mapping . dispatchId } - processing verdict` ) ;
207+ await processVerdict ( hookCtx , dispatch , { success, output } , sessionKey ) ;
208+ }
209+ } ;
210+
211+ const escalateDispatchError = async ( sessionKey : string , err : unknown , hookName : string ) => {
162212 try {
163- const sessionKey = ctx ?. sessionKey ?? "" ;
164- if ( ! sessionKey ) return ;
213+ const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
214+ const state = await readDispatchState ( statePath ) ;
215+ const mapping = sessionKey ? lookupSessionMapping ( state , sessionKey ) : null ;
216+ if ( mapping ) {
217+ const dispatch = getActiveDispatch ( state , mapping . dispatchId ) ;
218+ if ( dispatch && dispatch . status !== "done" && dispatch . status !== "stuck" && dispatch . status !== "failed" ) {
219+ const stuckReason = `Hook error: ${ err instanceof Error ? err . message : String ( err ) } ` . slice ( 0 , 500 ) ;
220+ await transitionDispatch (
221+ mapping . dispatchId ,
222+ dispatch . status as DispatchStatus ,
223+ "stuck" ,
224+ { stuckReason } ,
225+ statePath ,
226+ ) ;
227+ await notify ( "escalation" , {
228+ identifier : dispatch . issueIdentifier ,
229+ title : dispatch . issueTitle ?? "Unknown" ,
230+ status : "stuck" ,
231+ reason : `Dispatch failed in ${ mapping . phase } phase: ${ stuckReason } ` ,
232+ } ) . catch ( ( ) => { } ) ;
233+ }
234+ }
235+ } catch ( escalateErr ) {
236+ api . logger . error ( `${ hookName } escalation also failed: ${ escalateErr } ` ) ;
237+ }
238+ } ;
239+
240+ // agent_end — fires when an agent run completes (primary dispatch handler)
241+ api . on ( "agent_end" , async ( event : PluginHookAgentEndEvent , ctx : PluginHookAgentContext ) => {
242+ const sessionKey = ctx ?. sessionKey ?? "" ;
243+ if ( ! sessionKey ) return ;
244+ try {
245+ const output = extractCompletionOutput ( event ) ;
246+ const success = parseCompletionSuccess ( event ) ;
247+ await handleDispatchCompletion ( sessionKey , success , output , "agent_end" ) ;
248+ } catch ( err ) {
249+ api . logger . error ( `agent_end hook error: ${ err } ` ) ;
250+ await escalateDispatchError ( sessionKey , err , "agent_end" ) ;
251+ }
252+ } ) ;
165253
254+ // subagent_ended — fires when a subagent session ends (proper lifecycle hook, new in 3.7)
255+ // This catches sessions_spawn sub-agents with structured outcome data.
256+ api . on ( "subagent_ended" , async ( event : PluginHookSubagentEndedEvent , ctx : PluginHookSubagentContext ) => {
257+ const sessionKey = event . targetSessionKey ?? ctx ?. childSessionKey ?? "" ;
258+ if ( ! sessionKey ) return ;
259+ try {
260+ const success = event . outcome === "ok" ;
261+ const output = event . error ?? event . reason ?? "" ;
262+ await handleDispatchCompletion ( sessionKey , success , output , "subagent_ended" ) ;
263+ } catch ( err ) {
264+ api . logger . error ( `subagent_ended hook error: ${ err } ` ) ;
265+ await escalateDispatchError ( sessionKey , err , "subagent_ended" ) ;
266+ }
267+ } ) ;
268+
269+ // session_start — track dispatch session lifecycle
270+ api . on ( "session_start" , async ( event : PluginHookSessionStartEvent , ctx : PluginHookSessionContext ) => {
271+ const sessionKey = ctx ?. sessionKey ?? event ?. sessionKey ?? "" ;
272+ if ( ! sessionKey ) return ;
273+ try {
166274 const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
167275 const state = await readDispatchState ( statePath ) ;
168276 const mapping = lookupSessionMapping ( state , sessionKey ) ;
169- if ( ! mapping ) return ; // Not a dispatch sub-agent
170-
171- const dispatch = getActiveDispatch ( state , mapping . dispatchId ) ;
172- if ( ! dispatch ) {
173- api . logger . info ( `${ hookName } : dispatch ${ mapping . dispatchId } no longer active` ) ;
174- return ;
277+ if ( mapping ) {
278+ api . logger . info ( `session_start: dispatch ${ mapping . dispatchId } phase=${ mapping . phase } session started` ) ;
175279 }
280+ } catch {
281+ // Never block session start for telemetry
282+ }
283+ } ) ;
176284
177- // Stale event rejection — only process if attempt matches
178- if ( dispatch . attempt !== mapping . attempt ) {
285+ // session_end — log dispatch session duration for observability
286+ api . on ( "session_end" , async ( event : PluginHookSessionEndEvent , ctx : PluginHookSessionContext ) => {
287+ const sessionKey = ctx ?. sessionKey ?? event ?. sessionKey ?? "" ;
288+ if ( ! sessionKey ) return ;
289+ try {
290+ const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
291+ const state = await readDispatchState ( statePath ) ;
292+ const mapping = lookupSessionMapping ( state , sessionKey ) ;
293+ if ( mapping ) {
294+ const durationSec = event . durationMs ? Math . round ( event . durationMs / 1000 ) : "?" ;
179295 api . logger . info (
180- `${ hookName } : stale event for ${ mapping . dispatchId } ` +
181- `(event attempt =${ mapping . attempt } , current =${ dispatch . attempt } ) `
296+ `session_end: dispatch ${ mapping . dispatchId } phase= ${ mapping . phase } ` +
297+ `messages =${ event . messageCount } duration =${ durationSec } s `
182298 ) ;
183- return ;
184299 }
300+ } catch {
301+ // Never block session end for telemetry
302+ }
303+ } ) ;
185304
186- // Create Linear API for hook context
187- const tokenInfo = resolveLinearToken ( pluginConfig ) ;
188- if ( ! tokenInfo . accessToken ) {
189- api . logger . error ( `${ hookName } : no Linear access token — cannot process dispatch event` ) ;
190- return ;
305+ // after_compaction — log when dispatch sessions compact (visibility into context pressure)
306+ api . on ( "after_compaction" , async ( event : PluginHookAfterCompactionEvent , ctx : PluginHookAgentContext ) => {
307+ const sessionKey = ctx ?. sessionKey ?? "" ;
308+ if ( ! sessionKey ) return ;
309+ try {
310+ const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
311+ const state = await readDispatchState ( statePath ) ;
312+ const mapping = lookupSessionMapping ( state , sessionKey ) ;
313+ if ( mapping ) {
314+ api . logger . warn (
315+ `after_compaction: dispatch ${ mapping . dispatchId } phase=${ mapping . phase } ` +
316+ `compacted ${ event . compactedCount } messages (${ event . messageCount } remaining)`
317+ ) ;
191318 }
192- const linearApi = new LinearAgentApi ( tokenInfo . accessToken , {
193- refreshToken : tokenInfo . refreshToken ,
194- expiresAt : tokenInfo . expiresAt ,
195- } ) ;
196-
197- const hookCtx : HookContext = {
198- api,
199- linearApi,
200- notify,
201- pluginConfig,
202- configPath : statePath ,
203- } ;
204-
205- const output = extractCompletionOutput ( event ) ;
206- const success = parseCompletionSuccess ( event ) ;
319+ } catch {
320+ // Never block compaction pipeline
321+ }
322+ } ) ;
207323
208- if ( mapping . phase === "worker" ) {
209- api . logger . info ( `${ hookName } : worker completed for ${ mapping . dispatchId } - triggering audit` ) ;
210- await triggerAudit ( hookCtx , dispatch , {
211- success,
212- output,
213- } , sessionKey ) ;
214- } else if ( mapping . phase === "audit" ) {
215- api . logger . info ( `${ hookName } : audit completed for ${ mapping . dispatchId } - processing verdict` ) ;
216- await processVerdict ( hookCtx , dispatch , {
217- success,
218- output,
219- } , sessionKey ) ;
220- }
221- } catch ( err ) {
222- api . logger . error ( `${ hookName } hook error: ${ err } ` ) ;
223- // Escalate: mark dispatch as stuck so it's visible
224- try {
225- const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
226- const state = await readDispatchState ( statePath ) ;
227- const sessionKey = ctx ?. sessionKey ?? "" ;
228- const mapping = sessionKey ? lookupSessionMapping ( state , sessionKey ) : null ;
229- if ( mapping ) {
230- const dispatch = getActiveDispatch ( state , mapping . dispatchId ) ;
231- if ( dispatch && dispatch . status !== "done" && dispatch . status !== "stuck" && dispatch . status !== "failed" ) {
232- const stuckReason = `Hook error: ${ err instanceof Error ? err . message : String ( err ) } ` . slice ( 0 , 500 ) ;
233- await transitionDispatch (
234- mapping . dispatchId ,
235- dispatch . status as DispatchStatus ,
236- "stuck" ,
237- { stuckReason } ,
238- statePath ,
239- ) ;
240- // Notify if possible
241- await notify ( "escalation" , {
242- identifier : dispatch . issueIdentifier ,
243- title : dispatch . issueTitle ?? "Unknown" ,
244- status : "stuck" ,
245- reason : `Dispatch failed in ${ mapping . phase } phase: ${ stuckReason } ` ,
246- } ) . catch ( ( ) => { } ) ; // Don't fail on notification failure
247- }
248- }
249- } catch ( escalateErr ) {
250- api . logger . error ( `${ hookName } escalation also failed: ${ escalateErr } ` ) ;
324+ // before_reset — clean up dispatch tracking when a session is reset
325+ api . on ( "before_reset" , async ( event : PluginHookBeforeResetEvent , ctx : PluginHookAgentContext ) => {
326+ const sessionKey = ctx ?. sessionKey ?? "" ;
327+ if ( ! sessionKey ) return ;
328+ try {
329+ const statePath = pluginConfig ?. dispatchStatePath as string | undefined ;
330+ const state = await readDispatchState ( statePath ) ;
331+ const mapping = lookupSessionMapping ( state , sessionKey ) ;
332+ if ( mapping ) {
333+ api . logger . warn (
334+ `before_reset: dispatch ${ mapping . dispatchId } phase=${ mapping . phase } session reset ` +
335+ `(reason: ${ event . reason ?? "unknown" } )`
336+ ) ;
251337 }
338+ } catch {
339+ // Never block reset
252340 }
253- } ;
341+ } ) ;
254342
255- for ( const hookName of COMPLETION_HOOK_NAMES ) {
256- onAnyHook ( hookName , ( event : any , ctx : any ) => handleCompletionEvent ( event , ctx , hookName ) ) ;
257- }
258- api . logger . info ( `Dispatch completion hooks registered: ${ COMPLETION_HOOK_NAMES . join ( ", " ) } ` ) ;
343+ api . logger . info ( "Dispatch lifecycle hooks registered: agent_end, subagent_ended, session_start, session_end, after_compaction, before_reset" ) ;
259344
260345 // Inject recent dispatch history as context for worker/audit agents
261346 api . on ( "before_agent_start" , async ( event : any , ctx : any ) => {
@@ -342,11 +427,11 @@ export default function register(api: OpenClawPluginApi) {
342427 ] ;
343428 const MAX_SHORT_RESPONSE = 250 ;
344429
345- api . on ( "message_sending" , ( event : { content ?: string } ) => {
430+ api . on ( "message_sending" , ( event ) => {
346431 const text = event ?. content ?? "" ;
347- if ( ! text || text . length > MAX_SHORT_RESPONSE ) return { } ;
432+ if ( ! text || text . length > MAX_SHORT_RESPONSE ) return ;
348433 const isNarration = NARRATION_PATTERNS . some ( ( p ) => p . test ( text ) ) ;
349- if ( ! isNarration ) return { } ;
434+ if ( ! isNarration ) return ;
350435 api . logger . warn ( `Narration guard triggered: "${ text . slice ( 0 , 80 ) } ..."` ) ;
351436 return {
352437 content :
0 commit comments