@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.
- package/CHANGELOG.md +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- 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
|
-
|
|
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
|
|
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
|
|
2824
|
-
|
|
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 =
|
|
2913
|
-
|
|
2914
|
-
|
|
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
|
|
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 =
|
|
4442
|
-
|
|
4443
|
-
|
|
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
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
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;
|