codekanban 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ import { CodeKanbanConfigError, CodeKanbanError, CodeKanbanValidationError } from './errors.js';
2
+ import {
3
+ WEB_SESSION_PROTOCOL_VERSION,
4
+ buildWebSessionCommandFrame,
5
+ buildWebSessionHeartbeatFrame,
6
+ decodeWebSessionSocketMessage,
7
+ isWebSessionHeartbeatFrame,
8
+ normalizeWebSessionFrame,
9
+ } from './web-session-shared.js';
10
+ import { ensureArrayOfStrings, ensureOptionalString, ensureString } from './utils.js';
11
+
12
+ const SOCKET_OPEN = 1;
13
+ const DEFAULT_OPEN_TIMEOUT_MS = 8000;
14
+ const ACK_SETTLE_DELAY_MS = 10;
15
+
16
+ function createRequestId() {
17
+ return `web_ws_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
18
+ }
19
+
20
+ function normalizeHistoryLimit(value) {
21
+ const parsed = Number(value);
22
+ if (!Number.isFinite(parsed) || parsed <= 0) {
23
+ return 80;
24
+ }
25
+ return Math.max(1, Math.trunc(parsed));
26
+ }
27
+
28
+ export class WebSessionCommandChannel {
29
+ constructor({ url, WebSocketImpl } = {}) {
30
+ const resolvedUrl = ensureString(url, 'url');
31
+ const Socket = WebSocketImpl || globalThis.WebSocket;
32
+ if (!Socket) {
33
+ throw new CodeKanbanConfigError('WebSocket implementation is unavailable');
34
+ }
35
+
36
+ this.url = resolvedUrl;
37
+ this.socket = new Socket(resolvedUrl);
38
+ this.protocolVersion = WEB_SESSION_PROTOCOL_VERSION;
39
+ this._pendingRequests = new Map();
40
+ this._pendingFollowUp = null;
41
+ this._commandQueue = Promise.resolve();
42
+ this._openPromise = new Promise((resolve, reject) => {
43
+ this._resolveOpen = resolve;
44
+ this._rejectOpen = reject;
45
+ });
46
+ this._closed = false;
47
+
48
+ this.socket.addEventListener('open', () => {
49
+ this._resolveOpen?.();
50
+ });
51
+
52
+ this.socket.addEventListener('message', event => {
53
+ this._handleMessage(event.data);
54
+ });
55
+
56
+ this.socket.addEventListener('error', event => {
57
+ const error = new CodeKanbanError('web session command websocket error', { event });
58
+ this._rejectOpen?.(error);
59
+ this._rejectAll(error);
60
+ });
61
+
62
+ this.socket.addEventListener('close', event => {
63
+ this._closed = true;
64
+ const error = new CodeKanbanError('web session command websocket closed', { event });
65
+ this._rejectOpen?.(error);
66
+ this._rejectAll(error);
67
+ });
68
+ }
69
+
70
+ async waitForOpen(timeoutMs = DEFAULT_OPEN_TIMEOUT_MS) {
71
+ if (this.socket.readyState === SOCKET_OPEN) {
72
+ return;
73
+ }
74
+ await Promise.race([
75
+ this._openPromise,
76
+ new Promise((_, reject) => {
77
+ setTimeout(
78
+ () => reject(new CodeKanbanValidationError(`web session command channel did not open within ${timeoutMs}ms`)),
79
+ timeoutMs,
80
+ );
81
+ }),
82
+ ]);
83
+ }
84
+
85
+ async list(projectId) {
86
+ const ack = await this._executeCommand({
87
+ operation: 'list',
88
+ payload: {
89
+ pid: ensureString(projectId, 'projectId'),
90
+ },
91
+ });
92
+ return Array.isArray(ack.payload?.items) ? ack.payload.items : [];
93
+ }
94
+
95
+ async create(input = {}) {
96
+ const { frame } = await this._executeCommand({
97
+ operation: 'create',
98
+ payload: {
99
+ pid: ensureString(input.projectId, 'projectId'),
100
+ wid: ensureOptionalString(input.worktreeId),
101
+ ag: ensureString(input.agent, 'agent'),
102
+ md: ensureOptionalString(input.model),
103
+ re: ensureOptionalString(input.reasoningEffort),
104
+ wm: ensureOptionalString(input.workflowMode),
105
+ pl: ensureOptionalString(input.permissionLevel),
106
+ pm: ensureOptionalString(input.permissionMode),
107
+ ttl: ensureOptionalString(input.title),
108
+ },
109
+ expectType: 'snapshot',
110
+ });
111
+ return frame.snapshot;
112
+ }
113
+
114
+ async connect(sessionId) {
115
+ const { frame } = await this._executeCommand({
116
+ operation: 'connect',
117
+ sessionId,
118
+ expectType: 'snapshot',
119
+ });
120
+ return frame.snapshot;
121
+ }
122
+
123
+ async history(sessionId, options = {}) {
124
+ const { frame } = await this._executeCommand({
125
+ operation: 'hist',
126
+ sessionId,
127
+ payload: {
128
+ lim: normalizeHistoryLimit(options.limit),
129
+ bc: ensureOptionalString(options.beforeCursor),
130
+ },
131
+ expectType: 'historyPage',
132
+ });
133
+ return frame.history;
134
+ }
135
+
136
+ async sendMessage(sessionId, input = {}) {
137
+ const text = ensureOptionalString(input.text);
138
+ const attachmentIds = ensureArrayOfStrings(input.attachmentIds, 'attachmentIds');
139
+ if (!text && attachmentIds.length === 0) {
140
+ throw new CodeKanbanValidationError('text or attachmentIds is required');
141
+ }
142
+ return await this._executeCommand({
143
+ operation: 'send',
144
+ sessionId,
145
+ payload: {
146
+ txt: text,
147
+ atts: attachmentIds,
148
+ },
149
+ });
150
+ }
151
+
152
+ async abort(sessionId) {
153
+ return await this._executeCommand({ operation: 'abort', sessionId });
154
+ }
155
+
156
+ async approve(sessionId) {
157
+ return await this._executeCommand({ operation: 'approve', sessionId });
158
+ }
159
+
160
+ async reject(sessionId) {
161
+ return await this._executeCommand({ operation: 'reject', sessionId });
162
+ }
163
+
164
+ async answerUserInput(sessionId, input = {}) {
165
+ return await this._executeCommand({
166
+ operation: 'user_input',
167
+ sessionId,
168
+ payload: {
169
+ iid: ensureString(input.itemId, 'itemId'),
170
+ ans: input.answers && typeof input.answers === 'object' ? input.answers : {},
171
+ },
172
+ });
173
+ }
174
+
175
+ async rename(sessionId, input = {}) {
176
+ return await this._executeCommand({
177
+ operation: 'rename',
178
+ sessionId,
179
+ payload: {
180
+ ttl: ensureString(input.title, 'title'),
181
+ },
182
+ });
183
+ }
184
+
185
+ async updateModel(sessionId, input = {}) {
186
+ return await this._executeCommand({
187
+ operation: 'set_md',
188
+ sessionId,
189
+ payload: {
190
+ md: ensureString(input.model, 'model'),
191
+ },
192
+ });
193
+ }
194
+
195
+ async updateReasoningEffort(sessionId, input = {}) {
196
+ return await this._executeCommand({
197
+ operation: 'set_re',
198
+ sessionId,
199
+ payload: {
200
+ re: ensureString(input.reasoningEffort, 'reasoningEffort'),
201
+ },
202
+ });
203
+ }
204
+
205
+ async updateWorkflowMode(sessionId, input = {}) {
206
+ return await this._executeCommand({
207
+ operation: 'set_wm',
208
+ sessionId,
209
+ payload: {
210
+ wm: ensureString(input.workflowMode, 'workflowMode'),
211
+ },
212
+ });
213
+ }
214
+
215
+ async updatePermissionLevel(sessionId, input = {}) {
216
+ return await this._executeCommand({
217
+ operation: 'set_pl',
218
+ sessionId,
219
+ payload: {
220
+ pl: ensureString(input.permissionLevel, 'permissionLevel'),
221
+ },
222
+ });
223
+ }
224
+
225
+ async updateAgent(sessionId, input = {}) {
226
+ return await this._executeCommand({
227
+ operation: 'set_ag',
228
+ sessionId,
229
+ payload: {
230
+ ag: ensureString(input.agent, 'agent'),
231
+ },
232
+ });
233
+ }
234
+
235
+ async move(sessionId, input = {}) {
236
+ return await this._executeCommand({
237
+ operation: 'move',
238
+ sessionId,
239
+ payload: {
240
+ prv: ensureOptionalString(input.prevSessionId),
241
+ nxt: ensureOptionalString(input.nextSessionId),
242
+ },
243
+ });
244
+ }
245
+
246
+ async delete(sessionId) {
247
+ return await this._executeCommand({ operation: 'del', sessionId });
248
+ }
249
+
250
+ close() {
251
+ this._closed = true;
252
+ this.socket.close();
253
+ }
254
+
255
+ _enqueue(task) {
256
+ const next = this._commandQueue.then(task, task);
257
+ this._commandQueue = next.catch(() => {});
258
+ return next;
259
+ }
260
+
261
+ async _executeCommand({ operation, sessionId, payload = {}, expectType = null }) {
262
+ return await this._enqueue(async () => {
263
+ await this.waitForOpen();
264
+ if (this._closed || this.socket.readyState !== SOCKET_OPEN) {
265
+ throw new CodeKanbanError('web session command channel is not open');
266
+ }
267
+
268
+ const requestId = createRequestId();
269
+ const ackPromise = new Promise((resolve, reject) => {
270
+ this._pendingRequests.set(requestId, {
271
+ resolve,
272
+ reject,
273
+ ack: null,
274
+ ackTimer: null,
275
+ });
276
+ });
277
+
278
+ const followUpPromise =
279
+ expectType == null
280
+ ? null
281
+ : new Promise((resolve, reject) => {
282
+ this._pendingFollowUp = {
283
+ expectedType: expectType,
284
+ sessionId: ensureOptionalString(sessionId) || null,
285
+ resolve,
286
+ reject,
287
+ };
288
+ });
289
+
290
+ this.socket.send(
291
+ JSON.stringify(
292
+ buildWebSessionCommandFrame({
293
+ requestId,
294
+ sessionId,
295
+ operation,
296
+ payload,
297
+ }),
298
+ ),
299
+ );
300
+
301
+ const ack = await ackPromise;
302
+ if (!followUpPromise) {
303
+ return ack;
304
+ }
305
+ const frame = await followUpPromise;
306
+ return { ack, frame };
307
+ });
308
+ }
309
+
310
+ _handleMessage(data) {
311
+ const rawFrame = decodeWebSessionSocketMessage(data);
312
+ if (isWebSessionHeartbeatFrame(rawFrame)) {
313
+ if (rawFrame?.op === 'ping' && this.socket.readyState === SOCKET_OPEN) {
314
+ this.socket.send(JSON.stringify(buildWebSessionHeartbeatFrame('pong')));
315
+ }
316
+ return;
317
+ }
318
+ const frame = normalizeWebSessionFrame(rawFrame);
319
+
320
+ if (frame.type === 'error' && frame.requestId && this._pendingRequests.has(frame.requestId)) {
321
+ const pending = this._pendingRequests.get(frame.requestId);
322
+ this._clearAckTimer(pending);
323
+ this._pendingRequests.delete(frame.requestId);
324
+ pending?.reject(
325
+ new CodeKanbanError(frame.message, {
326
+ name: 'CodeKanbanWebSessionCommandError',
327
+ code: frame.code,
328
+ retry: frame.retry,
329
+ frame,
330
+ }),
331
+ );
332
+ return;
333
+ }
334
+
335
+ if (frame.type === 'ack' && frame.requestId && this._pendingRequests.has(frame.requestId)) {
336
+ const pending = this._pendingRequests.get(frame.requestId);
337
+ if (!pending) {
338
+ return;
339
+ }
340
+ pending.ack = frame;
341
+ pending.ackTimer = setTimeout(() => {
342
+ this._pendingRequests.delete(frame.requestId);
343
+ pending.resolve(frame);
344
+ }, ACK_SETTLE_DELAY_MS);
345
+ return;
346
+ }
347
+
348
+ if (
349
+ this._pendingFollowUp &&
350
+ frame.type === this._pendingFollowUp.expectedType &&
351
+ (!this._pendingFollowUp.sessionId || frame.sessionId === this._pendingFollowUp.sessionId)
352
+ ) {
353
+ const pendingFollowUp = this._pendingFollowUp;
354
+ this._pendingFollowUp = null;
355
+ pendingFollowUp.resolve(frame);
356
+ }
357
+ }
358
+
359
+ _clearAckTimer(pending) {
360
+ if (pending?.ackTimer) {
361
+ clearTimeout(pending.ackTimer);
362
+ pending.ackTimer = null;
363
+ }
364
+ }
365
+
366
+ _rejectAll(error) {
367
+ this._pendingRequests.forEach(pending => {
368
+ this._clearAckTimer(pending);
369
+ pending.reject(error);
370
+ });
371
+ this._pendingRequests.clear();
372
+
373
+ if (this._pendingFollowUp) {
374
+ this._pendingFollowUp.reject(error);
375
+ this._pendingFollowUp = null;
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,216 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ import { CodeKanbanConfigError, CodeKanbanError, CodeKanbanValidationError } from './errors.js';
4
+ import {
5
+ buildWebSessionHeartbeatFrame,
6
+ decodeWebSessionSocketMessage,
7
+ isWebSessionHeartbeatFrame,
8
+ normalizeWebSessionFrame,
9
+ shouldEmitWebSessionFrame,
10
+ } from './web-session-shared.js';
11
+ import { ensureOptionalString, ensureString } from './utils.js';
12
+
13
+ const SOCKET_OPEN = 1;
14
+ const DEFAULT_OPEN_TIMEOUT_MS = 8000;
15
+
16
+ function nowEventBase() {
17
+ const timestampMs = Date.now();
18
+ return {
19
+ timestampMs,
20
+ timestamp: new Date(timestampMs).toISOString(),
21
+ };
22
+ }
23
+
24
+ export class WebSessionEventStream {
25
+ constructor({ url, sessionId, WebSocketImpl } = {}) {
26
+ const resolvedUrl = ensureString(url, 'url');
27
+ const Socket = WebSocketImpl || globalThis.WebSocket;
28
+ if (!Socket) {
29
+ throw new CodeKanbanConfigError('WebSocket implementation is unavailable');
30
+ }
31
+
32
+ this.url = resolvedUrl;
33
+ this.sessionId = ensureOptionalString(sessionId) || null;
34
+ this.socket = new Socket(resolvedUrl);
35
+ this._emitter = new EventEmitter();
36
+ this._queue = [];
37
+ this._nextResolvers = [];
38
+ this._closed = false;
39
+ this._openPromise = new Promise((resolve, reject) => {
40
+ this._resolveOpen = resolve;
41
+ this._rejectOpen = reject;
42
+ });
43
+
44
+ this.socket.addEventListener('open', () => {
45
+ const payload = {
46
+ type: 'open',
47
+ url: this.url,
48
+ sessionId: this.sessionId,
49
+ ...nowEventBase(),
50
+ };
51
+ this._resolveOpen?.();
52
+ this._publish(payload);
53
+ });
54
+
55
+ this.socket.addEventListener('message', event => {
56
+ this._handleMessage(event.data);
57
+ });
58
+
59
+ this.socket.addEventListener('error', event => {
60
+ const error = new CodeKanbanError('web session event websocket error', { event });
61
+ const payload = {
62
+ type: 'error',
63
+ errorType: 'socket',
64
+ message: error.message,
65
+ error,
66
+ raw: event,
67
+ sessionId: this.sessionId,
68
+ ...nowEventBase(),
69
+ };
70
+ this._rejectOpen?.(error);
71
+ this._publish(payload);
72
+ });
73
+
74
+ this.socket.addEventListener('close', event => {
75
+ this._closed = true;
76
+ const payload = {
77
+ type: 'close',
78
+ url: this.url,
79
+ sessionId: this.sessionId,
80
+ code: typeof event?.code === 'number' ? event.code : null,
81
+ reason: typeof event?.reason === 'string' ? event.reason : '',
82
+ wasClean: event?.wasClean === true,
83
+ ...nowEventBase(),
84
+ };
85
+ this._rejectOpen?.(new CodeKanbanError('web session event websocket closed', { event }));
86
+ this._publish(payload);
87
+ this._flushIteratorDone();
88
+ });
89
+ }
90
+
91
+ on(type, handler) {
92
+ this._emitter.on(type, handler);
93
+ return () => this.off(type, handler);
94
+ }
95
+
96
+ off(type, handler) {
97
+ this._emitter.off(type, handler);
98
+ }
99
+
100
+ async waitForOpen(timeoutMs = DEFAULT_OPEN_TIMEOUT_MS) {
101
+ if (this.socket.readyState === SOCKET_OPEN) {
102
+ return;
103
+ }
104
+ await Promise.race([
105
+ this._openPromise,
106
+ new Promise((_, reject) => {
107
+ setTimeout(
108
+ () => reject(new CodeKanbanValidationError(`web session event stream did not open within ${timeoutMs}ms`)),
109
+ timeoutMs,
110
+ );
111
+ }),
112
+ ]);
113
+ }
114
+
115
+ async waitFor(predicate, options = {}) {
116
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1, Math.trunc(options.timeoutMs)) : 15000;
117
+
118
+ return await new Promise((resolve, reject) => {
119
+ const cleanup = this.on('__event__', payload => {
120
+ try {
121
+ if (!predicate(payload)) {
122
+ return;
123
+ }
124
+ cleanup();
125
+ clearTimeout(timer);
126
+ resolve(payload);
127
+ } catch (error) {
128
+ cleanup();
129
+ clearTimeout(timer);
130
+ reject(error);
131
+ }
132
+ });
133
+
134
+ const timer = setTimeout(() => {
135
+ cleanup();
136
+ reject(new CodeKanbanValidationError(`web session event stream waitFor timed out after ${timeoutMs}ms`));
137
+ }, timeoutMs);
138
+ });
139
+ }
140
+
141
+ close() {
142
+ this._closed = true;
143
+ this.socket.close();
144
+ }
145
+
146
+ [Symbol.asyncIterator]() {
147
+ return {
148
+ next: () => {
149
+ if (this._queue.length > 0) {
150
+ return Promise.resolve({ value: this._queue.shift(), done: false });
151
+ }
152
+ if (this._closed) {
153
+ return Promise.resolve({ value: undefined, done: true });
154
+ }
155
+ return new Promise(resolve => {
156
+ this._nextResolvers.push(resolve);
157
+ });
158
+ },
159
+ };
160
+ }
161
+
162
+ _handleMessage(data) {
163
+ let rawFrame;
164
+ let frame;
165
+ try {
166
+ rawFrame = decodeWebSessionSocketMessage(data);
167
+ if (isWebSessionHeartbeatFrame(rawFrame)) {
168
+ if (rawFrame?.op === 'ping' && this.socket.readyState === SOCKET_OPEN) {
169
+ this.socket.send(JSON.stringify(buildWebSessionHeartbeatFrame('pong')));
170
+ }
171
+ return;
172
+ }
173
+ frame = normalizeWebSessionFrame(rawFrame);
174
+ } catch (error) {
175
+ const payload = {
176
+ type: 'error',
177
+ errorType: 'decode',
178
+ message: error instanceof Error ? error.message : String(error),
179
+ error,
180
+ raw: data,
181
+ sessionId: this.sessionId,
182
+ ...nowEventBase(),
183
+ };
184
+ this._publish(payload);
185
+ return;
186
+ }
187
+
188
+ if (!shouldEmitWebSessionFrame(frame, this.sessionId)) {
189
+ return;
190
+ }
191
+
192
+ this._publish(frame.type === 'error' ? { ...frame, errorType: 'frame' } : frame, true);
193
+ }
194
+
195
+ _publish(payload, emitFrame = false) {
196
+ if (emitFrame) {
197
+ this._emitter.emit('frame', payload);
198
+ }
199
+ this._emitter.emit(payload.type, payload);
200
+ this._emitter.emit('__event__', payload);
201
+
202
+ if (this._nextResolvers.length > 0) {
203
+ const resolve = this._nextResolvers.shift();
204
+ resolve?.({ value: payload, done: false });
205
+ return;
206
+ }
207
+ this._queue.push(payload);
208
+ }
209
+
210
+ _flushIteratorDone() {
211
+ while (this._nextResolvers.length > 0) {
212
+ const resolve = this._nextResolvers.shift();
213
+ resolve?.({ value: undefined, done: true });
214
+ }
215
+ }
216
+ }