@jigging/agent-acp 0.0.0 → 0.1.0-alpha.3

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.
@@ -0,0 +1,242 @@
1
+ /** Framing is data transport, not permission to dispatch an ACP operation. */
2
+ export const FINITE_ACP_LIMITS = Object.freeze({
3
+ fragmentBytes: 8_192,
4
+ frameBytes: 16_777_216,
5
+ requestBytes: 8_388_608,
6
+ responseBytes: 33_554_432,
7
+ requestFrames: 64,
8
+ responseFrames: 8_192,
9
+ fragments: 16_384,
10
+ identifierBytes: 1_024,
11
+ })
12
+
13
+ export type FiniteAcpConfiguration =
14
+ | { readonly configId: string; readonly value: string }
15
+ | { readonly configId: string; readonly type: 'boolean'; readonly value: boolean }
16
+
17
+ export interface FiniteAcpReady {
18
+ readonly kind: 'ready'
19
+ readonly protocolVersion: 1
20
+ readonly cwd: '/work'
21
+ readonly maxTurns: number
22
+ readonly configuration: readonly FiniteAcpConfiguration[]
23
+ readonly modeId?: string
24
+ readonly restoreSessionId?: string
25
+ }
26
+
27
+ export interface FiniteAcpFragment {
28
+ readonly kind: 'data'
29
+ readonly text: string
30
+ readonly end: boolean
31
+ }
32
+
33
+ export class FiniteAcpTransportError extends Error {
34
+ constructor(message: string) {
35
+ super(message)
36
+ this.name = 'FiniteAcpTransportError'
37
+ }
38
+ }
39
+
40
+ const encoder = new TextEncoder()
41
+
42
+ /** Does not invoke getters or accept non-data objects at a library boundary. */
43
+ function object(value: unknown): Record<string, unknown> {
44
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
45
+ invalid('Expected an object')
46
+ const prototype = Object.getPrototypeOf(value)
47
+ if (prototype !== null && prototype !== Object.prototype) invalid('Expected a data object')
48
+ const result: Record<string, unknown> = Object.create(null)
49
+ for (const key of Reflect.ownKeys(value)) {
50
+ if (typeof key !== 'string') invalid('Unexpected object key')
51
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
52
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'))
53
+ invalid('Expected data properties')
54
+ result[key] = descriptor.value
55
+ }
56
+ return result
57
+ }
58
+
59
+ function exact(
60
+ value: Record<string, unknown>,
61
+ required: readonly string[],
62
+ optional: readonly string[] = [],
63
+ ): void {
64
+ if (
65
+ required.some((key) => !Object.hasOwn(value, key)) ||
66
+ Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))
67
+ )
68
+ invalid('Unexpected object fields')
69
+ }
70
+
71
+ /** Count scalar UTF-8 bytes without replacing invalid Unicode or allocating it. */
72
+ function textBytes(value: unknown, maximum: number, empty = false): number {
73
+ if (typeof value !== 'string' || (!empty && value.length === 0)) invalid('Expected nonempty text')
74
+ let bytes = 0
75
+ for (let index = 0; index < value.length; index++) {
76
+ const unit = value.charCodeAt(index)
77
+ if (unit >= 0xd800 && unit <= 0xdbff) {
78
+ const next = value.charCodeAt(++index)
79
+ if (!(next >= 0xdc00 && next <= 0xdfff)) invalid('Text is not Unicode scalar data')
80
+ bytes += 4
81
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) invalid('Text is not Unicode scalar data')
82
+ else bytes += unit < 0x80 ? 1 : unit < 0x800 ? 2 : 3
83
+ if (bytes > maximum) invalid('Text exceeds the finite ACP byte limit')
84
+ }
85
+ return bytes
86
+ }
87
+
88
+ function identifier(value: unknown): string {
89
+ textBytes(value, FINITE_ACP_LIMITS.identifierBytes)
90
+ if ((value as string).includes('\0')) invalid('Identifier contains NUL')
91
+ return value as string
92
+ }
93
+
94
+ /** Snapshot the host's non-secret ready record; this copy grants no authority. */
95
+ export function readFiniteAcpReady(value: unknown): FiniteAcpReady {
96
+ const record = object(value)
97
+ exact(
98
+ record,
99
+ ['kind', 'protocolVersion', 'cwd', 'maxTurns', 'configuration'],
100
+ ['modeId', 'restoreSessionId'],
101
+ )
102
+ if (
103
+ record.kind !== 'ready' ||
104
+ record.protocolVersion !== 1 ||
105
+ record.cwd !== '/work' ||
106
+ typeof record.maxTurns !== 'number' ||
107
+ !Number.isSafeInteger(record.maxTurns) ||
108
+ record.maxTurns < 1 ||
109
+ record.maxTurns > 8 ||
110
+ !Array.isArray(record.configuration) ||
111
+ record.configuration.length > 16
112
+ )
113
+ invalid('Invalid finite ACP ready record')
114
+ const names = new Set<string>()
115
+ const input = record.configuration
116
+ if (
117
+ Object.getPrototypeOf(input) !== Array.prototype ||
118
+ Reflect.ownKeys(input).length !== input.length + 1
119
+ )
120
+ invalid('Configuration must be a dense data array')
121
+ const configuration = Array.from({ length: input.length }, (_, index) => {
122
+ const descriptor = Object.getOwnPropertyDescriptor(input, String(index))
123
+ if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable)
124
+ invalid('Configuration must be a dense data array')
125
+ const item = descriptor.value
126
+ const entry = object(item)
127
+ exact(entry, ['configId', 'value'], ['type'])
128
+ const configId = identifier(entry.configId)
129
+ if (names.has(configId)) invalid('Duplicate finite ACP configuration')
130
+ names.add(configId)
131
+ if (entry.type === 'boolean') {
132
+ if (typeof entry.value !== 'boolean') invalid('Invalid Boolean configuration')
133
+ return Object.freeze({ configId, type: 'boolean' as const, value: entry.value })
134
+ }
135
+ if (Object.hasOwn(entry, 'type')) invalid('Unsupported finite ACP configuration')
136
+ return Object.freeze({ configId, value: identifier(entry.value) })
137
+ })
138
+ const ready: FiniteAcpReady = Object.freeze({
139
+ kind: 'ready',
140
+ protocolVersion: 1,
141
+ cwd: '/work',
142
+ maxTurns: record.maxTurns as number,
143
+ configuration: Object.freeze(configuration),
144
+ ...(Object.hasOwn(record, 'modeId') ? { modeId: identifier(record.modeId) } : {}),
145
+ ...(Object.hasOwn(record, 'restoreSessionId')
146
+ ? { restoreSessionId: identifier(record.restoreSessionId) }
147
+ : {}),
148
+ })
149
+ if (encoder.encode(JSON.stringify(ready)).byteLength > 65_536)
150
+ invalid('Ready record exceeds the channel item bound')
151
+ return ready
152
+ }
153
+
154
+ /** One complete JSON frame, split only between Unicode scalars. No replay IDs. */
155
+ export function* fragmentFiniteAcpFrame(text: string): Generator<FiniteAcpFragment> {
156
+ textBytes(text, FINITE_ACP_LIMITS.frameBytes)
157
+ let start = 0
158
+ let bytes = 0
159
+ for (let index = 0; index < text.length; ) {
160
+ const code = text.codePointAt(index)!
161
+ const width = code > 0xffff ? 2 : 1
162
+ const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4
163
+ if (bytes + size > FINITE_ACP_LIMITS.fragmentBytes) {
164
+ yield Object.freeze({ kind: 'data', text: text.slice(start, index), end: false })
165
+ start = index
166
+ bytes = 0
167
+ }
168
+ bytes += size
169
+ index += width
170
+ }
171
+ yield Object.freeze({ kind: 'data', text: text.slice(start), end: true })
172
+ }
173
+
174
+ /** Bounded single-frame assembly. Failure permanently invalidates this stream. */
175
+ export class FiniteAcpFrames {
176
+ private readonly maximumBytes: number
177
+ private readonly maximumFrames: number
178
+ private totalBytes = 0
179
+ private frameBytes = 0
180
+ private frames = 0
181
+ private fragments = 0
182
+ private parts: string[] = []
183
+ private ended = false
184
+ private failed = false
185
+
186
+ constructor(direction: 'requests' | 'responses') {
187
+ if (direction !== 'requests' && direction !== 'responses')
188
+ invalid('Unknown finite ACP direction')
189
+ this.maximumBytes =
190
+ direction === 'requests' ? FINITE_ACP_LIMITS.requestBytes : FINITE_ACP_LIMITS.responseBytes
191
+ this.maximumFrames =
192
+ direction === 'requests' ? FINITE_ACP_LIMITS.requestFrames : FINITE_ACP_LIMITS.responseFrames
193
+ }
194
+
195
+ accept(value: unknown): string | undefined {
196
+ return this.guard(() => {
197
+ const record = object(value)
198
+ exact(record, ['kind', 'text', 'end'])
199
+ if (record.kind !== 'data' || typeof record.end !== 'boolean')
200
+ invalid('Invalid finite ACP fragment')
201
+ const bytes = textBytes(record.text, FINITE_ACP_LIMITS.fragmentBytes)
202
+ this.frameBytes += bytes
203
+ this.totalBytes += bytes
204
+ this.fragments++
205
+ if (
206
+ this.frameBytes > FINITE_ACP_LIMITS.frameBytes ||
207
+ this.totalBytes > this.maximumBytes ||
208
+ this.fragments > FINITE_ACP_LIMITS.fragments
209
+ )
210
+ invalid('Finite ACP stream exceeds its bounds')
211
+ this.parts.push(record.text as string)
212
+ if (!record.end) return undefined
213
+ if (++this.frames > this.maximumFrames) invalid('Finite ACP stream has too many frames')
214
+ const frame = this.parts.join('')
215
+ this.parts = []
216
+ this.frameBytes = 0
217
+ return frame
218
+ })
219
+ }
220
+
221
+ finish(): void {
222
+ this.guard(() => {
223
+ if (this.parts.length !== 0) invalid('Finite ACP stream ended inside a frame')
224
+ this.ended = true
225
+ })
226
+ }
227
+
228
+ private guard<T>(action: () => T): T {
229
+ if (this.failed || this.ended) invalid('Finite ACP stream is not open')
230
+ try {
231
+ return action()
232
+ } catch (error) {
233
+ this.failed = true
234
+ this.parts = []
235
+ throw error
236
+ }
237
+ }
238
+ }
239
+
240
+ function invalid(message: string): never {
241
+ throw new FiniteAcpTransportError(message)
242
+ }
package/src/updates.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { ChannelSender, JsonValue } from '@jigging/flow'
2
+
3
+ const encoder = new TextEncoder()
4
+ const MAX_ITEMS = 16
5
+ const MAX_BYTES = 262_144
6
+ const SEND_WAIT_MS = 500
7
+
8
+ /** Optional presentation never backpressures the essential ACP conversation. */
9
+ export class OptionalUpdates {
10
+ private readonly stopped = new AbortController()
11
+ private readonly queue: { value: JsonValue; bytes: number }[] = []
12
+ private bytes = 0
13
+ private pumping: Promise<void> | undefined
14
+ private incomplete = false
15
+
16
+ constructor(
17
+ private readonly sender: ChannelSender | undefined,
18
+ private readonly signal: AbortSignal,
19
+ ) {}
20
+
21
+ offer(value: JsonValue): void {
22
+ if (!this.sender || this.incomplete || this.signal.aborted) return
23
+ const bytes = encoder.encode(JSON.stringify(value)).byteLength
24
+ if (bytes > 65_536 || this.queue.length >= MAX_ITEMS || this.bytes + bytes > MAX_BYTES) {
25
+ this.stop()
26
+ return
27
+ }
28
+ this.queue.push({ value, bytes })
29
+ this.bytes += bytes
30
+ this.pumping ??= this.pump()
31
+ }
32
+
33
+ async finish(): Promise<void> {
34
+ await this.pumping
35
+ if (!this.sender) return
36
+ try {
37
+ await this.sender.close(this.incomplete ? { error: 'LAGGED' } : undefined)
38
+ } catch {
39
+ this.stop()
40
+ }
41
+ }
42
+
43
+ private async pump(): Promise<void> {
44
+ try {
45
+ while (this.queue.length && !this.incomplete) {
46
+ const item = this.queue[0]!
47
+ const timer = new AbortController()
48
+ const timeout = setTimeout(() => timer.abort(), SEND_WAIT_MS)
49
+ try {
50
+ await this.sender!.send(item.value, {
51
+ signal: AbortSignal.any([this.signal, this.stopped.signal, timer.signal]),
52
+ })
53
+ } finally {
54
+ clearTimeout(timeout)
55
+ }
56
+ if (!this.incomplete) {
57
+ this.queue.shift()
58
+ this.bytes -= item.bytes
59
+ }
60
+ }
61
+ } catch {
62
+ this.stop()
63
+ } finally {
64
+ this.pumping = undefined
65
+ }
66
+ }
67
+
68
+ private stop(): void {
69
+ if (this.incomplete) return
70
+ this.incomplete = true
71
+ this.queue.length = 0
72
+ this.bytes = 0
73
+ this.stopped.abort()
74
+ console.error('Agent progress is incomplete; the execution result remains separate.')
75
+ }
76
+ }