@calltelemetry/openclaw-linear 0.9.15 → 0.9.17

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.
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
3
3
  *
4
- * Three-tier resolution:
4
+ * Four-tier resolution:
5
5
  * 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
6
6
  * 2. Linear labels: repo:api, repo:frontend
7
- * 3. Config default: Falls back to single codexBaseRepo
7
+ * 3. Team mapping: teamMappings[teamKey].repos from plugin config
8
+ * 4. Config default: Falls back to single codexBaseRepo
8
9
  */
9
10
 
10
11
  import { existsSync, statSync } from "node:fs";
@@ -18,7 +19,55 @@ export interface RepoConfig {
18
19
 
19
20
  export interface RepoResolution {
20
21
  repos: RepoConfig[];
21
- source: "issue_body" | "labels" | "config_default";
22
+ source: "issue_body" | "labels" | "team_mapping" | "config_default";
23
+ }
24
+
25
+ /**
26
+ * Enriched repo entry — filesystem path + optional GitHub identity.
27
+ * Supports both plain string paths (backward compat) and objects.
28
+ */
29
+ export interface RepoEntry {
30
+ path: string;
31
+ github?: string; // "owner/repo" format
32
+ hostname?: string; // defaults to "github.com"
33
+ }
34
+
35
+ /**
36
+ * Parse the repos config, normalizing both string and object formats.
37
+ * String values become { path: value }, objects pass through.
38
+ */
39
+ export function getRepoEntries(pluginConfig?: Record<string, unknown>): Record<string, RepoEntry> {
40
+ const repos = pluginConfig?.repos as Record<string, string | Record<string, unknown>> | undefined;
41
+ if (!repos) return {};
42
+ const result: Record<string, RepoEntry> = {};
43
+ for (const [name, value] of Object.entries(repos)) {
44
+ if (typeof value === "string") {
45
+ result[name] = { path: value };
46
+ } else if (value && typeof value === "object") {
47
+ result[name] = {
48
+ path: (value as any).path as string,
49
+ github: (value as any).github as string | undefined,
50
+ hostname: (value as any).hostname as string | undefined,
51
+ };
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Build candidate repositories for Linear's issueRepositorySuggestions API.
59
+ * Extracts GitHub identity from enriched repo entries.
60
+ */
61
+ export function buildCandidateRepositories(
62
+ pluginConfig?: Record<string, unknown>,
63
+ ): Array<{ hostname: string; repositoryFullName: string }> {
64
+ const entries = getRepoEntries(pluginConfig);
65
+ return Object.values(entries)
66
+ .filter(e => e.github)
67
+ .map(e => ({
68
+ hostname: e.hostname ?? "github.com",
69
+ repositoryFullName: e.github!,
70
+ }));
22
71
  }
23
72
 
24
73
  /**
@@ -28,6 +77,7 @@ export function resolveRepos(
28
77
  description: string | null | undefined,
29
78
  labels: string[],
30
79
  pluginConfig?: Record<string, unknown>,
80
+ teamKey?: string,
31
81
  ): RepoResolution {
32
82
  // 1. Check issue body for repo markers
33
83
  // Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
@@ -62,7 +112,21 @@ export function resolveRepos(
62
112
  return { repos, source: "labels" };
63
113
  }
64
114
 
65
- // 3. Config default: single repo
115
+ // 3. Team mapping: teamMappings[teamKey].repos
116
+ if (teamKey) {
117
+ const teamMappings = pluginConfig?.teamMappings as Record<string, Record<string, unknown>> | undefined;
118
+ const teamRepoNames = teamMappings?.[teamKey]?.repos as string[] | undefined;
119
+ if (teamRepoNames && teamRepoNames.length > 0) {
120
+ const repoMap = getRepoMap(pluginConfig);
121
+ const repos = teamRepoNames.map(name => ({
122
+ name,
123
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
124
+ }));
125
+ return { repos, source: "team_mapping" };
126
+ }
127
+ }
128
+
129
+ // 4. Config default: single repo
66
130
  const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
67
131
  return {
68
132
  repos: [{ name: "default", path: baseRepo }],
@@ -71,8 +135,12 @@ export function resolveRepos(
71
135
  }
72
136
 
73
137
  function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
74
- const repos = pluginConfig?.repos as Record<string, string> | undefined;
75
- return repos ?? {};
138
+ const entries = getRepoEntries(pluginConfig);
139
+ const result: Record<string, string> = {};
140
+ for (const [name, entry] of Object.entries(entries)) {
141
+ result[name] = entry.path;
142
+ }
143
+ return result;
76
144
  }
77
145
 
78
146
  function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
@@ -0,0 +1,225 @@
1
+ import { execSync, spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { mkdirSync, createWriteStream } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
6
+ import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
7
+ import { formatActivityLogLine, createProgressEmitter } from "../tools/cli-shared.js";
8
+ import { InactivityWatchdog } from "../agent/watchdog.js";
9
+ import { shellEscape } from "./tmux.js";
10
+
11
+ export interface TmuxSession {
12
+ sessionName: string;
13
+ backend: string;
14
+ issueIdentifier: string;
15
+ issueId: string;
16
+ steeringMode: string;
17
+ }
18
+
19
+ export interface RunInTmuxOptions {
20
+ issueId: string;
21
+ issueIdentifier: string;
22
+ sessionName: string;
23
+ command: string;
24
+ cwd: string;
25
+ timeoutMs: number;
26
+ watchdogMs: number;
27
+ logPath: string;
28
+ mapEvent: (event: any) => ActivityContent | null;
29
+ linearApi?: LinearAgentApi;
30
+ agentSessionId?: string;
31
+ steeringMode: "stdin-pipe" | "one-shot";
32
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void };
33
+ onUpdate?: OnProgressUpdate;
34
+ progressHeader: string;
35
+ }
36
+
37
+ // Track active tmux sessions by issueId
38
+ const activeSessions = new Map<string, TmuxSession>();
39
+
40
+ /**
41
+ * Get the active tmux session for a given issueId, or null if none.
42
+ */
43
+ export function getActiveTmuxSession(issueId: string): TmuxSession | null {
44
+ return activeSessions.get(issueId) ?? null;
45
+ }
46
+
47
+ /**
48
+ * Run a command inside a tmux session with pipe-pane streaming to a JSONL log.
49
+ * Monitors the log file for events and streams them to Linear.
50
+ */
51
+ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
52
+ const {
53
+ issueId,
54
+ issueIdentifier,
55
+ sessionName,
56
+ command,
57
+ cwd,
58
+ timeoutMs,
59
+ watchdogMs,
60
+ logPath,
61
+ mapEvent,
62
+ linearApi,
63
+ agentSessionId,
64
+ steeringMode,
65
+ logger,
66
+ onUpdate,
67
+ progressHeader,
68
+ } = opts;
69
+
70
+ // Ensure log directory exists
71
+ mkdirSync(dirname(logPath), { recursive: true });
72
+
73
+ // Register active session
74
+ const session: TmuxSession = {
75
+ sessionName,
76
+ backend: sessionName.split("-").slice(-2, -1)[0] ?? "unknown",
77
+ issueIdentifier,
78
+ issueId,
79
+ steeringMode,
80
+ };
81
+ activeSessions.set(issueId, session);
82
+
83
+ const progress = createProgressEmitter({ header: progressHeader, onUpdate });
84
+ progress.emitHeader();
85
+
86
+ try {
87
+ // Create tmux session running the command, piping output to logPath
88
+ const tmuxCmd = [
89
+ `tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)}`,
90
+ `${shellEscape(command)} 2>&1 | tee ${shellEscape(logPath)}`,
91
+ ].join(" ");
92
+
93
+ execSync(tmuxCmd, { stdio: "ignore", timeout: 10_000 });
94
+
95
+ // Tail the log file and process JSONL events
96
+ return await new Promise<CliResult>((resolve) => {
97
+ const tail = spawn("tail", ["-f", "-n", "+1", logPath], {
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ });
100
+
101
+ let killed = false;
102
+ let killedByWatchdog = false;
103
+ const collectedMessages: string[] = [];
104
+
105
+ const timer = setTimeout(() => {
106
+ killed = true;
107
+ cleanup("timeout");
108
+ }, timeoutMs);
109
+
110
+ const watchdog = new InactivityWatchdog({
111
+ inactivityMs: watchdogMs,
112
+ label: `tmux:${sessionName}`,
113
+ logger,
114
+ onKill: () => {
115
+ killedByWatchdog = true;
116
+ killed = true;
117
+ cleanup("inactivity_timeout");
118
+ },
119
+ });
120
+ watchdog.start();
121
+
122
+ function cleanup(reason: string) {
123
+ clearTimeout(timer);
124
+ watchdog.stop();
125
+ tail.kill();
126
+
127
+ // Kill the tmux session
128
+ try {
129
+ execSync(`tmux kill-session -t ${shellEscape(sessionName)}`, {
130
+ stdio: "ignore",
131
+ timeout: 5_000,
132
+ });
133
+ } catch { /* session may already be gone */ }
134
+
135
+ activeSessions.delete(issueId);
136
+
137
+ const output = collectedMessages.join("\n\n") || "(no output)";
138
+
139
+ if (reason === "inactivity_timeout") {
140
+ logger.warn(`tmux session ${sessionName} killed by inactivity watchdog`);
141
+ resolve({
142
+ success: false,
143
+ output: `Agent killed by inactivity watchdog (no I/O for ${Math.round(watchdogMs / 1000)}s). Partial output:\n${output}`,
144
+ error: "inactivity_timeout",
145
+ });
146
+ } else if (reason === "timeout") {
147
+ logger.warn(`tmux session ${sessionName} timed out after ${Math.round(timeoutMs / 1000)}s`);
148
+ resolve({
149
+ success: false,
150
+ output: `Agent timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
151
+ error: "timeout",
152
+ });
153
+ } else {
154
+ // Normal completion
155
+ resolve({ success: true, output });
156
+ }
157
+ }
158
+
159
+ const rl = createInterface({ input: tail.stdout! });
160
+ rl.on("line", (line) => {
161
+ if (!line.trim()) return;
162
+ watchdog.tick();
163
+
164
+ let event: any;
165
+ try {
166
+ event = JSON.parse(line);
167
+ } catch {
168
+ collectedMessages.push(line);
169
+ return;
170
+ }
171
+
172
+ // Collect text for output
173
+ if (event.type === "assistant") {
174
+ const content = event.message?.content;
175
+ if (Array.isArray(content)) {
176
+ for (const block of content) {
177
+ if (block.type === "text" && block.text) {
178
+ collectedMessages.push(block.text);
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ // Stream to Linear
185
+ const activity = mapEvent(event);
186
+ if (activity) {
187
+ if (linearApi && agentSessionId) {
188
+ linearApi.emitActivity(agentSessionId, activity).catch((err) => {
189
+ logger.warn(`Failed to emit tmux activity: ${err}`);
190
+ });
191
+ }
192
+ progress.push(formatActivityLogLine(activity));
193
+ }
194
+
195
+ // Detect completion
196
+ if (event.type === "result") {
197
+ cleanup("done");
198
+ rl.close();
199
+ }
200
+ });
201
+
202
+ // Handle tail process ending (tmux session completed)
203
+ tail.on("close", () => {
204
+ if (!killed) {
205
+ cleanup("done");
206
+ }
207
+ rl.close();
208
+ });
209
+
210
+ tail.on("error", (err) => {
211
+ logger.error(`tmux tail error: ${err}`);
212
+ cleanup("error");
213
+ rl.close();
214
+ });
215
+ });
216
+ } catch (err) {
217
+ activeSessions.delete(issueId);
218
+ logger.error(`runInTmux failed: ${err}`);
219
+ return {
220
+ success: false,
221
+ output: `Failed to start tmux session: ${err}`,
222
+ error: String(err),
223
+ };
224
+ }
225
+ }
@@ -0,0 +1,44 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ /**
4
+ * Check if tmux is available on the system.
5
+ */
6
+ export function isTmuxAvailable(): boolean {
7
+ try {
8
+ execSync("tmux -V", { stdio: "ignore" });
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Build a deterministic tmux session name from issue identifier, backend, and index.
17
+ */
18
+ export function buildSessionName(identifier: string, backend: string, index: number): string {
19
+ // tmux session names can't contain dots or colons — sanitize
20
+ const safe = `${identifier}-${backend}-${index}`.replace(/[^a-zA-Z0-9_-]/g, "_");
21
+ return `claw-${safe}`;
22
+ }
23
+
24
+ /**
25
+ * Escape a string for safe shell interpolation (single-quote wrapping).
26
+ */
27
+ export function shellEscape(value: string): string {
28
+ // Wrap in single quotes, escaping any embedded single quotes
29
+ return `'${value.replace(/'/g, "'\\''")}'`;
30
+ }
31
+
32
+ /**
33
+ * Capture the last N lines from a tmux session pane.
34
+ */
35
+ export function capturePane(sessionName: string, lines: number): string {
36
+ try {
37
+ return execSync(
38
+ `tmux capture-pane -t ${shellEscape(sessionName)} -p -S -${lines}`,
39
+ { encoding: "utf8", timeout: 5_000 },
40
+ ).trimEnd();
41
+ } catch {
42
+ return "";
43
+ }
44
+ }
@@ -2,7 +2,7 @@
2
2
  * active-session.ts — Idempotent registry of active Linear agent sessions.
3
3
  *
4
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
5
+ * Any tool (cli_codex, cli_claude, etc.) can look up the active session for the current
6
6
  * issue to stream activities without relying on the LLM agent to pass params.
7
7
  *
8
8
  * This runs in the gateway process. Tool execution also happens in the gateway,
@@ -96,6 +96,24 @@ export function getCurrentSession(): ActiveSession | null {
96
96
  return null;
97
97
  }
98
98
 
99
+ /**
100
+ * Look up the most recent active session for a given agent ID.
101
+ * When multiple sessions exist for the same agent, returns the most
102
+ * recently started one. This is the primary lookup for tool execution
103
+ * contexts where the agent ID is known but the issue isn't.
104
+ */
105
+ export function getActiveSessionByAgentId(agentId: string): ActiveSession | null {
106
+ let best: ActiveSession | null = null;
107
+ for (const session of sessions.values()) {
108
+ if (session.agentId === agentId) {
109
+ if (!best || session.startedAt > best.startedAt) {
110
+ best = session;
111
+ }
112
+ }
113
+ }
114
+ return best;
115
+ }
116
+
99
117
  /**
100
118
  * Hydrate the in-memory session Map from dispatch-state.json.
101
119
  * Called on startup by the dispatch service to restore sessions
@@ -307,3 +307,45 @@ export function resolveOrchestratorWorkspace(
307
307
  return join(home, ".openclaw", "workspace");
308
308
  }
309
309
  }
310
+
311
+ /**
312
+ * Read worker output artifacts for all attempts.
313
+ */
314
+ export function readWorkerOutputs(worktreePath: string, maxAttempt: number): string[] {
315
+ const outputs: string[] = [];
316
+ for (let i = 0; i <= maxAttempt; i++) {
317
+ try {
318
+ outputs.push(readFileSync(join(clawDir(worktreePath), `worker-${i}.md`), "utf-8"));
319
+ } catch {
320
+ outputs.push("(not found)");
321
+ }
322
+ }
323
+ return outputs;
324
+ }
325
+
326
+ /**
327
+ * Read audit verdict artifacts for all attempts.
328
+ */
329
+ export function readAuditVerdicts(worktreePath: string, maxAttempt: number): string[] {
330
+ const verdicts: string[] = [];
331
+ for (let i = 0; i <= maxAttempt; i++) {
332
+ try {
333
+ verdicts.push(readFileSync(join(clawDir(worktreePath), `audit-${i}.json`), "utf-8"));
334
+ } catch {
335
+ verdicts.push("(not found)");
336
+ }
337
+ }
338
+ return verdicts;
339
+ }
340
+
341
+ /**
342
+ * Read the interaction log entries.
343
+ */
344
+ export function readLog(worktreePath: string): string[] {
345
+ try {
346
+ const raw = readFileSync(join(clawDir(worktreePath), "log.jsonl"), "utf-8");
347
+ return raw.trim().split("\n").filter(Boolean);
348
+ } catch {
349
+ return [];
350
+ }
351
+ }
@@ -69,6 +69,8 @@ export interface CompletedDispatch {
69
69
  prUrl?: string;
70
70
  project?: string;
71
71
  totalAttempts?: number;
72
+ worktreePath?: string;
73
+ cleanedUp?: boolean;
72
74
  }
73
75
 
74
76
  /** Maps session keys to dispatch context for agent_end hook lookup */
@@ -370,6 +372,7 @@ export async function completeDispatch(
370
372
  prUrl: result.prUrl,
371
373
  project: active?.project ?? result.project,
372
374
  totalAttempts: active?.attempt ?? 0,
375
+ worktreePath: active?.worktreePath,
373
376
  };
374
377
  await writeDispatchState(filePath, data);
375
378
  } finally {
@@ -6,6 +6,7 @@ import {
6
6
  getCachedGuidanceForTeam,
7
7
  formatGuidanceAppendix,
8
8
  isGuidanceEnabled,
9
+ resolveGuidance,
9
10
  _resetGuidanceCacheForTesting,
10
11
  } from "./guidance.js";
11
12
 
@@ -220,3 +221,55 @@ describe("isGuidanceEnabled", () => {
220
221
  expect(isGuidanceEnabled({ enableGuidance: true }, undefined)).toBe(true);
221
222
  });
222
223
  });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // resolveGuidance
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe("resolveGuidance", () => {
230
+ it("extracts guidance from webhook payload and caches it", () => {
231
+ const result = resolveGuidance("team-1", { guidance: "Use TypeScript." });
232
+ expect(result).toBe("Use TypeScript.");
233
+ // Should also be cached now
234
+ expect(getCachedGuidanceForTeam("team-1")).toBe("Use TypeScript.");
235
+ });
236
+
237
+ it("falls back to cache when payload has no guidance", () => {
238
+ cacheGuidanceForTeam("team-1", "Cached guidance");
239
+ const result = resolveGuidance("team-1", {});
240
+ expect(result).toBe("Cached guidance");
241
+ });
242
+
243
+ it("returns null when no guidance anywhere", () => {
244
+ const result = resolveGuidance("team-1", {});
245
+ expect(result).toBeNull();
246
+ });
247
+
248
+ it("returns null when guidance is disabled for team", () => {
249
+ cacheGuidanceForTeam("team-1", "Should be ignored");
250
+ const config = { teamGuidanceOverrides: { "team-1": false } };
251
+ const result = resolveGuidance("team-1", { guidance: "Direct guidance" }, config);
252
+ expect(result).toBeNull();
253
+ });
254
+
255
+ it("returns null when teamId is undefined and no payload guidance", () => {
256
+ const result = resolveGuidance(undefined, {});
257
+ expect(result).toBeNull();
258
+ });
259
+
260
+ it("extracts from payload even with undefined teamId", () => {
261
+ const result = resolveGuidance(undefined, { guidance: "Global guidance" });
262
+ expect(result).toBe("Global guidance");
263
+ });
264
+
265
+ it("returns null when payload is null and cache is empty", () => {
266
+ const result = resolveGuidance("team-1", null);
267
+ expect(result).toBeNull();
268
+ });
269
+
270
+ it("uses cached guidance when payload is null", () => {
271
+ cacheGuidanceForTeam("team-1", "Cached");
272
+ const result = resolveGuidance("team-1", null);
273
+ expect(result).toBe("Cached");
274
+ });
275
+ });
@@ -127,6 +127,44 @@ export function formatGuidanceAppendix(guidance: string | null): string {
127
127
  ].join("\n");
128
128
  }
129
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // Proactive resolution (webhook → cache → null)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Resolve guidance for a team through all available sources.
136
+ * Chain: webhook payload → cache → null
137
+ *
138
+ * This replaces ad-hoc cache lookups with a single resolution function.
139
+ * When the cache expires and no webhook guidance is available, returns null.
140
+ */
141
+ export function resolveGuidance(
142
+ teamId: string | undefined,
143
+ payload: Record<string, unknown> | null,
144
+ pluginConfig?: Record<string, unknown>,
145
+ ): string | null {
146
+ // Check if guidance is enabled for this team
147
+ if (!isGuidanceEnabled(pluginConfig, teamId)) return null;
148
+
149
+ // 1. Try extracting from webhook payload (freshest source)
150
+ if (payload) {
151
+ const extracted = extractGuidance(payload);
152
+ if (extracted.guidance) {
153
+ // Cache for future Comment webhook paths
154
+ if (teamId) cacheGuidanceForTeam(teamId, extracted.guidance);
155
+ return extracted.guidance;
156
+ }
157
+ }
158
+
159
+ // 2. Try cache
160
+ if (teamId) {
161
+ const cached = getCachedGuidanceForTeam(teamId);
162
+ if (cached) return cached;
163
+ }
164
+
165
+ return null;
166
+ }
167
+
130
168
  // ---------------------------------------------------------------------------
131
169
  // Config toggle
132
170
  // ---------------------------------------------------------------------------