@oar-sh/oar 0.9.15 → 0.9.16

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 (52) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  5. package/server/claude-worker/claude-session-process.mjs +4 -4
  6. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  7. package/server/db-schema.mjs +52 -0
  8. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  9. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  10. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  11. package/server/migrations/0022-relay-schedules.mjs +142 -0
  12. package/server/migrations/0023-relay-asks.mjs +141 -0
  13. package/server/public/app/ask-user-view.js +3 -0
  14. package/server/public/app/bootstrap.js +61 -3
  15. package/server/public/app/context-usage-view.mjs +17 -0
  16. package/server/public/app/conversation-group-order.mjs +35 -0
  17. package/server/public/app/inbox-ui.js +356 -0
  18. package/server/public/app/inbox-view.mjs +370 -0
  19. package/server/public/app/journal-view.js +23 -2
  20. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  21. package/server/public/app/relay-event-view.mjs +33 -7
  22. package/server/public/app/relay-schedule-view.mjs +119 -0
  23. package/server/public/app/relay-schedules-ui.js +172 -0
  24. package/server/public/app/socket-handlers.js +27 -3
  25. package/server/public/app/store.js +18 -5
  26. package/server/public/index.html +159 -2
  27. package/server/relay-tools.md +18 -3
  28. package/server/repositories/conversation-group-repository.mjs +97 -11
  29. package/server/repositories/remote-relay-repository.mjs +20 -2
  30. package/server/repositories/session-repository.mjs +7 -1
  31. package/server/routes/ask-user-routes.mjs +57 -3
  32. package/server/routes/cache-routes.mjs +6 -0
  33. package/server/routes/conversation-group-routes.mjs +17 -2
  34. package/server/routes/messages-routes.mjs +3 -3
  35. package/server/routes/relay-ask-routes.mjs +387 -0
  36. package/server/routes/relay-event-routes.mjs +3 -1
  37. package/server/routes/relay-schedule-routes.mjs +57 -0
  38. package/server/routes/sessions-routes.mjs +568 -42
  39. package/server/server-runtime.mjs +80 -1
  40. package/server/services/cache-rebuild-service.mjs +19 -2
  41. package/server/services/conversation-preferences-service.mjs +17 -0
  42. package/server/services/relay-ask-link-service.mjs +174 -0
  43. package/server/services/relay-ask-service.mjs +604 -0
  44. package/server/services/relay-event-service.mjs +589 -73
  45. package/server/services/relay-schedule-service.mjs +596 -0
  46. package/server/services/remote-relay-caller-context.mjs +89 -20
  47. package/server/services/remote-relay-dispatcher.mjs +956 -30
  48. package/shared/relay-asks.mjs +145 -0
  49. package/shared/relay-events.mjs +268 -18
  50. package/shared/relay-schedules.mjs +128 -0
  51. package/shared/remote-relay-contract.mjs +553 -25
  52. package/shared/remote-relay-tool-core.mjs +16 -5
@@ -45,6 +45,8 @@ import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
45
45
  import { createPreviewScopeResolver, registerPreviewRoutes } from './routes/preview-routes.mjs';
46
46
  import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
47
47
  import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
48
+ import { registerRelayScheduleRoutes } from './routes/relay-schedule-routes.mjs';
49
+ import { registerRelayAskRoutes } from './routes/relay-ask-routes.mjs';
48
50
  import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
49
51
  import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
50
52
  import { createShareRepository } from './repositories/share-repository.mjs';
@@ -84,6 +86,9 @@ import {
84
86
  } from './services/restart-resume-service.mjs';
85
87
  import { createBackgroundWorkView } from './services/background-work-view.mjs';
86
88
  import { createRelayEventService } from './services/relay-event-service.mjs';
89
+ import { createRelayScheduleService } from './services/relay-schedule-service.mjs';
90
+ import { createRelayAskService } from './services/relay-ask-service.mjs';
91
+ import { createRelayAskLinkService } from './services/relay-ask-link-service.mjs';
87
92
  import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
