@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/src/webhook.ts CHANGED
@@ -3,7 +3,11 @@ 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
+ import { runFullPipeline, type PipelineContext } from "./pipeline.js";
7
+ import { setActiveSession, clearActiveSession } from "./active-session.js";
8
+ import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
9
+ import { assessTier } from "./tier-assess.js";
10
+ import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
7
11
 
8
12
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
9
13
  interface AgentProfile {
@@ -49,8 +53,8 @@ function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProf
49
53
  return null;
50
54
  }
51
55
 
52
- // Store active session plans so we can resume after user approval
53
- const activeSessions = new Map<string, { plan: string; ctx: PipelineContext }>();
56
+ // Track issues with active agent runs to prevent concurrent duplicate runs.
57
+ const activeRuns = new Set<string>();
54
58
 
55
59
  // Dedup: track recently processed keys to avoid double-handling
56
60
  const recentlyProcessed = new Map<string, number>();
@@ -212,6 +216,13 @@ export async function handleLinearWebhook(
212
216
  agentSessionId = sessionResult.sessionId;
213
217
  if (agentSessionId) {
214
218
  api.logger.info(`Created agent session ${agentSessionId} for notification`);
219
+ setActiveSession({
220
+ agentSessionId,
221
+ issueIdentifier: enrichedIssue?.identifier ?? issue.id,
222
+ issueId: issue.id,
223
+ agentId,
224
+ startedAt: Date.now(),
225
+ });
215
226
  } else {
216
227
  api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
217
228
  }
@@ -220,20 +231,11 @@ export async function handleLinearWebhook(
220
231
  if (agentSessionId) {
221
232
  await linearApi.emitActivity(agentSessionId, {
222
233
  type: "thought",
223
- body: `Reviewing notification for ${enrichedIssue?.identifier ?? issue.id}...`,
234
+ body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
224
235
  }).catch(() => {});
225
236
  }
226
237
 
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
238
+ // 3. Run agent with streaming
237
239
  const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
238
240
  const { runAgent } = await import("./agent.js");
239
241
  const result = await runAgent({
@@ -242,6 +244,7 @@ export async function handleLinearWebhook(
242
244
  sessionId,
243
245
  message,
244
246
  timeoutMs: 3 * 60_000,
247
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
245
248
  });
246
249
 
247
250
  const responseBody = result.success
@@ -284,13 +287,18 @@ export async function handleLinearWebhook(
284
287
  body: `Failed to process notification: ${String(err).slice(0, 500)}`,
285
288
  }).catch(() => {});
286
289
  }
290
+ } finally {
291
+ clearActiveSession(issue.id);
287
292
  }
288
293
  })();
289
294
 
290
295
  return true;
291
296
  }
292
297
 
