@calltelemetry/openclaw-linear 0.3.1 → 0.4.1

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/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";
@@ -5,6 +6,7 @@ import { createLinearTools } from "./src/tools.js";
5
6
  import { handleLinearWebhook } from "./src/webhook.js";
6
7
  import { handleOAuthCallback } from "./src/oauth-callback.js";
7
8
  import { resolveLinearToken } from "./src/linear-api.js";
9
+ import { createDispatchService } from "./src/dispatch-service.js";
8
10
 
9
11
  export default function register(api: OpenClawPluginApi) {
10
12
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
@@ -22,7 +24,7 @@ export default function register(api: OpenClawPluginApi) {
22
24
  registerLinearProvider(api);
23
25
 
24
26
  // Register CLI commands: openclaw openclaw-linear auth|status
25
- api.registerCli(({ program }) => registerCli(program, api), {
27
+ api.registerCli(({ program }) => registerCli(program as any, api), {
26
28
  commands: ["openclaw-linear"],
27
29
  });
28
30
 
@@ -55,8 +57,58 @@ export default function register(api: OpenClawPluginApi) {
55
57
  },
56
58
  });
57
59
 
60
+ // Register dispatch monitor service (stale detection, session hydration, cleanup)
61
+ api.registerService(createDispatchService(api));
62
+
63
+ // Narration Guard: catch short "Let me explore..." responses that narrate intent
64
+ // without actually calling tools, and append a warning for the user.
65
+ const NARRATION_PATTERNS = [
66
+ /let me (explore|look|investigate|check|dig|analyze|search|find|review|examine)/i,
67
+ /i('ll| will) (explore|look into|investigate|check|dig into|analyze|search|find|review)/i,
68
+ /let me (take a look|dive into|pull up|go through)/i,
69
+ ];
70
+ const MAX_SHORT_RESPONSE = 250;
71
+
72
+ api.on("message_sending", (event: { content?: string }) => {
73
+ const text = event?.content ?? "";
74
+ if (!text || text.length > MAX_SHORT_RESPONSE) return {};
75
+ const isNarration = NARRATION_PATTERNS.some((p) => p.test(text));
76
+ if (!isNarration) return {};
77
+ api.logger.warn(`Narration guard triggered: "${text.slice(0, 80)}..."`);
78
+ return {
79
+ content:
80
+ text +
81
+ "\n\n⚠️ _Agent acknowledged but may not have completed the task. Try asking again or rephrase your request._",
82
+ };
83
+ });
84
+
85
+ // Check CLI availability (Codex, Claude, Gemini)
86
+ const cliChecks: Record<string, string> = {};
87
+ const cliBins: [string, string, string][] = [
88
+ ["codex", "/home/claw/.npm-global/bin/codex", "npm install -g @openai/codex"],
89
+ ["claude", "/home/claw/.npm-global/bin/claude", "npm install -g @anthropic-ai/claude-code"],
90
+ ["gemini", "/home/claw/.npm-global/bin/gemini", "npm install -g @anthropic-ai/gemini-cli"],
91
+ ];
92
+ for (const [name, bin, installCmd] of cliBins) {
93
+ try {
94
+ const raw = execFileSync(bin, ["--version"], {
95
+ encoding: "utf8",
96
+ timeout: 5_000,
97
+ env: { ...process.env, CLAUDECODE: undefined } as any,
98
+ }).trim();
99
+ cliChecks[name] = raw || "unknown";
100
+ } catch {
101
+ cliChecks[name] = "not found";
102
+ api.logger.warn(
103
+ `${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
104
+ );
105
+ }
106
+ }
107
+
58
108
  const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
109
+ const orchestration = pluginConfig?.enableOrchestration !== false ? "enabled" : "disabled";
110
+ const cliSummary = Object.entries(cliChecks).map(([k, v]) => `${k}: ${v}`).join(", ");
59
111
  api.logger.info(
60
- `Linear agent extension registered (agent: ${agentId}, token: ${tokenInfo.source !== "none" ? `${tokenInfo.source}` : "missing"})`,
112
+ `Linear agent extension registered (agent: ${agentId}, token: ${tokenInfo.source !== "none" ? `${tokenInfo.source}` : "missing"}, ${cliSummary}, orchestration: ${orchestration})`,
61
113
  );
62
114
  }
@@ -5,13 +5,21 @@
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 },
21
+ "worktreeBaseDir": { "type": "string", "description": "Base directory for persistent git worktrees (default: ~/.openclaw/worktrees)" },
22
+ "dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" }
15
23
  }
16
24
  }
17
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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,106 @@
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
+ * The in-memory Map is the fast-path for tool lookups. On startup, the
12
+ * dispatch service calls hydrateFromDispatchState() to rebuild it from
13
+ * the persistent dispatch-state.json file.
14
+ */
15
+
16
+ import { readDispatchState } from "./dispatch-state.js";
17
+
18
+ export interface ActiveSession {
19
+ agentSessionId: string;
20
+ issueIdentifier: string;
21
+ issueId: string;
22
+ agentId?: string;
23
+ startedAt: number;
24
+ }
25
+
26
+ // Keyed by issue ID — one active session per issue at a time.
27
+ const sessions = new Map<string, ActiveSession>();
28
+
29
+ /**
30
+ * Register the active session for an issue. Idempotent — calling again
31
+ * for the same issue just updates the session.
32
+ */
33
+ export function setActiveSession(session: ActiveSession): void {
34
+ sessions.set(session.issueId, session);
35
+ }
36
+
37
+ /**
38
+ * Clear the active session for an issue.
39
+ */
40
+ export function clearActiveSession(issueId: string): void {
41
+ sessions.delete(issueId);
42
+ }
43
+
44
+ /**
45
+ * Look up the active session for an issue by issue ID.
46
+ */
47
+ export function getActiveSession(issueId: string): ActiveSession | null {
48
+ return sessions.get(issueId) ?? null;
49
+ }
50
+
51
+ /**
52
+ * Look up the active session by issue identifier (e.g. "API-472").
53
+ * Slower than by ID — scans all sessions.
54
+ */
55
+ export function getActiveSessionByIdentifier(identifier: string): ActiveSession | null {
56
+ for (const session of sessions.values()) {
57
+ if (session.issueIdentifier === identifier) return session;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Get the current active session. If there's exactly one, return it.
64
+ * If there are multiple (concurrent pipelines), returns null — caller
65
+ * must specify which issue.
66
+ */
67
+ export function getCurrentSession(): ActiveSession | null {
68
+ if (sessions.size === 1) {
69
+ return sessions.values().next().value ?? null;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Hydrate the in-memory session Map from dispatch-state.json.
76
+ * Called on startup by the dispatch service to restore sessions
77
+ * that were active before a gateway restart.
78
+ *
79
+ * Returns the number of sessions restored.
80
+ */
81
+ export async function hydrateFromDispatchState(configPath?: string): Promise<number> {
82
+ const state = await readDispatchState(configPath);
83
+ const active = state.dispatches.active;
84
+ let restored = 0;
85
+
86
+ for (const [, dispatch] of Object.entries(active)) {
87
+ if (dispatch.status === "dispatched" || dispatch.status === "running") {
88
+ sessions.set(dispatch.issueId, {
89
+ agentSessionId: dispatch.agentSessionId ?? "",
90
+ issueIdentifier: dispatch.issueIdentifier,
91
+ issueId: dispatch.issueId,
92
+ startedAt: new Date(dispatch.dispatchedAt).getTime(),
93
+ });
94
+ restored++;
95
+ }
96
+ }
97
+
98
+ return restored;
99
+ }
100
+
101
+ /**
102
+ * Get the count of currently tracked sessions.
103
+ */
104
+ export function getSessionCount(): number {
105
+ return sessions.size;
106
+ }
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),