@oar-sh/oar 0.9.21 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  6. package/server/claude-worker/claude-session-process.mjs +9 -4
  7. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  9. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  10. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  11. package/server/db-schema.mjs +19 -0
  12. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  13. package/server/grok-worker/grok-session-worker.mjs +10 -1
  14. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  15. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  16. package/server/migrations/0038-turn-seq.mjs +92 -0
  17. package/server/public/app/activity-replay-state.mjs +160 -42
  18. package/server/public/app/api-client.js +7 -0
  19. package/server/public/app/ask-user-view.js +4 -3
  20. package/server/public/app/bootstrap.js +62 -4
  21. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  22. package/server/public/app/context-usage-view.mjs +68 -0
  23. package/server/public/app/conversation-view.js +306 -49
  24. package/server/public/app/header-priority-actions.mjs +5 -1
  25. package/server/public/app/held-details.mjs +202 -0
  26. package/server/public/app/inbox-ui.js +70 -79
  27. package/server/public/app/inbox-view.mjs +35 -19
  28. package/server/public/app/journal-view.js +17 -0
  29. package/server/public/app/live-activity-cap.mjs +103 -0
  30. package/server/public/app/questions-panel-ui.js +634 -0
  31. package/server/public/app/questions-stub-view.mjs +40 -0
  32. package/server/public/app/questions-view.mjs +89 -0
  33. package/server/public/app/relay-board-view.js +12 -2
  34. package/server/public/app/relay-thoughts.mjs +15 -1
  35. package/server/public/app/session-history-ui.js +1 -0
  36. package/server/public/app/settings-modal.js +6 -0
  37. package/server/public/app/socket-handlers.js +42 -11
  38. package/server/public/app/stream-view-preference.mjs +60 -0
  39. package/server/public/app/subagent-fold.mjs +6 -3
  40. package/server/public/app/subagent-timeline-view.mjs +12 -5
  41. package/server/public/app/subagent-viewer.mjs +8 -6
  42. package/server/public/app/thoughts-preference.mjs +79 -0
  43. package/server/public/app/turn-stream-view.mjs +124 -0
  44. package/server/public/index.html +391 -214
  45. package/server/repositories/message-repository.mjs +10 -0
  46. package/server/repositories/question-repository.mjs +51 -7
  47. package/server/repositories/relay-turn-rows.mjs +56 -0
  48. package/server/routes/ask-user-routes.mjs +34 -5
  49. package/server/routes/messages-routes.mjs +35 -14
  50. package/server/routes/relay-ask-routes.mjs +15 -5
  51. package/server/routes/sessions-routes.mjs +76 -5
  52. package/server/server-runtime.mjs +178 -131
  53. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  54. package/server/services/idle-timers.mjs +184 -0
  55. package/server/services/queue-wake-hook.mjs +145 -0
  56. package/server/services/queue-waker.mjs +127 -0
  57. package/server/services/relay-ask-service.mjs +64 -5
  58. package/server/services/relay-event-service.mjs +4 -1
  59. package/server/services/remote-relay-dispatcher.mjs +23 -4
  60. package/server/services/session-history-refresh-service.mjs +4 -0
  61. package/server/services/session-worker-supervisor-service.mjs +36 -0
  62. package/server/services/session-worker-websocket-service.mjs +143 -15
  63. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  64. package/shared/ask-user-bridge.mjs +9 -6
  65. package/shared/relay-asks.mjs +10 -0
  66. package/shared/relay-events.mjs +1 -0
  67. package/shared/worker-runtime/heartbeat.mjs +26 -0
  68. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  69. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ // Timers a relay keeps only while there is something to time: the CLI