293
- // ── AgentSessionEvent.created — start the pipeline ──────────────
298
+ // ── AgentSessionEvent.created — direct agent run ─────────────────
299
+ // User chatted with @ctclaw in Linear's agent session. Run the agent
300
+ // DIRECTLY with the user's message. The plan→implement→audit pipeline
301
+ // is only triggered from Issue.update delegation, not from chat.
294
302
  if (
295
303
  (payload.type === "AgentSessionEvent" && payload.action === "created") ||
296
304
  (payload.type === "AgentSession" && payload.action === "create")
@@ -307,7 +315,7 @@ export async function handleLinearWebhook(
307
315
  return true;
308
316
  }
309
317
 
310
- // Dedup: skip if we already handled this session (e.g. from Issue.update delegation)
318
+ // Dedup: skip if we already handled this session
311
319
  if (wasRecentlyProcessed(`session:${session.id}`)) {
312
320
  api.logger.info(`AgentSession ${session.id} already handled — skipping`);
313
321
  return true;
@@ -315,47 +323,147 @@ export async function handleLinearWebhook(
315
323
 
316
324
  const linearApi = createLinearApi(api);
317
325
  if (!linearApi) {
318
- api.logger.error("No Linear access token configured — cannot start pipeline. Run OAuth flow or set LINEAR_ACCESS_TOKEN.");
326
+ api.logger.error("No Linear access token configured");
319
327
  return true;
320
328
  }
321
329
 
322
330
  const agentId = resolveAgentId(api);
323
-
324
331
  const previousComments = payload.previousComments ?? [];
325
332
  const guidance = payload.guidance;
326
333
 
327
334
  api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
328
335
 
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.
336
+ // Guard: skip if an agent run is already active for this issue
337
+ if (activeRuns.has(issue.id)) {
338
+ api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
339
+ return true;
340
+ }
341
+
342
+ // Extract the user's latest message from previousComments
343
+ // The last comment is the most recent user message
344
+ const lastComment = previousComments.length > 0
345
+ ? previousComments[previousComments.length - 1]
346
+ : null;
347
+ const userMessage = lastComment?.body ?? guidance ?? "";
348
+
349
+ // Fetch full issue details
350
+ let enrichedIssue: any = issue;
351
+ try {
352
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
353
+ } catch (err) {
354
+ api.logger.warn(`Could not fetch issue details: ${err}`);
355
+ }
356
+
357
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
358
+
359
+ // Build conversation context from previous comments
360
+ const commentContext = previousComments
361
+ .slice(-5)
362
+ .map((c: any) => `**${c.user?.name ?? c.actorName ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
363
+ .join("\n\n");
364
+
365
+ const message = [
366
+ `You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
367
+ `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.`,
368
+ ``,
369
+ `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
370
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
371
+ ``,
372
+ `**Description:**`,
373
+ description,
374
+ commentContext ? `\n**Conversation:**\n${commentContext}` : "",
375
+ userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
376
+ ``,
377
+ `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.`,
378
+ ].filter(Boolean).join("\n");
379
+
380
+ // Run agent directly (non-blocking)
381
+ activeRuns.add(issue.id);
344
382
  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;
383
+ const profiles = loadAgentProfiles();
384
+ const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
385
+ const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
386
+
387
+ // Register active session for tool resolution (code_run, etc.)
388
+ setActiveSession({
389
+ agentSessionId: session.id,
390
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
391
+ issueId: issue.id,
392
+ agentId,
393
+ startedAt: Date.now(),
349
394
  });
350
- if (plan) {
351
- activeSessions.set(session.id, { plan, ctx });
395
+
396
+ try {
397
+ // Emit initial thought
398
+ await linearApi.emitActivity(session.id, {
399
+ type: "thought",
400
+ body: `Processing request for ${enrichedIssue?.identifier ?? issue.id}...`,
401
+ }).catch(() => {});
402
+
403
+ // Run agent with streaming to Linear
404
+ const sessionId = `linear-session-${session.id}`;
405
+ const { runAgent } = await import("./agent.js");
406
+ const result = await runAgent({
407
+ api,
408
+ agentId,
409
+ sessionId,
410
+ message,
411
+ timeoutMs: 5 * 60_000,
412
+ streaming: {
413
+ linearApi,
414
+ agentSessionId: session.id,
415
+ },
416
+ });
417
+
418
+ const responseBody = result.success
419
+ ? result.output
420
+ : `I encountered an error processing this request. Please try again.`;
421
+
422
+ // Post as comment
423
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
424
+ const brandingOpts = avatarUrl
425
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
426
+ : undefined;
427
+
428
+ try {
429
+ if (brandingOpts) {
430
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
431
+ } else {
432
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
433
+ }
434
+ } catch (brandErr) {
435
+ api.logger.warn(`Branded comment failed: ${brandErr}`);
436
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
437
+ }
438
+
439
+ // Emit response (closes session)
440
+ const truncated = responseBody.length > 2000
441
+ ? responseBody.slice(0, 2000) + "\u2026"
442
+ : responseBody;
443
+ await linearApi.emitActivity(session.id, {
444
+ type: "response",
445
+ body: truncated,
446
+ }).catch(() => {});
447
+
448
+ api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
449
+ } catch (err) {
450
+ api.logger.error(`AgentSession handler error: ${err}`);
451
+ await linearApi.emitActivity(session.id, {
452
+ type: "error",
453
+ body: `Failed: ${String(err).slice(0, 500)}`,
454
+ }).catch(() => {});
455
+ } finally {
456
+ clearActiveSession(issue.id);
457
+ activeRuns.delete(issue.id);
352
458
  }
353
459
  })();
354
460
 
355
461
  return true;
356
462
  }
357
463
 
358
- // ── AgentSession.prompted — user replied (plan approval) ────────
464
+ // ── AgentSession.prompted — follow-up user messages in existing sessions
465
+ // Also fires when we emit activities (feedback loop). Use activeRuns guard
466
+ // and webhookId dedup to distinguish user follow-ups from our own emissions.
359
467
  if (
360
468
  (payload.type === "AgentSessionEvent" && payload.action === "prompted") ||
361
469
  (payload.type === "AgentSession" && payload.action === "prompted")
@@ -364,55 +472,160 @@ export async function handleLinearWebhook(
364
472
  res.end("ok");
365
473
 
366
474
  const session = payload.agentSession ?? payload.data;
367
- if (!session?.id) {
368
- api.logger.error("AgentSession.prompted missing session id");
475
+ const issue = session?.issue ?? payload.issue;
476
+ const activity = payload.agentActivity;
477
+
478
+ if (!session?.id || !issue?.id) {
479
+ api.logger.info(`AgentSession prompted: missing session or issue — ignoring`);
369
480
  return true;
370
481
  }
371
482
 
372
- api.logger.info(`AgentSession prompted: ${session.id}`);
483
+ // If an agent run is already active for this issue, this is feedback from
484
+ // our own activity emissions — ignore to prevent loops.
485
+ if (activeRuns.has(issue.id)) {
486
+ api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, ignoring (feedback)`);
487
+ return true;
488
+ }
373
489
 
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`);
490
+ // Dedup by webhookId
491
+ const webhookId = payload.webhookId;
492
+ if (webhookId && wasRecentlyProcessed(`webhook:${webhookId}`)) {
493
+ api.logger.info(`AgentSession prompted: webhook ${webhookId} already processed — skipping`);
494
+ return true;
495
+ }
377
496
 
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
- }
497
+ // Extract user message from the activity or prompt context
498
+ const promptContext = payload.promptContext;
499
+ const userMessage =
500
+ activity?.content?.body ??
501
+ activity?.body ??
502
+ promptContext?.message ??
503
+ promptContext ??
504
+ "";
505
+
506
+ if (!userMessage || typeof userMessage !== "string" || userMessage.trim().length === 0) {
507
+ api.logger.info(`AgentSession prompted: ${session.id} — no user message found, ignoring`);
508
+ return true;
509
+ }
385
510
 
386
- const agentId = resolveAgentId(api);
511
+ const linearApi = createLinearApi(api);
512
+ if (!linearApi) {
513
+ api.logger.error("No Linear access token configured");
514
+ return true;
515
+ }
516
+
517
+ api.logger.info(`AgentSession prompted (follow-up): ${session.id} issue=${issue?.identifier ?? issue?.id} message="${userMessage.slice(0, 80)}..."`);
518
+
519
+ const agentId = resolveAgentId(api);
520
+
521
+ // Run agent for follow-up (non-blocking)
522
+ activeRuns.add(issue.id);
523
+ void (async () => {
524
+ const profiles = loadAgentProfiles();
525
+ const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
526
+ const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
387
527
 
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`);
528
+ // Fetch full issue details for context
529
+ let enrichedIssue: any = issue;
530
+ try {
531
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
532
+ } catch (err) {
533
+ api.logger.warn(`Could not fetch issue details: ${err}`);
534
+ }
391
535
 
392
- const ctx: PipelineContext = {
393
- api,
394
- linearApi,
536
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
537
+
538
+ // Build context from recent comments
539
+ const recentComments = enrichedIssue?.comments?.nodes ?? [];
540
+ const commentContext = recentComments
541
+ .slice(-5)
542
+ .map((c: any) => `**${c.user?.name ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
543
+ .join("\n\n");
544
+
545
+ const message = [
546
+ `You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
547
+ `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.`,
548
+ ``,
549
+ `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
550
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
551
+ ``,
552
+ `**Description:**`,
553
+ description,
554
+ commentContext ? `\n**Recent conversation:**\n${commentContext}` : "",
555
+ `\n**User's follow-up message:**\n> ${userMessage}`,
556
+ ``,
557
+ `Respond to the user's follow-up. Be concise and action-oriented.`,
558
+ ].filter(Boolean).join("\n");
559
+
560
+ setActiveSession({
395
561
  agentSessionId: session.id,
562
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
563
+ issueId: issue.id,
396
564
  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
- }
565
+ startedAt: Date.now(),
566
+ });
567
+
568
+ try {
569
+ await linearApi.emitActivity(session.id, {
570
+ type: "thought",
571
+ body: `Processing follow-up for ${enrichedIssue?.identifier ?? issue.id}...`,
572
+ }).catch(() => {});
573
+
574
+ const sessionId = `linear-session-${session.id}`;
575
+ const { runAgent } = await import("./agent.js");
576
+ const result = await runAgent({
577
+ api,
578
+ agentId,
579
+ sessionId,
580
+ message,
581
+ timeoutMs: 5 * 60_000,
582
+ streaming: {
583
+ linearApi,
584
+ agentSessionId: session.id,
585
+ },
586
+ });
587
+
588
+ const responseBody = result.success
589
+ ? result.output
590
+ : `I encountered an error processing this request. Please try again.`;
410
591
 
411
- // Resume with stored plan
412
- api.logger.info(`Resuming pipeline for session ${session.id}`);
413
- activeSessions.delete(session.id);
592
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
593
+ const brandingOpts = avatarUrl
594
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
595
+ : undefined;
596
+
597
+ try {
598
+ if (brandingOpts) {
599
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
600
+ } else {
601
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
602
+ }
603
+ } catch (brandErr) {
604
+ api.logger.warn(`Branded comment failed: ${brandErr}`);
605
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
606
+ }
607
+
608
+ const truncated = responseBody.length > 2000
609
+ ? responseBody.slice(0, 2000) + "\u2026"
610
+ : responseBody;
611
+ await linearApi.emitActivity(session.id, {
612
+ type: "response",
613
+ body: truncated,
614
+ }).catch(() => {});
615
+
616
+ api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
617
+ } catch (err) {
618
+ api.logger.error(`AgentSession prompted handler error: ${err}`);
619
+ await linearApi.emitActivity(session.id, {
620
+ type: "error",
621
+ body: `Failed: ${String(err).slice(0, 500)}`,
622
+ }).catch(() => {});
623
+ } finally {
624
+ clearActiveSession(issue.id);
625
+ activeRuns.delete(issue.id);
626
+ }
627
+ })();
414
628
 
