Skip to content

Commit 16855b3

Browse files
Upgrade to OpenClaw 2026.3.7 typed lifecycle hooks
Replace the type-unsafe `onAnyHook` cast and non-existent hook names (`task_completed`, `task_completion`) with properly typed hooks from the 2026.3.7 plugin SDK: - `agent_end` (typed): primary dispatch completion handler - `subagent_ended` (new): proper subagent lifecycle with structured outcome/error data — catches sessions_spawn sub-agents - `session_start`/`session_end` (new): dispatch session tracking with duration and message count for observability - `after_compaction` (new): logs when dispatch sessions hit context pressure and compact - `before_reset` (new): logs when dispatch sessions are reset Also: - Use `api.pluginConfig` directly (typed in 3.7) instead of `(api as any).pluginConfig` cast - Fix `message_sending` hook to return void instead of empty object - Update devDependency to openclaw ^2026.3.7 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c5dfd7e commit 16855b3

2 files changed

Lines changed: 179 additions & 94 deletions

File tree

index.ts

Lines changed: 178 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFileSync } from "node:child_process";
22
import { homedir } from "node:os";
33
import { 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";
55
import { registerLinearProvider } from "./src/api/auth.js";
66
import { registerCli } from "./src/infra/cli.js";
77
import { createLinearTools } from "./src/tools/tools.js";
@@ -20,7 +20,6 @@ import { createDispatchHistoryTool } from "./src/tools/dispatch-history-tool.js"
2020
import { readDispatchState as readStateForHook, listActiveDispatches as listActiveForHook } from "./src/pipeline/dispatch-state.js";
2121
import { startTokenRefreshTimer, stopTokenRefreshTimer } from "./src/infra/token-refresh-timer.js";
2222

23-
const COMPLETION_HOOK_NAMES = ["agent_end", "task_completed", "task_completion"] as const;
2423
const SUCCESS_STATUSES = new Set(["ok", "success", "completed", "complete", "done", "pass", "passed"]);
2524
const 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

6867
export 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:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
},
3636
"devDependencies": {
3737
"@vitest/coverage-v8": "^4.0.18",
38-
"openclaw": "^2026.2.13",
38+
"openclaw": "^2026.3.7",
3939
"typescript": "^5.9.3",
4040
"vitest": "^4.0.18"
4141
},

0 commit comments

Comments
 (0)