@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.
- package/CHANGELOG.md +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -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/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +309 -49
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- 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-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- 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 +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- 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 +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- 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
|
+
}
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|