@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  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 +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -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/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the task console's rows outlive a relay restart.
5
+ *
6
+ * `background_task_rows` holds what the relay keeps of a background task
7
+ * that has a console (a shell or Monitor task naming its output file), per
8
+ * store key (the id the worker posts under, its SDK session id) and task:
9
+ *
10
+ * - `state` 'live': a task of the worker's last set. After a restart it is
11
+ * held back until the worker reports again: the worker's set decides which
12
+ * are still running, and those of a worker that does not report are marked
13
+ * ended, never shown as running again.
14
+ * - `state` 'ended': the dimmed row the panel keeps for an hour after the end,
15
+ * with its status and exit code. `ended_at` (epoch ms) is what the hour
16
+ * counts from.
17
+ * - `row_json` the task row as the store holds it, without the health
18
+ * reading (the poller reads the file again).
19
+ *
20
+ * Rows go when the hour is up, when the conversation is deleted, and live
21
+ * rows when the worker's set no longer names them.
22
+ *
23
+ * Additive and idempotent. Safe to run on every boot. An older build never
24
+ * reads the table; its consoles start empty after a restart as before.
25
+ *
26
+ * Usage: node server/migrations/0018-background-task-rows.mjs [path/to/copilot.db]
27
+ */
28
+
29
+ import { fileURLToPath } from 'url';
30
+ import path from 'path';
31
+ import process from 'process';
32
+ import Database from 'better-sqlite3';
33
+
34
+ export const BACKGROUND_TASK_ROWS_COLUMNS = Object.freeze(['store_key', 'task_id', 'state', 'row_json', 'ended_at', 'updated_at']);
35
+
36
+ function tableExists(db, name) {
37
+ return !!db
38
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
39
+ .get(name);
40
+ }
41
+
42
+ /** Why the table cannot be used (a table of that name with other columns), or ''. */
43
+ export function backgroundTaskRowsTableProblem(db) {
44
+ if (!tableExists(db, 'background_task_rows')) return 'no background_task_rows table';
45
+ const present = new Set(db.prepare(`PRAGMA table_info(background_task_rows)`).all().map((column) => column.name));
46
+ const missing = BACKGROUND_TASK_ROWS_COLUMNS.filter((name) => !present.has(name));
47
+ return missing.length ? `background_task_rows lacks ${missing.join(', ')}` : '';
48
+ }
49
+
50
+ export function migrateBackgroundTaskRows(db) {
51
+ let tablesAdded = 0;
52
+ if (!tableExists(db, 'background_task_rows')) {
53
+ db.exec(`
54
+ CREATE TABLE background_task_rows (
55
+ store_key TEXT NOT NULL,
56
+ task_id TEXT NOT NULL,
57
+ state TEXT NOT NULL CHECK (state IN ('live', 'ended')),
58
+ row_json TEXT NOT NULL,
59
+ ended_at INTEGER,
60
+ updated_at INTEGER NOT NULL,
61
+ PRIMARY KEY (store_key, task_id)
62
+ )
63
+ `);
64
+ tablesAdded += 1;
65
+ }
66
+ db.exec(`
67
+ CREATE INDEX IF NOT EXISTS idx_background_task_rows_ended
68
+ ON background_task_rows(state, ended_at)
69
+ `);
70
+ return { applied: tablesAdded > 0, tablesAdded };
71
+ }
72
+
73
+ function defaultDbPath() {
74
+ const here = path.dirname(fileURLToPath(import.meta.url));
75
+ return path.resolve(here, '..', 'data', 'copilot.db');
76
+ }
77
+
78
+ export function migrate(dbPath, options = {}) {
79
+ const db = new Database(dbPath, options);
80
+ try {
81
+ return migrateBackgroundTaskRows(db);
82
+ } finally {
83
+ db.close();
84
+ }
85
+ }
86
+
87
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
88
+ if (isMain) {
89
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
90
+ try {
91
+ const result = migrate(dbPath);
92
+ console.log(`Migration 0018 on ${dbPath}`);
93
+ console.log(result.applied
94
+ ? ` background task rows: tables added ${result.tablesAdded}`
95
+ : ' skipped: nothing to change');
96
+ process.exit(0);
97
+ } catch (err) {
98
+ console.error(`Migration 0018 failed: ${err?.message || err}`);
99
+ process.exit(1);
100
+ }
101
+ }
@@ -20,6 +20,7 @@ import {
20
20
  } from './api-client.js';
21
21
  import { isSuspendHostActionVisible } from './settings-modal.js';
