@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  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 +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. 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 res.json({
3001
+ return {
2980
3002
  ok: true,
2981
- queued: false,
2982
- acknowledgement: 'no-active-turn',
2983
- requestedMessageId: requestedMessageId || null,
2984
- activeMessageId: null,
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 res.json({
3017
+ return {
2993
3018
  ok: true,
2994
- queued: false,
2995
- acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
2996
- requestedMessageId,
2997
- activeMessageId,
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 res.json({
3035
+ return {
3008
3036
  ok: true,
3009
- queued: false,
3010
- acknowledgement: 'active-turn-unbound',
3011
- requestedMessageId: activeMessageId || requestedMessageId || null,
3012
- activeMessageId,
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 res.json({
3049
+ return {
3019
3050
  ok: true,
3020
- queued: true,
3021
- duplicate: true,
3022
- acknowledgement: 'already-requested',
3023
- requestedMessageId: activeMessageId || requestedMessageId || null,
3024
- activeMessageId,
3025
- control: formatRelayControlResponse(existing),
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: String(req.body?.clientId || '').trim() || null,
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 res.json({
3080
+ return {
3047
3081
  ok: true,
3048
- queued: true,
3049
- acknowledgement: 'stop-queued',
3050
- requestedMessageId: activeMessageId || requestedMessageId || null,
3051
- activeMessageId,
3052
- control: formatRelayControlResponse(control),
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 (!isLoopbackRequest(req)) {
6488
- return res.status(403).json({ error: 'Image operation execution is localhost-only' });
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
- const exitCode = Number(req.body?.exitCode);
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: Number.isInteger(exitCode) ? exitCode : null,
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
- io.emit('background_tasks', { conversationId, tasks });
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