@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -0,0 +1,248 @@
1
+ // Agent-to-agent exchanges in the transcript: a prompt another agent sent this
2
+ // session (another relay's agent, or an agent on this relay) and this
3
+ // session's reply to it fold into ONE row, "note from win-test · report
4
+ // builder · replied", which opens to both as they render otherwise.
5
+ //
6
+ // DOM-free on purpose, like relay-event-view.mjs: conversation-view.js reads
7
+ // the rows off the transcript, plans with this module and applies the plan.
8
+ // Presentation only: the transcript data is never touched.
9
+ //
10
+ // Nothing addressed to the user is folded away. A turn folds only when the
11
+ // agent's prompt started it alone:
12
+ // - a message steered into the turn (handed off: the reply settled
13
+ // 'absorbed'; folded in or stopped with it: the steer's own marker) keeps
14
+ // it unfolded, and so does an agent's prompt that was itself steered into a
15
+ // turn;
16
+ // - so does a question or approval card of the turn that is still open;
17
+ // - and, while the turn runs, a message of the user's with no answer yet: it
18
+ // may be steered into the turn.
19
+ // The last two pass, so a turn shown for them is reported `transient`: the
20
+ // caller keeps it shown while the page lives instead of folding it away under
21
+ // the user once the card is answered.
22
+
23
+ import { isRelayEventMessage } from './relay-event-view.mjs';
24
+ import { relayErrorCodeFromText } from './relay-error-ctas.mjs';
25
+ import { isLocalAgentOrigin, localAgentOriginModel, normalizeRemoteOrigin } from './remote-relay-origin-view.mjs';
26
+
27
+ export const AGENT_EXCHANGE_HEAD_CLASS = 'agent-exchange-head';
28
+ export const AGENT_EXCHANGE_MEMBER_CLASS = 'agent-exchange-member';
29
+ export const AGENT_EXCHANGE_CLOSED_CLASS = 'agent-exchange-closed';
30
+
31
+ export const AGENT_EXCHANGE_STATUS_TEXT = Object.freeze({
32
+ working: 'working…',
33
+ replied: 'replied',
34
+ failed: 'failed',
35
+ stopped: 'stopped',
36
+ none: 'no reply yet',
37
+ });
38
+
39
+ // The code a turn the user stopped ends with (shared/relay-stop-failure.mjs).
40
+ const STOPPED_ERROR_CODE = 'turn-aborted';
41
+ // A steer's settle markers (steered-turn-merge.mjs): the message was consumed
42
+ // by another turn, folded into it or stopped with it.
43
+ const STEER_MARKER_KINDS = new Set(['folded', 'stopped']);
44
+ const ABSORBED_KIND = 'absorbed';
45
+ const ANSWERED_ELSEWHERE_KIND = 'answered-elsewhere';
46
+ const TRANSIENT_REASONS = new Set(['open-card', 'user-waiting']);
47
+
48
+ function defaultEscape(value) {
49
+ return String(value ?? '')
50
+ .replace(/&/g, '&')
51
+ .replace(/</g, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;')
54
+ .replace(/'/g, '&#39;');
55
+ }
56
+
57
+ function oneLine(value) {
58
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ function idSet(values) {
62
+ return new Set(Array.from(values || [], (value) => String(value ?? '').trim()).filter(Boolean));
63
+ }
64
+
65
+ /** True for an `origin` that names an agent: one on this relay or another relay's. */
66
+ export function isAgentOrigin(origin) {
67
+ return isLocalAgentOrigin(origin) || !!normalizeRemoteOrigin(origin);
68
+ }
69
+
70
+ /** A prompt an agent sent: a user row with an agent's origin, never a relay event. */
71
+ export function isAgentPromptMessage(message) {
72
+ return message?.role === 'user' && !isRelayEventMessage(message) && isAgentOrigin(message?.origin);
73
+ }
74
+
75
+ /**
76
+ * How a reply ended, from its text: 'failed' or 'stopped' for a failure note
77
+ * of the relay, '' for an answer. The note goes below what the turn had
78
+ * written before it failed, so only the last paragraph is read.
79
+ */
80
+ export function replyOutcome(text) {
81
+ const paragraphs = String(text ?? '').trim().split(/\r?\n\s*\r?\n/);
82
+ const code = relayErrorCodeFromText(paragraphs[paragraphs.length - 1]);
83
+ if (!code) return '';
84
+ return code === STOPPED_ERROR_CODE ? 'stopped' : 'failed';
85
+ }
86
+
87
+ /**
88
+ * What the plan needs of one message, built when its row is: an agent
89
+ * prompt's origin, an assistant row's kind, the prompt it answers and how it
90
+ * ended. `parseTimestamp` reads the message's timestamp (the caller's
91
+ * tolerant parser; Date.parse by default).
92
+ */
93
+ export function agentExchangeRowOf(message, { id = message?.id, parseTimestamp = Date.parse } = {}) {
94
+ const role = message?.role === 'assistant' ? 'assistant' : (message?.role === 'user' ? 'user' : '');
95
+ const timestampMs = Number(parseTimestamp(message?.timestamp));
96
+ return {
97
+ id: String(id ?? '').trim(),
98
+ role,
99
+ relayEvent: isRelayEventMessage(message),
100
+ origin: isAgentPromptMessage(message) ? message.origin : null,
101
+ kind: String(message?.kind || '').trim(),
102
+ sourceMessageId: role === 'assistant' ? String(message?.sourceMessageId || '').trim() : '',
103
+ timestampMs: Number.isFinite(timestampMs) && timestampMs > 0 ? timestampMs : 0,
104
+ outcome: role === 'assistant' ? replyOutcome(message?.text) : '',
105
+ };
106
+ }
107
+
108
+ function isUsersOwnMessage(row) {
109
+ return row.role === 'user' && !row.origin && !row.relayEvent;
110
+ }
111
+
112
+ /**
113
+ * The plan for a transcript: one entry per agent prompt, in transcript order.
114
+ *
115
+ * `rows` are the transcript's rows in order (agentExchangeRowOf, plus
116
+ * `steered` for a user row steered-turn-merge.mjs classed as steered), and
117
+ * the live bubble of a running turn as `{ id: <its message id>, role:
118
+ * 'assistant', live: true }`. `runningIds` names turns that run without a
119
+ * live bubble on the page, `openCardIds` the turns with an open question or
120
+ * approval card, `keptShownIds` those the caller keeps shown.
121
+ *
122
+ * Each entry: `{ promptId, prompt, members, status, fold, reasons,
123
+ * transient }`. `members` are the reply rows and the live bubble, the rows a
124
+ * folded exchange hides; `fold` is false while any of `reasons` holds.
125
+ */
126
+ export function planAgentExchangeFolds(rows, { runningIds = [], openCardIds = [], keptShownIds = [] } = {}) {
127
+ const list = (Array.isArray(rows) ? rows : []).filter((row) => row && String(row.id || '').trim());
128
+ const running = idSet(runningIds);
129
+ const openCards = idSet(openCardIds);
130
+ const keptShown = idSet(keptShownIds);
131
+
132
+ // One turn per user-side row (the user's, an agent's, a relay event), with
133
+ // its replies and its live bubble.
134
+ const turns = [];
135
+ const turnById = new Map();
136
+ list.forEach((row, index) => {
137
+ if (row.role !== 'user' || turnById.has(row.id)) return;
138
+ const turn = { prompt: row, index, replies: [], live: null };
139
+ turns.push(turn);
140
+ turnById.set(row.id, turn);
141
+ });
142
+ for (const row of list) {
143
+ if (row.role !== 'assistant') continue;
144
+ const turn = turnById.get(row.live ? row.id : row.sourceMessageId);
145
+ if (!turn) continue;
146
+ if (row.live) turn.live = row;
147
+ else turn.replies.push(row);
148
+ }
149
+ for (const turn of turns) {
150
+ turn.running = !!turn.live || running.has(turn.prompt.id);
151
+ turn.markers = turn.replies.filter((row) => STEER_MARKER_KINDS.has(row.kind));
152
+ turn.answers = turn.replies.filter((row) => !STEER_MARKER_KINDS.has(row.kind) && row.kind !== ANSWERED_ELSEWHERE_KIND);
153
+ // When the turn ended (its last answer was written); still open while it
154
+ // runs; null for a turn that has not run (queued, cancelled).
155
+ turn.end = turn.answers.length
156
+ ? Math.max(0, ...turn.answers.map((row) => Number(row.timestampMs) || 0))
157
+ : (turn.running ? Infinity : null);
158
+ }
159
+
160
+ // A steer folded into a turn (or stopped with it) settles as a marker under
161
+ // its own message; the turn it joined is the one running when it was sent:
162
+ // sent before it, ended after it, and of those the first to end (a
163
+ // session runs one turn at a time).
164
+ const steeredInto = new Set();
165
+ for (const turn of turns) {
166
+ const sentAt = Number(turn.prompt.timestampMs) || 0;
167
+ if (!turn.markers.length || !sentAt) continue;
168
+ let host = null;
169
+ for (const other of turns) {
170
+ const startedAt = Number(other.prompt.timestampMs) || 0;
171
+ if (other === turn || other.end === null || !startedAt) continue;
172
+ if (startedAt > sentAt || other.end < sentAt) continue;
173
+ if (!host || other.end < host.end || (other.end === host.end && startedAt > host.prompt.timestampMs)) host = other;
174
+ }
175
+ if (host) steeredInto.add(host);
176
+ }
177
+
178
+ return turns.filter((turn) => !!turn.prompt.origin).map((turn) => {
179
+ const reasons = [];
180
+ if (turn.prompt.steered || turn.markers.length) reasons.push('steered');
181
+ if (turn.replies.some((row) => row.kind === ABSORBED_KIND) || steeredInto.has(turn)) reasons.push('steered-in');
182
+ // Its answer is on a background-continuation row further down: there is
183
+ // no reply here to fold with it.
184
+ if (turn.replies.some((row) => row.kind === ANSWERED_ELSEWHERE_KIND)) reasons.push('answered-elsewhere');
185
+ if (openCards.has(turn.prompt.id)) reasons.push('open-card');
186
+ if (turn.running && !turn.answers.length && turns.some((other) => other.index > turn.index
187
+ && isUsersOwnMessage(other.prompt)
188
+ && !other.replies.length
189
+ && !other.running)) {
190
+ reasons.push('user-waiting');
191
+ }
192
+ if (keptShown.has(turn.prompt.id)) reasons.push('kept-shown');
193
+ const last = turn.answers[turn.answers.length - 1] || null;
194
+ const status = last ? (last.outcome || 'replied') : (turn.running ? 'working' : 'none');
195
+ return {
196
+ promptId: turn.prompt.id,
197
+ prompt: turn.prompt,
198
+ members: turn.live ? [...turn.replies, turn.live] : turn.replies.slice(),
199
+ status,
200
+ fold: reasons.length === 0,
201
+ reasons,
202
+ transient: reasons.some((reason) => TRANSIENT_REASONS.has(reason)),
203
+ };
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Whether a folded exchange shows open. `choice` is the user's own toggle of
209
+ * it (true opened, false closed, undefined none) and always wins; without
210
+ * one it is open while the turn works and closes by itself once it is
211
+ * replied, failed or stopped.
212
+ */
213
+ export function agentExchangeFoldOpen(status, choice) {
214
+ if (typeof choice === 'boolean') return choice;
215
+ return status === 'working';
216
+ }
217
+
218
+ /**
219
+ * The folded row's words: who sent the prompt (the relay's name; for an agent
220
+ * on this relay the name the origin carries, else "this relay"), from which
221
+ * session (for a local one its current title when `conversations` knows it),
222
+ * and how the turn stands.
223
+ */
224
+ export function agentExchangeSummary(origin, status, { conversations = null } = {}) {
225
+ const local = localAgentOriginModel(origin, { conversations, linkable: false });
226
+ const remote = local ? null : normalizeRemoteOrigin(origin);
227
+ const from = local ? (oneLine(origin?.relayName) || 'this relay') : (remote?.relayName || 'another relay');
228
+ const title = local ? local.fullTitle : (remote?.conversationTitle || '');
229
+ const known = Object.prototype.hasOwnProperty.call(AGENT_EXCHANGE_STATUS_TEXT, status) ? status : 'none';
230
+ const head = [`note from ${from}`, title].filter(Boolean).join(' · ');
231
+ const statusText = AGENT_EXCHANGE_STATUS_TEXT[known];
232
+ return { from, title, status: known, statusText, head, text: `${head} · ${statusText}` };
233
+ }
234
+
235
+ /**
236
+ * The folded row's markup: a <details> whose summary is the whole row. Its
237
+ * body stays empty — the prompt and the reply are the transcript rows below
238
+ * it, shown while it is open.
239
+ */
240
+ export function renderAgentExchangeRowHtml(summary, { escHtml = defaultEscape, timeText = '', open = false } = {}) {
241
+ return `<details class="agent-exchange-row agent-exchange-${escHtml(summary?.status || 'none')}"${open ? ' open' : ''}>`
242
+ + `<summary class="agent-exchange-summary" title="${escHtml(summary?.text)}">`
243
+ + `<span class="agent-exchange-text">${escHtml(summary?.head)}</span>`
244
+ + `<span class="agent-exchange-status">${escHtml(summary?.statusText)}</span>`
245
+ + (timeText ? `<span class="agent-exchange-time">${escHtml(timeText)}</span>` : '')
246
+ + '</summary>'
247
+ + '</details>';
248
+ }
@@ -498,6 +498,17 @@ export async function updateTurnCeilingSetting(ceilingMinutes) {
498
498
  });
499
499
  }
500
500
 
501
+ export async function loadSubagentRecordRetentionSetting() {
502
+ return apiFetch('/api/settings/subagent-record-retention');
503
+ }
504
+
505
+ export async function updateSubagentRecordRetentionSetting(retentionDays) {
506
+ return apiFetch('/api/settings/subagent-record-retention', {
507
+ method: 'POST',
508
+ body: JSON.stringify({ retentionDays: Number(retentionDays) }),
509
+ });
510
+ }
511
+
501
512
  export async function loadBackgroundTaskTimeoutSetting() {
502
513
  return apiFetch('/api/settings/background-task-timeout');
503
514
  }
@@ -900,6 +911,36 @@ export async function updateMessageShareVisibility(conversationId, messageId, hi
900
911
  });
901
912
  }
902
913
 
914
+ // Pin or unpin one message. Unlike apiFetch this keeps the relay's `error`
915
+ // text on a refusal (the pin limit), so the notice can say why.
916
+ export async function updateMessagePin(conversationId, messageId, pinned) {
917
+ const convId = String(conversationId || '').trim();
918
+ const msgId = String(messageId || '').trim();
919
+ if (!convId || !msgId || !networkRequestsEnabled) return null;
920
+ const url = `/api/conversation/${encodeURIComponent(convId)}/message/${encodeURIComponent(msgId)}/pin`;
921
+ try {
922
+ const response = await fetch(`${BASE}${url}`, {
923
+ signal: requestTimeoutSignal(),
924
+ method: 'PATCH',
925
+ headers: {
926
+ 'Content-Type': 'application/json',
927
+ ...authHeaders(),
928
+ },
929
+ body: JSON.stringify({ pinned: pinned === true }),
930
+ });
931
+ const result = await response.json().catch(() => null);
932
+ if (!response.ok) {
933
+ console.error('API error', response.status, url);
934
+ return { ok: false, status: response.status, error: String(result?.error || '').trim() };
935
+ }
936
+ noteFetchSuccess();
937
+ return result;
938
+ } catch (error) {
939
+ noteFetchFailure(url, error);
940
+ return null;
941
+ }
942
+ }
943
+
903
944
  export async function updateConversationTitle(id, title) {
904
945
  const convId = String(id || '').trim();
905
946
  if (!convId) return null;
@@ -1288,6 +1329,56 @@ export async function uploadAttachment(item) {
1288
1329
  return response.json();
1289
1330
  }
1290
1331
 
1332
+ // A POST whose failure message matters: unlike apiFetch it throws with the
1333
+ // relay's `error` text, so the explorer can show "That name is already taken"
1334
+ // instead of a bare failure.
1335
+ export async function apiMutate(url, body = {}) {
1336
+ if (!networkRequestsEnabled) throw new Error('Network requests are paused');
1337
+ let response;
1338
+ try {
1339
+ response = await fetch(`${BASE}${url}`, {
1340
+ signal: requestTimeoutSignal(),
1341
+ method: 'POST',
1342
+ headers: {
1343
+ 'Content-Type': 'application/json',
1344
+ ...authHeaders(),
1345
+ },
1346
+ body: JSON.stringify(body),
1347
+ });
1348
+ } catch (error) {
1349
+ noteFetchFailure(url, error);
1350
+ throw new Error('The relay could not be reached');
1351
+ }
1352
+ const payload = await response.json().catch(() => null);
1353
+ if (!response.ok) throw new Error(String(payload?.error || '').trim() || `Request failed (${response.status})`);
1354
+ noteFetchSuccess();
1355
+ return payload;
1356
+ }
1357
+
1358
+ // The explorer's edits. `root` is the explorer root the path belongs to:
1359
+ // 'workspace' paths are relative to the conversation's root, everything else
1360
+ // ('drives', 'session') is an absolute path checked like a drives listing.
1361
+ function explorerMutationRoute(root, operation) {
1362
+ return root === 'workspace' ? `/api/repo/${operation}` : `/api/drives/${operation}`;
1363
+ }
1364
+
1365
+ function explorerScope(root, conversationId) {
1366
+ const convId = String(conversationId || '').trim();
1367
+ return root === 'workspace' && convId ? { conversationId: convId } : {};
1368
+ }
1369
+
1370
+ export function createExplorerFolder({ root, path: parentPath, name, conversationId = null }) {
1371
+ return apiMutate(explorerMutationRoute(root, 'mkdir'), { path: String(parentPath || ''), name, ...explorerScope(root, conversationId) });
1372
+ }
1373
+
1374
+ export function renameExplorerEntry({ root, path: entryPath, newName, conversationId = null }) {
1375
+ return apiMutate(explorerMutationRoute(root, 'rename'), { path: String(entryPath || ''), newName, ...explorerScope(root, conversationId) });
1376
+ }
1377
+
1378
+ export function deleteExplorerEntry({ root, path: entryPath, conversationId = null, dryRun = false }) {
1379
+ return apiMutate(explorerMutationRoute(root, 'delete'), { path: String(entryPath || ''), dryRun: dryRun === true, ...explorerScope(root, conversationId) });
1380
+ }
1381
+
1291
1382
  export async function loadRepoTree(includeHidden = false, includeHeavy = false, conversationId = null) {
1292
1383
  const params = new URLSearchParams({
1293
1384
  includeHidden: includeHidden ? '1' : '0',
@@ -0,0 +1,28 @@
1
+ // Whether archiving a conversation asks first. Archiving stops the chat's
2
+ // worker, so a chat whose worker is live (the sidebar's green state, idle or
3
+ // not) or whose turn is running asks before it goes; an offline chat archives
4
+ // at once. DOM-free so the decision is unit-testable.
5
+
6
+ /**
7
+ * The confirmation an archive needs, or null when it goes through at once.
8
+ * `visualState` is the sidebar row's state (resolveConversationUiState), and
9
+ * `running` whether a turn of the chat is running as far as the page knows.
10
+ */
11
+ export function archiveConfirmationFor({ archived = false, visualState = '', running = false } = {}) {
12
+ if (archived === true) return null;
13
+ if (running === true) {
14
+ return {
15
+ reason: 'running',
16
+ message: 'Archive this session? It is still working on a reply.',
17
+ detail: 'The relay archives it only once the turn has stopped.',
18
+ };
19
+ }
20
+ if (String(visualState || '').trim().toLowerCase() === 'ready') {
21
+ return {
22
+ reason: 'active',
23
+ message: 'Archive this session? Its process is still active.',
24
+ detail: 'Archiving stops the idle process. The chat stays in the Archived view.',
25
+ };
26
+ }
27
+ return null;
28
+ }
@@ -330,6 +330,9 @@ export function renderRelayQuestions() {
330
330
  el.appendChild(wrapper);
331
331
  }
332
332
 
333
+ // An agent exchange whose turn asks this is shown whole, before anything
334
+ // scrolls (agent-exchange-fold-view.mjs).
335
+ window.syncAgentExchangeFolds?.();
333
336
  renderedRelayQuestionIds = nextRenderedQuestionIds;
334
337
  restoreFocusedQuestionControl(el, focusedControl);
335
338
  if (firstNewQuestionId) {