415
- void resumePipeline(stored.ctx, stored.plan);
416
629
  return true;
417
630
  }
418
631
 
@@ -526,6 +739,14 @@ export async function handleLinearWebhook(
526
739
  agentSessionId = sessionResult.sessionId;
527
740
  if (agentSessionId) {
528
741
  api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
742
+ // Register active session so code_run can resolve it automatically
743
+ setActiveSession({
744
+ agentSessionId,
745
+ issueIdentifier: enrichedIssue.identifier ?? issue.id,
746
+ issueId: issue.id,
747
+ agentId: mentionedAgent,
748
+ startedAt: Date.now(),
749
+ });
529
750
  } else {
530
751
  api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
531
752
  }
@@ -534,20 +755,11 @@ export async function handleLinearWebhook(
534
755
  if (agentSessionId) {
535
756
  await linearApi.emitActivity(agentSessionId, {
536
757
  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}`,
758
+ body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
547
759
  }).catch(() => {});
548
760
  }
549
761
 
550
- // 4. Run agent subprocess
762
+ // 3. Run agent subprocess with streaming
551
763
  const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
552
764
  const { runAgent } = await import("./agent.js");
553
765
  const result = await runAgent({
@@ -556,6 +768,7 @@ export async function handleLinearWebhook(
556
768
  sessionId,
557
769
  message: taskMessage,
558
770
  timeoutMs: 3 * 60_000,
771
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
559
772
  });
560
773
 
561
774
  const responseBody = result.success
@@ -592,13 +805,14 @@ export async function handleLinearWebhook(
592
805
  api.logger.info(`Posted @${mentionedAgent} response to ${issue.identifier ?? issue.id}`);
593
806
  } catch (err) {
594
807
  api.logger.error(`Comment mention handler error: ${err}`);
595
- // 7. Emit error activity if session exists
596
808
  if (agentSessionId) {
597
809
  await linearApi.emitActivity(agentSessionId, {
598
810
  type: "error",
599
811
  body: `Failed to process mention: ${String(err).slice(0, 500)}`,
600
812
  }).catch(() => {});
601
813
  }
814
+ } finally {
815
+ clearActiveSession(issue.id);
602
816
  }
603
817
  })();
604
818
 
@@ -646,7 +860,7 @@ export async function handleLinearWebhook(
646
860
  }
647
861
 
648
862
  const trigger = isDelegatedToUs ? "delegated" : "assigned";
649
- api.logger.info(`Issue ${trigger} to our app user (${viewerId}), processing`);
863
+ api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
650
864
 
651
865
  // Dedup on assignment/delegation
652
866
  const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
@@ -655,191 +869,11 @@ export async function handleLinearWebhook(
655
869
  return true;
656
870
  }
657
871
 
658
- const agentId = resolveAgentId(api);
659
-
660
- // Fetch full issue details + team labels for triage
661
- let enrichedIssue: any = issue;
662
- let teamLabels: Array<{ id: string; name: string }> = [];
663
- try {
664
- enrichedIssue = await linearApi.getIssueDetails(issue.id);
665
- if (enrichedIssue?.team?.id) {
666
- teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
667
- }
668
- } catch (err) {
669
- api.logger.warn(`Could not fetch issue details: ${err}`);
670
- }
671
-
672
- const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
673
- const comments = enrichedIssue?.comments?.nodes ?? [];
674
- const commentSummary = comments
675
- .slice(-5)
676
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
677
- .join("\n");
678
-
679
- const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
680
- const currentLabels = enrichedIssue?.labels?.nodes ?? [];
681
- const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
682
- const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
683
-
684
- const message = [
685
- `IMPORTANT: You are triaging a delegated Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
686
- ``,
687
- `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
688
- `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
689
- ``,
690
- `**Description:**`,
691
- description,
692
- commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
693
- ``,
694
- `## Your Triage Tasks`,
695
- ``,
696
- `1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
697
- `2. **Labels** — Select appropriate labels from the team's available labels`,
698
- `3. **Assessment** — Brief analysis of what this issue needs`,
699
- ``,
700
- `## Available Labels`,
701
- availableLabelList || " (no labels configured)",
702
- ``,
703
- `## Response Format`,
704
- ``,
705
- `You MUST start your response with a JSON block, then follow with your assessment:`,
706
- ``,
707
- '```json',
708
- `{`,
709
- ` "estimate": <number>,`,
710
- ` "labelIds": ["<id1>", "<id2>"],`,
711
- ` "assessment": "<one-line summary of your sizing rationale>"`,
712
- `}`,
713
- '```',
714
- ``,
715
- `Then write your full assessment as markdown below the JSON block.`,
716
- ].filter(Boolean).join("\n");
717
-
718
- // Dispatch agent with session lifecycle (non-blocking)
719
- void (async () => {
720
- const profiles = loadAgentProfiles();
721
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
722
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
723
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
724
- let agentSessionId: string | null = null;
725
-
726
- try {
727
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
728
- agentSessionId = sessionResult.sessionId;
729
- if (agentSessionId) {
730
- // Mark session as processed so AgentSessionEvent handler skips it
731
- wasRecentlyProcessed(`session:${agentSessionId}`);
732
- api.logger.info(`Created agent session ${agentSessionId} for ${trigger}`);
733
- } else {
734
- api.logger.warn(`Could not create agent session for assignment: ${sessionResult.error ?? "unknown"}`);
735
- }
736
-
737
- if (agentSessionId) {
738
- await linearApi.emitActivity(agentSessionId, {
739
- type: "thought",
740
- body: `Reviewing assigned issue ${enrichedIssue?.identifier ?? issue.id}...`,
741
- }).catch(() => {});
742
- }
743
-
744
- if (agentSessionId) {
745
- await linearApi.emitActivity(agentSessionId, {
746
- type: "action",
747
- action: "Triaging",
748
- parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling, sizing`,
749
- }).catch(() => {});
750
- }
751
-
752
- const sessionId = `linear-assign-${issue.id}-${Date.now()}`;
753
- const { runAgent } = await import("./agent.js");
754
- const result = await runAgent({
755
- api,
756
- agentId,
757
- sessionId,
758
- message,
759
- timeoutMs: 3 * 60_000,
760
- });
761
-
762
- const responseBody = result.success
763
- ? result.output
764
- : `I encountered an error reviewing this assignment. Please try again.`;
765
-
766
- // Parse triage JSON from agent response and apply to issue
767
- let commentBody = responseBody;
768
- if (result.success) {
769
- const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
770
- if (jsonMatch) {
771
- try {
772
- const triage = JSON.parse(jsonMatch[1]);
773
- const updateInput: Record<string, unknown> = {};
774
-
775
- if (typeof triage.estimate === "number") {
776
- updateInput.estimate = triage.estimate;
777
- }
778
- if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
779
- // Merge with existing labels
780
- const existingIds = currentLabels.map((l: any) => l.id);
781
- const allIds = [...new Set([...existingIds, ...triage.labelIds])];
782
- updateInput.labelIds = allIds;
783
- }
784
-
785
- if (Object.keys(updateInput).length > 0) {
786
- await linearApi.updateIssue(issue.id, updateInput);
787
- api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
788
-
789
- if (agentSessionId) {
790
- await linearApi.emitActivity(agentSessionId, {
791
- type: "action",
792
- action: "Applied triage",
793
- result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0} added`,
794
- }).catch(() => {});
795
- }
796
- }
797
-
798
- // Strip the JSON block from the comment — post only the assessment
799
- commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
800
- } catch (parseErr) {
801
- api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
802
- }
803
- }
804
- }
805
-
806
- // Post comment with assessment
807
- const brandingOpts = avatarUrl
808
- ? { createAsUser: label, displayIconUrl: avatarUrl }
809
- : undefined;
810
-
811
- try {
812
- if (brandingOpts) {
813
- await linearApi.createComment(issue.id, commentBody, brandingOpts);
814
- } else {
815
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
816
- }
817
- } catch (brandErr) {
818
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
819
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
820
- }
821
-
822
- if (agentSessionId) {
823
- const truncated = commentBody.length > 2000
824
- ? commentBody.slice(0, 2000) + "…"
825
- : commentBody;
826
- await linearApi.emitActivity(agentSessionId, {
827
- type: "response",
828
- body: truncated,
829
- }).catch(() => {});
830
- }
831
-
832
- api.logger.info(`Posted assignment response to ${enrichedIssue?.identifier ?? issue.id}`);
833
- } catch (err) {
834
- api.logger.error(`Issue assignment handler error: ${err}`);
835
- if (agentSessionId) {
836
- await linearApi.emitActivity(agentSessionId, {
837
- type: "error",
838
- body: `Failed to process assignment: ${String(err).slice(0, 500)}`,
839
- }).catch(() => {});
840
- }
841
- }
842
- })();
872
+ // Assignment triggers the full dispatch pipeline:
873
+ // tier assessment → worktree → plan → implement → audit
874
+ void handleDispatch(api, linearApi, issue).catch((err) => {
875
+ api.logger.error(`Dispatch pipeline error for ${issue.identifier ?? issue.id}: ${err}`);
876
+ });
843
877
 
