@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.
- package/CHANGELOG.md +113 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +9 -4
- package/server/claude-worker/claude-session-worker-main.mjs +10 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
- package/server/cursor-worker/cursor-session-worker.mjs +9 -1
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +1 -0
- package/server/grok-worker/grok-session-worker.mjs +10 -1
- package/server/grok-worker/grok-turn-runner.mjs +1 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +7 -0
- package/server/public/app/ask-user-view.js +4 -3
- package/server/public/app/bootstrap.js +62 -4
- package/server/public/app/chat-actions-menu-state.mjs +2 -0
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-view.js +306 -49
- package/server/public/app/header-priority-actions.mjs +5 -1
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/inbox-ui.js +70 -79
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +17 -0
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/session-history-ui.js +1 -0
- package/server/public/app/settings-modal.js +6 -0
- package/server/public/app/socket-handlers.js +42 -11
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +8 -6
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/index.html +391 -214
- package/server/repositories/message-repository.mjs +10 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/messages-routes.mjs +35 -14
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +76 -5
- package/server/server-runtime.mjs +178 -131
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +143 -15
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +26 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
package/server/db-schema.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
122
|
-
//
|
|
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,
|
|
127
|
-
//
|
|
128
|
-
|
|
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)) ||
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
|
234
|
-
|
|
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.
|