@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.
- package/LICENSE +21 -0
- package/dist/src/agent-client.d.ts +195 -0
- package/dist/src/agent-client.d.ts.map +1 -0
- package/dist/src/agent-client.js +548 -0
- package/dist/src/agent-session.d.ts +284 -0
- package/dist/src/agent-session.d.ts.map +1 -0
- package/dist/src/agent-session.js +875 -0
- package/dist/src/checkbox-utils.d.ts +88 -0
- package/dist/src/checkbox-utils.d.ts.map +1 -0
- package/dist/src/checkbox-utils.js +120 -0
- package/dist/src/constants.d.ts +77 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +87 -0
- package/dist/src/errors.d.ts +79 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +155 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +16 -0
- package/dist/src/retry.d.ts +43 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +73 -0
- package/dist/src/types.d.ts +412 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +121 -0
- package/dist/src/utils.d.ts +52 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +277 -0
- package/package.json +59 -0
|
@@ -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
|
+
}
|