844
878
  return true;
845
879
  }
@@ -904,6 +938,13 @@ export async function handleLinearWebhook(
904
938
  if (agentSessionId) {
905
939
  wasRecentlyProcessed(`session:${agentSessionId}`);
906
940
  api.logger.info(`Created agent session ${agentSessionId} for Issue.create triage`);
941
+ setActiveSession({
942
+ agentSessionId,
943
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
944
+ issueId: issue.id,
945
+ agentId,
946
+ startedAt: Date.now(),
947
+ });
907
948
  }
908
949
 
909
950
  if (agentSessionId) {
@@ -964,6 +1005,7 @@ export async function handleLinearWebhook(
964
1005
  sessionId,
965
1006
  message,
966
1007
  timeoutMs: 3 * 60_000,
1008
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
967
1009
  });
968
1010
 
969
1011
  const responseBody = result.success
@@ -1047,6 +1089,8 @@ export async function handleLinearWebhook(
1047
1089
  body: `Failed to triage: ${String(err).slice(0, 500)}`,
1048
1090
  }).catch(() => {});
1049
1091
  }
1092
+ } finally {
1093
+ clearActiveSession(issue.id);
1050
1094
  }
1051
1095
  })();
1052
1096
 
@@ -1059,3 +1103,216 @@ export async function handleLinearWebhook(
1059
1103
  res.end("ok");
1060
1104
  return true;
1061
1105
  }
