@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: give steering settle stubs a durable shape the transcript can
5
+ * render on reload.
6
+ *
7
+ * 1. `messages.source_message_id` — the user message an assistant row answers.
8
+ * The link used to live only in `queue.response_message_id`, and the queue
9
+ * keeps just the newest 200 finished rows, so older assistant rows lost
10
+ * their anchor and fell back to response-time ordering: an absorbed
11
+ * (handed-off) reply then sorted below the steered message it hands off to,
12
+ * and the merge never applied. Backfilled from whatever queue rows remain.
13
+ * 2. `messages.resend_of_message_id` — on a user message sent with Resend,
14
+ * the stopped steer it re-sends. Durable, so "Resent" survives reloads and
15
+ * other devices, and the relay can refuse a second Resend of one original.
16
+ * 3. Fold stubs saved as kind='absorbed' become kind='folded'. 'absorbed'
17
+ * tells the client the reply continues through the NEXT user message, so
18
+ * the next ordinary message after a fold was styled as steered.
19
+ *
20
+ * Idempotent: the column is added once, the backfill only fills NULLs, and the
21
+ * kind rewrite matches nothing once applied. Safe to run on every boot.
22
+ *
23
+ * Usage: node server/migrations/0004-steer-settle-markers.mjs [path/to/copilot.db]
24
+ */
25
+
26
+ import { fileURLToPath } from 'url';
27
+ import path from 'path';
28
+ import process from 'process';
29
+ import Database from 'better-sqlite3';
30
+
31
+ import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
32
+
33
+ function tableExists(db, name) {
34
+ return !!db
35
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
36
+ .get(name);
37
+ }
38
+
39
+ function columnNames(db, table) {
40
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
41
+ }
42
+
43
+ export function migrateSteerSettleMarkers(db) {
44
+ if (!tableExists(db, 'messages')) {
45
+ return { applied: false, reason: 'messages table not found', sourceLinksBackfilled: 0, foldStubsRekinded: 0 };
46
+ }
47
+ let columns = columnNames(db, 'messages');
48
+ let columnAdded = false;
49
+ if (!columns.has('source_message_id')) {
50
+ db.exec(`ALTER TABLE messages ADD COLUMN source_message_id TEXT`);
51
+ columnAdded = true;
52
+ }
53
+ if (!columns.has('resend_of_message_id')) {
54
+ db.exec(`ALTER TABLE messages ADD COLUMN resend_of_message_id TEXT`);
55
+ columnAdded = true;
56
+ }
57
+ if (columnAdded) columns = columnNames(db, 'messages');
58
+ let sourceLinksBackfilled = 0;
59
+ let foldStubsRekinded = 0;
60
+ db.transaction(() => {
61
+ // Walk the (small, pruned) queue rather than correlating every message
62
+ // against it.
63
+ if (tableExists(db, 'queue') && columnNames(db, 'queue').has('response_message_id')) {
64
+ const link = db.prepare(`
65
+ UPDATE messages SET source_message_id = ?
66
+ WHERE id = ? AND role = 'assistant' AND source_message_id IS NULL
67
+ `);
68
+ const rows = db.prepare(`
69
+ SELECT id, response_message_id FROM queue
70
+ WHERE response_message_id IS NOT NULL AND response_message_id != ''
71
+ `).all();
72
+ for (const row of rows) {
73
+ sourceLinksBackfilled += link.run(String(row.id), String(row.response_message_id)).changes;
74
+ }
75
+ }
76
+ if (columns.has('kind')) {
77
+ foldStubsRekinded = db.prepare(`
78
+ UPDATE messages SET kind = 'folded'
79
+ WHERE role = 'assistant' AND kind = 'absorbed' AND TRIM(text) = ?
80
+ `).run(STEER_FOLDED_TEXT).changes;
81
+ }
82
+ })();
83
+ return {
84
+ applied: columnAdded || sourceLinksBackfilled > 0 || foldStubsRekinded > 0,
85
+ columnAdded,
86
+ sourceLinksBackfilled,
87
+ foldStubsRekinded,
88
+ };
89
+ }
90
+
91
+ export function migrate(dbPath) {
92
+ const db = new Database(dbPath);
93
+ try {
94
+ return migrateSteerSettleMarkers(db);
95
+ } finally {
96
+ db.close();
97
+ }
98
+ }
99
+
100
+ function defaultDbPath() {
101
+ const here = path.dirname(fileURLToPath(import.meta.url));
102
+ return path.resolve(here, '..', 'data', 'copilot.db');
103
+ }
104
+
105
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
106
+ if (isMain) {
107
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
108
+ try {
109
+ const result = migrate(dbPath);
110
+ console.log(`Migration 0004 on ${dbPath}`);
111
+ console.log(result.applied
112
+ ? ` column added: ${result.columnAdded}; source links backfilled: ${result.sourceLinksBackfilled}; fold stubs re-kinded: ${result.foldStubsRekinded}`
113
+ : ` skipped: ${result.reason || 'nothing to change'}`);
114
+ process.exit(0);
115
+ } catch (err) {
116
+ console.error(`Migration 0004 failed: ${err?.message || err}`);
117
+ process.exit(1);
118
+ }
119
+ }
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: storage for remote relays (agents working across OAR relays).
5
+ *
6
+ * 1. `messages.origin_json` — on a user row that another relay's agent sent:
7
+ * who sent it (relay, source conversation, model, hop count). Drives the
8
+ * "from <relay>" badge, keeps such rows from unlocking relays, and skips
9
+ * the reply-ready push.
10
+ * 2. `conversations.origin_json` — on a conversation another relay's agent
11
+ * created: the "via <relay>" marker in the conversation list.
12
+ * 3. `conversation_remote_relay_unlocks` — which remote relays the user has
13
+ * mentioned (and so unlocked) in which conversation. Keyed by the remote's
14
+ * local registry id, so a renamed remote keeps its unlocks.
15
+ *
16
+ * Additive and idempotent. Safe to run on every boot.
17
+ *
18
+ * Usage: node server/migrations/0005-remote-relays.mjs [path/to/copilot.db]
19
+ */
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import path from 'path';
23
+ import process from 'process';
24
+ import Database from 'better-sqlite3';
25
+
26
+ function tableExists(db, name) {
27
+ return !!db
28
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
29
+ .get(name);
30
+ }
31
+
32
+ function columnNames(db, table) {
33
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
34
+ }
35
+
36
+ export function migrateRemoteRelays(db) {
37
+ let columnsAdded = 0;
38
+ for (const table of ['messages', 'conversations']) {
39
+ if (!tableExists(db, table)) continue;
40
+ if (!columnNames(db, table).has('origin_json')) {
41
+ db.exec(`ALTER TABLE ${table} ADD COLUMN origin_json TEXT`);
42
+ columnsAdded += 1;
43
+ }
44
+ }
45
+ const unlocksExisted = tableExists(db, 'conversation_remote_relay_unlocks');
46
+ db.exec(`
47
+ CREATE TABLE IF NOT EXISTS conversation_remote_relay_unlocks (
48
+ conversation_id TEXT NOT NULL,
49
+ remote_relay_id TEXT NOT NULL,
50
+ message_id TEXT,
51
+ created_at TEXT NOT NULL,
52
+ PRIMARY KEY (conversation_id, remote_relay_id)
53
+ )
54
+ `);
55
+ return {
56
+ applied: columnsAdded > 0 || !unlocksExisted,
57
+ columnsAdded,
58
+ unlocksTableCreated: !unlocksExisted,
59
+ };
60
+ }
61
+
62
+ export function migrate(dbPath) {
63
+ const db = new Database(dbPath);
64
+ try {
65
+ return migrateRemoteRelays(db);
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ function defaultDbPath() {
72
+ const here = path.dirname(fileURLToPath(import.meta.url));
73
+ return path.resolve(here, '..', 'data', 'copilot.db');
74
+ }
75
+
76
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
77
+ if (isMain) {
78
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
79
+ try {
80
+ const result = migrate(dbPath);
81
+ console.log(`Migration 0005 on ${dbPath}`);
82
+ console.log(result.applied
83
+ ? ` origin columns added: ${result.columnsAdded}; unlocks table created: ${result.unlocksTableCreated}`
84
+ : ' skipped: nothing to change');
85
+ process.exit(0);
86
+ } catch (err) {
87
+ console.error(`Migration 0005 failed: ${err?.message || err}`);
88
+ process.exit(1);
89
+ }
90
+ }
@@ -5,16 +5,26 @@ import {
5
5
  openSummaryModal,
6
6
  closeSummaryModal,
7
7
  setSummaryModalLoading,
8
+ renderSummaryModalContent,
9
+ summaryModalState,
8
10
  showTransientRelayNotice,
9
11
  } from './store.js';
10
12
  import {
11
13
  killSessionWorker,
12
14
  requestRelayRestart,
13
15
  requestHostSuspend,
16
+ getHostSuspendState,
17
+ cancelHostSuspend,
14
18
  requestQueueEmpty,
15
19
  refreshWorkspaceRootHints,
16
20
  } from './api-client.js';
17
21
  import { isSuspendHostActionVisible } from './settings-modal.js';
22
+ import {
23
+ applyHostSuspendState,
24
+ describeHostSuspendBlocker,
25
+ formatCountdown,
26
+ isHostSuspendPending,
27
+ } from './host-suspend-ui.js';
18
28
 
19
29
  let killSessionInFlight = false;
20
30
  let restartRelayInFlight = false;
@@ -129,22 +139,111 @@ export function openEmptyQueueConfirmation() {
129
139
  });
130
140
  }
