@calltelemetry/openclaw-linear 0.9.20 → 0.9.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/README.md +12 -2
- package/index.ts +70 -23
- package/package.json +1 -1
- package/src/infra/tmux-runner.test.ts +32 -0
- package/src/infra/tmux-runner.ts +28 -2
package/README.md
CHANGED
|
@@ -134,6 +134,14 @@ The end result: you work in Linear. You create issues, assign them, comment in p
|
|
|
134
134
|
|
|
135
135
|
## Quick Start
|
|
136
136
|
|
|
137
|
+
> **Tip:** Claude Code is very good at setting this up for you. Install the plugin, install the [Cloudflare CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/#1-download-and-install-cloudflared) and authenticate (`cloudflared tunnel login`), then just ask Claude to configure the rest. Or run the guided setup wizard:
|
|
138
|
+
>
|
|
139
|
+
> ```bash
|
|
140
|
+
> openclaw openclaw-linear setup
|
|
141
|
+
> ```
|
|
142
|
+
>
|
|
143
|
+
> It walks through agent profiles, auth, webhook provisioning, and verification in one interactive flow.
|
|
144
|
+
|
|
137
145
|
### 1. Install the plugin
|
|
138
146
|
|
|
139
147
|
```bash
|
|
@@ -1451,9 +1459,11 @@ The `request_work` intent is the only one gated by issue state. When the issue i
|
|
|
1451
1459
|
|
|
1452
1460
|
### Hook Lifecycle
|
|
1453
1461
|
|
|
1454
|
-
The plugin registers
|
|
1462
|
+
The plugin registers completion + lifecycle hooks via `api.on()` in `index.ts`.
|
|
1463
|
+
For completion events it listens to `agent_end`, `task_completed`, and `task_completion`
|
|
1464
|
+
to stay compatible across OpenClaw lifecycle event changes.
|
|
1455
1465
|
|
|
1456
|
-
|
|
1466
|
+
**Completion hooks (`agent_end` / `task_completed` / `task_completion`)** — Dispatch pipeline state machine. When a sub-agent (worker or auditor) finishes:
|
|
1457
1467
|
- Looks up the session key in dispatch state to find the active dispatch
|
|
1458
1468
|
- Validates the attempt number matches (rejects stale events from old retries)
|
|
1459
1469
|
- If the worker finished → triggers the audit phase (`triggerAudit`)
|
package/index.ts
CHANGED
|
@@ -20,6 +20,51 @@ import { createDispatchHistoryTool } from "./src/tools/dispatch-history-tool.js"
|
|
|
20
20
|
import { readDispatchState as readStateForHook, listActiveDispatches as listActiveForHook } from "./src/pipeline/dispatch-state.js";
|
|
21
21
|
import { startTokenRefreshTimer, stopTokenRefreshTimer } from "./src/infra/token-refresh-timer.js";
|
|
22
22
|
|
|
23
|
+
const COMPLETION_HOOK_NAMES = ["agent_end", "task_completed", "task_completion"] as const;
|
|
24
|
+
const SUCCESS_STATUSES = new Set(["ok", "success", "completed", "complete", "done", "pass", "passed"]);
|
|
25
|
+
const FAILURE_STATUSES = new Set(["error", "failed", "failure", "timeout", "timed_out", "cancelled", "canceled", "aborted", "unknown"]);
|
|
26
|
+
|
|
27
|
+
function parseCompletionSuccess(event: any): boolean {
|
|
28
|
+
if (typeof event?.success === "boolean") {
|
|
29
|
+
return event.success;
|
|
30
|
+
}
|
|
31
|
+
const status = typeof event?.status === "string" ? event.status.trim().toLowerCase() : "";
|
|
32
|
+
if (status) {
|
|
33
|
+
if (SUCCESS_STATUSES.has(status)) return true;
|
|
34
|
+
if (FAILURE_STATUSES.has(status)) return false;
|
|
35
|
+
}
|
|
36
|
+
if (typeof event?.error === "string" && event.error.trim().length > 0) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractCompletionOutput(event: any): string {
|
|
43
|
+
if (typeof event?.output === "string" && event.output.trim().length > 0) {
|
|
44
|
+
return event.output;
|
|
45
|
+
}
|
|
46
|
+
if (typeof event?.result === "string" && event.result.trim().length > 0) {
|
|
47
|
+
return event.result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const assistantBlocks = (event?.messages ?? [])
|
|
51
|
+
.filter((m: any) => m?.role === "assistant")
|
|
52
|
+
.flatMap((m: any) => {
|
|
53
|
+
if (typeof m?.content === "string") {
|
|
54
|
+
return [m.content];
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(m?.content)) {
|
|
57
|
+
return m.content
|
|
58
|
+
.filter((b: any) => b?.type === "text" && typeof b?.text === "string")
|
|
59
|
+
.map((b: any) => b.text);
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
})
|
|
63
|
+
.filter((value: string) => value.trim().length > 0);
|
|
64
|
+
|
|
65
|
+
return assistantBlocks.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
23
68
|
export default function register(api: OpenClawPluginApi) {
|
|
24
69
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
25
70
|
|
|
@@ -95,17 +140,19 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
95
140
|
}).catch((err) => api.logger.warn(`Planning state hydration failed: ${err}`));
|
|
96
141
|
|
|
97
142
|
// ---------------------------------------------------------------------------
|
|
98
|
-
// Dispatch pipeline v2: notifier +
|
|
143
|
+
// Dispatch pipeline v2: notifier + completion lifecycle hooks
|
|
99
144
|
// ---------------------------------------------------------------------------
|
|
100
145
|
|
|
101
146
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
102
147
|
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
103
148
|
|
|
104
|
-
// Register
|
|
105
|
-
// In the current implementation, the worker
|
|
106
|
-
// via spawnWorker() in pipeline.ts.
|
|
107
|
-
// (future upgrade path) and
|
|
108
|
-
api.on(
|
|
149
|
+
// Register completion hooks — safety net for sessions_spawn sub-agents.
|
|
150
|
+
// In the current implementation, the worker->audit->verdict flow runs inline
|
|
151
|
+
// via spawnWorker() in pipeline.ts. These hooks catch sessions_spawn agents
|
|
152
|
+
// (future upgrade path) and serve as a recovery mechanism.
|
|
153
|
+
const onAnyHook = api.on as unknown as (hookName: string, handler: (event: any, ctx: any) => Promise<void> | void) => void;
|
|
154
|
+
|
|
155
|
+
const handleCompletionEvent = async (event: any, ctx: any, hookName: string) => {
|
|
109
156
|
try {
|
|
110
157
|
const sessionKey = ctx?.sessionKey ?? "";
|
|
111
158
|
if (!sessionKey) return;
|
|
@@ -117,14 +164,14 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
117
164
|
|
|
118
165
|
const dispatch = getActiveDispatch(state, mapping.dispatchId);
|
|
119
166
|
if (!dispatch) {
|
|
120
|
-
api.logger.info(
|
|
167
|
+
api.logger.info(`${hookName}: dispatch ${mapping.dispatchId} no longer active`);
|
|
121
168
|
return;
|
|
122
169
|
}
|
|
123
170
|
|
|
124
171
|
// Stale event rejection — only process if attempt matches
|
|
125
172
|
if (dispatch.attempt !== mapping.attempt) {
|
|
126
173
|
api.logger.info(
|
|
127
|
-
|
|
174
|
+
`${hookName}: stale event for ${mapping.dispatchId} ` +
|
|
128
175
|
`(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
|
|
129
176
|
);
|
|
130
177
|
return;
|
|
@@ -133,7 +180,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
133
180
|
// Create Linear API for hook context
|
|
134
181
|
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
135
182
|
if (!tokenInfo.accessToken) {
|
|
136
|
-
api.logger.error(
|
|
183
|
+
api.logger.error(`${hookName}: no Linear access token — cannot process dispatch event`);
|
|
137
184
|
return;
|
|
138
185
|
}
|
|
139
186
|
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
@@ -149,29 +196,24 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
149
196
|
configPath: statePath,
|
|
150
197
|
};
|
|
151
198
|
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
? event.output
|
|
155
|
-
: (event?.messages ?? [])
|
|
156
|
-
.filter((m: any) => m?.role === "assistant")
|
|
157
|
-
.map((m: any) => typeof m?.content === "string" ? m.content : "")
|
|
158
|
-
.join("\n") || "";
|
|
199
|
+
const output = extractCompletionOutput(event);
|
|
200
|
+
const success = parseCompletionSuccess(event);
|
|
159
201
|
|
|
160
202
|
if (mapping.phase === "worker") {
|
|
161
|
-
api.logger.info(
|
|
203
|
+
api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
|
|
162
204
|
await triggerAudit(hookCtx, dispatch, {
|
|
163
|
-
success
|
|
205
|
+
success,
|
|
164
206
|
output,
|
|
165
207
|
}, sessionKey);
|
|
166
208
|
} else if (mapping.phase === "audit") {
|
|
167
|
-
api.logger.info(
|
|
209
|
+
api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
|
|
168
210
|
await processVerdict(hookCtx, dispatch, {
|
|
169
|
-
success
|
|
211
|
+
success,
|
|
170
212
|
output,
|
|
171
213
|
}, sessionKey);
|
|
172
214
|
}
|
|
173
215
|
} catch (err) {
|
|
174
|
-
api.logger.error(
|
|
216
|
+
api.logger.error(`${hookName} hook error: ${err}`);
|
|
175
217
|
// Escalate: mark dispatch as stuck so it's visible
|
|
176
218
|
try {
|
|
177
219
|
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
@@ -199,10 +241,15 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
199
241
|
}
|
|
200
242
|
}
|
|
201
243
|
} catch (escalateErr) {
|
|
202
|
-
api.logger.error(
|
|
244
|
+
api.logger.error(`${hookName} escalation also failed: ${escalateErr}`);
|
|
203
245
|
}
|
|
204
246
|
}
|
|
205
|
-
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
for (const hookName of COMPLETION_HOOK_NAMES) {
|
|
250
|
+
onAnyHook(hookName, (event: any, ctx: any) => handleCompletionEvent(event, ctx, hookName));
|
|
251
|
+
}
|
|
252
|
+
api.logger.info(`Dispatch completion hooks registered: ${COMPLETION_HOOK_NAMES.join(", ")}`);
|
|
206
253
|
|
|
207
254
|
// Inject recent dispatch history as context for worker/audit agents
|
|
208
255
|
api.on("before_agent_start", async (event: any, ctx: any) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isCompletionEvent } from "./tmux-runner.js";
|
|
3
|
+
|
|
4
|
+
describe("isCompletionEvent", () => {
|
|
5
|
+
it("detects Claude result events", () => {
|
|
6
|
+
expect(isCompletionEvent({ type: "result" })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("detects Codex session completion events", () => {
|
|
10
|
+
expect(isCompletionEvent({ type: "session.completed" })).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("detects task completion lifecycle variants", () => {
|
|
14
|
+
expect(isCompletionEvent({ type: "task_completed" })).toBe(true);
|
|
15
|
+
expect(isCompletionEvent({ type: "task.completed" })).toBe(true);
|
|
16
|
+
expect(isCompletionEvent({ type: "task_completion" })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("detects completion when event type is under item.type", () => {
|
|
20
|
+
expect(isCompletionEvent({ item: { type: "task_completed" } })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects completion from session.completed boolean", () => {
|
|
24
|
+
expect(isCompletionEvent({ session: { completed: true } })).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not treat non-completion events as complete", () => {
|
|
28
|
+
expect(isCompletionEvent({ type: "assistant" })).toBe(false);
|
|
29
|
+
expect(isCompletionEvent({ type: "message" })).toBe(false);
|
|
30
|
+
expect(isCompletionEvent({ item: { type: "agent_message" } })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/infra/tmux-runner.ts
CHANGED
|
@@ -8,6 +8,14 @@ import { formatActivityLogLine, createProgressEmitter } from "../tools/cli-share
|
|
|
8
8
|
import { InactivityWatchdog } from "../agent/watchdog.js";
|
|
9
9
|
import { shellEscape } from "./tmux.js";
|
|
10
10
|
|
|
11
|
+
const COMPLETION_EVENT_TYPES = new Set([
|
|
12
|
+
"result",
|
|
13
|
+
"session.completed",
|
|
14
|
+
"task_completed",
|
|
15
|
+
"task.completed",
|
|
16
|
+
"task_completion",
|
|
17
|
+
]);
|
|
18
|
+
|
|
11
19
|
export interface TmuxSession {
|
|
12
20
|
sessionName: string;
|
|
13
21
|
backend: string;
|
|
@@ -37,6 +45,24 @@ export interface RunInTmuxOptions {
|
|
|
37
45
|
// Track active tmux sessions by issueId
|
|
38
46
|
const activeSessions = new Map<string, TmuxSession>();
|
|
39
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Completion detector for streamed CLI JSONL events.
|
|
50
|
+
* Supports Claude and Codex event variants across releases.
|
|
51
|
+
*/
|
|
52
|
+
export function isCompletionEvent(event: any): boolean {
|
|
53
|
+
const type = typeof event?.type === "string" ? event.type.trim().toLowerCase() : "";
|
|
54
|
+
if (type && COMPLETION_EVENT_TYPES.has(type)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const itemType = typeof event?.item?.type === "string" ? event.item.type.trim().toLowerCase() : "";
|
|
59
|
+
if (itemType && COMPLETION_EVENT_TYPES.has(itemType)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return event?.session?.completed === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Get the active tmux session for a given issueId, or null if none.
|
|
42
68
|
*/
|
|
@@ -231,8 +257,8 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
231
257
|
progress.push(formatActivityLogLine(activity));
|
|
232
258
|
}
|
|
233
259
|
|
|
234
|
-
// Detect completion
|
|
235
|
-
if (event
|
|
260
|
+
// Detect completion across known CLI event shapes.
|
|
261
|
+
if (isCompletionEvent(event)) {
|
|
236
262
|
completionEventReceived = true;
|
|
237
263
|
cleanup("done");
|
|
238
264
|
rl.close();
|