@band-ai/sdk 0.1.6 → 0.2.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/README.md +372 -0
- package/dist/{ClaudeSDKAdapter-CRq4R-WB.d.cts → ClaudeSDKAdapter-BY4zEbCl.d.cts} +14 -10
- package/dist/{ClaudeSDKAdapter-BHOkjN3X.d.ts → ClaudeSDKAdapter-Cna5P1dR.d.ts} +14 -10
- package/dist/{acp-server-B1Da7L6u.d.ts → acp-server-CiUqN3G3.d.ts} +2 -2
- package/dist/{acp-server-BdRz9_UN.d.cts → acp-server-Dlj7D563.d.cts} +2 -2
- package/dist/adapters.cjs +734 -279
- package/dist/adapters.d.cts +21 -19
- package/dist/adapters.d.ts +21 -19
- package/dist/adapters.js +65 -44
- package/dist/backends-D-dBdDm-.d.ts +27 -0
- package/dist/backends-ZLxmQrTZ.d.cts +27 -0
- package/dist/{chunk-MSL6CWHY.js → chunk-4IAXZBXX.js} +77 -37
- package/dist/{chunk-SG4WLD2H.js → chunk-EAI2M3WM.js} +38 -94
- package/dist/{chunk-YR3BOQNW.js → chunk-JY75VSQK.js} +7 -7
- package/dist/{chunk-INA6YZTZ.js → chunk-LBWKISBS.js} +51 -31
- package/dist/{chunk-I5EZG52P.js → chunk-N2QTUMGO.js} +532 -327
- package/dist/{chunk-RHVH2GZR.js → chunk-NMZLX7TP.js} +110 -56
- package/dist/{chunk-EDPKUUJL.js → chunk-NT7TBFX2.js} +9 -1
- package/dist/{chunk-GVEQ3RAH.js → chunk-RV76SKFT.js} +169 -54
- package/dist/{chunk-W5NDZSYE.js → chunk-TLBCKDQO.js} +23 -23
- package/dist/chunk-US226AS4.js +130 -0
- package/dist/{chunk-IZPV6QS6.js → chunk-WL2HNJZ6.js} +38 -11
- package/dist/chunk-WLCM2OXG.js +181 -0
- package/dist/config.cjs +40 -13
- package/dist/config.d.cts +7 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.js +2 -2
- package/dist/converters.cjs +491 -35
- package/dist/converters.d.cts +5 -5
- package/dist/converters.d.ts +5 -5
- package/dist/converters.js +4 -3
- package/dist/core.cjs +53 -10
- package/dist/core.d.cts +10 -9
- package/dist/core.d.ts +10 -9
- package/dist/core.js +8 -4
- package/dist/disconnectReason-IuADl6TH.d.cts +34 -0
- package/dist/disconnectReason-IuADl6TH.d.ts +34 -0
- package/dist/{dtos-DUpbIu8k.d.ts → dtos-B8KU_q4d.d.cts} +26 -6
- package/dist/{dtos-DUpbIu8k.d.cts → dtos-B8KU_q4d.d.ts} +26 -6
- package/dist/index.cjs +1510 -735
- package/dist/index.d.cts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +21 -13
- package/dist/linear.cjs +1044 -112
- package/dist/linear.d.cts +146 -16
- package/dist/linear.d.ts +146 -16
- package/dist/linear.js +1031 -110
- package/dist/mcp-claude.cjs +338 -66
- package/dist/mcp-claude.d.cts +3 -3
- package/dist/mcp-claude.d.ts +3 -3
- package/dist/mcp-claude.js +215 -16
- package/dist/mcp.cjs +414 -105
- package/dist/mcp.d.cts +29 -16
- package/dist/mcp.d.ts +29 -16
- package/dist/mcp.js +14 -14
- package/dist/{opencode-BBcDchcN.d.cts → opencode-2YdscIc1.d.cts} +5 -5
- package/dist/{opencode-DthQQUsZ.d.ts → opencode-Bld62x5t.d.ts} +5 -5
- package/dist/{pagination-CcDkVFxK.d.ts → pagination-BgEx-HeL.d.ts} +2 -2
- package/dist/{pagination-BPiys10t.d.cts → pagination-DqdkQArT.d.cts} +2 -2
- package/dist/{protocols-DrhzOH6K.d.ts → protocols-Bwwcql0h.d.ts} +1 -1
- package/dist/{protocols-CInhtFpA.d.cts → protocols-Bz1rxcUg.d.cts} +1 -1
- package/dist/rest.cjs +41 -97
- package/dist/rest.d.cts +7 -7
- package/dist/rest.d.ts +7 -7
- package/dist/rest.js +2 -2
- package/dist/runtime.cjs +998 -620
- package/dist/runtime.d.cts +22 -29
- package/dist/runtime.d.ts +22 -29
- package/dist/runtime.js +19 -67
- package/dist/{schemas-BpEBkq5V.d.cts → schemas-BotL3ZLH.d.cts} +51 -56
- package/dist/{schemas-vos1AaBc.d.ts → schemas-DBsC4J1g.d.ts} +51 -56
- package/dist/{sdk-CwTAxhwI.d.cts → sdk-BTciSLRQ.d.cts} +35 -5
- package/dist/{sdk-CcnQ5OUC.d.ts → sdk-qKJtMma_.d.ts} +35 -5
- package/dist/{simpleAdapter-9OE_p6QW.d.ts → simpleAdapter--wznuoOw.d.ts} +1 -1
- package/dist/{simpleAdapter-DqhvjsGr.d.cts → simpleAdapter-BpT4XbZC.d.cts} +1 -1
- package/dist/testing.d.cts +3 -3
- package/dist/testing.d.ts +3 -3
- package/dist/{types-7jVzOeOP.d.ts → types-BTN_ZETM.d.ts} +2 -2
- package/dist/{types-CxII3XkA.d.ts → types-BgYFd_Yw.d.cts} +5 -5
- package/dist/{types-CSHFipIc.d.cts → types-BivGO7I9.d.ts} +5 -5
- package/dist/{types-CovcHpYf.d.cts → types-D958TC71.d.cts} +2 -2
- package/package.json +25 -23
- package/dist/backends-CzDbiQar.d.ts +0 -20
- package/dist/backends-SVMT-7l4.d.cts +0 -20
- package/dist/chunk-SFVKOEQH.js +0 -103
package/dist/linear.js
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-OD2G5LFQ.js";
|
|
4
4
|
import {
|
|
5
5
|
UnsupportedFeatureError
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-JY75VSQK.js";
|
|
7
7
|
|
|
8
8
|
// src/integrations/linear/bridge/handler.ts
|
|
9
9
|
import { LinearDocument as L2 } from "@linear/sdk";
|
|
@@ -28,20 +28,24 @@ function dedupeHandles(handles) {
|
|
|
28
28
|
|
|
29
29
|
// src/integrations/linear/activities.ts
|
|
30
30
|
import { LinearDocument as L } from "@linear/sdk";
|
|
31
|
-
|
|
31
|
+
var ELICITATION_BODY_MAX_LENGTH = 1e4;
|
|
32
|
+
var SELECT_OPTION_MAX_LENGTH = 200;
|
|
33
|
+
var PROVIDER_MAX_LENGTH = 100;
|
|
34
|
+
async function postActivity(client, sessionId, content, options) {
|
|
32
35
|
await client.createAgentActivity({
|
|
33
36
|
agentSessionId: sessionId,
|
|
34
|
-
content
|
|
37
|
+
content,
|
|
38
|
+
...options?.ephemeral ? { ephemeral: true } : {}
|
|
35
39
|
});
|
|
36
40
|
}
|
|
37
|
-
async function postBodyActivity(client, sessionId, type, body) {
|
|
38
|
-
await postActivity(client, sessionId, { type, body });
|
|
41
|
+
async function postBodyActivity(client, sessionId, type, body, options) {
|
|
42
|
+
await postActivity(client, sessionId, { type, body }, options);
|
|
39
43
|
}
|
|
40
|
-
async function postThought(client, sessionId, body) {
|
|
41
|
-
await postBodyActivity(client, sessionId, L.AgentActivityType.Thought, body);
|
|
44
|
+
async function postThought(client, sessionId, body, options) {
|
|
45
|
+
await postBodyActivity(client, sessionId, L.AgentActivityType.Thought, body, options);
|
|
42
46
|
}
|
|
43
|
-
async function postError(client, sessionId, body) {
|
|
44
|
-
await postBodyActivity(client, sessionId, L.AgentActivityType.Error, body);
|
|
47
|
+
async function postError(client, sessionId, body, options) {
|
|
48
|
+
await postBodyActivity(client, sessionId, L.AgentActivityType.Error, body, options);
|
|
45
49
|
}
|
|
46
50
|
async function postResponse(client, sessionId, body) {
|
|
47
51
|
await postBodyActivity(client, sessionId, L.AgentActivityType.Response, body);
|
|
@@ -49,14 +53,53 @@ async function postResponse(client, sessionId, body) {
|
|
|
49
53
|
async function postElicitation(client, sessionId, body) {
|
|
50
54
|
await postBodyActivity(client, sessionId, L.AgentActivityType.Elicitation, body);
|
|
51
55
|
}
|
|
52
|
-
async function
|
|
56
|
+
async function postSelectElicitation(client, sessionId, body, options) {
|
|
57
|
+
await postActivity(client, sessionId, {
|
|
58
|
+
type: L.AgentActivityType.Elicitation,
|
|
59
|
+
body,
|
|
60
|
+
signal: L.AgentActivitySignal.Select,
|
|
61
|
+
signalMetadata: { options }
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function postAuthElicitation(client, sessionId, body, url, provider) {
|
|
65
|
+
await postActivity(client, sessionId, {
|
|
66
|
+
type: L.AgentActivityType.Elicitation,
|
|
67
|
+
body,
|
|
68
|
+
signal: L.AgentActivitySignal.Auth,
|
|
69
|
+
signalMetadata: {
|
|
70
|
+
url,
|
|
71
|
+
...provider ? { provider } : {}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function postAction(client, sessionId, body, options) {
|
|
53
76
|
await postActivity(client, sessionId, {
|
|
54
77
|
type: L.AgentActivityType.Action,
|
|
55
78
|
action: body,
|
|
56
79
|
parameter: ""
|
|
57
|
-
});
|
|
80
|
+
}, options);
|
|
58
81
|
}
|
|
82
|
+
var PLAN_STATUS_MAP = {
|
|
83
|
+
pending: "pending",
|
|
84
|
+
in_progress: "inProgress",
|
|
85
|
+
completed: "completed",
|
|
86
|
+
failed: "canceled"
|
|
87
|
+
};
|
|
59
88
|
async function updatePlan(client, sessionId, steps) {
|
|
89
|
+
if (typeof client.updateAgentSession === "function") {
|
|
90
|
+
const plan = {
|
|
91
|
+
steps: steps.map((step) => ({
|
|
92
|
+
content: step.title,
|
|
93
|
+
status: PLAN_STATUS_MAP[step.status]
|
|
94
|
+
}))
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
await client.updateAgentSession(sessionId, { plan });
|
|
98
|
+
return;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn("updateAgentSession failed, falling back to legacy plan", err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
60
103
|
const planSummary = steps.map((step) => {
|
|
61
104
|
const icon = step.status === "completed" ? "\u2705" : step.status === "in_progress" ? "\u23F3" : step.status === "failed" ? "\u274C" : "\u2B1C";
|
|
62
105
|
return `${icon} ${step.title}`;
|
|
@@ -68,6 +111,141 @@ ${planSummary}`
|
|
|
68
111
|
});
|
|
69
112
|
}
|
|
70
113
|
|
|
114
|
+
// src/integrations/linear/types.ts
|
|
115
|
+
var STALE_SESSION_CHECK_INTERVAL_MS = 20 * 6e4;
|
|
116
|
+
var STALE_SESSION_THRESHOLD_MS = 25 * 6e4;
|
|
117
|
+
|
|
118
|
+
// src/integrations/linear/stale-session-guard.ts
|
|
119
|
+
var DEFAULT_KEEPALIVE_MESSAGE = "Still working \u2014 waiting for specialist output in the collaboration room.";
|
|
120
|
+
var StaleSessionGuard = class {
|
|
121
|
+
store;
|
|
122
|
+
linearClient;
|
|
123
|
+
logger;
|
|
124
|
+
checkIntervalMs;
|
|
125
|
+
staleThresholdMs;
|
|
126
|
+
keepAliveMessage;
|
|
127
|
+
timer = null;
|
|
128
|
+
constructor(options) {
|
|
129
|
+
this.store = options.store;
|
|
130
|
+
this.linearClient = options.linearClient;
|
|
131
|
+
this.logger = options.logger ?? new NoopLogger();
|
|
132
|
+
this.checkIntervalMs = options.checkIntervalMs ?? STALE_SESSION_CHECK_INTERVAL_MS;
|
|
133
|
+
this.staleThresholdMs = options.staleThresholdMs ?? STALE_SESSION_THRESHOLD_MS;
|
|
134
|
+
this.keepAliveMessage = options.keepAliveMessage ?? DEFAULT_KEEPALIVE_MESSAGE;
|
|
135
|
+
}
|
|
136
|
+
/** Start the periodic keepalive check. Safe to call multiple times. */
|
|
137
|
+
start() {
|
|
138
|
+
if (this.timer) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.logger.info("stale_session_guard.started", {
|
|
142
|
+
checkIntervalMs: this.checkIntervalMs,
|
|
143
|
+
staleThresholdMs: this.staleThresholdMs
|
|
144
|
+
});
|
|
145
|
+
this.timer = setInterval(() => {
|
|
146
|
+
void this.tick();
|
|
147
|
+
}, this.checkIntervalMs);
|
|
148
|
+
if (typeof this.timer === "object" && "unref" in this.timer) {
|
|
149
|
+
this.timer.unref();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Stop the periodic keepalive check. */
|
|
153
|
+
stop() {
|
|
154
|
+
if (!this.timer) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
clearInterval(this.timer);
|
|
158
|
+
this.timer = null;
|
|
159
|
+
this.logger.info("stale_session_guard.stopped", {});
|
|
160
|
+
}
|
|
161
|
+
/** Run a single keepalive check. Exposed for testing and manual invocation. */
|
|
162
|
+
async tick() {
|
|
163
|
+
if (!this.store.listActiveSessions) {
|
|
164
|
+
this.logger.warn("stale_session_guard.store_missing_listActiveSessions", {});
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
let sessions;
|
|
168
|
+
try {
|
|
169
|
+
sessions = await this.store.listActiveSessions();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
this.logger.error("stale_session_guard.list_sessions_failed", {
|
|
172
|
+
error: error instanceof Error ? error.message : String(error)
|
|
173
|
+
});
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
let kept = 0;
|
|
178
|
+
for (const session of sessions) {
|
|
179
|
+
if (isSessionStale(session, now, this.staleThresholdMs)) {
|
|
180
|
+
try {
|
|
181
|
+
await postThought(this.linearClient, session.linearSessionId, this.keepAliveMessage);
|
|
182
|
+
const activityTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
183
|
+
await this.store.upsert({
|
|
184
|
+
...session,
|
|
185
|
+
lastLinearActivityAt: activityTimestamp,
|
|
186
|
+
updatedAt: activityTimestamp
|
|
187
|
+
});
|
|
188
|
+
kept += 1;
|
|
189
|
+
this.logger.info("stale_session_guard.keepalive_sent", {
|
|
190
|
+
sessionId: session.linearSessionId,
|
|
191
|
+
lastLinearActivityAt: session.lastLinearActivityAt
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.logger.warn("stale_session_guard.keepalive_failed", {
|
|
195
|
+
sessionId: session.linearSessionId,
|
|
196
|
+
error: error instanceof Error ? error.message : String(error)
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (kept > 0) {
|
|
202
|
+
this.logger.info("stale_session_guard.tick_complete", {
|
|
203
|
+
totalSessions: sessions.length,
|
|
204
|
+
keepalivesSent: kept
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return kept;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
function isSessionStale(session, nowMs, thresholdMs) {
|
|
211
|
+
const referenceIso = session.lastLinearActivityAt ?? session.updatedAt;
|
|
212
|
+
const referenceMs = new Date(referenceIso).getTime();
|
|
213
|
+
if (Number.isNaN(referenceMs)) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
return nowMs - referenceMs >= thresholdMs;
|
|
217
|
+
}
|
|
218
|
+
async function sendRecoveryActivityIfStale(input) {
|
|
219
|
+
const thresholdMs = input.staleThresholdMs ?? STALE_SESSION_THRESHOLD_MS;
|
|
220
|
+
if (!isSessionStale(input.session, Date.now(), thresholdMs)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
await postThought(
|
|
225
|
+
input.linearClient,
|
|
226
|
+
input.session.linearSessionId,
|
|
227
|
+
"Resuming session \u2014 reconnecting after extended specialist work."
|
|
228
|
+
);
|
|
229
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
230
|
+
await input.store.upsert({
|
|
231
|
+
...input.session,
|
|
232
|
+
lastLinearActivityAt: now,
|
|
233
|
+
updatedAt: now
|
|
234
|
+
});
|
|
235
|
+
input.logger.info("stale_session_guard.recovery_activity_sent", {
|
|
236
|
+
sessionId: input.session.linearSessionId,
|
|
237
|
+
lastLinearActivityAt: input.session.lastLinearActivityAt
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
input.logger.warn("stale_session_guard.recovery_activity_failed", {
|
|
242
|
+
sessionId: input.session.linearSessionId,
|
|
243
|
+
error: error instanceof Error ? error.message : String(error)
|
|
244
|
+
});
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
71
249
|
// src/integrations/linear/bridge/message.ts
|
|
72
250
|
function buildBridgeMessage(input) {
|
|
73
251
|
const header = input.action === "created" ? "[Linear]: Agent session created." : "[Linear]: Agent session updated.";
|
|
@@ -87,6 +265,8 @@ function buildBridgeMessage(input) {
|
|
|
87
265
|
const issueStateTypeLine = input.issueStateType ? `issue_state_type: ${input.issueStateType}` : "issue_state_type: none";
|
|
88
266
|
const issueAssigneeLine = input.issueAssigneeName ? `issue_assignee: ${input.issueAssigneeName}` : "issue_assignee: none";
|
|
89
267
|
const issueAssigneeIdLine = input.issueAssigneeId ? `issue_assignee_id: ${input.issueAssigneeId}` : "issue_assignee_id: none";
|
|
268
|
+
const issueDelegateLine = input.issueDelegateName ? `issue_delegate: ${input.issueDelegateName}` : "issue_delegate: none";
|
|
269
|
+
const issueDelegateIdLine = input.issueDelegateId ? `issue_delegate_id: ${input.issueDelegateId}` : "issue_delegate_id: none";
|
|
90
270
|
const sessionStatusLine = input.sessionStatus ? `session_status: ${input.sessionStatus}` : "session_status: none";
|
|
91
271
|
const sessionTypeLine = input.sessionType ? `session_type: ${input.sessionType}` : "session_type: none";
|
|
92
272
|
const sessionCreatedLine = input.sessionCreatedAt ? `session_created_at: ${input.sessionCreatedAt}` : "session_created_at: none";
|
|
@@ -122,6 +302,8 @@ Linear session context:
|
|
|
122
302
|
- ${issueStateTypeLine}
|
|
123
303
|
- ${issueAssigneeLine}
|
|
124
304
|
- ${issueAssigneeIdLine}
|
|
305
|
+
- ${issueDelegateLine}
|
|
306
|
+
- ${issueDelegateIdLine}
|
|
125
307
|
- inferred_session_intent: ${input.sessionIntent}
|
|
126
308
|
- writeback_mode: ${input.writebackMode}
|
|
127
309
|
- ${appUserLine}
|
|
@@ -137,7 +319,7 @@ Bridge responsibilities:
|
|
|
137
319
|
- keep Linear updated with meaningful milestones
|
|
138
320
|
- use linear_post_thought and linear_post_action for meaningful progress updates
|
|
139
321
|
- call linear_post_response when the work is actually finished
|
|
140
|
-
- when you delegate inside
|
|
322
|
+
- when you delegate inside Band, include the relevant ticket context in the room message so the specialist can act without hidden state
|
|
141
323
|
|
|
142
324
|
Suggested peers available in the registry right now. They are not in the room yet:
|
|
143
325
|
${suggestedPeersLine}
|
|
@@ -207,6 +389,11 @@ function extractIssueAssigneeField(issue, field) {
|
|
|
207
389
|
const value = assignee?.[field];
|
|
208
390
|
return typeof value === "string" ? value : null;
|
|
209
391
|
}
|
|
392
|
+
function extractIssueDelegateField(issue, field) {
|
|
393
|
+
const delegate = extractNestedRecord(issue, "delegate");
|
|
394
|
+
const value = delegate?.[field];
|
|
395
|
+
return typeof value === "string" ? value : null;
|
|
396
|
+
}
|
|
210
397
|
function firstNonEmpty(...values) {
|
|
211
398
|
for (const value of values) {
|
|
212
399
|
if (!value) {
|
|
@@ -229,11 +416,13 @@ function extractRecord(value) {
|
|
|
229
416
|
}
|
|
230
417
|
|
|
231
418
|
// src/integrations/linear/bridge/handler.ts
|
|
419
|
+
var DEFAULT_APP_BASE_URL = "https://app.band.ai";
|
|
232
420
|
var SUPPORTED_ACTIONS = /* @__PURE__ */ new Set(["created", "updated", "canceled", "prompted"]);
|
|
233
421
|
var MAX_PEER_LOOKUP_PAGES = 25;
|
|
234
422
|
var PEER_PAGE_SIZE = 100;
|
|
235
423
|
var RECOVERED_ROOM_EVENT_RETRY_LIMIT = 2;
|
|
236
424
|
var RECOVERED_ROOM_EVENT_RETRY_BASE_DELAY_MS = 1e3;
|
|
425
|
+
var AUTO_START_ELIGIBLE_TYPES = /* @__PURE__ */ new Set(["backlog", "unstarted", "triage"]);
|
|
237
426
|
function createLinearBridgeRuntime() {
|
|
238
427
|
return {
|
|
239
428
|
roomResolutionLocks: /* @__PURE__ */ new Map(),
|
|
@@ -298,9 +487,19 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
298
487
|
});
|
|
299
488
|
return;
|
|
300
489
|
}
|
|
490
|
+
if (action === "updated" && existingBySession) {
|
|
491
|
+
await sendRecoveryActivityIfStale({
|
|
492
|
+
session: existingBySession,
|
|
493
|
+
linearClient: input.deps.linearClient,
|
|
494
|
+
store: input.deps.store,
|
|
495
|
+
logger
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
let lastLinearActivityAt = null;
|
|
301
499
|
if (action === "created" && !options?.skipAcknowledgment) {
|
|
302
500
|
try {
|
|
303
501
|
await postThought(input.deps.linearClient, sessionId, "Received session. Setting up workspace...");
|
|
502
|
+
lastLinearActivityAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
304
503
|
} catch (ackError) {
|
|
305
504
|
logger.warn("linear_thenvoi_bridge.acknowledgment_failed", {
|
|
306
505
|
sessionId,
|
|
@@ -308,17 +507,59 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
308
507
|
});
|
|
309
508
|
}
|
|
310
509
|
}
|
|
510
|
+
let delegatePromise;
|
|
511
|
+
if (action === "created" && issueId) {
|
|
512
|
+
const appUserId = input.payload.appUserId;
|
|
513
|
+
if (appUserId) {
|
|
514
|
+
delegatePromise = trySetAgentAsDelegate({
|
|
515
|
+
linearClient: input.deps.linearClient,
|
|
516
|
+
issueId,
|
|
517
|
+
appUserId,
|
|
518
|
+
logger
|
|
519
|
+
}).catch((delegateError) => {
|
|
520
|
+
logger.warn("linear_thenvoi_bridge.auto_delegate_failed", {
|
|
521
|
+
sessionId,
|
|
522
|
+
issueId,
|
|
523
|
+
appUserId,
|
|
524
|
+
error: delegateError instanceof Error ? delegateError.message : String(delegateError)
|
|
525
|
+
});
|
|
526
|
+
return { set: false, delegateName: null };
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
let autoStartPromise;
|
|
531
|
+
if (action === "created" && issueId) {
|
|
532
|
+
const originalStateType = extractIssueStateField(input.payload.agentSession.issue, "type");
|
|
533
|
+
const teamId = extractIssueTeamId(input.payload.agentSession.issue);
|
|
534
|
+
if (teamId && originalStateType && AUTO_START_ELIGIBLE_TYPES.has(originalStateType)) {
|
|
535
|
+
autoStartPromise = tryMoveIssueToStarted({
|
|
536
|
+
linearClient: input.deps.linearClient,
|
|
537
|
+
issueId,
|
|
538
|
+
teamId,
|
|
539
|
+
logger
|
|
540
|
+
}).catch((autoStartError) => {
|
|
541
|
+
logger.warn("linear_thenvoi_bridge.auto_start_failed", {
|
|
542
|
+
sessionId,
|
|
543
|
+
issueId,
|
|
544
|
+
teamId,
|
|
545
|
+
error: autoStartError instanceof Error ? autoStartError.message : String(autoStartError)
|
|
546
|
+
});
|
|
547
|
+
return { moved: false, stateId: null, stateName: null };
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
311
551
|
let roomRecord = null;
|
|
552
|
+
let externalUrlPromise;
|
|
312
553
|
try {
|
|
313
554
|
const hostAgentHandle = await resolveHostAgentHandle({
|
|
314
|
-
|
|
555
|
+
bandRest: input.deps.bandRest,
|
|
315
556
|
configuredHostHandle: config.hostAgentHandle,
|
|
316
557
|
logger,
|
|
317
558
|
runtime
|
|
318
559
|
});
|
|
319
560
|
const existingByIssue = config.roomStrategy === "issue" && issueId ? await input.deps.store.getByIssueId(issueId) : null;
|
|
320
561
|
roomRecord = await resolveRoomRecord({
|
|
321
|
-
|
|
562
|
+
bandRest: input.deps.bandRest,
|
|
322
563
|
store: input.deps.store,
|
|
323
564
|
roomStrategy: config.roomStrategy,
|
|
324
565
|
sessionId,
|
|
@@ -326,6 +567,22 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
326
567
|
logger,
|
|
327
568
|
runtime
|
|
328
569
|
});
|
|
570
|
+
if (action === "created") {
|
|
571
|
+
const roomId = roomRecord.bandRoomId;
|
|
572
|
+
externalUrlPromise = trySetSessionExternalUrl({
|
|
573
|
+
linearClient: input.deps.linearClient,
|
|
574
|
+
sessionId,
|
|
575
|
+
roomId,
|
|
576
|
+
appBaseUrl: config.bandAppBaseUrl,
|
|
577
|
+
logger
|
|
578
|
+
}).catch((urlError) => {
|
|
579
|
+
logger.warn("linear_thenvoi_bridge.set_external_url_failed", {
|
|
580
|
+
sessionId,
|
|
581
|
+
roomId,
|
|
582
|
+
error: urlError instanceof Error ? urlError.message : String(urlError)
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
}
|
|
329
586
|
const sessionIntent = detectSessionIntent({
|
|
330
587
|
issueStateType: extractIssueStateField(input.payload.agentSession.issue, "type"),
|
|
331
588
|
promptContext: input.payload.promptContext,
|
|
@@ -334,8 +591,8 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
334
591
|
issueDescription: input.payload.agentSession.issue?.description
|
|
335
592
|
});
|
|
336
593
|
const suggestedPeerHandles = await selectRelevantPeerHandles({
|
|
337
|
-
|
|
338
|
-
roomId: roomRecord.
|
|
594
|
+
bandRest: input.deps.bandRest,
|
|
595
|
+
roomId: roomRecord.bandRoomId,
|
|
339
596
|
intent: sessionIntent,
|
|
340
597
|
hostAgentHandle,
|
|
341
598
|
planningAgentHandles: input.config.planningAgentHandles,
|
|
@@ -343,11 +600,27 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
343
600
|
logger
|
|
344
601
|
});
|
|
345
602
|
await ensureRoomParticipants({
|
|
346
|
-
|
|
347
|
-
roomId: roomRecord.
|
|
603
|
+
bandRest: input.deps.bandRest,
|
|
604
|
+
roomId: roomRecord.bandRoomId,
|
|
348
605
|
handles: [hostAgentHandle],
|
|
349
606
|
logger
|
|
350
607
|
});
|
|
608
|
+
const delegateResult = await delegatePromise;
|
|
609
|
+
let issueDelegateId = extractIssueDelegateField(input.payload.agentSession.issue, "id");
|
|
610
|
+
let issueDelegateName = extractIssueDelegateField(input.payload.agentSession.issue, "displayName") ?? extractIssueDelegateField(input.payload.agentSession.issue, "name");
|
|
611
|
+
if (delegateResult?.set && input.payload.appUserId) {
|
|
612
|
+
issueDelegateId = input.payload.appUserId;
|
|
613
|
+
issueDelegateName = delegateResult.delegateName ?? input.payload.appUserId;
|
|
614
|
+
}
|
|
615
|
+
const autoStartResult = await autoStartPromise;
|
|
616
|
+
let issueStateId = extractIssueStateField(input.payload.agentSession.issue, "id");
|
|
617
|
+
let issueStateName = extractIssueStateField(input.payload.agentSession.issue, "name");
|
|
618
|
+
let resolvedStateType = extractIssueStateField(input.payload.agentSession.issue, "type");
|
|
619
|
+
if (autoStartResult?.moved) {
|
|
620
|
+
if (autoStartResult.stateId) issueStateId = autoStartResult.stateId;
|
|
621
|
+
if (autoStartResult.stateName) issueStateName = autoStartResult.stateName;
|
|
622
|
+
resolvedStateType = "started";
|
|
623
|
+
}
|
|
351
624
|
const message = buildBridgeMessage({
|
|
352
625
|
sessionId,
|
|
353
626
|
issueId,
|
|
@@ -365,11 +638,13 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
365
638
|
issueTeamKey: extractIssueTeamKey(input.payload.agentSession.issue),
|
|
366
639
|
issueTeamName: extractIssueTeamName(input.payload.agentSession.issue),
|
|
367
640
|
issueTeamId: extractIssueTeamId(input.payload.agentSession.issue),
|
|
368
|
-
issueStateId
|
|
369
|
-
issueStateName
|
|
370
|
-
issueStateType:
|
|
641
|
+
issueStateId,
|
|
642
|
+
issueStateName,
|
|
643
|
+
issueStateType: resolvedStateType,
|
|
371
644
|
issueAssigneeId: extractIssueAssigneeField(input.payload.agentSession.issue, "id"),
|
|
372
645
|
issueAssigneeName: extractIssueAssigneeField(input.payload.agentSession.issue, "displayName") ?? extractIssueAssigneeField(input.payload.agentSession.issue, "name"),
|
|
646
|
+
issueDelegateId,
|
|
647
|
+
issueDelegateName,
|
|
373
648
|
commentBody: input.payload.agentSession.comment?.body,
|
|
374
649
|
commentId: input.payload.agentSession.comment?.id,
|
|
375
650
|
sessionIntent,
|
|
@@ -387,17 +662,17 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
387
662
|
linear_issue_id: issueId,
|
|
388
663
|
linear_prompt_context: input.payload.promptContext ?? null,
|
|
389
664
|
linear_writeback_mode: config.writebackMode,
|
|
390
|
-
linear_bridge: "
|
|
665
|
+
linear_bridge: "band",
|
|
391
666
|
linear_host_handle: hostAgentHandle
|
|
392
667
|
};
|
|
393
668
|
const shouldResetRoomSession = Boolean(
|
|
394
|
-
existingByIssue && existingByIssue.linearSessionId !== sessionId && existingByIssue.
|
|
669
|
+
existingByIssue && existingByIssue.linearSessionId !== sessionId && existingByIssue.bandRoomId === roomRecord.bandRoomId && (existingByIssue.status === "completed" || existingByIssue.status === "errored")
|
|
395
670
|
);
|
|
396
|
-
const authenticatedHostHandle = runtime.authenticatedHostHandleCache.get(input.deps.
|
|
671
|
+
const authenticatedHostHandle = runtime.authenticatedHostHandleCache.get(input.deps.bandRest) ?? null;
|
|
397
672
|
const canBootstrapDirectly = options?.skipRoomWrite === true || authenticatedHostHandle === null || authenticatedHostHandle === hostAgentHandle;
|
|
398
673
|
if (!options?.skipRoomWrite || !canBootstrapDirectly) {
|
|
399
674
|
roomRecord = await forwardBridgeMessage({
|
|
400
|
-
|
|
675
|
+
bandRest: input.deps.bandRest,
|
|
401
676
|
store: input.deps.store,
|
|
402
677
|
logger,
|
|
403
678
|
roomRecord,
|
|
@@ -415,7 +690,7 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
415
690
|
await enqueueBootstrapRequest(input.deps.store, {
|
|
416
691
|
eventKey,
|
|
417
692
|
linearSessionId: sessionId,
|
|
418
|
-
|
|
693
|
+
bandRoomId: roomRecord.bandRoomId,
|
|
419
694
|
expectedContent: message,
|
|
420
695
|
messageType: "task",
|
|
421
696
|
senderId: linearActor.id,
|
|
@@ -426,19 +701,25 @@ async function handleAgentSessionEvent(input, options) {
|
|
|
426
701
|
} : messageMetadata
|
|
427
702
|
});
|
|
428
703
|
}
|
|
704
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
429
705
|
await saveSessionRecord(input.deps.store, {
|
|
430
706
|
...roomRecord,
|
|
431
707
|
status: "active",
|
|
432
708
|
lastEventKey: eventKey,
|
|
433
|
-
|
|
709
|
+
lastLinearActivityAt: lastLinearActivityAt ?? roomRecord.lastLinearActivityAt ?? now,
|
|
710
|
+
updatedAt: now
|
|
434
711
|
});
|
|
712
|
+
await externalUrlPromise;
|
|
435
713
|
logger.info("linear_thenvoi_bridge.message_forwarded", {
|
|
436
714
|
sessionId,
|
|
437
715
|
issueId,
|
|
438
|
-
roomId: roomRecord.
|
|
716
|
+
roomId: roomRecord.bandRoomId,
|
|
439
717
|
action
|
|
440
718
|
});
|
|
441
719
|
} catch (error) {
|
|
720
|
+
await externalUrlPromise;
|
|
721
|
+
await delegatePromise;
|
|
722
|
+
await autoStartPromise;
|
|
442
723
|
try {
|
|
443
724
|
await postError(
|
|
444
725
|
input.deps.linearClient,
|
|
@@ -484,18 +765,39 @@ async function completeLinearSession(input) {
|
|
|
484
765
|
if (!existing) {
|
|
485
766
|
return;
|
|
486
767
|
}
|
|
768
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
487
769
|
await saveSessionRecord(input.store, {
|
|
488
770
|
...existing,
|
|
489
771
|
status: "completed",
|
|
490
772
|
lastEventKey: input.lastEventKey ?? existing.lastEventKey ?? null,
|
|
491
|
-
|
|
773
|
+
lastLinearActivityAt: now,
|
|
774
|
+
updatedAt: now
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async function trySetSessionExternalUrl(input) {
|
|
778
|
+
if (typeof input.linearClient.agentSessionUpdateExternalUrl !== "function") {
|
|
779
|
+
input.logger.info("linear_thenvoi_bridge.set_external_url_skipped_no_api", {
|
|
780
|
+
sessionId: input.sessionId
|
|
781
|
+
});
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const base = input.appBaseUrl.replace(/\/+$/, "");
|
|
785
|
+
const roomUrl = `${base}/rooms/${input.roomId}`;
|
|
786
|
+
await input.linearClient.agentSessionUpdateExternalUrl(input.sessionId, {
|
|
787
|
+
externalUrls: [{ label: "View in Band", url: roomUrl }]
|
|
788
|
+
});
|
|
789
|
+
input.logger.info("linear_thenvoi_bridge.external_url_set", {
|
|
790
|
+
sessionId: input.sessionId,
|
|
791
|
+
roomId: input.roomId,
|
|
792
|
+
url: roomUrl
|
|
492
793
|
});
|
|
493
794
|
}
|
|
494
795
|
function normalizeConfig(config) {
|
|
495
796
|
return {
|
|
496
797
|
roomStrategy: config.roomStrategy ?? "issue",
|
|
497
798
|
writebackMode: config.writebackMode ?? "final_only",
|
|
498
|
-
hostAgentHandle: normalizeOptionalHandle(config.hostAgentHandle)
|
|
799
|
+
hostAgentHandle: normalizeOptionalHandle(config.hostAgentHandle),
|
|
800
|
+
bandAppBaseUrl: config.bandAppBaseUrl ?? DEFAULT_APP_BASE_URL
|
|
499
801
|
};
|
|
500
802
|
}
|
|
501
803
|
function normalizeOptionalHandle(handle) {
|
|
@@ -505,25 +807,82 @@ function normalizeOptionalHandle(handle) {
|
|
|
505
807
|
const normalized = stripHandlePrefix(handle);
|
|
506
808
|
return normalized.length > 0 ? normalized : null;
|
|
507
809
|
}
|
|
810
|
+
async function trySetAgentAsDelegate(input) {
|
|
811
|
+
const issue = await input.linearClient.issue(input.issueId);
|
|
812
|
+
const existingDelegateId = issue.delegateId;
|
|
813
|
+
if (existingDelegateId) {
|
|
814
|
+
input.logger.info("linear_thenvoi_bridge.delegate_already_set", {
|
|
815
|
+
issueId: input.issueId,
|
|
816
|
+
existingDelegateId
|
|
817
|
+
});
|
|
818
|
+
return { set: false, delegateName: null };
|
|
819
|
+
}
|
|
820
|
+
await input.linearClient.updateIssue(input.issueId, {
|
|
821
|
+
delegateId: input.appUserId
|
|
822
|
+
});
|
|
823
|
+
let delegateName = null;
|
|
824
|
+
try {
|
|
825
|
+
const updated = await input.linearClient.issue(input.issueId);
|
|
826
|
+
const delegate = await updated.delegate;
|
|
827
|
+
delegateName = delegate?.displayName ?? delegate?.name ?? null;
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
input.logger.info("linear_thenvoi_bridge.delegate_set", {
|
|
831
|
+
issueId: input.issueId,
|
|
832
|
+
delegateId: input.appUserId
|
|
833
|
+
});
|
|
834
|
+
return { set: true, delegateName };
|
|
835
|
+
}
|
|
836
|
+
async function tryMoveIssueToStarted(input) {
|
|
837
|
+
const response = await input.linearClient.workflowStates({
|
|
838
|
+
filter: {
|
|
839
|
+
team: { id: { eq: input.teamId } },
|
|
840
|
+
type: { eq: "started" }
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
const nodes = Array.isArray(response.nodes) ? response.nodes : [];
|
|
844
|
+
const startedStates = [...nodes].sort((a, b) => a.position - b.position);
|
|
845
|
+
const targetState = startedStates[0];
|
|
846
|
+
if (!targetState?.id) {
|
|
847
|
+
input.logger.info("linear_thenvoi_bridge.auto_start_no_started_state", {
|
|
848
|
+
issueId: input.issueId,
|
|
849
|
+
teamId: input.teamId
|
|
850
|
+
});
|
|
851
|
+
return { moved: false, stateId: null, stateName: null };
|
|
852
|
+
}
|
|
853
|
+
await input.linearClient.updateIssue(input.issueId, {
|
|
854
|
+
stateId: targetState.id
|
|
855
|
+
});
|
|
856
|
+
input.logger.info("linear_thenvoi_bridge.auto_start_moved", {
|
|
857
|
+
issueId: input.issueId,
|
|
858
|
+
stateId: targetState.id,
|
|
859
|
+
stateName: targetState.name ?? null
|
|
860
|
+
});
|
|
861
|
+
return {
|
|
862
|
+
moved: true,
|
|
863
|
+
stateId: targetState.id,
|
|
864
|
+
stateName: targetState.name ?? null
|
|
865
|
+
};
|
|
866
|
+
}
|
|
508
867
|
async function resolveHostAgentHandle(input) {
|
|
509
868
|
if (input.configuredHostHandle) {
|
|
510
|
-
input.runtime.resolvedHostHandleCache.set(input.
|
|
869
|
+
input.runtime.resolvedHostHandleCache.set(input.bandRest, input.configuredHostHandle);
|
|
511
870
|
return input.configuredHostHandle;
|
|
512
871
|
}
|
|
513
|
-
const cachedAuthenticated = input.runtime.authenticatedHostHandleCache.get(input.
|
|
872
|
+
const cachedAuthenticated = input.runtime.authenticatedHostHandleCache.get(input.bandRest);
|
|
514
873
|
if (cachedAuthenticated) {
|
|
515
|
-
input.runtime.resolvedHostHandleCache.set(input.
|
|
874
|
+
input.runtime.resolvedHostHandleCache.set(input.bandRest, cachedAuthenticated);
|
|
516
875
|
return cachedAuthenticated;
|
|
517
876
|
}
|
|
518
|
-
const cached = input.runtime.resolvedHostHandleCache.get(input.
|
|
877
|
+
const cached = input.runtime.resolvedHostHandleCache.get(input.bandRest);
|
|
519
878
|
if (cached) {
|
|
520
879
|
return cached;
|
|
521
880
|
}
|
|
522
|
-
const identity = await input.
|
|
881
|
+
const identity = await input.bandRest.getAgentMe();
|
|
523
882
|
const authenticatedAgentHandle = normalizeOptionalHandle(identity.handle);
|
|
524
883
|
if (authenticatedAgentHandle) {
|
|
525
|
-
input.runtime.authenticatedHostHandleCache.set(input.
|
|
526
|
-
input.runtime.resolvedHostHandleCache.set(input.
|
|
884
|
+
input.runtime.authenticatedHostHandleCache.set(input.bandRest, authenticatedAgentHandle);
|
|
885
|
+
input.runtime.resolvedHostHandleCache.set(input.bandRest, authenticatedAgentHandle);
|
|
527
886
|
return authenticatedAgentHandle;
|
|
528
887
|
}
|
|
529
888
|
throw new Error(
|
|
@@ -557,14 +916,14 @@ async function handleCanceledAction(input) {
|
|
|
557
916
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
558
917
|
});
|
|
559
918
|
if (!input.skipRoomWrite) {
|
|
560
|
-
await input.deps.
|
|
919
|
+
await input.deps.bandRest.createChatEvent(existing.bandRoomId, {
|
|
561
920
|
content: "[Linear]: Agent session canceled. Stop in-room execution and await new instructions.",
|
|
562
921
|
messageType: "task",
|
|
563
922
|
metadata: {
|
|
564
923
|
linear_event_action: "canceled",
|
|
565
924
|
linear_session_id: input.sessionId,
|
|
566
925
|
linear_issue_id: input.issueId,
|
|
567
|
-
linear_bridge: "
|
|
926
|
+
linear_bridge: "band"
|
|
568
927
|
}
|
|
569
928
|
});
|
|
570
929
|
}
|
|
@@ -590,15 +949,21 @@ async function handlePromptedAction(input) {
|
|
|
590
949
|
});
|
|
591
950
|
return;
|
|
592
951
|
}
|
|
952
|
+
await sendRecoveryActivityIfStale({
|
|
953
|
+
session: existing,
|
|
954
|
+
linearClient: input.deps.linearClient,
|
|
955
|
+
store: input.deps.store,
|
|
956
|
+
logger: input.logger
|
|
957
|
+
});
|
|
593
958
|
const message = `[Linear User Response]: ${userResponse}`;
|
|
594
959
|
const metadata = {
|
|
595
960
|
linear_event_action: "prompted",
|
|
596
961
|
linear_session_id: input.sessionId,
|
|
597
|
-
linear_bridge: "
|
|
962
|
+
linear_bridge: "band"
|
|
598
963
|
};
|
|
599
|
-
const canBootstrapDirectly = input.config.hostAgentHandle !== null ? true : Boolean(normalizeOptionalHandle((await input.deps.
|
|
964
|
+
const canBootstrapDirectly = input.config.hostAgentHandle !== null ? true : Boolean(normalizeOptionalHandle((await input.deps.bandRest.getAgentMe()).handle));
|
|
600
965
|
if (!input.skipRoomWrite || !canBootstrapDirectly) {
|
|
601
|
-
await input.deps.
|
|
966
|
+
await input.deps.bandRest.createChatEvent(existing.bandRoomId, {
|
|
602
967
|
content: message,
|
|
603
968
|
messageType: "text",
|
|
604
969
|
metadata
|
|
@@ -609,7 +974,7 @@ async function handlePromptedAction(input) {
|
|
|
609
974
|
await enqueueBootstrapRequest(input.deps.store, {
|
|
610
975
|
eventKey: input.eventKey,
|
|
611
976
|
linearSessionId: input.sessionId,
|
|
612
|
-
|
|
977
|
+
bandRoomId: existing.bandRoomId,
|
|
613
978
|
expectedContent: message,
|
|
614
979
|
messageType: "text",
|
|
615
980
|
senderId: linearActor.id,
|
|
@@ -625,7 +990,7 @@ async function handlePromptedAction(input) {
|
|
|
625
990
|
});
|
|
626
991
|
input.logger.info("linear_thenvoi_bridge.prompted_forwarded", {
|
|
627
992
|
sessionId: input.sessionId,
|
|
628
|
-
roomId: existing.
|
|
993
|
+
roomId: existing.bandRoomId
|
|
629
994
|
});
|
|
630
995
|
}
|
|
631
996
|
async function enqueueBootstrapRequest(store, request) {
|
|
@@ -667,7 +1032,7 @@ async function resolveRoomRecord(input) {
|
|
|
667
1032
|
}
|
|
668
1033
|
async function forwardBridgeMessage(input) {
|
|
669
1034
|
try {
|
|
670
|
-
await input.
|
|
1035
|
+
await input.bandRest.createChatEvent(input.roomRecord.bandRoomId, {
|
|
671
1036
|
content: input.message,
|
|
672
1037
|
messageType: input.messageType,
|
|
673
1038
|
metadata: input.metadata
|
|
@@ -680,26 +1045,26 @@ async function forwardBridgeMessage(input) {
|
|
|
680
1045
|
input.logger.warn("linear_thenvoi_bridge.room_forward_recovering_with_fresh_room", {
|
|
681
1046
|
sessionId: input.sessionId,
|
|
682
1047
|
issueId: input.issueId,
|
|
683
|
-
previousRoomId: input.roomRecord.
|
|
1048
|
+
previousRoomId: input.roomRecord.bandRoomId,
|
|
684
1049
|
error: error instanceof Error ? error.message : String(error)
|
|
685
1050
|
});
|
|
686
1051
|
const recoveredRoom = await createFreshRoomRecord({
|
|
687
|
-
|
|
1052
|
+
bandRest: input.bandRest,
|
|
688
1053
|
store: input.store,
|
|
689
1054
|
sessionId: input.sessionId,
|
|
690
1055
|
issueId: input.issueId,
|
|
691
1056
|
logger: input.logger
|
|
692
1057
|
});
|
|
693
1058
|
await ensureRoomParticipants({
|
|
694
|
-
|
|
695
|
-
roomId: recoveredRoom.
|
|
1059
|
+
bandRest: input.bandRest,
|
|
1060
|
+
roomId: recoveredRoom.bandRoomId,
|
|
696
1061
|
handles: [input.hostAgentHandle],
|
|
697
1062
|
logger: input.logger
|
|
698
1063
|
});
|
|
699
1064
|
let attempt = 0;
|
|
700
1065
|
while (true) {
|
|
701
1066
|
try {
|
|
702
|
-
await input.
|
|
1067
|
+
await input.bandRest.createChatEvent(recoveredRoom.bandRoomId, {
|
|
703
1068
|
content: input.message,
|
|
704
1069
|
messageType: input.messageType,
|
|
705
1070
|
metadata: input.metadata
|
|
@@ -715,7 +1080,7 @@ async function forwardBridgeMessage(input) {
|
|
|
715
1080
|
input.logger.warn("linear_thenvoi_bridge.room_recreated_retrying", {
|
|
716
1081
|
sessionId: input.sessionId,
|
|
717
1082
|
issueId: input.issueId,
|
|
718
|
-
roomId: recoveredRoom.
|
|
1083
|
+
roomId: recoveredRoom.bandRoomId,
|
|
719
1084
|
attempt,
|
|
720
1085
|
delayMs,
|
|
721
1086
|
error: recoveredError instanceof Error ? recoveredError.message : String(recoveredError)
|
|
@@ -745,11 +1110,11 @@ function isRetryableRateLimitError(error) {
|
|
|
745
1110
|
}
|
|
746
1111
|
async function createFreshRoomRecord(input) {
|
|
747
1112
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
748
|
-
const created = await input.
|
|
1113
|
+
const created = await input.bandRest.createChat();
|
|
749
1114
|
const record = {
|
|
750
1115
|
linearSessionId: input.sessionId,
|
|
751
1116
|
linearIssueId: input.issueId,
|
|
752
|
-
|
|
1117
|
+
bandRoomId: created.id,
|
|
753
1118
|
status: "active",
|
|
754
1119
|
lastEventKey: null,
|
|
755
1120
|
createdAt: now,
|
|
@@ -775,7 +1140,7 @@ async function resolveRoomRecordImpl(input) {
|
|
|
775
1140
|
const linkedRecord = {
|
|
776
1141
|
linearSessionId: input.sessionId,
|
|
777
1142
|
linearIssueId: input.issueId,
|
|
778
|
-
|
|
1143
|
+
bandRoomId: existingByIssue.bandRoomId,
|
|
779
1144
|
status: "active",
|
|
780
1145
|
lastEventKey: existingBySession?.lastEventKey ?? null,
|
|
781
1146
|
createdAt: existingBySession?.createdAt ?? now2,
|
|
@@ -786,11 +1151,11 @@ async function resolveRoomRecordImpl(input) {
|
|
|
786
1151
|
}
|
|
787
1152
|
}
|
|
788
1153
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
789
|
-
const created = await input.
|
|
1154
|
+
const created = await input.bandRest.createChat();
|
|
790
1155
|
const createdRecord = {
|
|
791
1156
|
linearSessionId: input.sessionId,
|
|
792
1157
|
linearIssueId: input.issueId,
|
|
793
|
-
|
|
1158
|
+
bandRoomId: created.id,
|
|
794
1159
|
status: "active",
|
|
795
1160
|
lastEventKey: null,
|
|
796
1161
|
createdAt: now,
|
|
@@ -811,7 +1176,7 @@ async function ensureRoomParticipants(input) {
|
|
|
811
1176
|
}
|
|
812
1177
|
let existingParticipants;
|
|
813
1178
|
try {
|
|
814
|
-
existingParticipants = await input.
|
|
1179
|
+
existingParticipants = await input.bandRest.listChatParticipants(input.roomId);
|
|
815
1180
|
} catch (error) {
|
|
816
1181
|
if (isRetryableRateLimitError(error)) {
|
|
817
1182
|
input.logger.warn("linear_thenvoi_bridge.participant_list_rate_limited", {
|
|
@@ -831,7 +1196,7 @@ async function ensureRoomParticipants(input) {
|
|
|
831
1196
|
}
|
|
832
1197
|
let peersByHandle;
|
|
833
1198
|
try {
|
|
834
|
-
peersByHandle = await lookupPeerIdsByHandle(input.
|
|
1199
|
+
peersByHandle = await lookupPeerIdsByHandle(input.bandRest, input.roomId, missingHandles);
|
|
835
1200
|
} catch (error) {
|
|
836
1201
|
if (error instanceof UnsupportedFeatureError) {
|
|
837
1202
|
input.logger.warn("linear_thenvoi_bridge.peer_lookup_unavailable", {
|
|
@@ -853,7 +1218,7 @@ async function ensureRoomParticipants(input) {
|
|
|
853
1218
|
continue;
|
|
854
1219
|
}
|
|
855
1220
|
try {
|
|
856
|
-
await input.
|
|
1221
|
+
await input.bandRest.addChatParticipant(
|
|
857
1222
|
input.roomId,
|
|
858
1223
|
{
|
|
859
1224
|
participantId,
|
|
@@ -872,14 +1237,14 @@ async function ensureRoomParticipants(input) {
|
|
|
872
1237
|
}
|
|
873
1238
|
}
|
|
874
1239
|
}
|
|
875
|
-
async function lookupPeerIdsByHandle(
|
|
876
|
-
if (!
|
|
1240
|
+
async function lookupPeerIdsByHandle(bandRest, roomId, handles) {
|
|
1241
|
+
if (!bandRest.listPeers) {
|
|
877
1242
|
throw new UnsupportedFeatureError("Peer listing is not available in current REST adapter");
|
|
878
1243
|
}
|
|
879
1244
|
const targets = new Set(handles);
|
|
880
1245
|
const found = /* @__PURE__ */ new Map();
|
|
881
1246
|
for (let page = 1; page <= MAX_PEER_LOOKUP_PAGES && found.size < targets.size; page += 1) {
|
|
882
|
-
const response = await
|
|
1247
|
+
const response = await bandRest.listPeers({
|
|
883
1248
|
page,
|
|
884
1249
|
pageSize: PEER_PAGE_SIZE,
|
|
885
1250
|
notInChat: roomId
|
|
@@ -920,12 +1285,12 @@ async function selectRelevantPeerHandles(input) {
|
|
|
920
1285
|
});
|
|
921
1286
|
return configuredHandles;
|
|
922
1287
|
}
|
|
923
|
-
if (!input.
|
|
1288
|
+
if (!input.bandRest.listPeers) {
|
|
924
1289
|
return [];
|
|
925
1290
|
}
|
|
926
1291
|
let peers;
|
|
927
1292
|
try {
|
|
928
|
-
const response = await input.
|
|
1293
|
+
const response = await input.bandRest.listPeers({
|
|
929
1294
|
page: 1,
|
|
930
1295
|
pageSize: PEER_PAGE_SIZE,
|
|
931
1296
|
notInChat: input.roomId
|
|
@@ -1056,6 +1421,7 @@ var SqliteSessionRoomStore = class {
|
|
|
1056
1421
|
thenvoi_room_id,
|
|
1057
1422
|
status,
|
|
1058
1423
|
last_event_key,
|
|
1424
|
+
last_linear_activity_at,
|
|
1059
1425
|
created_at,
|
|
1060
1426
|
updated_at
|
|
1061
1427
|
FROM linear_thenvoi_session_rooms
|
|
@@ -1076,6 +1442,7 @@ var SqliteSessionRoomStore = class {
|
|
|
1076
1442
|
thenvoi_room_id,
|
|
1077
1443
|
status,
|
|
1078
1444
|
last_event_key,
|
|
1445
|
+
last_linear_activity_at,
|
|
1079
1446
|
created_at,
|
|
1080
1447
|
updated_at
|
|
1081
1448
|
FROM linear_thenvoi_session_rooms
|
|
@@ -1097,23 +1464,26 @@ var SqliteSessionRoomStore = class {
|
|
|
1097
1464
|
thenvoi_room_id,
|
|
1098
1465
|
status,
|
|
1099
1466
|
last_event_key,
|
|
1467
|
+
last_linear_activity_at,
|
|
1100
1468
|
created_at,
|
|
1101
1469
|
updated_at
|
|
1102
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1470
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1103
1471
|
ON CONFLICT(linear_session_id)
|
|
1104
1472
|
DO UPDATE SET
|
|
1105
1473
|
linear_issue_id = excluded.linear_issue_id,
|
|
1106
1474
|
thenvoi_room_id = excluded.thenvoi_room_id,
|
|
1107
1475
|
status = excluded.status,
|
|
1108
1476
|
last_event_key = excluded.last_event_key,
|
|
1477
|
+
last_linear_activity_at = excluded.last_linear_activity_at,
|
|
1109
1478
|
updated_at = excluded.updated_at
|
|
1110
1479
|
`
|
|
1111
1480
|
).run(
|
|
1112
1481
|
record.linearSessionId,
|
|
1113
1482
|
record.linearIssueId,
|
|
1114
|
-
record.
|
|
1483
|
+
record.bandRoomId,
|
|
1115
1484
|
record.status,
|
|
1116
1485
|
record.lastEventKey ?? null,
|
|
1486
|
+
record.lastLinearActivityAt ?? null,
|
|
1117
1487
|
record.createdAt,
|
|
1118
1488
|
record.updatedAt
|
|
1119
1489
|
);
|
|
@@ -1157,7 +1527,7 @@ var SqliteSessionRoomStore = class {
|
|
|
1157
1527
|
).run(
|
|
1158
1528
|
request.eventKey,
|
|
1159
1529
|
request.linearSessionId,
|
|
1160
|
-
request.
|
|
1530
|
+
request.bandRoomId,
|
|
1161
1531
|
request.expectedContent,
|
|
1162
1532
|
request.messageType,
|
|
1163
1533
|
request.metadata ? JSON.stringify(request.metadata) : null,
|
|
@@ -1189,7 +1559,7 @@ var SqliteSessionRoomStore = class {
|
|
|
1189
1559
|
return rows.map((row) => ({
|
|
1190
1560
|
eventKey: row.event_key,
|
|
1191
1561
|
linearSessionId: row.linear_session_id,
|
|
1192
|
-
|
|
1562
|
+
bandRoomId: row.thenvoi_room_id,
|
|
1193
1563
|
expectedContent: row.expected_content,
|
|
1194
1564
|
messageType: row.message_type,
|
|
1195
1565
|
metadata: this.parseMetadata(row.metadata_json),
|
|
@@ -1207,6 +1577,29 @@ var SqliteSessionRoomStore = class {
|
|
|
1207
1577
|
`
|
|
1208
1578
|
).run((/* @__PURE__ */ new Date()).toISOString(), eventKey);
|
|
1209
1579
|
}
|
|
1580
|
+
async listActiveSessions() {
|
|
1581
|
+
const db = await this.getDb();
|
|
1582
|
+
const rawRows = db.prepare(
|
|
1583
|
+
`
|
|
1584
|
+
SELECT
|
|
1585
|
+
linear_session_id,
|
|
1586
|
+
linear_issue_id,
|
|
1587
|
+
thenvoi_room_id,
|
|
1588
|
+
status,
|
|
1589
|
+
last_event_key,
|
|
1590
|
+
last_linear_activity_at,
|
|
1591
|
+
created_at,
|
|
1592
|
+
updated_at
|
|
1593
|
+
FROM linear_thenvoi_session_rooms
|
|
1594
|
+
WHERE status IN ('active', 'waiting')
|
|
1595
|
+
ORDER BY updated_at DESC
|
|
1596
|
+
`
|
|
1597
|
+
).all();
|
|
1598
|
+
if (!Array.isArray(rawRows)) {
|
|
1599
|
+
return [];
|
|
1600
|
+
}
|
|
1601
|
+
return rawRows.map((raw) => parseSessionRoomRow(raw)).filter((row) => row !== null).map((row) => this.toRecord(row));
|
|
1602
|
+
}
|
|
1210
1603
|
async close() {
|
|
1211
1604
|
if (!this.dbPromise) {
|
|
1212
1605
|
return;
|
|
@@ -1288,15 +1681,26 @@ var SqliteSessionRoomStore = class {
|
|
|
1288
1681
|
throw error;
|
|
1289
1682
|
}
|
|
1290
1683
|
}
|
|
1684
|
+
try {
|
|
1685
|
+
db.exec(`
|
|
1686
|
+
ALTER TABLE linear_thenvoi_session_rooms
|
|
1687
|
+
ADD COLUMN last_linear_activity_at TEXT
|
|
1688
|
+
`);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
if (!isDuplicateColumnError(error)) {
|
|
1691
|
+
throw error;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1291
1694
|
return db;
|
|
1292
1695
|
}
|
|
1293
1696
|
toRecord(row) {
|
|
1294
1697
|
return {
|
|
1295
1698
|
linearSessionId: row.linear_session_id,
|
|
1296
1699
|
linearIssueId: row.linear_issue_id,
|
|
1297
|
-
|
|
1700
|
+
bandRoomId: row.thenvoi_room_id,
|
|
1298
1701
|
status: row.status,
|
|
1299
1702
|
lastEventKey: row.last_event_key,
|
|
1703
|
+
lastLinearActivityAt: row.last_linear_activity_at,
|
|
1300
1704
|
createdAt: row.created_at,
|
|
1301
1705
|
updatedAt: row.updated_at
|
|
1302
1706
|
};
|
|
@@ -1349,20 +1753,22 @@ function parseSessionRoomRow(value) {
|
|
|
1349
1753
|
}
|
|
1350
1754
|
const linearSessionId = asString(row.linear_session_id);
|
|
1351
1755
|
const linearIssueId = asNullableString(row.linear_issue_id);
|
|
1352
|
-
const
|
|
1756
|
+
const bandRoomId = asString(row.thenvoi_room_id);
|
|
1353
1757
|
const status = asString(row.status);
|
|
1354
1758
|
const lastEventKey = asNullableString(row.last_event_key);
|
|
1759
|
+
const lastLinearActivityAt = asNullableString(row.last_linear_activity_at);
|
|
1355
1760
|
const createdAt = asString(row.created_at);
|
|
1356
1761
|
const updatedAt = asString(row.updated_at);
|
|
1357
|
-
if (!linearSessionId || linearIssueId === void 0 || !
|
|
1762
|
+
if (!linearSessionId || linearIssueId === void 0 || !bandRoomId || !status || !isSessionStatus(status) || lastEventKey === void 0 || lastLinearActivityAt === void 0 || !createdAt || !updatedAt) {
|
|
1358
1763
|
return null;
|
|
1359
1764
|
}
|
|
1360
1765
|
return {
|
|
1361
1766
|
linear_session_id: linearSessionId,
|
|
1362
1767
|
linear_issue_id: linearIssueId,
|
|
1363
|
-
thenvoi_room_id:
|
|
1768
|
+
thenvoi_room_id: bandRoomId,
|
|
1364
1769
|
status,
|
|
1365
1770
|
last_event_key: lastEventKey,
|
|
1771
|
+
last_linear_activity_at: lastLinearActivityAt,
|
|
1366
1772
|
created_at: createdAt,
|
|
1367
1773
|
updated_at: updatedAt
|
|
1368
1774
|
};
|
|
@@ -1380,20 +1786,20 @@ function parseBootstrapRequestRow(value) {
|
|
|
1380
1786
|
}
|
|
1381
1787
|
const eventKey = asString(row.event_key);
|
|
1382
1788
|
const linearSessionId = asString(row.linear_session_id);
|
|
1383
|
-
const
|
|
1789
|
+
const bandRoomId = asString(row.thenvoi_room_id);
|
|
1384
1790
|
const expectedContent = asString(row.expected_content);
|
|
1385
1791
|
const messageType = asString(row.message_type);
|
|
1386
1792
|
const metadataJson = asNullableString(row.metadata_json);
|
|
1387
1793
|
const createdAt = asString(row.created_at);
|
|
1388
1794
|
const expiresAt = asString(row.expires_at);
|
|
1389
1795
|
const processedAt = asNullableString(row.processed_at);
|
|
1390
|
-
if (!eventKey || !linearSessionId || !
|
|
1796
|
+
if (!eventKey || !linearSessionId || !bandRoomId || !expectedContent || !messageType || metadataJson === void 0 || !createdAt || !expiresAt || processedAt === void 0) {
|
|
1391
1797
|
return null;
|
|
1392
1798
|
}
|
|
1393
1799
|
return {
|
|
1394
1800
|
event_key: eventKey,
|
|
1395
1801
|
linear_session_id: linearSessionId,
|
|
1396
|
-
thenvoi_room_id:
|
|
1802
|
+
thenvoi_room_id: bandRoomId,
|
|
1397
1803
|
expected_content: expectedContent,
|
|
1398
1804
|
message_type: messageType,
|
|
1399
1805
|
metadata_json: metadataJson,
|
|
@@ -1408,8 +1814,18 @@ function createSqliteSessionRoomStore(dbPath) {
|
|
|
1408
1814
|
|
|
1409
1815
|
// src/integrations/linear/tools.ts
|
|
1410
1816
|
import { z } from "zod";
|
|
1817
|
+
var LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1818
|
+
function isAllowedAuthUrl(u) {
|
|
1819
|
+
try {
|
|
1820
|
+
const parsed = new URL(u);
|
|
1821
|
+
if (parsed.protocol === "https:") return true;
|
|
1822
|
+
return LOCALHOST_HOSTNAMES.has(parsed.hostname);
|
|
1823
|
+
} catch {
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1411
1827
|
function createLinearTools(options) {
|
|
1412
|
-
const { client, store, enableElicitation = true } = options;
|
|
1828
|
+
const { client, store, enableElicitation = true, logger } = options;
|
|
1413
1829
|
const sessionBodySchema = z.object({
|
|
1414
1830
|
session_id: z.string().describe("The Linear agent session ID"),
|
|
1415
1831
|
body: z.string().describe("The message body in Markdown format")
|
|
@@ -1421,52 +1837,121 @@ function createLinearTools(options) {
|
|
|
1421
1837
|
const requiredIssueIdSchema = issueIdInputSchema;
|
|
1422
1838
|
const optionalIssueIdSchema = issueIdInputSchema;
|
|
1423
1839
|
const tools = [];
|
|
1424
|
-
const
|
|
1840
|
+
const ephemeralSessionBodySchema = sessionBodySchema.extend({
|
|
1841
|
+
ephemeral: z.boolean().optional().describe(
|
|
1842
|
+
'If true, this activity is displayed temporarily and replaced when the next activity arrives. Use for transient status indicators like "Thinking...", "Searching...", or "Waiting for response...".'
|
|
1843
|
+
)
|
|
1844
|
+
});
|
|
1845
|
+
const addSessionBodyTool = (name, description, activityFn, options2) => {
|
|
1846
|
+
const supportsEphemeral = options2?.supportsEphemeral ?? false;
|
|
1425
1847
|
tools.push({
|
|
1426
1848
|
name,
|
|
1427
|
-
description,
|
|
1428
|
-
schema: sessionBodySchema,
|
|
1429
|
-
handler
|
|
1849
|
+
description: supportsEphemeral ? description + " Set ephemeral: true for transient status updates that should disappear when the next activity arrives." : description,
|
|
1850
|
+
schema: supportsEphemeral ? ephemeralSessionBodySchema : sessionBodySchema,
|
|
1851
|
+
handler: async (args) => {
|
|
1852
|
+
await activityFn(
|
|
1853
|
+
client,
|
|
1854
|
+
args.session_id,
|
|
1855
|
+
args.body,
|
|
1856
|
+
supportsEphemeral && args.ephemeral === true ? { ephemeral: true } : void 0
|
|
1857
|
+
);
|
|
1858
|
+
return { ok: true };
|
|
1859
|
+
}
|
|
1430
1860
|
});
|
|
1431
1861
|
};
|
|
1432
1862
|
addSessionBodyTool(
|
|
1433
1863
|
"linear_post_thought",
|
|
1434
1864
|
"Post a thought to the Linear agent session, visible to the user as internal reasoning.",
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
return { ok: true };
|
|
1438
|
-
}
|
|
1865
|
+
postThought,
|
|
1866
|
+
{ supportsEphemeral: true }
|
|
1439
1867
|
);
|
|
1440
1868
|
addSessionBodyTool(
|
|
1441
1869
|
"linear_post_action",
|
|
1442
1870
|
"Post an action to the Linear agent session, showing the user what step is being taken.",
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
return { ok: true };
|
|
1446
|
-
}
|
|
1871
|
+
postAction,
|
|
1872
|
+
{ supportsEphemeral: true }
|
|
1447
1873
|
);
|
|
1448
1874
|
addSessionBodyTool(
|
|
1449
1875
|
"linear_post_error",
|
|
1450
1876
|
"Post an error to the Linear agent session to notify the user of a failure.",
|
|
1451
|
-
|
|
1452
|
-
await postError(client, args.session_id, args.body);
|
|
1453
|
-
return { ok: true };
|
|
1454
|
-
}
|
|
1877
|
+
postError
|
|
1455
1878
|
);
|
|
1456
1879
|
if (enableElicitation) {
|
|
1457
|
-
|
|
1458
|
-
"linear_ask_user",
|
|
1459
|
-
"Ask the Linear user a question
|
|
1460
|
-
|
|
1461
|
-
|
|
1880
|
+
tools.push({
|
|
1881
|
+
name: "linear_ask_user",
|
|
1882
|
+
description: "Ask the Linear user a question. When options are provided, Linear renders them as a clickable picker (select signal); otherwise the user sees a free-text prompt.",
|
|
1883
|
+
schema: z.object({
|
|
1884
|
+
session_id: z.string().describe("The Linear agent session ID"),
|
|
1885
|
+
body: z.string().max(ELICITATION_BODY_MAX_LENGTH).describe("The question to ask, in Markdown format"),
|
|
1886
|
+
options: z.array(z.object({
|
|
1887
|
+
label: z.string().max(SELECT_OPTION_MAX_LENGTH).describe("Display text for the option"),
|
|
1888
|
+
value: z.string().max(SELECT_OPTION_MAX_LENGTH).describe("Value returned when the option is selected")
|
|
1889
|
+
})).min(2).max(20).optional().describe("Clickable options for a select picker (2\u201320 items). Omit for free-text input.")
|
|
1890
|
+
}),
|
|
1891
|
+
handler: async (args) => {
|
|
1892
|
+
const sessionId = args.session_id;
|
|
1893
|
+
const body = args.body;
|
|
1894
|
+
const options2 = args.options;
|
|
1895
|
+
if (options2) {
|
|
1896
|
+
await postSelectElicitation(client, sessionId, body, options2);
|
|
1897
|
+
} else {
|
|
1898
|
+
await postElicitation(client, sessionId, body);
|
|
1899
|
+
}
|
|
1462
1900
|
return { ok: true };
|
|
1463
1901
|
}
|
|
1464
|
-
);
|
|
1902
|
+
});
|
|
1903
|
+
tools.push({
|
|
1904
|
+
name: "linear_select",
|
|
1905
|
+
description: "Present the Linear user with a set of clickable options via a select elicitation. Use this instead of linear_ask_user when the user should pick from a known list of choices.",
|
|
1906
|
+
schema: z.object({
|
|
1907
|
+
session_id: z.string().describe("The Linear agent session ID"),
|
|
1908
|
+
body: z.string().describe("The question or prompt shown above the options (Markdown)"),
|
|
1909
|
+
options: z.array(
|
|
1910
|
+
z.object({
|
|
1911
|
+
label: z.string().describe("Display label for this option"),
|
|
1912
|
+
value: z.string().describe("Value returned when this option is selected")
|
|
1913
|
+
})
|
|
1914
|
+
).min(1).max(25).describe("The selectable options")
|
|
1915
|
+
}),
|
|
1916
|
+
handler: async (args) => {
|
|
1917
|
+
await postSelectElicitation(
|
|
1918
|
+
client,
|
|
1919
|
+
args.session_id,
|
|
1920
|
+
args.body,
|
|
1921
|
+
args.options
|
|
1922
|
+
);
|
|
1923
|
+
return { ok: true };
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
tools.push({
|
|
1927
|
+
name: "linear_request_auth",
|
|
1928
|
+
description: "Ask the Linear user to link an external account by presenting an authentication button. The user sees a 'Link account' UI that opens the provided URL.",
|
|
1929
|
+
schema: z.object({
|
|
1930
|
+
session_id: z.string().describe("The Linear agent session ID"),
|
|
1931
|
+
body: z.string().max(ELICITATION_BODY_MAX_LENGTH).describe("Explanation of why authentication is needed, in Markdown format"),
|
|
1932
|
+
url: z.string().url().refine(
|
|
1933
|
+
isAllowedAuthUrl,
|
|
1934
|
+
{ message: "URL must use https (http allowed only for localhost)" }
|
|
1935
|
+
).describe("The authentication URL to open when the user clicks the link button"),
|
|
1936
|
+
provider: z.string().min(1).max(PROVIDER_MAX_LENGTH).optional().describe("Name of the external service (e.g. 'GitHub', 'Slack')")
|
|
1937
|
+
}),
|
|
1938
|
+
handler: async (args) => {
|
|
1939
|
+
await postAuthElicitation(
|
|
1940
|
+
client,
|
|
1941
|
+
args.session_id,
|
|
1942
|
+
args.body,
|
|
1943
|
+
args.url,
|
|
1944
|
+
args.provider
|
|
1945
|
+
);
|
|
1946
|
+
return { ok: true };
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1465
1949
|
}
|
|
1466
|
-
|
|
1467
|
-
"linear_post_response",
|
|
1468
|
-
"Post the final response to the Linear agent session and mark the session completed when a store is available.",
|
|
1469
|
-
|
|
1950
|
+
tools.push({
|
|
1951
|
+
name: "linear_post_response",
|
|
1952
|
+
description: "Post the final response to the Linear agent session and mark the session completed when a store is available.",
|
|
1953
|
+
schema: sessionBodySchema,
|
|
1954
|
+
handler: async (args) => {
|
|
1470
1955
|
await completeLinearSession({
|
|
1471
1956
|
linearClient: client,
|
|
1472
1957
|
agentSessionId: args.session_id,
|
|
@@ -1475,11 +1960,11 @@ function createLinearTools(options) {
|
|
|
1475
1960
|
});
|
|
1476
1961
|
return { ok: true };
|
|
1477
1962
|
}
|
|
1478
|
-
);
|
|
1963
|
+
});
|
|
1479
1964
|
tools.push(
|
|
1480
1965
|
{
|
|
1481
1966
|
name: "linear_update_plan",
|
|
1482
|
-
description: "Update the plan for the Linear agent session
|
|
1967
|
+
description: "Update the structured plan for the Linear agent session. Renders as a native checklist in the Linear Agent Session UI with live status indicators.",
|
|
1483
1968
|
schema: z.object({
|
|
1484
1969
|
session_id: z.string().describe("The Linear agent session ID"),
|
|
1485
1970
|
steps: z.array(z.object({
|
|
@@ -1504,6 +1989,35 @@ function createLinearTools(options) {
|
|
|
1504
1989
|
optionalIssueIdSchema,
|
|
1505
1990
|
issueCommentLimitSchema
|
|
1506
1991
|
});
|
|
1992
|
+
if (typeof client.issueRepositorySuggestions === "function") {
|
|
1993
|
+
const suggestRepositories = client.issueRepositorySuggestions.bind(client);
|
|
1994
|
+
tools.push({
|
|
1995
|
+
name: "linear_suggest_repositories",
|
|
1996
|
+
description: "Ask Linear to rank candidate repositories by relevance for the current issue. Returns ranked suggestions with confidence scores. Use this before asking the user which repository to work in \u2014 if a suggestion has high confidence, auto-select it; otherwise present the top options via the select elicitation signal.",
|
|
1997
|
+
schema: requiredIssueIdSchema.extend({
|
|
1998
|
+
session_id: z.string().describe("The Linear agent session ID"),
|
|
1999
|
+
repositories: z.array(
|
|
2000
|
+
z.object({
|
|
2001
|
+
hostname: z.string().describe("Hostname of the Git service (e.g. 'github.com')"),
|
|
2002
|
+
repositoryFullName: z.string().describe("Full name in owner/name format (e.g. 'acme/backend')")
|
|
2003
|
+
})
|
|
2004
|
+
).min(1).max(50).describe("Candidate repositories the agent has access to")
|
|
2005
|
+
}),
|
|
2006
|
+
handler: async (args) => {
|
|
2007
|
+
const issueId = resolveIssueId("linear_suggest_repositories", args);
|
|
2008
|
+
const sessionId = args.session_id;
|
|
2009
|
+
const candidates = args.repositories;
|
|
2010
|
+
const response = await suggestRepositories(
|
|
2011
|
+
candidates,
|
|
2012
|
+
issueId,
|
|
2013
|
+
{ agentSessionId: sessionId }
|
|
2014
|
+
);
|
|
2015
|
+
const suggestions = extractRepositorySuggestions(response);
|
|
2016
|
+
return { suggestions };
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
addSessionCreationTools({ tools, client, store, logger });
|
|
1507
2021
|
return tools;
|
|
1508
2022
|
}
|
|
1509
2023
|
function addIssueTools(input) {
|
|
@@ -1557,6 +2071,7 @@ function addIssueTools(input) {
|
|
|
1557
2071
|
priority: z.number().int().min(0).max(4).optional().describe("Priority 0-4"),
|
|
1558
2072
|
state_id: z.string().optional().describe("Workflow state ID"),
|
|
1559
2073
|
assignee_id: z.string().nullable().optional().describe("Assignee user ID, or null to unassign"),
|
|
2074
|
+
delegate_id: z.string().nullable().optional().describe("Agent user ID to delegate the issue to, or null to clear delegate"),
|
|
1560
2075
|
estimate: z.number().int().optional().describe("Estimate points"),
|
|
1561
2076
|
due_date: z.string().optional().describe("Due date ISO string")
|
|
1562
2077
|
}),
|
|
@@ -1565,7 +2080,7 @@ function addIssueTools(input) {
|
|
|
1565
2080
|
throw new Error("linear_update_issue is unavailable: Linear client does not support updateIssue().");
|
|
1566
2081
|
}
|
|
1567
2082
|
const issueId = resolveIssueId("linear_update_issue", args);
|
|
1568
|
-
const hasUpdates = args.title !== void 0 || args.description !== void 0 || args.priority !== void 0 || args.state_id !== void 0 || args.assignee_id !== void 0 || args.estimate !== void 0 || args.due_date !== void 0;
|
|
2083
|
+
const hasUpdates = args.title !== void 0 || args.description !== void 0 || args.priority !== void 0 || args.state_id !== void 0 || args.assignee_id !== void 0 || args.delegate_id !== void 0 || args.estimate !== void 0 || args.due_date !== void 0;
|
|
1569
2084
|
if (!hasUpdates) {
|
|
1570
2085
|
throw new Error("linear_update_issue requires at least one update field.");
|
|
1571
2086
|
}
|
|
@@ -1577,6 +2092,7 @@ function addIssueTools(input) {
|
|
|
1577
2092
|
...args.priority !== void 0 ? { priority: args.priority } : {},
|
|
1578
2093
|
...args.state_id !== void 0 ? { stateId: args.state_id } : {},
|
|
1579
2094
|
...args.assignee_id !== void 0 ? { assigneeId: args.assignee_id } : {},
|
|
2095
|
+
...args.delegate_id !== void 0 ? { delegateId: args.delegate_id } : {},
|
|
1580
2096
|
...args.estimate !== void 0 ? { estimate: args.estimate } : {},
|
|
1581
2097
|
...args.due_date !== void 0 ? { dueDate: args.due_date } : {}
|
|
1582
2098
|
}
|
|
@@ -1607,7 +2123,7 @@ function addIssueTools(input) {
|
|
|
1607
2123
|
function assertUuid(toolName, value) {
|
|
1608
2124
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1609
2125
|
if (!uuidPattern.test(value)) {
|
|
1610
|
-
throw new Error(`${toolName} requires the exact Linear
|
|
2126
|
+
throw new Error(`${toolName} requires the exact Linear UUID from the session context (not the human-readable identifier). Received "${value}".`);
|
|
1611
2127
|
}
|
|
1612
2128
|
}
|
|
1613
2129
|
function resolveIssueId(toolName, args) {
|
|
@@ -1650,6 +2166,11 @@ async function readIssue(client, issueId) {
|
|
|
1650
2166
|
id: issue.assignee.id ?? null,
|
|
1651
2167
|
name: issue.assignee.displayName ?? issue.assignee.name ?? null
|
|
1652
2168
|
} : null,
|
|
2169
|
+
delegate: issue.delegate ? {
|
|
2170
|
+
id: issue.delegate.id ?? null,
|
|
2171
|
+
name: issue.delegate.displayName ?? issue.delegate.name ?? null
|
|
2172
|
+
} : null,
|
|
2173
|
+
delegate_id: issue.delegateId ?? null,
|
|
1653
2174
|
team: issue.team ? {
|
|
1654
2175
|
id: issue.team.id ?? null,
|
|
1655
2176
|
key: issue.team.key ?? null,
|
|
@@ -1705,6 +2226,296 @@ async function readWorkflowStates(client, teamId) {
|
|
|
1705
2226
|
})).sort((left, right) => (left.position ?? Number.MAX_SAFE_INTEGER) - (right.position ?? Number.MAX_SAFE_INTEGER));
|
|
1706
2227
|
return { team_id: teamId, states };
|
|
1707
2228
|
}
|
|
2229
|
+
function addSessionCreationTools(input) {
|
|
2230
|
+
const { tools, client, store, logger } = input;
|
|
2231
|
+
const sessionCreationBaseSchema = z.object({
|
|
2232
|
+
external_link: z.string().url().optional().describe("Optional URL of an external page associated with this session"),
|
|
2233
|
+
room_id: z.string().min(1).optional().describe("The Band room ID to persist the session-room mapping. Pass this when creating a session from within a Band conversation.")
|
|
2234
|
+
});
|
|
2235
|
+
const sessionCreationSchema = sessionCreationBaseSchema.extend({
|
|
2236
|
+
issue_id: z.string().uuid().describe("The Linear issue ID (UUID)")
|
|
2237
|
+
});
|
|
2238
|
+
async function persistSessionRoom(sessionId, issueId, roomId) {
|
|
2239
|
+
if (!store || typeof roomId !== "string") return null;
|
|
2240
|
+
try {
|
|
2241
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2242
|
+
await store.upsert({
|
|
2243
|
+
linearSessionId: sessionId,
|
|
2244
|
+
linearIssueId: issueId,
|
|
2245
|
+
bandRoomId: roomId,
|
|
2246
|
+
status: "active",
|
|
2247
|
+
createdAt: now,
|
|
2248
|
+
updatedAt: now
|
|
2249
|
+
});
|
|
2250
|
+
return null;
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
logger?.warn("linear_tools.session_room_persist_failed", {
|
|
2253
|
+
sessionId,
|
|
2254
|
+
issueId,
|
|
2255
|
+
roomId,
|
|
2256
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2257
|
+
});
|
|
2258
|
+
return "session-room mapping not persisted";
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
const createOnIssue = client.agentSessionCreateOnIssue?.bind(client);
|
|
2262
|
+
if (typeof createOnIssue === "function") {
|
|
2263
|
+
tools.push({
|
|
2264
|
+
name: "linear_create_session_on_issue",
|
|
2265
|
+
description: "Create a new Linear agent session on an existing issue. Use this when the conversation produces work that should be tracked against a known Linear issue and no session exists yet.",
|
|
2266
|
+
schema: sessionCreationSchema,
|
|
2267
|
+
handler: async (args) => {
|
|
2268
|
+
const issueId = args.issue_id;
|
|
2269
|
+
const result = await createOnIssue({
|
|
2270
|
+
issueId,
|
|
2271
|
+
...typeof args.external_link === "string" ? { externalLink: args.external_link } : {}
|
|
2272
|
+
});
|
|
2273
|
+
const session = extractCreatedSession(result, logger);
|
|
2274
|
+
const warning = await persistSessionRoom(session.id, session.issueId, args.room_id);
|
|
2275
|
+
return { ok: true, session, ...warning ? { warning } : {} };
|
|
2276
|
+
}
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
const createOnComment = client.agentSessionCreateOnComment?.bind(client);
|
|
2280
|
+
if (typeof createOnComment === "function") {
|
|
2281
|
+
tools.push({
|
|
2282
|
+
name: "linear_create_session_on_comment",
|
|
2283
|
+
description: "Create a new Linear agent session on a specific comment thread. Use this to attach agent work to an existing discussion on a Linear issue.",
|
|
2284
|
+
schema: sessionCreationBaseSchema.extend({
|
|
2285
|
+
comment_id: z.string().uuid().describe("The Linear comment ID (UUID)")
|
|
2286
|
+
}),
|
|
2287
|
+
handler: async (args) => {
|
|
2288
|
+
const commentId = args.comment_id;
|
|
2289
|
+
const result = await createOnComment({
|
|
2290
|
+
commentId,
|
|
2291
|
+
...typeof args.external_link === "string" ? { externalLink: args.external_link } : {}
|
|
2292
|
+
});
|
|
2293
|
+
const session = extractCreatedSession(result, logger);
|
|
2294
|
+
const warning = await persistSessionRoom(session.id, session.issueId, args.room_id);
|
|
2295
|
+
return { ok: true, session, ...warning ? { warning } : {} };
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
const createIssueFn = client.createIssue?.bind(client);
|
|
2300
|
+
if (typeof createIssueFn === "function") {
|
|
2301
|
+
tools.push({
|
|
2302
|
+
name: "linear_create_issue",
|
|
2303
|
+
description: "Create a new Linear issue from scratch. Use this when the Band conversation produces work that should be tracked as a new Linear issue. After creating the issue, call linear_create_session_on_issue to attach an agent session. Never create issues without explicit human intent or clear delegation.",
|
|
2304
|
+
schema: z.object({
|
|
2305
|
+
team_id: z.string().uuid().describe("The Linear team ID to create the issue in"),
|
|
2306
|
+
title: z.string().min(1).describe("Issue title"),
|
|
2307
|
+
description: z.string().optional().describe("Issue description in Markdown"),
|
|
2308
|
+
priority: z.number().int().min(0).max(4).optional().describe("Priority 0-4 (0=none, 1=urgent, 2=high, 3=normal, 4=low)"),
|
|
2309
|
+
assignee_id: z.string().uuid().optional().describe("Assignee user ID (UUID)"),
|
|
2310
|
+
state_id: z.string().uuid().optional().describe("Workflow state ID (UUID)"),
|
|
2311
|
+
label_ids: z.array(z.string().uuid()).optional().describe("Label IDs to attach (UUIDs)")
|
|
2312
|
+
}),
|
|
2313
|
+
handler: async (args) => {
|
|
2314
|
+
const result = await createIssueFn({
|
|
2315
|
+
teamId: args.team_id,
|
|
2316
|
+
title: args.title,
|
|
2317
|
+
...typeof args.description === "string" ? { description: args.description } : {},
|
|
2318
|
+
...typeof args.priority === "number" ? { priority: args.priority } : {},
|
|
2319
|
+
...typeof args.assignee_id === "string" ? { assigneeId: args.assignee_id } : {},
|
|
2320
|
+
...typeof args.state_id === "string" ? { stateId: args.state_id } : {},
|
|
2321
|
+
...Array.isArray(args.label_ids) ? { labelIds: args.label_ids } : {}
|
|
2322
|
+
});
|
|
2323
|
+
const issue = extractCreatedIssue(result, logger);
|
|
2324
|
+
return { ok: true, issue };
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
function extractCreatedSession(result, logger) {
|
|
2330
|
+
const payload = typeof result === "object" && result !== null ? result : {};
|
|
2331
|
+
const session = typeof payload.agentSession === "object" && payload.agentSession !== null ? payload.agentSession : payload;
|
|
2332
|
+
const id = typeof session.id === "string" ? session.id : null;
|
|
2333
|
+
if (!id) {
|
|
2334
|
+
logger?.error("linear_tools.orphan_session_created", {
|
|
2335
|
+
raw: safeStringify(result)
|
|
2336
|
+
});
|
|
2337
|
+
throw new Error("Linear API returned a session without an ID.");
|
|
2338
|
+
}
|
|
2339
|
+
return {
|
|
2340
|
+
id,
|
|
2341
|
+
issueId: typeof session.issueId === "string" ? session.issueId : null,
|
|
2342
|
+
status: typeof session.status === "string" ? session.status : null
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
function extractCreatedIssue(result, logger) {
|
|
2346
|
+
const payload = typeof result === "object" && result !== null ? result : {};
|
|
2347
|
+
const issue = typeof payload.issue === "object" && payload.issue !== null ? payload.issue : payload;
|
|
2348
|
+
const id = typeof issue.id === "string" ? issue.id : null;
|
|
2349
|
+
if (!id) {
|
|
2350
|
+
logger?.error("linear_tools.orphan_issue_created", {
|
|
2351
|
+
raw: safeStringify(result)
|
|
2352
|
+
});
|
|
2353
|
+
throw new Error("Linear API returned an issue without an ID.");
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
id,
|
|
2357
|
+
identifier: typeof issue.identifier === "string" ? issue.identifier : null,
|
|
2358
|
+
url: typeof issue.url === "string" ? issue.url : null,
|
|
2359
|
+
title: typeof issue.title === "string" ? issue.title : null
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
function safeStringify(value) {
|
|
2363
|
+
try {
|
|
2364
|
+
return JSON.stringify(value);
|
|
2365
|
+
} catch {
|
|
2366
|
+
return String(value);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
function extractRepositorySuggestions(response) {
|
|
2370
|
+
if (response == null || typeof response !== "object") {
|
|
2371
|
+
return [];
|
|
2372
|
+
}
|
|
2373
|
+
const payload = response;
|
|
2374
|
+
if (!Array.isArray(payload.suggestions)) {
|
|
2375
|
+
return [];
|
|
2376
|
+
}
|
|
2377
|
+
return payload.suggestions.filter(
|
|
2378
|
+
(entry) => entry != null && typeof entry === "object" && typeof entry.repositoryFullName === "string"
|
|
2379
|
+
).map((entry) => ({
|
|
2380
|
+
repositoryFullName: entry.repositoryFullName,
|
|
2381
|
+
hostname: typeof entry.hostname === "string" ? entry.hostname : null,
|
|
2382
|
+
confidence: typeof entry.confidence === "number" ? entry.confidence : 0
|
|
2383
|
+
})).sort((a, b) => b.confidence - a.confidence);
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// src/integrations/linear/notification.ts
|
|
2387
|
+
var MAX_COMMENT_LENGTH = 4e3;
|
|
2388
|
+
var MAX_ACTOR_NAME_LENGTH = 100;
|
|
2389
|
+
function sanitizeInlineText(text) {
|
|
2390
|
+
return text.replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
2391
|
+
}
|
|
2392
|
+
function sanitizeBodyText(text) {
|
|
2393
|
+
return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "").trim();
|
|
2394
|
+
}
|
|
2395
|
+
async function handleAppUserNotification(input) {
|
|
2396
|
+
const { payload, deps, logger, appUserId } = input;
|
|
2397
|
+
const notification = payload.notification;
|
|
2398
|
+
const typename = notification.__typename;
|
|
2399
|
+
switch (typename) {
|
|
2400
|
+
case "IssueUnassignedFromYouNotificationWebhookPayload":
|
|
2401
|
+
await handleIssueUnassigned({
|
|
2402
|
+
notification,
|
|
2403
|
+
deps,
|
|
2404
|
+
logger
|
|
2405
|
+
});
|
|
2406
|
+
return;
|
|
2407
|
+
case "IssueNewCommentNotificationWebhookPayload":
|
|
2408
|
+
await handleIssueNewComment({
|
|
2409
|
+
notification,
|
|
2410
|
+
deps,
|
|
2411
|
+
logger,
|
|
2412
|
+
appUserId
|
|
2413
|
+
});
|
|
2414
|
+
return;
|
|
2415
|
+
case "IssueCommentReactionNotificationWebhookPayload":
|
|
2416
|
+
case "IssueEmojiReactionNotificationWebhookPayload":
|
|
2417
|
+
logReaction({
|
|
2418
|
+
notification,
|
|
2419
|
+
logger
|
|
2420
|
+
});
|
|
2421
|
+
return;
|
|
2422
|
+
default:
|
|
2423
|
+
logger.info("linear_thenvoi_bridge.notification_unhandled", {
|
|
2424
|
+
notificationType: typename
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
async function handleIssueUnassigned(input) {
|
|
2429
|
+
const { notification, deps, logger } = input;
|
|
2430
|
+
const existing = await deps.store.getByIssueId(notification.issueId);
|
|
2431
|
+
if (!existing || existing.status !== "active" && existing.status !== "waiting") {
|
|
2432
|
+
logger.info("linear_thenvoi_bridge.notification_unassigned_no_session", {
|
|
2433
|
+
issueId: notification.issueId,
|
|
2434
|
+
reason: existing ? `session_status_${existing.status}` : "no_active_session"
|
|
2435
|
+
});
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
await deps.bandRest.createChatEvent(existing.bandRoomId, {
|
|
2439
|
+
content: "[Linear]: Issue unassigned from agent. Disengage from active work and await reassignment.",
|
|
2440
|
+
messageType: "task",
|
|
2441
|
+
metadata: {
|
|
2442
|
+
linear_notification_type: "issueUnassignedFromYou",
|
|
2443
|
+
linear_issue_id: notification.issueId,
|
|
2444
|
+
linear_actor_id: notification.actorId ?? null,
|
|
2445
|
+
linear_bridge: "band"
|
|
2446
|
+
}
|
|
2447
|
+
});
|
|
2448
|
+
await deps.store.markCanceled(existing.linearSessionId);
|
|
2449
|
+
logger.info("linear_thenvoi_bridge.notification_unassigned_handled", {
|
|
2450
|
+
issueId: notification.issueId,
|
|
2451
|
+
sessionId: existing.linearSessionId,
|
|
2452
|
+
roomId: existing.bandRoomId
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
async function handleIssueNewComment(input) {
|
|
2456
|
+
const { notification, deps, logger, appUserId } = input;
|
|
2457
|
+
if (appUserId && notification.actorId === appUserId) {
|
|
2458
|
+
logger.info("linear_thenvoi_bridge.notification_comment_self_skipped", {
|
|
2459
|
+
issueId: notification.issueId,
|
|
2460
|
+
commentId: notification.commentId,
|
|
2461
|
+
actorId: notification.actorId
|
|
2462
|
+
});
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
const existing = await deps.store.getByIssueId(notification.issueId);
|
|
2466
|
+
if (!existing || existing.status !== "active" && existing.status !== "waiting") {
|
|
2467
|
+
logger.info("linear_thenvoi_bridge.notification_comment_skipped", {
|
|
2468
|
+
issueId: notification.issueId,
|
|
2469
|
+
reason: existing ? `session_status_${existing.status}` : "no_active_session"
|
|
2470
|
+
});
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
let actorName = sanitizeInlineText(notification.actor?.name ?? "");
|
|
2474
|
+
if (actorName.length > MAX_ACTOR_NAME_LENGTH) {
|
|
2475
|
+
actorName = actorName.slice(0, MAX_ACTOR_NAME_LENGTH) + "\u2026";
|
|
2476
|
+
}
|
|
2477
|
+
if (actorName.length === 0) {
|
|
2478
|
+
actorName = "Unknown user";
|
|
2479
|
+
}
|
|
2480
|
+
let commentBody = sanitizeBodyText(notification.comment.body ?? "");
|
|
2481
|
+
if (commentBody.length > MAX_COMMENT_LENGTH) {
|
|
2482
|
+
commentBody = commentBody.slice(0, MAX_COMMENT_LENGTH) + "\u2026";
|
|
2483
|
+
}
|
|
2484
|
+
if (!notification.actor?.name) {
|
|
2485
|
+
logger.info("linear_thenvoi_bridge.notification_comment_actor_fallback", {
|
|
2486
|
+
issueId: notification.issueId,
|
|
2487
|
+
commentId: notification.commentId,
|
|
2488
|
+
actorId: notification.actorId ?? null
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
await deps.bandRest.createChatEvent(existing.bandRoomId, {
|
|
2492
|
+
content: `[Linear Comment from ${actorName}]: ${commentBody}`,
|
|
2493
|
+
messageType: "text",
|
|
2494
|
+
metadata: {
|
|
2495
|
+
linear_notification_type: "issueNewComment",
|
|
2496
|
+
linear_issue_id: notification.issueId,
|
|
2497
|
+
linear_comment_id: notification.commentId,
|
|
2498
|
+
linear_actor_id: notification.actorId ?? null,
|
|
2499
|
+
linear_bridge: "band"
|
|
2500
|
+
}
|
|
2501
|
+
});
|
|
2502
|
+
logger.info("linear_thenvoi_bridge.notification_comment_forwarded", {
|
|
2503
|
+
issueId: notification.issueId,
|
|
2504
|
+
commentId: notification.commentId,
|
|
2505
|
+
sessionId: existing.linearSessionId,
|
|
2506
|
+
roomId: existing.bandRoomId
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
function logReaction(input) {
|
|
2510
|
+
const { notification, logger } = input;
|
|
2511
|
+
logger.info("linear_thenvoi_bridge.notification_reaction", {
|
|
2512
|
+
notificationType: notification.__typename,
|
|
2513
|
+
issueId: notification.issueId,
|
|
2514
|
+
actorId: notification.actorId ?? null,
|
|
2515
|
+
commentId: "commentId" in notification ? notification.commentId : null,
|
|
2516
|
+
reactionEmoji: notification.reactionEmoji
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
1708
2519
|
|
|
1709
2520
|
// src/integrations/linear/webhook.ts
|
|
1710
2521
|
import {
|
|
@@ -1844,6 +2655,7 @@ function createLinearWebhookHandler(options) {
|
|
|
1844
2655
|
const dispatcher = options.dispatcher ?? createInlineLinearBridgeDispatcher({ logger });
|
|
1845
2656
|
const webhookClient = new LinearWebhookClient(options.config.linearWebhookSecret);
|
|
1846
2657
|
const inFlightEventKeys = /* @__PURE__ */ new Set();
|
|
2658
|
+
const processedNotificationIds = /* @__PURE__ */ new Set();
|
|
1847
2659
|
return async (request, response) => {
|
|
1848
2660
|
if (request.method !== "POST") {
|
|
1849
2661
|
logger.warn("linear_thenvoi_bridge.webhook_invalid_method", {
|
|
@@ -1865,9 +2677,9 @@ function createLinearWebhookHandler(options) {
|
|
|
1865
2677
|
return;
|
|
1866
2678
|
}
|
|
1867
2679
|
const rawBody = await readRawBody(request);
|
|
1868
|
-
let
|
|
2680
|
+
let parsed;
|
|
1869
2681
|
try {
|
|
1870
|
-
|
|
2682
|
+
parsed = webhookClient.parseData(
|
|
1871
2683
|
rawBody,
|
|
1872
2684
|
signature,
|
|
1873
2685
|
timestamp
|
|
@@ -1879,13 +2691,111 @@ function createLinearWebhookHandler(options) {
|
|
|
1879
2691
|
sendText(response, 400, "Invalid webhook");
|
|
1880
2692
|
return;
|
|
1881
2693
|
}
|
|
1882
|
-
if (
|
|
2694
|
+
if (parsed.type === "AppUserNotification") {
|
|
2695
|
+
const notificationPayload = parsed;
|
|
2696
|
+
if (!notificationPayload.notification) {
|
|
2697
|
+
logger.warn("linear_thenvoi_bridge.webhook_notification_missing_payload", {
|
|
2698
|
+
appUserId: notificationPayload.appUserId ?? null,
|
|
2699
|
+
organizationId: notificationPayload.organizationId ?? null
|
|
2700
|
+
});
|
|
2701
|
+
sendText(response, 200, "OK");
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
const notificationId = notificationPayload.notification?.id;
|
|
2705
|
+
const notificationType = notificationPayload.notification.__typename;
|
|
2706
|
+
const isDedupable = notificationType !== "IssueCommentReactionNotificationWebhookPayload" && notificationType !== "IssueEmojiReactionNotificationWebhookPayload";
|
|
2707
|
+
if (isDedupable && notificationId && processedNotificationIds.has(notificationId)) {
|
|
2708
|
+
logger.info("linear_thenvoi_bridge.webhook_notification_duplicate_ignored", {
|
|
2709
|
+
notificationId
|
|
2710
|
+
});
|
|
2711
|
+
sendText(response, 200, "OK");
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
logger.info("linear_thenvoi_bridge.webhook_notification_received", {
|
|
2715
|
+
notificationType: notificationType ?? null,
|
|
2716
|
+
notificationId: notificationId ?? null,
|
|
2717
|
+
appUserId: notificationPayload.appUserId,
|
|
2718
|
+
organizationId: notificationPayload.organizationId
|
|
2719
|
+
});
|
|
2720
|
+
try {
|
|
2721
|
+
await handleAppUserNotification({
|
|
2722
|
+
payload: notificationPayload,
|
|
2723
|
+
deps: options.deps,
|
|
2724
|
+
logger,
|
|
2725
|
+
appUserId: notificationPayload.appUserId
|
|
2726
|
+
});
|
|
2727
|
+
if (isDedupable && notificationId) {
|
|
2728
|
+
processedNotificationIds.add(notificationId);
|
|
2729
|
+
if (processedNotificationIds.size > 1e3) {
|
|
2730
|
+
const oldest = processedNotificationIds.values().next().value;
|
|
2731
|
+
if (oldest) processedNotificationIds.delete(oldest);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
} catch (error) {
|
|
2735
|
+
logger.error("linear_thenvoi_bridge.webhook_notification_failed", {
|
|
2736
|
+
notificationType: notificationType ?? null,
|
|
2737
|
+
error: serializeError(error)
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
sendText(response, 200, "OK");
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
if (parsed.type === "PermissionChange") {
|
|
2744
|
+
const action = parsed.action;
|
|
2745
|
+
if (normalizeAction2(action) === "teamaccesschanged") {
|
|
2746
|
+
const permPayload = parsed;
|
|
2747
|
+
logger.info("linear_thenvoi_bridge.team_access_changed", {
|
|
2748
|
+
action: permPayload.action,
|
|
2749
|
+
addedTeamIds: permPayload.addedTeamIds,
|
|
2750
|
+
removedTeamIds: permPayload.removedTeamIds,
|
|
2751
|
+
canAccessAllPublicTeams: permPayload.canAccessAllPublicTeams,
|
|
2752
|
+
organizationId: permPayload.organizationId
|
|
2753
|
+
});
|
|
2754
|
+
try {
|
|
2755
|
+
await options.permissionCallbacks?.onTeamAccessChanged?.(permPayload);
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
logger.error("linear_thenvoi_bridge.team_access_changed_callback_failed", {
|
|
2758
|
+
error: serializeError(error)
|
|
2759
|
+
});
|
|
2760
|
+
}
|
|
2761
|
+
} else {
|
|
2762
|
+
logger.info("linear_thenvoi_bridge.permission_change_event_ignored", {
|
|
2763
|
+
action
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
sendText(response, 200, "OK");
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
if (parsed.type === "OAuthApp") {
|
|
2770
|
+
const oauthPayload = parsed;
|
|
2771
|
+
if (normalizeAction2(oauthPayload.action) === "revoked") {
|
|
2772
|
+
logger.warn("linear_thenvoi_bridge.oauth_app_revoked", {
|
|
2773
|
+
oauthClientId: oauthPayload.oauthClientId,
|
|
2774
|
+
organizationId: oauthPayload.organizationId
|
|
2775
|
+
});
|
|
2776
|
+
try {
|
|
2777
|
+
await options.permissionCallbacks?.onOAuthAppRevoked?.(oauthPayload);
|
|
2778
|
+
} catch (error) {
|
|
2779
|
+
logger.error("linear_thenvoi_bridge.oauth_app_revoked_callback_failed", {
|
|
2780
|
+
error: serializeError(error)
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
} else {
|
|
2784
|
+
logger.info("linear_thenvoi_bridge.oauth_app_event_ignored", {
|
|
2785
|
+
action: oauthPayload.action
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
sendText(response, 200, "OK");
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
if (parsed.type !== "AgentSessionEvent") {
|
|
1883
2792
|
logger.info("linear_thenvoi_bridge.webhook_ignored_event", {
|
|
1884
|
-
type:
|
|
2793
|
+
type: parsed.type
|
|
1885
2794
|
});
|
|
1886
2795
|
sendText(response, 200, "OK");
|
|
1887
2796
|
return;
|
|
1888
2797
|
}
|
|
2798
|
+
const payload = parsed;
|
|
1889
2799
|
const eventKey = getAgentSessionEventKey(payload);
|
|
1890
2800
|
logger.info("linear_thenvoi_bridge.webhook_received", {
|
|
1891
2801
|
sessionId: payload.agentSession.id,
|
|
@@ -2117,6 +3027,12 @@ var DEFAULT_STATUS_MAPPING = {
|
|
|
2117
3027
|
};
|
|
2118
3028
|
export {
|
|
2119
3029
|
DEFAULT_STATUS_MAPPING,
|
|
3030
|
+
ELICITATION_BODY_MAX_LENGTH,
|
|
3031
|
+
PROVIDER_MAX_LENGTH,
|
|
3032
|
+
SELECT_OPTION_MAX_LENGTH,
|
|
3033
|
+
STALE_SESSION_CHECK_INTERVAL_MS,
|
|
3034
|
+
STALE_SESSION_THRESHOLD_MS,
|
|
3035
|
+
StaleSessionGuard,
|
|
2120
3036
|
buildLinearAuthorizationHeader,
|
|
2121
3037
|
completeLinearSession,
|
|
2122
3038
|
createInProcessLinearBridgeDispatcher,
|
|
@@ -2128,12 +3044,17 @@ export {
|
|
|
2128
3044
|
createSqliteSessionRoomStore,
|
|
2129
3045
|
dedupeHandles,
|
|
2130
3046
|
handleAgentSessionEvent,
|
|
3047
|
+
handleAppUserNotification,
|
|
2131
3048
|
isLinearApiKey,
|
|
3049
|
+
isSessionStale,
|
|
2132
3050
|
postAction,
|
|
3051
|
+
postAuthElicitation,
|
|
2133
3052
|
postElicitation,
|
|
2134
3053
|
postError,
|
|
2135
3054
|
postResponse,
|
|
3055
|
+
postSelectElicitation,
|
|
2136
3056
|
postThought,
|
|
3057
|
+
sendRecoveryActivityIfStale,
|
|
2137
3058
|
stripHandlePrefix,
|
|
2138
3059
|
updatePlan
|
|
2139
3060
|
};
|