1106
+
1107
+ // ── @dispatch handler ─────────────────────────────────────────────
1108
+ //
1109
+ // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
1110
+ // creates a persistent worktree, registers the dispatch in state, and
1111
+ // launches the pipeline (plan → implement → audit).
1112
+
1113
+ async function handleDispatch(
1114
+ api: OpenClawPluginApi,
1115
+ linearApi: LinearAgentApi,
1116
+ issue: any,
1117
+ ): Promise<void> {
1118
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
1119
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
1120
+ const worktreeBaseDir = pluginConfig?.worktreeBaseDir as string | undefined;
1121
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
1122
+ const identifier = issue.identifier ?? issue.id;
1123
+
1124
+ api.logger.info(`@dispatch: processing ${identifier}`);
1125
+
1126
+ // 1. Check for existing active dispatch — reclaim if stale
1127
+ const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
1128
+ const state = await readDispatchState(statePath);
1129
+ const existing = getActiveDispatch(state, identifier);
1130
+ if (existing) {
1131
+ const ageMs = Date.now() - new Date(existing.dispatchedAt).getTime();
1132
+ const isStale = ageMs > STALE_DISPATCH_MS;
1133
+ const inMemory = activeRuns.has(issue.id);
1134
+
1135
+ if (!isStale && inMemory) {
1136
+ // Truly still running in this gateway process
1137
+ api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1138
+ await linearApi.createComment(
1139
+ issue.id,
1140
+ `Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
1141
+ );
1142
+ return;
1143
+ }
1144
+
1145
+ // Stale or not in memory (gateway restarted) — reclaim
1146
+ api.logger.info(
1147
+ `dispatch: ${identifier} reclaiming stale dispatch (status: ${existing.status}, ` +
1148
+ `age: ${Math.round(ageMs / 1000)}s, inMemory: ${inMemory}, stale: ${isStale})`,
1149
+ );
1150
+ await removeActiveDispatch(identifier, statePath);
1151
+ activeRuns.delete(issue.id);
1152
+ }
1153
+
1154
+ // 2. Prevent concurrent runs on same issue
1155
+ if (activeRuns.has(issue.id)) {
1156
+ api.logger.info(`@dispatch: ${identifier} has active agent run — skipping`);
1157
+ return;
1158
+ }
1159
+
1160
+ // 3. Fetch full issue details for tier assessment
1161
+ let enrichedIssue: any;
1162
+ try {
1163
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1164
+ } catch (err) {
1165
+ api.logger.error(`@dispatch: failed to fetch issue details: ${err}`);
1166
+ enrichedIssue = issue;
1167
+ }
1168
+
1169
+ const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1170
+ const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
1171
+
1172
+ // 4. Assess complexity tier
1173
+ const assessment = await assessTier(api, {
1174
+ identifier,
1175
+ title: enrichedIssue.title ?? "(untitled)",
1176
+ description: enrichedIssue.description,
1177
+ labels,
1178
+ commentCount,
1179
+ });
1180
+
1181
+ api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1182
+
1183
+ // 5. Create persistent worktree
1184
+ let worktree;
1185
+ try {
1186
+ worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1187
+ api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
1188
+ } catch (err) {
1189
+ api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1190
+ await linearApi.createComment(
1191
+ issue.id,
1192
+ `Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
1193
+ );
1194
+ return;
1195
+ }
1196
+
1197
+ // 5b. Prepare workspace: pull latest from origin + init submodules
1198
+ const prep = prepareWorkspace(worktree.path, worktree.branch);
1199
+ if (prep.errors.length > 0) {
1200
+ api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
1201
+ } else {
1202
+ api.logger.info(
1203
+ `@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
1204
+ );
1205
+ }
1206
+
1207
+ // 6. Create agent session on Linear
1208
+ let agentSessionId: string | undefined;
1209
+ try {
1210
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1211
+ agentSessionId = sessionResult.sessionId ?? undefined;
1212
+ } catch (err) {
1213
+ api.logger.warn(`@dispatch: could not create agent session: ${err}`);
1214
+ }
1215
+
1216
+ // 7. Register dispatch in persistent state
1217
+ const now = new Date().toISOString();
1218
+ await registerDispatch(identifier, {
1219
+ issueId: issue.id,
1220
+ issueIdentifier: identifier,
1221
+ worktreePath: worktree.path,
1222
+ branch: worktree.branch,
1223
+ tier: assessment.tier,
1224
+ model: assessment.model,
1225
+ status: "dispatched",
1226
+ dispatchedAt: now,
1227
+ agentSessionId,
1228
+ }, statePath);
1229
+
1230
+ // 8. Register active session for tool resolution
1231
+ setActiveSession({
1232
+ agentSessionId: agentSessionId ?? "",
1233
+ issueIdentifier: identifier,
1234
+ issueId: issue.id,
1235
+ agentId: resolveAgentId(api),
1236
+ startedAt: Date.now(),
1237
+ });
1238
+
1239
+ // 9. Post dispatch confirmation comment
1240
+ const prepStatus = prep.errors.length > 0
1241
+ ? `Workspace prep: partial (${prep.errors.join("; ")})`
1242
+ : `Workspace prep: OK (pulled=${prep.pulled}, submodules=${prep.submodulesInitialized})`;
1243
+ const statusComment = [
1244
+ `**Dispatched** as **${assessment.tier}** (${assessment.model})`,
1245
+ `> ${assessment.reasoning}`,
1246
+ ``,
1247
+ `Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
1248
+ `Branch: \`${worktree.branch}\``,
1249
+ prepStatus,
1250
+ ].join("\n");
1251
+
1252
+ await linearApi.createComment(issue.id, statusComment);
1253
+
1254
+ if (agentSessionId) {
1255
+ await linearApi.emitActivity(agentSessionId, {
1256
+ type: "thought",
1257
+ body: `Dispatching ${identifier} as ${assessment.tier}...`,
1258
+ }).catch(() => {});
1259
+ }
1260
+
1261
+ // 10. Apply tier label (best effort)
1262
+ try {
1263
+ if (enrichedIssue.team?.id) {
1264
+ const teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
1265
+ const tierLabel = teamLabels.find((l: any) => l.name === `developer:${assessment.tier}`);
1266
+ if (tierLabel) {
1267
+ const currentLabelIds = enrichedIssue.labels?.nodes?.map((l: any) => l.id) ?? [];
1268
+ await linearApi.updateIssue(issue.id, {
1269
+ labelIds: [...currentLabelIds, tierLabel.id],
1270
+ });
1271
+ }
1272
+ }
1273
+ } catch (err) {
1274
+ api.logger.warn(`@dispatch: could not apply tier label: ${err}`);
1275
+ }
1276
+
1277
+ // 11. Run pipeline (non-blocking)
1278
+ const agentId = resolveAgentId(api);
1279
+ const pipelineCtx: PipelineContext = {
1280
+ api,
1281
+ linearApi,
1282
+ agentSessionId: agentSessionId ?? `dispatch-${identifier}-${Date.now()}`,
1283
+ agentId,
1284
+ issue: {
1285
+ id: issue.id,
1286
+ identifier,
1287
+ title: enrichedIssue.title ?? "(untitled)",
1288
+ description: enrichedIssue.description,
1289
+ },
1290
+ worktreePath: worktree.path,
1291
+ codexBranch: worktree.branch,
1292
+ tier: assessment.tier,
1293
+ model: assessment.model,
1294
+ };
1295
+
1296
+ activeRuns.add(issue.id);
1297
+
1298
+ // Update status to running
1299
+ await updateDispatchStatus(identifier, "running", statePath);
1300
+
1301
+ runFullPipeline(pipelineCtx)
1302
+ .then(async () => {
1303
+ await completeDispatch(identifier, {
1304
+ tier: assessment.tier,
1305
+ status: "done",
1306
+ completedAt: new Date().toISOString(),
1307
+ }, statePath);
1308
+ api.logger.info(`@dispatch: pipeline completed for ${identifier}`);
1309
+ })
1310
+ .catch(async (err) => {
1311
+ api.logger.error(`@dispatch: pipeline failed for ${identifier}: ${err}`);
1312
+ await updateDispatchStatus(identifier, "failed", statePath);
1313
+ })
1314
+ .finally(() => {
1315
+ activeRuns.delete(issue.id);
1316
+ clearActiveSession(issue.id);
1317
+ });
1318
+ }