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.
- package/package.json +6 -6
- package/packages/node-sdk/README.md +137 -2
- package/packages/node-sdk/package.json +1 -1
- package/packages/node-sdk/src/cli.js +331 -10
- package/packages/node-sdk/src/client.js +607 -15
- package/packages/node-sdk/src/index.js +3 -0
- package/packages/node-sdk/src/web-session-command-channel.js +378 -0
- package/packages/node-sdk/src/web-session-event-stream.js +216 -0
- package/packages/node-sdk/src/web-session-shared.js +629 -0
- package/skills/codekanban-project-session/agents/openai.yaml +2 -2
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
1
3
|
import { buildAgentLaunchSpec } from './command-builder.js';
|
|
2
4
|
import { CodeKanbanConfigError, CodeKanbanHttpError, CodeKanbanValidationError } from './errors.js';
|
|
3
5
|
import { TerminalConnection } from './terminal-connection.js';
|
|
6
|
+
import { WebSessionCommandChannel } from './web-session-command-channel.js';
|
|
7
|
+
import { WebSessionEventStream } from './web-session-event-stream.js';
|
|
8
|
+
import {
|
|
9
|
+
WEB_SESSION_COMMAND_WS_PATH,
|
|
10
|
+
WEB_SESSION_EVENTS_WS_PATH,
|
|
11
|
+
analyzeWebSession,
|
|
12
|
+
ensureImageMimeType,
|
|
13
|
+
normalizeWebSessionAttachment,
|
|
14
|
+
} from './web-session-shared.js';
|
|
4
15
|
import {
|
|
5
16
|
ensureOptionalString,
|
|
6
17
|
ensureString,
|
|
@@ -12,19 +23,122 @@ import {
|
|
|
12
23
|
toWsUrl,
|
|
13
24
|
} from './utils.js';
|
|
14
25
|
|
|
26
|
+
const DEFAULT_REQUEST_RETRY = Object.freeze({
|
|
27
|
+
attempts: 2,
|
|
28
|
+
baseDelayMs: 250,
|
|
29
|
+
maxDelayMs: 1000,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
33
|
+
|
|
34
|
+
const DEFAULT_WEB_SESSION_AUTO_RETRY_SCOPE = 'network_only';
|
|
35
|
+
const DEFAULT_WEB_SESSION_AUTO_RETRY_PRESET = 'gentle_stop';
|
|
36
|
+
|
|
37
|
+
function defaultWebSessionModel(agent) {
|
|
38
|
+
return agent === 'claude' ? 'opus' : 'gpt-5.4';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultWebSessionReasoningEffort(agent) {
|
|
42
|
+
return agent === 'claude' ? 'default' : 'xhigh';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultWebSessionWorkflowMode(permissionMode) {
|
|
46
|
+
return permissionMode === 'plan' ? 'plan' : 'default';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function defaultWebSessionPermissionLevel(permissionMode) {
|
|
50
|
+
return permissionMode === 'yolo' ? 'yolo' : 'elevated';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeWebSessionAutoRetryScope(scope) {
|
|
54
|
+
const normalized = ensureOptionalString(scope);
|
|
55
|
+
if (['network_only', 'network_and_rate_limit', 'all_failures'].includes(normalized)) {
|
|
56
|
+
return normalized;
|
|
57
|
+
}
|
|
58
|
+
return DEFAULT_WEB_SESSION_AUTO_RETRY_SCOPE;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeWebSessionAutoRetryPreset(preset) {
|
|
62
|
+
const normalized = ensureOptionalString(preset);
|
|
63
|
+
if (['gentle_stop', 'aggressive_stop', 'sustain_60s'].includes(normalized)) {
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
return DEFAULT_WEB_SESSION_AUTO_RETRY_PRESET;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeRequestRetryConfig(value = {}) {
|
|
70
|
+
const attempts = Number.isFinite(value.attempts) ? Math.max(1, Math.trunc(value.attempts)) : DEFAULT_REQUEST_RETRY.attempts;
|
|
71
|
+
const baseDelayMs = Number.isFinite(value.baseDelayMs)
|
|
72
|
+
? Math.max(1, Math.trunc(value.baseDelayMs))
|
|
73
|
+
: DEFAULT_REQUEST_RETRY.baseDelayMs;
|
|
74
|
+
const maxDelayMs = Number.isFinite(value.maxDelayMs)
|
|
75
|
+
? Math.max(baseDelayMs, Math.trunc(value.maxDelayMs))
|
|
76
|
+
: DEFAULT_REQUEST_RETRY.maxDelayMs;
|
|
77
|
+
return {
|
|
78
|
+
attempts,
|
|
79
|
+
baseDelayMs,
|
|
80
|
+
maxDelayMs,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveRequestRetryConfig(method, defaults, override) {
|
|
85
|
+
if (override === false) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const normalizedMethod = String(method || 'GET').toUpperCase();
|
|
90
|
+
const source = override && typeof override === 'object' ? override : {};
|
|
91
|
+
const enabled =
|
|
92
|
+
typeof source.enabled === 'boolean'
|
|
93
|
+
? source.enabled
|
|
94
|
+
: ['GET', 'HEAD', 'OPTIONS'].includes(normalizedMethod);
|
|
95
|
+
if (!enabled) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return normalizeRequestRetryConfig({
|
|
100
|
+
...defaults,
|
|
101
|
+
...source,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRetryableRequestError(error) {
|
|
106
|
+
if (error instanceof CodeKanbanHttpError) {
|
|
107
|
+
return RETRYABLE_HTTP_STATUS_CODES.has(error.status);
|
|
108
|
+
}
|
|
109
|
+
if (error instanceof CodeKanbanValidationError || error instanceof CodeKanbanConfigError) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof SyntaxError) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (error?.name === 'AbortError') {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (error instanceof TypeError) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getRetryDelayMs(retryConfig, attemptIndex) {
|
|
125
|
+
return Math.min(retryConfig.maxDelayMs, retryConfig.baseDelayMs * 2 ** attemptIndex);
|
|
126
|
+
}
|
|
127
|
+
|
|
15
128
|
export class CodeKanbanClient {
|
|
16
129
|
constructor(options = {}) {
|
|
17
130
|
this.baseURL = normalizeBaseUrl(options.baseURL);
|
|
18
131
|
this.headers = { ...(options.headers || {}) };
|
|
19
132
|
this.fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
20
133
|
this.WebSocketImpl = options.WebSocketImpl || globalThis.WebSocket;
|
|
134
|
+
this.requestRetry = normalizeRequestRetryConfig(options.requestRetry);
|
|
21
135
|
if (!this.fetchImpl) {
|
|
22
136
|
throw new CodeKanbanConfigError('fetch implementation is unavailable');
|
|
23
137
|
}
|
|
24
138
|
}
|
|
25
139
|
|
|
26
140
|
async requestJson(path, options = {}) {
|
|
27
|
-
const method = options.method || 'GET';
|
|
141
|
+
const method = String(options.method || 'GET').toUpperCase();
|
|
28
142
|
const headers = { Accept: 'application/json', ...this.headers, ...(options.headers || {}) };
|
|
29
143
|
const request = {
|
|
30
144
|
method,
|
|
@@ -34,18 +148,56 @@ export class CodeKanbanClient {
|
|
|
34
148
|
request.body = JSON.stringify(options.body);
|
|
35
149
|
request.headers['Content-Type'] = 'application/json';
|
|
36
150
|
}
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
body
|
|
46
|
-
|
|
151
|
+
|
|
152
|
+
const retryConfig = resolveRequestRetryConfig(method, this.requestRetry, options.retry);
|
|
153
|
+
const maxAttempts = retryConfig?.attempts ?? 1;
|
|
154
|
+
|
|
155
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await this.fetchImpl(new URL(path, this.baseURL), request);
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
const body = text ? JSON.parse(text) : null;
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new CodeKanbanHttpError(`request failed with ${response.status}`, {
|
|
162
|
+
status: response.status,
|
|
163
|
+
method,
|
|
164
|
+
path,
|
|
165
|
+
body,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return body;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const canRetry = retryConfig && attempt + 1 < maxAttempts && isRetryableRequestError(error);
|
|
171
|
+
if (!canRetry) {
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
await sleep(getRetryDelayMs(retryConfig, attempt));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new CodeKanbanValidationError(`request retry loop exited unexpectedly for ${method} ${path}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async resolveProjectReference({ projectId, path, ensureProject = true }) {
|
|
182
|
+
const { project } = await this.resolveProject({
|
|
183
|
+
projectId,
|
|
184
|
+
path,
|
|
185
|
+
ensureProject,
|
|
186
|
+
});
|
|
187
|
+
return project;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async resolveProjectId({ projectId, path, ensureProject = true }) {
|
|
191
|
+
const resolvedProjectId = ensureOptionalString(projectId);
|
|
192
|
+
if (resolvedProjectId) {
|
|
193
|
+
return resolvedProjectId;
|
|
47
194
|
}
|
|
48
|
-
|
|
195
|
+
const { project } = await this.resolveProject({
|
|
196
|
+
projectId,
|
|
197
|
+
path,
|
|
198
|
+
ensureProject,
|
|
199
|
+
});
|
|
200
|
+
return ensureString(project?.id, 'projectId');
|
|
49
201
|
}
|
|
50
202
|
|
|
51
203
|
async listProjects() {
|
|
@@ -136,7 +288,7 @@ export class CodeKanbanClient {
|
|
|
136
288
|
return response?.item || null;
|
|
137
289
|
}
|
|
138
290
|
|
|
139
|
-
async createTerminalSession({ projectId, worktreeId, workingDir = '', title = '', rows = 0, cols = 0
|
|
291
|
+
async createTerminalSession({ projectId, worktreeId, workingDir = '', title = '', rows = 0, cols = 0 }) {
|
|
140
292
|
ensureString(projectId, 'projectId');
|
|
141
293
|
ensureString(worktreeId, 'worktreeId');
|
|
142
294
|
const response = await this.requestJson(`/api/v1/projects/${projectId}/worktrees/${worktreeId}/terminals`, {
|
|
@@ -146,7 +298,6 @@ export class CodeKanbanClient {
|
|
|
146
298
|
title,
|
|
147
299
|
rows,
|
|
148
300
|
cols,
|
|
149
|
-
taskId,
|
|
150
301
|
},
|
|
151
302
|
});
|
|
152
303
|
return response?.item;
|
|
@@ -223,6 +374,31 @@ export class CodeKanbanClient {
|
|
|
223
374
|
});
|
|
224
375
|
}
|
|
225
376
|
|
|
377
|
+
openWebSessionCommandChannel() {
|
|
378
|
+
return new WebSessionCommandChannel({
|
|
379
|
+
url: toWsUrl(this.baseURL, WEB_SESSION_COMMAND_WS_PATH),
|
|
380
|
+
WebSocketImpl: this.WebSocketImpl,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
openWebSessionEventStream(options = {}) {
|
|
385
|
+
return new WebSessionEventStream({
|
|
386
|
+
url: toWsUrl(this.baseURL, WEB_SESSION_EVENTS_WS_PATH),
|
|
387
|
+
sessionId: ensureOptionalString(options.sessionId),
|
|
388
|
+
WebSocketImpl: this.WebSocketImpl,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async withWebSessionCommandChannel(handler) {
|
|
393
|
+
const channel = this.openWebSessionCommandChannel();
|
|
394
|
+
try {
|
|
395
|
+
await channel.waitForOpen();
|
|
396
|
+
return await handler(channel);
|
|
397
|
+
} finally {
|
|
398
|
+
channel.close();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
226
402
|
async listSessions({ projectId, path, includeTerminal = true, includeAI = true, ensureProject = true }) {
|
|
227
403
|
const { project, matchedBy } = await this.resolveProject({ projectId, path, ensureProject });
|
|
228
404
|
|
|
@@ -246,6 +422,423 @@ export class CodeKanbanClient {
|
|
|
246
422
|
};
|
|
247
423
|
}
|
|
248
424
|
|
|
425
|
+
async listWebSessions({ projectId, path, ensureProject = true } = {}) {
|
|
426
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject });
|
|
427
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions`);
|
|
428
|
+
return response?.items || [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async createWebSession(input = {}) {
|
|
432
|
+
const projectId = await this.resolveProjectId({
|
|
433
|
+
projectId: input.projectId,
|
|
434
|
+
path: input.path,
|
|
435
|
+
ensureProject: true,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const agent = ensureString(input.agent, 'agent');
|
|
439
|
+
const permissionMode = ensureOptionalString(input.permissionMode).toLowerCase();
|
|
440
|
+
const worktree = await this.resolveWorktree({
|
|
441
|
+
projectId,
|
|
442
|
+
worktreeId: input.worktreeId,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const response = await this.requestJson(`/api/v1/projects/${projectId}/web-sessions`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
body: {
|
|
448
|
+
worktreeId: ensureString(worktree?.id, 'worktreeId'),
|
|
449
|
+
agent,
|
|
450
|
+
model: ensureOptionalString(input.model) || defaultWebSessionModel(agent),
|
|
451
|
+
reasoningEffort:
|
|
452
|
+
ensureOptionalString(input.reasoningEffort) || defaultWebSessionReasoningEffort(agent),
|
|
453
|
+
workflowMode: ensureOptionalString(input.workflowMode) || defaultWebSessionWorkflowMode(permissionMode),
|
|
454
|
+
permissionLevel:
|
|
455
|
+
ensureOptionalString(input.permissionLevel) || defaultWebSessionPermissionLevel(permissionMode),
|
|
456
|
+
autoRetryEnabled: input.autoRetryEnabled === true,
|
|
457
|
+
autoRetryScope: normalizeWebSessionAutoRetryScope(input.autoRetryScope),
|
|
458
|
+
autoRetryPreset: normalizeWebSessionAutoRetryPreset(input.autoRetryPreset),
|
|
459
|
+
permissionMode,
|
|
460
|
+
title: ensureOptionalString(input.title),
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
return response?.item || null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async getWebSessionSnapshot({ projectId, path, sessionId, limit = 80 }) {
|
|
467
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
468
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
469
|
+
const normalizedLimit = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 80;
|
|
470
|
+
const response = await this.requestJson(
|
|
471
|
+
`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/snapshot?limit=${normalizedLimit}`,
|
|
472
|
+
);
|
|
473
|
+
return response?.item || null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async getWebSessionHistory({ projectId, path, sessionId, beforeCursor, limit = 80 }) {
|
|
477
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
478
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
479
|
+
const params = new URLSearchParams();
|
|
480
|
+
if (ensureOptionalString(beforeCursor)) {
|
|
481
|
+
params.set('beforeCursor', ensureOptionalString(beforeCursor));
|
|
482
|
+
}
|
|
483
|
+
if (Number.isFinite(limit)) {
|
|
484
|
+
params.set('limit', String(Math.max(1, Math.trunc(limit))));
|
|
485
|
+
}
|
|
486
|
+
const suffix = params.toString();
|
|
487
|
+
const response = await this.requestJson(
|
|
488
|
+
`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/history${suffix ? `?${suffix}` : ''}`,
|
|
489
|
+
);
|
|
490
|
+
return response?.item || null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async syncWebSession({ projectId, path, sessionId, mode, clearExisting = false }) {
|
|
494
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
495
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
496
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/sync`, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
body: {
|
|
499
|
+
...(ensureOptionalString(mode) ? { mode: ensureOptionalString(mode) } : {}),
|
|
500
|
+
clearExisting: clearExisting === true,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
return response?.item || null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async archiveWebSession({ projectId, path, sessionId }) {
|
|
507
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
508
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
509
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/archive`, {
|
|
510
|
+
method: 'POST',
|
|
511
|
+
});
|
|
512
|
+
return response?.item || null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async unarchiveWebSession({ projectId, path, sessionId }) {
|
|
516
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
517
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
518
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/unarchive`, {
|
|
519
|
+
method: 'POST',
|
|
520
|
+
});
|
|
521
|
+
return response?.item || null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async renameWebSession({ projectId, path, sessionId, title }) {
|
|
525
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
526
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
527
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/rename`, {
|
|
528
|
+
method: 'POST',
|
|
529
|
+
body: {
|
|
530
|
+
title: ensureString(title, 'title'),
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
return response?.item || null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async closeWebSession({ projectId, path, sessionId }) {
|
|
537
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
538
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
539
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/close`, {
|
|
540
|
+
method: 'POST',
|
|
541
|
+
});
|
|
542
|
+
return {
|
|
543
|
+
message: response?.message || 'session aborted',
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async deleteWebSession({ projectId, path, sessionId }) {
|
|
548
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
549
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
550
|
+
const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}`, {
|
|
551
|
+
method: 'DELETE',
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
message: response?.message || 'session deleted',
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async queryArchivedWebSessions({ projectIds, offset = 0, limit = 20 }) {
|
|
559
|
+
const response = await this.requestJson('/api/v1/web-sessions/archived/query', {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
body: {
|
|
562
|
+
projectIds: Array.isArray(projectIds) ? projectIds : [],
|
|
563
|
+
offset: Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0,
|
|
564
|
+
limit: Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 20,
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
return response?.item || { items: [], total: 0, hasMore: false, nextOffset: 0 };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async getWebSessionCommandGroup({ projectId, path, sessionId, groupId }) {
|
|
571
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
572
|
+
const resolvedSessionId = ensureString(sessionId, 'sessionId');
|
|
573
|
+
const resolvedGroupId = ensureString(groupId, 'groupId');
|
|
574
|
+
const response = await this.requestJson(
|
|
575
|
+
`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/command-groups/${resolvedGroupId}`,
|
|
576
|
+
);
|
|
577
|
+
return response?.item || null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async getWebSessionRuntimeConfig() {
|
|
581
|
+
const response = await this.requestJson('/api/v1/web-sessions/runtime-config');
|
|
582
|
+
return response?.item || null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async uploadWebSessionAttachment({ projectId, path, filePath, fileName, mimeType }) {
|
|
586
|
+
const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
|
|
587
|
+
const resolvedFilePath = ensureString(filePath, 'filePath');
|
|
588
|
+
const resolvedFileName = ensureOptionalString(fileName) || pathBasename(resolvedFilePath);
|
|
589
|
+
const resolvedMimeType = ensureImageMimeType(mimeType, resolvedFileName);
|
|
590
|
+
const fileBuffer = await readFile(resolvedFilePath);
|
|
591
|
+
const formData = new FormData();
|
|
592
|
+
formData.append('file', new File([fileBuffer], resolvedFileName, { type: resolvedMimeType }));
|
|
593
|
+
|
|
594
|
+
const headers = {
|
|
595
|
+
Accept: 'application/json',
|
|
596
|
+
...this.headers,
|
|
597
|
+
};
|
|
598
|
+
delete headers['Content-Type'];
|
|
599
|
+
|
|
600
|
+
const response = await this.fetchImpl(
|
|
601
|
+
new URL(`/api/v1/projects/${resolvedProjectId}/web-sessions/attachments`, this.baseURL),
|
|
602
|
+
{
|
|
603
|
+
method: 'POST',
|
|
604
|
+
headers,
|
|
605
|
+
body: formData,
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
const text = await response.text();
|
|
609
|
+
const body = text ? JSON.parse(text) : null;
|
|
610
|
+
if (!response.ok) {
|
|
611
|
+
throw new CodeKanbanHttpError(`request failed with ${response.status}`, {
|
|
612
|
+
status: response.status,
|
|
613
|
+
method: 'POST',
|
|
614
|
+
path: `/api/v1/projects/${resolvedProjectId}/web-sessions/attachments`,
|
|
615
|
+
body,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return normalizeWebSessionAttachment(body?.item);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
analyzeWebSession(snapshot) {
|
|
622
|
+
return analyzeWebSession(snapshot);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async getWebSessionState({ projectId, path, sessionId, limit = 120 }) {
|
|
626
|
+
const snapshot = await this.getWebSessionSnapshot({
|
|
627
|
+
projectId,
|
|
628
|
+
path,
|
|
629
|
+
sessionId,
|
|
630
|
+
limit,
|
|
631
|
+
});
|
|
632
|
+
return this.analyzeWebSession(snapshot);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async sendWebSessionMessage({ sessionId, text, attachmentIds = [] }) {
|
|
636
|
+
return await this.withWebSessionCommandChannel(channel =>
|
|
637
|
+
channel.sendMessage(sessionId, {
|
|
638
|
+
text,
|
|
639
|
+
attachmentIds,
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async updateWebSessionWorkflowMode({ sessionId, workflowMode }) {
|
|
645
|
+
return await this.withWebSessionCommandChannel(channel =>
|
|
646
|
+
channel.updateWorkflowMode(sessionId, {
|
|
647
|
+
workflowMode,
|
|
648
|
+
}),
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async answerWebSessionUserInput({ sessionId, itemId, answers }) {
|
|
653
|
+
return await this.withWebSessionCommandChannel(channel =>
|
|
654
|
+
channel.answerUserInput(sessionId, {
|
|
655
|
+
itemId,
|
|
656
|
+
answers,
|
|
657
|
+
}),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async approveWebSession({ sessionId }) {
|
|
662
|
+
return await this.withWebSessionCommandChannel(channel => channel.approve(sessionId));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async rejectWebSession({ sessionId }) {
|
|
666
|
+
return await this.withWebSessionCommandChannel(channel => channel.reject(sessionId));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async answerPendingUserInput({ projectId, path, sessionId, answers, limit = 120 }) {
|
|
670
|
+
const state = await this.getWebSessionState({
|
|
671
|
+
projectId,
|
|
672
|
+
path,
|
|
673
|
+
sessionId,
|
|
674
|
+
limit,
|
|
675
|
+
});
|
|
676
|
+
if (!state.pendingUserInput?.itemId) {
|
|
677
|
+
throw new CodeKanbanValidationError(`web session ${sessionId} has no pending user input`);
|
|
678
|
+
}
|
|
679
|
+
const ack = await this.answerWebSessionUserInput({
|
|
680
|
+
sessionId,
|
|
681
|
+
itemId: state.pendingUserInput.itemId,
|
|
682
|
+
answers,
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
sessionId,
|
|
686
|
+
itemId: state.pendingUserInput.itemId,
|
|
687
|
+
prompt: state.pendingUserInput.prompt,
|
|
688
|
+
ack,
|
|
689
|
+
state,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async approvePending({ projectId, path, sessionId, limit = 120 }) {
|
|
694
|
+
const state = await this.getWebSessionState({
|
|
695
|
+
projectId,
|
|
696
|
+
path,
|
|
697
|
+
sessionId,
|
|
698
|
+
limit,
|
|
699
|
+
});
|
|
700
|
+
if (!state.pendingApproval) {
|
|
701
|
+
throw new CodeKanbanValidationError(`web session ${sessionId} has no pending approval`);
|
|
702
|
+
}
|
|
703
|
+
const ack = await this.approveWebSession({ sessionId });
|
|
704
|
+
return {
|
|
705
|
+
sessionId,
|
|
706
|
+
prompt: state.pendingApproval.prompt,
|
|
707
|
+
ack,
|
|
708
|
+
state,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async rejectPending({ projectId, path, sessionId, limit = 120 }) {
|
|
713
|
+
const state = await this.getWebSessionState({
|
|
714
|
+
projectId,
|
|
715
|
+
path,
|
|
716
|
+
sessionId,
|
|
717
|
+
limit,
|
|
718
|
+
});
|
|
719
|
+
if (!state.pendingApproval) {
|
|
720
|
+
throw new CodeKanbanValidationError(`web session ${sessionId} has no pending approval`);
|
|
721
|
+
}
|
|
722
|
+
const ack = await this.rejectWebSession({ sessionId });
|
|
723
|
+
return {
|
|
724
|
+
sessionId,
|
|
725
|
+
prompt: state.pendingApproval.prompt,
|
|
726
|
+
ack,
|
|
727
|
+
state,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async executeLatestPlan({ projectId, path, sessionId, prompt = 'Implement the plan.', limit = 120 }) {
|
|
732
|
+
const state = await this.getWebSessionState({
|
|
733
|
+
projectId,
|
|
734
|
+
path,
|
|
735
|
+
sessionId,
|
|
736
|
+
limit,
|
|
737
|
+
});
|
|
738
|
+
if (!state.latestPlan) {
|
|
739
|
+
throw new CodeKanbanValidationError(`web session ${sessionId} has no latest plan to execute`);
|
|
740
|
+
}
|
|
741
|
+
if (!state.canSend && state.nextAction?.type !== 'execute_plan') {
|
|
742
|
+
throw new CodeKanbanValidationError(`web session ${sessionId} is not ready to execute the latest plan`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return await this.withWebSessionCommandChannel(async channel => {
|
|
746
|
+
if (state.session?.workflowMode === 'plan') {
|
|
747
|
+
await channel.updateWorkflowMode(sessionId, {
|
|
748
|
+
workflowMode: 'default',
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (
|
|
753
|
+
state.pendingUserInput?.isPlanChoice &&
|
|
754
|
+
state.pendingUserInput.itemId &&
|
|
755
|
+
state.pendingUserInput.questionId &&
|
|
756
|
+
state.pendingUserInput.executeOptionLabel
|
|
757
|
+
) {
|
|
758
|
+
const ack = await channel.answerUserInput(sessionId, {
|
|
759
|
+
itemId: state.pendingUserInput.itemId,
|
|
760
|
+
answers: {
|
|
761
|
+
[state.pendingUserInput.questionId]: [state.pendingUserInput.executeOptionLabel],
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
sessionId,
|
|
766
|
+
mode: 'plan_choice',
|
|
767
|
+
latestPlan: state.latestPlan,
|
|
768
|
+
ack,
|
|
769
|
+
state,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const ack = await channel.sendMessage(sessionId, {
|
|
774
|
+
text: prompt,
|
|
775
|
+
attachmentIds: [],
|
|
776
|
+
});
|
|
777
|
+
return {
|
|
778
|
+
sessionId,
|
|
779
|
+
mode: 'followup_message',
|
|
780
|
+
prompt,
|
|
781
|
+
latestPlan: state.latestPlan,
|
|
782
|
+
ack,
|
|
783
|
+
state,
|
|
784
|
+
};
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async waitForWebSessionState({
|
|
789
|
+
projectId,
|
|
790
|
+
path,
|
|
791
|
+
sessionId,
|
|
792
|
+
until,
|
|
793
|
+
intervalMs = 5000,
|
|
794
|
+
timeoutMs = 60000,
|
|
795
|
+
limit = 120,
|
|
796
|
+
}) {
|
|
797
|
+
if (!until) {
|
|
798
|
+
throw new CodeKanbanValidationError('until is required');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const matches =
|
|
802
|
+
typeof until === 'function'
|
|
803
|
+
? until
|
|
804
|
+
: Array.isArray(until)
|
|
805
|
+
? state => until.includes(state.phase)
|
|
806
|
+
: state => state.phase === until;
|
|
807
|
+
|
|
808
|
+
const startedAt = Date.now();
|
|
809
|
+
let lastRetryableError = null;
|
|
810
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
811
|
+
try {
|
|
812
|
+
const state = await this.getWebSessionState({
|
|
813
|
+
projectId,
|
|
814
|
+
path,
|
|
815
|
+
sessionId,
|
|
816
|
+
limit,
|
|
817
|
+
});
|
|
818
|
+
lastRetryableError = null;
|
|
819
|
+
if (matches(state)) {
|
|
820
|
+
return state;
|
|
821
|
+
}
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (!isRetryableRequestError(error)) {
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
lastRetryableError = error;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
await sleep(Math.max(1, Math.trunc(intervalMs)));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (lastRetryableError) {
|
|
833
|
+
throw new CodeKanbanValidationError(
|
|
834
|
+
`web session ${sessionId} did not reach the requested state within ${timeoutMs}ms (last transient error: ${lastRetryableError.message})`,
|
|
835
|
+
{ cause: lastRetryableError },
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
throw new CodeKanbanValidationError(`web session ${sessionId} did not reach the requested state within ${timeoutMs}ms`);
|
|
840
|
+
}
|
|
841
|
+
|
|
249
842
|
async startWorkflow(input = {}) {
|
|
250
843
|
const launch = buildAgentLaunchSpec(input);
|
|
251
844
|
const { project, matchedBy } = await this.resolveProject({
|
|
@@ -263,7 +856,6 @@ export class CodeKanbanClient {
|
|
|
263
856
|
worktreeId: worktree.id,
|
|
264
857
|
workingDir: ensureOptionalString(input.workingDir) || worktree.path,
|
|
265
858
|
title: ensureOptionalString(input.title) || ensureOptionalString(input.prompt) || 'AI workflow',
|
|
266
|
-
taskId: ensureOptionalString(input.taskId),
|
|
267
859
|
rows: Number.isFinite(input.rows) ? input.rows : 0,
|
|
268
860
|
cols: Number.isFinite(input.cols) ? input.cols : 0,
|
|
269
861
|
});
|
|
@@ -15,3 +15,6 @@ export {
|
|
|
15
15
|
CodeKanbanValidationError,
|
|
16
16
|
} from './errors.js';
|
|
17
17
|
export { TerminalConnection } from './terminal-connection.js';
|
|
18
|
+
export { WebSessionCommandChannel } from './web-session-command-channel.js';
|
|
19
|
+
export { WebSessionEventStream } from './web-session-event-stream.js';
|
|
20
|
+
export { analyzeWebSession } from './web-session-shared.js';
|