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,629 @@
1
+ import path from 'node:path';
2
+
3
+ import { CodeKanbanValidationError } from './errors.js';
4
+ import { ensureArrayOfStrings, ensureOptionalString, ensureString } from './utils.js';
5
+
6
+ export const WEB_SESSION_PROTOCOL_VERSION = 1;
7
+ export const WEB_SESSION_COMMAND_WS_PATH = '/api/v1/web-sessions/ws';
8
+ export const WEB_SESSION_EVENTS_WS_PATH = '/api/v1/web-sessions/events';
9
+ export const WEB_SESSION_HEARTBEAT_KIND = 'hb';
10
+
11
+ const IMAGE_MIME_BY_EXT = new Map([
12
+ ['.apng', 'image/apng'],
13
+ ['.bmp', 'image/bmp'],
14
+ ['.gif', 'image/gif'],
15
+ ['.heic', 'image/heic'],
16
+ ['.heif', 'image/heif'],
17
+ ['.jpeg', 'image/jpeg'],
18
+ ['.jpg', 'image/jpeg'],
19
+ ['.png', 'image/png'],
20
+ ['.svg', 'image/svg+xml'],
21
+ ['.svgz', 'image/svg+xml'],
22
+ ['.tif', 'image/tiff'],
23
+ ['.tiff', 'image/tiff'],
24
+ ['.webp', 'image/webp'],
25
+ ]);
26
+
27
+ function stringValue(value) {
28
+ if (typeof value === 'string') {
29
+ return value;
30
+ }
31
+ if (value == null) {
32
+ return '';
33
+ }
34
+ return String(value);
35
+ }
36
+
37
+ function trimmedString(value) {
38
+ return stringValue(value).trim();
39
+ }
40
+
41
+ function numberValue(value, fallback = 0) {
42
+ const normalized = Number(value);
43
+ return Number.isFinite(normalized) ? normalized : fallback;
44
+ }
45
+
46
+ function nullableNumberValue(value) {
47
+ const normalized = Number(value);
48
+ return Number.isFinite(normalized) ? normalized : null;
49
+ }
50
+
51
+ function isoFromUnixMilli(value) {
52
+ const millis = nullableNumberValue(value);
53
+ if (millis == null) {
54
+ return null;
55
+ }
56
+ return new Date(millis).toISOString();
57
+ }
58
+
59
+ function booleanValue(value) {
60
+ return value === true;
61
+ }
62
+
63
+ function normalizeUsage(value) {
64
+ return {
65
+ inputTokens: numberValue(value?.in, 0),
66
+ cachedInputTokens: numberValue(value?.cin, 0),
67
+ outputTokens: numberValue(value?.out, 0),
68
+ cost: numberValue(value?.cost, 0),
69
+ };
70
+ }
71
+
72
+ function normalizeHistoryAttachment(value) {
73
+ return {
74
+ id: trimmedString(value?.id),
75
+ name: trimmedString(value?.name),
76
+ mime: trimmedString(value?.mime) || null,
77
+ size: nullableNumberValue(value?.sz),
78
+ path: trimmedString(value?.path) || null,
79
+ };
80
+ }
81
+
82
+ function normalizeHistoryToolCommandGroup(value) {
83
+ const id = trimmedString(value?.id);
84
+ if (!id) {
85
+ return null;
86
+ }
87
+ return {
88
+ id,
89
+ count: Math.max(1, numberValue(value?.count, 1)),
90
+ firstSeq: nullableNumberValue(value?.firstSeq),
91
+ lastSeq: nullableNumberValue(value?.lastSeq),
92
+ latestToolId: trimmedString(value?.latestToolId) || null,
93
+ compacted: booleanValue(value?.compacted),
94
+ };
95
+ }
96
+
97
+ function normalizeHistoryTool(value) {
98
+ if (!value || typeof value !== 'object') {
99
+ return null;
100
+ }
101
+ return {
102
+ id: trimmedString(value.id),
103
+ name: trimmedString(value.name),
104
+ kind: trimmedString(value.kind) || null,
105
+ input: value.in,
106
+ output: trimmedString(value.out) || null,
107
+ status: trimmedString(value.st) || 'running',
108
+ meta: value.meta && typeof value.meta === 'object' ? value.meta : null,
109
+ commandGroup: normalizeHistoryToolCommandGroup(value.cg),
110
+ };
111
+ }
112
+
113
+ function normalizeUserInputOption(value) {
114
+ return {
115
+ label: trimmedString(value?.label),
116
+ description: trimmedString(value?.description),
117
+ };
118
+ }
119
+
120
+ function normalizeUserInputQuestion(value) {
121
+ return {
122
+ id: trimmedString(value?.id),
123
+ header: trimmedString(value?.header),
124
+ question: trimmedString(value?.question),
125
+ isOther: booleanValue(value?.isOther),
126
+ isSecret: booleanValue(value?.isSecret),
127
+ options: Array.isArray(value?.options) ? value.options.map(normalizeUserInputOption) : [],
128
+ };
129
+ }
130
+
131
+ function normalizeHistoryAnswerEntry(value) {
132
+ return {
133
+ id: trimmedString(value?.id),
134
+ label: trimmedString(value?.label),
135
+ values: ensureArrayOfStrings(value?.values, 'values'),
136
+ masked: booleanValue(value?.masked),
137
+ };
138
+ }
139
+
140
+ function normalizeHistoryDetail(value) {
141
+ if (!value || typeof value !== 'object') {
142
+ return null;
143
+ }
144
+ return {
145
+ type: trimmedString(value.type),
146
+ prompt: trimmedString(value.prompt) || null,
147
+ questions: Array.isArray(value.questions) ? value.questions.map(normalizeUserInputQuestion) : [],
148
+ answers: Array.isArray(value.answers) ? value.answers.map(normalizeHistoryAnswerEntry) : [],
149
+ action: trimmedString(value.action) || null,
150
+ };
151
+ }
152
+
153
+ export function normalizeWebSessionHistoryItem(value) {
154
+ const updatedTimestamp = isoFromUnixMilli(value?.ts2) || isoFromUnixMilli(value?.obs);
155
+ return {
156
+ id: trimmedString(value?.id),
157
+ sourceTurnId: trimmedString(value?.stid) || null,
158
+ sourceItemId: trimmedString(value?.siid) || null,
159
+ orderIndex: numberValue(value?.oi, 0),
160
+ kind: trimmedString(value?.kd) || 'system',
161
+ itemType: trimmedString(value?.tp),
162
+ text: stringValue(value?.txt),
163
+ timestamp: isoFromUnixMilli(value?.ts2),
164
+ observedAt: isoFromUnixMilli(value?.obs),
165
+ attachments: Array.isArray(value?.atts) ? value.atts.map(normalizeHistoryAttachment) : [],
166
+ tool: normalizeHistoryTool(value?.tl),
167
+ level: trimmedString(value?.lvl) || null,
168
+ done: booleanValue(value?.dn),
169
+ detail: normalizeHistoryDetail(value?.dt),
170
+ payload: value?.pl && typeof value.pl === 'object' ? value.pl : null,
171
+ updatedAt: updatedTimestamp,
172
+ };
173
+ }
174
+
175
+ export function normalizeWebSessionHistoryWindow(value) {
176
+ return {
177
+ items: Array.isArray(value?.its) ? value.its.map(normalizeWebSessionHistoryItem) : [],
178
+ hasMore: booleanValue(value?.hm),
179
+ beforeCursor: trimmedString(value?.bc) || null,
180
+ total: numberValue(value?.tot, 0),
181
+ };
182
+ }
183
+
184
+ export function normalizeWebSessionSummaryFromWire(value) {
185
+ const updatedAtMs = numberValue(value?.lu, Date.now());
186
+ return {
187
+ id: trimmedString(value?.id),
188
+ projectId: trimmedString(value?.pid),
189
+ worktreeId: trimmedString(value?.wid) || null,
190
+ orderIndex: numberValue(value?.oi, 0),
191
+ agent: trimmedString(value?.ag) || 'codex',
192
+ title: trimmedString(value?.ttl),
193
+ model: trimmedString(value?.md),
194
+ reasoningEffort: trimmedString(value?.re) || 'default',
195
+ workflowMode: trimmedString(value?.wm) || 'default',
196
+ permissionLevel: trimmedString(value?.pl) || 'elevated',
197
+ cwd: trimmedString(value?.cwd),
198
+ nativeSessionId: trimmedString(value?.nsid) || null,
199
+ status: trimmedString(value?.st) || 'idle',
200
+ assistantState: trimmedString(value?.ast) || null,
201
+ hasUnread: booleanValue(value?.unr),
202
+ archivedAt: isoFromUnixMilli(value?.aa),
203
+ activityAt: isoFromUnixMilli(value?.act) || new Date(updatedAtMs).toISOString(),
204
+ lastMessageAt: isoFromUnixMilli(value?.lma),
205
+ assistantStateUpdatedAt: isoFromUnixMilli(value?.asu),
206
+ sourceKind: trimmedString(value?.sk) || 'codex_app_server',
207
+ syncState: trimmedString(value?.ss) || 'missing',
208
+ lastSyncMode: trimmedString(value?.lsm) || null,
209
+ sourceCreatedAt: isoFromUnixMilli(value?.sca),
210
+ sourceUpdatedAt: isoFromUnixMilli(value?.sua),
211
+ lastSyncedAt: isoFromUnixMilli(value?.lsa),
212
+ threadPath: trimmedString(value?.tp) || null,
213
+ threadPreview: trimmedString(value?.tpv) || null,
214
+ turnCount: numberValue(value?.tc, 0),
215
+ itemCount: numberValue(value?.ic, 0),
216
+ syncError: trimmedString(value?.se) || null,
217
+ createdAt: isoFromUnixMilli(value?.ca) || new Date(updatedAtMs).toISOString(),
218
+ updatedAt: new Date(updatedAtMs).toISOString(),
219
+ usage: {
220
+ ...normalizeUsage(value?.usa),
221
+ cost: numberValue(value?.cost, 0),
222
+ },
223
+ contextWindowTokens: nullableNumberValue(value?.cwt),
224
+ contextWindowSource: trimmedString(value?.cws) || 'unavailable',
225
+ };
226
+ }
227
+
228
+ export function normalizeWebSessionSnapshotFromWire(frame) {
229
+ return {
230
+ session: normalizeWebSessionSummaryFromWire(frame?.s),
231
+ history: normalizeWebSessionHistoryWindow(frame?.h),
232
+ };
233
+ }
234
+
235
+ const PROCESS_RESTART_REASON = 'process_restart';
236
+ const DEFAULT_RECOVERY_MESSAGE = 'Session runtime was interrupted. Send a new message to continue.';
237
+
238
+ function isProcessRestartPayload(payload) {
239
+ return trimmedString(payload?.reason) === PROCESS_RESTART_REASON;
240
+ }
241
+
242
+ function getRecoveryMessage(payload) {
243
+ return trimmedString(payload?.msg) || DEFAULT_RECOVERY_MESSAGE;
244
+ }
245
+
246
+ function normalizeChoiceText(value) {
247
+ return trimmedString(value)
248
+ .toLowerCase()
249
+ .replace(/\s+/g, ' ');
250
+ }
251
+
252
+ function isExecutePlanOption(option) {
253
+ const text = normalizeChoiceText(`${option?.label || ''} ${option?.description || ''}`);
254
+ const mentionsPlan = /计划|plan/.test(text);
255
+ const mentionsExecute = /开始|执行|实现|实施|继续|start|execute|implement|proceed/.test(text);
256
+ const mentionsCancel = /取消|暂不|稍后|later|cancel|dismiss|hold/.test(text);
257
+ return mentionsExecute && (mentionsPlan || !mentionsCancel);
258
+ }
259
+
260
+ function isCancelPlanOption(option) {
261
+ const text = normalizeChoiceText(`${option?.label || ''} ${option?.description || ''}`);
262
+ return /取消|暂不|稍后|later|cancel|dismiss|hold|keep planning|stay in plan/.test(text);
263
+ }
264
+
265
+ function isPlanChoiceQuestion(question) {
266
+ if (!question || !Array.isArray(question.options) || question.options.length !== 2) {
267
+ return false;
268
+ }
269
+ const hasExecute = question.options.some(isExecutePlanOption);
270
+ const hasCancel = question.options.some(isCancelPlanOption);
271
+ return hasExecute && hasCancel;
272
+ }
273
+
274
+ function findPendingApproval(items) {
275
+ let pending = null;
276
+ for (const item of items) {
277
+ if (item?.detail?.type === 'approval_request') {
278
+ pending = {
279
+ id: item.id,
280
+ itemId: item.sourceItemId || item.id,
281
+ prompt: item.detail.prompt || item.text || '',
282
+ requestedAt: item.timestamp || item.observedAt || null,
283
+ stale: false,
284
+ };
285
+ continue;
286
+ }
287
+ if (item?.detail?.type === 'approval_response' || item?.kind === 'user') {
288
+ pending = null;
289
+ continue;
290
+ }
291
+ if (item?.itemType === 'run_abort' && pending && isProcessRestartPayload(item.payload || undefined)) {
292
+ pending = {
293
+ ...pending,
294
+ stale: true,
295
+ recoveryReason: trimmedString(item.payload?.reason) || null,
296
+ recoveryMessage: getRecoveryMessage(item.payload || undefined),
297
+ };
298
+ continue;
299
+ }
300
+ if (item?.itemType === 'run_abort' || item?.itemType === 'run_fail') {
301
+ pending = null;
302
+ }
303
+ }
304
+ return pending;
305
+ }
306
+
307
+ function findPendingUserInput(items) {
308
+ let pending = null;
309
+ for (const item of items) {
310
+ if (item?.detail?.type === 'user_input_request') {
311
+ const questions = Array.isArray(item.detail.questions) ? item.detail.questions : [];
312
+ const question = questions[0];
313
+ const executeOption =
314
+ questions.length === 1 && isPlanChoiceQuestion(question)
315
+ ? question.options.find(isExecutePlanOption) || null
316
+ : null;
317
+ pending = {
318
+ id: item.id,
319
+ itemId: item.sourceItemId || trimmedString(item.payload?.iid) || item.id,
320
+ prompt: item.detail.prompt || item.text || '',
321
+ questions,
322
+ requestedAt: item.timestamp || item.observedAt || null,
323
+ stale: false,
324
+ isPlanChoice: Boolean(executeOption),
325
+ questionId: executeOption ? trimmedString(question?.id) || null : null,
326
+ executeOptionLabel: executeOption ? trimmedString(executeOption.label) || null : null,
327
+ };
328
+ continue;
329
+ }
330
+ if (item?.detail?.type === 'user_input_response' || item?.kind === 'user') {
331
+ pending = null;
332
+ continue;
333
+ }
334
+ if (item?.itemType === 'run_abort' && pending && isProcessRestartPayload(item.payload || undefined)) {
335
+ pending = {
336
+ ...pending,
337
+ stale: true,
338
+ recoveryReason: trimmedString(item.payload?.reason) || null,
339
+ recoveryMessage: getRecoveryMessage(item.payload || undefined),
340
+ };
341
+ continue;
342
+ }
343
+ if (item?.itemType === 'run_abort' || item?.itemType === 'run_fail') {
344
+ pending = null;
345
+ }
346
+ }
347
+ return pending;
348
+ }
349
+
350
+ function findLatestPlan(items) {
351
+ const latestPlan = [...items]
352
+ .reverse()
353
+ .find(item => item?.kind === 'tool' && item?.tool?.kind === 'plan');
354
+ if (!latestPlan) {
355
+ return null;
356
+ }
357
+ const hasUserMessageAfter = items.some(
358
+ item => item?.kind === 'user' && numberValue(item.orderIndex, 0) > numberValue(latestPlan.orderIndex, 0),
359
+ );
360
+ return {
361
+ id: latestPlan.id,
362
+ itemId: latestPlan.id,
363
+ toolId: latestPlan.tool.id || latestPlan.id,
364
+ output: latestPlan.tool.output || latestPlan.text || '',
365
+ timestamp: latestPlan.timestamp || latestPlan.observedAt || null,
366
+ orderIndex: latestPlan.orderIndex,
367
+ hasUserMessageAfter,
368
+ awaitingExecution: !hasUserMessageAfter,
369
+ };
370
+ }
371
+
372
+ function findLastAssistantMessage(items) {
373
+ const lastAssistant = [...items].reverse().find(item => item?.kind === 'assistant');
374
+ if (!lastAssistant) {
375
+ return null;
376
+ }
377
+ return {
378
+ id: lastAssistant.id,
379
+ itemId: lastAssistant.id,
380
+ text: lastAssistant.text || '',
381
+ timestamp: lastAssistant.timestamp || lastAssistant.observedAt || null,
382
+ orderIndex: lastAssistant.orderIndex,
383
+ };
384
+ }
385
+
386
+ export function analyzeWebSession(snapshot) {
387
+ const session = snapshot?.session || null;
388
+ const history = snapshot?.history || { items: [], hasMore: false, beforeCursor: null, total: 0 };
389
+ const items = Array.isArray(history.items) ? history.items : [];
390
+ const pendingApproval = findPendingApproval(items);
391
+ const pendingUserInput = findPendingUserInput(items);
392
+ const latestPlan = findLatestPlan(items);
393
+ const lastAssistantMessage = findLastAssistantMessage(items);
394
+
395
+ let phase = 'idle';
396
+ if (session?.assistantState === 'waiting_input') {
397
+ phase = 'waiting_input';
398
+ } else if (session?.assistantState === 'waiting_approval') {
399
+ phase = 'waiting_approval';
400
+ } else if (session?.assistantState === 'waiting_plan_approval') {
401
+ phase = 'waiting_plan_approval';
402
+ } else if (session?.status === 'running') {
403
+ phase = 'running';
404
+ } else if (session?.status === 'done') {
405
+ phase = 'done';
406
+ } else if (session?.status === 'err') {
407
+ phase = 'error';
408
+ } else if (session?.status === 'aborting') {
409
+ phase = 'aborting';
410
+ }
411
+
412
+ let nextAction = null;
413
+ if (pendingApproval) {
414
+ nextAction = {
415
+ type: 'approval',
416
+ prompt: pendingApproval.prompt,
417
+ requestedAt: pendingApproval.requestedAt,
418
+ };
419
+ } else if (pendingUserInput?.isPlanChoice && latestPlan?.awaitingExecution) {
420
+ nextAction = {
421
+ type: 'execute_plan',
422
+ itemId: pendingUserInput.itemId,
423
+ questionId: pendingUserInput.questionId,
424
+ executeOptionLabel: pendingUserInput.executeOptionLabel,
425
+ latestPlan,
426
+ };
427
+ } else if (pendingUserInput) {
428
+ nextAction = {
429
+ type: 'answer_user_input',
430
+ itemId: pendingUserInput.itemId,
431
+ prompt: pendingUserInput.prompt,
432
+ questions: pendingUserInput.questions,
433
+ requestedAt: pendingUserInput.requestedAt,
434
+ };
435
+ } else if (phase === 'waiting_plan_approval' && latestPlan?.awaitingExecution) {
436
+ nextAction = {
437
+ type: 'execute_plan',
438
+ latestPlan,
439
+ };
440
+ }
441
+
442
+ const canSend =
443
+ phase === 'idle' ||
444
+ phase === 'done' ||
445
+ phase === 'error' ||
446
+ phase === 'waiting_plan_approval';
447
+
448
+ return {
449
+ phase,
450
+ canSend,
451
+ needsAction: nextAction != null,
452
+ nextAction,
453
+ pendingApproval,
454
+ pendingUserInput,
455
+ latestPlan,
456
+ lastAssistantMessage,
457
+ session,
458
+ snapshot: {
459
+ session,
460
+ history,
461
+ },
462
+ };
463
+ }
464
+
465
+ export function decodeWebSessionSocketMessage(raw) {
466
+ if (typeof raw === 'string') {
467
+ return JSON.parse(raw);
468
+ }
469
+ if (raw instanceof ArrayBuffer) {
470
+ return JSON.parse(Buffer.from(raw).toString('utf8'));
471
+ }
472
+ if (ArrayBuffer.isView(raw)) {
473
+ return JSON.parse(Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8'));
474
+ }
475
+ return JSON.parse(String(raw ?? ''));
476
+ }
477
+
478
+ export function normalizeWebSessionFrame(rawFrame) {
479
+ const timestampMs = nullableNumberValue(rawFrame?.ts);
480
+ const base = {
481
+ requestId: trimmedString(rawFrame?.rid) || null,
482
+ sessionId: trimmedString(rawFrame?.sid) || null,
483
+ timestampMs,
484
+ timestamp: timestampMs == null ? null : new Date(timestampMs).toISOString(),
485
+ operation: trimmedString(rawFrame?.op) || null,
486
+ raw: rawFrame,
487
+ };
488
+
489
+ if (rawFrame?.k === 'ack') {
490
+ return {
491
+ ...base,
492
+ type: 'ack',
493
+ ok: rawFrame?.ok === 1 || rawFrame?.ok === true,
494
+ payload: rawFrame?.p ?? null,
495
+ };
496
+ }
497
+
498
+ if (rawFrame?.k === 'err') {
499
+ return {
500
+ ...base,
501
+ type: 'error',
502
+ code: trimmedString(rawFrame?.code) || 'unknown_error',
503
+ message: trimmedString(rawFrame?.msg) || 'Unknown websocket error',
504
+ retry: booleanValue(rawFrame?.retry),
505
+ };
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
+
516
+ if (rawFrame?.k === 'snap') {
517
+ return {
518
+ ...base,
519
+ type: 'snapshot',
520
+ snapshot: normalizeWebSessionSnapshotFromWire(rawFrame),
521
+ };
522
+ }
523
+
524
+ if (rawFrame?.k === 'evt') {
525
+ if (rawFrame?.op === 'session' && rawFrame?.s) {
526
+ return {
527
+ ...base,
528
+ type: 'session',
529
+ session: normalizeWebSessionSummaryFromWire(rawFrame.s),
530
+ };
531
+ }
532
+ if (rawFrame?.op === 'hist_page' && rawFrame?.h) {
533
+ return {
534
+ ...base,
535
+ type: 'historyPage',
536
+ history: normalizeWebSessionHistoryWindow(rawFrame.h),
537
+ };
538
+ }
539
+ if (rawFrame?.op === 'hist_item' && rawFrame?.i) {
540
+ return {
541
+ ...base,
542
+ type: 'historyItem',
543
+ item: normalizeWebSessionHistoryItem(rawFrame.i),
544
+ session: rawFrame?.s ? normalizeWebSessionSummaryFromWire(rawFrame.s) : null,
545
+ };
546
+ }
547
+ return {
548
+ ...base,
549
+ type: 'event',
550
+ payload: rawFrame?.p ?? null,
551
+ };
552
+ }
553
+
554
+ return {
555
+ ...base,
556
+ type: 'unknown',
557
+ payload: rawFrame?.p ?? null,
558
+ };
559
+ }
560
+
561
+ export function buildWebSessionCommandFrame({ requestId, sessionId, operation, payload }) {
562
+ return {
563
+ v: WEB_SESSION_PROTOCOL_VERSION,
564
+ k: 'cmd',
565
+ rid: ensureString(requestId, 'requestId'),
566
+ sid: ensureOptionalString(sessionId) || undefined,
567
+ op: ensureString(operation, 'operation'),
568
+ p: payload ?? {},
569
+ };
570
+ }
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
+
585
+ export function normalizeWebSessionAttachment(value) {
586
+ if (!value || typeof value !== 'object') {
587
+ return null;
588
+ }
589
+ return {
590
+ id: trimmedString(value.id),
591
+ name: trimmedString(value.name),
592
+ mime: trimmedString(value.mime),
593
+ size: numberValue(value.size, 0),
594
+ path: trimmedString(value.path),
595
+ createdAt: stringValue(value.createdAt || ''),
596
+ };
597
+ }
598
+
599
+ export function inferWebSessionAttachmentMimeType(fileName) {
600
+ const extension = path.extname(trimmedString(fileName)).toLowerCase();
601
+ return IMAGE_MIME_BY_EXT.get(extension) || '';
602
+ }
603
+
604
+ export function ensureImageMimeType(value, fileName) {
605
+ const explicit = ensureOptionalString(value);
606
+ const inferred = inferWebSessionAttachmentMimeType(fileName);
607
+ const mimeType = explicit || inferred;
608
+ if (!mimeType || !mimeType.startsWith('image/')) {
609
+ throw new CodeKanbanValidationError('web session attachments must be image files');
610
+ }
611
+ return mimeType;
612
+ }
613
+
614
+ export function shouldEmitWebSessionFrame(frame, sessionIdFilter) {
615
+ const normalizedFilter = ensureOptionalString(sessionIdFilter);
616
+ if (!normalizedFilter) {
617
+ return frame?.type !== 'heartbeat';
618
+ }
619
+ if (!frame || typeof frame !== 'object') {
620
+ return false;
621
+ }
622
+ if (frame.type === 'open' || frame.type === 'close' || frame.type === 'error') {
623
+ return true;
624
+ }
625
+ if (frame.type === 'heartbeat') {
626
+ return false;
627
+ }
628
+ return ensureOptionalString(frame.sessionId) === normalizedFilter;
629
+ }
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "CodeKanban Project Session"
3
- short_description: "Start AI workflows and inspect project sessions"
4
- default_prompt: "Use the CodeKanban project session workflow to start Codex or Claude in a project path, including plan or yolo startup profiles, or inspect terminal and AI sessions for that project."
3
+ short_description: "Prefer web sessions for CodeKanban AI work"
4
+ default_prompt: "Use the CodeKanban project session workflow with web session as the default interactive path. Prefer web-session create/control/watch for structured AI work in a project path, and use workflow start or terminal continue only when the user explicitly wants PTY-style terminal behavior or startup profiles such as plan or yolo."