@margay/ccl-core 1.3.5-rc.6
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/INTENT-GATEWAY-GUIDE.md +82 -0
- package/QUICKSTART.md +188 -0
- package/README.md +286 -0
- package/dist/cli.js +735032 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/sdk/index.js +12 -0
- package/dist/sdk/mcpServer.d.ts +52 -0
- package/dist/sdk/mcpServer.js +134 -0
- package/dist/sdk/query.d.ts +91 -0
- package/dist/sdk/query.js +697 -0
- package/dist/sdk/queryHandle.d.ts +41 -0
- package/dist/sdk/queryHandle.js +414 -0
- package/dist/sdk/store.d.ts +16 -0
- package/dist/sdk/store.js +73 -0
- package/dist/sdk/transport.d.ts +20 -0
- package/dist/sdk/transport.js +100 -0
- package/dist/sdk/types.d.ts +423 -0
- package/dist/sdk/types.js +16 -0
- package/dist/vendor/ripgrep/VERSION +8 -0
- package/dist/vendor/ripgrep/arm64-darwin/rg +0 -0
- package/dist/vendor/ripgrep/x64-darwin/rg +0 -0
- package/dist/vendor/ripgrep/x64-linux/rg +0 -0
- package/dist/vendor/ripgrep/x64-win32/rg.exe +0 -0
- package/package.json +149 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccl-sdk Query handle (P1 sdk-query-handle).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the queryWithTransport event loop in a handle that is both the
|
|
5
|
+
* AsyncGenerator<SDKEvent> of the query AND the host side of the control
|
|
6
|
+
* protocol (entrypoints/sdk/controlSchemas.ts):
|
|
7
|
+
*
|
|
8
|
+
* - The handle eagerly pumps the underlying event loop, so control methods
|
|
9
|
+
* resolve without the host ever iterating events.
|
|
10
|
+
* - Events are buffered and replayed to the iterator, so the stream a host
|
|
11
|
+
* sees is identical to the bare queryWithTransport generator.
|
|
12
|
+
* - Each control method writes one control_request and resolves with the
|
|
13
|
+
* matching control_response payload; error responses reject with the CLI's
|
|
14
|
+
* error text; once the query has ended (result delivered, transport closed,
|
|
15
|
+
* or subprocess exited) every pending and future call rejects fast — except
|
|
16
|
+
* rewindFiles, a post-run operation that stays callable between the result
|
|
17
|
+
* and close() (the child is kept alive until close() on normal completion).
|
|
18
|
+
* - Consumed events are released from the buffer (queue semantics); the
|
|
19
|
+
* narrow __diagnostics() port (not on the public Query interface) exposes
|
|
20
|
+
* {pendingCount, bufferedEventCount, cursor} for tests/soak drivers.
|
|
21
|
+
* - The initialize response the event loop already requests is parsed and
|
|
22
|
+
* cached; initializationResult()/supportedCommands()/supportedModels()/
|
|
23
|
+
* supportedAgents()/accountInfo() serve it, and reinitialize() sends a new
|
|
24
|
+
* initialize (fresh request_id) and refreshes the cache.
|
|
25
|
+
*/
|
|
26
|
+
import type { Query, QueryOptions, Transport } from './types.js';
|
|
27
|
+
/**
|
|
28
|
+
* Test-injection seam for the P1 Query handle, mirroring queryWithTransport.
|
|
29
|
+
* Production callers use query().
|
|
30
|
+
*/
|
|
31
|
+
export declare function queryHandleWithTransport(prompt: string | null, options: QueryOptions, transport: Transport): Query;
|
|
32
|
+
/** @deprecated Use the object form query({ prompt, options }) instead. */
|
|
33
|
+
export declare function query(prompt: string | AsyncIterable<unknown>, options?: QueryOptions): Query;
|
|
34
|
+
/**
|
|
35
|
+
* Run one prompt against a CCL agent session and return a Query handle: the
|
|
36
|
+
* async stream of SDKEvents plus the control methods of the wire protocol.
|
|
37
|
+
*/
|
|
38
|
+
export declare function query(params: {
|
|
39
|
+
prompt: string;
|
|
40
|
+
options?: QueryOptions;
|
|
41
|
+
}): Query;
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccl-sdk Query handle (P1 sdk-query-handle).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the queryWithTransport event loop in a handle that is both the
|
|
5
|
+
* AsyncGenerator<SDKEvent> of the query AND the host side of the control
|
|
6
|
+
* protocol (entrypoints/sdk/controlSchemas.ts):
|
|
7
|
+
*
|
|
8
|
+
* - The handle eagerly pumps the underlying event loop, so control methods
|
|
9
|
+
* resolve without the host ever iterating events.
|
|
10
|
+
* - Events are buffered and replayed to the iterator, so the stream a host
|
|
11
|
+
* sees is identical to the bare queryWithTransport generator.
|
|
12
|
+
* - Each control method writes one control_request and resolves with the
|
|
13
|
+
* matching control_response payload; error responses reject with the CLI's
|
|
14
|
+
* error text; once the query has ended (result delivered, transport closed,
|
|
15
|
+
* or subprocess exited) every pending and future call rejects fast — except
|
|
16
|
+
* rewindFiles, a post-run operation that stays callable between the result
|
|
17
|
+
* and close() (the child is kept alive until close() on normal completion).
|
|
18
|
+
* - Consumed events are released from the buffer (queue semantics); the
|
|
19
|
+
* narrow __diagnostics() port (not on the public Query interface) exposes
|
|
20
|
+
* {pendingCount, bufferedEventCount, cursor} for tests/soak drivers.
|
|
21
|
+
* - The initialize response the event loop already requests is parsed and
|
|
22
|
+
* cached; initializationResult()/supportedCommands()/supportedModels()/
|
|
23
|
+
* supportedAgents()/accountInfo() serve it, and reinitialize() sends a new
|
|
24
|
+
* initialize (fresh request_id) and refreshes the cache.
|
|
25
|
+
*/
|
|
26
|
+
import { queryWithTransport, spawnForOptions } from './query.js';
|
|
27
|
+
let requestCounter = 0;
|
|
28
|
+
const nextRequestId = () => `ccl-sdk-req-${++requestCounter}-${Math.random().toString(36).slice(2, 10)}`;
|
|
29
|
+
class QueryHandle {
|
|
30
|
+
transport;
|
|
31
|
+
options;
|
|
32
|
+
pending = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* UNCONSUMED events only (C3 buffer release): pump pushes, next() shifts.
|
|
35
|
+
* Events handed to the iterator are dropped immediately, so retained memory
|
|
36
|
+
* is bounded by the host's consumption lag, not the stream length. FIFO
|
|
37
|
+
* push/shift keeps the consumed sequence identical to the bare
|
|
38
|
+
* queryWithTransport generator (parity).
|
|
39
|
+
*/
|
|
40
|
+
eventQueue = [];
|
|
41
|
+
/** Cumulative count of events already handed to the iterator. */
|
|
42
|
+
consumedCount = 0;
|
|
43
|
+
eventWaiters = [];
|
|
44
|
+
pumpDone = false;
|
|
45
|
+
pumpError;
|
|
46
|
+
ended = false;
|
|
47
|
+
endReason = 'ccl-sdk: query has ended';
|
|
48
|
+
/**
|
|
49
|
+
* Soft end: the result event was delivered. Control methods reject fast
|
|
50
|
+
* (locked by C3/C1c), but the child is deliberately kept alive until
|
|
51
|
+
* close() so inherently post-run requests (rewind_files) still round-trip.
|
|
52
|
+
*/
|
|
53
|
+
resultDelivered = false;
|
|
54
|
+
/** Hard end: the transport is truly gone — nothing can round-trip. */
|
|
55
|
+
transportDead = false;
|
|
56
|
+
residualReaderStarted = false;
|
|
57
|
+
streamDone = false;
|
|
58
|
+
/**
|
|
59
|
+
* Single shared iterator over transport.read(): consumed by tapRead()
|
|
60
|
+
* during the query and by the residual reader afterwards. Manual next()
|
|
61
|
+
* calls (never for-await) so abandoning tapRead cannot tear the stream
|
|
62
|
+
* down via AsyncIterator.return().
|
|
63
|
+
*/
|
|
64
|
+
innerRead;
|
|
65
|
+
initResult;
|
|
66
|
+
initPromise;
|
|
67
|
+
initResolve;
|
|
68
|
+
initReject;
|
|
69
|
+
constructor(prompt, options, transport) {
|
|
70
|
+
this.transport = transport;
|
|
71
|
+
this.options = options;
|
|
72
|
+
this.innerRead = transport.read()[Symbol.asyncIterator]();
|
|
73
|
+
this.initPromise = new Promise((resolve, reject) => {
|
|
74
|
+
this.initResolve = resolve;
|
|
75
|
+
this.initReject = reject;
|
|
76
|
+
});
|
|
77
|
+
// The init promise is settled internally; hosts that never ask for the
|
|
78
|
+
// initialization result must not see an unhandled rejection.
|
|
79
|
+
this.initPromise.catch(() => { });
|
|
80
|
+
void this.pump(prompt, options);
|
|
81
|
+
}
|
|
82
|
+
// ------------------------------------------------------------- event pump
|
|
83
|
+
async pump(prompt, options) {
|
|
84
|
+
try {
|
|
85
|
+
for await (const event of queryWithTransport(prompt, options, this.tap())) {
|
|
86
|
+
this.eventQueue.push(event);
|
|
87
|
+
if (event.type === 'result') {
|
|
88
|
+
this.resultDelivered = true;
|
|
89
|
+
this.markEnded('ccl-sdk: query has ended (result delivered)');
|
|
90
|
+
}
|
|
91
|
+
this.notifyEventWaiters();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.pumpError = error;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.pumpDone = true;
|
|
99
|
+
// Normal completion (result) is a SOFT end — the child stays alive for
|
|
100
|
+
// post-run control requests until close(). Every other exit (stream
|
|
101
|
+
// ended, suspended, error) really closed the transport: hard end.
|
|
102
|
+
if (!this.resultDelivered) {
|
|
103
|
+
this.markDead('ccl-sdk: query has ended (transport closed)');
|
|
104
|
+
}
|
|
105
|
+
this.notifyEventWaiters();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Transport seam handed to the inner event loop: writes are sniffed so the
|
|
110
|
+
* loop's own initialize request feeds the handle's cache (no second
|
|
111
|
+
* initialize on the wire), reads are observed so control_responses settle
|
|
112
|
+
* the handle's pending requests before the loop sees them.
|
|
113
|
+
*/
|
|
114
|
+
tap() {
|
|
115
|
+
const inner = this.transport;
|
|
116
|
+
return {
|
|
117
|
+
read: () => this.tapRead(),
|
|
118
|
+
write: async (message) => {
|
|
119
|
+
if (message.type === 'control_request') {
|
|
120
|
+
const request = message.request;
|
|
121
|
+
if (request?.subtype === 'initialize') {
|
|
122
|
+
this.trackInitialize(String(message.request_id ?? ''));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await inner.write(message);
|
|
126
|
+
},
|
|
127
|
+
close: async () => {
|
|
128
|
+
// Normal completion (result delivered) DEFERS the real close to
|
|
129
|
+
// handle.close(): the CLI's stdin message loop keeps serving control
|
|
130
|
+
// requests after the result, which is what makes the post-run
|
|
131
|
+
// rewind_files round-trip possible. Abort/error paths (no result)
|
|
132
|
+
// close for real, exactly as before.
|
|
133
|
+
if (this.resultDelivered && !this.transportDead)
|
|
134
|
+
return;
|
|
135
|
+
await inner.close();
|
|
136
|
+
},
|
|
137
|
+
exited: inner.exited,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async *tapRead() {
|
|
141
|
+
try {
|
|
142
|
+
for (;;) {
|
|
143
|
+
const next = await this.innerRead.next();
|
|
144
|
+
if (next.done) {
|
|
145
|
+
this.streamDone = true;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this.observe(next.value);
|
|
149
|
+
yield next.value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
// The inner event loop stopped consuming (result delivered / early
|
|
154
|
+
// return). Keep observing control_responses on the SAME iterator so
|
|
155
|
+
// post-run control requests (rewind_files) still settle.
|
|
156
|
+
this.startResidualReader();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Post-loop observer: drains the shared read iterator after the inner
|
|
160
|
+
* event loop has abandoned it, settling pending control requests. */
|
|
161
|
+
startResidualReader() {
|
|
162
|
+
if (this.residualReaderStarted || this.streamDone || this.transportDead)
|
|
163
|
+
return;
|
|
164
|
+
this.residualReaderStarted = true;
|
|
165
|
+
void (async () => {
|
|
166
|
+
try {
|
|
167
|
+
for (;;) {
|
|
168
|
+
const next = await this.innerRead.next();
|
|
169
|
+
if (next.done)
|
|
170
|
+
break;
|
|
171
|
+
this.observe(next.value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// stream torn down — hard end below either way
|
|
176
|
+
}
|
|
177
|
+
this.streamDone = true;
|
|
178
|
+
this.markDead('ccl-sdk: query has ended (transport closed)');
|
|
179
|
+
})();
|
|
180
|
+
}
|
|
181
|
+
observe(message) {
|
|
182
|
+
if (message.type !== 'control_response')
|
|
183
|
+
return;
|
|
184
|
+
const response = (message.response ?? {});
|
|
185
|
+
const requestId = String(response.request_id ?? '');
|
|
186
|
+
const entry = this.pending.get(requestId);
|
|
187
|
+
if (!entry)
|
|
188
|
+
return;
|
|
189
|
+
this.pending.delete(requestId);
|
|
190
|
+
if (response.subtype === 'error') {
|
|
191
|
+
entry.reject(new Error(String(response.error ?? 'control request failed')));
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
entry.resolve(response.response);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
trackInitialize(requestId) {
|
|
198
|
+
this.pending.set(requestId, {
|
|
199
|
+
resolve: payload => {
|
|
200
|
+
const result = (payload ?? {});
|
|
201
|
+
this.initResult = result;
|
|
202
|
+
this.initResolve(result);
|
|
203
|
+
},
|
|
204
|
+
reject: error => this.initReject(error),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Soft end (result delivered): blocks NEW control calls (fast-reject in
|
|
209
|
+
* controlRequest) but deliberately does NOT reject requests already on the
|
|
210
|
+
* wire — the child keeps serving its stdin loop until close(), and the
|
|
211
|
+
* residual reader settles their late responses. (Soak C4 %5 rounds: fast
|
|
212
|
+
* turns delivered the result while requests were in flight; rejecting them
|
|
213
|
+
* here was a real SDK bug.)
|
|
214
|
+
*/
|
|
215
|
+
markEnded(reason) {
|
|
216
|
+
if (this.ended)
|
|
217
|
+
return;
|
|
218
|
+
this.ended = true;
|
|
219
|
+
this.endReason = reason;
|
|
220
|
+
}
|
|
221
|
+
/** Hard end: transport gone/closed — every in-flight request must reject
|
|
222
|
+
* (nothing can answer it anymore). */
|
|
223
|
+
markDead(reason) {
|
|
224
|
+
this.markEnded(reason);
|
|
225
|
+
if (this.transportDead)
|
|
226
|
+
return;
|
|
227
|
+
this.transportDead = true;
|
|
228
|
+
this.endReason = reason;
|
|
229
|
+
this.rejectAllPending(new Error(reason));
|
|
230
|
+
}
|
|
231
|
+
rejectAllPending(error) {
|
|
232
|
+
for (const entry of this.pending.values())
|
|
233
|
+
entry.reject(error);
|
|
234
|
+
this.pending.clear();
|
|
235
|
+
}
|
|
236
|
+
notifyEventWaiters() {
|
|
237
|
+
const waiters = this.eventWaiters;
|
|
238
|
+
this.eventWaiters = [];
|
|
239
|
+
for (const wake of waiters)
|
|
240
|
+
wake();
|
|
241
|
+
}
|
|
242
|
+
// -------------------------------------------------------- control channel
|
|
243
|
+
controlRequest(request, opts = {}) {
|
|
244
|
+
// Soft end (result delivered, child still alive) blocks every control
|
|
245
|
+
// method except explicitly post-run ones; hard end blocks everything.
|
|
246
|
+
const blocked = this.transportDead || (this.ended && !opts.allowAfterResult);
|
|
247
|
+
if (blocked)
|
|
248
|
+
return Promise.reject(new Error(this.endReason));
|
|
249
|
+
const requestId = nextRequestId();
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
this.pending.set(requestId, { resolve, reject });
|
|
252
|
+
this.transport
|
|
253
|
+
.write({ type: 'control_request', request_id: requestId, request })
|
|
254
|
+
.catch(error => {
|
|
255
|
+
if (this.pending.delete(requestId)) {
|
|
256
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
interrupt() {
|
|
262
|
+
return this.controlRequest({ subtype: 'interrupt' });
|
|
263
|
+
}
|
|
264
|
+
setPermissionMode(mode) {
|
|
265
|
+
return this.controlRequest({ subtype: 'set_permission_mode', mode });
|
|
266
|
+
}
|
|
267
|
+
setModel(model) {
|
|
268
|
+
return this.controlRequest({
|
|
269
|
+
subtype: 'set_model',
|
|
270
|
+
...(model !== undefined && { model }),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
setMaxThinkingTokens(n) {
|
|
274
|
+
return this.controlRequest({
|
|
275
|
+
subtype: 'set_max_thinking_tokens',
|
|
276
|
+
max_thinking_tokens: n,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
mcpServerStatus() {
|
|
280
|
+
return this.controlRequest({ subtype: 'mcp_status' });
|
|
281
|
+
}
|
|
282
|
+
reconnectMcpServer(name) {
|
|
283
|
+
return this.controlRequest({ subtype: 'mcp_reconnect', serverName: name });
|
|
284
|
+
}
|
|
285
|
+
toggleMcpServer(name, enabled) {
|
|
286
|
+
return this.controlRequest({ subtype: 'mcp_toggle', serverName: name, enabled });
|
|
287
|
+
}
|
|
288
|
+
setMcpServers(servers) {
|
|
289
|
+
return this.controlRequest({ subtype: 'mcp_set_servers', servers });
|
|
290
|
+
}
|
|
291
|
+
stopTask(id) {
|
|
292
|
+
return this.controlRequest({ subtype: 'stop_task', task_id: id });
|
|
293
|
+
}
|
|
294
|
+
applyFlagSettings(settings) {
|
|
295
|
+
return this.controlRequest({ subtype: 'apply_flag_settings', settings });
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Rewind file changes made since a user message. Wire shape follows
|
|
299
|
+
* SDKControlRewindFilesRequestSchema (entrypoints/sdk/controlSchemas.ts):
|
|
300
|
+
* subtype "rewind_files", user_message_id, optional dry_run — pass
|
|
301
|
+
* `{ dry_run: true }` via options. Handler: cli/print.ts (rewind_files).
|
|
302
|
+
* Rewinding is inherently a post-run operation, so unlike every other
|
|
303
|
+
* control method it is allowed after the result event (the child is kept
|
|
304
|
+
* alive until close(); see tap().close).
|
|
305
|
+
*/
|
|
306
|
+
rewindFiles(userMessageId, options) {
|
|
307
|
+
return this.controlRequest({ ...options, subtype: 'rewind_files', user_message_id: userMessageId }, { allowAfterResult: true });
|
|
308
|
+
}
|
|
309
|
+
// ------------------------------------------------------------- initialize
|
|
310
|
+
initializationResult() {
|
|
311
|
+
if (this.initResult)
|
|
312
|
+
return Promise.resolve(this.initResult);
|
|
313
|
+
if (this.ended)
|
|
314
|
+
return Promise.reject(new Error(this.endReason));
|
|
315
|
+
return this.initPromise;
|
|
316
|
+
}
|
|
317
|
+
async reinitialize() {
|
|
318
|
+
const payload = await this.controlRequest({
|
|
319
|
+
subtype: 'initialize',
|
|
320
|
+
...(this.options.systemPrompt !== undefined && {
|
|
321
|
+
systemPrompt: this.options.systemPrompt,
|
|
322
|
+
}),
|
|
323
|
+
...(this.options.appendSystemPrompt !== undefined && {
|
|
324
|
+
appendSystemPrompt: this.options.appendSystemPrompt,
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
const result = (payload ?? {});
|
|
328
|
+
this.initResult = result;
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
async supportedCommands() {
|
|
332
|
+
return (await this.initializationResult()).commands;
|
|
333
|
+
}
|
|
334
|
+
async supportedModels() {
|
|
335
|
+
return (await this.initializationResult()).models;
|
|
336
|
+
}
|
|
337
|
+
async supportedAgents() {
|
|
338
|
+
return (await this.initializationResult()).agents;
|
|
339
|
+
}
|
|
340
|
+
async accountInfo() {
|
|
341
|
+
return (await this.initializationResult()).account;
|
|
342
|
+
}
|
|
343
|
+
async close() {
|
|
344
|
+
this.markDead('ccl-sdk: query closed');
|
|
345
|
+
await this.transport.close();
|
|
346
|
+
}
|
|
347
|
+
// ----------------------------------------------------------- diagnostics
|
|
348
|
+
/**
|
|
349
|
+
* Narrow test/ops port (C3, sdk-e2e-endurance) — deliberately NOT part of
|
|
350
|
+
* the public Query interface in sdk/types.ts. pendingCount = in-flight
|
|
351
|
+
* control requests; bufferedEventCount = events buffered but not yet
|
|
352
|
+
* consumed by the iterator; cursor = cumulative consumed events.
|
|
353
|
+
*/
|
|
354
|
+
__diagnostics() {
|
|
355
|
+
return {
|
|
356
|
+
pendingCount: this.pending.size,
|
|
357
|
+
bufferedEventCount: this.eventQueue.length,
|
|
358
|
+
cursor: this.consumedCount,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// ---------------------------------------------------- AsyncGenerator face
|
|
362
|
+
async next() {
|
|
363
|
+
for (;;) {
|
|
364
|
+
// Queue semantics release consumed events immediately (C3 buffer
|
|
365
|
+
// release); the Query is a single-iterator AsyncGenerator, so FIFO
|
|
366
|
+
// shift preserves the exact bare-generator sequence (parity).
|
|
367
|
+
const event = this.eventQueue.shift();
|
|
368
|
+
if (event !== undefined) {
|
|
369
|
+
this.consumedCount++;
|
|
370
|
+
return { value: event, done: false };
|
|
371
|
+
}
|
|
372
|
+
if (this.pumpDone) {
|
|
373
|
+
if (this.pumpError !== undefined) {
|
|
374
|
+
const error = this.pumpError;
|
|
375
|
+
this.pumpError = undefined;
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
return { value: undefined, done: true };
|
|
379
|
+
}
|
|
380
|
+
await new Promise(resolve => this.eventWaiters.push(resolve));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async return(_value) {
|
|
384
|
+
await this.close().catch(() => { });
|
|
385
|
+
return { value: undefined, done: true };
|
|
386
|
+
}
|
|
387
|
+
async throw(error) {
|
|
388
|
+
await this.close().catch(() => { });
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
[Symbol.asyncIterator]() {
|
|
392
|
+
return this;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Test-injection seam for the P1 Query handle, mirroring queryWithTransport.
|
|
397
|
+
* Production callers use query().
|
|
398
|
+
*/
|
|
399
|
+
export function queryHandleWithTransport(prompt, options, transport) {
|
|
400
|
+
return new QueryHandle(prompt, options, transport);
|
|
401
|
+
}
|
|
402
|
+
export function query(promptOrParams, positionalOptions) {
|
|
403
|
+
const positional = typeof promptOrParams === 'string' ||
|
|
404
|
+
(typeof promptOrParams === 'object' &&
|
|
405
|
+
promptOrParams !== null &&
|
|
406
|
+
Symbol.asyncIterator in promptOrParams);
|
|
407
|
+
const prompt = positional
|
|
408
|
+
? promptOrParams
|
|
409
|
+
: promptOrParams.prompt;
|
|
410
|
+
const options = (positional
|
|
411
|
+
? positionalOptions
|
|
412
|
+
: promptOrParams.options) ?? {};
|
|
413
|
+
return queryHandleWithTransport(prompt, options, spawnForOptions(options));
|
|
414
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccl-sdk session store (issue P0-3) — pluggable persistence for SDK-level
|
|
3
|
+
* session state: pending tool approvals (P0-2 pending) and accumulated usage
|
|
4
|
+
* (P1-7). The conversation transcript itself stays owned by the CLI's
|
|
5
|
+
* existing file store (~/.ccl/projects/**), which is what --resume reads;
|
|
6
|
+
* this store holds the state the CLI cannot know about (a host-side approval
|
|
7
|
+
* that outlives the process).
|
|
8
|
+
*
|
|
9
|
+
* The file implementation is the reference implementation; Redis/DB backends
|
|
10
|
+
* implement the same SessionStore interface (deliberately small: six async
|
|
11
|
+
* methods, JSON-serializable records).
|
|
12
|
+
*/
|
|
13
|
+
import type { SessionStore } from './types.js';
|
|
14
|
+
export declare function defaultStoreDir(): string;
|
|
15
|
+
/** Local-file reference implementation of SessionStore. */
|
|
16
|
+
export declare function fileSessionStore(dir?: string): SessionStore;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccl-sdk session store (issue P0-3) — pluggable persistence for SDK-level
|
|
3
|
+
* session state: pending tool approvals (P0-2 pending) and accumulated usage
|
|
4
|
+
* (P1-7). The conversation transcript itself stays owned by the CLI's
|
|
5
|
+
* existing file store (~/.ccl/projects/**), which is what --resume reads;
|
|
6
|
+
* this store holds the state the CLI cannot know about (a host-side approval
|
|
7
|
+
* that outlives the process).
|
|
8
|
+
*
|
|
9
|
+
* The file implementation is the reference implementation; Redis/DB backends
|
|
10
|
+
* implement the same SessionStore interface (deliberately small: six async
|
|
11
|
+
* methods, JSON-serializable records).
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises';
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
const EMPTY_USAGE = {
|
|
17
|
+
queries: 0,
|
|
18
|
+
inputTokens: 0,
|
|
19
|
+
outputTokens: 0,
|
|
20
|
+
cacheCreationInputTokens: 0,
|
|
21
|
+
cacheReadInputTokens: 0,
|
|
22
|
+
totalCostUsd: 0,
|
|
23
|
+
};
|
|
24
|
+
function safeSessionId(sessionId) {
|
|
25
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
|
|
26
|
+
throw new Error(`ccl-sdk store: invalid session id '${sessionId}' (path-safe characters only)`);
|
|
27
|
+
}
|
|
28
|
+
return sessionId;
|
|
29
|
+
}
|
|
30
|
+
export function defaultStoreDir() {
|
|
31
|
+
return (process.env.CCL_SDK_STORE_DIR ?? join(homedir(), '.ccl', 'sdk-store'));
|
|
32
|
+
}
|
|
33
|
+
/** Local-file reference implementation of SessionStore. */
|
|
34
|
+
export function fileSessionStore(dir = defaultStoreDir()) {
|
|
35
|
+
const pendingPath = (sessionId) => join(dir, `${safeSessionId(sessionId)}.pending.json`);
|
|
36
|
+
const usagePath = (sessionId) => join(dir, `${safeSessionId(sessionId)}.usage.json`);
|
|
37
|
+
async function writeAtomic(path, data) {
|
|
38
|
+
await mkdir(dir, { recursive: true });
|
|
39
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
40
|
+
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
41
|
+
await rename(tmp, path);
|
|
42
|
+
}
|
|
43
|
+
async function readJson(path) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
savePendingApproval: async (record) => writeAtomic(pendingPath(record.sessionId), record),
|
|
53
|
+
loadPendingApproval: async (sessionId) => readJson(pendingPath(sessionId)),
|
|
54
|
+
deletePendingApproval: async (sessionId) => {
|
|
55
|
+
await rm(pendingPath(sessionId), { force: true });
|
|
56
|
+
},
|
|
57
|
+
addUsage: async (sessionId, usage, costUsd) => {
|
|
58
|
+
const current = (await readJson(usagePath(sessionId))) ?? EMPTY_USAGE;
|
|
59
|
+
const next = {
|
|
60
|
+
queries: current.queries + 1,
|
|
61
|
+
inputTokens: current.inputTokens + (usage.input_tokens ?? 0),
|
|
62
|
+
outputTokens: current.outputTokens + (usage.output_tokens ?? 0),
|
|
63
|
+
cacheCreationInputTokens: current.cacheCreationInputTokens +
|
|
64
|
+
(usage.cache_creation_input_tokens ?? 0),
|
|
65
|
+
cacheReadInputTokens: current.cacheReadInputTokens + (usage.cache_read_input_tokens ?? 0),
|
|
66
|
+
totalCostUsd: current.totalCostUsd + (costUsd ?? 0),
|
|
67
|
+
};
|
|
68
|
+
await writeAtomic(usagePath(sessionId), next);
|
|
69
|
+
return next;
|
|
70
|
+
},
|
|
71
|
+
getUsage: async (sessionId) => readJson(usagePath(sessionId)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed-subprocess transport for ccl-sdk.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the CCL CLI in headless stream-json mode and frames NDJSON in both
|
|
5
|
+
* directions. No TTY is ever allocated (stdio is all pipes), satisfying the
|
|
6
|
+
* library-form "no stdin/terminal interaction" requirement — the CLI's print
|
|
7
|
+
* mode already has no interactive code path when --input-format/--output-format
|
|
8
|
+
* are stream-json.
|
|
9
|
+
*/
|
|
10
|
+
import type { Transport } from './types.js';
|
|
11
|
+
export type SpawnTransportOptions = {
|
|
12
|
+
execPath: string;
|
|
13
|
+
cliPath: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
cwd?: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
/** Receives non-JSON stderr/stdout noise for host-side logging. */
|
|
18
|
+
onDiagnostic?: (line: string) => void;
|
|
19
|
+
};
|
|
20
|
+
export declare function spawnCliTransport(opts: SpawnTransportOptions): Transport;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed-subprocess transport for ccl-sdk.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the CCL CLI in headless stream-json mode and frames NDJSON in both
|
|
5
|
+
* directions. No TTY is ever allocated (stdio is all pipes), satisfying the
|
|
6
|
+
* library-form "no stdin/terminal interaction" requirement — the CLI's print
|
|
7
|
+
* mode already has no interactive code path when --input-format/--output-format
|
|
8
|
+
* are stream-json.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
export function spawnCliTransport(opts) {
|
|
13
|
+
const child = spawn(opts.execPath, [opts.cliPath, ...opts.args], {
|
|
14
|
+
cwd: opts.cwd,
|
|
15
|
+
env: { ...process.env, ...opts.env },
|
|
16
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
17
|
+
});
|
|
18
|
+
child.stderr.setEncoding('utf8');
|
|
19
|
+
child.stderr.on('data', (chunk) => {
|
|
20
|
+
if (opts.onDiagnostic) {
|
|
21
|
+
for (const line of chunk.split('\n')) {
|
|
22
|
+
if (line.trim())
|
|
23
|
+
opts.onDiagnostic(line);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const exited = new Promise(resolve => {
|
|
28
|
+
child.once('close', code => resolve(code));
|
|
29
|
+
child.once('error', () => resolve(null));
|
|
30
|
+
});
|
|
31
|
+
let closed = false;
|
|
32
|
+
async function* read() {
|
|
33
|
+
child.stdout.setEncoding('utf8');
|
|
34
|
+
let buffer = '';
|
|
35
|
+
for await (const chunk of child.stdout) {
|
|
36
|
+
buffer += chunk;
|
|
37
|
+
if (buffer.length > MAX_LINE_BYTES) {
|
|
38
|
+
// A single runaway line must not OOM the host (see YW1975/ccl#14 for
|
|
39
|
+
// why unbounded accumulation is a real failure mode). Drop through
|
|
40
|
+
// the next newline and surface a diagnostic.
|
|
41
|
+
const nl = buffer.indexOf('\n');
|
|
42
|
+
if (nl === -1) {
|
|
43
|
+
opts.onDiagnostic?.(`ccl-sdk: dropping oversized output line (> ${MAX_LINE_BYTES} bytes)`);
|
|
44
|
+
buffer = '';
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
49
|
+
while (newlineIndex !== -1) {
|
|
50
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
51
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
52
|
+
newlineIndex = buffer.indexOf('\n');
|
|
53
|
+
if (!line)
|
|
54
|
+
continue;
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(line);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
opts.onDiagnostic?.(line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (typeof parsed === 'object' &&
|
|
64
|
+
parsed !== null &&
|
|
65
|
+
typeof parsed.type === 'string') {
|
|
66
|
+
yield parsed;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
opts.onDiagnostic?.(line);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
read,
|
|
76
|
+
// Child PID for host-side monitoring/cleanup (RSS sampling, leftover
|
|
77
|
+
// sweeps). process.title rewriting makes pgrep-by-argv unreliable, so
|
|
78
|
+
// the transport must expose it (sdk-e2e-endurance C4).
|
|
79
|
+
pid: child.pid,
|
|
80
|
+
write: (message) => new Promise((resolve, reject) => {
|
|
81
|
+
if (closed || !child.stdin.writable) {
|
|
82
|
+
reject(new Error('ccl-sdk transport: stdin is closed'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
child.stdin.write(`${JSON.stringify(message)}\n`, err => err ? reject(err) : resolve());
|
|
86
|
+
}),
|
|
87
|
+
close: async () => {
|
|
88
|
+
if (closed)
|
|
89
|
+
return;
|
|
90
|
+
closed = true;
|
|
91
|
+
child.stdin.end();
|
|
92
|
+
// Give the CLI a moment to flush + exit on its own before killing.
|
|
93
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
94
|
+
child.kill('SIGTERM');
|
|
95
|
+
await exited;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
},
|
|
98
|
+
exited,
|
|
99
|
+
};
|
|
100
|
+
}
|