@oar-sh/oar 0.9.10 → 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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- 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 +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- 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/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- 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/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- 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 +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -41,6 +41,15 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
|
|
|
41
41
|
import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
|
|
42
42
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
43
43
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
44
|
+
import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
45
|
+
import {
|
|
46
|
+
DEFAULT_TASK_STALL_MINUTES,
|
|
47
|
+
TASK_OUTPUT_LIMITS,
|
|
48
|
+
normalizeTaskOutputKind,
|
|
49
|
+
normalizeTaskOutputPath,
|
|
50
|
+
} from '../../shared/task-output-text.mjs';
|
|
51
|
+
import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../services/task-output-service.mjs';
|
|
52
|
+
import { taskKindOf } from '../services/session-activity-tracker.mjs';
|
|
44
53
|
import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
45
54
|
import { isLiveResend } from '../services/steer-resend.mjs';
|
|
46
55
|
import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
@@ -57,9 +66,11 @@ import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
|
|
|
57
66
|
import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
|
|
58
67
|
import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
|
|
59
68
|
import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
|
|
69
|
+
import { createLocalRequestCheck } from '../services/request-boundary.mjs';
|
|
60
70
|
import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
|
|
61
71
|
import {
|
|
62
72
|
admitRemoteRelayRequest,
|
|
73
|
+
readRemoteRelayRequest,
|
|
63
74
|
findMentionedRemoteRelays,
|
|
64
75
|
findOriginRemoteRelay,
|
|
65
76
|
formatRemoteRelayOriginHint,
|
|
@@ -1775,6 +1786,13 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1775
1786
|
getModelCatalogState,
|
|
1776
1787
|
buildRelayReadyBannerData,
|
|
1777
1788
|
backgroundTaskStore,
|
|
1789
|
+
backgroundWorkView = null,
|
|
1790
|
+
// The 8 KiB a worker posts for a task without an output file
|
|
1791
|
+
// (services/task-output-service.mjs createTaskOutputSnapshots).
|
|
1792
|
+
taskOutputSnapshots = null,
|
|
1793
|
+
// Has the health poller read soon (a task with a console ended).
|
|
1794
|
+
pokeTaskHealth = null,
|
|
1795
|
+
getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
|
|
1778
1796
|
sendWorkerControl,
|
|
1779
1797
|
ensureSessionId,
|
|
1780
1798
|
touchCli,
|
|
@@ -1811,6 +1829,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1811
1829
|
relayRestartOrchestrator,
|
|
1812
1830
|
requestRelayShutdown,
|
|
1813
1831
|
cancelRelayShutdown = null,
|
|
1832
|
+
// Truly local = loopback peer, no tunnel marker, no forwarding header
|
|
1833
|
+
// (services/request-boundary.mjs). The runtime passes the check built
|
|
1834
|
+
// with the configured marker headers.
|
|
1835
|
+
isTrulyLocalRequest = createLocalRequestCheck().isTrulyLocal,
|
|
1814
1836
|
featureFlags,
|
|
1815
1837
|
sessionWorkerRegistry,
|
|
1816
1838
|
sessionWorkerSupervisor,
|
|
@@ -1833,6 +1855,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1833
1855
|
// agent-sent messages, the inbound switch, and mention unlocks. Absent in
|
|
1834
1856
|
// older wiring and most tests, where every remote-relay step is a no-op.
|
|
1835
1857
|
remoteRelayInbound = null,
|
|
1858
|
+
// Session groups (repositories/conversation-group-repository.mjs): the
|
|
1859
|
+
// group stop route. Absent in older wiring and most tests.
|
|
1860
|
+
conversationGroups = null,
|
|
1836
1861
|
// Relay event subscriptions (services/relay-event-service.mjs) and the
|
|
1837
1862
|
// per-session activity they read (services/session-activity-tracker.mjs).
|
|
1838
1863
|
// Absent in older wiring and most tests: every hook is then a no-op.
|
|
@@ -1936,21 +1961,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1936
1961
|
|| event.includes('blocked');
|
|
1937
1962
|
}
|
|
1938
1963
|
|
|
1939
|
-
function isLoopbackAddress(value) {
|
|
1940
|
-
const text = String(value || '').trim().toLowerCase();
|
|
1941
|
-
if (!text) return false;
|
|
1942
|
-
return text === '127.0.0.1'
|
|
1943
|
-
|| text === '::1'
|
|
1944
|
-
|| text === '::ffff:127.0.0.1'
|
|
1945
|
-
|| text === '::ffff:7f00:1';
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
function isLoopbackRequest(req) {
|
|
1949
|
-
const socketAddress = req?.socket?.remoteAddress;
|
|
1950
|
-
const ipAddress = req?.ip;
|
|
1951
|
-
return isLoopbackAddress(socketAddress) || isLoopbackAddress(ipAddress);
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
1964
|
function readBridgeIdentity(req) {
|
|
1955
1965
|
return relayBridgeOwnerService?.normalizeIdentity?.({
|
|
1956
1966
|
pid: req.headers['x-relay-process-pid'],
|
|
@@ -2966,36 +2976,54 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2966
2976
|
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
2967
2977
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
2968
2978
|
const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
|
|
2979
|
+
const outcome = requestTurnStop(conversationId, {
|
|
2980
|
+
requestedMessageId,
|
|
2981
|
+
clientId: String(req.body?.clientId || '').trim() || null,
|
|
2982
|
+
});
|
|
2983
|
+
if (!outcome.ok) return rejectSessionBinding(res, outcome.status, outcome.error);
|
|
2984
|
+
return res.json(outcome.body);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
/**
|
|
2988
|
+
* The work of POST /api/conversation/:id/cancel-turn: an `abort_turn`
|
|
2989
|
+
* control for the live turn. `{ ok: true, body }` with the route's answer,
|
|
2990
|
+
* or `{ ok: false, status, error }` when the conversation is unknown.
|
|
2991
|
+
*/
|
|
2992
|
+
function requestTurnStop(conversationId, { requestedMessageId = '', clientId = null } = {}) {
|
|
2969
2993
|
const sessionState = getConversationSessionState(conversationId);
|
|
2970
|
-
if (!sessionState.ok) {
|
|
2971
|
-
return rejectSessionBinding(res, sessionState.status, sessionState.error);
|
|
2972
|
-
}
|
|
2994
|
+
if (!sessionState.ok) return { ok: false, status: sessionState.status, error: sessionState.error };
|
|
2973
2995
|
|
|
2974
2996
|
// The live turn, not merely the newest processing row: a steered message
|
|
2975
2997
|
// folded into the running turn is also `processing`, and Stop must target
|
|
2976
2998
|
// the turn actually executing (which the worker interrupts as a whole).
|
|
2977
2999
|
const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
|
|
2978
3000
|
if (!queueRow) {
|
|
2979
|
-
return
|
|
3001
|
+
return {
|
|
2980
3002
|
ok: true,
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
3003
|
+
body: {
|
|
3004
|
+
ok: true,
|
|
3005
|
+
queued: false,
|
|
3006
|
+
acknowledgement: 'no-active-turn',
|
|
3007
|
+
requestedMessageId: requestedMessageId || null,
|
|
3008
|
+
activeMessageId: null,
|
|
3009
|
+
},
|
|
3010
|
+
};
|
|
2986
3011
|
}
|
|
2987
3012
|
|
|
2988
3013
|
const activeMessageId = String(queueRow.id || '').trim();
|
|
2989
3014
|
if (requestedMessageId && activeMessageId && requestedMessageId !== activeMessageId) {
|
|
2990
3015
|
const requestedRow = stmts.findQById.get(requestedMessageId) || null;
|
|
2991
3016
|
const requestedStatus = String(requestedRow?.status || '').trim().toLowerCase();
|
|
2992
|
-
return
|
|
3017
|
+
return {
|
|
2993
3018
|
ok: true,
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3019
|
+
body: {
|
|
3020
|
+
ok: true,
|
|
3021
|
+
queued: false,
|
|
3022
|
+
acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
|
|
3023
|
+
requestedMessageId,
|
|
3024
|
+
activeMessageId,
|
|
3025
|
+
},
|
|
3026
|
+
};
|
|
2999
3027
|
}
|
|
3000
3028
|
|
|
3001
3029
|
const sdkSessionId = normalizeSessionWorkerId(
|
|
@@ -3004,33 +3032,39 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3004
3032
|
|| sessionState.conversationSdkSessionId,
|
|
3005
3033
|
);
|
|
3006
3034
|
if (!sdkSessionId) {
|
|
3007
|
-
return
|
|
3035
|
+
return {
|
|
3008
3036
|
ok: true,
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3037
|
+
body: {
|
|
3038
|
+
ok: true,
|
|
3039
|
+
queued: false,
|
|
3040
|
+
acknowledgement: 'active-turn-unbound',
|
|
3041
|
+
requestedMessageId: activeMessageId || requestedMessageId || null,
|
|
3042
|
+
activeMessageId,
|
|
3043
|
+
},
|
|
3044
|
+
};
|
|
3014
3045
|
}
|
|
3015
3046
|
|
|
3016
3047
|
const existing = findActiveRelayControlByQueueMessage.get(activeMessageId, 'abort_turn') || null;
|
|
3017
3048
|
if (existing) {
|
|
3018
|
-
return
|
|
3049
|
+
return {
|
|
3019
3050
|
ok: true,
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3051
|
+
body: {
|
|
3052
|
+
ok: true,
|
|
3053
|
+
queued: true,
|
|
3054
|
+
duplicate: true,
|
|
3055
|
+
acknowledgement: 'already-requested',
|
|
3056
|
+
requestedMessageId: activeMessageId || requestedMessageId || null,
|
|
3057
|
+
activeMessageId,
|
|
3058
|
+
control: formatRelayControlResponse(existing),
|
|
3059
|
+
},
|
|
3060
|
+
};
|
|
3027
3061
|
}
|
|
3028
3062
|
|
|
3029
3063
|
const now = new Date().toISOString();
|
|
3030
3064
|
const controlId = uuidv4();
|
|
3031
3065
|
const requestPayload = JSON.stringify({
|
|
3032
3066
|
source: 'relay-ui',
|
|
3033
|
-
requestedByClientId:
|
|
3067
|
+
requestedByClientId: clientId || null,
|
|
3034
3068
|
});
|
|
3035
3069
|
insertRelayControlRequest.run(
|
|
3036
3070
|
controlId,
|
|
@@ -3043,14 +3077,59 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3043
3077
|
now,
|
|
3044
3078
|
);
|
|
3045
3079
|
const control = getRelayControlRequestById.get(controlId) || null;
|
|
3046
|
-
return
|
|
3080
|
+
return {
|
|
3047
3081
|
ok: true,
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3082
|
+
body: {
|
|
3083
|
+
ok: true,
|
|
3084
|
+
queued: true,
|
|
3085
|
+
acknowledgement: 'stop-queued',
|
|
3086
|
+
requestedMessageId: activeMessageId || requestedMessageId || null,
|
|
3087
|
+
activeMessageId,
|
|
3088
|
+
control: formatRelayControlResponse(control),
|
|
3089
|
+
},
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
// POST /api/conversation-groups/:id/stop — "Stop all running" on a session
|
|
3094
|
+
// group's head row: a stop for every member's live turn, and a stop for
|
|
3095
|
+
// every stoppable background task. Tasks the panel shows no Stop button
|
|
3096
|
+
// for are listed as `unstoppable`. The owner's action only: a paired
|
|
3097
|
+
// relay's agent is refused.
|
|
3098
|
+
app.post('/api/conversation-groups/:id/stop', auth, (req, res) => {
|
|
3099
|
+
if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
|
|
3100
|
+
return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
|
|
3101
|
+
}
|
|
3102
|
+
const groupId = String(req.params.id || '').trim();
|
|
3103
|
+
const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
|
|
3104
|
+
if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
|
|
3105
|
+
const clientId = String(req.body?.clientId || '').trim() || null;
|
|
3106
|
+
const turns = [];
|
|
3107
|
+
const tasks = [];
|
|
3108
|
+
const unstoppable = [];
|
|
3109
|
+
for (const member of conversationGroups.membersOf(groupId)) {
|
|
3110
|
+
if (member.archived) continue;
|
|
3111
|
+
const outcome = requestTurnStop(member.id, { clientId });
|
|
3112
|
+
if (outcome.ok && outcome.body.queued === true) turns.push({ id: member.id, messageId: outcome.body.activeMessageId });
|
|
3113
|
+
const conversation = stmts.getConvAnyStatus?.get?.(member.id) || null;
|
|
3114
|
+
const sessionId = String(conversation?.sdk_session_id || member.id);
|
|
3115
|
+
const liveTasks = [...new Set([member.id, sessionId])].flatMap((key) => backgroundTaskStore?.get?.(key) || []);
|
|
3116
|
+
for (const task of liveTasks) {
|
|
3117
|
+
// The store holds what the publish route keeps: `taskId`.
|
|
3118
|
+
const taskId = String(task?.taskId || '').trim();
|
|
3119
|
+
if (!taskId) continue;
|
|
3120
|
+
if (task?.stoppable === false) {
|
|
3121
|
+
unstoppable.push({ id: member.id, taskId, reason: 'no-stop' });
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
const sent = sendWorkerControl?.(sessionId, { type: 'stop_background_task', taskId });
|
|
3125
|
+
if (sent) tasks.push({ id: member.id, taskId });
|
|
3126
|
+
else unstoppable.push({ id: member.id, taskId, reason: 'worker-not-connected' });
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
if (turns.length || tasks.length) {
|
|
3130
|
+
console.log(`[${ts()}] GROUP STOP ${groupId.slice(0, 12)} turns=${turns.length} tasks=${tasks.length}`);
|
|
3131
|
+
}
|
|
3132
|
+
return res.json({ ok: true, turns, tasks, unstoppable });
|
|
3054
3133
|
});
|
|
3055
3134
|
|
|
3056
3135
|
app.post('/api/conversation/:conversationId/subagent/:subagentRunId/cancel', auth, (req, res) => {
|
|
@@ -5313,6 +5392,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5313
5392
|
const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
|
|
5314
5393
|
const settleFailedHandled = [];
|
|
5315
5394
|
const settleFailedSkipped = [];
|
|
5395
|
+
let resyncBackgroundTasks = false;
|
|
5316
5396
|
const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
|
|
5317
5397
|
// A persistent-process worker can hold several live rows at once (the
|
|
5318
5398
|
// running turn plus a delivered message queued behind it plus a
|
|
@@ -5379,6 +5459,33 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5379
5459
|
}
|
|
5380
5460
|
}
|
|
5381
5461
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5462
|
+
// The worker's digest of its live background-task ids against the set
|
|
5463
|
+
// held for it (keyed as the worker posts, by its session id). They
|
|
5464
|
+
// differ after a relay restart, which starts with no sets, and after a
|
|
5465
|
+
// lost post either way round: the answer then asks for the full set
|
|
5466
|
+
// again. A worker that sends no digest has no opinion and is never
|
|
5467
|
+
// asked; a relay that holds a set while the worker reports none asks
|
|
5468
|
+
// too, and the empty answer ends those tasks.
|
|
5469
|
+
// The process this beat comes from holds the session: a set post of
|
|
5470
|
+
// another process is a late one (see the task route).
|
|
5471
|
+
backgroundTaskStore?.noteHolder?.(requesterSessionId, normalizedRequesterPid);
|
|
5472
|
+
const taskDigest = normalizeBackgroundTaskDigest(req.body?.backgroundTasks);
|
|
5473
|
+
if (taskDigest && typeof backgroundTaskStore?.matches === 'function') {
|
|
5474
|
+
resyncBackgroundTasks = !backgroundTaskStore.matches(taskDigest, requesterSessionId, requesterConversationId);
|
|
5475
|
+
}
|
|
5476
|
+
// The placeholder for background work this runtime was never told the
|
|
5477
|
+
// details of (a worker that cannot resync yet): the pages hear when it
|
|
5478
|
+
// appears and when it goes without a set having replaced it.
|
|
5479
|
+
if (!resyncBackgroundTasks && backgroundWorkView) {
|
|
5480
|
+
const unreported = backgroundWorkView.hasUnreportedWork(requesterSessionId) === true;
|
|
5481
|
+
if (backgroundTaskStore?.announce?.(requesterSessionId, unreported)) {
|
|
5482
|
+
io.emit('background_tasks', {
|
|
5483
|
+
conversationId: requesterSessionId,
|
|
5484
|
+
tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
|
|
5485
|
+
endedTasks: backgroundTaskStore?.ended?.(requesterSessionId) || [],
|
|
5486
|
+
});
|
|
5487
|
+
}
|
|
5488
|
+
}
|
|
5382
5489
|
// Consumed steers whose settle the worker could not save within its
|
|
5383
5490
|
// retry budget (e.g. its writes keep failing): the relay fails them
|
|
5384
5491
|
// itself with the worker's terminal error, never requeueing them.
|
|
@@ -5438,6 +5545,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5438
5545
|
pendingCount,
|
|
5439
5546
|
...(settleFailedHandled.length ? { settleFailedHandled } : {}),
|
|
5440
5547
|
...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
|
|
5548
|
+
...(resyncBackgroundTasks ? { resyncBackgroundTasks: true } : {}),
|
|
5441
5549
|
});
|
|
5442
5550
|
});
|
|
5443
5551
|
|
|
@@ -6421,10 +6529,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6421
6529
|
return res.json({ ok: true, recovered: rows.length, maxAgeMs });
|
|
6422
6530
|
});
|
|
6423
6531
|
|
|
6532
|
+
// Emptying the queue, restarting the relay and withdrawing a restart are
|
|
6533
|
+
// the user's own maintenance actions: the browser app calls them from a
|
|
6534
|
+
// phone, over the tunnel and over the LAN. The token is the boundary.
|
|
6424
6535
|
app.post('/api/queue/empty', auth, (req, res) => {
|
|
6425
|
-
if (!isLoopbackRequest(req)) {
|
|
6426
|
-
return res.status(403).json({ error: 'Queue empty endpoint is localhost-only' });
|
|
6427
|
-
}
|
|
6428
6536
|
const rows = stmts.listQueueForPauseDrop.all();
|
|
6429
6537
|
const droppedCount = rows.length;
|
|
6430
6538
|
if (!droppedCount) {
|
|
@@ -6452,9 +6560,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6452
6560
|
});
|
|
6453
6561
|
|
|
6454
6562
|
app.post('/api/relay/shutdown', auth, (req, res) => {
|
|
6455
|
-
if (!isLoopbackRequest(req)) {
|
|
6456
|
-
return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
|
|
6457
|
-
}
|
|
6458
6563
|
if (typeof requestRelayShutdown !== 'function') {
|
|
6459
6564
|
return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
|
|
6460
6565
|
}
|
|
@@ -6472,9 +6577,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6472
6577
|
// Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
|
|
6473
6578
|
// as queueing one.
|
|
6474
6579
|
app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
|
|
6475
|
-
if (!isLoopbackRequest(req)) {
|
|
6476
|
-
return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
|
|
6477
|
-
}
|
|
6478
6580
|
if (typeof cancelRelayShutdown !== 'function') {
|
|
6479
6581
|
return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
|
|
6480
6582
|
}
|
|
@@ -6483,9 +6585,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6483
6585
|
return res.json({ ok: true, ...result });
|
|
6484
6586
|
});
|
|
6485
6587
|
|
|
6588
|
+
// Called by the relay's own worker on this machine; nothing that came in
|
|
6589
|
+
// through a proxy or the tunnel may start a provider image request.
|
|
6486
6590
|
app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
|
|
6487
|
-
if (!
|
|
6488
|
-
return res.status(403).json({ error: 'Image operation execution is
|
|
6591
|
+
if (!isTrulyLocalRequest(req)) {
|
|
6592
|
+
return res.status(403).json({ error: 'Image operation execution is reachable from this machine only' });
|
|
6489
6593
|
}
|
|
6490
6594
|
if (featureFlags?.IMAGE_CONVERSATION_CONTINUITY_ENABLED !== true) {
|
|
6491
6595
|
return res.status(404).json({ error: 'Image continuity is not enabled' });
|
|
@@ -7540,6 +7644,34 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7540
7644
|
res.json({ ok: true, messageId, conversationId, attemptId });
|
|
7541
7645
|
});
|
|
7542
7646
|
|
|
7647
|
+
// An integer from a worker's post, or undefined (dropped from the row).
|
|
7648
|
+
function wholeNumber(value, { min = Number.MIN_SAFE_INTEGER } = {}) {
|
|
7649
|
+
if (value === null || value === undefined || value === '' || typeof value === 'boolean') return undefined;
|
|
7650
|
+
const number = Number(value);
|
|
7651
|
+
return Number.isSafeInteger(number) && number >= min ? number : undefined;
|
|
7652
|
+
}
|
|
7653
|
+
|
|
7654
|
+
// A task row's console fields as the publish route keeps them.
|
|
7655
|
+
function taskOutputFields(task) {
|
|
7656
|
+
const outputPath = normalizeTaskOutputPath(task?.outputPath);
|
|
7657
|
+
const requestedKind = String(task?.outputKind || '').trim();
|
|
7658
|
+
if (!outputPath && !requestedKind) return {};
|
|
7659
|
+
const outputKind = normalizeTaskOutputKind(requestedKind, outputPath);
|
|
7660
|
+
// A file kind without a readable path has nothing to show.
|
|
7661
|
+
if ((outputKind === 'file' || outputKind === 'spool') && !outputPath) return { outputKind: 'none' };
|
|
7662
|
+
return { ...(outputPath ? { outputPath } : {}), outputKind };
|
|
7663
|
+
}
|
|
7664
|
+
|
|
7665
|
+
// `background_tasks` with the health readings on the live rows and the
|
|
7666
|
+
// ended rows beside them, for the key the worker posts under.
|
|
7667
|
+
function emitBackgroundTasks(key, tasks = backgroundTaskStore?.get?.(key) || []) {
|
|
7668
|
+
io.emit('background_tasks', {
|
|
7669
|
+
conversationId: key,
|
|
7670
|
+
tasks: backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, key) : tasks,
|
|
7671
|
+
endedTasks: backgroundTaskStore?.ended?.(key) || [],
|
|
7672
|
+
});
|
|
7673
|
+
}
|
|
7674
|
+
|
|
7543
7675
|
function resolveTaskConversationId(id) {
|
|
7544
7676
|
const key = String(id || '').trim();
|
|
7545
7677
|
if (!key) return '';
|
|
@@ -7559,13 +7691,42 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7559
7691
|
const conversationId = resolveTaskConversationId(req.body?.conversationId);
|
|
7560
7692
|
const taskId = String(req.body?.taskId || '').trim();
|
|
7561
7693
|
if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
7562
|
-
|
|
7694
|
+
// Only a number the worker sent: a missing or null exit code stays null
|
|
7695
|
+
// (Number(null) would read as a clean exit).
|
|
7696
|
+
const exitCode = wholeNumber(req.body?.exitCode);
|
|
7697
|
+
const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
|
|
7698
|
+
// The store is keyed as the worker posts. A task with a console stays
|
|
7699
|
+
// viewable as an ended row; one that ended while this runtime was away
|
|
7700
|
+
// becomes one when the post names its output.
|
|
7701
|
+
const settledOutputPath = normalizeTaskOutputPath(req.body?.outputPath);
|
|
7702
|
+
const endedRow = backgroundTaskStore?.settle?.(String(req.body?.conversationId || '').trim(), taskId, {
|
|
7703
|
+
status: req.body?.status,
|
|
7704
|
+
exitCode: exitCode ?? null,
|
|
7705
|
+
steps: wholeNumber(req.body?.steps, { min: 0 }),
|
|
7706
|
+
outputPath: settledOutputPath || undefined,
|
|
7707
|
+
outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
|
|
7708
|
+
description: settledDescription,
|
|
7709
|
+
taskType: String(req.body?.taskType || '').trim().slice(0, 80),
|
|
7710
|
+
startedAt: Number(req.body?.startedAt) || null,
|
|
7711
|
+
}) || null;
|
|
7712
|
+
if (endedRow) {
|
|
7713
|
+
emitBackgroundTasks(String(req.body?.conversationId || '').trim());
|
|
7714
|
+
pokeTaskHealth?.();
|
|
7715
|
+
}
|
|
7563
7716
|
sessionActivityTracker?.noteTaskSettled?.(conversationId, {
|
|
7564
7717
|
taskId,
|
|
7565
7718
|
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7566
7719
|
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7567
7720
|
summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
|
|
7568
|
-
exitCode:
|
|
7721
|
+
exitCode: exitCode ?? null,
|
|
7722
|
+
// What the task was, from a worker that sends it: the end of a task
|
|
7723
|
+
// this runtime never knew (it ended while the relay was away) is
|
|
7724
|
+
// still an event then.
|
|
7725
|
+
...(settledDescription ? {
|
|
7726
|
+
description: settledDescription,
|
|
7727
|
+
taskType: String(req.body?.taskType || '').trim().slice(0, 80),
|
|
7728
|
+
startedAt: Number(req.body?.startedAt) || null,
|
|
7729
|
+
} : {}),
|
|
7569
7730
|
});
|
|
7570
7731
|
relayEventService?.afterStateChange?.(conversationId);
|
|
7571
7732
|
res.json({ ok: true });
|
|
@@ -7574,6 +7735,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7574
7735
|
// POST /api/background-tasks — a session worker publishes its live
|
|
7575
7736
|
// background-task set (REPLACE semantics, mirroring the SDK's
|
|
7576
7737
|
// background_tasks_changed signal) so the composer panel tracks it live.
|
|
7738
|
+
// `resync: true` marks the set as the answer to a heartbeat that asked for
|
|
7739
|
+
// it again (`resyncBackgroundTasks`): the same replace, and the pages get
|
|
7740
|
+
// the set again, but a task the relay had forgotten is not a new start.
|
|
7741
|
+
// `order` lets the relay drop a set that a newer one overtook.
|
|
7577
7742
|
app.post('/api/background-tasks', auth, (req, res) => {
|
|
7578
7743
|
touchCli();
|
|
7579
7744
|
const conversationId = String(req.body?.conversationId || '').trim();
|
|
@@ -7600,15 +7765,53 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7600
7765
|
// The spawning tool_use id: the sub-agent run this task is. The panel
|
|
7601
7766
|
// opens the run's viewer from it.
|
|
7602
7767
|
toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
|
|
7768
|
+
// The console (shell and Monitor tasks): the file holding the
|
|
7769
|
+
// output, how it is obtained ('none': the provider has none to give).
|
|
7770
|
+
...taskOutputFields(task),
|
|
7771
|
+
// Tool calls a sub-agent made so far.
|
|
7772
|
+
steps: wholeNumber(task?.steps, { min: 0 }),
|
|
7773
|
+
exitCode: wholeNumber(task?.exitCode),
|
|
7603
7774
|
}))
|
|
7604
7775
|
.filter((task) => task.taskId);
|
|
7776
|
+
// The first path a task names wins: a later post cannot point a live
|
|
7777
|
+
// task's console at another file.
|
|
7778
|
+
for (const task of tasks) {
|
|
7779
|
+
const held = (backgroundTaskStore?.get?.(conversationId) || []).find((entry) => entry?.taskId === task.taskId);
|
|
7780
|
+
if (held?.outputPath && task.outputPath !== held.outputPath) {
|
|
7781
|
+
task.outputPath = held.outputPath;
|
|
7782
|
+
task.outputKind = held.outputKind;
|
|
7783
|
+
}
|
|
7784
|
+
}
|
|
7785
|
+
const resync = req.body?.resync === true;
|
|
7786
|
+
// Posts can overtake each other on the way (an ordinary publish and the
|
|
7787
|
+
// answer to a resync, two publishes): a worker numbers its sets
|
|
7788
|
+
// (`order: { epoch, seq }`), and one older than the newest applied is
|
|
7789
|
+
// dropped, or a task of the newer set would read as ended. A worker
|
|
7790
|
+
// that numbers none is taken as it comes.
|
|
7791
|
+
// A drop is never silent: it is logged, and the answer says `stale`,
|
|
7792
|
+
// on which the worker asks for a heartbeat (which makes it the holder
|
|
7793
|
+
// of the session if it was not yet, and has the relay ask for the set).
|
|
7794
|
+
const setOrder = backgroundTaskStore?.acceptOrder?.(conversationId, req.body?.order, {
|
|
7795
|
+
pid: readBridgeIdentity(req)?.pid,
|
|
7796
|
+
});
|
|
7797
|
+
if (setOrder && setOrder.accepted === false) {
|
|
7798
|
+
console.log(`[${typeof ts === 'function' ? ts() : new Date().toISOString()}] BG-TASKS set dropped (${setOrder.reason}) session=${conversationId.slice(0, 8)} epoch=${Number(req.body?.order?.epoch) || 0} seq=${Number(req.body?.order?.seq) || 0} tasks=${tasks.length}`);
|
|
7799
|
+
return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
|
|
7800
|
+
}
|
|
7801
|
+
// A task with a console that leaves the set ended: its file is read once
|
|
7802
|
+
// more soon (its last output often lands after the poller's last look).
|
|
7803
|
+
const nextIds = new Set(tasks.map((task) => task.taskId));
|
|
7804
|
+
const endedWithConsole = (backgroundTaskStore?.get?.(conversationId) || [])
|
|
7805
|
+
.some((task) => task?.outputPath && !nextIds.has(task.taskId));
|
|
7605
7806
|
backgroundTaskStore?.replace?.(conversationId, tasks);
|
|
7606
|
-
|
|
7807
|
+
backgroundTaskStore?.announce?.(conversationId, false);
|
|
7808
|
+
if (endedWithConsole) pokeTaskHealth?.();
|
|
7809
|
+
emitBackgroundTasks(conversationId, tasks);
|
|
7607
7810
|
// The store is keyed as the worker posts (its SDK session id); the
|
|
7608
7811
|
// tracker by the conversation.
|
|
7609
7812
|
try {
|
|
7610
7813
|
const trackedConversationId = resolveTaskConversationId(conversationId);
|
|
7611
|
-
sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks);
|
|
7814
|
+
sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks, { resync });
|
|
7612
7815
|
relayEventService?.afterStateChange?.(trackedConversationId);
|
|
7613
7816
|
} catch {}
|
|
7614
7817
|
// The panel's copy of a task lives in memory and goes when the task
|
|
@@ -7631,7 +7834,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7631
7834
|
.some((column) => before[column] !== after[column]);
|
|
7632
7835
|
if (changed) emitSubagentStatus(after);
|
|
7633
7836
|
}
|
|
7634
|
-
res.json({ ok: true, count: tasks.length });
|
|
7837
|
+
res.json({ ok: true, count: tasks.length, ...(resync ? { resync: true } : {}) });
|
|
7635
7838
|
});
|
|
7636
7839
|
|
|
7637
7840
|
// POST /api/conversation/:conversationId/background-task/:taskId/stop —
|
|
@@ -7650,6 +7853,168 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7650
7853
|
res.json({ ok: true });
|
|
7651
7854
|
});
|
|
7652
7855
|
|
|
7856
|
+
// ─── Task console ──────────────────────────────────────────────────────────
|
|
7857
|
+
|
|
7858
|
+
// The task a conversation's console asks for: in the live or the ended set
|
|
7859
|
+
// of the conversation, under its own id or its worker's session id (the
|
|
7860
|
+
// key the worker posts under), as the stop route resolves it.
|
|
7861
|
+
function findConversationTask(conversationId, taskId) {
|
|
7862
|
+
const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
|
|
7863
|
+
if (!conversation) return { conversation: null, found: null, sessionId: '' };
|
|
7864
|
+
const sessionId = String(conversation.sdk_session_id || conversationId);
|
|
7865
|
+
const found = backgroundTaskStore?.findTask?.([conversationId, sessionId], taskId) || null;
|
|
7866
|
+
return { conversation, found, sessionId };
|
|
7867
|
+
}
|
|
7868
|
+
|
|
7869
|
+
// The providers whose workers name a shell task's output (a file, a spool
|
|
7870
|
+
// or a snapshot). A task of theirs without one comes from a worker that
|
|
7871
|
+
// started before the console existed.
|
|
7872
|
+
const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
|
|
7873
|
+
const SUBAGENT_OUTPUT_MESSAGE = 'Sub-agents have no shell output; open the sub-agent view.';
|
|
7874
|
+
|
|
7875
|
+
// Why a task has no output to show, for the console's `none` answer.
|
|
7876
|
+
function noOutputMessage(conversationId, task) {
|
|
7877
|
+
if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
|
|
7878
|
+
let provider = '';
|
|
7879
|
+
try {
|
|
7880
|
+
provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
|
|
7881
|
+
} catch {}
|
|
7882
|
+
if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
|
|
7883
|
+
return 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.';
|
|
7884
|
+
}
|
|
7885
|
+
return 'Output is not available for this provider.';
|
|
7886
|
+
}
|
|
7887
|
+
|
|
7888
|
+
// A task the session's task list shows that the task store does not hold:
|
|
7889
|
+
// a sub-agent (live or ended, its run is its record) or a task that ended
|
|
7890
|
+
// without a console. The activity tracker knows it by its task or run id.
|
|
7891
|
+
function trackedTask(conversationId, taskId) {
|
|
7892
|
+
let view = null;
|
|
7893
|
+
try { view = sessionActivityTracker?.describe?.(conversationId) || null; } catch {}
|
|
7894
|
+
const tasks = Array.isArray(view?.tasks) ? view.tasks : [];
|
|
7895
|
+
const found = tasks.find((task) => !task?.placeholder && (String(task?.id || '') === taskId || String(task?.runId || '') === taskId));
|
|
7896
|
+
if (found) return found;
|
|
7897
|
+
const run = stmts.getSubagentRun?.get?.(taskId) || null;
|
|
7898
|
+
if (run && String(run.conversation_id || '') === conversationId) {
|
|
7899
|
+
return { id: taskId, kind: 'subagent', type: run.subagent_type || null, description: run.display_name || '', status: run.status || null };
|
|
7900
|
+
}
|
|
7901
|
+
return null;
|
|
7902
|
+
}
|
|
7903
|
+
|
|
7904
|
+
function taskStallMinutes() {
|
|
7905
|
+
try {
|
|
7906
|
+
return getTaskStallMinutes();
|
|
7907
|
+
} catch {
|
|
7908
|
+
return DEFAULT_TASK_STALL_MINUTES;
|
|
7909
|
+
}
|
|
7910
|
+
}
|
|
7911
|
+
|
|
7912
|
+
// GET /api/conversation/:conversationId/background-task/:taskId/output —
|
|
7913
|
+
// the console's read of a shell or Monitor task's output. Without
|
|
7914
|
+
// `before`: the last `lines` lines (default 2,000, at most 5,000) within
|
|
7915
|
+
// 256 KB; with `before` (a startOffset of an earlier answer): the lines
|
|
7916
|
+
// ending there. Everything is cleaned (controls out, colours kept,
|
|
7917
|
+
// credentials scrubbed) for every reader. Another relay's agent reads it
|
|
7918
|
+
// too (remote_relay read_task_output, read permission): no inbound switch,
|
|
7919
|
+
// nothing it reads changes anything.
|
|
7920
|
+
app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
|
|
7921
|
+
const conversationId = String(req.params.conversationId || '').trim();
|
|
7922
|
+
const taskId = String(req.params.taskId || '').trim();
|
|
7923
|
+
if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
7924
|
+
const { conversation, found, sessionId } = findConversationTask(conversationId, taskId);
|
|
7925
|
+
if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
|
|
7926
|
+
if (!found) {
|
|
7927
|
+
const tracked = trackedTask(conversationId, taskId);
|
|
7928
|
+
if (!tracked) return res.status(404).json({ error: 'Task not found', code: 'task-not-found' });
|
|
7929
|
+
const running = String(tracked.status || 'running') === 'running';
|
|
7930
|
+
const subagent = tracked.kind !== 'shell';
|
|
7931
|
+
return res.json({
|
|
7932
|
+
ok: true,
|
|
7933
|
+
taskId,
|
|
7934
|
+
kind: 'none',
|
|
7935
|
+
ended: !running,
|
|
7936
|
+
status: running ? 'running' : String(tracked.status || 'unknown'),
|
|
7937
|
+
exitCode: running ? null : (Number.isInteger(tracked.exitCode) ? tracked.exitCode : null),
|
|
7938
|
+
health: null,
|
|
7939
|
+
taskStallMinutes: taskStallMinutes(),
|
|
7940
|
+
task: {
|
|
7941
|
+
taskId,
|
|
7942
|
+
taskType: tracked.type || (subagent ? 'local_agent' : 'local_bash'),
|
|
7943
|
+
description: String(tracked.description || ''),
|
|
7944
|
+
startedAt: Date.parse(tracked.startedAt) || null,
|
|
7945
|
+
endedAt: Date.parse(tracked.endedAt) || null,
|
|
7946
|
+
stoppable: false,
|
|
7947
|
+
},
|
|
7948
|
+
message: subagent ? SUBAGENT_OUTPUT_MESSAGE : noOutputMessage(conversationId, { taskType: 'local_bash' }),
|
|
7949
|
+
});
|
|
7950
|
+
}
|
|
7951
|
+
const { task, ended, key } = found;
|
|
7952
|
+
const { kind, path } = taskOutputSource(task);
|
|
7953
|
+
const head = {
|
|
7954
|
+
ok: true,
|
|
7955
|
+
taskId,
|
|
7956
|
+
kind,
|
|
7957
|
+
ended,
|
|
7958
|
+
status: ended ? task.status : 'running',
|
|
7959
|
+
exitCode: ended ? (task.exitCode ?? null) : null,
|
|
7960
|
+
health: task.health || null,
|
|
7961
|
+
taskStallMinutes: taskStallMinutes(),
|
|
7962
|
+
task: {
|
|
7963
|
+
taskId,
|
|
7964
|
+
taskType: task.taskType || null,
|
|
7965
|
+
description: task.description || '',
|
|
7966
|
+
startedAt: task.startedAt || null,
|
|
7967
|
+
endedAt: task.endedAt || null,
|
|
7968
|
+
stoppable: !ended && task.stoppable !== false,
|
|
7969
|
+
},
|
|
7970
|
+
};
|
|
7971
|
+
if (kind === 'none') {
|
|
7972
|
+
return res.json({ ...head, message: noOutputMessage(conversationId, task) });
|
|
7973
|
+
}
|
|
7974
|
+
if (kind === 'snapshot') {
|
|
7975
|
+
// The worker answers a request with a post of the task's last 8 KiB;
|
|
7976
|
+
// the console asks again every few seconds and gets the newest copy.
|
|
7977
|
+
const cached = taskOutputSnapshots?.get?.([key, sessionId, conversationId], taskId) || null;
|
|
7978
|
+
if (!ended) {
|
|
7979
|
+
const sent = sendWorkerControl?.(sessionId, { type: 'task_output_snapshot', taskId });
|
|
7980
|
+
if (!sent && !cached) return res.status(409).json({ error: 'Session worker is not connected', code: 'worker-not-connected' });
|
|
7981
|
+
}
|
|
7982
|
+
if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
|
|
7983
|
+
return res.json({ ...head, ...shapeSnapshot(cached) });
|
|
7984
|
+
}
|
|
7985
|
+
if (!path) return res.json({ ...head, kind: 'none', message: 'Output is not available for this task.' });
|
|
7986
|
+
const lines = req.query?.lines;
|
|
7987
|
+
const before = req.query?.before;
|
|
7988
|
+
const read = before !== undefined && before !== ''
|
|
7989
|
+
? await readBefore(path, { before, lines })
|
|
7990
|
+
: await readTail(path, { lines });
|
|
7991
|
+
if (!read.ok) {
|
|
7992
|
+
// No file yet (a spool is named before its first byte) reads as no
|
|
7993
|
+
// output yet; one the relay does not read (a symlink, a folder) is
|
|
7994
|
+
// said so instead of failing.
|
|
7995
|
+
const empty = { text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false };
|
|
7996
|
+
return res.json({ ...head, ...empty, ...(read.code === 'missing' ? {} : { unavailable: read.code }) });
|
|
7997
|
+
}
|
|
7998
|
+
return res.json({ ...head, ...read });
|
|
7999
|
+
});
|
|
8000
|
+
|
|
8001
|
+
// POST /api/background-tasks/:taskId/output-snapshot — a worker's answer
|
|
8002
|
+
// to `task_output_snapshot`: the task's latest output where it has no
|
|
8003
|
+
// file (Claude's getTaskOutput, its last 8 KiB).
|
|
8004
|
+
app.post('/api/background-tasks/:taskId/output-snapshot', auth, (req, res) => {
|
|
8005
|
+
touchCli();
|
|
8006
|
+
const key = String(req.body?.conversationId || '').trim();
|
|
8007
|
+
const taskId = String(req.params.taskId || '').trim();
|
|
8008
|
+
if (!key || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
|
|
8009
|
+
if (typeof req.body?.output !== 'string') return res.status(400).json({ error: 'output must be a string' });
|
|
8010
|
+
taskOutputSnapshots?.set?.(key, taskId, {
|
|
8011
|
+
output: req.body.output.slice(0, TASK_OUTPUT_LIMITS.chunkBytes),
|
|
8012
|
+
totalBytes: req.body?.totalBytes,
|
|
8013
|
+
truncated: req.body?.truncated === true,
|
|
8014
|
+
});
|
|
8015
|
+
res.json({ ok: true });
|
|
8016
|
+
});
|
|
8017
|
+
|
|
7653
8018
|
// POST /api/activity — relay sends in-flight activity updates (tool/search sections)
|
|
7654
8019
|
// Optional attempt fence for the live lanes (stream/thought/activity/
|
|
7655
8020
|
// subagent): a writer that names its processing attempt is rejected once
|