dsh-hitl 0.1.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/lib/fields.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Proposal construction: turning one pending tool execution plus its mount into
3
+ * the wire request the browser renders, and turning a human decision back into
4
+ * the text the model reads.
5
+ *
6
+ * Defaults follow the plugin's core promise: with no configuration at all, the
7
+ * proposal is the call's own parameters — one field per parameter, titled by the
8
+ * parameter's key, rendered as an editable markdown box.
9
+ */
10
+
11
+ import { LIMITS, truncate } from './protocol.js'
12
+ import { defaultEditable } from './resolve.js'
13
+
14
+ /** Model-facing prefix every reason this plugin produces starts with. */
15
+ export const REASON_PREFIX = 'HITL'
16
+
17
+ /** Render a parameter value as proposal text. */
18
+ export function renderValue(value) {
19
+ if (typeof value === 'string') return value
20
+ if (value === undefined) return '(undefined)'
21
+ try {
22
+ const json = JSON.stringify(value, null, 2)
23
+ return json === undefined ? String(value) : json
24
+ } catch {
25
+ return String(value)
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The default renderer of one parameter value: an editable markdown box, for
31
+ * every value. A non-string value reaches it as pretty-printed JSON text, and a
32
+ * mount that prefers the read-only JSON inspector says `render: 'json'`.
33
+ * @returns the render mode, always `'markdown'`.
34
+ */
35
+ export function inferRender() {
36
+ return 'markdown'
37
+ }
38
+
39
+ function schemaOf(execution) {
40
+ const parameters = execution.schema?.parameters
41
+ return typeof parameters === 'object' && parameters !== null ? parameters : undefined
42
+ }
43
+
44
+ function schemaEntry(execution, param) {
45
+ const schema = schemaOf(execution)
46
+ const entry = schema === undefined ? undefined : schema[param]
47
+ return typeof entry === 'object' && entry !== null ? entry : undefined
48
+ }
49
+
50
+ function argumentsOf(execution) {
51
+ const args = execution.arguments
52
+ return typeof args === 'object' && args !== null && !Array.isArray(args) ? args : undefined
53
+ }
54
+
55
+ /**
56
+ * Derive the default field list of one call: every parameter, in argument order.
57
+ * @param execution - the pending tool execution.
58
+ * @returns field specs (`{ param, title? }`) before values are attached.
59
+ */
60
+ export function defaultFieldSpecs(execution) {
61
+ const args = argumentsOf(execution)
62
+ if (args === undefined) return [{ param: '(arguments)', render: 'json' }]
63
+ const keys = Object.keys(args)
64
+ if (keys.length === 0) return [{ param: '(arguments)', render: 'json' }]
65
+ return keys.map(param => ({ param }))
66
+ }
67
+
68
+ function diffSpec(spec, mount, execution) {
69
+ const requested = spec.diff ?? mount.options.diff
70
+ if (requested === undefined || requested === null || typeof requested !== 'object') return undefined
71
+ const before = requested.before
72
+ const after = requested.after
73
+ if (typeof before !== 'string' || typeof after !== 'string') return undefined
74
+ const args = argumentsOf(execution)
75
+ if (args === undefined) return undefined
76
+ return {
77
+ path: typeof requested.path === 'string' ? renderValue(args[requested.path]) : undefined,
78
+ oldText: renderValue(args[before]),
79
+ newText: renderValue(args[after]),
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Build the wire fields of one request.
85
+ * @param execution - the pending tool execution.
86
+ * @param mount - the matched mount.
87
+ * @returns an ordered field array, values already rendered and truncated.
88
+ */
89
+ export function buildFields(execution, mount) {
90
+ const options = mount.options
91
+ const args = argumentsOf(execution)
92
+ const mountDiff = options.diff === undefined || options.diff === null ? undefined : options.diff
93
+ // A mount-level diff pairs two parameters, so those parameters must not also
94
+ // appear as their own fields in the defaulted list.
95
+ const paired = options.fields === undefined && mountDiff !== undefined
96
+ ? new Set([mountDiff.before, mountDiff.after].filter(entry => typeof entry === 'string'))
97
+ : new Set()
98
+ const specs = options.fields
99
+ ?? defaultFieldSpecs(execution).filter(spec => !paired.has(spec.param))
100
+ const fields = []
101
+ for (const spec of specs) {
102
+ const param = spec.param
103
+ const schema = schemaEntry(execution, param)
104
+ // The scalar fallback field reads the whole argument value; every other
105
+ // field reads its own parameter out of the argument record.
106
+ const raw = param === '(arguments)' && args === undefined
107
+ ? execution.arguments
108
+ : (args === undefined ? undefined : args[param])
109
+ const render = spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
110
+ const diff = render === 'diff' ? diffSpec(spec, mount, execution) : undefined
111
+ const title = spec.title
112
+ ?? (typeof schema?.title === 'string' ? schema.title : undefined)
113
+ ?? param
114
+ const description = spec.description
115
+ ?? (typeof schema?.description === 'string' ? schema.description : undefined)
116
+ const editable = spec.editable ?? defaultEditable(render)
117
+ // A hidden field carries no value: it exists only to stay out of the panel.
118
+ const value = diff === undefined && render !== 'hidden'
119
+ ? truncate(renderValue(raw), options.maxFieldChars)
120
+ : { text: '', truncated: false }
121
+ fields.push({
122
+ param,
123
+ title,
124
+ ...(description === undefined ? {} : { description }),
125
+ render,
126
+ editable: render === 'diff' || render === 'hidden' ? false : editable,
127
+ labels: spec.labels ?? [],
128
+ ...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff }),
129
+ })
130
+ }
131
+ if (options.fields === undefined && mountDiff !== undefined) {
132
+ const diff = diffSpec({ diff: mountDiff }, mount, execution)
133
+ if (diff !== undefined) {
134
+ fields.push({
135
+ param: `${options.diff.before}→${options.diff.after}`,
136
+ title: typeof options.diff.title === 'string' ? options.diff.title : 'diff',
137
+ render: 'diff',
138
+ editable: false,
139
+ labels: [],
140
+ diff,
141
+ })
142
+ }
143
+ }
144
+ if (fields.length === 0) {
145
+ return [{ param: '(arguments)', title: '(arguments)', render: 'json', editable: false, labels: [], value: '{}', truncated: false }]
146
+ }
147
+ return fields
148
+ }
149
+
150
+ /**
151
+ * Build one complete wire request.
152
+ * @param input - `{ execution, mount, sessionId, id, now }`.
153
+ * @returns the request object broadcast to browsers.
154
+ */
155
+ export function buildRequest({ execution, mount, sessionId, id, now = Date.now() }) {
156
+ const options = mount.options
157
+ const fields = buildFields(execution, mount)
158
+ const countdown = options.countdown === null ? null : {
159
+ remainingMs: options.countdown.seconds * 1000,
160
+ action: options.countdown.action,
161
+ freezeOnInteract: options.countdown.freezeOnInteract,
162
+ }
163
+ return {
164
+ id,
165
+ sessionId,
166
+ toolName: execution.name,
167
+ ...(execution.callId === undefined ? {} : { callId: String(execution.callId) }),
168
+ ...(options.title === undefined ? {} : { title: options.title }),
169
+ layout: options.layout,
170
+ labels: options.labels,
171
+ fields,
172
+ buttons: {
173
+ ...options.buttons,
174
+ feedback: {
175
+ enabled: options.reject.feedback,
176
+ ...(options.reject.feedbackPrompt === undefined ? {} : { prompt: options.reject.feedbackPrompt }),
177
+ required: options.reject.requireFeedback,
178
+ },
179
+ },
180
+ countdown,
181
+ createdAt: now,
182
+ }
183
+ }
184
+
185
+ /** Whether a decision carries a human edit of the proposal. */
186
+ export function isRevision(decision) {
187
+ return decision.kind === 'modify' && decision.fields.length > 0
188
+ }
189
+
190
+ /**
191
+ * Render the human's edited fields as text the model can act on.
192
+ * @param decision - a modify decision.
193
+ * @returns one block per changed field.
194
+ */
195
+ export function revisionText(decision) {
196
+ return decision.fields
197
+ .map(entry => `- ${entry.param}:\n${entry.text}`)
198
+ .join('\n')
199
+ }
200
+
201
+ /**
202
+ * Fold one settled decision into the single message the model reads.
203
+ * @param request - the request the human decided on.
204
+ * @param decision - the normalized decision.
205
+ * @param source - `user`, `timeout`, `abort`, or `host`.
206
+ * @returns the model-facing reason text.
207
+ */
208
+ export function describeDecision(request, decision, source) {
209
+ const tool = JSON.stringify(request.toolName)
210
+ if (source === 'timeout') {
211
+ const seconds = Math.round((request.countdown?.remainingMs ?? 0) / 1000)
212
+ if (decision.kind === 'approve') return `${REASON_PREFIX}: no human decision arrived within ${seconds}s, so tool ${tool} ran under the mount's timeout policy.`
213
+ if (request.countdown?.action === 'notify') {
214
+ return `${REASON_PREFIX}: no human decision arrived within ${seconds}s, so tool ${tool} did not run. Tell the user this call timed out and wait for an explicit instruction before running it.`
215
+ }
216
+ return `${REASON_PREFIX}: no human decision arrived within ${seconds}s, so tool ${tool} did not run (timeout policy: reject).`
217
+ }
218
+ if (source === 'abort' || source === 'host') {
219
+ return `${REASON_PREFIX}: the decision for tool ${tool} was withdrawn before the user answered, so it did not run.`
220
+ }
221
+ if (decision.kind === 'modify') {
222
+ return `${REASON_PREFIX}: the user revised the proposal, so tool ${tool} did not run. Re-issue the call with these changes:\n${revisionText(decision)}`
223
+ }
224
+ const feedback = decision.kind === 'reject' && typeof decision.feedback === 'string' && decision.feedback !== ''
225
+ ? ` User feedback: ${decision.feedback}`
226
+ : ''
227
+ return `${REASON_PREFIX}: the user rejected tool ${tool} and it did not run.${feedback}`
228
+ }
229
+
230
+ /**
231
+ * Fold one approved-with-edits decision into context the model reads alongside
232
+ * the tool result (`modify.mode: allow-and-inform`).
233
+ * @param request - the request the human approved.
234
+ * @param decision - a modify decision.
235
+ * @returns the follow-up text.
236
+ */
237
+ export function revisionContext(request, decision) {
238
+ return `${REASON_PREFIX}: the user approved tool ${JSON.stringify(request.toolName)} after editing the proposal. Their revised content:\n${revisionText(decision)}`
239
+ }
240
+
241
+ /** Cap one model-facing text at the protocol limit. */
242
+ export function capReason(text) {
243
+ return truncate(text, LIMITS.maxDecisionChars).text
244
+ }
package/lib/pending.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The pending-decision registry: one state machine per open HITL request,
3
+ * owning its countdown, its interaction hold, and the exactly-once settlement
4
+ * that releases the blocked tool call.
5
+ *
6
+ * The clock is injected so tests drive seconds without waiting for them, and so
7
+ * every timer this plugin arms passes through one place that can be cleared on
8
+ * unload.
9
+ */
10
+
11
+ import { ERROR_CODES, OUTCOMES } from './protocol.js'
12
+
13
+ /** Default cap on how long one browser may freeze a countdown by interacting. */
14
+ export const DEFAULT_HOLD_GRACE_MS = 300000
15
+
16
+ /** Decision a countdown action applies when it reaches zero. */
17
+ export function timeoutDecision(action) {
18
+ if (action === 'approve') return { kind: 'approve' }
19
+ return { kind: 'reject' }
20
+ }
21
+
22
+ /**
23
+ * Create one process-wide pending registry.
24
+ * @param options - injected clock/timers, settlement sink, and hold grace.
25
+ * @returns the registry API used by the host half.
26
+ */
27
+ export function createPendingRegistry({
28
+ now = Date.now,
29
+ setTimer = setTimeout,
30
+ clearTimer = clearTimeout,
31
+ onSettle = () => {},
32
+ holdGraceMs = DEFAULT_HOLD_GRACE_MS,
33
+ } = {}) {
34
+ const entries = new Map()
35
+ const settledOrder = []
36
+ // Settled identities outlive their entry so a late or duplicated decision is
37
+ // reported as already-settled rather than as an unknown request. The set is
38
+ // only a diagnostic, so it is dropped wholesale rather than aged.
39
+ const settledIds = new Set()
40
+ const SETTLED_MEMORY = 4096
41
+
42
+ /** Verdict for an identity that has no open entry. */
43
+ function missing(id) {
44
+ return settledIds.has(id) ? ERROR_CODES.alreadySettled : ERROR_CODES.unknownRequest
45
+ }
46
+
47
+ function clearEntryTimers(entry) {
48
+ if (entry.timer !== undefined) {
49
+ clearTimer(entry.timer)
50
+ entry.timer = undefined
51
+ }
52
+ if (entry.graceTimer !== undefined) {
53
+ clearTimer(entry.graceTimer)
54
+ entry.graceTimer = undefined
55
+ }
56
+ }
57
+
58
+ function armCountdown(entry) {
59
+ if (entry.countdownMs === null || entry.settled) return
60
+ const delay = Math.max(0, entry.remainingMs)
61
+ entry.timer = setTimer(() => {
62
+ entry.timer = undefined
63
+ close(entry.id, OUTCOMES.timeout, timeoutDecision(entry.countdownAction))
64
+ }, delay)
65
+ }
66
+
67
+ function releaseHold(entry) {
68
+ if (!entry.held) return
69
+ entry.held = false
70
+ entry.holdExpiresAt = undefined
71
+ if (entry.graceTimer !== undefined) {
72
+ clearTimer(entry.graceTimer)
73
+ entry.graceTimer = undefined
74
+ }
75
+ // Re-base the countdown so the frozen remainder is what still runs, and so a
76
+ // later hold computes the remainder from the resumed clock rather than from
77
+ // the original deadline that already passed.
78
+ entry.startedAt = now() + entry.remainingMs - entry.countdownMs
79
+ armCountdown(entry)
80
+ }
81
+
82
+ function close(id, source, decision) {
83
+ const entry = entries.get(id)
84
+ if (entry === undefined) return { accepted: false, code: missing(id) }
85
+ if (entry.settled) return { accepted: false, code: ERROR_CODES.alreadySettled }
86
+ clearEntryTimers(entry)
87
+ entry.settled = true
88
+ entry.settledAt = now()
89
+ entry.source = source
90
+ entry.decision = decision
91
+ entries.delete(id)
92
+ settledOrder.push(id)
93
+ settledIds.add(id)
94
+ if (settledIds.size > SETTLED_MEMORY) settledIds.clear()
95
+ onSettle(entry, { decision, source })
96
+ return { accepted: true }
97
+ }
98
+
99
+ return {
100
+ /**
101
+ * Register one open decision.
102
+ * @param input - identity, wire request, countdown policy, and callbacks.
103
+ * @returns `{ ok: true }` or `{ ok: false, code }` for a duplicate id.
104
+ */
105
+ open({ id, request, countdown, onTimeout }) {
106
+ if (entries.has(id)) return { ok: false, code: ERROR_CODES.alreadySettled }
107
+ const countdownMs = countdown === null || countdown === undefined ? null : countdown.remainingMs
108
+ const entry = {
109
+ id,
110
+ request,
111
+ countdownMs,
112
+ countdownAction: countdown?.action ?? 'reject',
113
+ freezeOnInteract: countdown?.freezeOnInteract !== false,
114
+ onTimeout,
115
+ remainingMs: countdownMs ?? 0,
116
+ startedAt: now(),
117
+ held: false,
118
+ holdExpiresAt: undefined,
119
+ timer: undefined,
120
+ graceTimer: undefined,
121
+ settled: false,
122
+ settledAt: undefined,
123
+ source: undefined,
124
+ decision: undefined,
125
+ }
126
+ entries.set(id, entry)
127
+ armCountdown(entry)
128
+ return { ok: true, expiresAt: countdownMs === null ? undefined : entry.startedAt + countdownMs }
129
+ },
130
+
131
+ /** Apply the human's decision. First call wins; later calls are reported. */
132
+ settle(id, decision) {
133
+ return close(id, OUTCOMES.user, decision)
134
+ },
135
+
136
+ /**
137
+ * Freeze or resume one countdown while its browser holds the decision.
138
+ * @param id - request identity.
139
+ * @param held - whether the browser is still interacting.
140
+ * @returns the acceptance, and when a hold stops applying on its own.
141
+ */
142
+ hold(id, held) {
143
+ const entry = entries.get(id)
144
+ if (entry === undefined) return { ok: false, code: missing(id) }
145
+ if (entry.settled) return { ok: false, code: ERROR_CODES.alreadySettled }
146
+ if (entry.countdownMs === null) return { ok: true, held: false }
147
+ if (!entry.freezeOnInteract) return { ok: true, held: false }
148
+ if (held) {
149
+ if (entry.held) return { ok: true, held: true, expiresAt: entry.holdExpiresAt }
150
+ entry.remainingMs = Math.max(0, entry.startedAt + entry.countdownMs - now())
151
+ entry.held = true
152
+ if (entry.timer !== undefined) {
153
+ clearTimer(entry.timer)
154
+ entry.timer = undefined
155
+ }
156
+ entry.holdExpiresAt = now() + holdGraceMs
157
+ entry.graceTimer = setTimer(() => {
158
+ entry.graceTimer = undefined
159
+ releaseHold(entry)
160
+ }, holdGraceMs)
161
+ return { ok: true, held: true, expiresAt: entry.holdExpiresAt }
162
+ }
163
+ releaseHold(entry)
164
+ return { ok: true, held: false }
165
+ },
166
+
167
+ /** Withdraw one request (turn interruption, unload, or host-side cancel). */
168
+ abort(id, source = OUTCOMES.abort) {
169
+ return close(id, source, { kind: 'cancel' })
170
+ },
171
+
172
+ /** Withdraw every open request; used on plugin teardown. */
173
+ drain(source = OUTCOMES.host) {
174
+ const ids = [...entries.keys()]
175
+ for (const id of ids) close(id, source, { kind: 'cancel' })
176
+ return ids
177
+ },
178
+
179
+ /** Whether one request identity is still open. */
180
+ has(id) {
181
+ return entries.has(id)
182
+ },
183
+
184
+ /** The wire request behind one identity, or undefined once settled. */
185
+ get(id) {
186
+ return entries.get(id)?.request
187
+ },
188
+
189
+ /** Every open request, in registration order. */
190
+ list() {
191
+ return [...entries.values()].map(entry => entry.request)
192
+ },
193
+
194
+ /** Count of open requests. */
195
+ size() {
196
+ return entries.size
197
+ },
198
+
199
+ /** Identities settled so far, in settlement order (diagnostics and tests). */
200
+ settled() {
201
+ return [...settledOrder]
202
+ },
203
+
204
+ /** Whether one request's countdown is currently frozen by a client. */
205
+ held(id) {
206
+ return entries.get(id)?.held === true
207
+ },
208
+
209
+ /** Remaining milliseconds of one countdown, or null when it has none. */
210
+ remaining(id) {
211
+ const entry = entries.get(id)
212
+ if (entry === undefined || entry.countdownMs === null) return null
213
+ if (entry.held) return entry.remainingMs
214
+ return Math.max(0, entry.startedAt + entry.countdownMs - now())
215
+ },
216
+ }
217
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The dsh-hitl wire protocol: the normative definition of every frame the host
3
+ * half broadcasts, every payload the browser half sends back, and the limits
4
+ * both sides agree on.
5
+ *
6
+ * Both halves are plain JavaScript loaded without a build step, so `client.js`
7
+ * cannot import this module. It re-implements the few shapes it must produce;
8
+ * the tests in `tests/protocol.test.js` pin the contract the two halves share.
9
+ * Anything the wire carries is untrusted on arrival and passes a validator here
10
+ * before it reaches the gate.
11
+ */
12
+
13
+ /** Bumped when a frame shape changes incompatibly. */
14
+ export const PROTOCOL_VERSION = 1
15
+
16
+ /** The `globalThis` property the served index carries this plugin's boot facts in. */
17
+ export const GLOBAL_KEY = '__DSH_HITL__'
18
+
19
+ /** Default route prefix the host half owns on the browser HTTP carrier. */
20
+ export const DEFAULT_ENDPOINT = '/dsh-hitl'
21
+
22
+ /** Discriminator of the pending-interaction values the browser half publishes. */
23
+ export const PENDING_KIND = 'hitl'
24
+
25
+ /** Bounds applied to untrusted input and to what reaches the browser. */
26
+ export const LIMITS = {
27
+ /** Largest uplink body the decide route accepts. */
28
+ maxBodyBytes: 256 * 1024,
29
+ /** Longest rendered value of one field before truncation. */
30
+ maxFieldChars: 20000,
31
+ /** Longest decision text folded into one model-facing message. */
32
+ maxDecisionChars: 8000,
33
+ }
34
+
35
+ /** Failure vocabulary shared by the HTTP replies and the panel's error state. */
36
+ export const ERROR_CODES = {
37
+ unauthorized: 'unauthorized',
38
+ badPayload: 'bad-payload',
39
+ unknownRequest: 'unknown-request',
40
+ alreadySettled: 'already-settled',
41
+ hostGone: 'host-gone',
42
+ }
43
+
44
+ /** How a pending request left the pending set. */
45
+ export const OUTCOMES = {
46
+ user: 'user',
47
+ timeout: 'timeout',
48
+ abort: 'abort',
49
+ host: 'host',
50
+ }
51
+
52
+ /** Structural guard for plain records (not arrays, not null). */
53
+ export function isRecord(value) {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
55
+ }
56
+
57
+ /** Structural guard for the decision vocabulary the uplink may carry. */
58
+ export function isDecision(value) {
59
+ if (!isRecord(value)) return false
60
+ if (value.kind === 'approve') return true
61
+ if (value.kind === 'reject') {
62
+ return value.feedback === undefined || typeof value.feedback === 'string'
63
+ }
64
+ if (value.kind === 'modify') {
65
+ return Array.isArray(value.fields)
66
+ && value.fields.every(entry => isRecord(entry)
67
+ && typeof entry.param === 'string'
68
+ && typeof entry.text === 'string')
69
+ }
70
+ return false
71
+ }
72
+
73
+ /**
74
+ * Normalize one untrusted uplink payload.
75
+ * @param value - parsed JSON body of a decide/hold request.
76
+ * @returns `{ ok: true, value }` with a trimmed decision, or `{ ok: false, code, message }`.
77
+ */
78
+ export function parseUplink(value) {
79
+ if (!isRecord(value)) return invalid('the uplink body must be a JSON object')
80
+ if (typeof value.id !== 'string' || value.id === '') return invalid('id must be a non-empty string')
81
+ if (value.type === 'hold') {
82
+ if (typeof value.held !== 'boolean') return invalid('hold.held must be a boolean')
83
+ return { ok: true, value: { type: 'hold', id: value.id, held: value.held } }
84
+ }
85
+ if (value.type !== 'decide') return invalid(`unknown uplink type ${JSON.stringify(value.type)}`)
86
+ if (!isDecision(value.decision)) return invalid('decision must be approve, modify, or reject')
87
+ const decision = value.decision
88
+ if (decision.kind === 'reject') {
89
+ const feedback = typeof decision.feedback === 'string' ? decision.feedback.trim() : ''
90
+ return {
91
+ ok: true,
92
+ value: {
93
+ type: 'decide',
94
+ id: value.id,
95
+ decision: feedback === '' ? { kind: 'reject' } : { kind: 'reject', feedback },
96
+ },
97
+ }
98
+ }
99
+ if (decision.kind === 'modify') {
100
+ const fields = decision.fields
101
+ .map(entry => ({ param: entry.param, text: entry.text }))
102
+ .filter(entry => entry.param !== '')
103
+ if (fields.length === 0) return invalid('a modify decision must carry at least one field change')
104
+ return { ok: true, value: { type: 'decide', id: value.id, decision: { kind: 'modify', fields } } }
105
+ }
106
+ return { ok: true, value: { type: 'decide', id: value.id, decision: { kind: 'approve' } } }
107
+ }
108
+
109
+ function invalid(message) {
110
+ return { ok: false, code: ERROR_CODES.badPayload, message }
111
+ }
112
+
113
+ /** Frame announcing the complete pending set; sent on connect and on demand. */
114
+ export function frameSnapshot(requests) {
115
+ return { type: 'snapshot', version: PROTOCOL_VERSION, requests }
116
+ }
117
+
118
+ /** Frame announcing one newly pending request. */
119
+ export function frameRequest(request) {
120
+ return { type: 'request', version: PROTOCOL_VERSION, request }
121
+ }
122
+
123
+ /** Frame announcing one request left the pending set. */
124
+ export function frameSettled(id, outcome, decidedBy) {
125
+ return {
126
+ type: 'settled',
127
+ version: PROTOCOL_VERSION,
128
+ id,
129
+ outcome,
130
+ ...(decidedBy === undefined ? {} : { decidedBy }),
131
+ }
132
+ }
133
+
134
+ /** Liveness frame: it carries nothing, and proves the stream is still attached. */
135
+ export function framePing() {
136
+ return { type: 'ping', version: PROTOCOL_VERSION }
137
+ }
138
+
139
+ /**
140
+ * Frame answering a hold request: whether the host froze the countdown, when a
141
+ * hold stops applying on its own, and the remainder the host now holds.
142
+ *
143
+ * The remainder travels with the ack because a freeze and a release both move
144
+ * the deadline; a client that kept counting on its own clock would show a
145
+ * number the host disagrees with on the very frame the pause ends.
146
+ */
147
+ export function frameHoldAck(id, held, expiresAt, remainingMs) {
148
+ return {
149
+ type: 'holdAck',
150
+ version: PROTOCOL_VERSION,
151
+ id,
152
+ held,
153
+ ...(expiresAt === undefined ? {} : { expiresAt }),
154
+ ...(remainingMs === null || remainingMs === undefined ? {} : { remainingMs }),
155
+ }
156
+ }
157
+
158
+ /** One Server-Sent-Event data chunk carrying a JSON frame. */
159
+ export function sseChunk(frame) {
160
+ return `data: ${JSON.stringify(frame)}\n\n`
161
+ }
162
+
163
+ /** JSON body of a failed route call. */
164
+ export function errorBody(code, message) {
165
+ return { ok: false, code, message }
166
+ }
167
+
168
+ /** JSON body of a successful route call. */
169
+ export function okBody(accepted) {
170
+ return { ok: true, accepted }
171
+ }
172
+
173
+ /**
174
+ * Cap one string at a limit, marking that it was cut.
175
+ * @param text - value to bound.
176
+ * @param limit - maximum character count.
177
+ * @returns the value, cut with a trailing notice when it exceeded the limit.
178
+ */
179
+ export function truncate(text, limit) {
180
+ if (text.length <= limit) return { text, truncated: false }
181
+ return { text: `${text.slice(0, limit)}\n… [已截断,共 ${text.length} 字]`, truncated: true }
182
+ }