88
93
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
89
94
  import {
@@ -5128,6 +5133,11 @@ function createCompactedConversation(sourceConversationId) {
5128
5133
  targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
5129
5134
  });
5130
5135
  tx();
5136
+ // Its schedules go on in the new conversation, both the ones that fire
5137
+ // into it and the ones it set: the archived source would end them.
5138
+ try { relayScheduleService.carryToCompacted(sourceConversationId, targetConversationId); } catch {}
5139
+ // And so do its questions that don't block, asked in it or by it.
5140
+ try { relayAskService.carryToCompacted(sourceConversationId, targetConversationId); } catch {}
5131
5141
 
5132
5142
  return {
5133
5143
  sourceConversationId,
@@ -5458,6 +5468,37 @@ relayEventService = createRelayEventService({
5458
5468
  logger: console,
5459
5469
  });
5460
5470
 
5471
+ // ─── Relay schedules ──────────────────────────────────────────────────────────
5472
+ // Prompts agents scheduled with remote_relay's `schedule`: kept in the
5473
+ // database, their timers rebuilt at boot (services/relay-schedule-service.mjs).
5474
+ const relayScheduleService = createRelayScheduleService({
5475
+ db,
5476
+ stmts,
5477
+ emit: (event, payload) => io.emit(event, payload),
5478
+ // The scheduled message is an ordinary pending one, like a relay event's.
5479
+ wake: () => {
5480
+ void primePendingSessionWorkers('relay-schedule');
5481
+ sessionWorkerWebSocketService?.emitQueueChanged?.('relay-schedule');
5482
+ },
5483
+ // A firing into another session is a prompt of the conversation that set
5484
+ // it: the agents' loop guard counts it (and holds it once it wants the user).
5485
+ admitFiring: ({ createdBy }) => remoteRelayDispatcher.admitScheduledFiring({ createdBy }),
5486
+ logger: console,
5487
+ });
5488
+
5489
+ // ─── Questions that don't block ───────────────────────────────────────────────
5490
+ // remote_relay's `ask`: a card in the session and the Inbox; the answer
5491
+ // reaches the askers as a relay event (services/relay-ask-service.mjs).
5492
+ const relayAskService = createRelayAskService({
5493
+ db,
5494
+ emit: (event, payload) => io.emit(event, payload),
5495
+ relayEvents: relayEventService,
5496
+ // urgency "now" only.
5497
+ pushQuestion: (question) => pushDispatchService.notifyQuestion?.(question),
5498
+ logger: console,
5499
+ });
5500
+ if (!relayAskService.isAvailable()) console.warn(`[relay-asks] not available this boot: ${relayAskService.problem()}`);
5501
+
5461
5502
  /** A worker died: its tasks are gone, and whoever watches its sessions hears of it. */
5462
5503
  function noteWorkerDeathForEvents(sessionId, conversationIds, reason) {
5463
5504
  const ids = new Set((conversationIds || []).map((id) => String(id || '').trim()).filter(Boolean));
@@ -6883,7 +6924,10 @@ const remoteRelayRegistry = createRemoteRelayRegistry({
6883
6924
  platform: process.platform,
6884
6925
  version: RUNNING_VERSION,
6885
6926
  emit: (event, payload) => io.emit(event, payload),
6886
- onRemove: (entry) => { void relayEventLinkService?.noteRelayRemoved(entry); },
6927
+ onRemove: (entry) => {
6928
+ void relayEventLinkService?.noteRelayRemoved(entry);
6929
+ try { relayAskService.noteRelayRemoved(entry?.id); } catch {}
6930
+ },
6887
6931
  logger: console,
6888
6932
  });
6889
6933
  relayEventLinkService = createRelayEventLinkService({
@@ -6892,6 +6936,13 @@ relayEventLinkService = createRelayEventLinkService({
6892
6936
  client: remoteRelayClient,
6893
6937
  logger: console,
6894
6938
  });
6939
+ // The answers to asks this relay's agents put on paired relays.
6940
+ const relayAskLinkService = createRelayAskLinkService({
6941
+ asks: relayAskService,
6942
+ registry: remoteRelayRegistry,
6943
+ client: remoteRelayClient,
6944
+ logger: console,
6945
+ });
6895
6946
  const remoteRelayPairing = createRemoteRelayPairing({
6896
6947
  registry: remoteRelayRegistry,
6897
6948
  client: remoteRelayClient,
@@ -6946,6 +6997,9 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
6946
6997
  resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
6947
6998
  relayEvents: relayEventService,
6948
6999
  relayEventLink: relayEventLinkService,
7000
+ relaySchedules: relayScheduleService,
7001
+ relayAsks: relayAskService,
7002
+ relayAskLink: relayAskLinkService,
6949
7003
  logger: console,
6950
7004
  });
6951
7005
 
@@ -7165,6 +7219,8 @@ const sharedRouteDeps = {
7165
7219
  collectHostActivity,
7166
7220
  usageLimitPauseService,
7167
7221
  relayEventService,
7222
+ relayScheduleService,
7223
+ relayAskService,
7168
7224
  sessionActivityTracker,
7169
7225
  resolveConversationRecord,
7170
7226
  markSharedViewerPresence,
@@ -7210,6 +7266,22 @@ registerRelayEventRoutes(app, {
7210
7266
  relayEventService,
7211
7267
  remoteRelayInbound,
7212
7268
  });
7269
+ registerRelayScheduleRoutes(app, {
7270
+ auth,
7271
+ relayScheduleService,
7272
+ remoteRelayInbound,
7273
+ });
7274
+ registerRelayAskRoutes(app, {
7275
+ auth,
7276
+ db,
7277
+ stmts,
7278
+ formatQuestionRow,
7279
+ relayAskService,
7280
+ relayAskLinkService,
7281
+ remoteRelayInbound,
7282
+ remoteRelayRegistry,
7283
+ remoteRelayClient,
7284
+ });
7213
7285
  registerRelayRestartRoutes(app, {
7214
7286
  auth,
7215
7287
  // Read only: a GET neither fires the restart nor asks the question.
@@ -7572,6 +7644,11 @@ void primePendingSessionWorkers('startup');
7572
7644
  // heartbeat are swept from here on.
7573
7645
  relayEventService.start();
7574
7646
  relayEventLinkService.start();
7647
+ // Schedules get their timers back; one that came due while the relay was
7648
+ // down fires now, once.
7649
+ relayScheduleService.start();
7650
+ // Asks on paired relays still open there: look for their answers again.
7651
+ relayAskLinkService.start();
7575
7652
  runtimeTimers.pendingWorkerPrime = setInterval(() => {
7576
7653
  void primePendingSessionWorkers('maintenance');
7577
7654
  detachedWorkerWatch.sweep().catch((error) => {
@@ -7892,6 +7969,8 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
7892
7969
  try { relayEventService.noteRelayExit({ action: runtimeShutdownExitCode === RELAY_RESTART_EXIT_CODE ? 'restart' : 'shutdown', reason }); } catch {}
7893
7970
  try { relayEventService.stop(); } catch {}
7894
7971
  try { relayEventLinkService.stop(); } catch {}
7972
+ try { relayScheduleService.stop(); } catch {}
7973
+ try { relayAskLinkService.stop(); } catch {}
7895
7974
  tmuxInspectorSocketService.stop();
7896
7975
  stopWorkspaceFileWatcher();
7897
7976
  sshTunnelManager.stop();
@@ -68,6 +68,9 @@ export function createCacheRebuildService({
68
68
  // Session groups: a purged head leaves its group, and a wipe is followed
69
69
  // by a repair of what the wiped rows headed. Absent in older wiring.
70
70
  conversationGroups = null,
71
+ // (conversationId, { deleted }) => void: a conversation this service
72
+ // archived or removed (its relay schedules end). Absent in older wiring.
73
+ onConversationGone = null,
71
74
  }) {
72
75
  const legacyConversationList = buildLegacyConversationList(db);
73
76
  const boundConversationList = buildBoundConversationList(db);
@@ -99,10 +102,19 @@ export function createCacheRebuildService({
99
102
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
100
103
  })();
101
104
  closePreviews(sid);
105
+ noteGone(sid, { deleted: true });
102
106
  deleteOrphanedUploads(orphanedUploads);
103
107
  return orphanedUploads;
104
108
  }
105
109
 
110
+ function noteGone(conversationId, options) {
111
+ try {
112
+ onConversationGone?.(conversationId, options);
113
+ } catch (error) {
114
+ console.warn(`[cache] the end of a conversation was not passed on: ${error?.message || error}`);
115
+ }
116
+ }
117
+
106
118
  function closePreviews(conversationId) {
107
119
  try {
108
120
  closeConversationPreviews?.(conversationId);
@@ -115,7 +127,9 @@ export function createCacheRebuildService({
115
127
  const sid = normalizeId(conversationId);
116
128
  if (!sid) return false;
117
129
  const result = db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ? AND status != 'deleted'`).run(new Date().toISOString(), sid);
118
- return Number(result?.changes || 0) > 0;
130
+ const archived = Number(result?.changes || 0) > 0;
131
+ if (archived) noteGone(sid, { deleted: false });
132
+ return archived;
119
133
  }
120
134
 
121
135
  function fullWipeCache() {
@@ -176,7 +190,10 @@ export function createCacheRebuildService({
176
190
  })();
177
191
 
178
192
  for (const conversationId of conversationIds) {
179
- if (!manualTitleConversationSet.has(conversationId)) closePreviews(conversationId);
193
+ if (!manualTitleConversationSet.has(conversationId)) {
194
+ closePreviews(conversationId);
195
+ noteGone(conversationId, { deleted: true });
196
+ }
180
197
  }
181
198
 
182
199
  deleteOrphanedUploads(uploadHashes);
@@ -41,6 +41,9 @@ export function persistConversationPreferences({
41
41
  // fallback is the relay default (on / summarized).
42
42
  thinkingEnabled = undefined,
43
43
  thinkingDisplay = undefined,
44
+ // The owner's "agents in this conversation may start sessions" (a boolean;
45
+ // `undefined` leaves it alone). The route decides who may write it.
46
+ mayCreateSessions = undefined,
44
47
  updatedAt = new Date().toISOString(),
45
48
  createIfMissing = false,
46
49
  createTitle = 'Session',
@@ -56,6 +59,7 @@ export function persistConversationPreferences({
56
59
  const enabled = writesThinkingEnabled ? parseThinkingEnabled(thinkingEnabled) : DEFAULT_THINKING_ENABLED;
57
60
  const writesThinkingDisplay = thinkingDisplay !== undefined;
58
61
  const display = writesThinkingDisplay ? parseThinkingDisplay(thinkingDisplay) : null;
62
+ const writesMayCreateSessions = mayCreateSessions !== undefined;
59
63
  if (!db || !stmts || !convId || !mode) {
60
64
  return {
61
65
  ok: false,
@@ -66,6 +70,7 @@ export function persistConversationPreferences({
66
70
  autoCompactWindow: null,
67
71
  thinkingEnabled: DEFAULT_THINKING_ENABLED,
68
72
  thinkingDisplay: DEFAULT_THINKING_DISPLAY,
73
+ mayCreateSessions: false,
69
74
  updatedAt,
70
75
  };
71
76
  }
@@ -99,6 +104,10 @@ export function persistConversationPreferences({
99
104
  extraSets.push('thinking_display = ?');
100
105
  extraValues.push(display);
101
106
  }
107
+ if (writesMayCreateSessions) {
108
+ extraSets.push('may_create_sessions = ?');
109
+ extraValues.push(mayCreateSessions === true ? 1 : 0);
110
+ }
102
111
  try {
103
112
  if (extraSets.length) {
104
113
  db.prepare(`
@@ -136,12 +145,20 @@ export function persistConversationPreferences({
136
145
  thinkingDisplay: writesThinkingDisplay
137
146
  ? display
138
147
  : parseThinkingDisplay(existing?.thinking_display),
148
+ mayCreateSessions: writesMayCreateSessions
149
+ ? mayCreateSessions === true
150
+ : readMayCreateSessions(existing),
139
151
  updatedAt,
140
152
  };
141
153
  });
142
154
  return writePreferences();
143
155
  }
144
156
 
157
+ /** The owner's "agents in this conversation may start sessions" of a conversations row. */
158
+ export function readMayCreateSessions(row) {
159
+ return Number(row?.may_create_sessions) === 1;
160
+ }
161
+
145
162
  export function persistConversationModelPreference({
146
163
  db,
147
164
  stmts,
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The asking side of questions that don't block on paired relays: while an
5
+ * ask this relay's agent put on a paired relay is open there (a mirror row,
6
+ * relay-ask-service.mjs), this relay asks that relay every few seconds how it
7
+ * stands (POST /api/relay-asks/remote/status) and closes what closed there;
8
+ * the ask service tells the conversation that asked. Only this relay calls,
9
+ * so a pairing in one direction is enough. A relay that does not answer is
10
+ * asked less often until it does; nothing is lost meanwhile, the asks wait
11
+ * there.
12
+ */
13
+
14
+ import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
15
+ import { RELAY_ASK_LIMITS } from '../../shared/relay-asks.mjs';
16
+
17
+ // The routes a relay serves its paired relays for asks (routes/relay-ask-routes.mjs).
18
+ export const RELAY_ASK_REMOTE_PATHS = Object.freeze({
19
+ ask: '/api/relay-asks/remote/ask',
20
+ status: '/api/relay-asks/remote/status',
21
+ withdraw: '/api/relay-asks/remote/withdraw',
22
+ answer: '/api/relay-asks/remote/answer',
23
+ dismiss: '/api/relay-asks/remote/dismiss',
24
+ inbox: '/api/relay-asks/remote/inbox',
25
+ });
26
+
27
+ const LOG_PREFIX = '[relay-asks]';
28
+
29
+ function toText(value) {
30
+ return String(value ?? '').trim();
31
+ }
32
+
33
+ /**
34
+ * A paired relay that predates asks answers their routes with Express's HTML
35
+ * 404: no JSON, no code. A 404 of these routes always carries one.
36
+ */
37
+ export function isMissingRelayAskRoute(error) {
38
+ return toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound && !toText(error?.remoteBody?.code);
39
+ }
40
+
41
+ export function relayWithoutAsksText(name) {
42
+ return `Relay "${toText(name) || 'the paired relay'}" runs an OAR version without questions that don't block; it needs an update before its sessions can be asked for from here.`;
43
+ }
44
+
45
+ export function createRelayAskLinkService({
46
+ asks,
47
+ // `get(id)`: the registry entry (address and token) of a paired relay.
48
+ registry,
49
+ // `request(relay, method, path, { body, timeoutMs })`: remote-relay-client.mjs.
50
+ client,
51
+ setTimer = (fn, ms) => {
52
+ const timer = setTimeout(fn, ms);
53
+ timer.unref?.();
54
+ return timer;
55
+ },
56
+ clearTimer = (timer) => clearTimeout(timer),
57
+ logger = console,
58
+ pollMs = RELAY_ASK_LIMITS.remotePollMs,
59
+ failingPollMs = RELAY_ASK_LIMITS.remotePollFailingMs,
60
+ timeoutMs = 15_000,
61
+ } = {}) {
62
+ if (!asks) throw new Error('asks is required');
63
+ // paired relay id → { timer, running, again }
64
+ const links = new Map();
65
+ let started = false;
66
+
67
+ function warn(line) {
68
+ try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
69
+ }
70
+
71
+ function linkState(relayId) {
72
+ if (!links.has(relayId)) links.set(relayId, { timer: null, running: null, again: false });
73
+ return links.get(relayId);
74
+ }
75
+
76
+ function forget(relayId) {
77
+ const link = links.get(relayId);
78
+ if (link?.timer) clearTimer(link.timer);
79
+ links.delete(relayId);
80
+ }
81
+
82
+ function schedule(relayId, delayMs) {
83
+ if (!started) return;
84
+ const link = linkState(relayId);
85
+ if (link.timer) clearTimer(link.timer);
86
+ link.timer = setTimer(() => {
87
+ link.timer = null;
88
+ void fetchNow(relayId);
89
+ }, Math.max(0, delayMs));
90
+ }
91
+
92
+ async function fetchOnce(relayId) {
93
+ const ids = asks.pendingMirrorIds(relayId);
94
+ if (!ids.length) return { ok: true, idle: true };
95
+ const relay = registry?.get?.(relayId) || null;
96
+ if (!relay) {
97
+ asks.noteRelayRemoved(relayId);
98
+ return { ok: false, removed: true };
99
+ }
100
+ let response;
101
+ try {
102
+ response = await client.request(relay, 'POST', RELAY_ASK_REMOTE_PATHS.status, { body: { ids }, timeoutMs });
103
+ } catch (error) {
104
+ return { ok: false, error: toText(error?.message || error).slice(0, 300) || 'no answer' };
105
+ }
106
+ if (!response || !Array.isArray(response.asks)) return { ok: false, error: 'unexpected answer' };
107
+ asks.applyRemoteStatus(relayId, { relayName: toText(relay.name), statuses: response.asks });
108
+ return { ok: true };
109
+ }
110
+
111
+ /** Asks a paired relay now (one already running is followed by one more). Never throws. */
112
+ function fetchNow(relayId) {
113
+ const id = toText(relayId);
114
+ const link = linkState(id);
115
+ if (link.timer) {
116
+ clearTimer(link.timer);
117
+ link.timer = null;
118
+ }
119
+ if (link.running) {
120
+ link.again = true;
121
+ return link.running;
122
+ }
123
+ link.running = (async () => {
124
+ let result;
125
+ try {
126
+ result = await fetchOnce(id);
127
+ } catch (error) {
128
+ warn(`status from paired relay ${id.slice(0, 12)} failed: ${toText(error?.message || error).slice(0, 200)}`);
129
+ result = { ok: false };
130
+ } finally {
131
+ link.running = null;
132
+ }
133
+ if (result.removed) {
134
+ forget(id);
135
+ } else if (link.again) {
136
+ link.again = false;
137
+ schedule(id, 0);
138
+ } else if (asks.pendingMirrorIds(id).length) {
139
+ schedule(id, result.ok ? pollMs : failingPollMs);
140
+ } else {
141
+ forget(id);
142
+ }
143
+ return result;
144
+ })();
145
+ return link.running;
146
+ }
147
+
148
+ /** An ask was just put on that relay: look for its answer from now on. */
149
+ function noteAsked(relayId) {
150
+ const id = toText(relayId);
151
+ if (!id || links.get(id)?.timer || links.get(id)?.running) return;
152
+ schedule(id, pollMs);
153
+ }
154
+
155
+ function start() {
156
+ if (started) return;
157
+ started = true;
158
+ let delay = 0;
159
+ for (const relayId of asks.mirrorRelays()) {
160
+ schedule(relayId, delay);
161
+ delay += 250;
162
+ }
163
+ }
164
+
165
+ function stop() {
166
+ started = false;
167
+ for (const link of links.values()) {
168
+ if (link.timer) clearTimer(link.timer);
169
+ link.timer = null;
170
+ }
171
+ }
172
+
173
+ return { start, stop, fetchNow, noteAsked, isPolling: (relayId) => links.has(toText(relayId)) };
174
+ }