@oar-sh/oar 0.9.11 → 0.9.12

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -9,10 +9,19 @@ import { randomUUID } from 'crypto';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
10
  import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
+ import {
13
+ buildConversationHistoryPageInfo,
14
+ normalizeConversationHistoryLimit,
15
+ normalizeConversationTimestampMs,
16
+ } from '../services/conversation-history-page.mjs';
12
17
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
13
18
  import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
14
19
  import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
15
20
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
21
+ import {
22
+ claudeBackgroundCommandLimitSettingsFields,
23
+ parseClaudeBackgroundCommandLimitUpdate,
24
+ } from '../../shared/claude-background-command-limit.mjs';
16
25
  import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
17
26
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
18
27
  import { isProviderModelAvailable, resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
@@ -97,6 +106,12 @@ import {
97
106
  BACKGROUND_TASK_TIMEOUT_STEP_MINUTES,
98
107
  parseBackgroundTaskTimeoutUpdate,
99
108
  } from '../../shared/background-task-timeout.mjs';
109
+ import {
110
+ DEFAULT_TASK_STALL_MINUTES,
111
+ TASK_STALL_MINUTES_MAX,
112
+ TASK_STALL_MINUTES_MIN,
113
+ parseTaskStallMinutesUpdate,
114
+ } from '../../shared/task-output-text.mjs';
100
115
  import {
101
116
  PWA_APP_NAME_DEFAULT,
102
117
  PWA_APP_NAME_MAX_LENGTH,
@@ -286,9 +301,16 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
286
301
  const hasEnabledModels = Array.isArray(payload.enabledModels);
287
302
  const hasAttributionMode = Object.prototype.hasOwnProperty.call(payload, 'attributionMode');
288
303
  const attributionMode = hasAttributionMode ? normalizeClaudeAttributionMode(payload.attributionMode) : undefined;
289
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode) {
304
+ const hasBackgroundCommandLimit = Object.prototype.hasOwnProperty.call(payload, 'backgroundCommandLimitMinutes');
305
+ const backgroundCommandLimit = hasBackgroundCommandLimit
306
+ ? parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes)
307
+ : null;
308
+ if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode && !hasBackgroundCommandLimit) {
290
309
  return { ok: false, error: 'No Claude settings update provided' };
291
310
  }
311
+ if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
312
+ return { ok: false, error: backgroundCommandLimit.error };
313
+ }
292
314
  if (hasModel && !isSafeClaudeModelId(model)) {
293
315
  return { ok: false, error: 'Invalid Claude model ID' };
294
316
  }
@@ -302,6 +324,8 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
302
324
  ...(hasModels ? { models: payload.models } : {}),
303
325
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
304
326
  ...(hasAttributionMode ? { attributionMode } : {}),
327
+ // Clamped to 30 min … 8 h and snapped to 30-minute steps.
328
+ ...(backgroundCommandLimit ? { backgroundCommandLimitMinutes: backgroundCommandLimit.minutes } : {}),
305
329
  };
306
330
  }
307
331
 
@@ -1504,19 +1528,6 @@ function isLikelyCanonicalDuplicateMessage(a, b, timestampWindowMs = 30_000) {
1504
1528
  return Math.abs(aTs - bTs) <= Math.max(1_000, Number(timestampWindowMs) || 30_000);
1505
1529
  }
1506
1530
 
1507
- function normalizeConversationTimestampMs(value) {
1508
- const text = String(value || '').trim();
1509
- if (!text) return 0;
1510
- const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
1511
- const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
1512
- let normalized = text;
1513
- if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
1514
- const [, day, time] = text.match(sqliteUtcLike) || [];
1515
- normalized = `${day}T${time}Z`;
1516
- }
1517
- const parsed = Date.parse(normalized);
1518
- return Number.isFinite(parsed) ? parsed : 0;
1519
- }
1520
1531
 