22
22
  import { describeRelayRestartError } from './relay-restart-error.mjs';
23
+ import { initRelayRestartUi, openRestartRelayConfirmation } from './relay-restart-ui.js';
23
24
  import {
24
25
  applyHostSuspendState,
25
26
  describeHostSuspendBlocker,
@@ -106,21 +107,10 @@ export async function confirmKillCurrentSession() {
106
107
  }
107
108
  }
108
109
 
109
- export function openRestartRelayConfirmation() {
110
- openSummaryModal({
111
- title: 'Restart web relay',
112
- subtitle: 'Queues restart via /api/relay/shutdown',
113
- kind: 'restart-relay',
114
- bodyHtml: `
115
- <p>Queue a manual relay restart now?</p>
116
- <p>The restart waits until the current turn is idle, so it does not interrupt an in-flight turn immediately.</p>
117
- <div class="summary-modal-actions">
118
- <button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart web relay</button>
119
- <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
120
- </div>
121
- `,
122
- });
123
- }
110
+ // ⋯ → Restart web relay first shows what the restart would wait for and
111
+ // leave running (relay-restart-ui.js); its buttons land in
112
+ // confirmRestartWebRelay below or in confirmRestartRelayNow.
113
+ export { openRestartRelayConfirmation };
124
114
 
125
115
  export function openEmptyQueueConfirmation() {
126
116
  menuDeps.lockChatActionsMenuShield(350);
@@ -375,6 +365,10 @@ export async function confirmRestartWebRelay() {
375
365
  showTransientRelayNotice('Relay is already shutting down/restarting.', 7000);
376
366
  return;
377
367
  }
368
+ if (result.status === 'shutting_down') {
369
+ showTransientRelayNotice('Nothing was running: the relay is restarting now.', 7000);
370
+ return;
371
+ }
378
372
  const queue = result.queue || {};
379
373
  showTransientRelayNotice(
380
374
  `Relay restart queued (pending=${Number(queue.pendingCount || 0)}, processing=${Number(queue.processingCount || 0)}).`,
@@ -399,6 +393,9 @@ export function initActionConfirmations({
399
393
  syncQueueStatusMenuEntry: typeof syncQueueStatusMenuEntry === 'function' ? syncQueueStatusMenuEntry : menuDeps.syncQueueStatusMenuEntry,
400
394
  refreshSessionWorkerStatus: typeof refreshSessionWorkerStatus === 'function' ? refreshSessionWorkerStatus : menuDeps.refreshSessionWorkerStatus,
401
395
  };
396
+ // The restart confirmations' and banner's inline handlers live on window
397
+ // either way (bootstrap exposes this module's own).
398
+ initRelayRestartUi();
402
399
  if (!exposeOnWindow) return;
403
400
  window.openKillSessionConfirmation = openKillSessionConfirmation;
404
401
  window.openRestartRelayConfirmation = openRestartRelayConfirmation;
@@ -45,7 +45,7 @@ export function areNetworkRequestsEnabled() {
45
45
  return networkRequestsEnabled;
46
46
  }
47
47
 
48
- export async function apiFetch(url, opts = {}) {
48
+ export async function apiFetch(url, { onErrorResponse, ...opts } = {}) {
49
49
  // A keepalive request is a page's last word; it goes out even when the
50
50
  // page's regular network work is already suspended.
51
51
  if (!networkRequestsEnabled && !opts.keepalive) return null;
@@ -61,6 +61,9 @@ export async function apiFetch(url, opts = {}) {
61
61
  });
62
62
  if (!response.ok) {
63
63
  console.error('API error', response.status, url);
64
+ // A caller that shows why a request failed reads the answer here; the
65
+ // result is still null.
66
+ if (typeof onErrorResponse === 'function') await onErrorResponse(response);
64
67
  return null;
65
68
  }
66
69
  noteFetchSuccess();
@@ -328,12 +331,14 @@ export async function updateClaudeSettings({
328
331
  model = undefined,
329
332
  enabledModels = undefined,
330
333
  attributionMode = undefined,
334
+ backgroundCommandLimitMinutes = undefined,
331
335
  } = {}) {
332
336
  const payload = {};
333
337
  if (typeof enabled === 'boolean') payload.enabled = enabled;
334
338
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
335
339
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
336
340
  if (typeof attributionMode === 'string' && attributionMode.trim()) payload.attributionMode = attributionMode.trim();
341
+ if (Number.isFinite(backgroundCommandLimitMinutes)) payload.backgroundCommandLimitMinutes = backgroundCommandLimitMinutes;
337
342
  return settingsRequest('/api/settings/claude', payload, 'Failed to update Claude settings');
338
343
  }
339
344
 
@@ -572,6 +577,29 @@ export async function updateBackgroundTaskTimeoutSetting(timeoutMinutes) {
572
577
  });
573
578
  }
574
579
 
580
+ export async function loadTaskStallMinutesSetting() {
581
+ return apiFetch('/api/settings/task-stall-minutes');
582
+ }
583
+
584
+ export async function updateTaskStallMinutesSetting(minutes) {
585
+ return apiFetch('/api/settings/task-stall-minutes', {
586
+ method: 'POST',
587
+ body: JSON.stringify({ minutes: Number(minutes) }),
588
+ });
589
+ }
590
+
591
+ // "After a relay restart, tell interrupted sessions to carry on" (off by default).
592
+ export async function loadRestartResumeSetting() {
593
+ return apiFetch('/api/settings/restart-resume');
594
+ }
595
+
596
+ export async function updateRestartResumeSetting(enabled) {
597
+ return apiFetch('/api/settings/restart-resume', {
598
+ method: 'POST',
599
+ body: JSON.stringify({ enabled: enabled === true }),
600
+ });
601
+ }
602
+
575
603
  export async function loadPwaAppNameSetting() {
576
604
  return apiFetch('/api/settings/pwa-app-name');
577
605
  }
@@ -1412,6 +1440,31 @@ export async function cancelRelayRestart(body = {}) {
1412
1440
  });
1413
1441
  }
