@oar-sh/oar 0.9.2 → 0.9.4
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/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Open GitHub Actions runs for a set of workspace roots, via the `gh` CLI the
|
|
7
|
+
* host already has (same credentials the agents use). Nothing in the relay
|
|
8
|
+
* models CI, so this is a best-effort outside view: a repo whose runs cannot
|
|
9
|
+
* be read counts as "unknown" and blocks for a grace period, after which it is
|
|
10
|
+
* ignored rather than keeping the host awake forever over a GitHub outage.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CACHE_MS = 20 * 1000;
|
|
14
|
+
const DEFAULT_UNKNOWN_GRACE_MS = 15 * 60 * 1000;
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 20 * 1000;
|
|
16
|
+
const RUN_LIST_LIMIT = 30;
|
|
17
|
+
|
|
18
|
+
const OPEN_RUN_STATUSES = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
|
|
19
|
+
|
|
20
|
+
/** `git@github.com:o/r.git`, `https://github.com/o/r`, `ssh://git@github.com/o/r.git` → `o/r`. */
|
|
21
|
+
export function parseGitHubRepoSlug(remoteUrl) {
|
|
22
|
+
const text = String(remoteUrl || '').trim();
|
|
23
|
+
if (!text) return null;
|
|
24
|
+
const match = text.match(/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
return `${match[1]}/${match[2]}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isOpenCiRunStatus(status) {
|
|
30
|
+
const value = String(status || '').trim().toLowerCase();
|
|
31
|
+
if (!value) return false;
|
|
32
|
+
return OPEN_RUN_STATUSES.has(value) || value !== 'completed';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function execText(execFileImpl, file, args, { cwd = undefined, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
try {
|
|
39
|
+
execFileImpl(file, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
if (error) {
|
|
43
|
+
const detail = String(stderr || error.message || '').trim().split('\n')[0] || 'command failed';
|
|
44
|
+
reject(new Error(detail.slice(0, 200)));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve(String(stdout || ''));
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
reject(error);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createGitHubCiActivityService({
|
|
58
|
+
execFileImpl = execFile,
|
|
59
|
+
now = () => Date.now(),
|
|
60
|
+
cacheMs = DEFAULT_CACHE_MS,
|
|
61
|
+
unknownGraceMs = DEFAULT_UNKNOWN_GRACE_MS,
|
|
62
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
63
|
+
logger = console,
|
|
64
|
+
} = {}) {
|
|
65
|
+
// root → { slug: string|null, at }
|
|
66
|
+
const slugByRoot = new Map();
|
|
67
|
+
// slug → { runs, error, at, unknownSince, inFlight }
|
|
68
|
+
const runsBySlug = new Map();
|
|
69
|
+
|
|
70
|
+
async function resolveSlug(root) {
|
|
71
|
+
const key = String(root || '').trim();
|
|
72
|
+
if (!key) return null;
|
|
73
|
+
const cached = slugByRoot.get(key);
|
|
74
|
+
if (cached && cached.at !== null && now() - cached.at < 10 * cacheMs) return cached.slug;
|
|
75
|
+
let slug = null;
|
|
76
|
+
try {
|
|
77
|
+
const url = await execText(execFileImpl, 'git', ['-C', key, 'remote', 'get-url', 'origin'], { timeoutMs });
|
|
78
|
+
slug = parseGitHubRepoSlug(url);
|
|
79
|
+
} catch {
|
|
80
|
+
slug = null;
|
|
81
|
+
}
|
|
82
|
+
slugByRoot.set(key, { slug, at: now() });
|
|
83
|
+
return slug;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fetchRuns(slug) {
|
|
87
|
+
const entry = runsBySlug.get(slug) || { runs: [], error: null, at: null, unknownSince: null, inFlight: null };
|
|
88
|
+
runsBySlug.set(slug, entry);
|
|
89
|
+
if (entry.inFlight) return entry.inFlight;
|
|
90
|
+
if (entry.at !== null && now() - entry.at < cacheMs) return entry;
|
|
91
|
+
entry.inFlight = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
const out = await execText(execFileImpl, 'gh', [
|
|
94
|
+
'run', 'list', '-R', slug, '--limit', String(RUN_LIST_LIMIT),
|
|
95
|
+
'--json', 'status,name,headBranch,databaseId,event',
|
|
96
|
+
], { timeoutMs });
|
|
97
|
+
const parsed = JSON.parse(out || '[]');
|
|
98
|
+
entry.runs = (Array.isArray(parsed) ? parsed : [])
|
|
99
|
+
.filter((run) => run && isOpenCiRunStatus(run.status))
|
|
100
|
+
.map((run) => ({
|
|
101
|
+
id: run.databaseId ?? null,
|
|
102
|
+
name: String(run.name || 'workflow').slice(0, 120),
|
|
103
|
+
headBranch: String(run.headBranch || '').slice(0, 160),
|
|
104
|
+
status: String(run.status || '').slice(0, 40),
|
|
105
|
+
event: String(run.event || '').slice(0, 40),
|
|
106
|
+
}));
|
|
107
|
+
entry.error = null;
|
|
108
|
+
entry.unknownSince = null;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
entry.error = String(error?.message || error || 'gh failed').slice(0, 200);
|
|
111
|
+
if (entry.unknownSince === null) entry.unknownSince = now();
|
|
112
|
+
try { logger.warn?.(`[host-suspend] CI state for ${slug} unknown: ${entry.error}`); } catch {}
|
|
113
|
+
} finally {
|
|
114
|
+
entry.at = now();
|
|
115
|
+
entry.inFlight = null;
|
|
116
|
+
}
|
|
117
|
+
return entry;
|
|
118
|
+
})();
|
|
119
|
+
return entry.inFlight;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string[]} workspaceRoots
|
|
124
|
+
* @returns {Promise<{ repos: Array<{ repo, runs }>, unknown: Array<{ repo, error, since, ignored }>, blockers: Array<object> }>}
|
|
125
|
+
*/
|
|
126
|
+
async function describeRuns(workspaceRoots = []) {
|
|
127
|
+
const roots = [...new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((r) => String(r || '').trim()).filter(Boolean))];
|
|
128
|
+
const slugs = new Set();
|
|
129
|
+
for (const root of roots) {
|
|
130
|
+
const slug = await resolveSlug(root);
|
|
131
|
+
if (slug) slugs.add(slug);
|
|
132
|
+
}
|
|
133
|
+
const repos = [];
|
|
134
|
+
const unknown = [];
|
|
135
|
+
const blockers = [];
|
|
136
|
+
for (const slug of slugs) {
|
|
137
|
+
const entry = await fetchRuns(slug);
|
|
138
|
+
if (entry.error) {
|
|
139
|
+
const since = entry.unknownSince === null ? now() : entry.unknownSince;
|
|
140
|
+
const ignored = now() - since >= unknownGraceMs;
|
|
141
|
+
unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
|
|
142
|
+
if (!ignored) {
|
|
143
|
+
blockers.push({ kind: 'ci-unknown', detail: `CI state of ${slug} unknown (${entry.error})`, title: slug });
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (entry.runs.length) {
|
|
148
|
+
repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
|
|
149
|
+
const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
|
|
150
|
+
blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { repos, unknown, blockers };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Last known picture without touching git/gh — for synchronous collectors
|
|
158
|
+
* that kick describeRuns() off in the background. A root or repo that has
|
|
159
|
+
* never been resolved yet reports a `ci-checking` blocker so the host does
|
|
160
|
+
* not sleep during the very first lookup.
|
|
161
|
+
*/
|
|
162
|
+
function snapshot(workspaceRoots = []) {
|
|
163
|
+
const blockers = [];
|
|
164
|
+
const repos = [];
|
|
165
|
+
const unknown = [];
|
|
166
|
+
const slugs = new Set();
|
|
167
|
+
let checking = 0;
|
|
168
|
+
// Several busy conversations usually share one workspace; count it once.
|
|
169
|
+
const roots = new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((root) => String(root || '').trim()));
|
|
170
|
+
for (const key of roots) {
|
|
171
|
+
if (!key) continue;
|
|
172
|
+
const cached = slugByRoot.get(key);
|
|
173
|
+
if (!cached) { checking += 1; continue; }
|
|
174
|
+
if (cached.slug) slugs.add(cached.slug);
|
|
175
|
+
}
|
|
176
|
+
for (const slug of slugs) {
|
|
177
|
+
const entry = runsBySlug.get(slug);
|
|
178
|
+
if (!entry || entry.at === null) { checking += 1; continue; }
|
|
179
|
+
if (entry.error) {
|
|
180
|
+
const since = entry.unknownSince === null ? now() : entry.unknownSince;
|
|
181
|
+
const ignored = now() - since >= unknownGraceMs;
|
|
182
|
+
unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
|
|
183
|
+
if (!ignored) blockers.push({ kind: 'ci-unknown', title: slug, detail: `CI state of ${slug} unknown (${entry.error})` });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (entry.runs.length) {
|
|
187
|
+
repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
|
|
188
|
+
const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
|
|
189
|
+
blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (checking > 0) {
|
|
193
|
+
blockers.push({ kind: 'ci-checking', count: checking, detail: `Checking CI state of ${checking} workspace${checking === 1 ? '' : 's'}` });
|
|
194
|
+
}
|
|
195
|
+
return { repos, unknown, blockers };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { describeRuns, snapshot, parseGitHubRepoSlug };
|
|
199
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
|
|
7
|
+
* the box into S3 (hibernation is not enabled on the host, so this never
|
|
8
|
+
* hibernates). Detached so the relay does not wait on it.
|
|
9
|
+
*
|
|
10
|
+
* `OAR_HOST_SUSPEND_DRY_RUN=1` logs instead of sleeping the machine, which is
|
|
11
|
+
* how the deferred-suspend flow gets exercised on a dev box.
|
|
12
|
+
*/
|
|
13
|
+
export function runHostSuspendToRam({
|
|
14
|
+
platform = process.platform,
|
|
15
|
+
spawnImpl = spawn,
|
|
16
|
+
dryRun = /^(1|true|yes|on)$/i.test(String(process.env.OAR_HOST_SUSPEND_DRY_RUN || '')),
|
|
17
|
+
logger = console,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (platform !== 'win32') {
|
|
20
|
+
return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
|
|
21
|
+
}
|
|
22
|
+
const command = 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0';
|
|
23
|
+
if (dryRun) {
|
|
24
|
+
try { logger.log?.(`[host-suspend] DRY RUN — would run: ${command}`); } catch {}
|
|
25
|
+
return { ok: true, command, dryRun: true };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
child.unref?.();
|
|
34
|
+
return { ok: true, command };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deferred host suspend: a "💤 Suspend host" request is queued and fulfilled
|
|
5
|
+
* only once nothing is running any more — no queued/processing turn, no live
|
|
6
|
+
* background task, no open CI run of a watched workspace — for a sustained
|
|
7
|
+
* idle window. Idle at request time means a short countdown instead, so the
|
|
8
|
+
* banner still gives the user a moment to cancel.
|
|
9
|
+
*
|
|
10
|
+
* Everything lives in memory: a relay restart drops the request (dispose()
|
|
11
|
+
* reports it so the caller can push a notice). The service is pure
|
|
12
|
+
* orchestration; the activity collector, the suspend command and the timers
|
|
13
|
+
* are injected so the state machine is unit-testable with fake clocks.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const HOST_SUSPEND_IDLE_WINDOW_MS = 2 * 60 * 1000;
|
|
17
|
+
export const HOST_SUSPEND_IDLE_COUNTDOWN_MS = 30 * 1000;
|
|
18
|
+
const DEFAULT_POLL_MS = 1000;
|
|
19
|
+
|
|
20
|
+
export const HOST_SUSPEND_STATUSES = Object.freeze(['idle', 'queued', 'countdown', 'suspending']);
|
|
21
|
+
|
|
22
|
+
function clampText(value, max, fallback) {
|
|
23
|
+
const text = String(value ?? '').trim().slice(0, max);
|
|
24
|
+
return text || fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalise a blocker so the client can render it without guessing. Blockers
|
|
29
|
+
* with the same `kind` and `detail` are treated as identical when deciding
|
|
30
|
+
* whether the state changed.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeHostActivityBlocker(raw) {
|
|
33
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
34
|
+
const kind = String(raw.kind || '').trim();
|
|
35
|
+
if (!kind) return null;
|
|
36
|
+
const count = Number(raw.count);
|
|
37
|
+
return {
|
|
38
|
+
kind,
|
|
39
|
+
conversationId: raw.conversationId ? String(raw.conversationId) : null,
|
|
40
|
+
title: raw.title ? String(raw.title).slice(0, 160) : null,
|
|
41
|
+
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : null,
|
|
42
|
+
detail: String(raw.detail || '').slice(0, 400),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function blockersFromActivity(activity) {
|
|
47
|
+
const list = Array.isArray(activity?.blockers) ? activity.blockers : Array.isArray(activity) ? activity : [];
|
|
48
|
+
return list.map(normalizeHostActivityBlocker).filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createHostSuspendService({
|
|
52
|
+
collectActivity,
|
|
53
|
+
runSuspend,
|
|
54
|
+
onStateChange = null,
|
|
55
|
+
onSuspended = null,
|
|
56
|
+
onDropped = null,
|
|
57
|
+
logger = console,
|
|
58
|
+
now = () => Date.now(),
|
|
59
|
+
setIntervalImpl = setInterval,
|
|
60
|
+
clearIntervalImpl = clearInterval,
|
|
61
|
+
pollMs = DEFAULT_POLL_MS,
|
|
62
|
+
idleWindowMs = HOST_SUSPEND_IDLE_WINDOW_MS,
|
|
63
|
+
idleCountdownMs = HOST_SUSPEND_IDLE_COUNTDOWN_MS,
|
|
64
|
+
} = {}) {
|
|
65
|
+
if (typeof collectActivity !== 'function') throw new Error('collectActivity is required');
|
|
66
|
+
if (typeof runSuspend !== 'function') throw new Error('runSuspend is required');
|
|
67
|
+
|
|
68
|
+
let status = 'idle';
|
|
69
|
+
let request = null;
|
|
70
|
+
let blockers = [];
|
|
71
|
+
let idleSince = null;
|
|
72
|
+
let fireAt = null;
|
|
73
|
+
let everBlocked = false;
|
|
74
|
+
let checkedAt = null;
|
|
75
|
+
let lastError = null;
|
|
76
|
+
let pollTimer = null;
|
|
77
|
+
let lastEmittedKey = '';
|
|
78
|
+
let ticking = false;
|
|
79
|
+
|
|
80
|
+
function publicState() {
|
|
81
|
+
return {
|
|
82
|
+
status,
|
|
83
|
+
pending: status === 'queued' || status === 'countdown' || status === 'suspending',
|
|
84
|
+
reason: request?.reason || null,
|
|
85
|
+
requestedBy: request?.requestedBy || null,
|
|
86
|
+
requestedAt: request?.requestedAt || null,
|
|
87
|
+
idleSince: idleSince ? new Date(idleSince).toISOString() : null,
|
|
88
|
+
fireAt: fireAt ? new Date(fireAt).toISOString() : null,
|
|
89
|
+
idleWindowMs: everBlocked ? idleWindowMs : idleCountdownMs,
|
|
90
|
+
checkedAt: checkedAt ? new Date(checkedAt).toISOString() : null,
|
|
91
|
+
blockers: blockers.map((b) => ({ ...b })),
|
|
92
|
+
lastError,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function emit(force = false) {
|
|
97
|
+
const state = publicState();
|
|
98
|
+
// Countdowns tick on the client from fireAt; only real transitions go out.
|
|
99
|
+
const key = JSON.stringify([
|
|
100
|
+
state.status,
|
|
101
|
+
state.requestedAt,
|
|
102
|
+
state.fireAt,
|
|
103
|
+
state.blockers.map((b) => `${b.kind}|${b.conversationId}|${b.count}|${b.detail}`),
|
|
104
|
+
state.lastError,
|
|
105
|
+
]);
|
|
106
|
+
if (!force && key === lastEmittedKey) return;
|
|
107
|
+
lastEmittedKey = key;
|
|
108
|
+
try { onStateChange?.(state); } catch (error) {
|
|
109
|
+
try { logger.warn?.(`[host-suspend] state listener failed: ${error?.message || error}`); } catch {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stopPolling() {
|
|
114
|
+
if (!pollTimer) return;
|
|
115
|
+
try { clearIntervalImpl(pollTimer); } catch {}
|
|
116
|
+
pollTimer = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resetToIdle() {
|
|
120
|
+
status = 'idle';
|
|
121
|
+
request = null;
|
|
122
|
+
blockers = [];
|
|
123
|
+
idleSince = null;
|
|
124
|
+
fireAt = null;
|
|
125
|
+
everBlocked = false;
|
|
126
|
+
lastError = null;
|
|
127
|
+
stopPolling();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collect() {
|
|
131
|
+
try {
|
|
132
|
+
const activity = collectActivity({ request: request ? { ...request } : null });
|
|
133
|
+
lastError = null;
|
|
134
|
+
return blockersFromActivity(activity);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// An unreadable activity picture must not sleep the box under running
|
|
137
|
+
// agents: report it as a blocker until the collector recovers.
|
|
138
|
+
lastError = String(error?.message || error || 'activity check failed').slice(0, 300);
|
|
139
|
+
return [{
|
|
140
|
+
kind: 'error',
|
|
141
|
+
conversationId: null,
|
|
142
|
+
title: null,
|
|
143
|
+
count: null,
|
|
144
|
+
detail: `Activity check failed: ${lastError}`,
|
|
145
|
+
}];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function evaluate() {
|
|
150
|
+
if (status !== 'queued' && status !== 'countdown') return;
|
|
151
|
+
const at = now();
|
|
152
|
+
checkedAt = at;
|
|
153
|
+
blockers = collect();
|
|
154
|
+
if (blockers.length) {
|
|
155
|
+
everBlocked = true;
|
|
156
|
+
idleSince = null;
|
|
157
|
+
fireAt = null;
|
|
158
|
+
status = 'queued';
|
|
159
|
+
emit();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!idleSince) idleSince = at;
|
|
163
|
+
const windowMs = everBlocked ? idleWindowMs : idleCountdownMs;
|
|
164
|
+
fireAt = idleSince + windowMs;
|
|
165
|
+
status = 'countdown';
|
|
166
|
+
if (at >= fireAt) {
|
|
167
|
+
fire();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
emit();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fire() {
|
|
174
|
+
status = 'suspending';
|
|
175
|
+
stopPolling();
|
|
176
|
+
const state = publicState();
|
|
177
|
+
emit(true);
|
|
178
|
+
try { logger.log?.(`[host-suspend] everything idle for ${Math.round((state.idleWindowMs || 0) / 1000)} s; suspending host (requested by ${state.requestedBy || 'unknown'})`); } catch {}
|
|
179
|
+
let result = null;
|
|
180
|
+
try {
|
|
181
|
+
result = runSuspend({ state });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
lastError = String(error?.message || error || 'suspend failed').slice(0, 300);
|
|
184
|
+
try { logger.warn?.(`[host-suspend] suspend command failed: ${lastError}`); } catch {}
|
|
185
|
+
}
|
|
186
|
+
const failed = result && result.ok === false;
|
|
187
|
+
if (failed && !lastError) lastError = String(result.error || 'suspend failed').slice(0, 300);
|
|
188
|
+
const finalError = lastError;
|
|
189
|
+
resetToIdle();
|
|
190
|
+
lastError = finalError;
|
|
191
|
+
emit(true);
|
|
192
|
+
if (!finalError) {
|
|
193
|
+
try { onSuspended?.(state); } catch {}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function tick() {
|
|
198
|
+
if (ticking) return;
|
|
199
|
+
ticking = true;
|
|
200
|
+
try { evaluate(); } finally { ticking = false; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function requestSuspend({ reason = 'manual-suspend', requestedBy = 'localhost-api' } = {}) {
|
|
204
|
+
if (status === 'suspending') return { accepted: false, state: publicState() };
|
|
205
|
+
if (status === 'queued' || status === 'countdown') {
|
|
206
|
+
return { accepted: false, alreadyPending: true, state: publicState() };
|
|
207
|
+
}
|
|
208
|
+
request = {
|
|
209
|
+
reason: clampText(reason, 140, 'manual-suspend'),
|
|
210
|
+
requestedBy: clampText(requestedBy, 80, 'localhost-api'),
|
|
211
|
+
requestedAt: new Date(now()).toISOString(),
|
|
212
|
+
};
|
|
213
|
+
everBlocked = false;
|
|
214
|
+
idleSince = null;
|
|
215
|
+
fireAt = null;
|
|
216
|
+
lastError = null;
|
|
217
|
+
status = 'queued';
|
|
218
|
+
evaluate();
|
|
219
|
+
if (status === 'queued' || status === 'countdown') {
|
|
220
|
+
pollTimer = setIntervalImpl(tick, pollMs);
|
|
221
|
+
pollTimer?.unref?.();
|
|
222
|
+
}
|
|
223
|
+
return { accepted: true, state: publicState() };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cancel({ requestedBy = 'localhost-api' } = {}) {
|
|
227
|
+
if (status !== 'queued' && status !== 'countdown') {
|
|
228
|
+
return { cancelled: false, state: publicState() };
|
|
229
|
+
}
|
|
230
|
+
try { logger.log?.(`[host-suspend] queued suspend cancelled by ${clampText(requestedBy, 80, 'unknown')}`); } catch {}
|
|
231
|
+
resetToIdle();
|
|
232
|
+
emit(true);
|
|
233
|
+
return { cancelled: true, state: publicState() };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function dispose({ reason = 'shutdown' } = {}) {
|
|
237
|
+
const wasPending = status === 'queued' || status === 'countdown';
|
|
238
|
+
const state = publicState();
|
|
239
|
+
resetToIdle();
|
|
240
|
+
if (wasPending) {
|
|
241
|
+
try { logger.log?.(`[host-suspend] queued suspend dropped (${reason})`); } catch {}
|
|
242
|
+
try { onDropped?.(state, reason); } catch {}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
request: requestSuspend,
|
|
248
|
+
cancel,
|
|
249
|
+
getState: publicState,
|
|
250
|
+
/** Re-run the activity check now (used by the status/GET routes). */
|
|
251
|
+
refresh() { if (status === 'queued' || status === 'countdown') tick(); return publicState(); },
|
|
252
|
+
dispose,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Which of a conversation's processing queue rows is the LIVE turn.
|
|
2
|
+
//
|
|
3
|
+
// Mid-turn steering means a conversation can have more than one row in
|
|
4
|
+
// `processing` at once: the running turn plus a message the user steered in,
|
|
5
|
+
// which the CLI folds into that turn and which produces no stream or activity
|
|
6
|
+
// of its own — or, after a replay handoff, the row that just took the turn
|
|
7
|
+
// over while the handed-off row is still settling. The live turn is the row
|
|
8
|
+
// that produced output MOST RECENTLY; with no output to separate them, the
|
|
9
|
+
// oldest processing row (the turn a steered message joins). Kept pure so the
|
|
10
|
+
// tie-break rules are testable without a DB.
|
|
11
|
+
//
|
|
12
|
+
// Recency, not volume: relay_stream_events.seq is only unique within one
|
|
13
|
+
// queue_message_id, so comparing it across rows picked "the row with the most
|
|
14
|
+
// events" — a long handed-off turn kept the live bubble (and Stop) from the
|
|
15
|
+
// row that had actually taken over.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Array<{id:string, lastOutputAtMs?:number, streamDone?:boolean, processingAtMs?:number}>} rows
|
|
19
|
+
* Processing rows for one conversation. `lastOutputAtMs` is when the row
|
|
20
|
+
* last wrote a stream snapshot or activity line (0 = never); `streamDone`
|
|
21
|
+
* marks a row whose main stream snapshot is final — a handed-off row
|
|
22
|
+
* settling, whose closing snapshot re-stamps it as the newest write — and
|
|
23
|
+
* ranks it below every row still open; `processingAtMs` is when it entered
|
|
24
|
+
* processing (final tie-break; smaller = older = wins).
|
|
25
|
+
* @returns {string} the live turn's row id, or '' when there are no rows.
|
|
26
|
+
*/
|
|
27
|
+
export function pickLiveTurnRowId(rows = []) {
|
|
28
|
+
const list = Array.isArray(rows) ? rows.filter((row) => row && row.id) : [];
|
|
29
|
+
if (!list.length) return '';
|
|
30
|
+
if (list.length === 1) return String(list[0].id);
|
|
31
|
+
const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const row of list) {
|
|
34
|
+
if (!best) { best = row; continue; }
|
|
35
|
+
if (Boolean(row.streamDone) !== Boolean(best.streamDone)) {
|
|
36
|
+
if (!row.streamDone) best = row;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const output = finite(row.lastOutputAtMs);
|
|
40
|
+
const bestOutput = finite(best.lastOutputAtMs);
|
|
41
|
+
if (output > bestOutput
|
|
42
|
+
|| (output === bestOutput && finite(row.processingAtMs) < finite(best.processingAtMs))) {
|
|
43
|
+
best = row;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return String(best.id);
|
|
47
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* reports outcomes through the status-events store instead.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline']);
|
|
13
|
+
export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend']);
|
|
14
14
|
|
|
15
15
|
export const VAPID_PUBLIC_KEY_SETTING = 'push_vapid_public_key';
|
|
16
16
|
export const VAPID_PRIVATE_KEY_SETTING = 'push_vapid_private_key';
|
|
@@ -34,8 +34,13 @@ const EVENT_LABELS = Object.freeze({
|
|
|
34
34
|
turnFailed: { title: 'Turn failed', generic: 'The agent turn failed.' },
|
|
35
35
|
board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
|
|
36
36
|
cliOffline: { title: 'CLI offline', generic: 'The relay lost its connection to the CLI host.' },
|
|
37
|
+
hostSuspend: { title: 'Host suspend', generic: 'The queued host suspend changed state.' },
|
|
37
38
|
});
|
|
38
39
|
|
|
40
|
+
// System events carry their own body: no conversation text to preview, and
|
|
41
|
+
// the body is the message itself regardless of the preview preference.
|
|
42
|
+
const SYSTEM_BODY_TYPES = new Set(['hostSuspend']);
|
|
43
|
+
|
|
39
44
|
function clampInt(value, min, max, fallback) {
|
|
40
45
|
const numeric = Number(value);
|
|
41
46
|
if (!Number.isFinite(numeric)) return fallback;
|
|
@@ -90,7 +95,9 @@ export function renderPushNotification(event, preferences) {
|
|
|
90
95
|
let body = labels.generic;
|
|
91
96
|
const text = String(event?.body || '').trim();
|
|
92
97
|
// cliOffline has no conversation text to preview; the generic body is the message.
|
|
93
|
-
if (text && type
|
|
98
|
+
if (text && SYSTEM_BODY_TYPES.has(type)) {
|
|
99
|
+
body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
|
|
100
|
+
} else if (text && type !== 'cliOffline') {
|
|
94
101
|
if (prefs.content.preview === 'full') {
|
|
95
102
|
body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
|
|
96
103
|
} else if (prefs.content.preview === 'truncated') {
|
|
@@ -300,6 +307,23 @@ export function createPushDispatchService({
|
|
|
300
307
|
} catch {}
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
// A turn another relay's agent started (its user message carries `origin`)
|
|
311
|
+
// pushes no "reply ready" or "failed": that agent is the one waiting for the
|
|
312
|
+
// reply and reports back to the user itself (decision 22). Question pushes
|
|
313
|
+
// are unchanged. Prepared lazily: a schema without the column (before
|
|
314
|
+
// migration 0005) simply means "not an agent's turn".
|
|
315
|
+
let messageOriginStatement = null;
|
|
316
|
+
function turnStartedByRemoteAgent(sourceMessageId) {
|
|
317
|
+
const id = String(sourceMessageId || '').trim();
|
|
318
|
+
if (!id) return false;
|
|
319
|
+
try {
|
|
320
|
+
messageOriginStatement ??= db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
|
|
321
|
+
return String(messageOriginStatement.get(id)?.origin_json || '').trim() !== '';
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
303
327
|
async function sendToSubscription(row, event) {
|
|
304
328
|
const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
|
|
305
329
|
const payload = renderPushNotification(event, preferences);
|
|
@@ -412,7 +436,10 @@ export function createPushDispatchService({
|
|
|
412
436
|
});
|
|
413
437
|
}
|
|
414
438
|
|
|
415
|
-
|
|
439
|
+
// `messageId` is the reply; `sourceMessageId` the user message that started
|
|
440
|
+
// the turn.
|
|
441
|
+
function notifyTurnComplete({ conversationId, messageId, sourceMessageId, text } = {}) {
|
|
442
|
+
if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
|
|
416
443
|
return dispatch({
|
|
417
444
|
type: 'turnComplete',
|
|
418
445
|
conversationId: conversationId || null,
|
|
@@ -421,7 +448,8 @@ export function createPushDispatchService({
|
|
|
421
448
|
});
|
|
422
449
|
}
|
|
423
450
|
|
|
424
|
-
function notifyTurnFailed({ conversationId, messageId, text } = {}) {
|
|
451
|
+
function notifyTurnFailed({ conversationId, messageId, sourceMessageId, text } = {}) {
|
|
452
|
+
if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
|
|
425
453
|
return dispatch({
|
|
426
454
|
type: 'turnFailed',
|
|
427
455
|
conversationId: conversationId || null,
|
|
@@ -443,7 +471,12 @@ export function createPushDispatchService({
|
|
|
443
471
|
return dispatch({ type: 'cliOffline' });
|
|
444
472
|
}
|
|
445
473
|
|
|
474
|
+
function notifyHostSuspend({ body = null } = {}) {
|
|
475
|
+
return dispatch({ type: 'hostSuspend', body: body || null, data: { hostSuspendAt: Date.now() } });
|
|
476
|
+
}
|
|
477
|
+
|
|
446
478
|
return {
|
|
479
|
+
notifyHostSuspend,
|
|
447
480
|
listDevices,
|
|
448
481
|
getDevice,
|
|
449
482
|
upsertSubscription,
|
|
@@ -135,6 +135,21 @@ const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
|
|
|
135
135
|
'gi',
|
|
136
136
|
);
|
|
137
137
|
|
|
138
|
+
// A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
|
|
139
|
+
// runtime stored, right after any mode marker. Matched by its stable opening
|
|
140
|
+
// words, because the browser copy of this module cannot import shared/.
|
|
141
|
+
const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
|
|
142
|
+
|
|
143
|
+
// The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
|
|
144
|
+
// zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
|
|
145
|
+
// it is matched on its own, and only where it guards a "/".
|
|
146
|
+
const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
|
|
147
|
+
|
|
148
|
+
function stripLeadingRelayNotes(text) {
|
|
149
|
+
const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
|
|
150
|
+
return stripped.trim() ? stripped.trim() : String(text || '').trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
|
|
139
154
|
const value = stripAttachmentPromptArtifacts(text)
|
|
140
155
|
.replace(MEDIA_EMBED_BLOCK_PATTERN, '')
|
|
@@ -143,7 +158,7 @@ export function stripRelayPromptContext(text, relayMode = '', attachments = [])
|
|
|
143
158
|
const patterns = buildPromptPrefixPatterns(relayMode);
|
|
144
159
|
for (const pattern of patterns) {
|
|
145
160
|
const stripped = value.replace(pattern, '').trim();
|
|
146
|
-
if (stripped && stripped !== value) return stripped;
|
|
161
|
+
if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
|
|
147
162
|
}
|
|
148
|
-
return value;
|
|
163
|
+
return stripLeadingRelayNotes(value);
|
|
149
164
|
}
|