@supaku/agentfactory-linear 0.1.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.
@@ -0,0 +1,875 @@
1
+ import { WORK_TYPE_START_STATUS, WORK_TYPE_COMPLETE_STATUS, WORK_TYPE_FAIL_STATUS, } from './types';
2
+ import { LinearSessionError, LinearActivityError, LinearPlanError, } from './errors';
3
+ import { buildCompletionComments } from './utils';
4
+ import { DEFAULT_TEAM_ID, LINEAR_PROJECTS, LINEAR_LABELS, } from './constants';
5
+ import { parseCheckboxes, updateCheckboxes, } from './checkbox-utils';
6
+ function generatePlanItemId() {
7
+ return `plan-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
8
+ }
9
+ /**
10
+ * Agent Session Handler
11
+ * Manages the lifecycle of an agent working on a Linear issue
12
+ */
13
+ export class AgentSession {
14
+ client;
15
+ issueId;
16
+ autoTransition;
17
+ workType;
18
+ sessionId = null;
19
+ state = 'pending';
20
+ currentPlan = { items: [] };
21
+ issue = null;
22
+ activityLog = [];
23
+ constructor(config) {
24
+ this.client = config.client;
25
+ this.issueId = config.issueId;
26
+ this.sessionId = config.sessionId ?? null;
27
+ this.autoTransition = config.autoTransition ?? true;
28
+ this.workType = config.workType ?? 'development';
29
+ }
30
+ get currentState() {
31
+ return this.state;
32
+ }
33
+ get id() {
34
+ return this.sessionId;
35
+ }
36
+ get plan() {
37
+ return { ...this.currentPlan };
38
+ }
39
+ get activities() {
40
+ return [...this.activityLog];
41
+ }
42
+ /**
43
+ * Add or update an external URL for the session
44
+ * External URLs appear in the Linear issue view, linking to dashboards, logs, or PRs
45
+ *
46
+ * @param label - Display label for the URL (e.g., "Pull Request", "Logs")
47
+ * @param url - The URL to link to
48
+ */
49
+ async addExternalUrl(label, url) {
50
+ if (!this.sessionId) {
51
+ throw new LinearSessionError('Cannot add external URL without a session ID. Call start() first or provide sessionId in config.', undefined, this.issueId);
52
+ }
53
+ try {
54
+ await this.client.updateAgentSession({
55
+ sessionId: this.sessionId,
56
+ externalUrls: [{ label, url }],
57
+ });
58
+ }
59
+ catch (error) {
60
+ throw new LinearSessionError(`Failed to add external URL: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId, this.issueId);
61
+ }
62
+ }
63
+ /**
64
+ * Set the pull request URL for this session
65
+ * This unlocks additional PR-related features in Linear
66
+ *
67
+ * @param prUrl - The GitHub pull request URL
68
+ * @see https://linear.app/developers/agents
69
+ */
70
+ async setPullRequestUrl(prUrl) {
71
+ await this.addExternalUrl('Pull Request', prUrl);
72
+ }
73
+ /**
74
+ * Start the agent session
75
+ *
76
+ * Transitions issue status based on work type:
77
+ * - development: Backlog -> Started
78
+ * - Other work types: No transition on start (issue stays in current status)
79
+ */
80
+ async start() {
81
+ try {
82
+ this.issue = await this.client.getIssue(this.issueId);
83
+ if (!this.sessionId) {
84
+ this.sessionId = `session-${this.issueId}-${Date.now()}`;
85
+ }
86
+ this.state = 'active';
87
+ // Transition based on work type
88
+ const startStatus = WORK_TYPE_START_STATUS[this.workType];
89
+ if (this.autoTransition && startStatus) {
90
+ await this.client.updateIssueStatus(this.issueId, startStatus);
91
+ }
92
+ return { success: true, sessionId: this.sessionId };
93
+ }
94
+ catch (error) {
95
+ this.state = 'error';
96
+ throw new LinearSessionError(`Failed to start session: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined, this.issueId);
97
+ }
98
+ }
99
+ /**
100
+ * Mark session as awaiting user input
101
+ */
102
+ async awaitInput(prompt) {
103
+ this.state = 'awaitingInput';
104
+ await this.emitActivity({
105
+ type: 'response',
106
+ content: { text: `Awaiting input: ${prompt}` },
107
+ });
108
+ }
109
+ /**
110
+ * Complete the session successfully
111
+ *
112
+ * Transitions issue status based on work type:
113
+ * - development/inflight: Started -> Finished
114
+ * - qa: Finished -> Delivered (only if workResult === 'passed')
115
+ * - acceptance: Delivered -> Accepted (only if workResult === 'passed')
116
+ * - refinement: Rejected -> Backlog
117
+ * - research: No transition (user decides when to move to Backlog)
118
+ *
119
+ * @param summary - Optional completion summary to post as a comment
120
+ * @param workResult - For QA/acceptance: 'passed' promotes, 'failed' transitions to fail status, undefined skips transition
121
+ */
122
+ async complete(summary, workResult) {
123
+ try {
124
+ this.state = 'complete';
125
+ this.currentPlan.items = this.currentPlan.items.map((item) => ({
126
+ ...item,
127
+ state: item.state === 'pending' || item.state === 'inProgress'
128
+ ? 'completed'
129
+ : item.state,
130
+ }));
131
+ if (summary) {
132
+ await this.postCompletionComment(summary);
133
+ }
134
+ // Sync all remaining description checkboxes to complete
135
+ try {
136
+ const checkboxes = await this.getDescriptionCheckboxes();
137
+ const unchecked = checkboxes.filter((cb) => !cb.checked);
138
+ if (unchecked.length > 0) {
139
+ await this.updateDescriptionCheckboxes(unchecked.map((cb) => ({ textPattern: cb.text, checked: true })));
140
+ }
141
+ }
142
+ catch (error) {
143
+ // Log but don't fail completion - checkbox sync is non-critical
144
+ console.warn('[AgentSession] Failed to sync description checkboxes on complete:', error instanceof Error ? error.message : String(error));
145
+ }
146
+ // Transition based on work type
147
+ if (this.autoTransition) {
148
+ const isResultSensitive = this.workType === 'qa' || this.workType === 'acceptance';
149
+ if (isResultSensitive) {
150
+ // For QA/acceptance: only transition if workResult is explicitly set
151
+ if (workResult === 'passed') {
152
+ const completeStatus = WORK_TYPE_COMPLETE_STATUS[this.workType];
153
+ if (completeStatus) {
154
+ await this.client.updateIssueStatus(this.issueId, completeStatus);
155
+ }
156
+ }
157
+ else if (workResult === 'failed') {
158
+ const failStatus = WORK_TYPE_FAIL_STATUS[this.workType];
159
+ if (failStatus) {
160
+ await this.client.updateIssueStatus(this.issueId, failStatus);
161
+ }
162
+ }
163
+ // undefined workResult -> skip transition (safe default)
164
+ }
165
+ else {
166
+ // Non-QA/acceptance: unchanged behavior
167
+ const completeStatus = WORK_TYPE_COMPLETE_STATUS[this.workType];
168
+ if (completeStatus) {
169
+ await this.client.updateIssueStatus(this.issueId, completeStatus);
170
+ }
171
+ }
172
+ }
173
+ return { success: true, sessionId: this.sessionId ?? undefined };
174
+ }
175
+ catch (error) {
176
+ throw new LinearSessionError(`Failed to complete session: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined, this.issueId);
177
+ }
178
+ }
179
+ /**
180
+ * Mark session as failed
181
+ * Emits an error activity (auto-generates comment) if session ID is available,
182
+ * otherwise falls back to creating a comment directly.
183
+ */
184
+ async fail(errorMessage) {
185
+ try {
186
+ this.state = 'error';
187
+ this.currentPlan.items = this.currentPlan.items.map((item) => ({
188
+ ...item,
189
+ state: item.state === 'inProgress'
190
+ ? 'canceled'
191
+ : item.state,
192
+ }));
193
+ // Use error activity if we have a session ID (auto-generates comment)
194
+ // Otherwise fall back to direct comment
195
+ if (this.sessionId) {
196
+ await this.createActivity({
197
+ type: 'error',
198
+ body: `**Agent Error**\n\n${errorMessage}\n\n---\n*Session ID: ${this.sessionId}*`,
199
+ }, false // not ephemeral - errors should persist
200
+ );
201
+ }
202
+ else {
203
+ await this.client.createComment(this.issueId, `## Agent Error\n\n${errorMessage}\n\n---\n*Session ID: ${this.sessionId}*`);
204
+ }
205
+ return { success: true, sessionId: this.sessionId ?? undefined };
206
+ }
207
+ catch (error) {
208
+ throw new LinearSessionError(`Failed to mark session as failed: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined, this.issueId);
209
+ }
210
+ }
211
+ /**
212
+ * Emit a generic activity (legacy method for backward compatibility)
213
+ * @deprecated Use createActivity for native Linear Agent API
214
+ */
215
+ async emitActivity(options) {
216
+ try {
217
+ this.activityLog.push({
218
+ type: options.type,
219
+ timestamp: new Date(),
220
+ content: options.content.text,
221
+ });
222
+ if (!options.ephemeral && options.type === 'response') {
223
+ await this.client.createComment(this.issueId, this.formatActivityAsComment(options));
224
+ }
225
+ }
226
+ catch (error) {
227
+ throw new LinearActivityError(`Failed to emit activity: ${error instanceof Error ? error.message : 'Unknown error'}`, options.type, this.sessionId ?? undefined);
228
+ }
229
+ }
230
+ /**
231
+ * Create an activity using the native Linear Agent API
232
+ *
233
+ * @param content - The activity content payload
234
+ * @param ephemeral - Whether the activity should disappear after the next activity
235
+ * @param signal - Optional modifier for how the activity should be interpreted
236
+ * @returns Result containing success status and activity ID
237
+ */
238
+ async createActivity(content, ephemeral = false, signal) {
239
+ if (!this.sessionId) {
240
+ throw new LinearActivityError('Cannot create activity without a session ID. Call start() first or provide sessionId in config.', content.type, undefined);
241
+ }
242
+ try {
243
+ const contentText = content.type === 'action'
244
+ ? `${content.action}: ${content.parameter}`
245
+ : content.body;
246
+ this.activityLog.push({
247
+ type: content.type,
248
+ timestamp: new Date(),
249
+ content: contentText,
250
+ });
251
+ const result = await this.client.createAgentActivity({
252
+ agentSessionId: this.sessionId,
253
+ content,
254
+ ephemeral,
255
+ signal,
256
+ });
257
+ return result;
258
+ }
259
+ catch (error) {
260
+ throw new LinearActivityError(`Failed to create activity: ${error instanceof Error ? error.message : 'Unknown error'}`, content.type, this.sessionId);
261
+ }
262
+ }
263
+ /**
264
+ * Emit a thought activity (persistent by default for visibility in Linear)
265
+ */
266
+ async emitThought(text, ephemeral = false) {
267
+ if (this.sessionId) {
268
+ await this.createActivity({ type: 'thought', body: text }, ephemeral);
269
+ }
270
+ else {
271
+ await this.emitActivity({
272
+ type: 'thought',
273
+ content: { text },
274
+ ephemeral,
275
+ });
276
+ }
277
+ }
278
+ /**
279
+ * Emit an action activity (tool call)
280
+ */
281
+ async emitAction(toolName, input, ephemeral = true) {
282
+ if (this.sessionId) {
283
+ await this.createActivity({
284
+ type: 'action',
285
+ action: toolName,
286
+ parameter: JSON.stringify(input),
287
+ }, ephemeral);
288
+ }
289
+ else {
290
+ await this.emitActivity({
291
+ type: 'action',
292
+ content: {
293
+ text: `Calling ${toolName}`,
294
+ metadata: { toolName, input },
295
+ },
296
+ ephemeral,
297
+ signals: { toolName, toolInput: input },
298
+ });
299
+ }
300
+ }
301
+ /**
302
+ * Emit a tool result activity
303
+ */
304
+ async emitToolResult(toolName, output, ephemeral = true) {
305
+ if (this.sessionId) {
306
+ await this.createActivity({
307
+ type: 'action',
308
+ action: toolName,
309
+ parameter: 'result',
310
+ result: typeof output === 'string' ? output : JSON.stringify(output, null, 2),
311
+ }, ephemeral);
312
+ }
313
+ else {
314
+ await this.emitActivity({
315
+ type: 'action',
316
+ content: {
317
+ text: `Result from ${toolName}`,
318
+ metadata: { toolName, output },
319
+ },
320
+ ephemeral,
321
+ signals: { toolName, toolOutput: output },
322
+ });
323
+ }
324
+ }
325
+ /**
326
+ * Emit a response activity (persisted)
327
+ */
328
+ async emitResponse(text) {
329
+ if (this.sessionId) {
330
+ await this.createActivity({ type: 'response', body: text }, false);
331
+ }
332
+ else {
333
+ await this.emitActivity({
334
+ type: 'response',
335
+ content: { text },
336
+ ephemeral: false,
337
+ });
338
+ }
339
+ }
340
+ /**
341
+ * Emit an error activity using native API
342
+ */
343
+ async emitError(error) {
344
+ if (this.sessionId) {
345
+ await this.createActivity({
346
+ type: 'error',
347
+ body: `**${error.name}**: ${error.message}${error.stack ? `\n\n\`\`\`\n${error.stack}\n\`\`\`` : ''}`,
348
+ }, false);
349
+ }
350
+ else {
351
+ await this.emitActivity({
352
+ type: 'response',
353
+ content: {
354
+ text: `Error: ${error.message}`,
355
+ metadata: {
356
+ errorName: error.name,
357
+ errorStack: error.stack,
358
+ },
359
+ },
360
+ ephemeral: false,
361
+ signals: {
362
+ error: {
363
+ message: error.message,
364
+ stack: error.stack,
365
+ },
366
+ },
367
+ });
368
+ }
369
+ }
370
+ /**
371
+ * Emit an elicitation activity - asking for clarification from the user
372
+ */
373
+ async emitElicitation(text, ephemeral = false) {
374
+ if (this.sessionId) {
375
+ return this.createActivity({ type: 'elicitation', body: text }, ephemeral);
376
+ }
377
+ else {
378
+ await this.emitActivity({
379
+ type: 'response',
380
+ content: { text: `Awaiting clarification: ${text}` },
381
+ ephemeral,
382
+ });
383
+ }
384
+ }
385
+ /**
386
+ * Emit a prompt activity - prompts/instructions for the user
387
+ */
388
+ async emitPrompt(text, ephemeral = false) {
389
+ if (this.sessionId) {
390
+ return this.createActivity({ type: 'prompt', body: text }, ephemeral);
391
+ }
392
+ else {
393
+ await this.emitActivity({
394
+ type: 'response',
395
+ content: { text },
396
+ ephemeral,
397
+ });
398
+ }
399
+ }
400
+ /**
401
+ * Emit an authentication required activity
402
+ * Shows an authentication prompt to the user with a link to authorize
403
+ *
404
+ * @param authUrl - The URL the user should visit to authenticate
405
+ * @param providerName - Optional name of the auth provider (e.g., "GitHub", "Google")
406
+ * @param body - Optional custom message body
407
+ * @returns Activity result with ID
408
+ *
409
+ * @see https://linear.app/developers/agent-signals
410
+ */
411
+ async emitAuthRequired(authUrl, providerName, body) {
412
+ if (!this.sessionId) {
413
+ throw new LinearActivityError('Cannot emit auth activity without a session ID. Call start() first or provide sessionId in config.', 'elicitation', undefined);
414
+ }
415
+ const messageBody = body
416
+ ?? `Authentication required${providerName ? ` with ${providerName}` : ''}. Please [click here](${authUrl}) to authorize.`;
417
+ return this.client.createAgentActivity({
418
+ agentSessionId: this.sessionId,
419
+ content: { type: 'elicitation', body: messageBody },
420
+ ephemeral: false,
421
+ signal: 'auth',
422
+ });
423
+ }
424
+ /**
425
+ * Emit a selection prompt activity
426
+ * Shows a multiple choice selection to the user
427
+ *
428
+ * @param prompt - The question or prompt for the user
429
+ * @param options - Array of option strings the user can select from
430
+ * @returns Activity result with ID
431
+ *
432
+ * @see https://linear.app/developers/agent-signals
433
+ */
434
+ async emitSelect(prompt, options) {
435
+ if (!this.sessionId) {
436
+ throw new LinearActivityError('Cannot emit select activity without a session ID. Call start() first or provide sessionId in config.', 'elicitation', undefined);
437
+ }
438
+ // Format options as numbered list in the body
439
+ const optionsList = options.map((opt, i) => `${i + 1}. ${opt}`).join('\n');
440
+ const body = `${prompt}\n\n${optionsList}`;
441
+ return this.client.createAgentActivity({
442
+ agentSessionId: this.sessionId,
443
+ content: { type: 'elicitation', body },
444
+ ephemeral: false,
445
+ signal: 'select',
446
+ });
447
+ }
448
+ /**
449
+ * Report an environment issue for self-improvement.
450
+ * Creates a bug in the Agent project backlog to track infrastructure improvements.
451
+ *
452
+ * This is a best-effort operation - failures are logged but don't propagate.
453
+ *
454
+ * @param title - Short description of the issue
455
+ * @param description - Detailed explanation of what happened
456
+ * @param context - Additional context about the issue
457
+ * @returns The created issue, or null if creation failed
458
+ */
459
+ async reportEnvironmentIssue(title, description, context) {
460
+ try {
461
+ const fullDescription = `## Environment Issue Report
462
+
463
+ ${description}
464
+
465
+ ### Context
466
+
467
+ | Field | Value |
468
+ |-------|-------|
469
+ | Source Issue | ${context?.sourceIssueId ?? this.issueId} |
470
+ | Session ID | ${this.sessionId ?? 'N/A'} |
471
+ | Issue Type | ${context?.issueType ?? 'unknown'} |
472
+ | Timestamp | ${new Date().toISOString()} |
473
+
474
+ ${context?.additionalContext ? `### Additional Details\n\n\`\`\`json\n${JSON.stringify(context.additionalContext, null, 2)}\n\`\`\`` : ''}
475
+
476
+ ${context?.errorStack ? `### Error Stack\n\n\`\`\`\n${context.errorStack}\n\`\`\`` : ''}
477
+
478
+ ---
479
+ *Auto-generated by agent self-improvement system*`;
480
+ const issue = await this.client.createIssue({
481
+ title: `Bug: [Agent Environment] ${title}`,
482
+ description: fullDescription,
483
+ teamId: DEFAULT_TEAM_ID,
484
+ projectId: LINEAR_PROJECTS.AGENT,
485
+ labelIds: [LINEAR_LABELS.BUG],
486
+ });
487
+ return {
488
+ id: issue.id,
489
+ identifier: issue.identifier,
490
+ url: issue.url,
491
+ };
492
+ }
493
+ catch (error) {
494
+ // Log but don't throw - this is a best-effort feature
495
+ console.error('[AgentSession] Failed to report environment issue:', error instanceof Error ? error.message : String(error));
496
+ return null;
497
+ }
498
+ }
499
+ // ============================================================================
500
+ // SUB-ISSUE SESSION METHODS (for coordination work type)
501
+ // ============================================================================
502
+ /**
503
+ * Create an agent session on a sub-issue for activity reporting
504
+ *
505
+ * The coordinator uses this to emit activities to individual sub-issue threads,
506
+ * making sub-agent progress visible on each sub-issue in Linear.
507
+ *
508
+ * @param subIssueId - The sub-issue ID (UUID) to create a session on
509
+ * @returns The session ID for the sub-issue, or null if creation failed
510
+ */
511
+ async createSubIssueSession(subIssueId) {
512
+ try {
513
+ const result = await this.client.createAgentSessionOnIssue({
514
+ issueId: subIssueId,
515
+ });
516
+ if (result.success && result.sessionId) {
517
+ return result.sessionId;
518
+ }
519
+ console.warn(`[AgentSession] Failed to create sub-issue session for ${subIssueId}:`, result);
520
+ return null;
521
+ }
522
+ catch (error) {
523
+ console.warn('[AgentSession] Error creating sub-issue session:', error instanceof Error ? error.message : String(error));
524
+ return null;
525
+ }
526
+ }
527
+ /**
528
+ * Emit an activity to a sub-issue's agent session
529
+ *
530
+ * Used by the coordinator to report progress on individual sub-issues.
531
+ * Falls back to creating a comment if the activity emission fails.
532
+ *
533
+ * @param subIssueSessionId - The agent session ID for the sub-issue
534
+ * @param content - The activity content to emit
535
+ * @param ephemeral - Whether the activity is ephemeral (default: false)
536
+ */
537
+ async emitSubIssueActivity(subIssueSessionId, content, ephemeral = false) {
538
+ try {
539
+ await this.client.createAgentActivity({
540
+ agentSessionId: subIssueSessionId,
541
+ content,
542
+ ephemeral,
543
+ });
544
+ }
545
+ catch (error) {
546
+ console.warn('[AgentSession] Failed to emit sub-issue activity:', error instanceof Error ? error.message : String(error));
547
+ }
548
+ }
549
+ // ============================================================================
550
+ // DESCRIPTION CHECKBOX METHODS
551
+ // ============================================================================
552
+ /**
553
+ * Get the current issue description
554
+ * Refreshes the issue data from Linear if needed
555
+ */
556
+ async getDescription() {
557
+ if (!this.issue) {
558
+ this.issue = await this.client.getIssue(this.issueId);
559
+ }
560
+ return this.issue?.description ?? undefined;
561
+ }
562
+ /**
563
+ * Parse checkboxes from the issue description
564
+ *
565
+ * @returns Array of checkbox items, or empty array if no description
566
+ */
567
+ async getDescriptionCheckboxes() {
568
+ const description = await this.getDescription();
569
+ if (!description)
570
+ return [];
571
+ return parseCheckboxes(description);
572
+ }
573
+ /**
574
+ * Update checkboxes in the issue description
575
+ *
576
+ * @param updates - Array of updates to apply
577
+ * @returns The updated issue, or null if no changes were made
578
+ */
579
+ async updateDescriptionCheckboxes(updates) {
580
+ const description = await this.getDescription();
581
+ if (!description)
582
+ return null;
583
+ const newDescription = updateCheckboxes(description, updates);
584
+ if (newDescription === description)
585
+ return null; // No changes
586
+ const updatedIssue = await this.client.updateIssue(this.issueId, {
587
+ description: newDescription,
588
+ });
589
+ // Update local cache
590
+ this.issue = updatedIssue;
591
+ return updatedIssue;
592
+ }
593
+ /**
594
+ * Mark a specific task as complete in the issue description
595
+ *
596
+ * @param textPattern - String or regex to match the task text
597
+ * @returns The updated issue, or null if task not found
598
+ */
599
+ async completeDescriptionTask(textPattern) {
600
+ return this.updateDescriptionCheckboxes([{ textPattern, checked: true }]);
601
+ }
602
+ /**
603
+ * Mark a specific task as incomplete in the issue description
604
+ *
605
+ * @param textPattern - String or regex to match the task text
606
+ * @returns The updated issue, or null if task not found
607
+ */
608
+ async uncompleteDescriptionTask(textPattern) {
609
+ return this.updateDescriptionCheckboxes([{ textPattern, checked: false }]);
610
+ }
611
+ // ============================================================================
612
+ // ISSUE RELATION CONVENIENCE METHODS
613
+ // ============================================================================
614
+ /**
615
+ * Link this issue as related to another issue
616
+ *
617
+ * @param relatedIssueId - The issue ID or identifier to link to
618
+ * @returns Result with relation ID, or null if relation already exists
619
+ */
620
+ async linkRelatedIssue(relatedIssueId) {
621
+ // Check if relation already exists
622
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
623
+ const alreadyLinked = existingRelations.relations.some((r) => r.type === 'related' &&
624
+ (r.relatedIssueId === relatedIssueId ||
625
+ r.relatedIssueIdentifier === relatedIssueId));
626
+ if (alreadyLinked) {
627
+ return null; // Already linked
628
+ }
629
+ return this.client.createIssueRelation({
630
+ issueId: this.issueId,
631
+ relatedIssueId,
632
+ type: 'related',
633
+ });
634
+ }
635
+ /**
636
+ * Mark this issue as blocked by another issue
637
+ *
638
+ * @param blockingIssueId - The issue ID or identifier that blocks this one
639
+ * @returns Result with relation ID, or null if relation already exists
640
+ */
641
+ async markAsBlockedBy(blockingIssueId) {
642
+ // Check if relation already exists
643
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
644
+ const alreadyBlocked = existingRelations.inverseRelations.some((r) => r.type === 'blocks' &&
645
+ (r.issueId === blockingIssueId || r.issueIdentifier === blockingIssueId));
646
+ if (alreadyBlocked) {
647
+ return null; // Already blocked by this issue
648
+ }
649
+ // The blocking issue blocks this issue
650
+ return this.client.createIssueRelation({
651
+ issueId: blockingIssueId,
652
+ relatedIssueId: this.issueId,
653
+ type: 'blocks',
654
+ });
655
+ }
656
+ /**
657
+ * Mark this issue as blocking another issue
658
+ *
659
+ * @param blockedIssueId - The issue ID or identifier that this issue blocks
660
+ * @returns Result with relation ID, or null if relation already exists
661
+ */
662
+ async markAsBlocking(blockedIssueId) {
663
+ // Check if relation already exists
664
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
665
+ const alreadyBlocking = existingRelations.relations.some((r) => r.type === 'blocks' &&
666
+ (r.relatedIssueId === blockedIssueId ||
667
+ r.relatedIssueIdentifier === blockedIssueId));
668
+ if (alreadyBlocking) {
669
+ return null; // Already blocking this issue
670
+ }
671
+ // This issue blocks the other issue
672
+ return this.client.createIssueRelation({
673
+ issueId: this.issueId,
674
+ relatedIssueId: blockedIssueId,
675
+ type: 'blocks',
676
+ });
677
+ }
678
+ /**
679
+ * Mark this issue as a duplicate of another issue
680
+ *
681
+ * @param originalIssueId - The original issue ID or identifier
682
+ * @returns Result with relation ID, or null if relation already exists
683
+ */
684
+ async markAsDuplicateOf(originalIssueId) {
685
+ // Check if relation already exists
686
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
687
+ const alreadyDuplicate = existingRelations.relations.some((r) => r.type === 'duplicate' &&
688
+ (r.relatedIssueId === originalIssueId ||
689
+ r.relatedIssueIdentifier === originalIssueId));
690
+ if (alreadyDuplicate) {
691
+ return null; // Already marked as duplicate
692
+ }
693
+ // This issue is a duplicate of the original
694
+ return this.client.createIssueRelation({
695
+ issueId: this.issueId,
696
+ relatedIssueId: originalIssueId,
697
+ type: 'duplicate',
698
+ });
699
+ }
700
+ /**
701
+ * Get issues that are blocking this issue
702
+ *
703
+ * @returns Array of relation info for blocking issues
704
+ */
705
+ async getBlockers() {
706
+ const relations = await this.client.getIssueRelations(this.issueId);
707
+ // Blockers are inverse relations where another issue blocks this one
708
+ return relations.inverseRelations.filter((r) => r.type === 'blocks');
709
+ }
710
+ /**
711
+ * Check if this issue is blocked by any other issues
712
+ *
713
+ * @returns True if blocked, false otherwise
714
+ */
715
+ async isBlocked() {
716
+ const blockers = await this.getBlockers();
717
+ return blockers.length > 0;
718
+ }
719
+ /**
720
+ * Update the agent's plan (full replacement)
721
+ *
722
+ * Uses Linear's native agentSessionUpdate mutation to display the plan
723
+ * as checkboxes in the Linear UI. Also maintains internal plan state
724
+ * for checkbox sync and completion tracking.
725
+ */
726
+ async updatePlan(items) {
727
+ try {
728
+ // Store internal plan with IDs for backward compatibility and checkbox sync
729
+ this.currentPlan = {
730
+ items: items.map((item) => ({
731
+ ...item,
732
+ id: generatePlanItemId(),
733
+ children: item.children?.map((child) => ({
734
+ ...child,
735
+ id: generatePlanItemId(),
736
+ })),
737
+ })),
738
+ };
739
+ // Flatten the plan for Linear's native API (no nested children)
740
+ const linearPlan = this.flattenPlanItems(items);
741
+ // Only update via API if we have a session ID
742
+ if (this.sessionId) {
743
+ await this.client.updateAgentSession({
744
+ sessionId: this.sessionId,
745
+ plan: linearPlan,
746
+ });
747
+ }
748
+ }
749
+ catch (error) {
750
+ throw new LinearPlanError(`Failed to update plan: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined);
751
+ }
752
+ }
753
+ /**
754
+ * Flatten nested plan items into Linear's flat format
755
+ * Converts: { title, state, children } -> { content, status }
756
+ */
757
+ flattenPlanItems(items) {
758
+ const result = [];
759
+ for (const item of items) {
760
+ // Add parent item
761
+ result.push({
762
+ content: item.details ? `${item.title} - ${item.details}` : item.title,
763
+ status: item.state,
764
+ });
765
+ // Add children as indented items (Linear shows as sub-tasks)
766
+ if (item.children && item.children.length > 0) {
767
+ for (const child of item.children) {
768
+ result.push({
769
+ content: child.details ? ` ${child.title} - ${child.details}` : ` ${child.title}`,
770
+ status: child.state,
771
+ });
772
+ }
773
+ }
774
+ }
775
+ return result;
776
+ }
777
+ /**
778
+ * Update a single plan item's state
779
+ * Also updates the plan in Linear's native API and syncs description checkboxes
780
+ */
781
+ async updatePlanItemState(itemId, state) {
782
+ // Find the item to get its title for checkbox sync
783
+ let itemTitle;
784
+ const updateItemState = (items) => {
785
+ return items.map((item) => {
786
+ if (item.id === itemId) {
787
+ itemTitle = item.title;
788
+ return { ...item, state };
789
+ }
790
+ if (item.children) {
791
+ const updatedChildren = updateItemState(item.children);
792
+ // Check if we found the item in children
793
+ if (!itemTitle) {
794
+ const foundChild = item.children.find((c) => c.id === itemId);
795
+ if (foundChild) {
796
+ itemTitle = foundChild.title;
797
+ }
798
+ }
799
+ return { ...item, children: updatedChildren };
800
+ }
801
+ return item;
802
+ });
803
+ };
804
+ this.currentPlan.items = updateItemState(this.currentPlan.items);
805
+ // Push updated plan to Linear
806
+ if (this.sessionId) {
807
+ try {
808
+ const linearPlan = this.flattenPlanItems(this.currentPlan.items);
809
+ await this.client.updateAgentSession({
810
+ sessionId: this.sessionId,
811
+ plan: linearPlan,
812
+ });
813
+ }
814
+ catch (error) {
815
+ // Log but don't throw - individual state updates are non-critical
816
+ console.warn('[AgentSession] Failed to update plan state in Linear:', error instanceof Error ? error.message : String(error));
817
+ }
818
+ }
819
+ // Sync description checkboxes when plan item completes
820
+ if (state === 'completed' && itemTitle) {
821
+ try {
822
+ await this.completeDescriptionTask(itemTitle);
823
+ }
824
+ catch (error) {
825
+ // Log but don't throw - checkbox sync is non-critical
826
+ console.warn('[AgentSession] Failed to sync description checkbox:', error instanceof Error ? error.message : String(error));
827
+ }
828
+ }
829
+ }
830
+ /**
831
+ * Create a plan item helper
832
+ */
833
+ createPlanItem(title, state = 'pending', details) {
834
+ return { title, state, details };
835
+ }
836
+ formatActivityAsComment(options) {
837
+ const typeEmoji = {
838
+ thought: '\u{1F4AD}',
839
+ action: '\u{26A1}',
840
+ response: '\u{1F4AC}',
841
+ elicitation: '\u{2753}',
842
+ error: '\u{274C}',
843
+ prompt: '\u{1F4DD}',
844
+ };
845
+ const emoji = typeEmoji[options.type] ?? '\u{1F4AC}';
846
+ return `${emoji} **${options.type.charAt(0).toUpperCase() + options.type.slice(1)}**\n\n${options.content.text}`;
847
+ }
848
+ async postCompletionComment(summary) {
849
+ const planItems = this.currentPlan.items.map((item) => ({
850
+ state: item.state,
851
+ title: item.title,
852
+ }));
853
+ const comments = buildCompletionComments(summary, planItems, this.sessionId);
854
+ // Post comments sequentially to maintain order
855
+ for (const chunk of comments) {
856
+ try {
857
+ await this.client.createComment(this.issueId, chunk.body);
858
+ // Small delay between comments to ensure ordering
859
+ if (chunk.partNumber < chunk.totalParts) {
860
+ await new Promise(resolve => setTimeout(resolve, 100));
861
+ }
862
+ }
863
+ catch (error) {
864
+ // Log and continue with remaining comments
865
+ console.error(`[AgentSession] Failed to post completion comment part ${chunk.partNumber}/${chunk.totalParts}:`, error instanceof Error ? error.message : String(error));
866
+ }
867
+ }
868
+ }
869
+ }
870
+ /**
871
+ * Create a new agent session
872
+ */
873
+ export function createAgentSession(config) {
874
+ return new AgentSession(config);
875
+ }