@oar-sh/oar 0.9.21 → 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 (69) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  6. package/server/claude-worker/claude-session-process.mjs +9 -4
  7. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  9. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  10. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  11. package/server/db-schema.mjs +19 -0
  12. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  13. package/server/grok-worker/grok-session-worker.mjs +10 -1
  14. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  15. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  16. package/server/migrations/0038-turn-seq.mjs +92 -0
  17. package/server/public/app/activity-replay-state.mjs +160 -42
  18. package/server/public/app/api-client.js +7 -0
  19. package/server/public/app/ask-user-view.js +4 -3
  20. package/server/public/app/bootstrap.js +62 -4
  21. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  22. package/server/public/app/context-usage-view.mjs +68 -0
  23. package/server/public/app/conversation-view.js +306 -49
  24. package/server/public/app/header-priority-actions.mjs +5 -1
  25. package/server/public/app/held-details.mjs +202 -0
  26. package/server/public/app/inbox-ui.js +70 -79
  27. package/server/public/app/inbox-view.mjs +35 -19
  28. package/server/public/app/journal-view.js +17 -0
  29. package/server/public/app/live-activity-cap.mjs +103 -0
  30. package/server/public/app/questions-panel-ui.js +634 -0
  31. package/server/public/app/questions-stub-view.mjs +40 -0
  32. package/server/public/app/questions-view.mjs +89 -0
  33. package/server/public/app/relay-board-view.js +12 -2
  34. package/server/public/app/relay-thoughts.mjs +15 -1
  35. package/server/public/app/session-history-ui.js +1 -0
  36. package/server/public/app/settings-modal.js +6 -0
  37. package/server/public/app/socket-handlers.js +42 -11
  38. package/server/public/app/stream-view-preference.mjs +60 -0
  39. package/server/public/app/subagent-fold.mjs +6 -3
  40. package/server/public/app/subagent-timeline-view.mjs +12 -5
  41. package/server/public/app/subagent-viewer.mjs +8 -6
  42. package/server/public/app/thoughts-preference.mjs +79 -0
  43. package/server/public/app/turn-stream-view.mjs +124 -0
  44. package/server/public/index.html +391 -214
  45. package/server/repositories/message-repository.mjs +10 -0
  46. package/server/repositories/question-repository.mjs +51 -7
  47. package/server/repositories/relay-turn-rows.mjs +56 -0
  48. package/server/routes/ask-user-routes.mjs +34 -5
  49. package/server/routes/messages-routes.mjs +35 -14
  50. package/server/routes/relay-ask-routes.mjs +15 -5
  51. package/server/routes/sessions-routes.mjs +76 -5
  52. package/server/server-runtime.mjs +178 -131
  53. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  54. package/server/services/idle-timers.mjs +184 -0
  55. package/server/services/queue-wake-hook.mjs +145 -0
  56. package/server/services/queue-waker.mjs +127 -0
  57. package/server/services/relay-ask-service.mjs +64 -5
  58. package/server/services/relay-event-service.mjs +4 -1
  59. package/server/services/remote-relay-dispatcher.mjs +23 -4
  60. package/server/services/session-history-refresh-service.mjs +4 -0
  61. package/server/services/session-worker-supervisor-service.mjs +36 -0
  62. package/server/services/session-worker-websocket-service.mjs +143 -15
  63. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  64. package/shared/ask-user-bridge.mjs +9 -6
  65. package/shared/relay-asks.mjs +10 -0
  66. package/shared/relay-events.mjs +1 -0
  67. package/shared/worker-runtime/heartbeat.mjs +26 -0
  68. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  69. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
@@ -42,6 +42,8 @@ import { migrateClaudeApiUsageCheckpoints } from './migrations/0033-claude-api-u
42
42
  import { migrateClaudeApiUsageTotals } from './migrations/0034-claude-api-usage-totals.mjs';
43
43
  import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan-only.mjs';
44
44
  import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
45
+ import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
46
+ import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
45
47
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
46
48
 
47
49
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -1077,6 +1079,23 @@ try {
1077
1079
  console.warn(`[token-usage] migration 0036 failed; retrying on next boot: ${error?.message || error}`);
1078
1080
  }
1079
1081
 
