@oar-sh/oar 0.9.21 → 0.9.23
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 +206 -0
- package/README.md +9 -9
- package/bin/oar.js +28 -0
- package/package.json +13 -14
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -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 +46 -34
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +77 -6
- package/server/server-runtime.mjs +199 -171
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/earlier-database-service.mjs +1 -1
- package/server/services/file-serving.mjs +13 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +7 -4
- 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-console-log.mjs +2 -3
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- 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/server/services/update-install-service.mjs +32 -1
- 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/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- package/server/tty-console-bootstrap.mjs +0 -75
|
@@ -361,6 +361,16 @@ export function createMessageRepository(db) {
|
|
|
361
361
|
LIMIT ?
|
|
362
362
|
`),
|
|
363
363
|
countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') ${notHeldForUsageLimit()} GROUP BY status`),
|
|
364
|
+
// Stale recovery has nothing to look at while no row is processing.
|
|
365
|
+
hasProcessingRow: db.prepare(`SELECT 1 AS present FROM queue WHERE status = 'processing' LIMIT 1`),
|
|
366
|
+
// The earliest future retry time of a pending row (a backoff, a
|
|
367
|
+
// usage-limit hold): the relay wakes its queue then. A hold until the
|
|
368
|
+
// user resumes (year 9999) is no time.
|
|
369
|
+
nextPendingAttemptAt: db.prepare(`
|
|
370
|
+
SELECT MIN(next_attempt_at) AS due FROM queue
|
|
371
|
+
WHERE status = 'pending' AND next_attempt_at IS NOT NULL
|
|
372
|
+
AND next_attempt_at > ? AND next_attempt_at < '9999'
|
|
373
|
+
`),
|
|
364
374
|
// Deferred host suspend: which conversations still hold live turns.
|
|
365
375
|
listActiveQueueCountsByConversation: db.prepare(`
|
|
366
376
|
SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
|
|
@@ -1,12 +1,48 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { createSubagentTimelineStatements } from './subagent-timeline-statements.mjs';
|
|
4
|
+
import { hasTurnSeqColumn } from '../migrations/0038-turn-seq.mjs';
|
|
5
|
+
|
|
6
|
+
const nextTurnSeqStatements = new WeakMap();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The next number of a turn's shared order (migration 0038): one more than
|
|
10
|
+
* the highest turn_seq of its thoughts and tool lines together, 1 for a turn
|
|
11
|
+
* that has none. Call it inside the write transaction that inserts the row
|
|
12
|
+
* (BEGIN IMMEDIATE takes the write lock before this read), so two writers
|
|
13
|
+
* never get the same number.
|
|
14
|
+
*/
|
|
15
|
+
export function nextTurnSeq(db, queueMessageId) {
|
|
16
|
+
let statement = nextTurnSeqStatements.get(db);
|
|
17
|
+
if (!statement) {
|
|
18
|
+
statement = db.prepare(`
|
|
19
|
+
SELECT MAX(
|
|
20
|
+
COALESCE((SELECT MAX(turn_seq) FROM relay_activity WHERE queue_message_id = @id AND turn_seq IS NOT NULL), 0),
|
|
21
|
+
COALESCE((SELECT MAX(turn_seq) FROM relay_thought WHERE queue_message_id = @id AND turn_seq IS NOT NULL), 0)
|
|
22
|
+
) AS max_seq
|
|
23
|
+
`);
|
|
24
|
+
nextTurnSeqStatements.set(db, statement);
|
|
25
|
+
}
|
|
26
|
+
return Math.max(0, Number(statement.get({ id: String(queueMessageId) })?.max_seq || 0)) + 1;
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
export function createQuestionRepository(db) {
|
|
6
30
|
const subagentTimeline = createSubagentTimelineStatements(db);
|
|
7
31
|
const runColumns = subagentTimeline.subagentRunSelectColumns;
|
|
8
32
|
const runColumnsAs = (alias) => runColumns.split(', ').map((column) => `${alias}.${column}`).join(', ');
|
|
33
|
+
// Migration 0038 may have failed (it is retried next boot): without the
|
|
34
|
+
// column, rows are written without a number and read back without one,
|
|
35
|
+
// and every turn keeps the grouped layout.
|
|
36
|
+
const turnSeq = hasTurnSeqColumn(db, 'relay_activity') && hasTurnSeqColumn(db, 'relay_thought');
|
|
37
|
+
// The insert statements take turn_seq as their last value, which a caller
|
|
38
|
+
// may leave out (null); a database without the column drops it.
|
|
39
|
+
const fitValues = (statement, count) => ({
|
|
40
|
+
run: (...values) => statement.run(...values.slice(0, count), ...Array(Math.max(0, count - values.length)).fill(null)),
|
|
41
|
+
});
|
|
9
42
|
return {
|
|
43
|
+
hasTurnSeq: turnSeq,
|
|
44
|
+
// The next turn_seq of a queue message, or null without the column.
|
|
45
|
+
nextTurnSeq: (queueMessageId) => (turnSeq ? nextTurnSeq(db, queueMessageId) : null),
|
|
10
46
|
...subagentTimeline,
|
|
11
47
|
// relay questions
|
|
12
48
|
insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at, attempt_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)`),
|
|
@@ -27,10 +63,14 @@ export function createQuestionRepository(db) {
|
|
|
27
63
|
expireQuestions: db.prepare(`UPDATE relay_questions SET status = 'timed_out' WHERE status = 'pending' AND expires_at < ? RETURNING id, created_at`),
|
|
28
64
|
|
|
29
65
|
// relay activity
|
|
30
|
-
|
|
66
|
+
// (queue_message_id, response_message_id, conversation_id, relay_mode,
|
|
67
|
+
// text, created_at, subagent_run_id, metadata_json, turn_seq)
|
|
68
|
+
insertActivity: turnSeq
|
|
69
|
+
? fitValues(db.prepare(`INSERT INTO relay_activity (queue_message_id, response_message_id, conversation_id, relay_mode, text, created_at, subagent_run_id, metadata_json, turn_seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), 9)
|
|
70
|
+
: fitValues(db.prepare(`INSERT INTO relay_activity (queue_message_id, response_message_id, conversation_id, relay_mode, text, created_at, subagent_run_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), 8),
|
|
31
71
|
linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
|
|
32
|
-
listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
|
|
33
|
-
listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
|
|
72
|
+
listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json${turnSeq ? ', turn_seq' : ''} FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
|
|
73
|
+
listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json${turnSeq ? ', turn_seq' : ''} FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
|
|
34
74
|
// When a queue row last produced output — its newest stream snapshot
|
|
35
75
|
// (rows are rewritten in place and re-stamped per update) or activity
|
|
36
76
|
// line. The live-turn picker compares rows by this, never by the
|
|
@@ -73,8 +113,12 @@ export function createQuestionRepository(db) {
|
|
|
73
113
|
|
|
74
114
|
// relay thoughts (agent reasoning)
|
|
75
115
|
getLastThoughtSeqByQueueMessage: db.prepare(`SELECT COALESCE(MAX(seq), 0) AS max_seq FROM relay_thought WHERE queue_message_id = ?`),
|
|
76
|
-
getThoughtByQueueAndReasoning: db.prepare(`SELECT seq FROM relay_thought WHERE queue_message_id = ? AND reasoning_id = ? LIMIT 1`),
|
|
77
|
-
|
|
116
|
+
getThoughtByQueueAndReasoning: db.prepare(`SELECT seq${turnSeq ? ', turn_seq' : ''} FROM relay_thought WHERE queue_message_id = ? AND reasoning_id = ? LIMIT 1`),
|
|
117
|
+
// (queue_message_id, response_message_id, conversation_id, relay_mode,
|
|
118
|
+
// reasoning_id, seq, text, done, created_at, subagent_run_id, turn_seq)
|
|
119
|
+
insertThought: turnSeq
|
|
120
|
+
? fitValues(db.prepare(`INSERT INTO relay_thought (queue_message_id, response_message_id, conversation_id, relay_mode, reasoning_id, seq, text, done, created_at, subagent_run_id, turn_seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), 11)
|
|
121
|
+
: fitValues(db.prepare(`INSERT INTO relay_thought (queue_message_id, response_message_id, conversation_id, relay_mode, reasoning_id, seq, text, done, created_at, subagent_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), 10),
|
|
78
122
|
updateThoughtByQueueAndReasoning: db.prepare(`
|
|
79
123
|
UPDATE relay_thought
|
|
80
124
|
SET response_message_id = COALESCE(response_message_id, ?),
|
|
@@ -88,8 +132,8 @@ export function createQuestionRepository(db) {
|
|
|
88
132
|
AND reasoning_id = ?
|
|
89
133
|
`),
|
|
90
134
|
linkThoughtsToResponse: db.prepare(`UPDATE relay_thought SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
|
|
91
|
-
listThoughtsByResponse: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id FROM relay_thought WHERE response_message_id = ? ORDER BY seq ASC, id ASC`),
|
|
92
|
-
listThoughtsByQueueMessage: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id FROM relay_thought WHERE queue_message_id = ? ORDER BY seq ASC, id ASC`),
|
|
135
|
+
listThoughtsByResponse: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id${turnSeq ? ', turn_seq' : ''} FROM relay_thought WHERE response_message_id = ? ORDER BY seq ASC, id ASC`),
|
|
136
|
+
listThoughtsByQueueMessage: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id${turnSeq ? ', turn_seq' : ''} FROM relay_thought WHERE queue_message_id = ? ORDER BY seq ASC, id ASC`),
|
|
93
137
|
deleteConvThoughts: db.prepare(`DELETE FROM relay_thought WHERE conversation_id = ?`),
|
|
94
138
|
|
|
95
139
|
// subagent runs
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// A turn's stored thought and tool-line rows as the browser receives them
|
|
2
|
+
// (the message payload and the in-flight snapshot). Pure, so the payload
|
|
3
|
+
// shape is tested without booting the relay (server-runtime.mjs uses these).
|
|
4
|
+
|
|
5
|
+
export function sanitizeActivityText(value) {
|
|
6
|
+
const text = String(value || '').trim();
|
|
7
|
+
if (!text) return '';
|
|
8
|
+
return text.slice(0, 200);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A row's place in its turn's order (migration 0038), or null: a row from
|
|
12
|
+
// before it, or a database without the column.
|
|
13
|
+
function rowTurnSeq(row) {
|
|
14
|
+
if (row?.turn_seq === null || row?.turn_seq === undefined) return null;
|
|
15
|
+
const value = Number(row.turn_seq);
|
|
16
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function mapRelayActivityRow(row) {
|
|
20
|
+
const text = sanitizeActivityText(row?.text);
|
|
21
|
+
if (!text) return null;
|
|
22
|
+
const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
|
|
23
|
+
// Structured rows (compaction boundaries) carry their payload alongside the
|
|
24
|
+
// prose so the transcript can promote them to a break row; junk JSON is
|
|
25
|
+
// simply dropped back to a prose-only entry.
|
|
26
|
+
let metadata = null;
|
|
27
|
+
if (row?.metadata_json) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(String(row.metadata_json));
|
|
30
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) metadata = parsed;
|
|
31
|
+
} catch { metadata = null; }
|
|
32
|
+
}
|
|
33
|
+
// Only a numbered row says so: the payload of an unnumbered one is as before.
|
|
34
|
+
const turnSeq = rowTurnSeq(row);
|
|
35
|
+
return {
|
|
36
|
+
text,
|
|
37
|
+
subagentRunId: subagentRunId || null,
|
|
38
|
+
...(metadata ? { metadata } : {}),
|
|
39
|
+
...(turnSeq !== null ? { turnSeq } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mapRelayThoughtRow(row) {
|
|
44
|
+
const seq = Math.max(0, Math.trunc(Number(row?.seq || 0)));
|
|
45
|
+
const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
|
|
46
|
+
const turnSeq = rowTurnSeq(row);
|
|
47
|
+
return {
|
|
48
|
+
reasoningId: row?.reasoning_id ? String(row.reasoning_id) : null,
|
|
49
|
+
seq,
|
|
50
|
+
text: String(row?.text || ''),
|
|
51
|
+
done: Number(row?.done || 0) === 1,
|
|
52
|
+
timestamp: row?.created_at || null,
|
|
53
|
+
subagentRunId: subagentRunId || null,
|
|
54
|
+
...(turnSeq !== null ? { turnSeq } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
|
|
4
4
|
import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
5
5
|
import { REMOTE_RELAY_ERROR_CODES, isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
|
|
6
|
-
import { isRelayAskId, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
7
|
-
import { sendRelayAskError } from './relay-ask-routes.mjs';
|
|
6
|
+
import { isPersonOnlyQuestion, isRelayAskId, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
7
|
+
import { askForPairedRelay, sendRelayAskError } from './relay-ask-routes.mjs';
|
|
8
8
|
import { closedQuestionAnswer, questionAcceptsFreeText } from '../../shared/question-answer-rule.mjs';
|
|
9
9
|
import {
|
|
10
10
|
extractRequestedSchema,
|
|
@@ -56,6 +56,23 @@ function isRemoteRelayApprovalRow(row) {
|
|
|
56
56
|
return envelope ? isRemoteRelayApprovalQuestion(envelope) : false;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A stored question row that is a Claude session's Safety check card: an
|
|
61
|
+
* ask Claude Code marks as person-only. Only the user answers it, never an
|
|
62
|
+
* agent of this relay (the loopback client counts as remote) or another.
|
|
63
|
+
*/
|
|
64
|
+
function isPersonOnlyRow(row) {
|
|
65
|
+
const envelope = parseRowJson(row?.request);
|
|
66
|
+
return envelope ? isPersonOnlyQuestion(envelope.context) : false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PERSON_ONLY_ANSWER_REFUSAL = 'This card is the user\'s to answer: Claude Code marks the call as person-only, so no agent may answer it.';
|
|
70
|
+
|
|
71
|
+
/** A card no agent may list or answer. */
|
|
72
|
+
function isUserOnlyRow(row) {
|
|
73
|
+
return isRemoteRelayApprovalRow(row) || isPersonOnlyRow(row);
|
|
74
|
+
}
|
|
75
|
+
|
|
59
76
|
/**
|
|
60
77
|
* The choices of a stored card that takes no typed text (approval and
|
|
61
78
|
* permission cards, and a card with choices that stored no flag, as the
|
|
@@ -167,13 +184,17 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
167
184
|
const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
|
|
168
185
|
const status = String(req.query.status || 'pending').trim() || 'pending';
|
|
169
186
|
let rows = stmts.listQuestions.all(status, conversationId, conversationId);
|
|
170
|
-
if (fromRemoteRelay(req)) rows = rows.filter((row) => !
|
|
187
|
+
if (fromRemoteRelay(req)) rows = rows.filter((row) => !isUserOnlyRow(row));
|
|
171
188
|
const questions = rows.map(formatQuestionRow).map((question) => ({ ...question, kind: relayQuestionKind(question) }));
|
|
172
189
|
// Questions that don't block, on request (read_session's pendingQuestions):
|
|
173
190
|
// the cards' own readers take only what a turn waits on.
|
|
174
191
|
if (String(req.query.includeAsks || '') === '1' && relayAskService?.isAvailable?.() && status === 'pending') {
|
|
175
192
|
const asks = conversationId ? relayAskService.listOf(conversationId) : relayAskService.listHere();
|
|
176
|
-
|
|
193
|
+
// A paired relay's read_session never learns the asking turn's queue id.
|
|
194
|
+
const shown = fromRemoteRelay(req) ? askForPairedRelay : (ask) => ask;
|
|
195
|
+
questions.push(...asks
|
|
196
|
+
.filter((ask) => !ask.relayId && ask.status === 'pending' && (!conversationId || ask.conversationId === conversationId))
|
|
197
|
+
.map(shown));
|
|
177
198
|
}
|
|
178
199
|
res.json({ questions });
|
|
179
200
|
});
|
|
@@ -181,7 +202,8 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
181
202
|
app.get('/api/relay-question/:id', auth, (req, res) => {
|
|
182
203
|
if (isRelayAskId(req.params.id)) {
|
|
183
204
|
const ask = relayAskService?.get?.(req.params.id) || null;
|
|
184
|
-
|
|
205
|
+
if (!ask) return res.status(404).json({ error: 'Not found' });
|
|
206
|
+
return res.json({ question: fromRemoteRelay(req) ? askForPairedRelay(ask) : ask });
|
|
185
207
|
}
|
|
186
208
|
const row = stmts.getQuestion.get(req.params.id);
|
|
187
209
|
if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
|
|
@@ -343,6 +365,10 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
343
365
|
console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=remote-relay-approval-from-remote`);
|
|
344
366
|
return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
|
|
345
367
|
}
|
|
368
|
+
if (isPersonOnlyRow(row) && fromRemoteRelay(req)) {
|
|
369
|
+
console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=person-only-from-agent`);
|
|
370
|
+
return res.status(403).json({ error: PERSON_ONLY_ANSWER_REFUSAL, code: REMOTE_RELAY_ERROR_CODES.forbidden });
|
|
371
|
+
}
|
|
346
372
|
if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
|
|
347
373
|
|
|
348
374
|
const schema = parseQuestionRequest(row.request_schema);
|
|
@@ -418,6 +444,9 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
418
444
|
if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
|
|
419
445
|
return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
|
|
420
446
|
}
|
|
447
|
+
if (isPersonOnlyRow(row) && fromRemoteRelay(req)) {
|
|
448
|
+
return res.status(403).json({ error: PERSON_ONLY_ANSWER_REFUSAL, code: REMOTE_RELAY_ERROR_CODES.forbidden });
|
|
449
|
+
}
|
|
421
450
|
if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
|
|
422
451
|
|
|
423
452
|
const result = stmts.timeoutQuestion.run(row.id);
|
|
@@ -113,7 +113,9 @@ const OPAQUE_RESPONSE_TEXT_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-
|
|
|
113
113
|
const OPAQUE_RESPONSE_RECOVERY_WAIT_MS = 600_000;
|
|
114
114
|
const OPAQUE_RESPONSE_RECOVERY_POLL_MS = 250;
|
|
115
115
|
const STRANDED_SESSION_PRIME_COOLDOWN_MS = 10_000;
|
|
116
|
-
|
|
116
|
+
// An idle worker beats every 30 s (shared/worker-runtime/worker-keepalive.mjs):
|
|
117
|
+
// its last beat may be that old and still prove it alive.
|
|
118
|
+
export const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 45_000;
|
|
117
119
|
// Hold finalization after a relay question is answered so the SDK has time to fire the next
|
|
118
120
|
// onUserInputRequest (multi-step ask_user). Without this, the transcript is used prematurely and
|
|
119
121
|
// the queue row is marked done while additional questions are still pending.
|
|
@@ -161,6 +163,13 @@ export function shouldRequireNewOpenAIConversation({
|
|
|
161
163
|
);
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
// The path a `{*splat}` route matched: express 5 hands it over as its
|
|
167
|
+
// decoded segments, or not at all when the path ends at the slash.
|
|
168
|
+
export function splatPath(req) {
|
|
169
|
+
const splat = req.params?.splat;
|
|
170
|
+
return Array.isArray(splat) ? splat.join('/') : String(splat || '');
|
|
171
|
+
}
|
|
172
|
+
|
|
164
173
|
function normalizeRequestedProviderType(value = '') {
|
|
165
174
|
const normalized = String(value || '').trim().toLowerCase();
|
|
166
175
|
if (normalized === 'openai' || normalized === 'openai-byok' || normalized === 'openai-image') return 'openai';
|
|
@@ -2140,8 +2149,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2140
2149
|
deepseekClaudeConfigDir = '',
|
|
2141
2150
|
} = deps;
|
|
2142
2151
|
|
|
2143
|
-
const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
|
|
2144
|
-
|
|
2145
2152
|
// ─── Sub-agent runs: header, timeline, live events ─────────────────────────
|
|
2146
2153
|
const subagentTimeline = createSubagentTimelineService({ db, stmts });
|
|
2147
2154
|
|
|
@@ -2253,18 +2260,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2253
2260
|
LIMIT 1
|
|
2254
2261
|
`);
|
|
2255
2262
|
|
|
2256
|
-
function isAbnormalWorkerTelemetry(payload = {}, level = 'log') {
|
|
2257
|
-
const normalizedLevel = String(level || 'log').trim().toLowerCase();
|
|
2258
|
-
if (normalizedLevel === 'warn' || normalizedLevel === 'error') return true;
|
|
2259
|
-
const event = String(payload?.event || '').trim().toLowerCase();
|
|
2260
|
-
const state = String(payload?.state || '').trim().toLowerCase();
|
|
2261
|
-
if (state === 'error') return true;
|
|
2262
|
-
return event.includes('error')
|
|
2263
|
-
|| event.includes('failed')
|
|
2264
|
-
|| event.includes('failure')
|
|
2265
|
-
|| event.includes('blocked');
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
2263
|
function readBridgeIdentity(req) {
|
|
2269
2264
|
return relayBridgeOwnerService?.normalizeIdentity?.({
|
|
2270
2265
|
pid: req.headers['x-relay-process-pid'],
|
|
@@ -2338,8 +2333,6 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2338
2333
|
const payload = extra && typeof extra === 'object'
|
|
2339
2334
|
? { ...envelope, ...extra }
|
|
2340
2335
|
: envelope;
|
|
2341
|
-
const shouldLog = !ttyConsoleActive || isAbnormalWorkerTelemetry(payload, level);
|
|
2342
|
-
if (!shouldLog) return;
|
|
2343
2336
|
const method = typeof console[level] === 'function' ? level : 'log';
|
|
2344
2337
|
console[method](`[${ts()}] WORKER ${JSON.stringify(payload)}`);
|
|
2345
2338
|
}
|
|
@@ -4019,8 +4012,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4019
4012
|
});
|
|
4020
4013
|
});
|
|
4021
4014
|
|
|
4022
|
-
app.get('/api/files
|
|
4023
|
-
const requestedPath =
|
|
4015
|
+
app.get('/api/files/{*splat}', auth, (req, res) => {
|
|
4016
|
+
const requestedPath = splatPath(req).trim();
|
|
4024
4017
|
const rootOverride = resolveScopedWorkspaceRootPath(req);
|
|
4025
4018
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
4026
4019
|
if (!filePath) return res.status(400).json({ error: 'Invalid file path' });
|
|
@@ -4041,8 +4034,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4041
4034
|
});
|
|
4042
4035
|
});
|
|
4043
4036
|
|
|
4044
|
-
app.get('/api/files-preview
|
|
4045
|
-
const requestedPath =
|
|
4037
|
+
app.get('/api/files-preview/{*splat}', auth, (req, res) => {
|
|
4038
|
+
const requestedPath = splatPath(req).trim();
|
|
4046
4039
|
const normalizedPath = normalizeWorkspaceRelativePath(requestedPath);
|
|
4047
4040
|
const rootOverride = resolveScopedWorkspaceRootPath(req);
|
|
4048
4041
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
@@ -9179,16 +9172,28 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9179
9172
|
const responseMessageId = q?.response_message_id || null;
|
|
9180
9173
|
const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
|
|
9181
9174
|
const normalizedMetadata = sanitizeActivityMetadata(metadata);
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9175
|
+
// The row's place in the turn's order (thoughts and tool lines share
|
|
9176
|
+
// it): numbered and written in one IMMEDIATE transaction, so two writers
|
|
9177
|
+
// on the same turn never get the same number.
|
|
9178
|
+
let turnSeq = null;
|
|
9179
|
+
const insertActivityRow = () => {
|
|
9180
|
+
turnSeq = stmts.nextTurnSeq?.(messageId) ?? null;
|
|
9181
|
+
return stmts.insertActivity.run(
|
|
9182
|
+
messageId,
|
|
9183
|
+
responseMessageId,
|
|
9184
|
+
conversationId,
|
|
9185
|
+
normalizeRelayMode(mode) || DEFAULT_RELAY_MODE,
|
|
9186
|
+
activityText,
|
|
9187
|
+
new Date().toISOString(),
|
|
9188
|
+
normalizedSubagentRunId,
|
|
9189
|
+
normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
|
|
9190
|
+
turnSeq,
|
|
9191
|
+
);
|
|
9192
|
+
};
|
|
9193
|
+
const insertActivityTx = typeof db?.transaction === 'function' ? db.transaction(insertActivityRow) : null;
|
|
9194
|
+
const activityRow = typeof insertActivityTx?.immediate === 'function'
|
|
9195
|
+
? insertActivityTx.immediate()
|
|
9196
|
+
: insertActivityRow();
|
|
9192
9197
|
if (normalizedSubagentRunId) {
|
|
9193
9198
|
// The lane keeps the capped line; the timeline copy keeps what came in.
|
|
9194
9199
|
mirrorSubagentFrame({
|
|
@@ -9206,6 +9211,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9206
9211
|
timestamp: new Date().toISOString(),
|
|
9207
9212
|
subagentRunId: normalizedSubagentRunId || undefined,
|
|
9208
9213
|
...(normalizedMetadata ? { metadata: normalizedMetadata } : {}),
|
|
9214
|
+
...(turnSeq !== null ? { turnSeq } : {}),
|
|
9209
9215
|
});
|
|
9210
9216
|
res.json({ ok: true });
|
|
9211
9217
|
});
|
|
@@ -9363,6 +9369,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9363
9369
|
const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
|
|
9364
9370
|
const normalizedReasoningId = String(reasoningId || '').trim() || null;
|
|
9365
9371
|
let seq = null;
|
|
9372
|
+
// The thought's place in the turn's order: given on its first write and
|
|
9373
|
+
// kept by every update, so a thought sits where its first chunk arrived.
|
|
9374
|
+
let turnSeq = null;
|
|
9366
9375
|
const shouldPersistThought = !!done || !!normalizedReasoningId;
|
|
9367
9376
|
if (shouldPersistThought) {
|
|
9368
9377
|
const isThoughtSeqConstraintError = (error) => {
|
|
@@ -9397,12 +9406,13 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9397
9406
|
messageId,
|
|
9398
9407
|
normalizedReasoningId,
|
|
9399
9408
|
);
|
|
9400
|
-
return existingSeq;
|
|
9409
|
+
return { seq: existingSeq, turnSeq: existingThought.turn_seq == null ? null : Number(existingThought.turn_seq) };
|
|
9401
9410
|
}
|
|
9402
9411
|
}
|
|
9403
9412
|
const row = stmts.getLastThoughtSeqByQueueMessage?.get(messageId);
|
|
9404
9413
|
const maxSeq = Math.max(0, Number(row?.max_seq || 0));
|
|
9405
9414
|
const nextSeq = maxSeq + 1;
|
|
9415
|
+
const nextTurn = stmts.nextTurnSeq?.(messageId) ?? null;
|
|
9406
9416
|
stmts.insertThought?.run(
|
|
9407
9417
|
messageId,
|
|
9408
9418
|
responseMessageId,
|
|
@@ -9414,8 +9424,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9414
9424
|
done ? 1 : 0,
|
|
9415
9425
|
now,
|
|
9416
9426
|
normalizedSubagentRunId,
|
|
9427
|
+
nextTurn,
|
|
9417
9428
|
);
|
|
9418
|
-
return nextSeq;
|
|
9429
|
+
return { seq: nextSeq, turnSeq: nextTurn };
|
|
9419
9430
|
});
|
|
9420
9431
|
const runInsertThought = typeof insertThoughtTx?.immediate === 'function'
|
|
9421
9432
|
? insertThoughtTx.immediate.bind(insertThoughtTx)
|
|
@@ -9423,7 +9434,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9423
9434
|
const maxRetries = 4;
|
|
9424
9435
|
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
9425
9436
|
try {
|
|
9426
|
-
seq = runInsertThought();
|
|
9437
|
+
({ seq, turnSeq } = runInsertThought());
|
|
9427
9438
|
break;
|
|
9428
9439
|
} catch (error) {
|
|
9429
9440
|
if (error?.code === 'THOUGHT_CONVERSATION_MISMATCH') {
|
|
@@ -9458,6 +9469,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
9458
9469
|
seq,
|
|
9459
9470
|
timestamp: now,
|
|
9460
9471
|
subagentRunId: normalizedSubagentRunId || undefined,
|
|
9472
|
+
...(turnSeq !== null ? { turnSeq } : {}),
|
|
9461
9473
|
});
|
|
9462
9474
|
res.json({ ok: true, seq });
|
|
9463
9475
|
});
|
|
@@ -151,6 +151,16 @@ export function inboxItemOfPlanBoard(board, session, { forRemote = false } = {})
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* An ask as a paired relay gets it: without `queueId`, the queue row of the
|
|
156
|
+
* turn that asked here, which means nothing there.
|
|
157
|
+
*/
|
|
158
|
+
export function askForPairedRelay(ask) {
|
|
159
|
+
if (!ask || typeof ask !== 'object') return ask;
|
|
160
|
+
const { queueId, ...rest } = ask;
|
|
161
|
+
return rest;
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
/** One Inbox item of an ask (relay-ask-service.mjs shape). */
|
|
155
165
|
export function inboxItemOfAsk(ask) {
|
|
156
166
|
const {
|
|
@@ -322,7 +332,7 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
322
332
|
if (!relayAskService?.isAvailable?.()) return res.json({ available: false, asks: [], pending: 0 });
|
|
323
333
|
const conversationId = toText(req.query.conversationId);
|
|
324
334
|
const asks = conversationId
|
|
325
|
-
? relayAskService.
|
|
335
|
+
? relayAskService.listIn(conversationId).filter((ask) => !ask.relayId && ask.conversationId === conversationId)
|
|
326
336
|
: relayAskService.listHere();
|
|
327
337
|
res.json({ available: true, asks, pending: relayAskService.pendingCount() });
|
|
328
338
|
});
|
|
@@ -411,7 +421,7 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
411
421
|
detail: body.detail,
|
|
412
422
|
urgency: body.urgency,
|
|
413
423
|
});
|
|
414
|
-
res.json({ ok: true, ask });
|
|
424
|
+
res.json({ ok: true, ask: askForPairedRelay(ask) });
|
|
415
425
|
} catch (error) {
|
|
416
426
|
sendRelayAskError(res, error, 'The ask');
|
|
417
427
|
}
|
|
@@ -433,7 +443,7 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
433
443
|
const caller = admitPairedRelay(req, res);
|
|
434
444
|
if (!caller || !available(res)) return;
|
|
435
445
|
try {
|
|
436
|
-
res.json({ ok: true, ask: relayAskService.withdraw(toText(asObject(req.body).id), { byRelayId: caller.relayId }) });
|
|
446
|
+
res.json({ ok: true, ask: askForPairedRelay(relayAskService.withdraw(toText(asObject(req.body).id), { byRelayId: caller.relayId })) });
|
|
437
447
|
} catch (error) {
|
|
438
448
|
sendRelayAskError(res, error, 'The withdrawal');
|
|
439
449
|
}
|
|
@@ -447,7 +457,7 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
447
457
|
const caller = admitPairedRelay(req, res, body.via);
|
|
448
458
|
if (!caller || !available(res)) return;
|
|
449
459
|
try {
|
|
450
|
-
res.json({ ok: true, ask: relayAskService.answer(toText(body.id), { answer: String(body.answer ?? ''), choice: String(body.choice ?? ''), via: caller.name }) });
|
|
460
|
+
res.json({ ok: true, ask: askForPairedRelay(relayAskService.answer(toText(body.id), { answer: String(body.answer ?? ''), choice: String(body.choice ?? ''), via: caller.name })) });
|
|
451
461
|
} catch (error) {
|
|
452
462
|
sendRelayAskError(res, error, 'The answer');
|
|
453
463
|
}
|
|
@@ -458,7 +468,7 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
458
468
|
const caller = admitPairedRelay(req, res, body.via);
|
|
459
469
|
if (!caller || !available(res)) return;
|
|
460
470
|
try {
|
|
461
|
-
res.json({ ok: true, ask: relayAskService.dismiss(toText(body.id), { via: caller.name }) });
|
|
471
|
+
res.json({ ok: true, ask: askForPairedRelay(relayAskService.dismiss(toText(body.id), { via: caller.name })) });
|
|
462
472
|
} catch (error) {
|
|
463
473
|
sendRelayAskError(res, error, 'The dismissal');
|
|
464
474
|
}
|
|
@@ -78,6 +78,7 @@ import { createShareRepository } from '../repositories/share-repository.mjs';
|
|
|
78
78
|
import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
|
|
79
79
|
import { GROUP_NAME_MAX, resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
|
|
80
80
|
import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
|
|
81
|
+
import { detailsVersion, parseHeldDetailsParam, thoughtFingerprint } from '../public/app/held-details.mjs';
|
|
81
82
|
import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
82
83
|
import {
|
|
83
84
|
RELAUNCH_COALESCE_WINDOW_MS,
|
|
@@ -160,7 +161,7 @@ export { mapUsageSnapshotRow };
|
|
|
160
161
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
161
162
|
const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing', 'parked']);
|
|
162
163
|
// Registry statuses that mean "this worker is currently running" (same triple
|
|
163
|
-
// as
|
|
164
|
+
// as ACTIVE_SESSION_CWD_STATUSES in server-runtime.mjs).
|
|
164
165
|
const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
|
|
165
166
|
// A shared attachment's URL names its bytes (sha256 or a fixed generated-image
|
|
166
167
|
// path), so a kept answer stays right.
|
|
@@ -4706,6 +4707,59 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4706
4707
|
});
|
|
4707
4708
|
|
|
4708
4709
|
// GET /api/conversation/:id — get paginated conversation history
|
|
4710
|
+
// The details version of each finished reply (held-details.mjs), one query
|
|
4711
|
+
// per table for the whole window (the ids go in as one JSON parameter).
|
|
4712
|
+
// Tool rows are only inserted: their count and highest row id. Thought rows
|
|
4713
|
+
// are updated in place as they stream and when done: their rows are read
|
|
4714
|
+
// (id, done, text) and fingerprinted, so any change shows.
|
|
4715
|
+
let detailsVersionStatements = null;
|
|
4716
|
+
function relayDetailsVersions(messageIds) {
|
|
4717
|
+
const ids = [...new Set(messageIds.filter(Boolean))];
|
|
4718
|
+
const versions = new Map();
|
|
4719
|
+
if (!ids.length || typeof db?.prepare !== 'function') return versions;
|
|
4720
|
+
try {
|
|
4721
|
+
detailsVersionStatements ||= {
|
|
4722
|
+
activity: db.prepare(`
|
|
4723
|
+
SELECT response_message_id AS id, COUNT(*) AS n, MAX(rowid) AS max_rowid
|
|
4724
|
+
FROM relay_activity
|
|
4725
|
+
WHERE response_message_id IN (SELECT value FROM json_each(?))
|
|
4726
|
+
GROUP BY response_message_id
|
|
4727
|
+
`),
|
|
4728
|
+
thought: db.prepare(`
|
|
4729
|
+
SELECT response_message_id AS response_id, rowid AS row_id, done, text
|
|
4730
|
+
FROM relay_thought
|
|
4731
|
+
WHERE response_message_id IN (SELECT value FROM json_each(?))
|
|
4732
|
+
`),
|
|
4733
|
+
};
|
|
4734
|
+
const idsJson = JSON.stringify(ids);
|
|
4735
|
+
const activity = new Map();
|
|
4736
|
+
for (const row of detailsVersionStatements.activity.all(idsJson) || []) {
|
|
4737
|
+
activity.set(String(row?.id || ''), { n: Number(row.n) || 0, max: Number(row.max_rowid) || 0 });
|
|
4738
|
+
}
|
|
4739
|
+
const thoughtRows = new Map();
|
|
4740
|
+
for (const row of detailsVersionStatements.thought.all(idsJson) || []) {
|
|
4741
|
+
const id = String(row?.response_id || '');
|
|
4742
|
+
if (!thoughtRows.has(id)) thoughtRows.set(id, []);
|
|
4743
|
+
thoughtRows.get(id).push({ rowid: row.row_id, done: row.done, text: row.text });
|
|
4744
|
+
}
|
|
4745
|
+
for (const id of ids) {
|
|
4746
|
+
const tools = activity.get(id) || { n: 0, max: 0 };
|
|
4747
|
+
const thoughts = thoughtRows.get(id) || [];
|
|
4748
|
+
versions.set(id, detailsVersion({
|
|
4749
|
+
activityCount: tools.n,
|
|
4750
|
+
activityMaxRowId: tools.max,
|
|
4751
|
+
thoughtCount: thoughts.length,
|
|
4752
|
+
thoughtMaxRowId: thoughts.reduce((max, row) => Math.max(max, Number(row.rowid) || 0), 0),
|
|
4753
|
+
thoughtHash: thoughtFingerprint(thoughts),
|
|
4754
|
+
}));
|
|
4755
|
+
}
|
|
4756
|
+
} catch {
|
|
4757
|
+
// No versions: nothing is held, everything goes out in full.
|
|
4758
|
+
return new Map();
|
|
4759
|
+
}
|
|
4760
|
+
return versions;
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4709
4763
|
app.get('/api/conversation/:id', auth, (req, res) => {
|
|
4710
4764
|
const requestedId = String(req.params.id || '').trim();
|
|
4711
4765
|
const limit = normalizeConversationHistoryLimit(req.query.limit, 20);
|
|
@@ -4729,7 +4783,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4729
4783
|
supportedRelayModes: SUPPORTED_RELAY_MODES,
|
|
4730
4784
|
defaultRelayMode: DEFAULT_RELAY_MODE,
|
|
4731
4785
|
});
|
|
4732
|
-
|
|
4786
|
+
// The running turn's tool lines: as many per thread as this page shows
|
|
4787
|
+
// (its "Tool lines shown" setting); none given, the relay's default.
|
|
4788
|
+
const inFlight = inFlightStateForConversation(resolvedConversationId, { activityCap: req.query.activityCap });
|
|
4733
4789
|
const sdkSessionId = String(conv.sdk_session_id || resolvedConversationId || '').trim();
|
|
4734
4790
|
const sessionUsageSummary = resolveSessionUsageSummaryForSdkSession({
|
|
4735
4791
|
sdkSessionId,
|
|
@@ -4768,14 +4824,23 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4768
4824
|
.map((row) => [String(row?.response_message_id || '').trim(), String(row?.id || '').trim()])
|
|
4769
4825
|
.filter(([responseMessageId, sourceMessageId]) => !!responseMessageId && !!sourceMessageId),
|
|
4770
4826
|
);
|
|
4827
|
+
// The live poll names the finished replies whose tool lines and thoughts
|
|
4828
|
+
// it holds (`heldDetails`, held-details.mjs); those whose details have
|
|
4829
|
+
// not changed since (the same `detailsVersion`) go out without them.
|
|
4830
|
+
const assistantIds = dbMessages.filter((m) => m.role === 'assistant').map((m) => String(m.id));
|
|
4831
|
+
const detailsVersions = relayDetailsVersions(assistantIds);
|
|
4832
|
+
const heldParam = parseHeldDetailsParam(req.query.heldDetails);
|
|
4833
|
+
const heldNow = new Set(heldParam
|
|
4834
|
+
? assistantIds.filter((id) => heldParam.get(id) === detailsVersions.get(id))
|
|
4835
|
+
: []);
|
|
4771
4836
|
const relayActivitiesByMessageId = new Map(
|
|
4772
4837
|
dbMessages
|
|
4773
|
-
.filter((m) => m.role === 'assistant')
|
|
4838
|
+
.filter((m) => m.role === 'assistant' && !heldNow.has(String(m.id)))
|
|
4774
4839
|
.map((m) => [m.id, relayActivityForResponse(m.id)]),
|
|
4775
4840
|
);
|
|
4776
4841
|
const relayThoughtsByMessageId = new Map(
|
|
4777
4842
|
dbMessages
|
|
4778
|
-
.filter((m) => m.role === 'assistant')
|
|
4843
|
+
.filter((m) => m.role === 'assistant' && !heldNow.has(String(m.id)))
|
|
4779
4844
|
.map((m) => [m.id, relayThoughtsForResponse ? relayThoughtsForResponse(m.id) : []]),
|
|
4780
4845
|
);
|
|
4781
4846
|
const subagentRunsByMessageId = new Map(
|
|
@@ -4816,8 +4881,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4816
4881
|
});
|
|
4817
4882
|
messages = messages.map((message) => {
|
|
4818
4883
|
if (message.role !== 'assistant') return message;
|
|
4819
|
-
const
|
|
4820
|
-
|
|
4884
|
+
const id = String(message.id || '').trim();
|
|
4885
|
+
const sourceMessageId = responseMessageToSourceId.get(id) || message.sourceMessageId || undefined;
|
|
4886
|
+
// Held: the page fills these from what it has. Otherwise the version
|
|
4887
|
+
// the page keeps with them.
|
|
4888
|
+
const details = heldNow.has(id)
|
|
4889
|
+
? { activities: [], thoughts: [], detailsHeld: true }
|
|
4890
|
+
: (detailsVersions.has(id) ? { detailsVersion: detailsVersions.get(id) } : {});
|
|
4891
|
+
return { ...message, ...(sourceMessageId ? { sourceMessageId } : {}), ...details };
|
|
4821
4892
|
});
|
|
4822
4893
|
const history = sqlPage
|
|
4823
4894
|
? { messages, pageInfo: sqlPage.pageInfo }
|