codekanban 0.27.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 CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "codekanban",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "An auxiliary programming tool for the AI era, helping you speed up 10x.",
5
5
  "bin": {
6
6
  "codekanban": "npm-bin/codekanban.js"
7
7
  },
8
8
  "optionalDependencies": {
9
- "@codekanban/win32-x64": "0.27.0",
10
- "@codekanban/darwin-x64": "0.27.0",
11
- "@codekanban/darwin-arm64": "0.27.0",
12
- "@codekanban/linux-x64": "0.27.0",
13
- "@codekanban/linux-arm64": "0.27.0"
9
+ "@codekanban/win32-x64": "0.28.0",
10
+ "@codekanban/darwin-x64": "0.28.0",
11
+ "@codekanban/darwin-arm64": "0.28.0",
12
+ "@codekanban/linux-x64": "0.28.0",
13
+ "@codekanban/linux-arm64": "0.28.0"
14
14
  },
15
15
  "keywords": [
16
16
  "ai",
@@ -1,13 +1,15 @@
1
1
  # `@codekanban/sdk`
2
2
 
3
- Node SDK and CLI for CodeKanban project workflows.
3
+ Node SDK and CLI for CodeKanban coding workflows, terminal sessions, and web sessions.
4
+
5
+ Use it as a programming-assistant toolkit for launching, observing, and steering CodeKanban work in a registered project or local path, with `web session` as the default structured path and `terminal session` for PTY-style flows.
4
6
 
5
7
  ## Features
6
8
 
7
9
  - Resolve CodeKanban projects by `projectId` or local `path`
8
10
  - Auto-create projects for new paths
9
11
  - Select the main worktree automatically
10
- - Launch terminal + agent workflows for `codex` or `claude`
12
+ - Launch coding workflows for `codex` or `claude`
11
13
  - Support `standard`, `plan`, and `yolo` Codex profiles
12
14
  - Read terminal session lists and AI session summaries
13
15
  - Read AI conversations and continue existing terminal sessions
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codekanban/sdk",
3
3
  "version": "0.1.0",
4
- "description": "Node SDK and CLI for launching CodeKanban project workflows and reading business sessions.",
4
+ "description": "Node SDK and CLI for launching CodeKanban coding workflows, terminal sessions, and web sessions.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -113,10 +113,6 @@ function parseCliArgs(argv) {
113
113
  flags.workingDir = readFlagValue(argv, index, token);
114
114
  index += 1;
115
115
  break;
116
- case '--task-id':
117
- flags.taskId = readFlagValue(argv, index, token);
118
- index += 1;
119
- break;
120
116
  case '--worktree-id':
121
117
  flags.worktreeId = readFlagValue(argv, index, token);
122
118
  index += 1;
@@ -364,7 +360,6 @@ export async function runCli(argv, options = {}) {
364
360
  extraArgs: flags.extraArgs,
365
361
  title: flags.title,
366
362
  workingDir: flags.workingDir,
367
- taskId: flags.taskId,
368
363
  });
