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