dsh-plugin-jules 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/client.js ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * A dependency-free client for the Jules v1alpha REST API.
3
+ *
4
+ * The Jules CLI is not usable here: it authenticates with an interactive OAuth
5
+ * flow kept in the OS keyring, reads no `JULES_*` environment variable, emits
6
+ * no machine-readable output, and talks to an internal backend rather than the
7
+ * documented v1alpha service. The REST API is therefore the only interface a
8
+ * long-running harness plugin can drive, and it needs nothing beyond `fetch`.
9
+ *
10
+ * @module dsh-plugin-jules/client
11
+ */
12
+ /** A Jules API call failed. */
13
+ export class JulesError extends Error {
14
+ /** HTTP status, when the failure came from a response. */
15
+ status;
16
+ constructor(message, status, options) {
17
+ super(message, options);
18
+ this.name = 'JulesError';
19
+ this.status = status;
20
+ }
21
+ }
22
+ /** The API rejected the credential, or no credential was configured. */
23
+ export class JulesAuthError extends JulesError {
24
+ constructor(message, status, options) {
25
+ super(message, status, options);
26
+ this.name = 'JulesAuthError';
27
+ }
28
+ }
29
+ /** The API asked the caller to slow down and the retry budget was exhausted. */
30
+ export class JulesRateLimitError extends JulesError {
31
+ constructor(message, status, options) {
32
+ super(message, status, options);
33
+ this.name = 'JulesRateLimitError';
34
+ }
35
+ }
36
+ /** The named resource does not exist, or is not visible to this credential. */
37
+ export class JulesNotFoundError extends JulesError {
38
+ constructor(message, status, options) {
39
+ super(message, status, options);
40
+ this.name = 'JulesNotFoundError';
41
+ }
42
+ }
43
+ /** One request outlived its configured deadline. */
44
+ export class JulesTimeoutError extends JulesError {
45
+ constructor(message, options) {
46
+ super(message, undefined, options);
47
+ this.name = 'JulesTimeoutError';
48
+ }
49
+ }
50
+ /** The transport failed before a response arrived. */
51
+ export class JulesNetworkError extends JulesError {
52
+ constructor(message, options) {
53
+ super(message, undefined, options);
54
+ this.name = 'JulesNetworkError';
55
+ }
56
+ }
57
+ /**
58
+ * The statuses a retry can plausibly fix.
59
+ *
60
+ * Deliberately not "any 5xx": 501 and 505 mean the service does not implement
61
+ * what was asked, and 400/401/403/404 are decisions rather than accidents.
62
+ * Retrying those would spend the caller's deadline re-asking a question that was
63
+ * already answered. 502/503/504 are gateway and availability failures, which are
64
+ * the transient kind.
65
+ */
66
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
67
+ /**
68
+ * Sleep, resolving early when the caller cancels.
69
+ * @param ms - milliseconds to wait.
70
+ * @param signal - cancellation signal.
71
+ */
72
+ async function delay(ms, signal) {
73
+ await new Promise((resolve, reject) => {
74
+ const onAbort = () => {
75
+ clearTimeout(timer);
76
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));
77
+ };
78
+ const timer = setTimeout(() => {
79
+ signal?.removeEventListener('abort', onAbort);
80
+ resolve();
81
+ }, ms);
82
+ if (signal?.aborted === true) {
83
+ onAbort();
84
+ return;
85
+ }
86
+ signal?.addEventListener('abort', onAbort, { once: true });
87
+ });
88
+ }
89
+ /**
90
+ * Read the service's error envelope without trusting its shape.
91
+ * @param response - the failed response.
92
+ * @returns the message to surface, or a status-derived fallback.
93
+ */
94
+ async function errorMessage(response) {
95
+ const fallback = `Jules API request failed with HTTP ${response.status}`;
96
+ try {
97
+ const text = await response.text();
98
+ if (text.length === 0)
99
+ return fallback;
100
+ const parsed = JSON.parse(text);
101
+ const message = parsed.error?.message;
102
+ return typeof message === 'string' && message.length > 0 ? message : fallback;
103
+ }
104
+ catch {
105
+ return fallback;
106
+ }
107
+ }
108
+ /**
109
+ * Map a status and message onto the most specific error class.
110
+ * @param status - HTTP status of the response.
111
+ * @param message - service-supplied message.
112
+ * @returns the error to throw.
113
+ */
114
+ function errorFor(status, message) {
115
+ if (status === 401 || status === 403)
116
+ return new JulesAuthError(message, status);
117
+ if (status === 404)
118
+ return new JulesNotFoundError(message, status);
119
+ if (status === 429)
120
+ return new JulesRateLimitError(message, status);
121
+ return new JulesError(message, status);
122
+ }
123
+ /**
124
+ * Build the AIP-160 expression that selects activities newer than a cursor.
125
+ *
126
+ * The API reference documents `?createTime=<rfc3339>`, but the service rejects
127
+ * it outright — "Cannot bind query parameter. Field 'createTime' could not be
128
+ * found in request message" — so this sends the filter expression the official
129
+ * Jules SDK uses instead. Because that form is not in the reference, callers
130
+ * must treat a rejected cursor as a bandwidth problem rather than a failure;
131
+ * {@link runWatch} does exactly that.
132
+ * @param since - RFC 3339 timestamp; strictly newer activities are returned.
133
+ * @returns the filter expression.
134
+ */
135
+ export function activitiesSinceFilter(since) {
136
+ return `create_time>"${since}"`;
137
+ }
138
+ /** Drive the Jules v1alpha REST API. */
139
+ export class JulesClient {
140
+ #options;
141
+ constructor(options) {
142
+ this.#options = options;
143
+ }
144
+ /**
145
+ * Perform one authenticated request, retrying the transient failures the
146
+ * service documents.
147
+ * @param method - HTTP method.
148
+ * @param path - path below the API root, starting with `/`.
149
+ * @param options - query, body, and caller cancellation.
150
+ * @returns the decoded JSON body, or `undefined` for an empty response.
151
+ * @throws {JulesAuthError} when no key is configured or the key is rejected.
152
+ * @throws {JulesRateLimitError} when 429 persists past the retry budget.
153
+ * @throws {JulesTimeoutError} when the deadline passes first.
154
+ * @throws {JulesNetworkError} when the transport fails.
155
+ * @throws {JulesError} for every other non-2xx response.
156
+ */
157
+ async request(method, path, options = {}) {
158
+ const apiKey = await this.#options.resolveApiKey();
159
+ if (apiKey === undefined || apiKey.length === 0) {
160
+ throw new JulesAuthError('No Jules API key is configured. Create one at https://jules.google.com/settings '
161
+ + 'and expose it as the credential named by the "apiKeyEnv" option (JULES_API_KEY by default).');
162
+ }
163
+ const url = new URL(this.#options.baseURL + path);
164
+ for (const [key, value] of Object.entries(options.query ?? {})) {
165
+ if (value !== undefined)
166
+ url.searchParams.set(key, String(value));
167
+ }
168
+ const headers = {
169
+ 'X-Goog-Api-Key': apiKey,
170
+ Accept: 'application/json',
171
+ };
172
+ if (options.body !== undefined)
173
+ headers['Content-Type'] = 'application/json';
174
+ if (this.#options.userAgent !== undefined)
175
+ headers['User-Agent'] = this.#options.userAgent;
176
+ const { maxAttempts, baseDelayMs, maxDelayMs } = this.#options.retry;
177
+ let lastError;
178
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
179
+ options.signal?.throwIfAborted();
180
+ const timeout = AbortSignal.timeout(this.#options.requestTimeoutMs);
181
+ const signal = options.signal === undefined
182
+ ? timeout
183
+ : AbortSignal.any([options.signal, timeout]);
184
+ let response;
185
+ try {
186
+ response = await (this.#options.fetchImpl ?? fetch)(url, {
187
+ method,
188
+ headers,
189
+ ...options.body === undefined ? {} : { body: JSON.stringify(options.body) },
190
+ signal,
191
+ });
192
+ }
193
+ catch (error) {
194
+ // Caller cancellation is not this client's failure to report.
195
+ options.signal?.throwIfAborted();
196
+ if (timeout.aborted) {
197
+ throw new JulesTimeoutError(`Jules API ${method} ${path} exceeded ${this.#options.requestTimeoutMs} ms`, { cause: error });
198
+ }
199
+ throw new JulesNetworkError(`Jules API ${method} ${path} could not be reached`, { cause: error });
200
+ }
201
+ if (response.ok) {
202
+ // :approvePlan and :sendMessage answer with an empty body, and 204 has no
203
+ // body by definition. Both mean "it worked", not "the response was
204
+ // missing", so they resolve to undefined rather than failing a parse.
205
+ if (response.status === 204)
206
+ return undefined;
207
+ const text = await response.text();
208
+ if (text.length === 0)
209
+ return undefined;
210
+ try {
211
+ return JSON.parse(text);
212
+ }
213
+ catch (error) {
214
+ // A 2xx that is not JSON is a broken endpoint or a proxy in the way,
215
+ // not a caller mistake; it belongs in this client's error taxonomy
216
+ // rather than surfacing as a bare SyntaxError.
217
+ throw new JulesError(`Jules API ${method} ${path} returned a body that is not JSON`, response.status, { cause: error });
218
+ }
219
+ }
220
+ const message = await errorMessage(response);
221
+ const failure = errorFor(response.status, message);
222
+ if (!RETRYABLE_STATUS.has(response.status) || attempt === maxAttempts) {
223
+ if (response.status === 429 && attempt === maxAttempts) {
224
+ throw new JulesRateLimitError(`${message} (gave up after ${attempt} attempts)`, response.status);
225
+ }
226
+ throw failure;
227
+ }
228
+ lastError = failure;
229
+ const retryAfter = Number(response.headers.get('retry-after'));
230
+ const backoff = Number.isFinite(retryAfter) && retryAfter > 0
231
+ ? retryAfter * 1000
232
+ : baseDelayMs * 2 ** (attempt - 1);
233
+ await delay(Math.min(backoff, maxDelayMs), options.signal);
234
+ }
235
+ /* c8 ignore next -- the loop returns or throws on its final attempt */
236
+ throw lastError ?? new JulesError('Jules API request failed');
237
+ }
238
+ /**
239
+ * Bounded page size for one list call.
240
+ *
241
+ * Clamped rather than rejected: a page size is a preference, and a caller
242
+ * asking for more than the service allows should get the largest page there
243
+ * is, not a failed call. The service rejects out-of-range values outright, so
244
+ * this is what keeps a model-supplied number from turning into an error.
245
+ * @param requested - the caller's preference, when it expressed one.
246
+ * @returns a page size the service accepts.
247
+ */
248
+ #pageSize(requested) {
249
+ const { defaultPageSize, maxPageSize } = this.#options;
250
+ const wanted = requested ?? defaultPageSize;
251
+ return Math.max(1, Math.min(Math.trunc(wanted), maxPageSize));
252
+ }
253
+ /**
254
+ * List the repositories connected to Jules.
255
+ * @param options - paging and cancellation.
256
+ * @returns one page of sources.
257
+ */
258
+ async listSources(options = {}) {
259
+ return this.request('GET', '/sources', {
260
+ query: { pageSize: this.#pageSize(options.pageSize), pageToken: options.pageToken },
261
+ ...options.signal === undefined ? {} : { signal: options.signal },
262
+ });
263
+ }
264
+ /**
265
+ * Read one connected repository.
266
+ * @param name - canonical source resource name.
267
+ * @param signal - cancellation signal.
268
+ * @returns the source.
269
+ */
270
+ async getSource(name, signal) {
271
+ return this.request('GET', `/${name}`, { ...signal === undefined ? {} : { signal } });
272
+ }
273
+ /**
274
+ * Create a session, which starts the remote agent immediately.
275
+ * @param input - prompt, optional source context, and automation flags.
276
+ * @param signal - cancellation signal.
277
+ * @returns the created session.
278
+ */
279
+ async createSession(input, signal) {
280
+ return this.request('POST', '/sessions', { body: input, ...signal === undefined ? {} : { signal } });
281
+ }
282
+ /**
283
+ * List sessions visible to this credential, newest first.
284
+ * @param options - paging and cancellation.
285
+ * @returns one page of sessions.
286
+ */
287
+ async listSessions(options = {}) {
288
+ return this.request('GET', '/sessions', {
289
+ query: { pageSize: this.#pageSize(options.pageSize), pageToken: options.pageToken },
290
+ ...options.signal === undefined ? {} : { signal: options.signal },
291
+ });
292
+ }
293
+ /**
294
+ * Read one session, including its outputs once it finishes.
295
+ * @param id - bare session id.
296
+ * @param signal - cancellation signal.
297
+ * @returns the session.
298
+ */
299
+ async getSession(id, signal) {
300
+ return this.request('GET', `/sessions/${encodeURIComponent(id)}`, { ...signal === undefined ? {} : { signal } });
301
+ }
302
+ /**
303
+ * Read a session's event log, oldest first.
304
+ * @param id - bare session id.
305
+ * @param options - paging, an optional `since` cursor, and cancellation.
306
+ * @returns one page of activities.
307
+ */
308
+ async listActivities(id, options = {}) {
309
+ return this.request('GET', `/sessions/${encodeURIComponent(id)}/activities`, {
310
+ query: {
311
+ pageSize: this.#pageSize(options.pageSize),
312
+ pageToken: options.pageToken,
313
+ filter: options.since === undefined ? undefined : activitiesSinceFilter(options.since),
314
+ },
315
+ ...options.signal === undefined ? {} : { signal: options.signal },
316
+ });
317
+ }
318
+ /**
319
+ * Read every activity page, oldest first.
320
+ *
321
+ * The event log is append-only and immutable, so walking it whole is safe;
322
+ * the page cap keeps a runaway log from consuming unbounded memory.
323
+ * @param id - bare session id.
324
+ * @param options - page cap, cursor, and cancellation.
325
+ * @returns every activity the walk collected.
326
+ */
327
+ async listAllActivities(id, options = {}) {
328
+ const maxPages = options.maxPages ?? 10;
329
+ const collected = [];
330
+ let pageToken;
331
+ for (let page = 0; page < maxPages; page += 1) {
332
+ const response = await this.listActivities(id, {
333
+ pageSize: options.pageSize ?? this.#options.maxPageSize,
334
+ ...pageToken === undefined ? {} : { pageToken },
335
+ ...options.since === undefined ? {} : { since: options.since },
336
+ ...options.signal === undefined ? {} : { signal: options.signal },
337
+ });
338
+ collected.push(...response.activities ?? []);
339
+ pageToken = response.nextPageToken;
340
+ if (pageToken === undefined || pageToken.length === 0)
341
+ break;
342
+ }
343
+ return collected;
344
+ }
345
+ /**
346
+ * Approve the plan a session is waiting on.
347
+ * @param id - bare session id.
348
+ * @param signal - cancellation signal.
349
+ */
350
+ async approvePlan(id, signal) {
351
+ await this.request('POST', `/sessions/${encodeURIComponent(id)}:approvePlan`, {
352
+ body: {},
353
+ ...signal === undefined ? {} : { signal },
354
+ });
355
+ }
356
+ /**
357
+ * Send a message to the session's agent.
358
+ * @param id - bare session id.
359
+ * @param prompt - what to tell the agent.
360
+ * @param signal - cancellation signal.
361
+ */
362
+ async sendMessage(id, prompt, signal) {
363
+ await this.request('POST', `/sessions/${encodeURIComponent(id)}:sendMessage`, {
364
+ body: { prompt },
365
+ ...signal === undefined ? {} : { signal },
366
+ });
367
+ }
368
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Google Jules as a harness capability: the `jules_*` tool family lets a model
3
+ * delegate a coding task to the remote Jules agent, follow it, approve its plan,
4
+ * and retrieve the diff or pull request it produced.
5
+ *
6
+ * The plugin speaks the documented Jules v1alpha REST API directly. The Jules
7
+ * CLI is deliberately not used: it authenticates through an interactive OAuth
8
+ * flow in the OS keyring, reads no `JULES_*` environment variable, produces no
9
+ * machine-readable output, and drives an internal backend rather than the
10
+ * documented one — none of which survives contact with an unattended harness
11
+ * process.
12
+ *
13
+ * @module dsh-plugin-jules
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis';
16
+ import z from '@deepseek-ai/schemastery';
17
+ /** Cordis plugin name used by loader diagnostics. */
18
+ export declare const name = "jules";
19
+ /** The tool registry, and the system prompt the guidance section joins. */
20
+ export declare const inject: string[];
21
+ /** Credential name read when the configuration does not name another one. */
22
+ export declare const DEFAULT_API_KEY_ENV = "JULES_API_KEY";
23
+ /** API root. Jules exposes one documented version, `v1alpha`. */
24
+ export declare const DEFAULT_BASE_URL = "https://jules.googleapis.com/v1alpha";
25
+ /** Where a user creates the API key this plugin reads. */
26
+ export declare const API_KEY_URL = "https://jules.google.com/settings";
27
+ /** Default per-request deadline. */
28
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
29
+ /** Default page size for list calls; the service default is 30. */
30
+ export declare const DEFAULT_PAGE_SIZE = 30;
31
+ /** Largest page size the service accepts. */
32
+ export declare const MAX_PAGE_SIZE = 100;
33
+ /** Default cap on a diff returned by `jules_patch`. */
34
+ export declare const DEFAULT_MAX_PATCH_BYTES = 200000;
35
+ /** Default wait budget for `jules_wait`. */
36
+ export declare const DEFAULT_WAIT_MS = 120000;
37
+ /** Largest wait budget a caller may request. */
38
+ export declare const MAX_WAIT_MS = 300000;
39
+ /** Delay between two `jules_wait` status polls. */
40
+ export declare const DEFAULT_POLL_INTERVAL_MS = 5000;
41
+ /** Pages of activities one read may walk. */
42
+ export declare const DEFAULT_MAX_ACTIVITY_PAGES = 10;
43
+ /** Default background watch budget for `jules_watch`. */
44
+ export declare const DEFAULT_WATCH_MS = 1800000;
45
+ /** Largest background watch budget a caller may request. */
46
+ export declare const MAX_WATCH_MS = 7200000;
47
+ /** Delay between two background watch polls. */
48
+ export declare const DEFAULT_WATCH_POLL_INTERVAL_MS = 15000;
49
+ /** Retry attempts for a rate-limited or failing request. */
50
+ export declare const DEFAULT_RETRY_MAX_ATTEMPTS = 4;
51
+ /** First backoff step for a retried request. */
52
+ export declare const DEFAULT_RETRY_BASE_DELAY_MS = 1000;
53
+ /** Ceiling for one backoff step. */
54
+ export declare const DEFAULT_RETRY_MAX_DELAY_MS = 30000;
55
+ /** Plugin configuration. Every field has a default except the credential. */
56
+ export interface Config {
57
+ /** Literal API key. Prefer {@link apiKeyEnv} so no secret enters configuration files. */
58
+ apiKey?: string;
59
+ /** Credential holding the key; defaults to `JULES_API_KEY`. */
60
+ apiKeyEnv?: string;
61
+ /** API root; defaults to the documented v1alpha endpoint. */
62
+ baseURL?: string;
63
+ /** Repository `jules_create` targets when the call omits one, as `owner/repo`. */
64
+ defaultSource?: string;
65
+ /** Per-request deadline in milliseconds. Defaults to 30000. */
66
+ requestTimeoutMs?: number;
67
+ /** Page size for list calls that do not choose one. Defaults to 30. */
68
+ defaultPageSize?: number;
69
+ /** Largest page size a caller may request. Defaults to 100. */
70
+ maxPageSize?: number;
71
+ /** Largest diff `jules_patch` returns. Defaults to 200000 bytes. */
72
+ maxPatchBytes?: number;
73
+ /** Wait budget `jules_wait` uses when the caller does not set one. Defaults to 120000 ms. */
74
+ waitDefaultMs?: number;
75
+ /** Largest wait budget a caller may request. Defaults to 300000 ms. */
76
+ waitMaxMs?: number;
77
+ /** Delay between two status polls inside `jules_wait`. Defaults to 5000 ms. */
78
+ pollIntervalMs?: number;
79
+ /** Activity pages one read may walk. Defaults to 10. */
80
+ maxActivityPages?: number;
81
+ /** Expose `jules_watch`, which watches a session through the background job registry. Defaults to true. */
82
+ enableWatch?: boolean;
83
+ /** Watch budget `jules_watch` uses when the caller does not set one. Defaults to 1800000 ms. */
84
+ watchDefaultMs?: number;
85
+ /** Largest watch budget a caller may request. Defaults to 7200000 ms. */
86
+ watchMaxMs?: number;
87
+ /** Delay between two polls inside `jules_watch`. Defaults to 15000 ms. */
88
+ watchPollIntervalMs?: number;
89
+ /** End `jules_watch` when the agent posts a message, not only on a session-state change. Defaults to true. */
90
+ watchSettleOnMessage?: boolean;
91
+ /** Attempts per request, including the first. Defaults to 4. */
92
+ retryMaxAttempts?: number;
93
+ /** First backoff step in milliseconds. Defaults to 1000. */
94
+ retryBaseDelayMs?: number;
95
+ /** Ceiling for one backoff step in milliseconds. Defaults to 30000. */
96
+ retryMaxDelayMs?: number;
97
+ }
98
+ /** Schemastery configuration for loader defaults and the generated config catalog. */
99
+ export declare const Config: z<Config>;
100
+ /** Model guidance placed beside the subagent instructions. */
101
+ export declare const JULES_PROMPT: string;
102
+ /**
103
+ * Register the Jules tool family and its model guidance.
104
+ * @param ctx - plugin context supplying the tool registry and credential seam.
105
+ * @param config - validated configuration section.
106
+ */
107
+ export declare function apply(ctx: Context, config: Config): void;