@gotcos/glasses-server 6.53.3 → 6.54.0
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/.env.example +15 -0
- package/CHANGELOG.md +30 -0
- package/README.md +91 -1
- package/bin/hooks/cos-session-hook +18 -9
- package/managed-runtime-contract.json +4 -1
- package/package.json +1 -1
- package/server/index.ts +7 -0
- package/server/lib/attached-provider-adapter.ts +8 -0
- package/server/lib/claude-hooks-installer.ts +12 -6
- package/server/lib/fence-liveness.ts +76 -2
- package/server/lib/lens-gist-engines.ts +416 -0
- package/server/lib/lens-gist.ts +893 -0
- package/server/lib/provider-proof.ts +4 -2
- package/server/lib/session-halt.ts +59 -12
- package/server/lib/session-hooks-runtime.ts +10 -3
- package/server/routes/agent-session-bindings.ts +8 -16
- package/server/routes/health.ts +3 -0
- package/server/routes/lens-gist.ts +58 -0
|
@@ -194,7 +194,9 @@ function safeProofError(result: ProcessResult): string {
|
|
|
194
194
|
return 'provider returned no valid proof response'
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
/** A provider's own words for a spent session, usage, rate or capacity limit. Also the lens
|
|
198
|
+
* gist's (lens-gist-engines.ts), so both read a limit the same way. */
|
|
199
|
+
export const PROVIDER_QUOTA_RE = /usage[ _]limit|hit your (?:usage |session )?limit|session limit|rate.?limit|too many requests|\b429\b|\bquota\b|resets? (?:at|in) |over capacity|overloaded|\b529\b|plan limit/i
|
|
198
200
|
const AUTH_RE = /not logged in|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
|
|
199
201
|
const OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
|
|
200
202
|
const MISSING_RE = /ENOENT|command not found|no such file/i
|
|
@@ -209,7 +211,7 @@ export function classifyProofFailure(result: ProcessResult, answer: string, expe
|
|
|
209
211
|
const haystack = `${result.stderr}\n${result.stdout}`.slice(0, 40_000)
|
|
210
212
|
if (result.code === null && MISSING_RE.test(haystack)) return 'provider_missing'
|
|
211
213
|
if (OVERFLOW_RE.test(haystack)) return 'provider_context_overflow'
|
|
212
|
-
if (
|
|
214
|
+
if (PROVIDER_QUOTA_RE.test(haystack)) return 'provider_quota'
|
|
213
215
|
if (AUTH_RE.test(haystack)) return 'provider_auth'
|
|
214
216
|
if (result.code === 0) return answer === expected ? 'provider_failed' : 'provider_bad_answer'
|
|
215
217
|
return 'provider_failed'
|
|
@@ -38,6 +38,7 @@ import { join } from 'node:path'
|
|
|
38
38
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
39
39
|
import { dataPath } from './data-dir.js'
|
|
40
40
|
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
41
|
+
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
|
|
@@ -46,6 +47,14 @@ import { isValidNativeThreadId } from './native-thread-id.js'
|
|
|
46
47
|
*/
|
|
47
48
|
export const HALT_MARKER_TTL_MS = 60 * 60_000
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 6.53.3 /qa: how long a re-arm waits when the hand-off was NOT verified (`maybe`: the frame
|
|
52
|
+
* may or may not have reached the session). A verified hand-off keeps the marker's hour; an
|
|
53
|
+
* unverified one that has not surfaced as a prompt within two minutes most likely never
|
|
54
|
+
* arrived, and holding it longer only widens the window for an identical desk prompt.
|
|
55
|
+
*/
|
|
56
|
+
export const HALT_REARM_UNVERIFIED_MS = 2 * 60_000
|
|
57
|
+
|
|
49
58
|
export interface HaltMarker {
|
|
50
59
|
at: number
|
|
51
60
|
clientCancelId: string
|
|
@@ -121,15 +130,30 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
|
|
|
121
130
|
// plain write would then be deleted by the very turn it was written to stop.
|
|
122
131
|
//
|
|
123
132
|
// So the marker is written, and when the delivered turn has not visibly started yet, it is
|
|
124
|
-
// written AGAIN, once, on the first UserPromptSubmit for that session
|
|
125
|
-
// the delivered
|
|
126
|
-
//
|
|
127
|
-
//
|
|
133
|
+
// written AGAIN, once, on the first UserPromptSubmit for that session AFTER the send, if that
|
|
134
|
+
// prompt IS the delivered one. The window is the marker's own hour (two minutes for an
|
|
135
|
+
// unverified hand-off); SessionEnd drops it. In memory on purpose: a restart forgets it, like
|
|
136
|
+
// every other hold here.
|
|
137
|
+
//
|
|
138
|
+
// 6.53.3 /qa (Skeptic W1): the first cut matched `prompt.includes(<first 120 characters>)`.
|
|
139
|
+
// A short COS prompt ("yes") is inside most desk prompts, so the person's own next prompt
|
|
140
|
+
// re-armed the marker: THEIR run was denied "Cancelled from COS", and the delivered turn's
|
|
141
|
+
// own prompt, finding nothing pending, then ran the cancelled turn. Now:
|
|
142
|
+
// - EXACT: the delivered words and the prompt's words are equal once whitespace runs are
|
|
143
|
+
// collapsed and Claude Code's peer wrapper ("Another Claude session sent a message: ...",
|
|
144
|
+
// which a live Continue arrives in) is taken off. The frame carries no id of its own and
|
|
145
|
+
// its text is not changed to add one.
|
|
146
|
+
// - FIRST PROMPT DECIDES: the first prompt after the send spends the re-arm whether or not
|
|
147
|
+
// it matches. A non-match is logged (never the prompt). This gives up the cancel when
|
|
148
|
+
// the person's own prompt reaches the session first, rather than risk halting their run:
|
|
149
|
+
// a halted desk run is the worse error, and the lens already said "at its next step".
|
|
150
|
+
// - LIMIT, stated: a desk prompt with exactly the delivered words, typed after the send and
|
|
151
|
+
// taken first, is indistinguishable from the delivered turn and re-arms.
|
|
128
152
|
|
|
129
153
|
interface PendingRearm {
|
|
130
154
|
marker: HaltMarker
|
|
131
|
-
/**
|
|
132
|
-
|
|
155
|
+
/** The delivered prompt's words, whitespace-normalised (`normalizePromptWords`). */
|
|
156
|
+
words: string
|
|
133
157
|
/** Prompts submitted before this are someone else's. */
|
|
134
158
|
after: number
|
|
135
159
|
until: number
|
|
@@ -147,18 +171,22 @@ const MAX_PENDING_REARMS = 64
|
|
|
147
171
|
export function haltDeliveredTurn(
|
|
148
172
|
sessionId: string,
|
|
149
173
|
marker: HaltMarker,
|
|
150
|
-
turn: {
|
|
174
|
+
turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number },
|
|
151
175
|
dir = haltDir(),
|
|
152
176
|
): boolean {
|
|
153
177
|
if (!writeHaltMarker(sessionId, marker, dir)) return false
|
|
154
178
|
const id = sessionId.toLowerCase()
|
|
155
|
-
|
|
179
|
+
const words = normalizePromptWords(turn.prompt)
|
|
180
|
+
if (!turn.rearm || words.length === 0) {
|
|
156
181
|
pendingRearms.delete(id)
|
|
157
182
|
return true
|
|
158
183
|
}
|
|
159
184
|
const now = turn.now ?? Date.now()
|
|
185
|
+
const windowMs = typeof turn.windowMs === 'number' && Number.isFinite(turn.windowMs) && turn.windowMs > 0
|
|
186
|
+
? Math.min(turn.windowMs, HALT_MARKER_TTL_MS)
|
|
187
|
+
: HALT_MARKER_TTL_MS
|
|
160
188
|
pendingRearms.delete(id)
|
|
161
|
-
pendingRearms.set(id, { marker,
|
|
189
|
+
pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs })
|
|
162
190
|
while (pendingRearms.size > MAX_PENDING_REARMS) {
|
|
163
191
|
const oldest = pendingRearms.keys().next()
|
|
164
192
|
if (oldest.done) break
|
|
@@ -167,10 +195,26 @@ export function haltDeliveredTurn(
|
|
|
167
195
|
return true
|
|
168
196
|
}
|
|
169
197
|
|
|
198
|
+
/** Whitespace runs collapsed to one space, ends trimmed. Non-strings are empty. */
|
|
199
|
+
export function normalizePromptWords(text: unknown): string {
|
|
200
|
+
return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Is `prompt` (a UserPromptSubmit's `prompt`) the delivered prompt? Equal words, compared
|
|
205
|
+
* whole, after taking off Claude Code's peer wrapper when the prompt arrived in one.
|
|
206
|
+
*/
|
|
207
|
+
export function isDeliveredPrompt(prompt: unknown, delivered: string): boolean {
|
|
208
|
+
const want = normalizePromptWords(delivered)
|
|
209
|
+
if (want.length === 0 || typeof prompt !== 'string') return false
|
|
210
|
+
return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
|
|
211
|
+
}
|
|
212
|
+
|
|
170
213
|
/**
|
|
171
214
|
* A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
|
|
172
|
-
* the session's marker.
|
|
173
|
-
*
|
|
215
|
+
* the session's marker. The FIRST such prompt after the send spends the pending re-arm: when
|
|
216
|
+
* it is the delivered turn's own prompt the marker is written back, otherwise the re-arm is
|
|
217
|
+
* dropped and logged. True when the marker was re-armed.
|
|
174
218
|
*/
|
|
175
219
|
export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
|
|
176
220
|
const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
|
|
@@ -181,8 +225,11 @@ export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: u
|
|
|
181
225
|
return false
|
|
182
226
|
}
|
|
183
227
|
if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
|
|
184
|
-
if (typeof prompt !== 'string' || !prompt.includes(pending.promptMarker)) return false
|
|
185
228
|
pendingRearms.delete(id)
|
|
229
|
+
if (!isDeliveredPrompt(prompt, pending.words)) {
|
|
230
|
+
console.warn(`[session-halt] re-arm dropped: the first prompt after the hand-off was not the delivered turn session=${id.slice(0, 8)} clientCancelId=${pending.marker.clientCancelId}`)
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
186
233
|
return writeHaltMarker(id, pending.marker, dir)
|
|
187
234
|
}
|
|
188
235
|
|
|
@@ -17,7 +17,7 @@ import { SessionHookLedger } from './session-hook-ledger.js'
|
|
|
17
17
|
import { startSpoolIngester, type SpoolIngester, type SpoolStats } from './session-hook-spool.js'
|
|
18
18
|
import { SessionSignalStore, type SessionSignal } from './session-signal-store.js'
|
|
19
19
|
import { OPEN_TURN_CEILING_MS, deriveSessionState, type DerivedSessionState, type RegistryFacts, type TranscriptFacts } from './session-state-derive.js'
|
|
20
|
-
import { clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
|
|
20
|
+
import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionEnd, dropHaltRearm, haltDeliveredTurn, rearmHaltOnPrompt, sweepHaltMarkers, type HaltMarker } from './session-halt.js'
|
|
21
21
|
import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
|
|
22
22
|
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
23
23
|
|
|
@@ -235,10 +235,17 @@ export function claudeDeskRunning(sessionId: string, now = Date.now(), dir = cla
|
|
|
235
235
|
* hooks do not yet show the turn started (its UserPromptSubmit at or after `sentAt`), it is
|
|
236
236
|
* re-armed once when that prompt's own UserPromptSubmit deletes it (`haltDeliveredTurn`).
|
|
237
237
|
*/
|
|
238
|
-
export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: {
|
|
238
|
+
export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: { prompt: string; sentAt: number; unverified?: boolean }, now = Date.now()): boolean {
|
|
239
239
|
const signal = signalFor(sessionId)
|
|
240
240
|
const started = !!signal && typeof signal.turnStartedAt === 'number' && signal.turnStartedAt >= turn.sentAt
|
|
241
|
-
return haltDeliveredTurn(sessionId, marker, {
|
|
241
|
+
return haltDeliveredTurn(sessionId, marker, {
|
|
242
|
+
prompt: turn.prompt,
|
|
243
|
+
after: turn.sentAt,
|
|
244
|
+
rearm: !started,
|
|
245
|
+
now,
|
|
246
|
+
// 6.53.3 /qa: an unverified hand-off (`maybe`) holds its re-arm two minutes, not an hour.
|
|
247
|
+
windowMs: turn.unverified === true ? HALT_REARM_UNVERIFIED_MS : HALT_MARKER_TTL_MS,
|
|
248
|
+
})
|
|
242
249
|
}
|
|
243
250
|
|
|
244
251
|
let ledger: SessionHookLedger | null = null
|
|
@@ -96,8 +96,7 @@
|
|
|
96
96
|
// and answer 400. They never treat an unparsed body as an empty one.
|
|
97
97
|
|
|
98
98
|
import type { FenceRecord } from '../lib/thread-fence-store.js'
|
|
99
|
-
import {
|
|
100
|
-
import { fenceLiveness, makePidStartProbe, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
|
|
99
|
+
import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
|
|
101
100
|
import { Router, type Request, type Response } from 'express'
|
|
102
101
|
import { createHash, randomUUID } from 'node:crypto'
|
|
103
102
|
import {
|
|
@@ -125,7 +124,7 @@ import {
|
|
|
125
124
|
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
126
125
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
127
126
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
128
|
-
import { PEER_VERIFY_TIMEOUT_MS
|
|
127
|
+
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
129
128
|
import {
|
|
130
129
|
CANCEL_QUEUE_HOLD_MS,
|
|
131
130
|
CLIENT_CANCEL_ID_RE,
|
|
@@ -460,7 +459,7 @@ export interface CancelDeps {
|
|
|
460
459
|
* have reached) the open session. Writes the halt marker, and re-arms it once when that
|
|
461
460
|
* turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
|
|
462
461
|
*/
|
|
463
|
-
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: {
|
|
462
|
+
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
|
|
464
463
|
/** Append to `data/session-cancel.jsonl`. */
|
|
465
464
|
ledger?: (row: SessionCancelLedgerRow) => void
|
|
466
465
|
}
|
|
@@ -1560,17 +1559,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1560
1559
|
const guard = deps.guard ?? new TargetGuard(deps.fencePersistence ?? null)
|
|
1561
1560
|
// `ps -o lstart=` is the only start-time keyword macOS ps offers. Injectable so a
|
|
1562
1561
|
// test drives recycled and unreadable pids without spawning real processes.
|
|
1563
|
-
const livenessDeps: FenceLivenessDeps = deps.liveness ?? {
|
|
1564
|
-
pidStartMs: makePidStartProbe((pid) => {
|
|
1565
|
-
try {
|
|
1566
|
-
return execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
1567
|
-
encoding: 'utf8', timeout: 2_000,
|
|
1568
|
-
})
|
|
1569
|
-
} catch {
|
|
1570
|
-
return null // not running, or ps refused the pid
|
|
1571
|
-
}
|
|
1572
|
-
}),
|
|
1573
|
-
}
|
|
1562
|
+
const livenessDeps: FenceLivenessDeps = deps.liveness ?? { pidStartMs: defaultPidStartProbe() }
|
|
1574
1563
|
// 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
|
|
1575
1564
|
// gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
|
|
1576
1565
|
guard.adoptCancelLiveness(livenessDeps)
|
|
@@ -1691,8 +1680,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1691
1680
|
}) ?? 'unsupported'
|
|
1692
1681
|
if (target === 'desk_run') {
|
|
1693
1682
|
const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
|
|
1694
|
-
|
|
1683
|
+
// 6.53.3 /qa: the WHOLE delivered prompt (the re-arm matches it exactly), and whether
|
|
1684
|
+
// the hand-off was only possibly delivered (`maybe`: a two-minute re-arm, not an hour).
|
|
1685
|
+
prompt: turn.prompt,
|
|
1695
1686
|
sentAt: turn.sentAt,
|
|
1687
|
+
unverified: reached === 'maybe',
|
|
1696
1688
|
}) === true, false)
|
|
1697
1689
|
if (armed) {
|
|
1698
1690
|
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
package/server/routes/health.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../lib/whisper-local.js'
|
|
23
23
|
import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
|
|
24
24
|
import { getMeetingSummaryBudgetState } from '../lib/meeting-summary-budget.js'
|
|
25
|
+
import { lensGistPublicHealth } from '../lib/lens-gist.js'
|
|
25
26
|
import { meetingSummaryLLMEnabled } from '../lib/meeting-summary.js'
|
|
26
27
|
import { getKeyStatus } from '../lib/openai-key.js'
|
|
27
28
|
import {
|
|
@@ -410,6 +411,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
410
411
|
whisper_health,
|
|
411
412
|
openai_whisper_budget,
|
|
412
413
|
meeting_summary,
|
|
414
|
+
// 6.54.0: the lens gist engine (lib/lens-gist.ts); detail on /api/lens-gist/config.
|
|
415
|
+
lens_gist: lensGistPublicHealth(),
|
|
413
416
|
tts_local,
|
|
414
417
|
codex_models,
|
|
415
418
|
cursor_models,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import {
|
|
3
|
+
LensGistInputError,
|
|
4
|
+
getLensGist,
|
|
5
|
+
lensGistHealth,
|
|
6
|
+
listLensGistEngines,
|
|
7
|
+
normalizeLensGistInput,
|
|
8
|
+
saveLensGistConfig,
|
|
9
|
+
} from '../lib/lens-gist.js'
|
|
10
|
+
import { maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
11
|
+
import { errMsg } from '../lib/utils.js'
|
|
12
|
+
|
|
13
|
+
// Server 6.54.0. The phone asks for the three-line card behind the G2 prompt box
|
|
14
|
+
// (lib/lens-gist.ts). Every answer the server gives is a 200 with a `status`, so the
|
|
15
|
+
// phone never reads "the server answered no" as "the server is unreachable" (gotchas:
|
|
16
|
+
// an HTTP status means the server ANSWERED). Only a malformed request is a 400.
|
|
17
|
+
export const lensGistRouter = Router()
|
|
18
|
+
|
|
19
|
+
lensGistRouter.post('/lens-gist', async (req, res) => {
|
|
20
|
+
let input
|
|
21
|
+
try {
|
|
22
|
+
input = normalizeLensGistInput(req.body)
|
|
23
|
+
} catch (err) {
|
|
24
|
+
res.status(err instanceof LensGistInputError ? err.status : 400).json({ error: errMsg(err) })
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
// Outside the request lease (index.ts lifecycleOwned): a drain for an update or restart
|
|
28
|
+
// closes admissions, and a gist is the first thing to give way.
|
|
29
|
+
if (!maintenanceAdmissionsOpen()) {
|
|
30
|
+
res.json({ status: 'unavailable', reason: 'maintenance' })
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
// No abort on disconnect: the phone asks ahead of the hold and may not wait, and the
|
|
34
|
+
// answer is cached for when it asks again.
|
|
35
|
+
try {
|
|
36
|
+
res.json(await getLensGist(input))
|
|
37
|
+
} catch (err) {
|
|
38
|
+
res.status(500).json({ error: errMsg(err) })
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
lensGistRouter.get('/lens-gist/config', async (_req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
res.json({ ...lensGistHealth(), engines: await listLensGistEngines() })
|
|
45
|
+
} catch (err) {
|
|
46
|
+
res.status(500).json({ error: errMsg(err) })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
lensGistRouter.put('/lens-gist/config', (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const saved = saveLensGistConfig(req.body ?? {})
|
|
53
|
+
const overridden = !!process.env.COS_LENS_GIST_ENGINE?.trim()
|
|
54
|
+
res.json({ saved: true, effective: saved, ...(overridden ? { overriddenByEnv: 'COS_LENS_GIST_ENGINE' } : {}) })
|
|
55
|
+
} catch (err) {
|
|
56
|
+
res.status(err instanceof LensGistInputError ? err.status : 500).json({ error: errMsg(err) })
|
|
57
|
+
}
|
|
58
|
+
})
|