1521
1532
  function normalizeConversationTimestampIso(value, fallback = null) {
1522
1533
  const ms = normalizeConversationTimestampMs(value);
@@ -1563,11 +1574,7 @@ function compareConversationMessageOrder(a, b) {
1563
1574
  return aId.localeCompare(bId);
1564
1575
  }
1565
1576
 
1566
- export function normalizeConversationHistoryLimit(value, fallback = 20) {
1567
- const numeric = Number(value);
1568
- if (!Number.isFinite(numeric)) return fallback;
1569
- return Math.min(100, Math.max(1, Math.trunc(numeric)));
1570
- }
1577
+ export { normalizeConversationHistoryLimit };
1571
1578
 
1572
1579
  function resolveConversationHistoryCursor(messages = [], {
1573
1580
  messageId = '',
@@ -1672,26 +1679,9 @@ export function selectConversationHistoryPage(messages = [], {
1672
1679
  }
1673
1680
  const hasMoreOlder = firstIdx > 0;
1674
1681
  const hasMoreNewer = lastIdx >= 0 && lastIdx < (ordered.length - 1);
1675
- const oldestMessage = pageMessages[0] || null;
1676
1682
  return {
1677
1683
  messages: pageMessages,
1678
- pageInfo: {
1679
- hasMore: hasMoreOlder,
1680
- hasMoreOlder,
1681
- hasMoreNewer,
1682
- nextCursor: oldestMessage ? {
1683
- beforeMessageId: String(oldestMessage.id || '').trim(),
1684
- beforeTimestamp: String(oldestMessage.timestamp || '').trim() || null,
1685
- } : null,
1686
- olderCursor: oldestMessage ? {
1687
- beforeMessageId: String(oldestMessage.id || '').trim(),
1688
- beforeTimestamp: String(oldestMessage.timestamp || '').trim() || null,
1689
- } : null,
1690
- newerCursor: lastMessage ? {
1691
- afterMessageId: String(lastMessage.id || '').trim(),
1692
- afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
1693
- } : null,
1694
- },
1684
+ pageInfo: buildConversationHistoryPageInfo(pageMessages, { hasMoreOlder, hasMoreNewer }),
1695
1685
  };
1696
1686
  }
1697
1687
 
@@ -1762,6 +1752,10 @@ export function buildConversationMessages({
1762
1752
  responseMessageToSourceId = new Map(),
1763
1753
  queueRows = [],
1764
1754
  usageByResponseMessageId = new Map(),
1755
+ // When dbMessages is one page: the conversation's rows liveResendsByOriginal
1756
+ // reads (id, role, source_message_id, resend_of_message_id), since a stopped
1757
+ // marker on the page may have its Resend outside it.
1758
+ resendIndexMessages = null,
1765
1759
  } = {}) {
1766
1760
  const queueRowsById = new Map(
1767
1761
  (Array.isArray(queueRows) ? queueRows : [])
@@ -1770,7 +1764,7 @@ export function buildConversationMessages({
1770
1764
  );
1771
1765
  // A stopped steer's marker says "Resent" from the data, so it holds across
1772
1766
  // reloads and devices (the relay refuses a second Resend on the same basis).
1773
- const liveResends = liveResendsByOriginal(dbMessages, queueRows);
1767
+ const liveResends = liveResendsByOriginal(Array.isArray(resendIndexMessages) ? resendIndexMessages : dbMessages, queueRows);
1774
1768
  const normalizedDbMessages = Array.isArray(dbMessages)
1775
1769
  ? dbMessages.map((message) => {
1776
1770
  const id = String(message?.id || '').trim();
@@ -2143,6 +2137,8 @@ export function registerSessionsRoutes(app, deps) {
2143
2137
  setPwaAppName = () => ({ ok: false, status: 500, error: 'App name settings are unavailable' }),
2144
2138
  getBackgroundTaskTimeoutMinutes = () => DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
2145
2139
  setBackgroundTaskTimeoutMinutes = () => ({ ok: false, error: 'Background task timeout settings are unavailable' }),
2140
+ getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
2141
+ setTaskStallMinutes = () => ({ ok: false, error: 'Task stall settings are unavailable' }),
2146
2142
  markSharedViewerPresence,
2147
2143
  getSharedWatcherCount,
2148
2144
  statusEventService,
@@ -2790,6 +2786,36 @@ export function registerSessionsRoutes(app, deps) {
2790
2786
  };
2791
2787
  }
2792
2788
 
2789
+ // One history page selected in SQL (conversation-history-page.mjs), so
2790
+ // only the page is hydrated; null when the statements are absent or the
2791
+ // conversation cannot be paged in SQL, and the caller loads every row.
2792
+ function selectConversationHistoryPageRows(conversationId, cursor = {}, { sharedOnly = false } = {}) {
2793
+ const pager = stmts.conversationHistoryPage;
2794
+ if (!pager || typeof pager.selectConversationHistoryPage !== 'function') return null;
2795
+ return pager.selectConversationHistoryPage(conversationId, { ...cursor, sharedOnly }) || null;
2796
+ }
2797
+
2798
+ // The usage snapshots of a page's replies, keyed by reply id.
2799
+ function usageSnapshotsForPage(conversationId, pageRows = []) {
2800
+ const usage = new Map();
2801
+ if (!stmts.getMessageUsageSnapshotByResponse) return usage;
2802
+ for (const row of pageRows) {
2803
+ if (row?.role !== 'assistant') continue;
2804
+ const id = String(row?.id || '').trim();
2805
+ const snapshot = id ? mapUsageSnapshotRow(stmts.getMessageUsageSnapshotByResponse.get(id, conversationId)) : null;
2806
+ if (snapshot) usage.set(id, snapshot);
2807
+ }
2808
+ return usage;
2809
+ }
2810
+
2811
+ // What liveResendsByOriginal needs beyond a page: only read when the page
2812
+ // holds a stopped marker, the one place its result is shown.
2813
+ function resendIndexForPage(conversationId, pageRows = [], { sharedOnly = false } = {}) {
2814
+ const hasStoppedMarker = pageRows.some((row) => row?.role === 'assistant' && row?.kind === 'stopped');
2815
+ if (!hasStoppedMarker) return [];
2816
+ return stmts.conversationHistoryPage?.listConversationResendIndex?.(conversationId, { sharedOnly }) || [];
2817
+ }
2818
+
2793
2819
  // A shared attachment is served by its share URL: the host path it is
2794
2820
  // stored at (and the `@path` reference built from it) stays on the relay.
2795
2821
  function shareSafeAttachment(attachment, shareToken) {
@@ -2820,9 +2846,11 @@ export function registerSessionsRoutes(app, deps) {
2820
2846
  });
2821
2847
  const inFlight = inFlightStateForConversation(resolvedConversationId);
2822
2848
  const sdkSessionId = String(conv.sdk_session_id || resolvedConversationId || '').trim();
2823
- const dbMessages = filterMessagesVisibleToSharedView(
2824
- (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
2825
- );
2849
+ const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
2850
+ const sqlPage = selectConversationHistoryPageRows(resolvedConversationId, historyCursor, { sharedOnly: true });
2851
+ const dbMessages = filterMessagesVisibleToSharedView(sqlPage
2852
+ ? sqlPage.rows
2853
+ : (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId));
2826
2854
  const queueRows = db.prepare(`
2827
2855
  SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
2828
2856
  FROM queue
@@ -2853,6 +2881,20 @@ export function registerSessionsRoutes(app, deps) {
2853
2881
  .filter((m) => m.role === 'assistant')
2854
2882
  .map((m) => [m.id, workflowRunsForResponse ? workflowRunsForResponse(m.id) : []]),
2855
2883
  );
2884
+ // The page hydrates only its own cards, but the tokens to redact are the
2885
+ // whole conversation's: a reply on this page can quote a card's link from
2886
+ // outside it. The page's cards below add theirs too (a relay without the
2887
+ // statement still redacts what it shows).
2888
+ if (previewTokens) {
2889
+ for (const row of stmts.listPreviewCardJsonByConversation?.all(resolvedConversationId) || []) {
2890
+ try {
2891
+ const token = JSON.parse(String(row?.preview_json || ''))?.token;
2892
+ if (token) previewTokens.add?.(String(token));
2893
+ } catch {
2894
+ // An unreadable snapshot renders no card, so it has no link to quote.
2895
+ }
2896
+ }
2897
+ }
2856
2898
  // A preview link is a capability that now outlives the turn and the
2857
2899
  // restart: a public transcript keeps only the label, never token or link.
2858
2900
  const previewCardsByMessageId = new Map(
@@ -2870,7 +2912,7 @@ export function registerSessionsRoutes(app, deps) {
2870
2912
  redacted: true,
2871
2913
  }))]),
2872
2914
  );
2873
- const usageByResponseMessageId = new Map(
2915
+ const usageByResponseMessageId = sqlPage ? usageSnapshotsForPage(conv.id, dbMessages) : new Map(
2874
2916
  (stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
2875
2917
  .map((row) => [String(row?.response_message_id || '').trim(), mapUsageSnapshotRow(row)])
2876
2918
  .filter(([messageId, usage]) => !!messageId && !!usage),
@@ -2892,6 +2934,7 @@ export function registerSessionsRoutes(app, deps) {
2892
2934
  responseMessageToSourceId,
2893
2935
  queueRows,
2894
2936
  usageByResponseMessageId,
2937
+ resendIndexMessages: sqlPage ? resendIndexForPage(resolvedConversationId, dbMessages, { sharedOnly: true }) : null,
2895
2938
  });
2896
2939
  messages = messages.map((message) => {
2897
2940
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
@@ -2909,14 +2952,9 @@ export function registerSessionsRoutes(app, deps) {
2909
2952
  ? nextMessage
2910
2953
  : { ...nextMessage, attachments };
2911
2954
  });
2912
- const history = selectConversationHistoryPage(messages, {
2913
- limit,
2914
- beforeMessageId,
2915
- beforeTimestamp,
2916
- afterMessageId,
2917
- afterTimestamp,
2918
- aroundMessageId,
2919
- });
2955
+ const history = sqlPage
2956
+ ? { messages, pageInfo: sqlPage.pageInfo }
2957
+ : selectConversationHistoryPage(messages, historyCursor);
2920
2958
  return {
2921
2959
  id: resolvedConversationId,
2922
2960
  sdkSessionId: conv.sdk_session_id || null,
@@ -4377,7 +4415,10 @@ export function registerSessionsRoutes(app, deps) {
4377
4415
  resolveClaudeSessionRoot,
4378
4416
  resolveCursorSessionRoot,
4379
4417
  });
4380
- const dbMessages = stmts.getMessages.all(resolvedConversationId);
4418
+ const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
4419
+ // The page's rows only: every later lookup is per page, not per conversation.
4420
+ const sqlPage = selectConversationHistoryPageRows(resolvedConversationId, historyCursor);
4421
+ const dbMessages = sqlPage ? sqlPage.rows : stmts.getMessages.all(resolvedConversationId);
4381
4422
  const queueRows = db.prepare(`
4382
4423
  SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
4383
4424
  FROM queue
@@ -4413,7 +4454,7 @@ export function registerSessionsRoutes(app, deps) {
4413
4454
  .filter((m) => m.role === 'assistant')
4414
4455
  .map((m) => [m.id, previewCardsForResponse ? previewCardsForResponse(m.id) : []]),
4415
4456
  );
4416
- const usageByResponseMessageId = new Map(
4457
+ const usageByResponseMessageId = sqlPage ? usageSnapshotsForPage(conv.id, dbMessages) : new Map(
4417
4458
  (stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
4418
4459
  .map((row) => [String(row?.response_message_id || '').trim(), mapUsageSnapshotRow(row)])
4419
4460
  .filter(([messageId, usage]) => !!messageId && !!usage),
@@ -4432,20 +4473,16 @@ export function registerSessionsRoutes(app, deps) {
4432
4473
  responseMessageToSourceId,
4433
4474
  queueRows,
4434
4475
  usageByResponseMessageId,
4476
+ resendIndexMessages: sqlPage ? resendIndexForPage(resolvedConversationId, dbMessages, { sharedOnly: false }) : null,
4435
4477
  });
4436
4478
  messages = messages.map((message) => {
4437
4479
  if (message.role !== 'assistant') return message;
4438
4480
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
4439
4481
  return sourceMessageId ? { ...message, sourceMessageId } : message;
4440
4482
  });
4441
- const history = selectConversationHistoryPage(messages, {
4442
- limit,
4443
- beforeMessageId,
4444
- beforeTimestamp,
4445
- afterMessageId,
4446
- afterTimestamp,
4447
- aroundMessageId,
4448
- });
4483
+ const history = sqlPage
4484
+ ? { messages, pageInfo: sqlPage.pageInfo }
4485
+ : selectConversationHistoryPage(messages, historyCursor);
4449
4486
  res.json({
4450
4487
  id: conv.id,
4451
4488
  sdkSessionId: conv.sdk_session_id || null,
@@ -4483,9 +4520,20 @@ export function registerSessionsRoutes(app, deps) {
4483
4520
  ...(describeSessionActivity(resolvedConversationId) || {}),
4484
4521
  // The published set, else the placeholder for background work a live
4485
4522
  // worker reports without this runtime knowing its details.
4486
- backgroundTasks: backgroundWorkView
4487
- ? backgroundWorkView.panelTasks(resolvedConversationId, runtimeSession?.sdk_session_id)
4488
- : (backgroundTaskStore?.get?.(resolvedConversationId) || []),
4523
+ // Each with the health poller's last reading (`health`); the tasks
4524
+ // that ended within the hour with a console beside them.
4525
+ backgroundTasks: (() => {
4526
+ const taskKeys = [resolvedConversationId, runtimeSession?.sdk_session_id];
4527
+ const tasks = backgroundWorkView
4528
+ ? backgroundWorkView.panelTasks(...taskKeys)
4529
+ : (backgroundTaskStore?.get?.(resolvedConversationId) || []);
4530
+ return backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, ...taskKeys) : tasks;
4531
+ })(),
4532
+ endedBackgroundTasks: backgroundTaskStore?.ended?.(resolvedConversationId, runtimeSession?.sdk_session_id) || [],
4533
+ // Minutes without output after which the panel calls a task quiet.
4534
+ taskStallMinutes: (() => {
4535
+ try { return getTaskStallMinutes(); } catch { return DEFAULT_TASK_STALL_MINUTES; }
4536
+ })(),
4489
4537
  usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
4490
4538
  previews: listConversationPreviews?.(resolvedConversationId) || [],
4491
4539
  // Every pin of the conversation, also those outside this history page.
@@ -6291,6 +6339,7 @@ export function registerSessionsRoutes(app, deps) {
6291
6339
  models: Array.isArray(settings?.models) ? settings.models : [],
6292
6340
  availableModels: Array.isArray(settings?.availableModels) ? settings.availableModels : [],
6293
6341
  ...claudeAttributionSettingsFields(settings),
6342
+ ...claudeBackgroundCommandLimitSettingsFields(settings),
6294
6343
  });
6295
6344
  });
6296
6345
 
@@ -6340,6 +6389,7 @@ export function registerSessionsRoutes(app, deps) {
6340
6389
  models: Array.isArray(currentSettings?.models) ? currentSettings.models : [],
6341
6390
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
6342
6391
  ...claudeAttributionSettingsFields(currentSettings),
6392
+ ...claudeBackgroundCommandLimitSettingsFields(currentSettings),
6343
6393
  reconciliation,
6344
6394
  };
6345
6395
  io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
@@ -7075,6 +7125,26 @@ export function registerSessionsRoutes(app, deps) {
7075
7125
  });
7076
7126
  });
7077
7127
 
7128
+ // "Mark a background task as quiet after N minutes" (task_stall_minutes).
7129
+ const taskStallMinutesPayload = (minutes) => ({
7130
+ minutes,
7131
+ min: TASK_STALL_MINUTES_MIN,
7132
+ max: TASK_STALL_MINUTES_MAX,
7133
+ default: DEFAULT_TASK_STALL_MINUTES,
7134
+ });
7135
+
7136
+ app.get('/api/settings/task-stall-minutes', auth, (_req, res) => {
7137
+ res.json(taskStallMinutesPayload(getTaskStallMinutes()));
7138
+ });
7139
+
7140
+ app.post('/api/settings/task-stall-minutes', auth, (req, res) => {
7141
+ const parsed = parseTaskStallMinutesUpdate(req.body?.minutes);
7142
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
7143
+ const result = setTaskStallMinutes(parsed.minutes);
7144
+ if (!result.ok) return res.status(500).json({ error: result.error });
7145
+ return res.json(taskStallMinutesPayload(result.minutes));
7146
+ });
7147
+
7078
7148
  const pwaAppNamePayload = (appName) => ({
7079
7149
  appName,
7080
7150
  defaultName: PWA_APP_NAME_DEFAULT,
@@ -79,6 +79,11 @@ import { createBackgroundWorkView } from './services/background-work-view.mjs';
79
79
  import { createRelayEventService } from './services/relay-event-service.mjs';
80
80
  import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
81
81
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
82
+ import {
83
+ claudeBackgroundCommandLimitMinutesToMs,
84
+ parseClaudeBackgroundCommandLimitUpdate,
85
+ readClaudeBackgroundCommandLimitSetting,
86
+ } from '../shared/claude-background-command-limit.mjs';
82
87
  import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
83
88
  import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
84
89
  import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
@@ -145,6 +150,8 @@ import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-acces
145
150
  import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
146
151
  import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
147
152
  import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
153
+ import { createTaskOutputRooms } from './services/task-output-rooms.mjs';
154
+ import { createTaskHealthPoller, createTaskOutputSnapshots } from './services/task-output-service.mjs';
148
155
  import {
149
156
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
150
157
  resolveLaunchWorkspaceRootPath,
@@ -208,6 +215,11 @@ import {
208
215
  readBackgroundTaskTimeoutSetting,
209
216
  backgroundTaskTimeoutMinutesToMs,
210
217
  } from '../shared/background-task-timeout.mjs';
218
+ import {
219
+ TASK_STALL_MINUTES_SETTING_KEY,
220
+ parseTaskStallMinutesUpdate,
221
+ readTaskStallMinutesSetting,
222
+ } from '../shared/task-output-text.mjs';
211
223
  import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
212
224
  import { capRelayActivityEntries } from './public/app/activity-replay-state.mjs';
213
225
  import {
@@ -504,10 +516,15 @@ function getClaudeProviderSettings() {
504
516
  effortsByModel,
505
517
  modelsDiscovered,
506
518
  attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
519
+ backgroundCommandLimitMinutes: getClaudeBackgroundCommandLimitMinutes(),
507
520
  providerType: 'claude',
508
521
  };
509
522
  }
510
523
 
524
+ function getClaudeBackgroundCommandLimitMinutes() {
525
+ return readClaudeBackgroundCommandLimitSetting(readAppSettingValue(CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY));
526
+ }
527
+
511
528
  function setClaudeProviderSettings(update = {}) {
512
529
  const payload = update && typeof update === 'object' ? update : {};
513
530
  const enabled = payload.enabled;
@@ -528,6 +545,12 @@ function setClaudeProviderSettings(update = {}) {
528
545
  if (attributionMode === null) {
529
546
  return { ok: false, error: 'Invalid Claude attribution mode' };
530
547
  }
548
+ const backgroundCommandLimit = payload.backgroundCommandLimitMinutes === undefined
549
+ ? null
550
+ : parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes);
551
+ if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
552
+ return { ok: false, error: backgroundCommandLimit.error };
553
+ }
531
554
  const nowIso = new Date().toISOString();
532
555
  const nextEnabled = typeof enabled === 'boolean' ? enabled : existing.enabled;
533
556
  stmts.upsertAppSetting.run(CLAUDE_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
@@ -535,6 +558,9 @@ function setClaudeProviderSettings(update = {}) {
535
558
  if (attributionMode) {
536
559
  stmts.upsertAppSetting.run(CLAUDE_ATTRIBUTION_SETTING_KEY, attributionMode, nowIso);
537
560
  }
561
+ if (backgroundCommandLimit) {
562
+ stmts.upsertAppSetting.run(CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY, String(backgroundCommandLimit.minutes), nowIso);
563
+ }
538
564
  if (Array.isArray(models)) {
539
565
  const normalizedModels = models
540
566
  .map((value) => String(value || '').trim())
@@ -1404,6 +1430,9 @@ const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
1404
1430
  // oar | vanilla | off — what commits and PRs made in Claude sessions say
1405
1431
  // (shared/claude-attribution.mjs); absent means oar.
1406
1432
  const CLAUDE_ATTRIBUTION_SETTING_KEY = 'claude_attribution_mode';
1433
+ // Minutes a backgrounded Bash command of a Claude session may run without its
1434
+ // own timeout (shared/claude-background-command-limit.mjs); absent means 2 h.
1435
+ const CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY = 'claude_background_command_limit_minutes';
1407
1436
  // The set of levels the SDK itself can report/accept as EffortLevel values.
1408
1437
  // Discovery results are filtered through this, so it must NOT contain the
1409
1438
  // relay's derived 'ultracode' sentinel — supportedModels() never reports it
@@ -2158,6 +2187,21 @@ function setBackgroundTaskTimeoutMinutes(value) {
2158
2187
  return { ok: true, timeoutMinutes: parsed.minutes };
2159
2188
  }
2160
2189
 
2190
+ // "Mark a background task as quiet after N minutes": the panel's stall hint.
2191
+ function getTaskStallMinutes() {
2192
+ return readTaskStallMinutesSetting(readAppSettingValue(TASK_STALL_MINUTES_SETTING_KEY));
2193
+ }
2194
+
2195
+ function setTaskStallMinutes(value) {
2196
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
2197
+ return { ok: false, error: 'Task stall settings are unavailable' };
2198
+ }
2199
+ const parsed = parseTaskStallMinutesUpdate(value);
2200
+ if (!parsed.ok) return parsed;
2201
+ stmts.upsertAppSetting.run(TASK_STALL_MINUTES_SETTING_KEY, String(parsed.minutes), new Date().toISOString());
2202
+ return { ok: true, minutes: parsed.minutes };
2203
+ }
2204
+
2161
2205
  function resolveDefaultSessionWorkspaceRootState() {
2162
2206
  return resolveDefaultSessionWorkspaceRootStateFromService({
2163
2207
  storedPath: readAppSettingValue(DEFAULT_SESSION_WORKSPACE_ROOT_KEY),
@@ -5208,6 +5252,9 @@ function previewCardsForResponse(responseMessageId) {
5208
5252
  // only: after a runtime start the heartbeat's digest brings each set back
5209
5253
  // (services/background-task-store.mjs).
5210
5254
  const backgroundTaskStore = createBackgroundTaskStore();
5255
+ // The output a worker posts for a task with no output file (the console's
5256
+ // fallback); memory only, an hour.
5257
+ const taskOutputSnapshots = createTaskOutputSnapshots();
5211
5258
 
5212
5259
  // ─── Deferred host suspend ───────────────────────────────────────────────────
5213
5260
  // "💤 Suspend host" waits until nothing runs any more: no queued/processing
@@ -6274,6 +6321,12 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6274
6321
  attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
6275
6322
  relayFolderFallback: deliveredProvider === 'claude',
6276
6323
  }),
6324
+ // The Claude tab's background command limit; the worker
6325
+ // hands it to the CLI it spawns next. Claude Cloud runs no
6326
+ // local CLI, so it does not get one.
6327
+ ...(deliveredProvider === 'claude'
6328
+ ? { claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()) }
6329
+ : {}),
6277
6330
  };
6278
6331
  })()),
6279
6332
  };
@@ -6959,6 +7012,11 @@ const sharedRouteDeps = {
6959
7012
  resolveLiveTurnQueueRow,
6960
7013
  backgroundTaskStore,
6961
7014
  backgroundWorkView,
7015
+ taskOutputSnapshots,
7016
+ // A closure: the poller is built further down the file.
7017
+ pokeTaskHealth: () => taskHealthPoller.poke(),
7018
+ getTaskStallMinutes,
7019
+ setTaskStallMinutes,
6962
7020
  // A closure, not the registry itself: this deps object is built before the
6963
7021
  // preview lane is constructed further down the file.
6964
7022
  listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
@@ -7487,6 +7545,22 @@ function sanitizeRelayQuestionContext(rawContext) {
7487
7545
  const subagentViewerRooms = createSubagentViewerRooms({
7488
7546
  getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
7489
7547
  });
7548
+ // A background task's output goes live only to the pages whose console shows
7549
+ // it; the task must be in the conversation's live or ended set, under the
7550
+ // conversation's id or its worker's session id (as the output route checks).
7551
+ const taskOutputRooms = createTaskOutputRooms({
7552
+ io,
7553
+ resolveTask: (conversationId, taskId) => {
7554
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
7555
+ if (!conversation) return null;
7556
+ const keys = [conversationId, String(conversation.sdk_session_id || conversationId)];
7557
+ return backgroundTaskStore.findTask(keys, taskId);
7558
+ },
7559
+ });
7560
+ // The stall hint and the pass/fail chip of the panel's rows: every 10 s over
7561
+ // the live tasks with an output file.
7562
+ const taskHealthPoller = createTaskHealthPoller({ store: backgroundTaskStore, io });
7563
+ taskHealthPoller.start();
7490
7564
  // The handshake presents the token as `auth.token` or the cookie; a query
7491
7565
  // `token` is not read (see services/relay-auth.mjs).
7492
7566
  io.use(relayAuth.socketMiddleware);
@@ -7524,6 +7598,7 @@ io.on('connection', (socket) => {
7524
7598
  activeDeviceTracker.registerSocket(socket);
7525
7599
  tmuxInspectorSocketService.registerSocket(socket);
7526
7600
  subagentViewerRooms.registerSocket(socket);
7601
+ taskOutputRooms.registerSocket(socket);
7527
7602
  });
7528
7603
 
7529
7604
  // ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
@@ -7656,6 +7731,8 @@ function clearRuntimeTimers() {
7656
7731
  try { clearInterval(timer); } catch {}
7657
7732
  }
7658
7733
  subagentRecordRetention?.stop();
7734
+ try { taskHealthPoller.stop(); } catch {}
7735
+ try { taskOutputRooms.stop(); } catch {}
7659
7736
  runtimeTimers.cliStatus = null;
7660
7737
  runtimeTimers.ownerWatchdog = null;
7661
7738
  runtimeTimers.staleRecovery = null;