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.
@@ -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
- const response = await this.fetchImpl(new URL(path, this.baseURL), request);
38
- const text = await response.text();
39
- const body = text ? JSON.parse(text) : null;
40
- if (!response.ok) {
41
- throw new CodeKanbanHttpError(`request failed with ${response.status}`, {
42
- status: response.status,
43
- method,
44
- path,
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
- return body;
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, taskId = '' }) {
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';