1414
1442
 
1443
+ // The queued restart's state, and the picture for a restart asked now: the
1444
+ // sessions it would wait for, the messages it would hold, the background
1445
+ // tasks and whether they survive (routes/relay-restart-routes.mjs).
1446
+ export async function loadRelayRestartPreview(action = 'restart') {
1447
+ return apiFetchOrThrow(`/api/relay/shutdown?action=${encodeURIComponent(action)}`);
1448
+ }
1449
+
1450
+ export async function keepWaitingForRelayRestart(body = {}) {
1451
+ return apiFetchOrThrow('/api/relay/shutdown/keep-waiting', {
1452
+ method: 'POST',
1453
+ body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
1454
+ });
1455
+ }
1456
+
1457
+ export async function loadRelayRestartMaxWaitSetting() {
1458
+ return apiFetch('/api/settings/relay-restart-max-wait');
1459
+ }
1460
+
1461
+ export async function updateRelayRestartMaxWaitSetting(maxWaitMinutes) {
1462
+ return apiFetchOrThrow('/api/settings/relay-restart-max-wait', {
1463
+ method: 'POST',
1464
+ body: JSON.stringify({ maxWaitMinutes: Number(maxWaitMinutes) }),
1465
+ });
1466
+ }
1467
+
1415
1468
  export async function requestQueueEmpty(body = {}) {
1416
1469
  return apiFetchOrThrow('/api/queue/empty', {
1417
1470
  method: 'POST',
@@ -1423,8 +1476,22 @@ export async function requestQueueEmpty(body = {}) {
1423
1476
  });
1424
1477
  }
1425
1478
 
1426
- export async function sendMessage(body) {
1427
- return apiFetch('/api/message', { method: 'POST', body: JSON.stringify(body) });
1479
+ // Null on any failure, like apiFetch. A send the relay refused for a reason
1480
+ // the user can act on (a 409 such as "this conversation's model is no longer
1481
+ // available") hands that reason to onRefused first, so the composer can say
1482
+ // it instead of "could not be sent". A replayed send that already landed
1483
+ // (DUPLICATE_MESSAGE_ID) is not a refusal.
1484
+ export async function sendMessage(body, { onRefused } = {}) {
1485
+ return apiFetch('/api/message', {
1486
+ method: 'POST',
1487
+ body: JSON.stringify(body),
1488
+ onErrorResponse: async (response) => {
1489
+ if (response.status !== 409 || typeof onRefused !== 'function') return;
1490
+ const payload = await response.json().catch(() => null);
1491
+ const reason = readableServerError(payload?.error);
1492
+ if (reason && payload?.code !== 'DUPLICATE_MESSAGE_ID') onRefused(reason, payload);
1493
+ },
1494
+ });
1428
1495
  }
1429
1496
 
1430
1497
  // Raw fetch instead of apiFetch: a rejected bootstrap (bad CWD, unavailable