131
141
 
142
+ const SUSPEND_MODAL_KIND = 'suspend-host';
143
+
144
+ /**
145
+ * Body of the Suspend host modal for one activity snapshot. Pure so the three
146
+ * shapes (idle, busy, already pending) are unit-testable without a DOM.
147
+ */
148
+ export function renderSuspendHostModalBody({ state = null, blockers = [], supported = true } = {}) {
149
+ const list = Array.isArray(blockers) ? blockers : [];
150
+ const items = list.map((b) => `<li>${escHtml(describeHostSuspendBlocker(b))}</li>`).join('');
151
+ if (!supported) {
152
+ return `
153
+ <p>Host suspend is only available when the relay runs on Windows.</p>
154
+ <div class="summary-modal-actions">
155
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
156
+ </div>`;
157
+ }
158
+ if (isHostSuspendPending(state)) {
159
+ const status = String(state?.status || '');
160
+ const countdown = status === 'countdown' ? formatCountdown(state.fireAt) : null;
161
+ const headline = status === 'suspending'
162
+ ? '<p>The host is suspending now.</p>'
163
+ : status === 'countdown'
164
+ ? `<p>Everything is idle. The host suspends in <strong>${escHtml(countdown || 'a moment')}</strong> unless you cancel.</p>`
165
+ : '<p>A suspend is already queued. The PC will not go to sleep until these finish, then after 2 minutes of quiet:</p>';
166
+ return `
167
+ ${headline}
168
+ ${items ? `<ul class="suspend-host-blockers">${items}</ul>` : ''}
169
+ <div class="summary-modal-actions">
170
+ <button class="chat-title-action-btn danger-btn" type="button" onclick="cancelQueuedHostSuspend()"${status === 'suspending' ? ' disabled data-keep-disabled="1"' : ''}>Cancel queued suspend</button>
171
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
172
+ </div>`;
173
+ }
174
+ if (!list.length) {
175
+ return `
176
+ <p>Put this PC to sleep?</p>
177
+ <p>Nothing is running. The host suspends <strong>30 seconds</strong> after you confirm; a banner with a countdown lets you cancel until then.</p>
178
+ <div class="summary-modal-actions">
179
+ <button class="chat-title-action-btn" type="button" onclick="confirmSuspendHost()">💤 Suspend host</button>
180
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
181
+ </div>`;
182
+ }
183
+ return `
184
+ <p><strong>Agents are still active.</strong> The PC will not suspend until these finish, then after 2 minutes of quiet:</p>
185
+ <ul class="suspend-host-blockers">${items}</ul>
186
+ <p>You can cancel the queued suspend from the banner or this menu at any time.</p>
187
+ <div class="summary-modal-actions">
188
+ <button class="chat-title-action-btn" type="button" onclick="confirmSuspendHost()">💤 Suspend when idle</button>
189
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
190
+ </div>`;
191
+ }
192
+
193
+ async function loadSuspendHostModal() {
194
+ const result = await getHostSuspendState();
195
+ if (summaryModalState.kind !== SUSPEND_MODAL_KIND) return;
196
+ if (!result?.ok) {
197
+ renderSummaryModalContent({
198
+ title: 'Suspend host',
199
+ subtitle: 'Suspend-to-RAM once agents are done',
200
+ kind: SUSPEND_MODAL_KIND,
201
+ refresh: loadSuspendHostModal,
202
+ bodyHtml: `
203
+ <div class="summary-error">Could not read what is running right now.</div>
204
+ <div class="summary-modal-actions">
205
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
206
+ </div>`,
207
+ });
208
+ return;
209
+ }
210
+ if (result.state) applyHostSuspendState(result.state);
211
+ renderSummaryModalContent({
212
+ title: 'Suspend host',
213
+ subtitle: isHostSuspendPending(result.state) ? 'Suspend queued' : 'Suspend-to-RAM once agents are done',
214
+ kind: SUSPEND_MODAL_KIND,
215
+ refresh: loadSuspendHostModal,
216
+ bodyHtml: renderSuspendHostModalBody({
217
+ state: result.state,
218
+ blockers: result.blockers,
219
+ supported: result.supported !== false,
220
+ }),
221
+ });
222
+ }
223
+
132
224
  export function openSuspendHostConfirmation() {
133
- if (!isSuspendHostActionVisible()) return;
225
+ if (!isSuspendHostActionVisible() && !isHostSuspendPending()) return;
134
226
  menuDeps.lockChatActionsMenuShield(350);
135
227
  menuDeps.closeChatActionsMenu();
136
228
  openSummaryModal({
137
229
  title: 'Suspend host',
138
- subtitle: 'Requests suspend-to-RAM',
139
- kind: 'suspend-host',
140
- bodyHtml: `
141
- <p>Put this PC to sleep now?</p>
142
- <p>This requests suspend-to-RAM immediately.</p>
143
- <div class="summary-modal-actions">
144
- <button class="chat-title-action-btn" type="button" onclick="confirmSuspendHost()">💤 Suspend host</button>
145
- <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
146
- </div>
147
- `,
230
+ subtitle: 'Suspend-to-RAM once agents are done',
231
+ kind: SUSPEND_MODAL_KIND,
232
+ refresh: loadSuspendHostModal,
233
+ bodyHtml: '<div class="summary-loading">Checking what is still running…</div>',
234
+ });
235
+ setSummaryModalLoading(true);
236
+ loadSuspendHostModal().catch(() => {
237
+ if (summaryModalState.kind !== SUSPEND_MODAL_KIND) return;
238
+ renderSummaryModalContent({
239
+ title: 'Suspend host',
240
+ kind: SUSPEND_MODAL_KIND,
241
+ bodyHtml: `
242
+ <div class="summary-error">Could not read what is running right now.</div>
243
+ <div class="summary-modal-actions">
244
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Close</button>
245
+ </div>`,
246
+ });
148
247
  });
149
248
  window.setTimeout(() => {
150
249
  const modal = document.getElementById('summary-modal');
@@ -152,7 +251,7 @@ export function openSuspendHostConfirmation() {
152
251
  const ariaVisible = String(modal?.getAttribute('aria-hidden') || 'true') === 'false';
153
252
  const displayVisible = modal ? window.getComputedStyle(modal).display !== 'none' : false;
154
253
  if (classVisible && ariaVisible && displayVisible) return;
155
- const confirmed = window.confirm('Put this PC to sleep now?\n\nThis requests suspend-to-RAM.');
254
+ const confirmed = window.confirm('Put this PC to sleep once all agents are done?\n\nThe suspend is queued and fires after everything has been idle.');
156
255
  if (!confirmed) return;
157
256
  confirmSuspendHost().catch(() => {});
158
257
  }, 90);
@@ -170,9 +269,37 @@ export async function confirmSuspendHost() {
170
269
  });
171
270
  closeSummaryModal();
172
271
  if (!result?.ok) {
173
- alert('Failed to suspend host');
272
+ alert('Failed to queue host suspend');
273
+ return;
274
+ }
275
+ if (result.state) applyHostSuspendState(result.state);
276
+ const status = String(result.state?.status || '');
277
+ if (result.alreadyPending) {
278
+ showTransientRelayNotice('A host suspend is already queued.', 5000);
279
+ } else if (status === 'countdown') {
280
+ showTransientRelayNotice('Host suspend queued: everything is idle, sleeping in 30 seconds. Cancel from the banner.', 7000);
281
+ } else {
282
+ showTransientRelayNotice('Host suspend queued: the PC sleeps once all agents are done.', 7000);
283
+ }
284
+ } finally {
285
+ suspendHostInFlight = false;
286
+ setSummaryModalLoading(false);
287
+ }
288
+ }
289
+
290
+ export async function cancelQueuedHostSuspend() {
291
+ if (suspendHostInFlight) return;
292
+ suspendHostInFlight = true;
293
+ setSummaryModalLoading(true);
294
+ try {
295
+ const result = await cancelHostSuspend();
296
+ closeSummaryModal();
297
+ if (!result?.ok) {
298
+ alert('Failed to cancel the queued host suspend');
174
299
  return;
175
300
  }
301
+ if (result.state) applyHostSuspendState(result.state);
302
+ showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
176
303
  } finally {
177
304
  suspendHostInFlight = false;
178
305
  setSummaryModalLoading(false);
@@ -263,5 +390,6 @@ export function initActionConfirmations({
263
390
  window.confirmKillCurrentSession = confirmKillCurrentSession;
264
391
  window.confirmRestartWebRelay = confirmRestartWebRelay;
265
392
  window.confirmSuspendHost = confirmSuspendHost;
393
+ window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;
266
394
  window.confirmEmptyQueue = confirmEmptyQueue;
267
395
  }
@@ -457,6 +457,91 @@ export async function updatePwaAppNameSetting(appName) {
457
457
  });
458
458
  }
459
459
 
460
+ // ─── Remote relays (Settings → Relays) ───────────────────────────────────────
461
+ // Adding a relay has several distinct refusals the form has to tell apart
462
+ // (needs a token, this is this relay, bad address), so these calls hand back
463
+ // the relay's JSON body on failure too — `{ ok:false, status, code, error }` —
464
+ // instead of apiFetch's bare null. They never throw; null only when network
465
+ // requests are paused.
466
+
467
+ async function remoteRelayRequest(path, { method = 'GET', body = undefined, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
468
+ if (!networkRequestsEnabled) return null;
469
+ try {
470
+ const response = await fetch(`${BASE}${path}`, {
471
+ signal: requestTimeoutSignal(timeoutMs),
472
+ method,
473
+ headers: {
474
+ 'Content-Type': 'application/json',
475
+ ...authHeaders(),
476
+ },
477
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
478
+ });
479
+ const payload = await response.json().catch(() => null);
480
+ noteFetchSuccess();
481
+ const result = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
482
+ if (!response.ok) {
483
+ return {
484
+ ...result,
485
+ ok: false,
486
+ status: response.status,
487
+ error: String(result.error || `Request failed (${response.status})`),
488
+ };
489
+ }
490
+ return { ok: true, ...result, status: response.status };
491
+ } catch (error) {
492
+ noteFetchFailure(path, error);
493
+ return { ok: false, status: 0, code: 'NETWORK', error: toErrorMessage(error) };
494
+ }
495
+ }
496
+
497
+ function remoteRelayPath(id, suffix = '') {
498
+ return `/api/remote-relays/${encodeURIComponent(String(id || '').trim())}${suffix}`;
499
+ }
500
+
501
+ /** `{ relays, self }`; never carries tokens. Null on any failure. */
502
+ export async function loadRemoteRelays() {
503
+ return apiFetch('/api/remote-relays');
504
+ }
505
+
506
+ /**
507
+ * Probes and saves a remote relay, and (pairBack) asks it to add this relay.
508
+ * The probe and the pair-back are two remote round trips of up to 15 s each,
509
+ * hence the long timeout.
510
+ */
511
+ export async function addRemoteRelay({ url, token = '', pairBack = true, selfUrl = '' } = {}) {
512
+ const body = { url: String(url || '').trim(), pairBack: pairBack !== false };
513
+ const trimmedToken = String(token || '').trim();
514
+ if (trimmedToken) body.token = trimmedToken;
515
+ const trimmedSelfUrl = String(selfUrl || '').trim();
516
+ if (trimmedSelfUrl) body.selfUrl = trimmedSelfUrl;
517
+ return remoteRelayRequest('/api/remote-relays', { method: 'POST', body, timeoutMs: LONG_REQUEST_TIMEOUT_MS });
518
+ }
519
+
520
+ /** `patch`: any of `{ permission, url, token, tokenMode }`. */
521
+ export async function updateRemoteRelay(id, patch = {}) {
522
+ return remoteRelayRequest(remoteRelayPath(id), { method: 'PATCH', body: patch || {} });
523
+ }
524
+
525
+ export async function removeRemoteRelay(id) {
526
+ return remoteRelayRequest(remoteRelayPath(id), { method: 'DELETE' });
527
+ }
528
+
529
+ export async function checkRemoteRelay(id) {
530
+ return remoteRelayRequest(remoteRelayPath(id, '/check'), { method: 'POST', body: {}, timeoutMs: LONG_REQUEST_TIMEOUT_MS });
531
+ }
532
+
533
+ /** `{ publicUrl, inboundEnabled }` for this relay. */
534
+ export async function loadRemoteRelaySettings() {
535
+ return apiFetch('/api/settings/remote-relays');
536
+ }
537
+
538
+ export async function updateRemoteRelaySettings(patch = {}) {
539
+ const body = {};
540
+ if (typeof patch.publicUrl === 'string') body.publicUrl = patch.publicUrl.trim();
541
+ if (typeof patch.inboundEnabled === 'boolean') body.inboundEnabled = patch.inboundEnabled;
542
+ return remoteRelayRequest('/api/settings/remote-relays', { method: 'POST', body });
543
+ }
544
+
460
545
  export async function loadUpdateState() {
461
546
  return apiFetch('/api/update/state');
462
547
  }
@@ -655,10 +740,30 @@ export async function searchMessages(options = {}) {
655
740
  return apiFetch(`/api/search/messages?${params.toString()}`);
656
741
  }
657
742
 
743
+ // Raw fetch instead of apiFetch: a refused delete (the conversation is still
744
+ // working, or its session would not stop) must surface the server's reason.
745
+ // The long timeout covers stopping the session and deleting its CLI session.
658
746
  export async function deleteConversation(id) {
659
747
  const convId = String(id || '').trim();
660
- if (!convId) return null;
661
- return apiFetch(`/api/conversation/${convId}`, { method: 'DELETE' });
748
+ if (!convId || !areNetworkRequestsEnabled()) return null;
749
+ const url = `/api/conversation/${encodeURIComponent(convId)}`;
750
+ try {
751
+ const response = await fetch(`${BASE}${url}`, {
752
+ signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
753
+ method: 'DELETE',
754
+ headers: {
755
+ 'Content-Type': 'application/json',
756
+ ...authHeaders(),
757
+ },
758
+ });
759
+ const payload = await response.json().catch(() => null);
760
+ if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
761
+ noteFetchSuccess();
762
+ return payload || { ok: true };
763
+ } catch (error) {
764
+ noteFetchFailure(url, error);
765
+ return null;
766
+ }
662
767
  }
663
768
 
664
769
  export async function updateMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
@@ -856,6 +961,24 @@ export async function requestHostSuspend(body = {}) {
856
961
  });
857
962
  }
858
963
 
964
+ export async function getHostSuspendState() {
965
+ return apiFetch('/api/host/suspend');
966
+ }
967
+
968
+ export async function cancelHostSuspend(body = {}) {
969
+ return apiFetch('/api/host/suspend/cancel', {
970
+ method: 'POST',
971
+ body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
972
+ });
973
+ }
974
+
975
+ export async function cancelRelayRestart(body = {}) {
976
+ return apiFetch('/api/relay/shutdown/cancel', {
977
+ method: 'POST',
978
+ body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
979
+ });
980
+ }
981
+
859
982
  export async function requestQueueEmpty(body = {}) {
860
983
  return apiFetch('/api/queue/empty', {
861
984
  method: 'POST',