@oar-sh/oar 0.9.20 → 0.9.22

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 (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: one order across a turn's thoughts and tool lines.
5
+ *
6
+ * `relay_activity.turn_seq` and `relay_thought.turn_seq`: a number per queue
7
+ * message shared by both tables (repositories/question-repository.mjs
8
+ * nextTurnSeq), given when a row is first written, so the stream view can
9
+ * show a turn's thoughts and tool calls in the order they happened. NULL for
10
+ * every row from before this migration and for rows a session-history
11
+ * rebuild writes; a turn with such a row keeps the grouped layout.
12
+ *
13
+ * Indexes on (queue_message_id, turn_seq), partial (only numbered rows): the
14
+ * next number is a max() over them. Not unique: one counter spans two tables.
15
+ *
16
+ * Not in SCHEMA_SQL (db-schema.mjs): that block runs before the migrations,
17
+ * so an index on the column there would fail on every existing database.
18
+ *
19
+ * Additive and idempotent. Safe to run on every boot; a table that does not
20
+ * exist yet is left alone. Rolling back needs no data change: older code
21
+ * ignores the columns.
22
+ *
23
+ * Usage: node server/migrations/0038-turn-seq.mjs [path/to/oar.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
+ import { cliDbPath } from '../services/database-file.mjs';
31
+
32
+ export const TURN_SEQ_TABLES = Object.freeze(['relay_activity', 'relay_thought']);
33
+
34
+ function tableExists(db, name) {
35
+ return !!db
36
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
37
+ .get(name);
38
+ }
39
+
40
+ export function hasTurnSeqColumn(db, table) {
41
+ try {
42
+ return db.prepare(`PRAGMA table_info(${table})`).all().some((column) => column.name === 'turn_seq');
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function migrateTurnSeq(db) {
49
+ let columnsAdded = 0;
50
+ let indexesAdded = 0;
51
+ for (const table of TURN_SEQ_TABLES) {
52
+ if (!tableExists(db, table)) continue;
53
+ if (!hasTurnSeqColumn(db, table)) {
54
+ db.exec(`ALTER TABLE ${table} ADD COLUMN turn_seq INTEGER`);
55
+ columnsAdded += 1;
56
+ }
57
+ const index = `idx_${table}_queue_turn_seq`;
58
+ const present = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`).get(index);
59
+ if (!present) {
60
+ db.exec(`CREATE INDEX ${index} ON ${table}(queue_message_id, turn_seq) WHERE turn_seq IS NOT NULL`);
61
+ indexesAdded += 1;
62
+ }
63
+ }
64
+ return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
65
+ }
66
+
67
+ export function migrate(dbPath, options = {}) {
68
+ const db = new Database(dbPath, options);
69
+ try {
70
+ return migrateTurnSeq(db);
71
+ } finally {
72
+ db.close();
73
+ }
74
+ }
75
+
76
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
77
+ if (isMain) {
78
+ try {
79
+ // The given path, else the database in server/data (oar.db, else the
80
+ // name before 0.9.20); never a new one.
81
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
82
+ const result = migrate(dbPath);
83
+ console.log(`Migration 0038 on ${dbPath}`);
84
+ console.log(result.applied
85
+ ? ` turn order: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
86
+ : ' skipped: nothing to change');
87
+ process.exit(0);
88
+ } catch (err) {
89
+ console.error(`Migration 0038 failed: ${err?.message || err}`);
90
+ process.exit(1);
91
+ }
92
+ }
@@ -1,4 +1,4 @@
1
- // ⋯ → About OAR: the menu entry's label and the modal (about-view.mjs builds
1
+ // ︙ → About OAR: the menu entry's label and the modal (about-view.mjs builds
2
2
  // its HTML from GET /api/about).
3
3
 
4
4
  import {
@@ -256,6 +256,23 @@ function backupsRow(backups, formatDateTime) {
256
256
  return row('Backups', esc(value), { hint });
257
257
  }
258
258
 
259
+ /**
260
+ * The running workers and how many still run the code before the last deploy
261
+ * (the relay recycles those once idle): "14, all on this build", "14, 3 on an
262
+ * older build", or the bare count when the relay cannot tell. Empty when no
263
+ * worker runs, so the row is left out.
264
+ */
265
+ export function workersBuildText(workers) {
266
+ const total = finite(workers?.total) || 0;
267
+ if (total <= 0) return '';
268
+ const count = formatCount(total);
269
+ // Number(null) is 0: an unknown count must not read as "all on this build".
270
+ const other = workers?.onOtherBuild === null || workers?.onOtherBuild === undefined ? null : finite(workers.onOtherBuild);
271
+ if (other === null) return count;
272
+ if (other <= 0) return total === 1 ? `${count}, on this build` : `${count}, all on this build`;
273
+ return `${count}, ${formatCount(other)} on an older build`;
274
+ }
275
+
259
276
  /**
260
277
  * @param {object} about the GET /api/about payload
261
278
  * @param {object} [options]
@@ -270,8 +287,10 @@ export function buildAboutModalHtml(about = {}, {
270
287
 
271
288
  const commit = String(about.commit || '').trim();
272
289
  const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
290
+ const workersText = workersBuildText(about.workers);
273
291
  sections.push(section('build', 'OAR', table([
274
292
  row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
293
+ ...(workersText ? [row('Workers', esc(workersText))] : []),
275
294
  ...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
276
295
  row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
277
296
  row('Uptime', esc(formatUptime(about.uptimeSeconds))),
@@ -107,7 +107,7 @@ export async function confirmKillCurrentSession() {
107
107
  }
108
108
  }
109
109
 
110
- // ⋯ → Restart web relay first shows what the restart would wait for and
110
+ // ︙ → Restart web relay first shows what the restart would wait for and
111
111
  // leave running (relay-restart-ui.js); its buttons land in
112
112
  // confirmRestartWebRelay below or in confirmRestartRelayNow.
113
113
  export { openRestartRelayConfirmation };
@@ -1,4 +1,10 @@
1
- const DEFAULT_ACTIVITY_LIMIT = 24;
1
+ // A row's place in its turn's order (migration 0038), or null: rows from
2
+ // before it, or from an older relay, have none.
3
+ export function normalizeTurnSeq(value) {
4
+ if (value === null || value === undefined || value === '') return null;
5
+ const num = Number(value);
6
+ return Number.isInteger(num) && num > 0 ? num : null;
7
+ }
2
8
 
3
9
  export function normalizeRelayActivityEntry(item) {
4
10
  if (item && typeof item === 'object' && !Array.isArray(item)) {
@@ -8,7 +14,8 @@ export function normalizeRelayActivityEntry(item) {
8
14
  const metadata = (item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata))
9
15
  ? item.metadata
10
16
  : null;
11
- return { text, subagentRunId, metadata };
17
+ const turnSeq = normalizeTurnSeq(item.turnSeq);
18
+ return { text, subagentRunId, metadata, ...(turnSeq !== null ? { turnSeq } : {}) };
12
19
  }
13
20
  const text = String(item || '').trim();
14
21
  if (!text) return null;
@@ -24,25 +31,87 @@ export function isCompactBoundaryActivityEntry(item) {
24
31
  return normalizeRelayActivityEntry(item)?.metadata?.kind === 'compact_boundary';
25
32
  }
26
33
 
27
- // The live list of one turn keeps its newest ordinary lines only, but every
28
- // structured row a line of the transcript is built from: the turn's
29
- // compaction entries and the session-restart notes (a compaction window, a
30
- // billing switch) would otherwise scroll out of a busy turn and take their
31
- // line with them until the next reload.
32
- export const LIVE_ACTIVITY_CAP = 24;
34
+ // The thread an activity row belongs to: a sub-agent run, or '' for the main
35
+ // agent. Every cap below counts each thread apart, so a busy sub-agent never
36
+ // pushes the main agent's lines out (or another sub-agent's).
37
+ function activityThreadKey(item) {
38
+ return normalizeRelayActivityEntry(item)?.subagentRunId || '';
39
+ }
40
+
41
+ // No limit: a cap of 0 (the setting's far end), or anything not a positive
42
+ // number.
43
+ function isUnlimitedCap(cap) {
44
+ const value = Number(cap);
45
+ return !Number.isFinite(value) || value <= 0;
46
+ }
47
+
48
+ /**
49
+ * Keep the newest `cap` rows of each thread among those `isKept` does not
50
+ * protect, and every protected row; the order stays. A sub-agent's thread
51
+ * keeps `subagentCap` (the same unless given). A list already within the cap
52
+ * is returned as it is.
53
+ */
54
+ function capNewestPerThread(list, cap, isKept, subagentCap = cap) {
55
+ if (isUnlimitedCap(cap) && isUnlimitedCap(subagentCap)) return list;
56
+ const maxFor = (key) => {
57
+ const limit = key ? subagentCap : cap;
58
+ return isUnlimitedCap(limit) ? Number.POSITIVE_INFINITY : Math.trunc(Number(limit));
59
+ };
60
+ const remaining = new Map();
61
+ for (const item of list) {
62
+ if (isKept(item)) continue;
63
+ const key = activityThreadKey(item);
64
+ remaining.set(key, (remaining.get(key) || 0) + 1);
65
+ }
66
+ if ([...remaining.entries()].every(([key, count]) => count <= maxFor(key))) return list;
67
+ return list.filter((item) => {
68
+ if (isKept(item)) return true;
69
+ const key = activityThreadKey(item);
70
+ const left = remaining.get(key) - 1;
71
+ remaining.set(key, left);
72
+ return left < maxFor(key);
73
+ });
74
+ }
75
+
76
+ // The live list of one turn keeps the newest ordinary lines of each thread
77
+ // (the main agent, each sub-agent) only, as many as the "Tool lines shown
78
+ // while an agent works" setting says (live-activity-cap.mjs; 0 keeps all),
79
+ // but every structured row a line of the transcript is built from: the
80
+ // turn's compaction entries and the session-restart notes (a compaction
81
+ // window, a billing switch) would otherwise scroll out of a busy turn and
82
+ // take their line with them until the next reload. The call sites pass the
83
+ // setting; LIVE_ACTIVITY_CAP is its default.
84
+ export const LIVE_ACTIVITY_CAP = 50;
33
85
  function isKeptLiveActivityEntry(item) {
34
86
  const kind = normalizeRelayActivityEntry(item)?.metadata?.kind;
35
87
  return kind === 'compact_boundary' || kind === 'compact_window_respawn' || kind === 'billing_switch_respawn';
36
88
  }
37
89
  export function capLiveActivityEntries(items, cap = LIVE_ACTIVITY_CAP) {
38
90
  const list = Array.isArray(items) ? items : [];
39
- let ordinary = list.reduce((count, item) => count + (isKeptLiveActivityEntry(item) ? 0 : 1), 0);
40
- if (ordinary <= cap) return list;
41
- return list.filter((item) => {
42
- if (isKeptLiveActivityEntry(item)) return true;
43
- ordinary -= 1;
44
- return ordinary < cap;
45
- });
91
+ return capNewestPerThread(list, cap, isKeptLiveActivityEntry);
92
+ }
93
+
94
+ // What the relay sends of a turn's activity rows, per thread.
95
+ //
96
+ // A running turn (the in-flight snapshot the live bubble polls every 900 ms):
97
+ // as many as the browser shows, which it says with `activityCap` (its "Tool
98
+ // lines shown" setting); a request without it (an older page, a paired
99
+ // relay's read) gets the default 50; the setting's "no limit" asks for the
100
+ // most the relay sends, 500.
101
+ export const IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD = 500;
102
+ // A finished one (the message payload): the main agent's newest 200 (the
103
+ // stream view lists them), each sub-agent's newest 50 (its own record is in
104
+ // the sub-agent viewer).
105
+ export const FINISHED_ACTIVITY_LIMIT_PER_THREAD = 200;
106
+ export const FINISHED_SUBAGENT_ACTIVITY_LIMIT_PER_THREAD = 50;
107
+
108
+ /** The in-flight cap a request asks for: `activityCap` as 1…500, 0 → 500, else 50. */
109
+ export function inFlightActivityCap(value) {
110
+ const text = String(value ?? '').trim();
111
+ if (!/^\d+$/.test(text)) return LIVE_ACTIVITY_CAP;
112
+ const cap = Number(text);
113
+ if (cap === 0) return IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD;
114
+ return Math.min(IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD, cap);
46
115
  }
47
116
 
48
117
  // A compaction is published in up to two steps on the same message: a
@@ -118,56 +187,99 @@ export function visibleActivityEntries(items, promoted) {
118
187
  });
119
188
  }
120
189
 
121
- // Head-cap that never drops structured rows. Activity lists are capped from
122
- // the front (the start of a turn is the interesting part), but a
190
+ // The relay's cap of a turn's activity rows, which never drops structured
191
+ // rows. Each thread (the main agent, each sub-agent) keeps its newest `limit`
192
+ // prose rows: a busy sub-agent no longer pushes the main agent's lines out,
193
+ // and the latest lines are the ones a reader of a long turn is after. A
123
194
  // metadata-bearing row carries transcript structure rather than prose, and a
124
195
  // long agentic turn can push its compaction boundary past the cap — losing
125
196
  // the break row entirely, with no prose fallback since the bubble filters
126
- // compact entries out. Keep every structured row, spend the rest of the
127
- // budget on the leading prose rows, preserve the original order.
128
- export function capRelayActivityEntries(items, limit = DEFAULT_ACTIVITY_LIMIT) {
197
+ // compact entries out. Keep every structured row, preserve the original
198
+ // order. Returns a new array. `subagentLimit` is a sub-agent thread's own
199
+ // limit (the finished payload keeps fewer of those).
200
+ //
201
+ // `omittedMarker`: when the main agent's rows were cut, a structured row
202
+ // saying how many (kind ACTIVITY_OMITTED_KIND) goes right before its first
203
+ // kept row, so a reader of the finished turn sees that its list starts late.
204
+ export function capRelayActivityEntries(items, limit, { subagentLimit = limit, omittedMarker = false } = {}) {
129
205
  const list = Array.isArray(items) ? items : [];
130
- const max = Math.max(1, Math.trunc(Number(limit)) || DEFAULT_ACTIVITY_LIMIT);
131
- if (list.length <= max) return list.slice();
132
- const structuredIndexes = [];
133
- for (let index = 0; index < list.length; index += 1) {
134
- if (normalizeRelayActivityEntry(list[index])?.metadata) structuredIndexes.push(index);
135
- }
136
- if (!structuredIndexes.length) return list.slice(0, max);
206
+ const max = Math.max(1, Math.trunc(Number(limit)) || LIVE_ACTIVITY_CAP);
207
+ const subagentMax = Math.max(1, Math.trunc(Number(subagentLimit)) || max);
208
+ const isStructured = (item) => !!normalizeRelayActivityEntry(item)?.metadata;
209
+ const structured = list.filter(isStructured);
137
210
  // Safety valve for a pathological turn with more structured rows than the
138
211
  // cap allows: the most recent ones win.
139
- const keep = new Set(structuredIndexes.slice(-max));
140
- let budget = max - keep.size;
141
- const kept = [];
142
- for (let index = 0; index < list.length; index += 1) {
143
- if (keep.has(index)) {
144
- kept.push(list[index]);
145
- continue;
146
- }
147
- if (budget <= 0) continue;
148
- budget -= 1;
149
- kept.push(list[index]);
212
+ let source = list;
213
+ if (structured.length > max) {
214
+ const keep = new Set(structured.slice(-max));
215
+ source = list.filter((item) => !isStructured(item) || keep.has(item));
150
216
  }
151
- return kept;
217
+ const capped = capNewestPerThread(source, max, isStructured, subagentMax);
218
+ if (capped === list) return list.slice();
219
+ if (!omittedMarker) return capped;
220
+ const isMainProse = (item) => !isStructured(item) && !activityThreadKey(item);
221
+ const kept = new Set(capped);
222
+ const dropped = source.filter((item) => isMainProse(item) && !kept.has(item));
223
+ if (!dropped.length) return capped;
224
+ const at = capped.findIndex(isMainProse);
225
+ // Where the first line left out was in the turn's order, when it had one.
226
+ const fromTurnSeq = normalizeRelayActivityEntry(dropped[0])?.turnSeq ?? null;
227
+ const marker = omittedActivityMarker(dropped.length, fromTurnSeq);
228
+ return at === -1 ? [...capped, marker] : [...capped.slice(0, at), marker, ...capped.slice(at)];
229
+ }
230
+
231
+ // The row that stands for the main agent's tool lines a finished turn's cap
232
+ // left out. It has no turn order of its own; `fromTurnSeq` is the place of
233
+ // the first line it stands for, where the stream view puts it.
234
+ export const ACTIVITY_OMITTED_KIND = 'activity_omitted';
235
+
236
+ export function omittedActivityMarker(count, fromTurnSeq = null) {
237
+ const n = Math.max(1, Math.trunc(Number(count)) || 1);
238
+ const from = normalizeTurnSeq(fromTurnSeq);
239
+ return {
240
+ text: `… ${n} earlier tool ${n === 1 ? 'line' : 'lines'} omitted`,
241
+ subagentRunId: null,
242
+ metadata: { kind: ACTIVITY_OMITTED_KIND, count: n, ...(from !== null ? { fromTurnSeq: from } : {}) },
243
+ };
244
+ }
245
+
246
+ export function isOmittedActivityMarker(item) {
247
+ return normalizeRelayActivityEntry(item)?.metadata?.kind === ACTIVITY_OMITTED_KIND;
248
+ }
249
+
250
+ // The finished turn's cap, as the relay sends it and the bubble reads it; a
251
+ // cut in the main agent's lines is marked.
252
+ export function capFinishedActivityEntries(items) {
253
+ return capRelayActivityEntries(items, FINISHED_ACTIVITY_LIMIT_PER_THREAD, {
254
+ subagentLimit: FINISHED_SUBAGENT_ACTIVITY_LIMIT_PER_THREAD,
255
+ omittedMarker: true,
256
+ });
152
257
  }
153
258
 
154
259
  export function relayActivityEntryText(item) {
155
260
  return normalizeRelayActivityEntry(item)?.text || '';
156
261
  }
157
262
 
158
- function normalizeActivityItems(items, limit = DEFAULT_ACTIVITY_LIMIT) {
263
+ // The live list's rule: the newest `cap` ordinary lines of each thread, the
264
+ // structured rows kept (capLiveActivityEntries); Infinity or 0 keeps all.
265
+ function normalizeActivityItems(items, cap = LIVE_ACTIVITY_CAP) {
159
266
  const normalized = Array.isArray(items)
160
267
  ? items.map((item) => normalizeRelayActivityEntry(item)).filter(Boolean)
161
268
  : [];
162
- return normalized.slice(-Math.max(1, Number(limit) || DEFAULT_ACTIVITY_LIMIT));
269
+ return capLiveActivityEntries(normalized, cap);
163
270
  }
164
271
 
272
+ // A numbered row is the row with that number (the same tool line twice is two
273
+ // rows; a replayed event is one); an unnumbered one is known by its text.
165
274
  function activityEntryKey(item) {
166
275
  const entry = normalizeRelayActivityEntry(item);
167
276
  if (!entry) return '';
277
+ if (entry.turnSeq !== undefined) return `#${entry.turnSeq}`;
168
278
  return `${entry.subagentRunId || ''}::${entry.text}`;
169
279
  }
170
280
 
281
+ const isNumbered = (item) => normalizeRelayActivityEntry(item)?.turnSeq !== undefined;
282
+
171
283
  function isSubsequence(subset, sequence) {
172
284
  if (!subset.length) return true;
173
285
  let cursor = 0;
@@ -178,11 +290,17 @@ function isSubsequence(subset, sequence) {
178
290
  return false;
179
291
  }
180
292
 
181
- export function mergeRelayActivityTexts(existingItems, incomingItems, limit = DEFAULT_ACTIVITY_LIMIT) {
293
+ export function mergeRelayActivityTexts(existingItems, incomingItems, limit = LIVE_ACTIVITY_CAP) {
182
294
  const existing = normalizeActivityItems(existingItems, Number.POSITIVE_INFINITY);
183
295
  const incoming = normalizeActivityItems(incomingItems, Number.POSITIVE_INFINITY);
184
296
  if (!existing.length) return normalizeActivityItems(incoming, limit);
185
297
  if (!incoming.length) return normalizeActivityItems(existing, limit);
298
+ // Both sides numbered (migration 0038): the union by number, in order.
299
+ if (existing.every(isNumbered) && incoming.every(isNumbered)) {
300
+ const byTurnSeq = new Map();
301
+ for (const item of [...existing, ...incoming]) byTurnSeq.set(item.turnSeq, item);
302
+ return normalizeActivityItems([...byTurnSeq.values()].sort((a, b) => a.turnSeq - b.turnSeq), limit);
303
+ }
186
304
  if (isSubsequence(existing, incoming)) return normalizeActivityItems(incoming, limit);
187
305
  if (isSubsequence(incoming, existing)) return normalizeActivityItems(existing, limit);
188
306
 
@@ -1,4 +1,5 @@
1
1
  import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
+ import { liveActivityCap } from './live-activity-cap.mjs';
2
3
 
3
4
  // fetch() has no default timeout. Without one, a request issued while the app is
4
5
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -981,8 +982,16 @@ export async function loadConversation(id, options = {}) {
981
982
  if (afterTimestamp) params.set('afterTimestamp', afterTimestamp);
982
983
  const aroundMessageId = String(options.aroundMessageId || '').trim();
983
984
  if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
985
+ // The running turn's tool lines per agent this page shows ("Tool lines
986
+ // shown"; 0 = no limit): the relay sends no more of them.
987
+ params.set('activityCap', String(liveActivityCap()));
988
+ // The live poll's finished replies it already holds (held-details.mjs).
989
+ const heldDetails = String(options.heldDetails || '');
990
+ if (heldDetails) params.set('heldDetails', heldDetails);
984
991
  const query = params.toString();
985
- return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`);
992
+ // `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
993
+ const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
994
+ return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`, onErrorResponse ? { onErrorResponse } : undefined);
986
995
  }
987
996
 
988
997
  export async function refreshConversationHistory(id, options = {}) {
@@ -1247,7 +1256,7 @@ export async function createConversationShareLink(id) {
1247
1256
  });
1248
1257
  }
1249
1258
 
1250
- // Share management (Settings → Shared conversations, the ⋯ menu). These throw
1259
+ // Share management (Settings → Shared conversations, the ︙ menu). These throw
1251
1260
  // with the relay's reason, so the caller can say why an action failed.
1252
1261
  export async function listConversationShares() {
1253
1262
  return apiFetchOrThrow('/api/shares');
@@ -16,7 +16,8 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
16
16
  import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
17
  import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
19
- import { renderSessionAskCards } from './inbox-ui.js';
19
+ import { renderQuestionStubs } from './questions-panel-ui.js';
20
+ import { isConversationAutoOpenHeld } from './session-history-ui.js';
20
21
 
21
22
  let relayQuestionRenderHash = '';
22
23
  let renderedRelayQuestionIds = new Set();
@@ -124,13 +125,20 @@ export async function loadRelayQuestions(conversationId) {
124
125
  window.renderConvList?.();
125
126
  window.syncComposerButtonState?.();
126
127
 
127
- if (!conversationId && pending.length) {
128
+ // Nothing open: the latest question's conversation opens. Not from the
129
+ // Inbox or Status, which show nothing open too: they are being read. And
130
+ // in place of the empty screen, not as a step of its own in history. Nor
131
+ // after a link the relay did not know: its notice keeps the screen empty
132
+ // until the reader opens something.
133
+ const readingAView = document.body?.classList?.contains('inbox-view-active')
134
+ || document.body?.classList?.contains('status-view-active');
135
+ if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
128
136
  const latest = pending
129
137
  .slice()
130
138
  .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
131
139
  const targetConversationId = String(latest?.conversationId || '').trim();
132
140
  if (targetConversationId && targetConversationId !== currentConvId && conversations[targetConversationId]) {
133
- await window.openConversation?.(targetConversationId);
141
+ await window.openConversation?.(targetConversationId, { history: 'replace' });
134
142
  return;
135
143
  }
136
144
  }
@@ -171,10 +179,16 @@ export function updatePendingQuestionBanner() {
171
179
  banner.dataset.targetQuestionId = String(latest?.id || '').trim();
172
180
  const count = pending.length;
173
181
  const currentCount = currentConvId ? pending.filter((q) => q?.conversationId === currentConvId).length : 0;
174
- const convTitle = escHtml(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
175
- banner.innerHTML = currentCount > 0
176
- ? `❓ ${count} open question${count === 1 ? '' : 's'}`
177
- : `❓ ${count} open question${count === 1 ? '' : 's'} · latest in ${convTitle}`;
182
+ const rawTitle = String(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
183
+ const countText = `❓ ${count} open question${count === 1 ? '' : 's'}`;
184
+ const tailText = currentCount > 0 ? '' : ` · latest in ${rawTitle}`;
185
+ // The full text, and the compact chip "❓ N" the header fit switches to
186
+ // (data-compact) where the row is short. Either way the button's name and
187
+ // tooltip are the full text.
188
+ banner.innerHTML = `<span class="pending-question-full"><span class="pending-question-count">${escHtml(countText)}</span>${tailText ? `<span class="pending-question-tail">${escHtml(tailText)}</span>` : ''}</span>`
189
+ + `<span class="pending-question-compact" aria-hidden="true">❓ ${count}</span>`;
190
+ banner.title = `${countText}${tailText}`;
191
+ banner.setAttribute('aria-label', `${countText}${tailText}`);
178
192
  banner.classList.add('visible');
179
193
  banner.disabled = false;
180
194
  }
@@ -216,8 +230,9 @@ function scrollQuestionCardIntoMessages(el, target) {
216
230
  export function renderRelayQuestions() {
217
231
  const el = document.getElementById('messages');
218
232
  if (!el) return;
219
- // The questions that don't block go first: the cards a turn waits on stay last.
220
- renderSessionAskCards();
233
+ // The questions that don't block: their 📓 stubs, in their turns' bubbles
234
+ // or standing alone before the cards a turn waits on.
235
+ renderQuestionStubs();
221
236
  const previousScrollTop = el.scrollTop;
222
237
  // A card is shown to a reader who is at the end. Anyone further up stays
223
238
  // where they are; the jump-to-bottom button says that a question waits.
@@ -277,7 +277,7 @@ export function renderAttachmentPreview() {
277
277
  const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
278
278
  const thumb = att.isImage && att.previewUrl
279
279
  ? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
280
- : `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
280
+ : `<div class="attachment-preview-meta" style="height:calc(88px * var(--ui-scale));display:flex;align-items:center;justify-content:center">📎</div>`;
281
281
  const annotateBadge = annotatable
282
282
  ? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
283
283
  : '';
@@ -9,7 +9,26 @@
9
9
 
10
10
  export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
11
11
  export const BACK_GUARD_APP = 'oarApp';
12
- const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
12
+ // Every entry the app writes carries its place in the stack, so a pop tells
13
+ // back from forward (session-history.mjs, viewer-history.mjs).
14
+ export const HISTORY_POSITION = 'oarPos';
15
+ // The visit's base (the floor, the phone's session list) is laid: a reload
16
+ // on any later entry must not lay it again.
17
+ export const VISIT_LAID = 'oarLaid';
18
+ // The place in the stack of the visit's lowest own entry (the phone's list,
19
+ // else the first session): back from above it stays in the app.
20
+ export const VISIT_BASE = 'oarBase';
21
+ export const FIRST_INTERACTION_EVENTS = Object.freeze(['pointerdown', 'keydown', 'touchstart']);
22
+
23
+ /** The marks every later entry of the visit carries over from the one below. */
24
+ export function inheritedVisitMarks(state) {
25
+ const marks = {};
26
+ if (!state || typeof state !== 'object') return marks;
27
+ if (state[BACK_GUARD_APP]) marks[BACK_GUARD_APP] = true;
28
+ if (state[VISIT_LAID]) marks[VISIT_LAID] = state[VISIT_LAID];
29
+ if (Number.isFinite(state[VISIT_BASE])) marks[VISIT_BASE] = state[VISIT_BASE];
30
+ return marks;
31
+ }
13
32
 
14
33
  /**
15
34
  * Lay the floor at the first touch or key, not at start-up: an entry a page
@@ -19,13 +38,13 @@ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
19
38
  * interaction nothing a page does keeps back from leaving; from then on
20
39
  * the floor holds.
21
40
  */
22
- export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
41
+ export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null, entryAgain = null } = {}) {
23
42
  if (!enabled || !history || !target) return null;
24
43
  const state = { guard: null };
25
44
  const arm = () => {
26
45
  for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
27
46
  if (state.guard) return;
28
- state.guard = installBackGuard({ history, enabled: true, onFloor });
47
+ state.guard = installBackGuard({ history, enabled: true, onFloor, entryAgain });
29
48
  target.addEventListener('popstate', () => state.guard.onPopState());
30
49
  };
31
50
  for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
@@ -38,8 +57,12 @@ export function armBackGuardOnFirstInteraction({ history, enabled = true, target
38
57
  * lets the next back leave unless the user touches the app in between:
39
58
  * one swallowed press, then the app closes — the pattern phones use, and
40
59
  * the moment to say "press back again to close".
60
+ *
61
+ * `entryAgain` names the entry pushed back above the floor: `{ state, url }`
62
+ * (the session list on a phone, else the session on screen). Without it a
63
+ * bare app entry goes back up.
41
64
  */
42
- export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
65
+ export function installBackGuard({ history, enabled = true, onFloor = null, entryAgain = null } = {}) {
43
66
  if (!enabled || !history) return null;
44
67
  const current = history.state && typeof history.state === 'object' ? history.state : {};
45
68
  // After a reload on the app's entry the floor already lies beneath it.
@@ -51,7 +74,10 @@ export function installBackGuard({ history, enabled = true, onFloor = null } = {
51
74
  /** Feed every popstate here. */
52
75
  onPopState() {
53
76
  if (!history.state?.[BACK_GUARD_FLOOR]) return;
54
- history.pushState({ [BACK_GUARD_APP]: true }, '');
77
+ const again = typeof entryAgain === 'function' ? entryAgain() : null;
78
+ if (again?.state && again.url) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '', again.url);
79
+ else if (again?.state) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '');
80
+ else history.pushState({ [BACK_GUARD_APP]: true }, '');
55
81
  onFloor?.();
56
82
  },
57
83
  };