4
+ // presence deadline and the intervals armed by work and disarmed by their own
5
+ // tick. An idle relay runs neither.
6
+
7
+ // ─── CLI presence ──────────────────────────────────────────────────────────────
8
+
9
+ // Whether "the CLI" (the relay's workers) has been heard from recently, kept
10
+ // without a polling timer. Every touch moves the last-seen time; one one-shot
11
+ // deadline timer, armed only while something was seen, notices when the
12
+ // window ran out. A deadline that fires early (a touch moved last-seen) arms
13
+ // again for the remainder, so an online relay wakes about once per window and
14
+ // an offline one not at all.
15
+ //
16
+ // The window depends on the worker sockets: an idle worker beats only every
17
+ // 30 s (shared/worker-runtime/worker-keepalive.mjs), so while a worker socket
18
+ // is open the window is wide enough for one lost beat. With no socket left a
19
+ // relay whose workers are all gone still reads offline within the short
20
+ // window, as before.
21
+
22
+ export const CLI_ONLINE_WINDOW_MS = 10_000;
23
+ export const CLI_ONLINE_WINDOW_WITH_WORKER_SOCKETS_MS = 45_000;
24
+
25
+ export function createCliPresence({
26
+ windowMs = CLI_ONLINE_WINDOW_MS,
27
+ socketWindowMs = CLI_ONLINE_WINDOW_WITH_WORKER_SOCKETS_MS,
28
+ // Called once when the window ran out after a touch (the deadline timer, or
29
+ // the last worker socket closing). The presence is cleared before the call.
30
+ onExpired = () => {},
31
+ now = () => Date.now(),
32
+ setTimeoutImpl = setTimeout,
33
+ clearTimeoutImpl = clearTimeout,
34
+ } = {}) {
35
+ let lastSeenMs = null;
36
+ let workerSocketCount = 0;
37
+ let deadlineTimer = null;
38
+
39
+ function currentWindowMs() {
40
+ return workerSocketCount > 0 ? socketWindowMs : windowMs;
41
+ }
42
+
43
+ function remainingMs() {
44
+ if (lastSeenMs === null) return 0;
45
+ return lastSeenMs + currentWindowMs() - now();
46
+ }
47
+
48
+ function disarm() {
49
+ if (!deadlineTimer) return;
50
+ clearTimeoutImpl(deadlineTimer);
51
+ deadlineTimer = null;
52
+ }
53
+
54
+ function arm(delayMs) {
55
+ disarm();
56
+ deadlineTimer = setTimeoutImpl(onDeadline, Math.max(1, delayMs));
57
+ deadlineTimer?.unref?.();
58
+ }
59
+
60
+ function expire() {
61
+ disarm();
62
+ lastSeenMs = null;
63
+ try { onExpired(); } catch {}
64
+ }
65
+
66
+ function onDeadline() {
67
+ deadlineTimer = null;
68
+ if (lastSeenMs === null) return;
69
+ const left = remainingMs();
70
+ if (left > 0) {
71
+ arm(left);
72
+ return;
73
+ }
74
+ expire();
75
+ }
76
+
77
+ function touch() {
78
+ lastSeenMs = now();
79
+ // An armed deadline is left alone: when it fires it finds the newer
80
+ // touch and arms again for what is left.
81
+ if (!deadlineTimer) arm(currentWindowMs());
82
+ }
83
+
84
+ /** The worker socket count changed: the window may have shrunk. */
85
+ function noteWorkerSocketCount(count) {
86
+ workerSocketCount = Math.max(0, Number(count) || 0);
87
+ if (lastSeenMs === null) return;
88
+ const left = remainingMs();
89
+ if (left <= 0) {
90
+ expire();
91
+ return;
92
+ }
93
+ arm(left);
94
+ }
95
+
96
+ /** Forget the last touch without calling onExpired (an explicit offline). */
97
+ function clear() {
98
+ disarm();
99
+ lastSeenMs = null;
100
+ }
101
+
102
+ function isFresh() {
103
+ return remainingMs() > 0;
104
+ }
105
+
106
+ return {
107
+ touch,
108
+ clear,
109
+ isFresh,
110
+ noteWorkerSocketCount,
111
+ status: () => ({
112
+ lastSeenMs,
113
+ workerSocketCount,
114
+ windowMs: currentWindowMs(),
115
+ armed: deadlineTimer !== null,
116
+ }),
117
+ };
118
+ }
119
+
120
+ // ─── Armable interval ──────────────────────────────────────────────────────────
121
+
122
+ // An interval that runs only while there is something for it to do: armed by
123
+ // whoever creates that work (a share viewer arriving, a pending row waiting
124
+ // for its worker), disarmed by its own tick once the work is gone. An idle
125
+ // relay then has no such timer at all. The timer is unref'd, and stop() (the
126
+ // runtime's shutdown) disarms it for good.
127
+
128
+ export function createArmableInterval({
129
+ intervalMs,
130
+ // One tick. Returns (or resolves to) `false` to disarm: nothing is left to
131
+ // do. Anything else keeps the interval running. An arm that lands while a
132
+ // tick is still running (the tick awaits) keeps the interval running even
133
+ // if that tick returns false: the work it was armed for may be newer than
134
+ // what the tick looked at.
135
+ tick,
136
+ onError = () => {},
137
+ setIntervalImpl = setInterval,
138
+ clearIntervalImpl = clearInterval,
139
+ } = {}) {
140
+ if (typeof tick !== 'function') throw new Error('createArmableInterval requires tick');
141
+ const periodMs = Math.max(1, Number(intervalMs) || 1);
142
+ let timer = null;
143
+ let stopped = false;
144
+ let generation = 0;
145
+
146
+ function disarm() {
147
+ if (!timer) return;
148
+ clearIntervalImpl(timer);
149
+ timer = null;
150
+ }
151
+
152
+ async function runTick() {
153
+ const armedAt = generation;
154
+ let keep;
155
+ try {
156
+ keep = await tick();
157
+ } catch (error) {
158
+ try { onError(error); } catch {}
159
+ return;
160
+ }
161
+ if (keep === false && generation === armedAt) disarm();
162
+ }
163
+
164
+ function arm() {
165
+ if (stopped) return false;
166
+ generation += 1;
167
+ if (timer) return false;
168
+ timer = setIntervalImpl(() => { void runTick(); }, periodMs);
169
+ timer?.unref?.();
170
+ return true;
171
+ }
172
+
173
+ function stop() {
174
+ stopped = true;
175
+ disarm();
176
+ }
177
+
178
+ return {
179
+ arm,
180
+ disarm,
181
+ stop,
182
+ isArmed: () => timer !== null,
183
+ };
184
+ }
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // Wakes the relay's queue delivery from the database itself. Every write
4
+ // that can make a queue row deliverable (a new pending row, a requeue, a
5
+ // retry time or owner or provider changed, a turn ending, a pending row
6
+ // cancelled or deleted) fires a TEMP trigger on this connection, which calls
7
+ // a JS function that only notes the reason and schedules one wake for after
8
+ // the current tick, so after the write's transaction. The function never
9
+ // touches the database and never throws.
10
+ //
11
+ // No row path has to remember to wake the queue, and none added later can
12
+ // forget: without it, a path that wrote a row and did not wake waited for
13
+ // the next poll. The triggers are TEMP (this connection only, nothing in the
14
+ // schema or the migrations). Their conditions name only the columns the
15
+ // queue table has, a self-test compiles every trigger inside a rolled-back
16
+ // savepoint, and on any error the triggers are dropped again and the caller
17
+ // falls back to polling: a broken trigger would fail every write to the
18
+ // queue.
19
+
20
+ export const QUEUE_WAKE_FUNCTION = 'oar_queue_wake';
21
+ const TRIGGER_PREFIX = 'oar_queue_wake_';
22
+ const TRIGGER_NAMES = ['insert', 'pending', 'settle', 'leave_pending', 'delete'].map((name) => `${TRIGGER_PREFIX}${name}`);
23
+ // A bulk statement calls the function once per row: the reasons of one tick
24
+ // are kept as a small set.
25
+ const MAX_REASONS = 8;
26
+
27
+ function queueColumns(db) {
28
+ return new Set(db.prepare('PRAGMA table_info(queue)').all().map((column) => String(column.name)));
29
+ }
30
+
31
+ /** The CREATE TEMP TRIGGER statements, their conditions built from the columns there are. */
32
+ export function buildQueueWakeTriggerSql(columns) {
33
+ const has = (name) => columns.has(name);
34
+ if (!has('status')) throw new Error('queue has no status column');
35
+ const changed = ["OLD.status IS NOT 'pending'"];
36
+ for (const column of ['next_attempt_at', 'owner_sdk_session_id', 'provider']) {
37
+ if (has(column)) changed.push(`OLD.${column} IS NOT NEW.${column}`);
38
+ }
39
+ const call = (reason) => `BEGIN SELECT ${QUEUE_WAKE_FUNCTION}('${reason}'); END`;
40
+ return [
41
+ // A new pending row.
42
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}insert AFTER INSERT ON main.queue
43
+ WHEN NEW.status = 'pending' ${call('insert')}`,
44
+ // A row that became pending, or a pending row whose retry time, owner or
45
+ // provider changed (a requeue, a hold released, a row moved to another
46
+ // worker). A claim leaves pending and a lease refresh stays processing:
47
+ // neither wakes.
48
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}pending AFTER UPDATE ON main.queue
49
+ WHEN NEW.status = 'pending' AND (${changed.join(' OR ')}) ${call('requeue')}`,
50
+ // A turn ended: its session's next row, or one held behind it by the plan
51
+ // boundary, may go now.
52
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}settle AFTER UPDATE ON main.queue
53
+ WHEN OLD.status = 'processing' AND NEW.status IS NOT 'processing' ${call('settle')}`,
54
+ // A pending row cancelled, failed or parked: a row held behind it may go.
55
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}leave_pending AFTER UPDATE ON main.queue
56
+ WHEN OLD.status = 'pending' AND NEW.status NOT IN ('pending', 'processing') ${call('cancel')}`,
57
+ // A pending or running row deleted (the cancel routes): the same.
58
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}delete AFTER DELETE ON main.queue
59
+ WHEN OLD.status IN ('pending', 'processing') ${call('delete')}`,
60
+ ];
61
+ }
62
+
63
+ function dropTriggers(db) {
64
+ for (const name of TRIGGER_NAMES) {
65
+ try { db.exec(`DROP TRIGGER IF EXISTS temp.${name}`); } catch {}
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Compiles every trigger by preparing a write of each kind, and runs one real
71
+ * update, inside a savepoint that is rolled back: a condition naming a column
72
+ * the table lacks fails here instead of on the first real write.
73
+ */
74
+ function selfTestTriggers(db) {
75
+ db.exec('SAVEPOINT oar_queue_wake_self_test');
76
+ try {
77
+ db.prepare('UPDATE queue SET status = status WHERE rowid = (SELECT rowid FROM queue LIMIT 1)').run();
78
+ db.prepare('UPDATE queue SET status = status WHERE 0').run();
79
+ db.prepare('DELETE FROM queue WHERE 0').run();
80
+ db.prepare('INSERT INTO queue SELECT * FROM queue WHERE 0').run();
81
+ } finally {
82
+ db.exec('ROLLBACK TO oar_queue_wake_self_test');
83
+ db.exec('RELEASE oar_queue_wake_self_test');
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Installs the triggers on `db`. `onWake(reason)` runs once per tick in which
89
+ * queue writes fired them, with the reasons joined. Returns `{ installed }`;
90
+ * when false the triggers are gone again and the caller must poll.
91
+ */
92
+ export function installQueueWakeHook(db, {
93
+ onWake = () => {},
94
+ log = () => {},
95
+ schedule = setImmediate,
96
+ selfTest = selfTestTriggers,
97
+ } = {}) {
98
+ const reasons = new Set();
99
+ let scheduled = false;
100
+ let muted = false;
101
+
102
+ function flush() {
103
+ scheduled = false;
104
+ const reason = [...reasons].join(',') || 'queue-write';
105
+ reasons.clear();
106
+ try {
107
+ onWake(reason);
108
+ } catch (error) {
109
+ log(`queue wake failed: ${error?.message || error}`);
110
+ }
111
+ }
112
+
113
+ // Called by SQLite from inside a write: no database access, no throw.
114
+ function wakeFromTrigger(reason) {
115
+ try {
116
+ if (muted) return null;
117
+ if (reasons.size < MAX_REASONS) reasons.add(String(reason || 'queue-write'));
118
+ if (!scheduled) {
119
+ scheduled = true;
120
+ schedule(flush);
121
+ }
122
+ } catch {}
123
+ return null;
124
+ }
125
+
126
+ try {
127
+ const sql = buildQueueWakeTriggerSql(queueColumns(db));
128
+ // The function first: a trigger calling a function that does not exist
129
+ // yet would fail the write.
130
+ db.function(QUEUE_WAKE_FUNCTION, { deterministic: false }, wakeFromTrigger);
131
+ dropTriggers(db);
132
+ for (const statement of sql) db.exec(statement);
133
+ muted = true;
134
+ try {
135
+ selfTest(db);
136
+ } finally {
137
+ muted = false;
138
+ }
139
+ return { installed: true, triggers: [...TRIGGER_NAMES] };
140
+ } catch (error) {
141
+ dropTriggers(db);
142
+ log(`queue wake triggers not installed, the queue is polled instead: ${error?.message || error}`);
143
+ return { installed: false, error };
144
+ }
145
+ }
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // What the relay does when its queue may hold work for a worker: hand rows to
4
+ // the workers that are ready (the socket service's notify), make sure every
5
+ // session with a pending row has a worker (the prime), and keep the prime's
6
+ // maintenance pass armed while rows wait. The wakes come from the queue's
7
+ // own writes (queue-wake-hook.mjs), from a retry time coming due, from gates
8
+ // that lift, and from the backstop poll.
9
+
10
+ /**
11
+ * The prime: every session that owns a pending row gets its worker started
12
+ * (rows in retry backoff included, so the worker is warm when the backoff
13
+ * matures; rows held at the usage limit are not listed until their reset).
14
+ */
15
+ export function createPendingWorkerPrimer({
16
+ isEnabled = () => true,
17
+ listPendingOwnerSessionIds,
18
+ isKillBlocked = () => false,
19
+ // A session whose rows were failed instead of launched (a Claude API chat
20
+ // the key cannot serve): true to skip its launch.
21
+ failRowsWithoutLaunch = () => false,
22
+ ensureWorker,
23
+ // Rows were found: the maintenance pass keeps retrying until none is.
24
+ onRowsWaiting = () => {},
25
+ warn = () => {},
26
+ limit = 25,
27
+ } = {}) {
28
+ return async function prime(reason = 'pending-worker-prime') {
29
+ if (!isEnabled()) return 0;
30
+ const rows = listPendingOwnerSessionIds(limit) || [];
31
+ if (rows.length) onRowsWaiting();
32
+ let requested = 0;
33
+ for (const row of rows) {
34
+ const sdkSessionId = String(row?.sdk_session_id || '').trim();
35
+ if (!sdkSessionId) continue;
36
+ if (isKillBlocked(sdkSessionId) === true) continue;
37
+ try {
38
+ // Per session: a throw here costs this session its launch, never the
39
+ // rest of the pass.
40
+ if (failRowsWithoutLaunch(sdkSessionId)) continue;
41
+ } catch (error) {
42
+ warn(`WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
43
+ continue;
44
+ }
45
+ requested += 1;
46
+ try {
47
+ const result = await ensureWorker(sdkSessionId);
48
+ if (!result?.ok) {
49
+ warn(`WORKER PRIME blocked session=${sdkSessionId.slice(0, 8)} reason=${String(result?.error || reason || 'pending-worker-prime')}`);
50
+ }
51
+ } catch (error) {
52
+ warn(`WORKER PRIME failed session=${sdkSessionId.slice(0, 8)} reason=${String(reason || 'pending-worker-prime')} err=${error?.message || error}`);
53
+ }
54
+ }
55
+ return requested;
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The wake. `wake(reason)` hands rows out at once, primes, and arms the
61
+ * maintenance pass; `wake(reason, { prime: false })` leaves the prime to that
62
+ * pass (a queue write: the send route launches its own worker, and a write
63
+ * burst must not run a prime per write). Primes never overlap: one asked for
64
+ * while one runs runs once after it. `primeFromBackstop` is the poll's share:
65
+ * a prime at most every `backstopPrimeGapMs`.
66
+ */
67
+ export function createQueueWaker({
68
+ prime,
69
+ notify = () => {},
70
+ armPrime = () => {},
71
+ isReady = () => true,
72
+ backstopPrimeGapMs = 5_000,
73
+ now = () => Date.now(),
74
+ warn = () => {},
75
+ } = {}) {
76
+ let primeRunning = false;
77
+ let primeAgain = null;
78
+ let lastBackstopPrimeAt = -Infinity;
79
+
80
+ async function runPrime(reason) {
81
+ if (primeRunning) {
82
+ primeAgain = reason;
83
+ return;
84
+ }
85
+ primeRunning = true;
86
+ try {
87
+ let next = reason;
88
+ while (next) {
89
+ primeAgain = null;
90
+ try {
91
+ await prime(next);
92
+ } catch (error) {
93
+ warn(`WORKER PRIME pass failed reason=${next} err=${error?.message || error}`);
94
+ }
95
+ next = primeAgain;
96
+ }
97
+ } finally {
98
+ primeRunning = false;
99
+ }
100
+ }
101
+
102
+ function wake(reason = 'queue-wake', { prime: withPrime = true } = {}) {
103
+ if (!isReady()) return false;
104
+ try { armPrime(); } catch {}
105
+ try { notify(reason); } catch (error) {
106
+ warn(`queue notify failed reason=${reason} err=${error?.message || error}`);
107
+ }
108
+ if (withPrime) void runPrime(reason);
109
+ return true;
110
+ }
111
+
112
+ function primeFromBackstop(reason = 'backstop') {
113
+ if (!isReady()) return false;
114
+ try { armPrime(); } catch {}
115
+ const at = now();
116
+ if (at - lastBackstopPrimeAt < backstopPrimeGapMs) return false;
117
+ lastBackstopPrimeAt = at;
118
+ void runPrime(reason);
119
+ return true;
120
+ }
121
+
122
+ return {
123
+ wake,
124
+ primeFromBackstop,
125
+ status: () => ({ primeRunning, primeQueued: primeAgain !== null }),
126
+ };
127
+ }
@@ -108,6 +108,15 @@ export function createRelayAskService({
108
108
  }
109
109
  })();
