@calltelemetry/openclaw-linear 0.3.1 → 0.4.0
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 +322 -386
- package/index.ts +50 -2
- package/openclaw.plugin.json +7 -1
- package/package.json +3 -2
- package/src/active-session.ts +66 -0
- package/src/agent.ts +173 -1
- package/src/auth.ts +6 -2
- package/src/claude-tool.ts +280 -0
- package/src/cli-shared.ts +75 -0
- package/src/cli.ts +39 -0
- package/src/client.ts +1 -0
- package/src/code-tool.ts +202 -0
- package/src/codex-tool.ts +240 -0
- package/src/codex-worktree.ts +264 -0
- package/src/gemini-tool.ts +238 -0
- package/src/orchestration-tools.ts +134 -0
- package/src/pipeline.ts +68 -10
- package/src/tools.ts +29 -79
- package/src/webhook.ts +321 -90
package/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
3
|
import { registerLinearProvider } from "./src/auth.js";
|
|
3
4
|
import { registerCli } from "./src/cli.js";
|
|
@@ -22,7 +23,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
22
23
|
registerLinearProvider(api);
|
|
23
24
|
|
|
24
25
|
// Register CLI commands: openclaw openclaw-linear auth|status
|
|
25
|
-
api.registerCli(({ program }) => registerCli(program, api), {
|
|
26
|
+
api.registerCli(({ program }) => registerCli(program as any, api), {
|
|
26
27
|
commands: ["openclaw-linear"],
|
|
27
28
|
});
|
|
28
29
|
|
|
@@ -55,8 +56,55 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
55
56
|
},
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
// Narration Guard: catch short "Let me explore..." responses that narrate intent
|
|
60
|
+
// without actually calling tools, and append a warning for the user.
|
|
61
|
+
const NARRATION_PATTERNS = [
|
|
62
|
+
/let me (explore|look|investigate|check|dig|analyze|search|find|review|examine)/i,
|
|
63
|
+
/i('ll| will) (explore|look into|investigate|check|dig into|analyze|search|find|review)/i,
|
|
64
|
+
/let me (take a look|dive into|pull up|go through)/i,
|
|
65
|
+
];
|
|
66
|
+
const MAX_SHORT_RESPONSE = 250;
|
|
67
|
+
|
|
68
|
+
api.on("message_sending", (event: { content?: string }) => {
|
|
69
|
+
const text = event?.content ?? "";
|
|
70
|
+
if (!text || text.length > MAX_SHORT_RESPONSE) return {};
|
|
71
|
+
const isNarration = NARRATION_PATTERNS.some((p) => p.test(text));
|
|
72
|
+
if (!isNarration) return {};
|
|
73
|
+
api.logger.warn(`Narration guard triggered: "${text.slice(0, 80)}..."`);
|
|
74
|
+
return {
|
|
75
|
+
content:
|
|
76
|
+
text +
|
|
77
|
+
"\n\n⚠️ _Agent acknowledged but may not have completed the task. Try asking again or rephrase your request._",
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Check CLI availability (Codex, Claude, Gemini)
|
|
82
|
+
const cliChecks: Record<string, string> = {};
|
|
83
|
+
const cliBins: [string, string, string][] = [
|
|
84
|
+
["codex", "/home/claw/.npm-global/bin/codex", "npm install -g @openai/codex"],
|
|
85
|
+
["claude", "/home/claw/.npm-global/bin/claude", "npm install -g @anthropic-ai/claude-code"],
|
|
86
|
+
["gemini", "/home/claw/.npm-global/bin/gemini", "npm install -g @anthropic-ai/gemini-cli"],
|
|
87
|
+
];
|
|
88
|
+
for (const [name, bin, installCmd] of cliBins) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = execFileSync(bin, ["--version"], {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
timeout: 5_000,
|
|
93
|
+
env: { ...process.env, CLAUDECODE: undefined } as any,
|
|
94
|
+
}).trim();
|
|
95
|
+
cliChecks[name] = raw || "unknown";
|
|
96
|
+
} catch {
|
|
97
|
+
cliChecks[name] = "not found";
|
|
98
|
+
api.logger.warn(
|
|
99
|
+
`${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
105
|
+
const orchestration = pluginConfig?.enableOrchestration !== false ? "enabled" : "disabled";
|
|
106
|
+
const cliSummary = Object.entries(cliChecks).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
59
107
|
api.logger.info(
|
|
60
|
-
`Linear agent extension registered (agent: ${agentId}, token: ${tokenInfo.source !== "none" ? `${tokenInfo.source}` : "missing"})`,
|
|
108
|
+
`Linear agent extension registered (agent: ${agentId}, token: ${tokenInfo.source !== "none" ? `${tokenInfo.source}` : "missing"}, ${cliSummary}, orchestration: ${orchestration})`,
|
|
61
109
|
);
|
|
62
110
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
"version": "0.2.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
8
9
|
"properties": {
|
|
10
|
+
"enabled": { "type": "boolean" },
|
|
9
11
|
"clientId": { "type": "string", "description": "Linear OAuth Client ID" },
|
|
10
12
|
"clientSecret": { "type": "string", "description": "Linear OAuth Client Secret", "sensitive": true },
|
|
11
13
|
"redirectUri": { "type": "string", "description": "Linear OAuth Redirect URI (optional, defaults to gateway URL)" },
|
|
12
14
|
"accessToken": { "type": "string", "description": "Linear API access token for agent activities", "sensitive": true },
|
|
13
15
|
"defaultAgentId": { "type": "string", "description": "OpenClaw agent ID to use for pipeline stages" },
|
|
14
|
-
"enableAudit": { "type": "boolean", "description": "Run auditor stage after implementation", "default": true }
|
|
16
|
+
"enableAudit": { "type": "boolean", "description": "Run auditor stage after implementation", "default": true },
|
|
17
|
+
"codexBaseRepo": { "type": "string", "description": "Path to git repo for Codex worktrees", "default": "/home/claw/ai-workspace" },
|
|
18
|
+
"codexModel": { "type": "string", "description": "Default Codex model (optional — uses Codex default if omitted)" },
|
|
19
|
+
"codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
|
|
20
|
+
"enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true }
|
|
15
21
|
}
|
|
16
22
|
}
|
|
17
23
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calltelemetry/openclaw-linear",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"openclaw": "^2026.2.13"
|
|
31
|
+
"openclaw": "^2026.2.13",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
32
33
|
},
|
|
33
34
|
"openclaw": {
|
|
34
35
|
"extensions": [
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* active-session.ts — Idempotent registry of active Linear agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* When the pipeline starts work on an issue, it registers the session here.
|
|
5
|
+
* Any tool (code_run, etc.) can look up the active session for the current
|
|
6
|
+
* issue to stream activities without relying on the LLM agent to pass params.
|
|
7
|
+
*
|
|
8
|
+
* This runs in the gateway process. Tool execution also happens in the gateway,
|
|
9
|
+
* so tools can read from this registry directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ActiveSession {
|
|
13
|
+
agentSessionId: string;
|
|
14
|
+
issueIdentifier: string;
|
|
15
|
+
issueId: string;
|
|
16
|
+
agentId?: string;
|
|
17
|
+
startedAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Keyed by issue ID — one active session per issue at a time.
|
|
21
|
+
const sessions = new Map<string, ActiveSession>();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register the active session for an issue. Idempotent — calling again
|
|
25
|
+
* for the same issue just updates the session.
|
|
26
|
+
*/
|
|
27
|
+
export function setActiveSession(session: ActiveSession): void {
|
|
28
|
+
sessions.set(session.issueId, session);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clear the active session for an issue.
|
|
33
|
+
*/
|
|
34
|
+
export function clearActiveSession(issueId: string): void {
|
|
35
|
+
sessions.delete(issueId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Look up the active session for an issue by issue ID.
|
|
40
|
+
*/
|
|
41
|
+
export function getActiveSession(issueId: string): ActiveSession | null {
|
|
42
|
+
return sessions.get(issueId) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Look up the active session by issue identifier (e.g. "API-472").
|
|
47
|
+
* Slower than by ID — scans all sessions.
|
|
48
|
+
*/
|
|
49
|
+
export function getActiveSessionByIdentifier(identifier: string): ActiveSession | null {
|
|
50
|
+
for (const session of sessions.values()) {
|
|
51
|
+
if (session.issueIdentifier === identifier) return session;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the current active session. If there's exactly one, return it.
|
|
58
|
+
* If there are multiple (concurrent pipelines), returns null — caller
|
|
59
|
+
* must specify which issue.
|
|
60
|
+
*/
|
|
61
|
+
export function getCurrentSession(): ActiveSession | null {
|
|
62
|
+
if (sessions.size === 1) {
|
|
63
|
+
return sessions.values().next().value ?? null;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -1,21 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
|
|
4
|
+
|
|
5
|
+
// Import extensionAPI for embedded agent runner (internal, not in public SDK)
|
|
6
|
+
let _extensionAPI: typeof import("/home/claw/.npm-global/lib/node_modules/openclaw/dist/extensionAPI.js") | null = null;
|
|
7
|
+
async function getExtensionAPI() {
|
|
8
|
+
if (!_extensionAPI) {
|
|
9
|
+
// Dynamic import to avoid blocking module load if unavailable
|
|
10
|
+
_extensionAPI = await import(
|
|
11
|
+
"/home/claw/.npm-global/lib/node_modules/openclaw/dist/extensionAPI.js"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return _extensionAPI;
|
|
15
|
+
}
|
|
2
16
|
|
|
3
17
|
export interface AgentRunResult {
|
|
4
18
|
success: boolean;
|
|
5
19
|
output: string;
|
|
6
20
|
}
|
|
7
21
|
|
|
22
|
+
export interface AgentStreamCallbacks {
|
|
23
|
+
linearApi: LinearAgentApi;
|
|
24
|
+
agentSessionId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run an agent using the embedded runner with streaming callbacks.
|
|
29
|
+
* Falls back to subprocess if the embedded runner is unavailable.
|
|
30
|
+
*/
|
|
8
31
|
export async function runAgent(params: {
|
|
9
32
|
api: OpenClawPluginApi;
|
|
10
33
|
agentId: string;
|
|
11
34
|
sessionId: string;
|
|
12
35
|
message: string;
|
|
13
36
|
timeoutMs?: number;
|
|
37
|
+
streaming?: AgentStreamCallbacks;
|
|
14
38
|
}): Promise<AgentRunResult> {
|
|
15
|
-
const { api, agentId, sessionId, message, timeoutMs = 5 * 60_000 } = params;
|
|
39
|
+
const { api, agentId, sessionId, message, timeoutMs = 5 * 60_000, streaming } = params;
|
|
16
40
|
|
|
17
41
|
api.logger.info(`Dispatching agent ${agentId} for session ${sessionId}`);
|
|
18
42
|
|
|
43
|
+
// Try embedded runner first (has streaming callbacks)
|
|
44
|
+
if (streaming) {
|
|
45
|
+
try {
|
|
46
|
+
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
api.logger.warn(`Embedded runner failed, falling back to subprocess: ${err}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback: subprocess (no streaming)
|
|
53
|
+
return runSubprocess(api, agentId, sessionId, message, timeoutMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Embedded agent runner with real-time streaming to Linear.
|
|
58
|
+
*/
|
|
59
|
+
async function runEmbedded(
|
|
60
|
+
api: OpenClawPluginApi,
|
|
61
|
+
agentId: string,
|
|
62
|
+
sessionId: string,
|
|
63
|
+
message: string,
|
|
64
|
+
timeoutMs: number,
|
|
65
|
+
streaming: AgentStreamCallbacks,
|
|
66
|
+
): Promise<AgentRunResult> {
|
|
67
|
+
const ext = await getExtensionAPI();
|
|
68
|
+
|
|
69
|
+
const workspaceDir = ext.resolveAgentWorkspaceDir({ agentId });
|
|
70
|
+
const sessionFile = ext.resolveSessionFilePath(sessionId);
|
|
71
|
+
const agentDir = ext.resolveAgentDir({ agentId });
|
|
72
|
+
const runId = randomUUID();
|
|
73
|
+
|
|
74
|
+
// Load config so embedded runner can resolve providers, API keys, etc.
|
|
75
|
+
const config = await api.runtime.config.loadConfig();
|
|
76
|
+
|
|
77
|
+
// Resolve model/provider from config — default is anthropic which requires
|
|
78
|
+
// a separate API key. Our agents use openrouter.
|
|
79
|
+
const configAny = config as Record<string, any>;
|
|
80
|
+
const agentList = configAny?.agents?.list as Array<Record<string, any>> | undefined;
|
|
81
|
+
const agentEntry = agentList?.find((a) => a.id === agentId);
|
|
82
|
+
const modelRef: string =
|
|
83
|
+
agentEntry?.model?.primary ??
|
|
84
|
+
configAny?.agents?.defaults?.model?.primary ??
|
|
85
|
+
`${ext.DEFAULT_PROVIDER}/${ext.DEFAULT_MODEL}`;
|
|
86
|
+
|
|
87
|
+
// Parse "provider/model-id" format (e.g. "openrouter/moonshotai/kimi-k2.5")
|
|
88
|
+
const slashIdx = modelRef.indexOf("/");
|
|
89
|
+
const provider = slashIdx > 0 ? modelRef.slice(0, slashIdx) : ext.DEFAULT_PROVIDER;
|
|
90
|
+
const model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
91
|
+
|
|
92
|
+
api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model}`);
|
|
93
|
+
|
|
94
|
+
const emit = (content: ActivityContent) => {
|
|
95
|
+
streaming.linearApi.emitActivity(streaming.agentSessionId, content).catch((err) => {
|
|
96
|
+
api.logger.warn(`Activity emit failed: ${err}`);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Track last emitted tool to avoid duplicates
|
|
101
|
+
let lastToolAction = "";
|
|
102
|
+
|
|
103
|
+
const result = await ext.runEmbeddedPiAgent({
|
|
104
|
+
sessionId,
|
|
105
|
+
sessionFile,
|
|
106
|
+
workspaceDir,
|
|
107
|
+
agentDir,
|
|
108
|
+
prompt: message,
|
|
109
|
+
agentId,
|
|
110
|
+
runId,
|
|
111
|
+
timeoutMs,
|
|
112
|
+
config,
|
|
113
|
+
provider,
|
|
114
|
+
model,
|
|
115
|
+
shouldEmitToolResult: () => true,
|
|
116
|
+
shouldEmitToolOutput: () => true,
|
|
117
|
+
|
|
118
|
+
// Stream reasoning/thinking to Linear
|
|
119
|
+
onReasoningStream: (payload) => {
|
|
120
|
+
const text = payload.text?.trim();
|
|
121
|
+
if (text && text.length > 10) {
|
|
122
|
+
emit({ type: "thought", body: text.slice(0, 500) });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Stream tool results to Linear
|
|
127
|
+
onToolResult: (payload) => {
|
|
128
|
+
const text = payload.text?.trim();
|
|
129
|
+
if (text) {
|
|
130
|
+
// Truncate tool results for activity display
|
|
131
|
+
const truncated = text.length > 300 ? text.slice(0, 300) + "..." : text;
|
|
132
|
+
emit({ type: "action", action: lastToolAction || "Tool result", parameter: truncated });
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// Raw agent events — capture tool starts/ends
|
|
137
|
+
onAgentEvent: (evt) => {
|
|
138
|
+
const { stream, data } = evt;
|
|
139
|
+
|
|
140
|
+
if (stream !== "tool") return;
|
|
141
|
+
|
|
142
|
+
const phase = String(data.phase ?? "");
|
|
143
|
+
const toolName = String(data.name ?? "tool");
|
|
144
|
+
const meta = typeof data.meta === "string" ? data.meta : "";
|
|
145
|
+
|
|
146
|
+
// Tool execution start — emit action with tool name + meta
|
|
147
|
+
if (phase === "start") {
|
|
148
|
+
lastToolAction = toolName;
|
|
149
|
+
emit({ type: "action", action: `Running ${toolName}`, parameter: meta.slice(0, 200) || toolName });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Tool execution result with error
|
|
153
|
+
if (phase === "result" && data.isError) {
|
|
154
|
+
emit({ type: "action", action: `${toolName} failed`, parameter: meta.slice(0, 200) || "error" });
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// Partial assistant text (for long responses)
|
|
159
|
+
onPartialReply: (payload) => {
|
|
160
|
+
// We don't emit every partial chunk to avoid flooding Linear
|
|
161
|
+
// The final response will be posted as a comment
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Extract output text from payloads
|
|
166
|
+
const payloads = result.payloads ?? [];
|
|
167
|
+
const outputText = payloads
|
|
168
|
+
.map((p) => p.text)
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.join("\n\n");
|
|
171
|
+
|
|
172
|
+
if (result.meta?.error) {
|
|
173
|
+
api.logger.error(`Embedded agent error: ${result.meta.error.kind}: ${result.meta.error.message}`);
|
|
174
|
+
return { success: false, output: outputText || result.meta.error.message };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
api.logger.info(`Embedded agent completed: agent=${agentId} session=${sessionId} duration=${result.meta.durationMs}ms`);
|
|
178
|
+
return { success: true, output: outputText || "(no output)" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Subprocess fallback (no streaming, used when no Linear session context).
|
|
183
|
+
*/
|
|
184
|
+
async function runSubprocess(
|
|
185
|
+
api: OpenClawPluginApi,
|
|
186
|
+
agentId: string,
|
|
187
|
+
sessionId: string,
|
|
188
|
+
message: string,
|
|
189
|
+
timeoutMs: number,
|
|
190
|
+
): Promise<AgentRunResult> {
|
|
19
191
|
const command = [
|
|
20
192
|
"openclaw",
|
|
21
193
|
"agent",
|
package/src/auth.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function registerLinearProvider(api: OpenClawPluginApi) {
|
|
|
46
46
|
{
|
|
47
47
|
id: "oauth",
|
|
48
48
|
label: "OAuth",
|
|
49
|
-
kind: "oauth",
|
|
49
|
+
kind: "oauth" as const,
|
|
50
50
|
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
|
51
51
|
// This is a placeholder for the actual OAuth flow.
|
|
52
52
|
// In a real implementation, we would use ctx.oauth.createVpsAwareHandlers
|
|
@@ -112,8 +112,12 @@ export function registerLinearProvider(api: OpenClawPluginApi) {
|
|
|
112
112
|
{
|
|
113
113
|
profileId: "linear:default",
|
|
114
114
|
credential: {
|
|
115
|
-
type: "oauth",
|
|
115
|
+
type: "oauth" as const,
|
|
116
116
|
provider: "linear",
|
|
117
|
+
access: tokens.access_token,
|
|
118
|
+
refresh: tokens.refresh_token,
|
|
119
|
+
expires: Date.now() + (tokens.expires_in * 1000),
|
|
120
|
+
// Keep aliases for backward compat with linear-api.ts resolveLinearToken
|
|
117
121
|
accessToken: tokens.access_token,
|
|
118
122
|
refreshToken: tokens.refresh_token,
|
|
119
123
|
expiresAt: Date.now() + (tokens.expires_in * 1000),
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { ActivityContent } from "./linear-api.js";
|
|
5
|
+
import {
|
|
6
|
+
buildLinearApi,
|
|
7
|
+
resolveSession,
|
|
8
|
+
extractPrompt,
|
|
9
|
+
DEFAULT_TIMEOUT_MS,
|
|
10
|
+
DEFAULT_BASE_REPO,
|
|
11
|
+
type CliToolParams,
|
|
12
|
+
type CliResult,
|
|
13
|
+
} from "./cli-shared.js";
|
|
14
|
+
|
|
15
|
+
const CLAUDE_BIN = "/home/claw/.npm-global/bin/claude";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Map a Claude Code stream-json JSONL event to a Linear activity.
|
|
19
|
+
*
|
|
20
|
+
* Claude event types:
|
|
21
|
+
* system(init) → assistant (text|tool_use) → user (tool_result) → result
|
|
22
|
+
*/
|
|
23
|
+
function mapClaudeEventToActivity(event: any): ActivityContent | null {
|
|
24
|
+
const type = event?.type;
|
|
25
|
+
|
|
26
|
+
// Assistant message — text response or tool use
|
|
27
|
+
if (type === "assistant") {
|
|
28
|
+
const content = event.message?.content;
|
|
29
|
+
if (!Array.isArray(content)) return null;
|
|
30
|
+
|
|
31
|
+
for (const block of content) {
|
|
32
|
+
if (block.type === "text" && block.text) {
|
|
33
|
+
return { type: "thought", body: block.text.slice(0, 1000) };
|
|
34
|
+
}
|
|
35
|
+
if (block.type === "tool_use") {
|
|
36
|
+
const toolName = block.name ?? "tool";
|
|
37
|
+
const input = block.input ?? {};
|
|
38
|
+
// Summarize the input for display
|
|
39
|
+
let paramSummary: string;
|
|
40
|
+
if (input.command) {
|
|
41
|
+
paramSummary = String(input.command).slice(0, 200);
|
|
42
|
+
} else if (input.file_path) {
|
|
43
|
+
paramSummary = String(input.file_path);
|
|
44
|
+
} else if (input.pattern) {
|
|
45
|
+
paramSummary = String(input.pattern);
|
|
46
|
+
} else if (input.query) {
|
|
47
|
+
paramSummary = String(input.query).slice(0, 200);
|
|
48
|
+
} else {
|
|
49
|
+
paramSummary = JSON.stringify(input).slice(0, 200);
|
|
50
|
+
}
|
|
51
|
+
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Tool result
|
|
58
|
+
if (type === "user") {
|
|
59
|
+
const content = event.message?.content;
|
|
60
|
+
if (!Array.isArray(content)) return null;
|
|
61
|
+
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (block.type === "tool_result") {
|
|
64
|
+
const output = typeof block.content === "string" ? block.content : "";
|
|
65
|
+
const truncated = output.length > 300 ? output.slice(0, 300) + "..." : output;
|
|
66
|
+
const isError = block.is_error === true;
|
|
67
|
+
return {
|
|
68
|
+
type: "action",
|
|
69
|
+
action: isError ? "Tool error" : "Tool result",
|
|
70
|
+
parameter: truncated || "(no output)",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Final result
|
|
78
|
+
if (type === "result") {
|
|
79
|
+
const cost = event.total_cost_usd;
|
|
80
|
+
const turns = event.num_turns ?? 0;
|
|
81
|
+
const usage = event.usage;
|
|
82
|
+
const parts: string[] = [`Claude completed (${turns} turns)`];
|
|
83
|
+
if (cost != null) parts.push(`$${cost.toFixed(4)}`);
|
|
84
|
+
if (usage) {
|
|
85
|
+
const input = usage.input_tokens ?? 0;
|
|
86
|
+
const output = usage.output_tokens ?? 0;
|
|
87
|
+
parts.push(`${input} in / ${output} out tokens`);
|
|
88
|
+
}
|
|
89
|
+
return { type: "thought", body: parts.join(" — ") };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run Claude Code CLI with JSONL streaming, mapping events to Linear activities.
|
|
97
|
+
*/
|
|
98
|
+
export async function runClaude(
|
|
99
|
+
api: OpenClawPluginApi,
|
|
100
|
+
params: CliToolParams,
|
|
101
|
+
pluginConfig?: Record<string, unknown>,
|
|
102
|
+
): Promise<CliResult> {
|
|
103
|
+
api.logger.info(`claude_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
104
|
+
|
|
105
|
+
const prompt = extractPrompt(params);
|
|
106
|
+
if (!prompt) {
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
output: `claude_run error: no prompt provided. Received keys: ${Object.keys(params).join(", ")}`,
|
|
110
|
+
error: "missing prompt",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { model, timeoutMs } = params;
|
|
115
|
+
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
116
|
+
|
|
117
|
+
api.logger.info(`claude_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
118
|
+
|
|
119
|
+
const timeout = timeoutMs ?? (pluginConfig?.claudeTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
120
|
+
const workingDir = params.workingDir ?? (pluginConfig?.claudeBaseRepo as string) ?? DEFAULT_BASE_REPO;
|
|
121
|
+
|
|
122
|
+
const linearApi = buildLinearApi(api, agentSessionId);
|
|
123
|
+
|
|
124
|
+
if (linearApi && agentSessionId) {
|
|
125
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
126
|
+
type: "thought",
|
|
127
|
+
body: `Starting Claude Code: "${prompt.slice(0, 100)}${prompt.length > 100 ? "..." : ""}"`,
|
|
128
|
+
}).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build claude command
|
|
132
|
+
const args = [
|
|
133
|
+
"--print",
|
|
134
|
+
"--output-format", "stream-json",
|
|
135
|
+
"--verbose",
|
|
136
|
+
"--dangerously-skip-permissions",
|
|
137
|
+
];
|
|
138
|
+
if (model ?? pluginConfig?.claudeModel) {
|
|
139
|
+
args.push("--model", (model ?? pluginConfig?.claudeModel) as string);
|
|
140
|
+
}
|
|
141
|
+
args.push("-C", workingDir);
|
|
142
|
+
args.push("-p", prompt);
|
|
143
|
+
|
|
144
|
+
api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
|
|
145
|
+
|
|
146
|
+
return new Promise<CliResult>((resolve) => {
|
|
147
|
+
// Must unset CLAUDECODE to avoid "nested session" error
|
|
148
|
+
const env = { ...process.env };
|
|
149
|
+
delete env.CLAUDECODE;
|
|
150
|
+
|
|
151
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
152
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
153
|
+
env,
|
|
154
|
+
timeout: 0,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let killed = false;
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
killed = true;
|
|
160
|
+
child.kill("SIGTERM");
|
|
161
|
+
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
|
|
162
|
+
}, timeout);
|
|
163
|
+
|
|
164
|
+
const collectedMessages: string[] = [];
|
|
165
|
+
const collectedCommands: string[] = [];
|
|
166
|
+
let stderrOutput = "";
|
|
167
|
+
let lastToolName = "";
|
|
168
|
+
|
|
169
|
+
const rl = createInterface({ input: child.stdout! });
|
|
170
|
+
rl.on("line", (line) => {
|
|
171
|
+
if (!line.trim()) return;
|
|
172
|
+
|
|
173
|
+
let event: any;
|
|
174
|
+
try {
|
|
175
|
+
event = JSON.parse(line);
|
|
176
|
+
} catch {
|
|
177
|
+
collectedMessages.push(line);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Collect assistant text for final output
|
|
182
|
+
if (event.type === "assistant") {
|
|
183
|
+
const content = event.message?.content;
|
|
184
|
+
if (Array.isArray(content)) {
|
|
185
|
+
for (const block of content) {
|
|
186
|
+
if (block.type === "text" && block.text) {
|
|
187
|
+
collectedMessages.push(block.text);
|
|
188
|
+
}
|
|
189
|
+
if (block.type === "tool_use") {
|
|
190
|
+
lastToolName = block.name ?? "tool";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Collect tool results for final output
|
|
197
|
+
if (event.type === "user") {
|
|
198
|
+
const content = event.message?.content;
|
|
199
|
+
const toolResult = event.tool_use_result;
|
|
200
|
+
if (Array.isArray(content)) {
|
|
201
|
+
for (const block of content) {
|
|
202
|
+
if (block.type === "tool_result") {
|
|
203
|
+
const output = toolResult?.stdout ?? (typeof block.content === "string" ? block.content : "");
|
|
204
|
+
const isError = block.is_error === true;
|
|
205
|
+
const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
206
|
+
if (truncOutput) {
|
|
207
|
+
collectedCommands.push(
|
|
208
|
+
`\`${lastToolName}\` → ${isError ? "error" : "ok"}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Collect final result text
|
|
217
|
+
if (event.type === "result" && event.result) {
|
|
218
|
+
// result.result contains the final answer — only add if we haven't already captured it
|
|
219
|
+
// (it duplicates the last assistant text message)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Stream activity to Linear
|
|
223
|
+
const activity = mapClaudeEventToActivity(event);
|
|
224
|
+
if (activity && linearApi && agentSessionId) {
|
|
225
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
226
|
+
api.logger.warn(`Failed to emit Claude activity: ${err}`);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
child.stderr?.on("data", (chunk) => {
|
|
232
|
+
stderrOutput += chunk.toString();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
child.on("close", (code) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
rl.close();
|
|
238
|
+
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
|
|
241
|
+
if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
|
|
242
|
+
const output = parts.join("\n\n") || stderrOutput || "(no output)";
|
|
243
|
+
|
|
244
|
+
if (killed) {
|
|
245
|
+
api.logger.warn(`Claude timed out after ${timeout}ms`);
|
|
246
|
+
resolve({
|
|
247
|
+
success: false,
|
|
248
|
+
output: `Claude timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
|
|
249
|
+
error: "timeout",
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (code !== 0) {
|
|
255
|
+
api.logger.warn(`Claude exited with code ${code}`);
|
|
256
|
+
resolve({
|
|
257
|
+
success: false,
|
|
258
|
+
output: `Claude failed (exit ${code}):\n${output}`,
|
|
259
|
+
error: `exit ${code}`,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
api.logger.info(`Claude completed successfully`);
|
|
265
|
+
resolve({ success: true, output });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
child.on("error", (err) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
rl.close();
|
|
271
|
+
api.logger.error(`Claude spawn error: ${err}`);
|
|
272
|
+
resolve({
|
|
273
|
+
success: false,
|
|
274
|
+
output: `Failed to start Claude: ${err.message}`,
|
|
275
|
+
error: err.message,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|