@calltelemetry/openclaw-linear 0.3.0 → 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/src/webhook.ts CHANGED
@@ -3,7 +3,9 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
5
  import { LinearAgentApi, resolveLinearToken } from "./linear-api.js";
6
- import { runFullPipeline, resumePipeline, type PipelineContext } from "./pipeline.js";
6
+ // Pipeline is used from Issue.update delegation handler (not from agent session chat)
7
+ // import { runFullPipeline, resumePipeline, type PipelineContext } from "./pipeline.js";
8
+ import { setActiveSession, clearActiveSession } from "./active-session.js";
7
9
 
8
10
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
9
11
  interface AgentProfile {
@@ -49,8 +51,8 @@ function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProf
49
51
  return null;
50
52
  }
51
53
 
52
- // Store active session plans so we can resume after user approval
53
- const activeSessions = new Map<string, { plan: string; ctx: PipelineContext }>();
54
+ // Track issues with active agent runs to prevent concurrent duplicate runs.
55
+ const activeRuns = new Set<string>();
54
56
 
55
57
  // Dedup: track recently processed keys to avoid double-handling
56
58
  const recentlyProcessed = new Map<string, number>();
@@ -212,6 +214,13 @@ export async function handleLinearWebhook(
212
214
  agentSessionId = sessionResult.sessionId;
213
215
  if (agentSessionId) {
214
216
  api.logger.info(`Created agent session ${agentSessionId} for notification`);
217
+ setActiveSession({
218
+ agentSessionId,
219
+ issueIdentifier: enrichedIssue?.identifier ?? issue.id,
220
+ issueId: issue.id,
221
+ agentId,
222
+ startedAt: Date.now(),
223
+ });
215
224
  } else {
216
225
  api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
217
226
  }
@@ -220,20 +229,11 @@ export async function handleLinearWebhook(
220
229
  if (agentSessionId) {
221
230
  await linearApi.emitActivity(agentSessionId, {
222
231
  type: "thought",
223
- body: `Reviewing notification for ${enrichedIssue?.identifier ?? issue.id}...`,
232
+ body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
224
233
  }).catch(() => {});
225
234
  }
226
235
 
227
- // 3. Emit action
228
- if (agentSessionId) {
229
- await linearApi.emitActivity(agentSessionId, {
230
- type: "action",
231
- action: "Processing notification",
232
- parameter: notifType ?? "unknown",
233
- }).catch(() => {});
234
- }
235
-
236
- // 4. Run agent
236
+ // 3. Run agent with streaming
237
237
  const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
238
238
  const { runAgent } = await import("./agent.js");
239
239
  const result = await runAgent({
@@ -242,6 +242,7 @@ export async function handleLinearWebhook(
242
242
  sessionId,
243
243
  message,
244
244
  timeoutMs: 3 * 60_000,
245
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
245
246
  });
246
247
 
247
248
  const responseBody = result.success
@@ -284,13 +285,18 @@ export async function handleLinearWebhook(
284
285
  body: `Failed to process notification: ${String(err).slice(0, 500)}`,
285
286
  }).catch(() => {});
286
287
  }
288
+ } finally {
289
+ clearActiveSession(issue.id);
287
290
  }
288
291
  })();
289
292
 
290
293
  return true;
291
294
  }
292
295
 
