@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
@@ -27,6 +27,7 @@ import {
27
27
  import { schemaFields } from '../../shared/question-schema.mjs';
28
28
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
29
29
  import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
30
+ import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
30
31
  import {
31
32
  RELAY_EVENT_REMOTE_PULL_PATH,
32
33
  RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
@@ -1238,7 +1239,21 @@ export function createRemoteRelayDispatcher({
1238
1239
  }
1239
1240
  }
1240
1241
 
1241
- function mapSession(row, workerIds, ctx = null) {
1242
+ /** The session group of a listed row: id, display name and the row's role. */
1243
+ function sessionGroupField(row, groups) {
1244
+ const groupId = toText(row?.groupId);
1245
+ if (!groupId) return {};
1246
+ const info = isPlainObject(groups?.[groupId]) ? groups[groupId] : null;
1247
+ return {
1248
+ group: {
1249
+ id: groupId,
1250
+ name: toText(info?.displayName) || toText(info?.name) || null,
1251
+ role: toText(row?.groupRole) === 'head' ? 'head' : 'member',
1252
+ },
1253
+ };
1254
+ }
1255
+
1256
+ function mapSession(row, workerIds, ctx = null, groups = null) {
1242
1257
  const id = toText(row?.id);
1243
1258
  const via = toText(row?.origin?.relayName);
1244
1259
  // A session an agent of that relay created there, and from which session.
@@ -1256,6 +1271,9 @@ export function createRemoteRelayDispatcher({
1256
1271
  ...(via ? { via } : {}),
1257
1272
  ...(createdBy ? { createdBy } : {}),
1258
1273
  ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1274
+ // The session group it sits in (sessions an agent started here are
1275
+ // grouped under that agent's session; the owner can group by hand).
1276
+ ...sessionGroupField(row, groups),
1259
1277
  };
1260
1278
  }
1261
1279
 
@@ -1264,7 +1282,37 @@ export function createRemoteRelayDispatcher({
1264
1282
  * `lastActivityAt` of a session, from the relay's `sessionState` /
1265
1283
  * `sessionTasks`. A relay that predates them gives none.
1266
1284
  */
1267
- function sessionActivityFields(row) {
1285
+ /**
1286
+ * The console facts read_session adds to a task (`row` is the conversation
1287
+ * payload): `taskId` for read_task_output, `output` whether there is any to
1288
+ * read, and `health` (minutes since the last output, the quiet threshold,
1289
+ * test-run counts) for a task with an output file.
1290
+ */
1291
+ function taskConsoleFields(row, id) {
1292
+ const taskId = toText(id);
1293
+ if (!taskId || !isPlainObject(row)) return {};
1294
+ const live = (Array.isArray(row.backgroundTasks) ? row.backgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
1295
+ const ended = live ? null : (Array.isArray(row.endedBackgroundTasks) ? row.endedBackgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
1296
+ const task = live || ended;
1297
+ if (!task) return {};
1298
+ const kind = toText(task.outputKind) || (toText(task.outputPath) ? 'file' : 'none');
1299
+ const health = isPlainObject(task.health) ? task.health : null;
1300
+ const quietAfter = Number(row.taskStallMinutes) || null;
1301
+ const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
1302
+ return {
1303
+ taskId,
1304
+ output: kind !== 'none',
1305
+ ...(health ? {
1306
+ health: {
1307
+ ...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
1308
+ ...(quietAfter && minutes !== null ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
1309
+ ...(isPlainObject(health.counts) ? { counts: { runner: toText(health.runner) || null, ...health.counts } } : {}),
1310
+ },
1311
+ } : {}),
1312
+ };
1313
+ }
1314
+
1315
+ function sessionActivityFields(row, { console: withConsole = false } = {}) {
1268
1316
  const state = toText(row?.sessionState);
1269
1317
  if (!SESSION_STATES.has(state)) return {};
1270
1318
  const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
@@ -1281,6 +1329,7 @@ export function createRemoteRelayDispatcher({
1281
1329
  ...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
1282
1330
  ...(task.endedAt ? { endedAt: task.endedAt } : {}),
1283
1331
  ...(toText(task.id) ? { id: toText(task.id) } : {}),
1332
+ ...(withConsole ? taskConsoleFields(row, task.id) : {}),
1284
1333
  }));
1285
1334
  return {
1286
1335
  state,
@@ -1296,7 +1345,7 @@ export function createRemoteRelayDispatcher({
1296
1345
  // pages, all = the whole list page by page (only it honours the cursor).
1297
1346
  let cursor = null;
1298
1347
  if (scope === 'all' && args.cursor) {
1299
- cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
1348
+ cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
1300
1349
  if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
1301
1350
  }
1302
1351
  const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
@@ -1305,6 +1354,7 @@ export function createRemoteRelayDispatcher({
1305
1354
  && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
1306
1355
 
1307
1356
  const sessions = [];
1357
+ const seen = new Set();
1308
1358
  let hasMore = false;
1309
1359
  let scanned = 0;
1310
1360
  for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
@@ -1313,18 +1363,32 @@ export function createRemoteRelayDispatcher({
1313
1363
  limit: filtering ? LIST_PAGE_MAX : limit,
1314
1364
  beforeConversationId: cursor?.beforeConversationId,
1315
1365
  beforeUpdatedAt: cursor?.beforeUpdatedAt,
1366
+ beforeGroupId: cursor?.beforeGroupId,
1316
1367
  },
1317
1368
  });
1318
1369
  const rows = Array.isArray(response?.conversations) ? response.conversations : [];
1370
+ const groups = isPlainObject(response?.groups) ? response.groups : null;
1319
1371
  let consumed = 0;
1320
1372
  for (const row of rows) {
1321
1373
  consumed += 1;
1322
- if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
1374
+ // A group served again whole (its cursor row vanished) repeats rows.
1375
+ if (seen.has(toText(row?.id))) continue;
1376
+ seen.add(toText(row?.id));
1377
+ if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
1323
1378
  if (sessions.length >= limit) break;
1324
1379
  }
1325
1380
  scanned += consumed;
1326
1381
  const last = rows[consumed - 1];
1327
- if (last) cursor = { beforeConversationId: toText(last.id), beforeUpdatedAt: toText(last.updatedAt) };
1382
+ // The row's sort time (its group's newest row, `activityAt`) and its
1383
+ // group, so the next page continues from the right place even if the
1384
+ // row is archived or deleted before then.
1385
+ if (last) {
1386
+ cursor = {
1387
+ beforeConversationId: toText(last.id),
1388
+ beforeUpdatedAt: toText(last.activityAt) || toText(last.updatedAt),
1389
+ ...(toText(last.groupId) ? { beforeGroupId: toText(last.groupId) } : {}),
1390
+ };
1391
+ }
1328
1392
  hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
1329
1393
  if (sessions.length >= limit || !hasMore || !rows.length) break;
1330
1394
  }
@@ -1405,7 +1469,9 @@ export function createRemoteRelayDispatcher({
1405
1469
  if (!before) throw invalidInput('before is not an olderCursor from read_session');
1406
1470
  }
1407
1471
  const conversation = await call(ctx, 'GET', sessionPath(args.session), {
1408
- query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp },
1472
+ // groupMembers: the session group's live members; the relay gives them
1473
+ // to its own sessions only.
1474
+ query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1409
1475
  });
1410
1476
  const conversationId = toText(conversation?.id) || args.session;
1411
1477
  const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
@@ -1425,6 +1491,23 @@ export function createRemoteRelayDispatcher({
1425
1491
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1426
1492
  const via = toText(conversation?.origin?.relayName);
1427
1493
  const inFlight = progressSnapshot(conversation?.inFlight);
1494
+ // Its session group, with the other members and what each is doing.
1495
+ const groupInfo = isPlainObject(conversation?.group) ? conversation.group : null;
1496
+ const group = groupInfo && toText(groupInfo.id) ? {
1497
+ id: toText(groupInfo.id),
1498
+ name: toText(groupInfo.displayName) || toText(groupInfo.name) || null,
1499
+ role: toText(groupInfo.role) === 'head' ? 'head' : 'member',
1500
+ members: (Array.isArray(groupInfo.members) ? groupInfo.members : [])
1501
+ .filter(isPlainObject)
1502
+ .slice(0, 50)
1503
+ .map((member) => ({
1504
+ id: toText(member.id),
1505
+ title: toText(member.title),
1506
+ role: toText(member.role) === 'head' ? 'head' : 'member',
1507
+ state: SESSION_STATES.has(toText(member.state)) ? toText(member.state) : 'idle',
1508
+ ...(member.archived === true ? { archived: true } : {}),
1509
+ })),
1510
+ } : null;
1428
1511
  return {
1429
1512
  summary: `read_session → ${ctx.relay.name} session ${shortId(conversationId)}: ${messages.length} message${messages.length === 1 ? '' : 's'}${inFlight ? ', turn running' : ''}${pendingQuestions.length ? `, ${pendingQuestions.length} question${pendingQuestions.length === 1 ? '' : 's'} pending` : ''}`,
1430
1513
  session: {
@@ -1436,8 +1519,9 @@ export function createRemoteRelayDispatcher({
1436
1519
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1437
1520
  archived: conversation?.archived === true,
1438
1521
  active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
1439
- ...sessionActivityFields(conversation),
1522
+ ...sessionActivityFields(conversation, { console: true }),
1440
1523
  ...(via ? { via } : {}),
1524
+ ...(group ? { group } : {}),
1441
1525
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1442
1526
  },
1443
1527
  messages,
@@ -1448,6 +1532,101 @@ export function createRemoteRelayDispatcher({
1448
1532
  };
1449
1533
  }
1450
1534
 
1535
+ /**
1536
+ * read_task_output: the end of a background shell or Monitor task's
1537
+ * output (GET …/background-task/:taskId/output on the target relay, which
1538
+ * scrubs it), as plain lines without colours, at most `lines` lines and
1539
+ * 32 KB; minutes since its last output; test-run counts when it is one.
1540
+ */
1541
+ async function readTaskOutput(ctx, args) {
1542
+ let answer;
1543
+ try {
1544
+ answer = await call(ctx, 'GET', sessionPath(args.session, `/background-task/${encodeURIComponent(args.task_id)}/output`), {
1545
+ query: { lines: args.lines },
1546
+ });
1547
+ } catch (error) {
1548
+ const status = Number(error?.status) || null;
1549
+ if (isRemoteRelayError(error) && (status === 404 || toText(error.code) === REMOTE_RELAY_ERROR_CODES.notFound)) {
1550
+ const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
1551
+ if (toText(body.code) === 'task-not-found') {
1552
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
1553
+ `Task ${args.task_id} is not running in session ${args.session} on relay "${ctx.relay.name}" and did not end there within the last hour; read_session lists the session's tasks.`);
1554
+ }
1555
+ if (!/conversation not found/i.test(toText(body.error))) {
1556
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
1557
+ `Relay "${ctx.relay.name}" cannot show background task output (it runs an older OAR version).`);
1558
+ }
1559
+ }
1560
+ throw error;
1561
+ }
1562
+ const model = Array.isArray(answer?.lines) ? answer.lines : [];
1563
+ const task = isPlainObject(answer?.task) ? answer.task : {};
1564
+ const ended = answer?.ended === true;
1565
+ // Plain text, a repeat folded as ×N; cleaned again, whatever the other
1566
+ // side did.
1567
+ const rendered = model.map((line) => {
1568
+ const text = stripSgr(cleanTaskOutput(String(line?.text ?? '')));
1569
+ const count = Number(line?.count) > 1 ? Number(line.count) : 1;
1570
+ return count > 1 ? `${text} [×${count}]` : text;
1571
+ });
1572
+ let tail = rendered.slice(-args.lines);
1573
+ let cut = tail.length < rendered.length;
1574
+ let bytes = 0;
1575
+ let keepFrom = tail.length;
1576
+ for (let index = tail.length - 1; index >= 0; index -= 1) {
1577
+ bytes += Buffer.byteLength(tail[index], 'utf8') + 1;
1578
+ if (bytes > REMOTE_RELAY_LIMITS.taskOutputBytesMax) break;
1579
+ keepFrom = index;
1580
+ }
1581
+ if (keepFrom > 0) {
1582
+ tail = tail.slice(keepFrom);
1583
+ cut = true;
1584
+ }
1585
+ const health = isPlainObject(answer?.health) ? answer.health : null;
1586
+ const lastOutputAt = Number(health?.lastOutputAt) || Number(answer?.mtime) || null;
1587
+ const minutes = !ended && lastOutputAt ? stallMinutes(lastOutputAt, now()) : null;
1588
+ const quietAfter = Number(answer?.taskStallMinutes) || null;
1589
+ let counts = null;
1590
+ if (isPlainObject(health?.counts)) {
1591
+ counts = { runner: toText(health.runner) || null, ...health.counts, final: health.final === true };
1592
+ } else {
1593
+ const parsed = parseRunnerSummary(model);
1594
+ if (parsed) counts = { runner: parsed.runner, passed: parsed.passed, failed: parsed.failed, skipped: parsed.skipped, final: parsed.final };
1595
+ }
1596
+ const kind = toText(answer?.kind) || 'file';
1597
+ let note = null;
1598
+ if (kind === 'none') note = toText(answer?.message) || 'Output is not available for this provider.';
1599
+ else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
1600
+ else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
1601
+ else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
1602
+ const status = ended ? (toText(answer?.status) || 'unknown') : 'running';
1603
+ const exitCode = Number.isInteger(answer?.exitCode) ? answer.exitCode : null;
1604
+ const parts = [`${tail.length} line${tail.length === 1 ? '' : 's'}`, ended ? `ended (${status}${exitCode !== null ? `, exit ${exitCode}` : ''})` : 'running'];
1605
+ if (minutes !== null) parts.push(`last output ${minutes} min ago`);
1606
+ if (counts) parts.push(`${counts.passed} passed, ${counts.failed} failed`);
1607
+ return {
1608
+ summary: `read_task_output → ${ctx.relay.name} session ${shortId(args.session)} task ${shortId(args.task_id)}: ${parts.join(', ')}`,
1609
+ task: {
1610
+ id: args.task_id,
1611
+ description: toText(task.description),
1612
+ type: toText(task.taskType) || null,
1613
+ startedAt: task.startedAt || null,
1614
+ status,
1615
+ exitCode,
1616
+ ...(task.endedAt ? { endedAt: task.endedAt } : {}),
1617
+ },
1618
+ stall: minutes === null ? null : {
1619
+ minutes,
1620
+ since: new Date(lastOutputAt).toISOString(),
1621
+ ...(quietAfter ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
1622
+ },
1623
+ counts,
1624
+ tail,
1625
+ truncated: cut || answer?.hasMoreBefore === true,
1626
+ ...(note ? { note } : {}),
1627
+ };
1628
+ }
1629
+
1451
1630
  function outcomeSummary(action, ctx, outcome) {
1452
1631
  const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
1453
1632
  return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
@@ -2220,6 +2399,7 @@ export function createRemoteRelayDispatcher({
2220
2399
  usage: usageAction,
2221
2400
  list_sessions: listSessions,
2222
2401
  read_session: readSession,
2402
+ read_task_output: readTaskOutput,
2223
2403
  wait: waitAction,
2224
2404
  send,
2225
2405
  create_session: createSession,
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ // Where a request came from, as far as the relay can tell.
4
+ //
5
+ // "Truly local" is the boundary for the few things only a process on the
6
+ // relay's own machine may do (run an image operation, open a session-worker
7
+ // socket): the TCP peer is loopback AND nothing in front of the relay touched
8
+ // the request. A same-host reverse proxy (Caddy, nginx), a hand-run
9
+ // cloudflared and the built-in tunnel all connect from 127.0.0.1 too, so the
10
+ // peer alone proves nothing; what tells them apart is what they add: an edge
11
+ // marker (`cf-ray`, or a configured one) or a forwarding header. Local workers
12
+ // and agent tools call the relay over 127.0.0.1 and send none of these.
13
+ //
14
+ // A forwarder that adds no header at all (an SSH reverse tunnel, a bare TCP
15
+ // proxy) cannot be told apart from a local process; the access token is then
16
+ // the only boundary, as it is for every other route.
17
+
18
+ // Cloudflare's edge stamps `cf-ray` on every proxied request and a remote
19
+ // client cannot strip it.
20
+ export const DEFAULT_TUNNEL_MARKER_HEADERS = Object.freeze(['cf-ray']);
21
+
22
+ // Headers a proxy adds to say who it forwards for. Read here only as a trace
23
+ // that a proxy was involved, never as an address.
24
+ export const FORWARDING_HEADERS = Object.freeze(['x-forwarded-for', 'forwarded', 'x-real-ip']);
25
+
26
+ function normalizeHeaderName(value) {
27
+ return String(value || '').trim().toLowerCase();
28
+ }
29
+
30
+ function headerNamesFrom(source) {
31
+ if (Array.isArray(source)) return source;
32
+ if (source === undefined || source === null) return [];
33
+ return String(source).split(',');
34
+ }
35
+
36
+ /**
37
+ * The marker headers to look for: `cf-ray` always, plus every name from every
38
+ * source given (an array or a comma-separated string). Sources are merged,
39
+ * none replaces another, so `tunnelMarkerHeaders: []` in the config does not
40
+ * switch off COPILOT_TUNNEL_MARKER_HEADERS.
41
+ */
42
+ export function resolveTunnelMarkerHeaders(...sources) {
43
+ const names = [...DEFAULT_TUNNEL_MARKER_HEADERS];
44
+ for (const source of sources) {
45
+ for (const entry of headerNamesFrom(source)) {
46
+ const name = normalizeHeaderName(entry);
47
+ if (name && !names.includes(name)) names.push(name);
48
+ }
49
+ }
50
+ return names;
51
+ }
52
+
53
+ /** 127.0.0.0/8 and ::1, plain or IPv4-mapped. */
54
+ export function isLoopbackAddress(value) {
55
+ let text = String(value || '').trim().toLowerCase();
56
+ if (!text) return false;
57
+ if (text === '::1' || text === '0:0:0:0:0:0:0:1') return true;
58
+ if (text.startsWith('::ffff:')) text = text.slice(7);
59
+ if (text === '7f00:1') return true;
60
+ return /^127(?:\.\d{1,3}){3}$/.test(text);
61
+ }
62
+
63
+ function headerPresent(headers, name) {
64
+ const value = headers?.[name];
65
+ if (Array.isArray(value)) return value.some((entry) => String(entry || '').trim());
66
+ return typeof value === 'string' ? !!value.trim() : !!value;
67
+ }
68
+
69
+ function peerAddressOf(req) {
70
+ return req?.socket?.remoteAddress || req?.connection?.remoteAddress || '';
71
+ }
72
+
73
+ /**
74
+ * `markerHeaders`: names as resolveTunnelMarkerHeaders returns them (the
75
+ * default `cf-ray` is added if missing).
76
+ */
77
+ export function createLocalRequestCheck({ markerHeaders = DEFAULT_TUNNEL_MARKER_HEADERS } = {}) {
78
+ const markers = resolveTunnelMarkerHeaders(markerHeaders);
79
+
80
+ function matchedMarkerHeader(headers) {
81
+ for (const name of markers) {
82
+ if (headerPresent(headers, name)) return name;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function matchedForwardingHeader(headers) {
88
+ for (const name of FORWARDING_HEADERS) {
89
+ if (headerPresent(headers, name)) return name;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Why a request is not truly local: `{ reason: 'peer' | 'marker' |
96
+ * 'forwarded', header }`, or null when it is.
97
+ */
98
+ function explain(req) {
99
+ if (!isLoopbackAddress(peerAddressOf(req))) return { reason: 'peer', header: null };
100
+ const marker = matchedMarkerHeader(req?.headers);
101
+ if (marker) return { reason: 'marker', header: marker };
102
+ const forwarded = matchedForwardingHeader(req?.headers);
103
+ if (forwarded) return { reason: 'forwarded', header: forwarded };
104
+ return null;
105
+ }
106
+
107
+ function isTrulyLocal(req) {
108
+ return explain(req) === null;
109
+ }
110
+
111
+ return {
112
+ markerHeaders: markers,
113
+ matchedMarkerHeader,
114
+ explain,
115
+ isTrulyLocal,
116
+ };
117
+ }
118
+
119
+ function trustsPeer(req) {
120
+ const trust = typeof req?.app?.get === 'function' ? req.app.get('trust proxy fn') : null;
121
+ return typeof trust === 'function' && trust(peerAddressOf(req), 0) === true;
122
+ }
123
+
124
+ function firstListEntry(value) {
125
+ return String(Array.isArray(value) ? value[0] : (value || '')).split(',')[0].trim();
126
+ }
127
+
128
+ /**
129
+ * The host (with port) the client addressed: X-Forwarded-Host when the
130
+ * immediate peer is a trusted proxy (Express `trust proxy`), else Host. A
131
+ * direct client cannot forge it. Express's own `req.hostname` drops the port,
132
+ * which a LAN address needs.
133
+ */
134
+ export function requestHostOf(req) {
135
+ if (trustsPeer(req)) {
136
+ const forwardedHost = firstListEntry(req?.headers?.['x-forwarded-host']);
137
+ if (forwardedHost) return forwardedHost;
138
+ }
139
+ return firstListEntry(req?.headers?.host);
140
+ }
141
+
142
+ /** An Express-style view of a raw request (a socket.io handshake), so `ip`, `protocol` and the trust checks follow `trust proxy`. */
143
+ export function expressRequestView(app, rawReq) {
144
+ const view = Object.create(app.request);
145
+ view.headers = rawReq?.headers || {};
146
+ view.socket = rawReq?.socket || rawReq?.connection || null;
147
+ view.connection = view.socket;
148
+ view.method = rawReq?.method;
149
+ view.url = rawReq?.url;
150
+ return view;
151
+ }
152
+
153
+ function normalizedHost(protocol, host) {
154
+ try {
155
+ return new URL(`${protocol}//${host}`).host.toLowerCase();
156
+ } catch {
157
+ return '';
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Same-origin check for a browser handshake. No Origin header (a non-browser
163
+ * client) passes; otherwise the Origin's host must be the host the request
164
+ * was addressed to (see requestHostOf), or be listed in `allowedOrigins`.
165
+ */
166
+ export function isSameOriginRequest(req, { allowedOrigins = [] } = {}) {
167
+ const origin = firstListEntry(req?.headers?.origin);
168
+ if (!origin) return true;
169
+ let parsed;
170
+ try {
171
+ parsed = new URL(origin);
172
+ } catch {
173
+ return false;
174
+ }
175
+ if (parsed.origin === 'null') return false;
176
+ const allowed = (Array.isArray(allowedOrigins) ? allowedOrigins : headerNamesFrom(allowedOrigins))
177
+ .map((entry) => String(entry || '').trim().replace(/\/+$/, '').toLowerCase())
178
+ .filter(Boolean);
179
+ if (allowed.includes(parsed.origin.toLowerCase())) return true;
180
+ const host = requestHostOf(req);
181
+ if (!host) return false;
182
+ return normalizedHost(parsed.protocol, host) === parsed.host.toLowerCase();
183
+ }