369
364
  } else if (scope === 'session' && action === 'list') {
370
365
  result = await client.listSessions({
@@ -23,19 +23,122 @@ import {
23
23
  toWsUrl,
24
24
  } from './utils.js';
25
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
+
26
128
  export class CodeKanbanClient {
27
129
  constructor(options = {}) {
28
130
  this.baseURL = normalizeBaseUrl(options.baseURL);
29
131
  this.headers = { ...(options.headers || {}) };
30
132
  this.fetchImpl = options.fetchImpl || globalThis.fetch;
31
133
  this.WebSocketImpl = options.WebSocketImpl || globalThis.WebSocket;
134
+ this.requestRetry = normalizeRequestRetryConfig(options.requestRetry);
32
135
  if (!this.fetchImpl) {
33
136
  throw new CodeKanbanConfigError('fetch implementation is unavailable');
34
137
  }
35
138
  }
36
139
 
37
140
  async requestJson(path, options = {}) {
38
- const method = options.method || 'GET';
141
+ const method = String(options.method || 'GET').toUpperCase();
39
142
  const headers = { Accept: 'application/json', ...this.headers, ...(options.headers || {}) };
40
143
  const request = {
41
144
  method,
@@ -45,18 +148,34 @@ export class CodeKanbanClient {
45
148
  request.body = JSON.stringify(options.body);
46
149
  request.headers['Content-Type'] = 'application/json';
47
150
  }
48
- const response = await this.fetchImpl(new URL(path, this.baseURL), request);
49
- const text = await response.text();
50
- const body = text ? JSON.parse(text) : null;
51
- if (!response.ok) {
52
- throw new CodeKanbanHttpError(`request failed with ${response.status}`, {
53
- status: response.status,
54
- method,
55
- path,
56
- body,
57
- });
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
+ }
58
176
  }
59
- return body;
177
+
178
+ throw new CodeKanbanValidationError(`request retry loop exited unexpectedly for ${method} ${path}`);
60
179
  }
61
180
 
62
181
  async resolveProjectReference({ projectId, path, ensureProject = true }) {
@@ -68,6 +187,19 @@ export class CodeKanbanClient {
68
187
  return project;
69
188
  }
70
189
 
190
+ async resolveProjectId({ projectId, path, ensureProject = true }) {
191
+ const resolvedProjectId = ensureOptionalString(projectId);
192
+ if (resolvedProjectId) {
193
+ return resolvedProjectId;
194
+ }
195
+ const { project } = await this.resolveProject({
196
+ projectId,
197
+ path,
198
+ ensureProject,
199
+ });
200
+ return ensureString(project?.id, 'projectId');
201
+ }
202
+
71
203
  async listProjects() {
72
204
  const response = await this.requestJson('/api/v1/projects');
73
205
  return response?.items || [];
@@ -156,7 +288,7 @@ export class CodeKanbanClient {
156
288
  return response?.item || null;
157
289
  }
158
290
 
159
- async createTerminalSession({ projectId, worktreeId, workingDir = '', title = '', rows = 0, cols = 0, taskId = '' }) {
291
+ async createTerminalSession({ projectId, worktreeId, workingDir = '', title = '', rows = 0, cols = 0 }) {
160
292
  ensureString(projectId, 'projectId');
161
293
  ensureString(worktreeId, 'worktreeId');
162
294
  const response = await this.requestJson(`/api/v1/projects/${projectId}/worktrees/${worktreeId}/terminals`, {
@@ -166,7 +298,6 @@ export class CodeKanbanClient {
166
298
  title,
167
299
  rows,
168
300
  cols,
169
- taskId,
170
301
  },
171
302
  });
172
303
  return response?.item;
@@ -292,27 +423,40 @@ export class CodeKanbanClient {
292
423
  }
293
424
 
294
425
  async listWebSessions({ projectId, path, ensureProject = true } = {}) {
295
- const project = await this.resolveProjectReference({ projectId, path, ensureProject });
296
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions`);
426
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject });
427
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions`);
297
428
  return response?.items || [];
298
429
  }
299
430
 
300
431
  async createWebSession(input = {}) {
301
- const project = await this.resolveProjectReference({
432
+ const projectId = await this.resolveProjectId({
302
433
  projectId: input.projectId,
303
434
  path: input.path,
304
435
  ensureProject: true,
305
436
  });
306
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions`, {
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`, {
307
446
  method: 'POST',
308
447
  body: {
309
- worktreeId: ensureOptionalString(input.worktreeId),
310
- agent: ensureString(input.agent, 'agent'),
311
- model: ensureOptionalString(input.model),
312
- reasoningEffort: ensureOptionalString(input.reasoningEffort),
313
- workflowMode: ensureOptionalString(input.workflowMode),
314
- permissionLevel: ensureOptionalString(input.permissionLevel),
315
- permissionMode: ensureOptionalString(input.permissionMode),
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,
316
460
  title: ensureOptionalString(input.title),
317
461
  },
318
462
  });
@@ -320,17 +464,17 @@ export class CodeKanbanClient {
320
464
  }
321
465
 
322
466
  async getWebSessionSnapshot({ projectId, path, sessionId, limit = 80 }) {
323
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
467
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
324
468
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
325
469
  const normalizedLimit = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 80;
326
470
  const response = await this.requestJson(
327
- `/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/snapshot?limit=${normalizedLimit}`,
471
+ `/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/snapshot?limit=${normalizedLimit}`,
328
472
  );
329
473
  return response?.item || null;
330
474
  }
331
475
 
332
476
  async getWebSessionHistory({ projectId, path, sessionId, beforeCursor, limit = 80 }) {
333
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
477
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
334
478
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
335
479
  const params = new URLSearchParams();
336
480
  if (ensureOptionalString(beforeCursor)) {
@@ -341,15 +485,15 @@ export class CodeKanbanClient {
341
485
  }
342
486
  const suffix = params.toString();
343
487
  const response = await this.requestJson(
344
- `/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/history${suffix ? `?${suffix}` : ''}`,
488
+ `/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/history${suffix ? `?${suffix}` : ''}`,
345
489
  );
346
490
  return response?.item || null;
347
491
  }
348
492
 
349
493
  async syncWebSession({ projectId, path, sessionId, mode, clearExisting = false }) {
350
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
494
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
351
495
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
352
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/sync`, {
496
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/sync`, {
353
497
  method: 'POST',
354
498
  body: {
355
499
  ...(ensureOptionalString(mode) ? { mode: ensureOptionalString(mode) } : {}),
@@ -360,27 +504,27 @@ export class CodeKanbanClient {
360
504
  }
361
505
 
362
506
  async archiveWebSession({ projectId, path, sessionId }) {
363
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
507
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
364
508
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
365
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/archive`, {
509
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/archive`, {
366
510
  method: 'POST',
367
511
  });
368
512
  return response?.item || null;
369
513
  }
370
514
 
371
515
  async unarchiveWebSession({ projectId, path, sessionId }) {
372
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
516
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
373
517
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
374
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/unarchive`, {
518
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/unarchive`, {
375
519
  method: 'POST',
376
520
  });
377
521
  return response?.item || null;
378
522
  }
379
523
 
380
524
  async renameWebSession({ projectId, path, sessionId, title }) {
381
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
525
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
382
526
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
383
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/rename`, {
527
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/rename`, {
384
528
  method: 'POST',
385
529
  body: {
386
530
  title: ensureString(title, 'title'),
@@ -390,9 +534,9 @@ export class CodeKanbanClient {
390
534
  }
391
535
 
392
536
  async closeWebSession({ projectId, path, sessionId }) {
393
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
537
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
394
538
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
395
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/close`, {
539
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/close`, {
396
540
  method: 'POST',
397
541
  });
398
542
  return {
@@ -401,9 +545,9 @@ export class CodeKanbanClient {
401
545
  }
402
546
 
403
547
  async deleteWebSession({ projectId, path, sessionId }) {
404
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
548
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
405
549
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
406
- const response = await this.requestJson(`/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}`, {
550
+ const response = await this.requestJson(`/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}`, {
407
551
  method: 'DELETE',
408
552
  });
409
553
  return {
@@ -424,11 +568,11 @@ export class CodeKanbanClient {
424
568
  }
425
569
 
426
570
  async getWebSessionCommandGroup({ projectId, path, sessionId, groupId }) {
427
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
571
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
428
572
  const resolvedSessionId = ensureString(sessionId, 'sessionId');
429
573
  const resolvedGroupId = ensureString(groupId, 'groupId');
430
574
  const response = await this.requestJson(
431
- `/api/v1/projects/${project.id}/web-sessions/${resolvedSessionId}/command-groups/${resolvedGroupId}`,
575
+ `/api/v1/projects/${resolvedProjectId}/web-sessions/${resolvedSessionId}/command-groups/${resolvedGroupId}`,
432
576
  );
433
577
  return response?.item || null;
434
578
  }
@@ -439,7 +583,7 @@ export class CodeKanbanClient {
439
583
  }
440
584
 
441
585
  async uploadWebSessionAttachment({ projectId, path, filePath, fileName, mimeType }) {
442
- const project = await this.resolveProjectReference({ projectId, path, ensureProject: true });
586
+ const resolvedProjectId = await this.resolveProjectId({ projectId, path, ensureProject: true });
443
587
  const resolvedFilePath = ensureString(filePath, 'filePath');
444
588
  const resolvedFileName = ensureOptionalString(fileName) || pathBasename(resolvedFilePath);
445
589
  const resolvedMimeType = ensureImageMimeType(mimeType, resolvedFileName);
@@ -454,7 +598,7 @@ export class CodeKanbanClient {
454
598
  delete headers['Content-Type'];
455
599
 
456
600
  const response = await this.fetchImpl(
457
- new URL(`/api/v1/projects/${project.id}/web-sessions/attachments`, this.baseURL),
601
+ new URL(`/api/v1/projects/${resolvedProjectId}/web-sessions/attachments`, this.baseURL),
458
602
  {
459
603
  method: 'POST',
460
604
  headers,
@@ -467,7 +611,7 @@ export class CodeKanbanClient {
467
611
  throw new CodeKanbanHttpError(`request failed with ${response.status}`, {
468
612
  status: response.status,
469
613
  method: 'POST',
470
- path: `/api/v1/projects/${project.id}/web-sessions/attachments`,
614
+ path: `/api/v1/projects/${resolvedProjectId}/web-sessions/attachments`,
471
615
  body,
472
616
  });
473
617
  }
@@ -662,19 +806,36 @@ export class CodeKanbanClient {
662
806
  : state => state.phase === until;
663
807
 
664
808
  const startedAt = Date.now();
809
+ let lastRetryableError = null;
665
810
  while (Date.now() - startedAt <= timeoutMs) {
666
- const state = await this.getWebSessionState({
667
- projectId,
668
- path,
669
- sessionId,
670
- limit,
671
- });
672
- if (matches(state)) {
673
- return state;
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;
674
827
  }
828
+
675
829
  await sleep(Math.max(1, Math.trunc(intervalMs)));
676
830
  }
677
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
+
678
839
  throw new CodeKanbanValidationError(`web session ${sessionId} did not reach the requested state within ${timeoutMs}ms`);
679
840
  }
680
841
 
@@ -695,7 +856,6 @@ export class CodeKanbanClient {
695
856
  worktreeId: worktree.id,
696
857
  workingDir: ensureOptionalString(input.workingDir) || worktree.path,
697
858
  title: ensureOptionalString(input.title) || ensureOptionalString(input.prompt) || 'AI workflow',
698
- taskId: ensureOptionalString(input.taskId),
699
859
  rows: Number.isFinite(input.rows) ? input.rows : 0,
700
860
  cols: Number.isFinite(input.cols) ? input.cols : 0,
701
861
  });
@@ -2,7 +2,9 @@ import { CodeKanbanConfigError, CodeKanbanError, CodeKanbanValidationError } fro
2
2
  import {
3
3
  WEB_SESSION_PROTOCOL_VERSION,
4
4
  buildWebSessionCommandFrame,
5
+ buildWebSessionHeartbeatFrame,
5
6
  decodeWebSessionSocketMessage,
7
+ isWebSessionHeartbeatFrame,
6
8
  normalizeWebSessionFrame,
7
9
  } from './web-session-shared.js';
8
10
  import { ensureArrayOfStrings, ensureOptionalString, ensureString } from './utils.js';
@@ -307,6 +309,12 @@ export class WebSessionCommandChannel {
307
309
 
308
310
  _handleMessage(data) {
309
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
+ }
310
318
  const frame = normalizeWebSessionFrame(rawFrame);
311
319
 
312
320
  if (frame.type === 'error' && frame.requestId && this._pendingRequests.has(frame.requestId)) {
@@ -2,7 +2,9 @@ import { EventEmitter } from 'node:events';
2
2
 
3
3
  import { CodeKanbanConfigError, CodeKanbanError, CodeKanbanValidationError } from './errors.js';
4
4
  import {
5
+ buildWebSessionHeartbeatFrame,
5
6
  decodeWebSessionSocketMessage,
7
+ isWebSessionHeartbeatFrame,
6
8
  normalizeWebSessionFrame,
7
9
  shouldEmitWebSessionFrame,
8
10
  } from './web-session-shared.js';
@@ -158,9 +160,17 @@ export class WebSessionEventStream {
158
160
  }
159
161
 
160
162
  _handleMessage(data) {
163
+ let rawFrame;
161
164
  let frame;
162
165
  try {
163
- frame = normalizeWebSessionFrame(decodeWebSessionSocketMessage(data));
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);
164
174
  } catch (error) {
165
175
  const payload = {
166
176
  type: 'error',
@@ -6,6 +6,7 @@ import { ensureArrayOfStrings, ensureOptionalString, ensureString } from './util
6
6
  export const WEB_SESSION_PROTOCOL_VERSION = 1;
7
7
  export const WEB_SESSION_COMMAND_WS_PATH = '/api/v1/web-sessions/ws';
8
8
  export const WEB_SESSION_EVENTS_WS_PATH = '/api/v1/web-sessions/events';
9
+ export const WEB_SESSION_HEARTBEAT_KIND = 'hb';
9
10
 
10
11
  const IMAGE_MIME_BY_EXT = new Map([
11
12
  ['.apng', 'image/apng'],
@@ -504,6 +505,14 @@ export function normalizeWebSessionFrame(rawFrame) {
504
505
  };
505
506
  }
506
507
 
508
+ if (rawFrame?.k === WEB_SESSION_HEARTBEAT_KIND) {
509
+ return {
510
+ ...base,
511
+ type: 'heartbeat',
512
+ heartbeatType: trimmedString(rawFrame?.op) || null,
513
+ };
514
+ }
515
+
507
516
  if (rawFrame?.k === 'snap') {
508
517
  return {
509
518
  ...base,
@@ -560,6 +569,19 @@ export function buildWebSessionCommandFrame({ requestId, sessionId, operation, p
560
569
  };
561
570
  }
562
571
 
572
+ export function buildWebSessionHeartbeatFrame(operation) {
573
+ return {
574
+ v: WEB_SESSION_PROTOCOL_VERSION,
575
+ k: WEB_SESSION_HEARTBEAT_KIND,
576
+ ts: Date.now(),
577
+ op: ensureString(operation, 'operation'),
578
+ };
579
+ }
580
+
581
+ export function isWebSessionHeartbeatFrame(rawFrame) {
582
+ return rawFrame?.k === WEB_SESSION_HEARTBEAT_KIND;
583
+ }
584
+
563
585
  export function normalizeWebSessionAttachment(value) {
564
586
  if (!value || typeof value !== 'object') {
565
587
  return null;
@@ -592,7 +614,7 @@ export function ensureImageMimeType(value, fileName) {
592
614
  export function shouldEmitWebSessionFrame(frame, sessionIdFilter) {
593
615
  const normalizedFilter = ensureOptionalString(sessionIdFilter);
594
616
  if (!normalizedFilter) {
595
- return true;
617
+ return frame?.type !== 'heartbeat';
596
618
  }
597
619
  if (!frame || typeof frame !== 'object') {
598
620
  return false;
@@ -600,5 +622,8 @@ export function shouldEmitWebSessionFrame(frame, sessionIdFilter) {
600
622
  if (frame.type === 'open' || frame.type === 'close' || frame.type === 'error') {
601
623
  return true;
602
624
  }
625
+ if (frame.type === 'heartbeat') {
626
+ return false;
627
+ }
603
628
  return ensureOptionalString(frame.sessionId) === normalizedFilter;
604
629
  }