293
- // ── AgentSessionEvent.created — start the pipeline ──────────────
296
+ // ── AgentSessionEvent.created — direct agent run ─────────────────
297
+ // User chatted with @ctclaw in Linear's agent session. Run the agent
298
+ // DIRECTLY with the user's message. The plan→implement→audit pipeline
299
+ // is only triggered from Issue.update delegation, not from chat.
294
300
  if (
295
301
  (payload.type === "AgentSessionEvent" && payload.action === "created") ||
296
302
  (payload.type === "AgentSession" && payload.action === "create")
@@ -307,7 +313,7 @@ export async function handleLinearWebhook(
307
313
  return true;
308
314
  }
309
315
 
310
- // Dedup: skip if we already handled this session (e.g. from Issue.update delegation)
316
+ // Dedup: skip if we already handled this session
311
317
  if (wasRecentlyProcessed(`session:${session.id}`)) {
312
318
  api.logger.info(`AgentSession ${session.id} already handled — skipping`);
313
319
  return true;
@@ -315,47 +321,147 @@ export async function handleLinearWebhook(
315
321
 
316
322
  const linearApi = createLinearApi(api);
317
323
  if (!linearApi) {
318
- api.logger.error("No Linear access token configured — cannot start pipeline. Run OAuth flow or set LINEAR_ACCESS_TOKEN.");
324
+ api.logger.error("No Linear access token configured");
319
325
  return true;
320
326
  }
321
327
 
322
328
  const agentId = resolveAgentId(api);
323
-
324
329
  const previousComments = payload.previousComments ?? [];
325
330
  const guidance = payload.guidance;
326
331
 
327
332
  api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
328
333
 
329
- const ctx: PipelineContext = {
330
- api,
331
- linearApi,
332
- agentSessionId: session.id,
333
- agentId,
334
- issue: {
335
- id: issue.id,
336
- identifier: issue.identifier ?? issue.id,
337
- title: issue.title ?? "(untitled)",
338
- description: issue.description,
339
- },
340
- promptContext: payload.promptContext ?? session.context,
341
- };
342
-
343
- // Run pipeline (non-blocking). Stage 1 emits first thought within 10s.
334
+ // Guard: skip if an agent run is already active for this issue
335
+ if (activeRuns.has(issue.id)) {
336
+ api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
337
+ return true;
338
+ }
339
+
340
+ // Extract the user's latest message from previousComments
341
+ // The last comment is the most recent user message
342
+ const lastComment = previousComments.length > 0
343
+ ? previousComments[previousComments.length - 1]
344
+ : null;
345
+ const userMessage = lastComment?.body ?? guidance ?? "";
346
+
347
+ // Fetch full issue details
348
+ let enrichedIssue: any = issue;
349
+ try {
350
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
351
+ } catch (err) {
352
+ api.logger.warn(`Could not fetch issue details: ${err}`);
353
+ }
354
+
355
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
356
+
357
+ // Build conversation context from previous comments
358
+ const commentContext = previousComments
359
+ .slice(-5)
360
+ .map((c: any) => `**${c.user?.name ?? c.actorName ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
361
+ .join("\n\n");
362
+
363
+ const message = [
364
+ `You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
365
+ `You have access to tools including \`code_run\` for coding tasks, \`spawn_agent\`/\`ask_agent\` for delegation, and standard tools (exec, read, edit, write, web_search, etc.). To manage Linear issues (update status, close, assign, comment, etc.) use the \`linearis\` CLI via exec — e.g. \`linearis issues update API-123 --status done\` to close an issue, \`linearis issues update API-123 --status "In Progress"\` to change status, \`linearis issues list\` to list issues. Run \`linearis usage\` for full command reference.`,
366
+ ``,
367
+ `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
368
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
369
+ ``,
370
+ `**Description:**`,
371
+ description,
372
+ commentContext ? `\n**Conversation:**\n${commentContext}` : "",
373
+ userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
374
+ ``,
375
+ `Respond to the user's request. If they ask you to write code or make changes, use the \`code_run\` tool. Be concise and action-oriented.`,
376
+ ].filter(Boolean).join("\n");
377
+
378
+ // Run agent directly (non-blocking)
379
+ activeRuns.add(issue.id);
344
380
  void (async () => {
345
- const { runPlannerStage } = await import("./pipeline.js");
346
- const plan = await runPlannerStage(ctx).catch((err) => {
347
- api.logger.error(`Planner stage error: ${err}`);
348
- return null;
381
+ const profiles = loadAgentProfiles();
382
+ const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
383
+ const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
384
+
385
+ // Register active session for tool resolution (code_run, etc.)
386
+ setActiveSession({
387
+ agentSessionId: session.id,
388
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
389
+ issueId: issue.id,
390
+ agentId,
391
+ startedAt: Date.now(),
349
392
  });
350
- if (plan) {
351
- activeSessions.set(session.id, { plan, ctx });
393
+
394
+ try {
395
+ // Emit initial thought
396
+ await linearApi.emitActivity(session.id, {
397
+ type: "thought",
398
+ body: `Processing request for ${enrichedIssue?.identifier ?? issue.id}...`,
399
+ }).catch(() => {});
400
+
401
+ // Run agent with streaming to Linear
402
+ const sessionId = `linear-session-${session.id}`;
403
+ const { runAgent } = await import("./agent.js");
404
+ const result = await runAgent({
405
+ api,
406
+ agentId,
407
+ sessionId,
408
+ message,
409
+ timeoutMs: 5 * 60_000,
410
+ streaming: {
411
+ linearApi,
412
+ agentSessionId: session.id,
413
+ },
414
+ });
415
+
416
+ const responseBody = result.success
417
+ ? result.output
418
+ : `I encountered an error processing this request. Please try again.`;
419
+
420
+ // Post as comment
421
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
422
+ const brandingOpts = avatarUrl
423
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
424
+ : undefined;
425
+
426
+ try {
427
+ if (brandingOpts) {
428
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
429
+ } else {
430
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
431
+ }
432
+ } catch (brandErr) {
433
+ api.logger.warn(`Branded comment failed: ${brandErr}`);
434
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
435
+ }
436
+
437
+ // Emit response (closes session)
438
+ const truncated = responseBody.length > 2000
439
+ ? responseBody.slice(0, 2000) + "\u2026"
440
+ : responseBody;
441
+ await linearApi.emitActivity(session.id, {
442
+ type: "response",
443
+ body: truncated,
444
+ }).catch(() => {});
445
+
446
+ api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
447
+ } catch (err) {
448
+ api.logger.error(`AgentSession handler error: ${err}`);
449
+ await linearApi.emitActivity(session.id, {
450
+ type: "error",
451
+ body: `Failed: ${String(err).slice(0, 500)}`,
452
+ }).catch(() => {});
453
+ } finally {
454
+ clearActiveSession(issue.id);
455
+ activeRuns.delete(issue.id);
352
456
  }
353
457
  })();
354
458
 
355
459
  return true;
356
460
  }
357
461
 
358
- // ── AgentSession.prompted — user replied (plan approval) ────────
462
+ // ── AgentSession.prompted — follow-up user messages in existing sessions
463
+ // Also fires when we emit activities (feedback loop). Use activeRuns guard
464
+ // and webhookId dedup to distinguish user follow-ups from our own emissions.
359
465
  if (
360
466
  (payload.type === "AgentSessionEvent" && payload.action === "prompted") ||
361
467
  (payload.type === "AgentSession" && payload.action === "prompted")
@@ -364,55 +470,160 @@ export async function handleLinearWebhook(
364
470
  res.end("ok");
365
471
 
366
472
  const session = payload.agentSession ?? payload.data;
367
- if (!session?.id) {
368
- api.logger.error("AgentSession.prompted missing session id");
473
+ const issue = session?.issue ?? payload.issue;
474
+ const activity = payload.agentActivity;
475
+
476
+ if (!session?.id || !issue?.id) {
477
+ api.logger.info(`AgentSession prompted: missing session or issue — ignoring`);
369
478
  return true;
370
479
  }
371
480
 
372
- api.logger.info(`AgentSession prompted: ${session.id}`);
481
+ // If an agent run is already active for this issue, this is feedback from
482
+ // our own activity emissions — ignore to prevent loops.
483
+ if (activeRuns.has(issue.id)) {
484
+ api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, ignoring (feedback)`);
485
+ return true;
486
+ }
373
487
 
374
- const stored = activeSessions.get(session.id);
375
- if (!stored) {
376
- api.logger.warn(`No active session found for ${session.id} — may have been restarted`);
488
+ // Dedup by webhookId
489
+ const webhookId = payload.webhookId;
490
+ if (webhookId && wasRecentlyProcessed(`webhook:${webhookId}`)) {
491
+ api.logger.info(`AgentSession prompted: webhook ${webhookId} already processed — skipping`);
492
+ return true;
493
+ }
377
494
 
378
- // Try to reconstruct context from payload
379
- const linearApi = createLinearApi(api);
380
- const issue = session?.issue ?? payload.issue;
381
- if (!linearApi || !issue?.id) {
382
- api.logger.error("Cannot reconstruct pipeline context for prompted session");
383
- return true;
384
- }
495
+ // Extract user message from the activity or prompt context
496
+ const promptContext = payload.promptContext;
497
+ const userMessage =
498
+ activity?.content?.body ??
499
+ activity?.body ??
500
+ promptContext?.message ??
501
+ promptContext ??
502
+ "";
503
+
504
+ if (!userMessage || typeof userMessage !== "string" || userMessage.trim().length === 0) {
505
+ api.logger.info(`AgentSession prompted: ${session.id} — no user message found, ignoring`);
506
+ return true;
507
+ }
508
+
509
+ const linearApi = createLinearApi(api);
510
+ if (!linearApi) {
511
+ api.logger.error("No Linear access token configured");
512
+ return true;
513
+ }
514
+
515
+ api.logger.info(`AgentSession prompted (follow-up): ${session.id} issue=${issue?.identifier ?? issue?.id} message="${userMessage.slice(0, 80)}..."`);
385
516
 
386
- const agentId = resolveAgentId(api);
517
+ const agentId = resolveAgentId(api);
387
518
 
388
- // The user's reply is the prompt content — treat as approval with context
389
- const userReply = session.context?.prompt ?? session.context?.body ?? "";
390
- api.logger.info(`Prompted session ${session.id} — treating reply as new request`);
519
+ // Run agent for follow-up (non-blocking)
520
+ activeRuns.add(issue.id);
521
+ void (async () => {
522
+ const profiles = loadAgentProfiles();
523
+ const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
524
+ const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
525
+
526
+ // Fetch full issue details for context
527
+ let enrichedIssue: any = issue;
528
+ try {
529
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
530
+ } catch (err) {
531
+ api.logger.warn(`Could not fetch issue details: ${err}`);
532
+ }
391
533
 
392
- const ctx: PipelineContext = {
393
- api,
394
- linearApi,
534
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
535
+
536
+ // Build context from recent comments
537
+ const recentComments = enrichedIssue?.comments?.nodes ?? [];
538
+ const commentContext = recentComments
539
+ .slice(-5)
540
+ .map((c: any) => `**${c.user?.name ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
541
+ .join("\n\n");
542
+
543
+ const message = [
544
+ `You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
545
+ `You have access to tools including \`code_run\` for coding tasks, \`spawn_agent\`/\`ask_agent\` for delegation, and standard tools (exec, read, edit, write, web_search, etc.). To manage Linear issues (update status, close, assign, comment, etc.) use the \`linearis\` CLI via exec — e.g. \`linearis issues update API-123 --status done\` to close an issue, \`linearis issues update API-123 --status "In Progress"\` to change status, \`linearis issues list\` to list issues. Run \`linearis usage\` for full command reference.`,
546
+ ``,
547
+ `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
548
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
549
+ ``,
550
+ `**Description:**`,
551
+ description,
552
+ commentContext ? `\n**Recent conversation:**\n${commentContext}` : "",
553
+ `\n**User's follow-up message:**\n> ${userMessage}`,
554
+ ``,
555
+ `Respond to the user's follow-up. Be concise and action-oriented.`,
556
+ ].filter(Boolean).join("\n");
557
+
558
+ setActiveSession({
395
559
  agentSessionId: session.id,
560
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
561
+ issueId: issue.id,
396
562
  agentId,
397
- issue: {
398
- id: issue.id,
399
- identifier: issue.identifier ?? issue.id,
400
- title: issue.title ?? "(untitled)",
401
- description: issue.description,
402
- },
403
- promptContext: session.context,
404
- };
405
-
406
- // Start fresh pipeline since we lost the plan
407
- void runFullPipeline(ctx);
408
- return true;
409
- }
563
+ startedAt: Date.now(),
564
+ });
410
565
 
411
- // Resume with stored plan
412
- api.logger.info(`Resuming pipeline for session ${session.id}`);
413
- activeSessions.delete(session.id);
566
+ try {
567
+ await linearApi.emitActivity(session.id, {
568
+ type: "thought",
569
+ body: `Processing follow-up for ${enrichedIssue?.identifier ?? issue.id}...`,
570
+ }).catch(() => {});
571
+
572
+ const sessionId = `linear-session-${session.id}`;
573
+ const { runAgent } = await import("./agent.js");
574
+ const result = await runAgent({
575
+ api,
576
+ agentId,
577
+ sessionId,
578
+ message,
579
+ timeoutMs: 5 * 60_000,
580
+ streaming: {
581
+ linearApi,
582
+ agentSessionId: session.id,
583
+ },
584
+ });
585
+
586
+ const responseBody = result.success
587
+ ? result.output
588
+ : `I encountered an error processing this request. Please try again.`;
589
+
590
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
591
+ const brandingOpts = avatarUrl
592
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
593
+ : undefined;
594
+
595
+ try {
596
+ if (brandingOpts) {
597
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
598
+ } else {
599
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
600
+ }
601
+ } catch (brandErr) {
602
+ api.logger.warn(`Branded comment failed: ${brandErr}`);
603
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
604
+ }
605
+
606
+ const truncated = responseBody.length > 2000
607
+ ? responseBody.slice(0, 2000) + "\u2026"
608
+ : responseBody;
609
+ await linearApi.emitActivity(session.id, {
610
+ type: "response",
611
+ body: truncated,
612
+ }).catch(() => {});
613
+
614
+ api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
615
+ } catch (err) {
616
+ api.logger.error(`AgentSession prompted handler error: ${err}`);
617
+ await linearApi.emitActivity(session.id, {
618
+ type: "error",
619
+ body: `Failed: ${String(err).slice(0, 500)}`,
620
+ }).catch(() => {});
621
+ } finally {
622
+ clearActiveSession(issue.id);
623
+ activeRuns.delete(issue.id);
624
+ }
625
+ })();
414
626
 
415
- void resumePipeline(stored.ctx, stored.plan);
416
627
  return true;
417
628
  }
418
629
 
@@ -526,6 +737,14 @@ export async function handleLinearWebhook(
526
737
  agentSessionId = sessionResult.sessionId;
527
738
  if (agentSessionId) {
528
739
  api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
740
+ // Register active session so code_run can resolve it automatically
741
+ setActiveSession({
742
+ agentSessionId,
743
+ issueIdentifier: enrichedIssue.identifier ?? issue.id,
744
+ issueId: issue.id,
745
+ agentId: mentionedAgent,
746
+ startedAt: Date.now(),
747
+ });
529
748
  } else {
530
749
  api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
531
750
  }
@@ -534,20 +753,11 @@ export async function handleLinearWebhook(
534
753
  if (agentSessionId) {
535
754
  await linearApi.emitActivity(agentSessionId, {
536
755
  type: "thought",
537
- body: `Analyzing ${enrichedIssue.identifier ?? issue.id}...`,
538
- }).catch(() => {});
539
- }
540
-
541
- // 3. Emit action
542
- if (agentSessionId) {
543
- await linearApi.emitActivity(agentSessionId, {
544
- type: "action",
545
- action: "Processing mention",
546
- parameter: `@${alias} by ${commentor}`,
756
+ body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
547
757
  }).catch(() => {});
548
758
  }
549
759
 
550
- // 4. Run agent subprocess
760
+ // 3. Run agent subprocess with streaming
551
761
  const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
552
762
  const { runAgent } = await import("./agent.js");
553
763
  const result = await runAgent({
@@ -556,6 +766,7 @@ export async function handleLinearWebhook(
556
766
  sessionId,
557
767
  message: taskMessage,
558
768
  timeoutMs: 3 * 60_000,
769
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
559
770
  });
560
771
 
561
772
  const responseBody = result.success
@@ -592,13 +803,14 @@ export async function handleLinearWebhook(
592
803
  api.logger.info(`Posted @${mentionedAgent} response to ${issue.identifier ?? issue.id}`);
593
804
  } catch (err) {
594
805
  api.logger.error(`Comment mention handler error: ${err}`);
595
- // 7. Emit error activity if session exists
596
806
  if (agentSessionId) {
597
807
  await linearApi.emitActivity(agentSessionId, {
598
808
  type: "error",
599
809
  body: `Failed to process mention: ${String(err).slice(0, 500)}`,
600
810
  }).catch(() => {});
601
811
  }
812
+ } finally {
813
+ clearActiveSession(issue.id);
602
814
  }
603
815
  })();
604
816
 
@@ -727,9 +939,15 @@ export async function handleLinearWebhook(
727
939
  const sessionResult = await linearApi.createSessionOnIssue(issue.id);
728
940
  agentSessionId = sessionResult.sessionId;
729
941
  if (agentSessionId) {
730
- // Mark session as processed so AgentSessionEvent handler skips it
731
942
  wasRecentlyProcessed(`session:${agentSessionId}`);
732
943
  api.logger.info(`Created agent session ${agentSessionId} for ${trigger}`);
944
+ setActiveSession({
945
+ agentSessionId,
946
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
947
+ issueId: issue.id,
948
+ agentId,
949
+ startedAt: Date.now(),
950
+ });
733
951
  } else {
734
952
  api.logger.warn(`Could not create agent session for assignment: ${sessionResult.error ?? "unknown"}`);
735
953
  }
@@ -757,6 +975,7 @@ export async function handleLinearWebhook(
757
975
  sessionId,
758
976
  message,
759
977
  timeoutMs: 3 * 60_000,
978
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
760
979
  });
761
980
 
762
981
  const responseBody = result.success
@@ -838,6 +1057,8 @@ export async function handleLinearWebhook(
838
1057
  body: `Failed to process assignment: ${String(err).slice(0, 500)}`,
839
1058
  }).catch(() => {});
840
1059
  }
1060
+ } finally {
1061
+ clearActiveSession(issue.id);
841
1062
  }
842
1063
  })();
843
1064
 
@@ -904,6 +1125,13 @@ export async function handleLinearWebhook(
904
1125
  if (agentSessionId) {
905
1126
  wasRecentlyProcessed(`session:${agentSessionId}`);
906
1127
  api.logger.info(`Created agent session ${agentSessionId} for Issue.create triage`);
1128
+ setActiveSession({
1129
+ agentSessionId,
1130
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
1131
+ issueId: issue.id,
1132
+ agentId,
1133
+ startedAt: Date.now(),
1134
+ });
907
1135
  }
908
1136
 
909
1137
  if (agentSessionId) {
@@ -964,6 +1192,7 @@ export async function handleLinearWebhook(
964
1192
  sessionId,
965
1193
  message,
966
1194
  timeoutMs: 3 * 60_000,
1195
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
967
1196
  });
968
1197
 
969
1198
  const responseBody = result.success
@@ -1047,6 +1276,8 @@ export async function handleLinearWebhook(
1047
1276
  body: `Failed to triage: ${String(err).slice(0, 500)}`,
1048
1277
  }).catch(() => {});
1049
1278
  }
1279
+ } finally {
1280
+ clearActiveSession(issue.id);
1050
1281
  }
1051
1282
  })();
1052
1283