1082
+ // The turn an ask was made in: one column on 0023's table. The ask service
1083
+ // works without it, so a failure is logged and retried next boot like 0023.
1084
+ try {
1085
+ migrateRelayAsksQueueId(db);
1086
+ } catch (error) {
1087
+ console.warn(`[relay-asks] migration 0037 failed; retrying on next boot: ${error?.message || error}`);
1088
+ }
1089
+
1090
+ // One order across a turn's thoughts and tool lines (the stream view). The
1091
+ // repository checks for the column and works without it (no numbers, the
1092
+ // grouped layout), so a failure is logged and retried next boot.
1093
+ try {
1094
+ migrateTurnSeq(db);
1095
+ } catch (error) {
1096
+ console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
1097
+ }
1098
+
1080
1099
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
1081
1100
  if (!conversationColumns.includes('archived')) {
1082
1101
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
@@ -248,6 +248,7 @@ export function createGrokTaskPublisher({
248
248
  resync: () => publish({ resync: true }),
249
249
  /** The relay answered a heartbeat: settle notices kept for it go out. */
250
250
  flushKeptSettles: () => keptSettles.flush(),
251
+ keptSettleCount: () => keptSettles.size(),
251
252
  sweep,
252
253
  close() {
253
254
  clearInterval(sweepTimer);
@@ -12,6 +12,7 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
15
16
  import { resolveBuildId } from '../../shared/build-id.mjs';
16
17
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
17
18
  import { createControlPoller } from '../../shared/control-poller.mjs';
@@ -113,13 +114,19 @@ async function main() {
113
114
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
114
115
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
115
116
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
117
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
116
118
  // No steering here; the snapshot tells the relay when it may replace
117
119
  // this worker: no turn, and no open terminal (they end with the worker).
118
120
  getSteeringState: () => buildGrokSteeringState(turnRunner),
119
121
  });
120
122
 
123
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
124
+ // while working, 30 s while idle on a relay that allows it.
125
+ let keepalive = null;
121
126
  const wsLink = createWorkerWebSocketLink({
122
127
  serverUrl,
128
+ readyRefreshMs: 0,
129
+ onRelayFrame: () => keepalive?.nudge(),
123
130
  token,
124
131
  dbg,
125
132
  buildId,
@@ -154,10 +161,12 @@ async function main() {
154
161
  dbg('unknown worker control ignored', type || '(none)');
155
162
  },
156
163
  });
164
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
157
165
 
158
166
  const shutdown = async (signal) => {
159
167
  dbg(`shutting down (${signal})`);
160
168
  try { wsLink.stop(); } catch {}
169
+ try { keepalive.stop(); } catch {}
161
170
  try { heartbeat.stopHeartbeat(); } catch {}
162
171
  try { controlPoller.stop(); } catch {}
163
172
  try { taskPublisher.close(); } catch {}
@@ -183,7 +192,7 @@ async function main() {
183
192
  });
184
193
 
185
194
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
186
- heartbeat.startHeartbeat();
195
+ keepalive.start();
187
196
  wsLink.start();
188
197
  }
189
198
 
@@ -673,6 +673,7 @@ export function createGrokTurnRunner({
673
673
  resyncBackgroundTasks: () => terminalTasks?.resync(),
674
674
  stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
675
675
  flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
676
+ keptTaskSettleCount: () => terminalTasks?.keptSettleCount?.() || 0,
676
677
  setTurnCeilingMs(value) {
677
678
  const ms = Number(value);
678
679
  turnCeilingMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the turn an ask was made in.
5
+ *
6
+ * `relay_asks.queue_id`: the queue row of the turn that was live in the
7
+ * asking conversation when its agent asked (services/relay-ask-service.mjs
8
+ * create), so the browser can show the ask in that turn's bubble. NULL for
9
+ * an ask made on behalf of another session, one a paired relay's agent made
10
+ * here, a mirror, and every ask from before this migration. No index: it is
11
+ * read with the row, never looked up by.
12
+ *
13
+ * Not one of RELAY_ASK_COLUMNS (0023): the ask service works without it and
14
+ * then stores nothing.
15
+ *
16
+ * Additive and idempotent. Safe to run on every boot; nothing to do while
17
+ * there is no relay_asks table (0023 not run yet). Rolling back needs no
18
+ * data change: older code ignores the column.
19
+ *
20
+ * Usage: node server/migrations/0037-relay-asks-queue-id.mjs [path/to/oar.db]
21
+ */
22
+
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+ import process from 'process';
26
+ import Database from 'better-sqlite3';
27
+ import { cliDbPath } from '../services/database-file.mjs';
28
+
29
+ function tableExists(db, name) {
30
+ return !!db
31
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
32
+ .get(name);
33
+ }
34
+
35
+ export function migrateRelayAsksQueueId(db) {
36
+ if (!tableExists(db, 'relay_asks')) return { applied: false, columnsAdded: 0 };
37
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_asks)`).all().map((column) => column.name));
38
+ if (present.has('queue_id')) return { applied: false, columnsAdded: 0 };
39
+ db.exec(`ALTER TABLE relay_asks ADD COLUMN queue_id TEXT`);
40
+ return { applied: true, columnsAdded: 1 };
41
+ }
42
+
43
+ export function migrate(dbPath, options = {}) {
44
+ const db = new Database(dbPath, options);
45
+ try {
46
+ return migrateRelayAsksQueueId(db);
47
+ } finally {
48
+ db.close();
49
+ }
50
+ }
51
+
52
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
53
+ if (isMain) {
54
+ try {
55
+ // The given path, else the database in server/data (oar.db, else the
56
+ // name before 0.9.20); never a new one.
57
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
58
+ const result = migrate(dbPath);
59
+ console.log(`Migration 0037 on ${dbPath}`);
60
+ console.log(result.applied ? ` relay asks: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
61
+ process.exit(0);
62
+ } catch (err) {
63
+ console.error(`Migration 0037 failed: ${err?.message || err}`);
64
+ process.exit(1);
65
+ }
66
+ }
@@ -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,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,6 +982,12 @@ 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
992
  // `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
986
993
  const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
@@ -16,7 +16,7 @@ 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
20
  import { isConversationAutoOpenHeld } from './session-history-ui.js';
21
21
 
22
22
  let relayQuestionRenderHash = '';
@@ -230,8 +230,9 @@ function scrollQuestionCardIntoMessages(el, target) {
230
230
  export function renderRelayQuestions() {
231
231
  const el = document.getElementById('messages');
232
232
  if (!el) return;
233
- // The questions that don't block go first: the cards a turn waits on stay last.
234
- 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();
235
236
  const previousScrollTop = el.scrollTop;
236
237
  // A card is shown to a reader who is at the end. Anyone further up stays
237
238
  // where they are; the jump-to-bottom button says that a question waits.