bosun 0.31.5 → 0.31.6

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.
@@ -903,7 +903,7 @@ class GitHubIssuesAdapter {
903
903
  process.env.BOSUN_TASK_LABEL || "bosun";
904
904
  this._taskScopeLabels = normalizeLabels(
905
905
  process.env.BOSUN_TASK_LABELS ||
906
- `${this._canonicalTaskLabel},codex-mointor`,
906
+ `${this._canonicalTaskLabel},codex-monitor`,
907
907
  );
908
908
  this._enforceTaskLabel = parseBooleanEnv(
909
909
  process.env.BOSUN_ENFORCE_TASK_LABEL,
@@ -915,7 +915,7 @@ class GitHubIssuesAdapter {
915
915
  true,
916
916
  );
917
917
  this._defaultAssignee =
918
- process.env.GITHUB_DEFAULT_ASSIGNEE || this._owner || null;
918
+ String(process.env.GITHUB_DEFAULT_ASSIGNEE || "").trim() || null;
919
919
 
920
920
  this._projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues")
921
921
  .trim()
package/monitor.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync, spawn, spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import {
3
4
  existsSync,
4
5
  mkdirSync,
@@ -7902,10 +7903,8 @@ function extractPlannerTasksFromOutput(output, maxTasks) {
7902
7903
  return normalized;
7903
7904
  }
7904
7905
 
7905
- async function materializePlannerTasksToKanban(projectId, tasks) {
7906
- const existingOpenTasks = await listKanbanTasks(projectId, {
7907
- status: "todo",
7908
- });
7906
+ async function materializePlannerTasksToKanban(tasks) {
7907
+ const existingOpenTasks = getInternalTasksByStatus("todo");
7909
7908
  const existingTitles = new Set(
7910
7909
  (Array.isArray(existingOpenTasks) ? existingOpenTasks : [])
7911
7910
  .map((task) => normalizePlannerTitleForComparison(task?.title))
@@ -7925,16 +7924,26 @@ async function materializePlannerTasksToKanban(projectId, tasks) {
7925
7924
  skipped.push({ title: task.title, reason: "duplicate_title" });
7926
7925
  continue;
7927
7926
  }
7928
- const createdTask = await createKanbanTask(projectId, {
7927
+ const baseBranch = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
7928
+ const plannerMeta = {
7929
+ source: "task-planner",
7930
+ plannerMode: "codex-sdk",
7931
+ kind: "planned-task",
7932
+ externalSyncPending: true,
7933
+ createdAt: new Date().toISOString(),
7934
+ };
7935
+ const createdTask = addInternalTask({
7936
+ id: randomUUID(),
7929
7937
  title: task.title,
7930
7938
  description: task.description,
7931
7939
  status: "todo",
7932
- // Honour explicit baseBranch from planner JSON, then fall back to
7933
- // module auto-detection via conventional commit scope in the title.
7934
- ...(() => {
7935
- const b = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
7936
- return b ? { baseBranch: b } : {};
7937
- })(),
7940
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
7941
+ ...(baseBranch ? { baseBranch } : {}),
7942
+ syncDirty: true,
7943
+ meta: {
7944
+ planner: plannerMeta,
7945
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
7946
+ },
7938
7947
  });
7939
7948
  if (createdTask?.id) {
7940
7949
  created.push({ id: createdTask.id, title: task.title });
@@ -8987,13 +8996,6 @@ async function triggerTaskPlannerViaKanban(
8987
8996
  details,
8988
8997
  numTasks,
8989
8998
  );
8990
- // Get project ID using the kanban adapter
8991
- const projectId = await findKanbanProjectId();
8992
- if (!projectId) {
8993
- throw new Error(
8994
- `Cannot reach kanban backend (${getActiveKanbanBackend()}) or no project found`,
8995
- );
8996
- }
8997
8999
 
8998
9000
  const desiredTitle = userPrompt
8999
9001
  ? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
@@ -9008,7 +9010,7 @@ async function triggerTaskPlannerViaKanban(
9008
9010
 
9009
9011
  // Check for existing planner tasks to avoid duplicates
9010
9012
  // Only block on TODO tasks whose title matches the exact format we create
9011
- const existingTasks = await listKanbanTasks(projectId, { status: "todo" });
9013
+ const existingTasks = getInternalTasksByStatus("todo");
9012
9014
  const existingPlanner = (
9013
9015
  Array.isArray(existingTasks) ? existingTasks : []
9014
9016
  ).find((t) => {
@@ -9042,7 +9044,7 @@ async function triggerTaskPlannerViaKanban(
9042
9044
  );
9043
9045
  }
9044
9046
 
9045
- const taskUrl = buildTaskUrl(existingPlanner, projectId);
9047
+ const taskUrl = null;
9046
9048
  if (notify) {
9047
9049
  const suffix = taskUrl ? `\n${taskUrl}` : "";
9048
9050
  await sendTelegramMessage(
@@ -9061,7 +9063,7 @@ async function triggerTaskPlannerViaKanban(
9061
9063
  taskId: existingPlanner.id,
9062
9064
  taskTitle: existingPlanner.title,
9063
9065
  taskUrl,
9064
- projectId,
9066
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9065
9067
  };
9066
9068
  }
9067
9069
 
@@ -9071,7 +9073,24 @@ async function triggerTaskPlannerViaKanban(
9071
9073
  status: "todo",
9072
9074
  };
9073
9075
 
9074
- const createdTask = await createKanbanTask(projectId, taskData);
9076
+ const createdTask = addInternalTask({
9077
+ id: randomUUID(),
9078
+ title: taskData.title,
9079
+ description: taskData.description,
9080
+ status: "todo",
9081
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9082
+ syncDirty: true,
9083
+ meta: {
9084
+ planner: {
9085
+ source: "task-planner",
9086
+ plannerMode: "kanban",
9087
+ kind: "planner-request",
9088
+ triggerReason: reason || "manual",
9089
+ externalSyncPending: true,
9090
+ createdAt: new Date().toISOString(),
9091
+ },
9092
+ },
9093
+ });
9075
9094
 
9076
9095
  if (createdTask && createdTask.id) {
9077
9096
  console.log(`[monitor] task planner task created: ${createdTask.id}`);
@@ -9082,7 +9101,7 @@ async function triggerTaskPlannerViaKanban(
9082
9101
  last_result: "kanban_task_created",
9083
9102
  });
9084
9103
  const createdId = createdTask.id;
9085
- const createdUrl = buildTaskUrl(createdTask, projectId);
9104
+ const createdUrl = null;
9086
9105
  if (notify) {
9087
9106
  const suffix = createdUrl ? `\n${createdUrl}` : "";
9088
9107
  await sendTelegramMessage(
@@ -9094,7 +9113,7 @@ async function triggerTaskPlannerViaKanban(
9094
9113
  taskId: createdId,
9095
9114
  taskTitle: taskData.title,
9096
9115
  taskUrl: createdUrl,
9097
- projectId,
9116
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9098
9117
  };
9099
9118
  }
9100
9119
  throw new Error("Task creation failed");
@@ -9194,14 +9213,7 @@ async function triggerTaskPlannerViaCodex(
9194
9213
  "utf8",
9195
9214
  );
9196
9215
 
9197
- const projectId = await findKanbanProjectId();
9198
- if (!projectId) {
9199
- throw new Error(
9200
- `Task planner produced ${parsedTasks.length} tasks, but no kanban project is reachable for backend "${getActiveKanbanBackend()}"`,
9201
- );
9202
- }
9203
9216
  const { created, skipped } = await materializePlannerTasksToKanban(
9204
- projectId,
9205
9217
  parsedTasks,
9206
9218
  );
9207
9219
 
@@ -9228,7 +9240,7 @@ async function triggerTaskPlannerViaCodex(
9228
9240
  status: "completed",
9229
9241
  outputPath: outPath,
9230
9242
  artifactPath,
9231
- projectId,
9243
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9232
9244
  parsedTaskCount: parsedTasks.length,
9233
9245
  createdTaskCount: created.length,
9234
9246
  skippedTaskCount: skipped.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.31.5",
3
+ "version": "0.31.6",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
package/sync-engine.mjs CHANGED
@@ -16,7 +16,6 @@
16
16
  import {
17
17
  getTask,
18
18
  getAllTasks,
19
- addTask,
20
19
  updateTask,
21
20
  getDirtyTasks,
22
21
  markSynced,
@@ -29,6 +28,7 @@ import {
29
28
  getKanbanAdapter,
30
29
  getKanbanBackendName,
31
30
  listTasks,
31
+ createTask as createExternalTask,
32
32
  updateTaskStatus as updateExternalStatus,
33
33
  } from "./kanban-adapter.mjs";
34
34
 
@@ -585,6 +585,11 @@ export class SyncEngine {
585
585
  baseBranchCandidate &&
586
586
  String(baseBranchCandidate) !== String(metaBaseBranch);
587
587
 
588
+ const shouldCreateExternal = this.#shouldCreateExternalTask(
589
+ task,
590
+ backendName,
591
+ );
592
+
588
593
  // Check shared state for conflicts before pushing
589
594
  if (SHARED_STATE_ENABLED) {
590
595
  try {
@@ -618,9 +623,48 @@ export class SyncEngine {
618
623
  // Continue with push on error (graceful degradation)
619
624
  }
620
625
  }
626
+ let pushId = task.externalId || task.id;
627
+
628
+ if (shouldCreateExternal) {
629
+ try {
630
+ const created = await this.#createExternalTask(task, baseBranchCandidate);
631
+ const createdId = this.#extractExternalTaskId(created);
632
+ if (!isIdValidForBackend(createdId, backendName)) {
633
+ throw new Error(
634
+ `external create returned incompatible id for ${backendName}: ${createdId || "<empty>"}`,
635
+ );
636
+ }
637
+
638
+ const plannerMeta = {
639
+ ...(task.meta?.planner || {}),
640
+ externalSyncPending: false,
641
+ externalCreatedAt: new Date().toISOString(),
642
+ externalTaskId: String(createdId),
643
+ };
644
+ updateTask(task.id, {
645
+ externalId: String(createdId),
646
+ externalBackend: backendName,
647
+ meta: {
648
+ ...(task.meta || {}),
649
+ planner: plannerMeta,
650
+ },
651
+ });
652
+
653
+ pushId = String(createdId);
654
+ console.log(
655
+ TAG,
656
+ `Created external task for ${task.id} → ${pushId} (${backendName})`,
657
+ );
658
+ } catch (err) {
659
+ const msg = `External create failed for ${task.id}: ${err.message}`;
660
+ console.warn(TAG, msg);
661
+ result.errors.push(msg);
662
+ continue;
663
+ }
664
+ }
665
+
621
666
  // Skip tasks whose IDs are incompatible with the active backend.
622
667
  // e.g. VK UUID tasks cannot be pushed to GitHub Issues (needs numeric IDs).
623
- const pushId = task.externalId || task.id;
624
668
  if (!isIdValidForBackend(pushId, backendName)) {
625
669
  // If the task originated from a different backend, silently clear dirty
626
670
  // flag — it will be synced when that backend is active.
@@ -973,6 +1017,28 @@ export class SyncEngine {
973
1017
  return await updateExternalStatus(taskId, status);
974
1018
  }
975
1019
 
1020
+ async #createExternalTask(task, baseBranchCandidate = null) {
1021
+ const payload = {
1022
+ title: task.title || "Untitled task",
1023
+ description: task.description || "",
1024
+ status: "todo",
1025
+ assignee: task.assignee || null,
1026
+ priority: task.priority || null,
1027
+ tags: Array.isArray(task.tags) ? task.tags : [],
1028
+ labels: Array.isArray(task.tags) ? task.tags : [],
1029
+ ...(baseBranchCandidate ? { baseBranch: baseBranchCandidate } : {}),
1030
+ };
1031
+
1032
+ if (
1033
+ this.#kanbanAdapter &&
1034
+ typeof this.#kanbanAdapter.createTask === "function"
1035
+ ) {
1036
+ return await this.#kanbanAdapter.createTask(this.#projectId, payload);
1037
+ }
1038
+
1039
+ return await createExternalTask(this.#projectId, payload);
1040
+ }
1041
+
976
1042
  async #updateExternalPatch(taskId, patch) {
977
1043
  if (
978
1044
  this.#kanbanAdapter &&
@@ -1022,6 +1088,25 @@ export class SyncEngine {
1022
1088
  );
1023
1089
  }
1024
1090
 
1091
+ #shouldCreateExternalTask(task, backend) {
1092
+ if (!task || backend === "internal") return false;
1093
+ if (task.externalId) return false;
1094
+ const plannerMeta = task.meta?.planner;
1095
+ if (!plannerMeta || typeof plannerMeta !== "object") return false;
1096
+ return plannerMeta.externalSyncPending === true;
1097
+ }
1098
+
1099
+ #extractExternalTaskId(created) {
1100
+ if (!created) return null;
1101
+ if (created.id != null && String(created.id).trim()) {
1102
+ return String(created.id).trim();
1103
+ }
1104
+ if (created.externalId != null && String(created.externalId).trim()) {
1105
+ return String(created.externalId).trim();
1106
+ }
1107
+ return null;
1108
+ }
1109
+
1025
1110
  /** Simple async sleep. */
1026
1111
  #sleep(ms) {
1027
1112
  return new Promise((resolve) => setTimeout(resolve, ms));
package/telegram-bot.mjs CHANGED
@@ -789,6 +789,20 @@ function getBrowserUiUrl() {
789
789
  const base = telegramUiUrl;
790
790
  if (!base) return null;
791
791
  const token = getSessionToken();
792
+
793
+ // Prefer LAN for browser opens when available (same-network path),
794
+ // and fall back to the configured/public URL.
795
+ try {
796
+ const parsed = new URL(base);
797
+ const lanIp = getLocalLanIp?.();
798
+ if (lanIp && parsed.port) {
799
+ const lanBase = `${parsed.protocol}//${lanIp}:${parsed.port}`;
800
+ return appendTokenToUrl(lanBase, token) || lanBase;
801
+ }
802
+ } catch {
803
+ // fall through to base URL
804
+ }
805
+
792
806
  if (!token) return base;
793
807
  return appendTokenToUrl(base, token) || base;
794
808
  }
@@ -3042,6 +3056,7 @@ async function refreshMenuButton() {
3042
3056
 
3043
3057
  const FAST_COMMANDS = new Set([
3044
3058
  "/menu",
3059
+ "/background",
3045
3060
  "/status",
3046
3061
  "/tasks",
3047
3062
  "/agents",
@@ -3063,39 +3078,36 @@ const FAST_COMMANDS = new Set([
3063
3078
  ]);
3064
3079
 
3065
3080
  function getTelegramWebAppUrl(url) {
3066
- // Prefer cloudflared tunnel URL (valid cert, works in Telegram Mini App)
3081
+ // Telegram Mini App must be HTTPS and publicly reachable.
3082
+ // Priority: explicit env URL -> tunnel URL -> provided URL.
3083
+ const explicit =
3084
+ process.env.TELEGRAM_WEBAPP_URL || process.env.TELEGRAM_UI_BASE_URL || "";
3067
3085
  const tUrl = getTunnelUrl();
3068
- if (tUrl) {
3069
- const normalizedTunnel = String(tUrl || "").trim().replace(/\/+$/, "");
3070
- return appendTokenToUrl(normalizedTunnel, getSessionToken()) || normalizedTunnel;
3071
- }
3086
+ const candidates = [explicit, tUrl, url];
3072
3087
 
3073
- const normalized = String(url || "")
3074
- .trim()
3075
- .replace(/\/+$/, "");
3076
- if (!normalized) return null;
3077
- try {
3078
- const parsed = new URL(normalized);
3079
- if (parsed.protocol !== "https:") {
3080
- if (telegramWebAppUrlWarned !== normalized) {
3081
- telegramWebAppUrlWarned = normalized;
3082
- console.warn(
3083
- `[telegram-bot] mini app URL must be HTTPS for Telegram WebApp buttons; got "${normalized}". Falling back to normal URL buttons only.`,
3084
- );
3085
- }
3086
- return null;
3087
- }
3088
- const normalizedUrl = parsed.toString().replace(/\/+$/, "");
3089
- return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
3090
- } catch {
3091
- if (telegramWebAppUrlWarned !== normalized) {
3092
- telegramWebAppUrlWarned = normalized;
3093
- console.warn(
3094
- `[telegram-bot] mini app URL is invalid: "${normalized}". Falling back to normal URL buttons only.`,
3095
- );
3088
+ for (const candidate of candidates) {
3089
+ const normalized = String(candidate || "")
3090
+ .trim()
3091
+ .replace(/\/+$/, "");
3092
+ if (!normalized) continue;
3093
+ try {
3094
+ const parsed = new URL(normalized);
3095
+ if (parsed.protocol !== "https:") continue;
3096
+ const normalizedUrl = parsed.toString().replace(/\/+$/, "");
3097
+ return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
3098
+ } catch {
3099
+ // try next candidate
3096
3100
  }
3097
- return null;
3098
3101
  }
3102
+
3103
+ const warnSource = String(explicit || tUrl || url || "").trim();
3104
+ if (warnSource && telegramWebAppUrlWarned !== warnSource) {
3105
+ telegramWebAppUrlWarned = warnSource;
3106
+ console.warn(
3107
+ `[telegram-bot] mini app URL requires a valid HTTPS URL. Got "${warnSource}". Falling back to browser URL buttons only.`,
3108
+ );
3109
+ }
3110
+ return null;
3099
3111
  }
3100
3112
 
3101
3113
  function appendTokenToUrl(inputUrl, token) {
@@ -4142,6 +4154,11 @@ Object.assign(UI_SCREENS, {
4142
4154
  },
4143
4155
  uiButton("✖", "cb:close_menu"),
4144
4156
  ]);
4157
+ if (telegramUiUrl) {
4158
+ rows.unshift([
4159
+ { text: "🌐 Open in Browser", url: getBrowserUiUrl() || telegramUiUrl },
4160
+ ]);
4161
+ }
4145
4162
  } else if (telegramUiUrl) {
4146
4163
  rows.unshift([
4147
4164
  { text: "🌐 Open Control Center", url: getBrowserUiUrl() || telegramUiUrl },
@@ -210,6 +210,15 @@ function categorizeMessage(msg) {
210
210
  return "message";
211
211
  }
212
212
 
213
+ function isThinkingTraceMessage(msg) {
214
+ if (!msg) return false;
215
+ const category = categorizeMessage(msg);
216
+ if (category === "tool" || category === "result" || category === "error") {
217
+ return true;
218
+ }
219
+ return (msg.type || "").toLowerCase() === "system";
220
+ }
221
+
213
222
  function formatMessageLine(msg) {
214
223
  const timestamp = msg?.timestamp || "";
215
224
  const kind = msg?.role || msg?.type || "message";
@@ -258,6 +267,38 @@ const ChatBubble = memo(function ChatBubble({ msg }) {
258
267
  `;
259
268
  }, (prev, next) => prev.msg === next.msg);
260
269
 
270
+ const ThinkingPanel = memo(function ThinkingPanel({ entries }) {
271
+ if (!Array.isArray(entries) || entries.length === 0) return null;
272
+ return html`
273
+ <details class="chat-thinking-panel">
274
+ <summary class="chat-thinking-summary">
275
+ 🧠 Thinking · ${entries.length} ${entries.length === 1 ? "step" : "steps"}
276
+ </summary>
277
+ <div class="chat-thinking-body">
278
+ ${entries.map((entry, index) => {
279
+ const category = categorizeMessage(entry);
280
+ const label =
281
+ category === "tool"
282
+ ? "Tool"
283
+ : category === "result"
284
+ ? "Result"
285
+ : category === "error"
286
+ ? "Error"
287
+ : "System";
288
+ return html`
289
+ <div class="chat-thinking-item" key=${entry.id || entry.timestamp || `thinking-${index}`}>
290
+ <div class="chat-thinking-item-label">${label}</div>
291
+ <div class="chat-thinking-item-content">
292
+ <${MessageContent} text=${entry.content || ""} />
293
+ </div>
294
+ </div>
295
+ `;
296
+ })}
297
+ </div>
298
+ </details>
299
+ `;
300
+ });
301
+
261
302
  /* ─── Chat View component ─── */
262
303
  export function ChatView({ sessionId, readOnly = false, embedded = false }) {
263
304
  const [input, setInput] = useState("");
@@ -351,23 +392,60 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
351
392
  }
352
393
  }, [messages.length]);
353
394
 
354
- /* Same approach for status signals use .peek() to avoid creating signal
355
- subscriptions. The status bar cosmetics don't justify re-rendering the
356
- entire 200-message list. We piggyback on the messages.length change
357
- (which already triggers a re-render) to refresh these values. */
395
+ /* Keep status indicators fully live while an agent is active. */
358
396
  let errorCount = 0;
359
397
  let statusState = "idle";
360
398
  let statusText = "";
361
399
  try {
362
- errorCount = totalErrorCount.peek() || 0;
363
- statusState = paused ? "paused" : (agentStatus.peek()?.state || "idle");
364
- statusText = paused ? "Stream paused" : (agentStatusText.peek() || "Ready");
400
+ errorCount = totalErrorCount.value || 0;
401
+ statusState = paused ? "paused" : (agentStatus.value?.state || "idle");
402
+ statusText = paused ? "Stream paused" : (agentStatusText.value || "Ready");
365
403
  } catch (err) {
366
404
  console.warn("[ChatView] Failed to read status signals:", err);
367
405
  statusState = paused ? "paused" : "idle";
368
406
  statusText = paused ? "Stream paused" : "Ready";
369
407
  }
370
408
 
409
+ const renderItems = useMemo(() => {
410
+ const items = [];
411
+ let pendingThinking = [];
412
+ let panelIndex = 0;
413
+
414
+ const flushThinking = (anchor = "") => {
415
+ if (pendingThinking.length === 0) return;
416
+ const seed = pendingThinking[0]?.id || pendingThinking[0]?.timestamp || panelIndex;
417
+ items.push({
418
+ kind: "thinking",
419
+ key: `thinking-${sessionId || "session"}-${seed}-${anchor}-${panelIndex}`,
420
+ entries: pendingThinking,
421
+ });
422
+ pendingThinking = [];
423
+ panelIndex += 1;
424
+ };
425
+
426
+ for (const msg of visibleMessages) {
427
+ if (isThinkingTraceMessage(msg)) {
428
+ pendingThinking.push(msg);
429
+ continue;
430
+ }
431
+
432
+ const isAssistantMessage =
433
+ (msg.role || "") === "assistant" || (msg.type || "") === "agent_message";
434
+ if (isAssistantMessage) {
435
+ flushThinking(msg.id || msg.timestamp || "assistant");
436
+ }
437
+
438
+ items.push({
439
+ kind: "message",
440
+ key: msg.id || msg.timestamp || `msg-${items.length}`,
441
+ msg,
442
+ });
443
+ }
444
+
445
+ flushThinking("tail");
446
+ return items;
447
+ }, [visibleMessages, sessionId]);
448
+
371
449
  const refreshMessages = useCallback(async () => {
372
450
  if (!sessionId) return;
373
451
  setLoading(true);
@@ -461,7 +539,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
461
539
  if (!paused && autoScroll) {
462
540
  // Use smooth scroll for small increments, instant for large jumps
463
541
  const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
464
- el.scrollTo({ top: el.scrollHeight, behavior: gap < 800 ? "smooth" : "instant" });
542
+ el.scrollTo({ top: el.scrollHeight, behavior: gap < 800 ? "smooth" : "auto" });
465
543
  return;
466
544
  }
467
545
  if (newMessages && !autoScroll) {
@@ -806,12 +884,10 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
806
884
  </button>
807
885
  </div>
808
886
  `}
809
- ${visibleMessages.map((msg) => html`
810
- <${ChatBubble}
811
- key=${msg.id || msg.timestamp}
812
- msg=${msg}
813
- />`
814
- )}
887
+ ${renderItems.map((item) => item.kind === "thinking"
888
+ ? html`<${ThinkingPanel} key=${item.key} entries=${item.entries} />`
889
+ : html`<${ChatBubble} key=${item.key} msg=${item.msg} />`
890
+ )}
815
891
 
816
892
  ${/* Pending messages (optimistic rendering) — use .peek() to avoid
817
893
  subscribing ChatView to pendingMessages signal. Pending messages
@@ -536,7 +536,7 @@ export const agentStatusText = computed(() => {
536
536
  });
537
537
 
538
538
  let _idleTimer = null;
539
- const IDLE_TIMEOUT = 5000;
539
+ const IDLE_TIMEOUT = 45000;
540
540
 
541
541
  /**
542
542
  * Internal: transition agent state and reset idle timer.
@@ -600,6 +600,12 @@ export function startAgentStatusTracking() {
600
600
  const role = message.role;
601
601
  const type = message.type;
602
602
  const adapter = payload.session?.type || "";
603
+ const sessionStatus = payload.session?.status || "active";
604
+
605
+ if (sessionStatus !== "active") {
606
+ _setAgentState("idle", "");
607
+ return;
608
+ }
603
609
 
604
610
  if (role === "assistant" || type === "agent_message") {
605
611
  _setAgentState("streaming", adapter);
@@ -607,7 +613,7 @@ export function startAgentStatusTracking() {
607
613
  _setAgentState("executing", adapter);
608
614
  } else if (type === "tool_result") {
609
615
  _setAgentState("streaming", adapter);
610
- } else if (type === "error" || type === "system") {
616
+ } else if (type === "error" || type === "stream_error") {
611
617
  _setAgentState("idle", "");
612
618
  }
613
619
  });
@@ -771,6 +771,7 @@
771
771
  min-height: 0;
772
772
  overflow-y: auto;
773
773
  overflow-x: hidden;
774
+ touch-action: pan-y;
774
775
  -webkit-overflow-scrolling: touch;
775
776
  overscroll-behavior-y: contain;
776
777
  scroll-padding-bottom: 80px;
@@ -952,6 +953,58 @@
952
953
  margin-top: 6px;
953
954
  }
954
955
 
956
+ .chat-thinking-panel {
957
+ align-self: stretch;
958
+ border: 1px solid var(--border);
959
+ border-radius: 12px;
960
+ background: rgba(255, 255, 255, 0.02);
961
+ margin: 2px 0 4px;
962
+ overflow: hidden;
963
+ }
964
+
965
+ .chat-thinking-summary {
966
+ cursor: pointer;
967
+ user-select: none;
968
+ font-size: 12px;
969
+ font-weight: 600;
970
+ color: var(--text-secondary);
971
+ padding: 8px 12px;
972
+ list-style: none;
973
+ }
974
+
975
+ .chat-thinking-summary::-webkit-details-marker {
976
+ display: none;
977
+ }
978
+
979
+ .chat-thinking-body {
980
+ border-top: 1px solid var(--border);
981
+ padding: 8px 10px;
982
+ display: flex;
983
+ flex-direction: column;
984
+ gap: 8px;
985
+ }
986
+
987
+ .chat-thinking-item {
988
+ display: flex;
989
+ flex-direction: column;
990
+ gap: 4px;
991
+ padding: 6px 8px;
992
+ border-radius: 10px;
993
+ background: rgba(255, 255, 255, 0.02);
994
+ }
995
+
996
+ .chat-thinking-item-label {
997
+ font-size: 10px;
998
+ text-transform: uppercase;
999
+ letter-spacing: 0.06em;
1000
+ color: var(--text-hint);
1001
+ }
1002
+
1003
+ .chat-thinking-item-content {
1004
+ font-size: 12px;
1005
+ color: var(--text-secondary);
1006
+ }
1007
+
955
1008
  /* Subtle entrance for bubbles — only the last few get the animation
956
1009
  (content-visibility: auto on older bubbles skips them for free) */
957
1010
  .chat-bubble:last-child,
@@ -1441,7 +1494,7 @@ ul.md-list li::before {
1441
1494
  position: sticky;
1442
1495
  bottom: 0;
1443
1496
  z-index: 10;
1444
- padding: 16px 16px;
1497
+ padding: 16px 16px calc(16px + var(--chat-keyboard-offset, 0px));
1445
1498
  border-top: 1px solid var(--border);
1446
1499
  background: var(--bg-surface, var(--bg-card));
1447
1500
  flex-shrink: 0;
@@ -1466,7 +1519,7 @@ ul.md-list li::before {
1466
1519
  .session-detail > .chat-view-embedded {
1467
1520
  flex: 1;
1468
1521
  min-height: 0;
1469
- overflow-y: auto;
1522
+ overflow: hidden;
1470
1523
  display: flex;
1471
1524
  flex-direction: column;
1472
1525
  }
@@ -1481,11 +1534,11 @@ ul.md-list li::before {
1481
1534
  }
1482
1535
 
1483
1536
  .app-shell[data-tab="chat"] .chat-view-embedded .chat-messages {
1484
- padding: 16px 20px 16px;
1537
+ padding: 16px 20px calc(16px + var(--chat-keyboard-offset, 0px));
1485
1538
  }
1486
1539
 
1487
1540
  .app-shell[data-tab="chat"] .chat-input-area {
1488
- padding: 0 0 calc(12px + var(--safe-bottom, 0px));
1541
+ padding: 0 0 calc(12px + var(--safe-bottom, 0px) + var(--chat-keyboard-offset, 0px));
1489
1542
  }
1490
1543
 
1491
1544
  .chat-input-wrapper {
package/ui/tabs/chat.js CHANGED
@@ -323,6 +323,52 @@ export function ChatTab() {
323
323
  };
324
324
  }, []);
325
325
 
326
+ useEffect(() => {
327
+ if (typeof window === "undefined" || typeof document === "undefined") {
328
+ return undefined;
329
+ }
330
+
331
+ const root = document.documentElement;
332
+ const viewport = window.visualViewport;
333
+
334
+ const updateKeyboardOffset = () => {
335
+ if (!viewport) {
336
+ root.style.setProperty("--chat-keyboard-offset", "0px");
337
+ return;
338
+ }
339
+
340
+ const keyboardOffset = Math.max(
341
+ 0,
342
+ Math.round(window.innerHeight - viewport.height - viewport.offsetTop),
343
+ );
344
+ root.style.setProperty("--chat-keyboard-offset", `${keyboardOffset}px`);
345
+ if (keyboardOffset > 0) {
346
+ root.setAttribute("data-chat-keyboard-open", "true");
347
+ } else {
348
+ root.removeAttribute("data-chat-keyboard-open");
349
+ }
350
+ };
351
+
352
+ updateKeyboardOffset();
353
+
354
+ if (!viewport) return () => {
355
+ root.style.setProperty("--chat-keyboard-offset", "0px");
356
+ root.removeAttribute("data-chat-keyboard-open");
357
+ };
358
+
359
+ viewport.addEventListener("resize", updateKeyboardOffset);
360
+ viewport.addEventListener("scroll", updateKeyboardOffset);
361
+ window.addEventListener("orientationchange", updateKeyboardOffset);
362
+
363
+ return () => {
364
+ viewport.removeEventListener("resize", updateKeyboardOffset);
365
+ viewport.removeEventListener("scroll", updateKeyboardOffset);
366
+ window.removeEventListener("orientationchange", updateKeyboardOffset);
367
+ root.style.setProperty("--chat-keyboard-offset", "0px");
368
+ root.removeAttribute("data-chat-keyboard-open");
369
+ };
370
+ }, []);
371
+
326
372
  useEffect(() => {
327
373
  if (typeof document === "undefined") return undefined;
328
374
  if (focusMode) {
package/ui/tabs/tasks.js CHANGED
@@ -1178,30 +1178,23 @@ export function TasksTab() {
1178
1178
 
1179
1179
  const startTask = async ({ taskId, sdk, model }) => {
1180
1180
  haptic("medium");
1181
- const prev = cloneValue(tasks);
1182
1181
  let res = null;
1183
- await runOptimistic(
1184
- () => {
1185
- tasksData.value = tasksData.value.map((t) =>
1186
- t.id === taskId ? { ...t, status: "inprogress" } : t,
1187
- );
1188
- },
1189
- async () => {
1190
- res = await apiFetch("/api/tasks/start", {
1191
- method: "POST",
1192
- body: JSON.stringify({
1193
- taskId,
1194
- ...(sdk ? { sdk } : {}),
1195
- ...(model ? { model } : {}),
1196
- }),
1197
- });
1198
- return res;
1199
- },
1200
- () => {
1201
- tasksData.value = prev;
1202
- },
1203
- ).catch(() => {});
1204
- if (res?.wasPaused) {
1182
+ try {
1183
+ res = await apiFetch("/api/tasks/start", {
1184
+ method: "POST",
1185
+ body: JSON.stringify({
1186
+ taskId,
1187
+ ...(sdk ? { sdk } : {}),
1188
+ ...(model ? { model } : {}),
1189
+ }),
1190
+ });
1191
+ } catch {
1192
+ return;
1193
+ }
1194
+
1195
+ if (res?.queued) {
1196
+ showToast("Task queued (waiting for free slot)", "info");
1197
+ } else if (res?.wasPaused) {
1205
1198
  showToast("Task started (executor paused — force-dispatched)", "warning");
1206
1199
  } else if (res) {
1207
1200
  showToast("Task started", "success");
package/ui-server.mjs CHANGED
@@ -2968,6 +2968,30 @@ async function handleApi(req, res, url) {
2968
2968
  jsonResponse(res, 404, { ok: false, error: "Task not found." });
2969
2969
  return;
2970
2970
  }
2971
+
2972
+ const status = executor.getStatus?.() || {};
2973
+ const freeSlots =
2974
+ (status.maxParallel || 0) - (status.activeSlots || 0);
2975
+
2976
+ if (freeSlots <= 0) {
2977
+ jsonResponse(res, 202, {
2978
+ ok: true,
2979
+ taskId,
2980
+ queued: true,
2981
+ started: false,
2982
+ reason: "No free slots",
2983
+ });
2984
+ broadcastUiEvent(
2985
+ ["tasks", "overview", "executor", "agents"],
2986
+ "invalidate",
2987
+ {
2988
+ reason: "task-queued",
2989
+ taskId,
2990
+ },
2991
+ );
2992
+ return;
2993
+ }
2994
+
2971
2995
  try {
2972
2996
  if (typeof adapter.updateTaskStatus === "function") {
2973
2997
  await adapter.updateTaskStatus(taskId, "inprogress");
@@ -2989,7 +3013,13 @@ async function handleApi(req, res, url) {
2989
3013
  `[telegram-ui] failed to execute task ${taskId}: ${error.message}`,
2990
3014
  );
2991
3015
  });
2992
- jsonResponse(res, 200, { ok: true, taskId, wasPaused });
3016
+ jsonResponse(res, 200, {
3017
+ ok: true,
3018
+ taskId,
3019
+ queued: false,
3020
+ started: true,
3021
+ wasPaused,
3022
+ });
2993
3023
  broadcastUiEvent(
2994
3024
  ["tasks", "overview", "executor", "agents"],
2995
3025
  "invalidate",