110
110
  const available = !problem;
111
+ // The turn an ask was made in (migration 0037); without the column asks
112
+ // still work and store none.
113
+ const hasQueueId = available && (() => {
114
+ try {
115
+ return db.prepare(`PRAGMA table_info(relay_asks)`).all().some((column) => column.name === 'queue_id');
116
+ } catch {
117
+ return false;
118
+ }
119
+ })();
111
120
 
112
121
  const sql = available ? {
113
122
  insert: db.prepare(`
@@ -116,6 +125,12 @@ export function createRelayAskService({
116
125
  question, options_json, recommendation, detail, urgency, status, created_at, updated_at)
117
126
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
118
127
  `),
128
+ insertWithQueue: hasQueueId ? db.prepare(`
129
+ INSERT INTO relay_asks
130
+ (id, relay_id, remote_id, conversation_id, conversation_title, created_by, creator_json,
131
+ question, options_json, recommendation, detail, urgency, status, created_at, updated_at, queue_id)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
133
+ `) : null,
119
134
  get: db.prepare(`SELECT * FROM relay_asks WHERE id = ?`),
120
135
  getMirror: db.prepare(`SELECT * FROM relay_asks WHERE relay_id = ? AND remote_id = ?`),
121
136
  // The Inbox: this relay's asks, pending and closed since.
@@ -131,6 +146,17 @@ export function createRelayAskService({
131
146
  AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
132
147
  ORDER BY created_at DESC, rowid DESC LIMIT ?
133
148
  `),
149
+ // A session's own cards: the asks in it kept here, none it only asked
150
+ // elsewhere and no mirrors.
151
+ listIn: db.prepare(`
152
+ SELECT * FROM relay_asks
153
+ WHERE conversation_id = ? AND relay_id = 'self'
154
+ AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
155
+ ORDER BY created_at DESC, rowid DESC LIMIT ?
156
+ `),
157
+ // What carryIn and carryBy are about to move.
158
+ listCarryIn: db.prepare(`SELECT id FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
159
+ listCarryBy: db.prepare(`SELECT id FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
134
160
  countPendingHere: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND status = 'pending'`),
135
161
  countPendingIn: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
136
162
  countPendingBy: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
@@ -228,6 +254,9 @@ export function createRelayAskService({
228
254
  ? { relay: creator.relayName || null, conversationId: creator.conversationId || null, title: creator.conversationTitle || null }
229
255
  : (toText(row.created_by) ? { conversationId: row.created_by, title: toText(asker?.title) || null } : null),
230
256
  onBehalf: creator ? true : (!!toText(row.created_by) && row.created_by !== row.conversation_id),
257
+ // The queue row of the turn that asked (null: on behalf, a paired
258
+ // relay's agent, or older); a mirror has none.
259
+ ...(mirror ? {} : { queueId: row.queue_id || null }),
231
260
  createdAt: row.created_at,
232
261
  updatedAt: row.updated_at,
233
262
  };
@@ -259,9 +288,10 @@ export function createRelayAskService({
259
288
  /**
260
289
  * Stores an ask about `conversationId` (a session of this relay). The asker
261
290
  * is `createdBy` (a conversation here: the same one, or a lead asking for
262
- * another) or `creator` (a paired relay's agent). Returns the ask.
291
+ * another) or `creator` (a paired relay's agent). `queueId`: the queue row
292
+ * of the turn that asked, when it ran in this session. Returns the ask.
263
293
  */
264
- function create({ conversationId, createdBy = null, creator = null, ...input } = {}) {
294
+ function create({ conversationId, createdBy = null, creator = null, queueId = null, ...input } = {}) {
265
295
  requireAvailable();
266
296
  const session = conversation(conversationId);
267
297
  if (!isLive(session)) {
@@ -281,7 +311,7 @@ export function createRelayAskService({
281
311
  const { question, options, recommendation, detail, urgency } = checked.value;
282
312
  const id = newId();
283
313
  const at = isoNow();
284
- sql.insert.run(
314
+ const values = [
285
315
  id,
286
316
  SELF,
287
317
  null,
@@ -296,7 +326,9 @@ export function createRelayAskService({
296
326
  urgency,
297
327
  at,
298
328
  at,
299
- );
329
+ ];
330
+ if (sql.insertWithQueue) sql.insertWithQueue.run(...values, clip(queueId, 200) || null);
331
+ else sql.insert.run(...values);
300
332
  const row = sql.get.get(id);
301
333
  const ask = shape(row);
302
334
  log(`${id} in ${shortId(session.id)} by ${remoteCreator ? `relay ${shortId(remoteCreator.relayId)}` : shortId(asker)}${urgency === 'now' ? ' (now)' : ''}`);
@@ -335,6 +367,20 @@ export function createRelayAskService({
335
367
  return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
336
368
  }
337
369
 
370
+ /**
371
+ * A session's cards: the asks in it kept on this relay (not those it only
372
+ * asked elsewhere, no mirrors), pending first, then those closed in the
373
+ * last answeredKeepMs.
374
+ */
375
+ function listIn(conversationId) {
376
+ if (!available) return [];
377
+ const id = toText(conversationId);
378
+ if (!id) return [];
379
+ const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
380
+ const rows = sql.listIn.all(id, since, RELAY_ASK_LIMITS.listMax);
381
+ return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
382
+ }
383
+
338
384
  /** Who is told the outcome of a row here: its session and the conversation that asked. */
339
385
  function tell(row) {
340
386
  const ask = shape(row);
@@ -429,7 +475,19 @@ export function createRelayAskService({
429
475
  const from = toText(sourceId);
430
476
  const to = toText(targetId);
431
477
  if (!from || !to || from === to) return 0;
432
- const moved = db.transaction(() => sql.carryIn.run(to, from).changes + sql.carryBy.run(to, from).changes)();
478
+ // The rows both updates touch, each once (a self-ask in and by the
479
+ // source is in both), so their cards follow to the successor.
480
+ const touched = new Set();
481
+ const moved = db.transaction(() => {
482
+ for (const row of sql.listCarryIn.all(from)) touched.add(row.id);
483
+ for (const row of sql.listCarryBy.all(from)) touched.add(row.id);
484
+ return sql.carryIn.run(to, from).changes + sql.carryBy.run(to, from).changes;
485
+ })();
486
+ for (const id of touched) {
487
+ const row = sql.get.get(id);
488
+ // A mirror's card lives on the paired relay: nothing to show here.
489
+ if (row && row.relay_id === SELF) changed(row);
490
+ }
433
491
  if (moved) log(`conv=${shortId(from)} compacted into ${shortId(to)}: ${moved} question${moved === 1 ? '' : 's'} moved`);
434
492
  return moved;
435
493
  }
@@ -586,6 +644,7 @@ export function createRelayAskService({
586
644
  get,
587
645
  listHere,
588
646
  listOf,
647
+ listIn,
589
648
  pendingCount,
590
649
  answer,
591
650
  dismiss,
@@ -107,7 +107,7 @@ import {
107
107
  relayEventQuietWindowEnd,
108
108
  relayEventTypesTake,
109
109
  } from '../../shared/relay-events.mjs';
110
- import { REMOTE_RELAY_APPROVAL_SOURCE } from '../../shared/relay-asks.mjs';
110
+ import { REMOTE_RELAY_APPROVAL_SOURCE, isPersonOnlyQuestion } from '../../shared/relay-asks.mjs';
111
111
 
112
112
  export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
113
113
  export const RELAY_EVENT_SELF_RELAY_ID = 'self';
@@ -1770,6 +1770,7 @@ export function createRelayEventService({
1770
1770
  prompt: 'text',
1771
1771
  questionId: 'text',
1772
1772
  approvalCard: 'bool',
1773
+ personOnly: 'bool',
1773
1774
  // question_answered
1774
1775
  answer: 'text',
1775
1776
  choice: 'text',
@@ -2316,6 +2317,8 @@ export function createRelayEventService({
2316
2317
  options,
2317
2318
  questionId: toText(question.id),
2318
2319
  ...(toText(context?.source) === REMOTE_RELAY_APPROVAL_SOURCE ? { approvalCard: true } : {}),
2320
+ // A Claude session's Safety check: the user's to answer.
2321
+ ...(isPersonOnlyQuestion(context) ? { personOnly: true } : {}),
2319
2322
  // A question that does not block (relay_tool ask).
2320
2323
  ...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
2321
2324
  });
@@ -44,7 +44,7 @@ import {
44
44
  isMissingRelayEventRoute,
45
45
  relayWithoutCrossRelayEventsText,
46
46
  } from './relay-event-link-service.mjs';
47
- import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
47
+ import { RELAY_ASK_KIND, isPersonOnlyQuestion, relayQuestionKind } from '../../shared/relay-asks.mjs';
48
48
  import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
49
49
 
50
50
  // The one place a `relay_tool` tool call is decided and carried out (plan
@@ -465,11 +465,13 @@ function relayTarget(relay) {
465
465
  /**
466
466
  * The question card's view of a relay question: what the agent needs to answer
467
467
  * it. Null for the remote's own remote-relay approval cards, which only its
468
- * user may answer (the remote hides them from us as well).
468
+ * user may answer (the remote hides them from us as well), and for a Claude
469
+ * session's Safety check card, which only the user may answer.
469
470
  */
470
471
  function mapPendingQuestion(question) {
471
472
  if (!isPlainObject(question) || !toText(question.id)) return null;
472
473
  if (isRemoteRelayApprovalQuestion(question)) return null;
474
+ if (isPersonOnlyQuestion(question.context)) return null;
473
475
  const kind = relayQuestionKind(question);
474
476
  if (kind === RELAY_ASK_KIND) {
475
477
  // A question that does not block: the user's to answer, the session works on.
@@ -1757,7 +1759,9 @@ export function createRemoteRelayDispatcher({
1757
1759
  budget -= text.length;
1758
1760
  const role = toText(row.role) || 'assistant';
1759
1761
  const via = toText(row?.origin?.relayName);
1760
- const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities) : [];
1762
+ // The newest lines, as the progress path reads them: the relay keeps the
1763
+ // newest per thread now, so the first ones are not the start anyway.
1764
+ const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities, { fromEnd: true }) : [];
1761
1765
  out.unshift({
1762
1766
  id: toText(row.id),
1763
1767
  role,
@@ -2594,6 +2598,11 @@ export function createRemoteRelayDispatcher({
2594
2598
  `Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
2595
2599
  }
2596
2600
  const session = toText(question.conversationId) || null;
2601
+ if (isPersonOnlyQuestion(question.context)) {
2602
+ throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
2603
+ `Question ${args.question_id} is a Safety check: Claude Code marks the call as person-only, so this card is the user's to answer, never an agent's. Leave it to the user.`,
2604
+ { session });
2605
+ }
2597
2606
  if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
2598
2607
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
2599
2608
  `Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
@@ -3462,7 +3471,17 @@ export function createRemoteRelayDispatcher({
3462
3471
  };
3463
3472
  }
3464
3473
  const session = askSession(ctx, args);
3465
- const ask = relayAskCall(() => service.create({ conversationId: session, createdBy: ctx.conversationId, ...askInput(args) }));
3474
+ const ask = relayAskCall(() => service.create({
3475
+ conversationId: session,
3476
+ createdBy: ctx.conversationId,
3477
+ ...askInput(args),
3478
+ // The queue id means "the turn that was live when the ask was made": a
3479
+ // background sub-agent asking after its parent turn ended may record
3480
+ // the user's next, unrelated turn (accepted; there is no worker
3481
+ // attribution). An ask on behalf of another session stores null: the
3482
+ // asking turn lives in another conversation.
3483
+ queueId: session === ctx.conversationId ? (ctx.caller?.processingRowId || null) : null,
3484
+ }));
3466
3485
  const forOther = session !== ctx.conversationId;
3467
3486
  return {
3468
3487
  summary: `ask → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: question ${ask.id} pending${ask.urgency === 'now' ? ' (now)' : ''}`,