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/tools.js ADDED
@@ -0,0 +1,576 @@
1
+ /**
2
+ * The model-facing `jules_*` tool family.
3
+ *
4
+ * Every tool returns a canonical value described by its own output schema and
5
+ * renders that value to text separately, so a caller that needs an id or a
6
+ * state never has to parse the prose.
7
+ *
8
+ * @module dsh-plugin-jules/tools
9
+ */
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { sleep } from "./async.js";
12
+ import { DEFAULT_WAIT_STATES, sessionIdOf, sourceNameOf } from "./types.js";
13
+ import { activityRow, latestPatch, patchFiles, renderActivities, renderPatch, renderSessionDetail, renderSessionRows, renderSources, renderWait, sessionDetail, sessionRow, sourceRow, } from "./views.js";
14
+ import { runWatch } from "./watch.js";
15
+ /** Attempts allowed for an approval to become visible in the log. */
16
+ const APPROVAL_CONFIRM_ATTEMPTS = 3;
17
+ /**
18
+ * First delay between approval-confirmation reads, in milliseconds. Short
19
+ * because the lag is normally sub-second; the total window is the interesting
20
+ * number, and it stays inside a tool call's patience.
21
+ */
22
+ const APPROVAL_CONFIRM_DELAY_MS = 750;
23
+ /**
24
+ * Whether one activity is the plan-approval event.
25
+ * @param activity - one activity from the log.
26
+ * @returns whether the plan was approved.
27
+ */
28
+ function isPlanApproved(activity) {
29
+ return activity.planApproved !== undefined;
30
+ }
31
+ /**
32
+ * The sentence that leads an approval reply.
33
+ *
34
+ * Three outcomes, and they are not interchangeable: confirmed, sent with the
35
+ * event not yet visible, and sent with the log unreadable. Reporting the third
36
+ * as the second would read as a failed approval when nothing is known either way.
37
+ * @param id - session id.
38
+ * @param confirmed - whether the planApproved event was observed.
39
+ * @param logRead - whether the log could be read at all.
40
+ * @returns the leading sentence.
41
+ */
42
+ function approvalLead(id, confirmed, logRead) {
43
+ if (confirmed)
44
+ return `Approved the plan for session ${id}; the planApproved event is in the log.`;
45
+ if (!logRead) {
46
+ return `Approval sent for session ${id}, but the activity log could not be read, so it could not be verified `
47
+ + 'either way. Check jules_activities rather than assuming it failed.';
48
+ }
49
+ return `Approval sent for session ${id}, but its planApproved event had not appeared after `
50
+ + APPROVAL_CONFIRM_ATTEMPTS + ' reads. That is the service lagging, not a failed approval — '
51
+ + 'confirm with jules_activities before assuming anything.';
52
+ }
53
+ /** One progress row shared by the session and wait projections. */
54
+ const GENERATED_FILE_SCHEMA = {
55
+ type: 'object', additionalProperties: false,
56
+ properties: {
57
+ path: { type: 'string', required: true },
58
+ changeType: { type: 'string', required: true },
59
+ bytes: { type: 'integer', required: true },
60
+ },
61
+ };
62
+ /** The full session projection returned by create, status, approve, and message. */
63
+ const SESSION_DETAIL_SCHEMA = {
64
+ type: 'object', additionalProperties: false,
65
+ properties: {
66
+ id: { type: 'string', required: true },
67
+ title: { type: 'string', required: true },
68
+ state: { type: 'string', required: true },
69
+ url: { type: 'string', required: true },
70
+ prompt: { type: 'string', required: true },
71
+ source: { type: 'string', required: true },
72
+ branch: { type: 'string', required: true },
73
+ requirePlanApproval: { type: 'string', required: true, description: 'yes | no | unknown — the service does not echo this create-time input, so unknown beats a fabricated no.' },
74
+ autoCreatePr: { type: 'string', required: true },
75
+ archived: { type: 'boolean', required: true },
76
+ createTime: { type: 'string', required: true },
77
+ updateTime: { type: 'string', required: true },
78
+ planId: { type: 'string', required: true },
79
+ planSteps: { type: 'array', required: true, items: { type: 'string' } },
80
+ planPending: { type: 'boolean', required: true },
81
+ planApproved: { type: 'boolean', required: true },
82
+ pullRequests: { type: 'array', required: true, items: { type: 'string' } },
83
+ generatedFiles: { type: 'array', required: true, items: GENERATED_FILE_SCHEMA },
84
+ latestCommand: { type: 'string', required: true },
85
+ lastMessage: { type: 'string', required: true },
86
+ lastProgress: { type: 'string', required: true },
87
+ unchangedForMs: { type: 'integer', required: true },
88
+ approvalConfirmed: { type: 'boolean', required: true },
89
+ logRead: { type: 'boolean', required: true },
90
+ },
91
+ };
92
+ /** One session row in the `jules_list` projection. */
93
+ const SESSION_ROW_SCHEMA = {
94
+ type: 'object', additionalProperties: false,
95
+ properties: {
96
+ id: { type: 'string', required: true },
97
+ title: { type: 'string', required: true },
98
+ state: { type: 'string', required: true },
99
+ url: { type: 'string', required: true },
100
+ source: { type: 'string', required: true },
101
+ branch: { type: 'string', required: true },
102
+ createTime: { type: 'string', required: true },
103
+ updateTime: { type: 'string', required: true },
104
+ pullRequests: { type: 'array', required: true, items: { type: 'string' } },
105
+ },
106
+ };
107
+ /** The `jules_sources` projection. */
108
+ const SOURCE_SCHEMA = {
109
+ type: 'object', additionalProperties: false,
110
+ properties: {
111
+ name: { type: 'string', required: true },
112
+ owner: { type: 'string', required: true },
113
+ repo: { type: 'string', required: true },
114
+ isPrivate: { type: 'boolean', required: true },
115
+ defaultBranch: { type: 'string', required: true },
116
+ branches: { type: 'array', required: true, items: { type: 'string' } },
117
+ },
118
+ };
119
+ /** One activity row in the `jules_activities` projection. */
120
+ const ACTIVITY_SCHEMA = {
121
+ type: 'object', additionalProperties: false,
122
+ properties: {
123
+ id: { type: 'string', required: true },
124
+ time: { type: 'string', required: true },
125
+ originator: { type: 'string', required: true },
126
+ kind: { type: 'string', required: true },
127
+ summary: { type: 'string', required: true },
128
+ artifacts: { type: 'array', required: true, items: { type: 'string' } },
129
+ },
130
+ };
131
+ /** The `jules_wait` projection. */
132
+ const WAIT_SCHEMA = {
133
+ type: 'object', additionalProperties: false,
134
+ properties: {
135
+ id: { type: 'string', required: true },
136
+ title: { type: 'string', required: true },
137
+ state: { type: 'string', required: true },
138
+ url: { type: 'string', required: true },
139
+ settled: { type: 'boolean', required: true },
140
+ timedOut: { type: 'boolean', required: true },
141
+ needsAttention: { type: 'boolean', required: true },
142
+ waitedMs: { type: 'integer', required: true },
143
+ polls: { type: 'integer', required: true },
144
+ planId: { type: 'string', required: true },
145
+ planSteps: { type: 'array', required: true, items: { type: 'string' } },
146
+ pullRequests: { type: 'array', required: true, items: { type: 'string' } },
147
+ lastMessage: { type: 'string', required: true },
148
+ lastProgress: { type: 'string', required: true },
149
+ },
150
+ };
151
+ /** The `jules_patch` projection. */
152
+ const PATCH_SCHEMA = {
153
+ type: 'object', additionalProperties: false,
154
+ properties: {
155
+ id: { type: 'string', required: true },
156
+ found: { type: 'boolean', required: true },
157
+ patch: { type: 'string', required: true },
158
+ bytes: { type: 'integer', required: true },
159
+ truncated: { type: 'boolean', required: true },
160
+ offset: { type: 'integer', required: true },
161
+ nextOffset: { type: 'integer', required: true },
162
+ files: { type: 'array', required: true, items: { type: 'string' } },
163
+ baseCommitId: { type: 'string', required: true },
164
+ suggestedCommitMessage: { type: 'string', required: true },
165
+ source: { type: 'string', required: true },
166
+ },
167
+ };
168
+ /**
169
+ * Read a session and its event log, tolerating a log that cannot be read yet.
170
+ *
171
+ * A session created moments ago can exist before its first activity does, so a
172
+ * failed read degrades the projection rather than failing the call.
173
+ * @param client - the Jules client.
174
+ * @param id - bare session id.
175
+ * @param signal - the call's cancellation signal.
176
+ * @param maxPages - activity page cap.
177
+ * @returns the session, whatever activities were readable, and whether the read worked.
178
+ */
179
+ async function readSessionDetail(client, id, signal, maxPages) {
180
+ const session = await client.getSession(id, signal);
181
+ try {
182
+ return { session, activities: await client.listAllActivities(id, { signal, maxPages, pageSize: 100 }), logRead: true };
183
+ }
184
+ catch (error) {
185
+ // The session itself was readable, so the call still has something to
186
+ // report — but an unreadable log must never masquerade as an empty one.
187
+ // `logRead` is what keeps "no plan pending" from meaning "we could not look".
188
+ if (signal.aborted)
189
+ throw error;
190
+ return { session, activities: [], logRead: false };
191
+ }
192
+ }
193
+ /**
194
+ * Register every `jules_*` tool.
195
+ *
196
+ * Concurrency classification is a rule, not a per-tool judgement: every
197
+ * read-only tool declares itself concurrency-safe so sibling calls may overlap,
198
+ * and exactly the three that ask the service to change something —
199
+ * `jules_create`, `jules_approve_plan`, `jules_send_message` — leave it unset so
200
+ * the pipeline serializes them against their siblings. Marking a mutating tool
201
+ * safe would let two approvals race, and marking a reader unsafe would make a
202
+ * batch of status checks run one at a time for no reason.
203
+ * @param ctx - plugin context supplying the tool registry.
204
+ * @param client - the configured Jules client.
205
+ * @param config - bounds applied to tool inputs.
206
+ */
207
+ export function registerJulesTools(ctx, client, config) {
208
+ const seenByAgent = new WeakMap();
209
+ const seenWithoutAgent = new Map();
210
+ /**
211
+ * Record this look and report how long the same answer has been standing.
212
+ * @param agent - the calling agent, when there is one.
213
+ * @param session - bare Jules session id.
214
+ * @param signature - everything that would change if the session moved.
215
+ * @returns milliseconds since the identical observation, or 0 when it is new.
216
+ */
217
+ const observe = (agent, session, signature) => {
218
+ let store;
219
+ if (agent === undefined) {
220
+ store = seenWithoutAgent;
221
+ }
222
+ else {
223
+ const existing = seenByAgent.get(agent);
224
+ if (existing === undefined) {
225
+ store = new Map();
226
+ seenByAgent.set(agent, store);
227
+ }
228
+ else {
229
+ store = existing;
230
+ }
231
+ }
232
+ const previous = store.get(session);
233
+ const at = Date.now();
234
+ store.set(session, { signature, at });
235
+ return previous !== undefined && previous.signature === signature ? at - previous.at : 0;
236
+ };
237
+ ctx.tools.register(defineTool({
238
+ name: 'jules_sources',
239
+ description: 'List the repositories connected to Jules. A repository must appear here before jules_create can target it. '
240
+ + 'Jules is a remote coding agent: it clones the repository, plans, edits files, and can open a pull request on its own.',
241
+ parameters: {
242
+ pageSize: { type: 'number', description: 'Repositories per page, 1-100.' },
243
+ pageToken: { type: 'string', description: 'Continuation token from a previous call.' },
244
+ },
245
+ output: {
246
+ schema: {
247
+ type: 'object', additionalProperties: false,
248
+ properties: {
249
+ sources: { type: 'array', required: true, items: SOURCE_SCHEMA },
250
+ nextPageToken: { type: 'string', required: true },
251
+ },
252
+ },
253
+ render: (_args, value) => {
254
+ const body = renderSources(value.sources);
255
+ return [{
256
+ type: 'text',
257
+ text: value.nextPageToken.length === 0 ? body : `${body}\n(nextPageToken: ${value.nextPageToken})`,
258
+ }];
259
+ },
260
+ },
261
+ isConcurrencySafe: () => true,
262
+ async execute(args, exec) {
263
+ const response = await client.listSources({
264
+ ...args.pageSize === undefined ? {} : { pageSize: args.pageSize },
265
+ ...args.pageToken === undefined ? {} : { pageToken: args.pageToken },
266
+ signal: exec.signal,
267
+ });
268
+ return {
269
+ sources: (response.sources ?? []).map(sourceRow),
270
+ nextPageToken: response.nextPageToken ?? '',
271
+ };
272
+ },
273
+ }));
274
+ ctx.tools.register(defineTool({
275
+ name: 'jules_create',
276
+ description: 'Start a Jules session: give the remote agent a task in a repository and return immediately with a session id. '
277
+ + 'Jules works asynchronously in the cloud, so follow up with jules_wait or jules_status rather than expecting the work to be done. '
278
+ + 'Set requirePlanApproval to review the plan before any file is edited, and autoCreatePr to have Jules open a pull request when it finishes. '
279
+ + 'Omit source to run a repoless session that only needs reasoning or new files.',
280
+ parameters: {
281
+ prompt: { type: 'string', required: true, description: 'The task, as you would brief a capable engineer. Be specific about the desired outcome.' },
282
+ source: { type: 'string', description: 'Repository as "owner/repo" or "sources/github/owner/repo". Omit for a repoless session.' },
283
+ branch: { type: 'string', description: 'Branch Jules starts from. Defaults to the repository default branch.' },
284
+ title: { type: 'string', description: 'Short session title. Jules generates one when omitted.' },
285
+ requirePlanApproval: { type: 'boolean', description: 'Wait for an explicit jules_approve_plan before editing files. Recommended for changes to existing code.' },
286
+ autoCreatePr: { type: 'boolean', description: 'Open a pull request automatically when the session completes.' },
287
+ },
288
+ output: {
289
+ schema: SESSION_DETAIL_SCHEMA,
290
+ render: (_args, value) => [{
291
+ type: 'text',
292
+ text: `Started Jules session ${value.id} (${value.state}).\n${renderSessionDetail(value)}\n`
293
+ + (value.requirePlanApproval
294
+ ? `Next: jules_wait with session "${value.id}" until the plan is ready, then jules_approve_plan.`
295
+ : `Next: jules_wait with session "${value.id}" to follow it to completion.`),
296
+ }],
297
+ },
298
+ async execute(args, exec) {
299
+ if (args.prompt.trim().length === 0)
300
+ throw new Error('jules_create requires a non-empty prompt');
301
+ const source = args.source ?? (config.defaultSource.length === 0 ? undefined : config.defaultSource);
302
+ const session = await client.createSession({
303
+ prompt: args.prompt,
304
+ ...args.title === undefined ? {} : { title: args.title },
305
+ ...args.requirePlanApproval === undefined ? {} : { requirePlanApproval: args.requirePlanApproval },
306
+ ...args.autoCreatePr === true ? { automationMode: 'AUTO_CREATE_PR' } : {},
307
+ ...source === undefined || source.trim().length === 0 ? {} : {
308
+ sourceContext: {
309
+ source: sourceNameOf(source),
310
+ ...args.branch === undefined ? {} : { githubRepoContext: { startingBranch: args.branch } },
311
+ },
312
+ },
313
+ }, exec.signal);
314
+ return sessionDetail(session);
315
+ },
316
+ }));
317
+ ctx.tools.register(defineTool({
318
+ name: 'jules_list',
319
+ description: 'List recent Jules sessions visible to this credential, newest first. A one-off survey — not a way to wait for progress.',
320
+ parameters: {
321
+ pageSize: { type: 'number', description: 'Sessions per page, 1-100.' },
322
+ pageToken: { type: 'string', description: 'Continuation token from a previous call.' },
323
+ },
324
+ output: {
325
+ schema: {
326
+ type: 'object', additionalProperties: false,
327
+ properties: {
328
+ sessions: { type: 'array', required: true, items: SESSION_ROW_SCHEMA },
329
+ nextPageToken: { type: 'string', required: true },
330
+ },
331
+ },
332
+ render: (_args, value) => [{ type: 'text', text: renderSessionRows(value.sessions, value.nextPageToken) }],
333
+ },
334
+ isConcurrencySafe: () => true,
335
+ async execute(args, exec) {
336
+ const response = await client.listSessions({
337
+ ...args.pageSize === undefined ? {} : { pageSize: args.pageSize },
338
+ ...args.pageToken === undefined ? {} : { pageToken: args.pageToken },
339
+ signal: exec.signal,
340
+ });
341
+ return {
342
+ sessions: (response.sessions ?? []).map(sessionRow),
343
+ nextPageToken: response.nextPageToken ?? '',
344
+ };
345
+ },
346
+ }));
347
+ ctx.tools.register(defineTool({
348
+ name: 'jules_status',
349
+ description: 'Read one Jules session in full: state, generated plan, pull requests, produced files, and the latest command. '
350
+ + 'Call it once to confirm an action you just took, to follow up a jules_watch notice, or when the user asks about a session — '
351
+ + 'NOT in a loop to wait for progress. Waiting is jules_watch (background) or jules_wait (foreground); both report this same '
352
+ + 'detail when they settle, so repeating this call only burns turns.',
353
+ parameters: {
354
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
355
+ },
356
+ output: {
357
+ schema: SESSION_DETAIL_SCHEMA,
358
+ render: (_args, value) => [{ type: 'text', text: renderSessionDetail(value) }],
359
+ },
360
+ isConcurrencySafe: () => true,
361
+ async execute(args, exec) {
362
+ const id = sessionIdOf(args.session);
363
+ const { session, activities, logRead } = await readSessionDetail(client, id, exec.signal, config.maxActivityPages);
364
+ // Everything that would differ if the session had moved. A repeat of the
365
+ // same signature is the signal that this call was not worth making.
366
+ const signature = [
367
+ session.state ?? '',
368
+ session.updateTime ?? '',
369
+ String(activities.length),
370
+ activities[activities.length - 1]?.id ?? '',
371
+ ].join('|');
372
+ return sessionDetail(session, activities, { unchangedForMs: observe(exec.agent, id, signature), logRead });
373
+ },
374
+ }));
375
+ ctx.tools.register(defineTool({
376
+ name: 'jules_activities',
377
+ description: 'Read a Jules session event log, oldest first: plan generation and approval, agent and user messages, progress updates, '
378
+ + 'completion or failure, and a summary of every artifact. Use it to see what the agent actually did, to diagnose a stall, or to '
379
+ + 'follow up a jules_watch notice — not to poll for progress.',
380
+ parameters: {
381
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
382
+ pageSize: { type: 'number', description: 'Activities per page, 1-100.' },
383
+ pageToken: { type: 'string', description: 'Continuation token from a previous call.' },
384
+ since: { type: 'string', description: 'RFC 3339 timestamp; return only activities created after it, for polling a long session.' },
385
+ },
386
+ output: {
387
+ schema: {
388
+ type: 'object', additionalProperties: false,
389
+ properties: {
390
+ activities: { type: 'array', required: true, items: ACTIVITY_SCHEMA },
391
+ nextPageToken: { type: 'string', required: true },
392
+ },
393
+ },
394
+ render: (_args, value) => [{ type: 'text', text: renderActivities(value.activities, value.nextPageToken) }],
395
+ },
396
+ isConcurrencySafe: () => true,
397
+ async execute(args, exec) {
398
+ const id = sessionIdOf(args.session);
399
+ const response = await client.listActivities(id, {
400
+ ...args.pageSize === undefined ? {} : { pageSize: args.pageSize },
401
+ ...args.pageToken === undefined ? {} : { pageToken: args.pageToken },
402
+ ...args.since === undefined ? {} : { since: args.since },
403
+ signal: exec.signal,
404
+ });
405
+ return {
406
+ activities: (response.activities ?? []).map(activityRow),
407
+ nextPageToken: response.nextPageToken ?? '',
408
+ };
409
+ },
410
+ }));
411
+ ctx.tools.register(defineTool({
412
+ name: 'jules_approve_plan',
413
+ description: 'Approve the plan a Jules session is waiting on, which lets the agent start editing files. '
414
+ + 'Only call this after jules_status or jules_activities showed the plan and you consider it correct. '
415
+ + 'To change it instead, use jules_send_message with the corrections.',
416
+ parameters: {
417
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
418
+ },
419
+ output: {
420
+ schema: SESSION_DETAIL_SCHEMA,
421
+ render: (_args, value) => [{
422
+ type: 'text',
423
+ text: approvalLead(value.id, value.approvalConfirmed, value.logRead) + '\n' + renderSessionDetail(value),
424
+ }],
425
+ },
426
+ async execute(args, exec) {
427
+ const id = sessionIdOf(args.session);
428
+ await client.approvePlan(id, exec.signal);
429
+ // :approvePlan returning is not evidence that the approval is readable.
430
+ // The service is eventually consistent about its own actions, so its
431
+ // planApproved event lags — and a snapshot taken immediately reported
432
+ // planApproved false, or a phase that had already moved on, for approvals
433
+ // that had in fact landed. Poll for the event and report whether it was
434
+ // seen, rather than handing back the race.
435
+ const read = () => client.listAllActivities(id, {
436
+ signal: exec.signal,
437
+ maxPages: config.maxActivityPages,
438
+ pageSize: 100,
439
+ });
440
+ // A read failure here must not be reported as an unconfirmed approval, nor
441
+ // thrown away: the approval may well have landed, and the caller needs to
442
+ // know that we could not look rather than that we looked and saw nothing.
443
+ let activities = [];
444
+ let logRead = true;
445
+ let confirmed = false;
446
+ try {
447
+ activities = await read();
448
+ confirmed = activities.some(isPlanApproved);
449
+ for (let attempt = 1; !confirmed && attempt <= APPROVAL_CONFIRM_ATTEMPTS; attempt += 1) {
450
+ await sleep(APPROVAL_CONFIRM_DELAY_MS * attempt, exec.signal);
451
+ activities = await read();
452
+ confirmed = activities.some(isPlanApproved);
453
+ }
454
+ }
455
+ catch (error) {
456
+ if (exec.signal.aborted)
457
+ throw error;
458
+ logRead = false;
459
+ }
460
+ const session = await client.getSession(id, exec.signal);
461
+ return sessionDetail(session, activities, { approvalConfirmed: confirmed, logRead });
462
+ },
463
+ }));
464
+ ctx.tools.register(defineTool({
465
+ name: 'jules_send_message',
466
+ description: 'Send a message to a Jules session: answer a question it asked, correct its plan, or give it follow-up work. '
467
+ + 'The agent replies asynchronously, so read jules_activities afterwards.',
468
+ parameters: {
469
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
470
+ prompt: { type: 'string', required: true, description: 'What to tell the agent.' },
471
+ },
472
+ output: {
473
+ schema: SESSION_DETAIL_SCHEMA,
474
+ render: (_args, value) => [{
475
+ type: 'text',
476
+ text: `Sent the message to session ${value.id} (now ${value.state}).\n${renderSessionDetail(value)}`,
477
+ }],
478
+ },
479
+ async execute(args, exec) {
480
+ if (args.prompt.trim().length === 0)
481
+ throw new Error('jules_send_message requires a non-empty prompt');
482
+ const id = sessionIdOf(args.session);
483
+ await client.sendMessage(id, args.prompt, exec.signal);
484
+ const { session, activities, logRead } = await readSessionDetail(client, id, exec.signal, config.maxActivityPages);
485
+ return sessionDetail(session, activities, { logRead });
486
+ },
487
+ }));
488
+ ctx.tools.register(defineTool({
489
+ name: 'jules_wait',
490
+ description: 'Hold the turn open until a Jules session finishes, fails, needs a plan decision, or posts a message, then report what '
491
+ + 'happened. Use it only when you genuinely have nothing else to do until the answer arrives — prefer jules_watch, which waits in '
492
+ + 'the background and lets your turn end. Neither is a licence to poll jules_status.',
493
+ parameters: {
494
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
495
+ timeoutMs: { type: 'number', description: `How long to wait before giving up, up to ${Math.trunc(config.waitMaxMs / 1000)} seconds.` },
496
+ until: {
497
+ type: 'array',
498
+ description: 'Session states that end the wait. Defaults to completion, failure, a pending plan, and a question.',
499
+ items: { type: 'string' },
500
+ },
501
+ },
502
+ output: {
503
+ schema: WAIT_SCHEMA,
504
+ render: (_args, value) => [{ type: 'text', text: renderWait(value) }],
505
+ },
506
+ // The tool's own deadline must outlast the wait budget it is given, or the
507
+ // pipeline would cut the call off before the wait could report. The margin
508
+ // covers the reads that follow settlement: an activity walk and a status
509
+ // fetch, each of which can take seconds against this service.
510
+ timeoutMs: config.waitMaxMs + 15_000,
511
+ isConcurrencySafe: () => true,
512
+ // The foreground wait shares runWatch with jules_watch. Duplicating the loop
513
+ // here is what let the two paths disagree: this one used to poll the session
514
+ // state alone, so a session that posted its answer while state stayed
515
+ // IN_PROGRESS would wait out the whole budget and report nothing.
516
+ async execute(args, exec) {
517
+ const id = sessionIdOf(args.session);
518
+ const requested = args.timeoutMs ?? config.waitDefaultMs;
519
+ const budget = Math.max(1_000, Math.min(Math.trunc(requested), config.waitMaxMs));
520
+ const targets = new Set(args.until === undefined || args.until.length === 0 ? DEFAULT_WAIT_STATES : args.until);
521
+ const outcome = await runWatch(client, id, targets, budget, {
522
+ pollIntervalMs: config.pollIntervalMs,
523
+ maxActivityPages: config.maxActivityPages,
524
+ settleOnMessage: config.settleOnMessage,
525
+ }, exec.signal);
526
+ return outcome.wait;
527
+ },
528
+ }));
529
+ ctx.tools.register(defineTool({
530
+ name: 'jules_patch',
531
+ description: 'Return the unified diff a Jules session produced, newest first, ready to apply with git apply. '
532
+ + 'Use it to review or land the agent work in this workspace instead of opening the pull request. Large diffs are '
533
+ + 'returned in slices: the reply lists every touched file and, when it truncates, the offset to continue from.',
534
+ parameters: {
535
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
536
+ maxBytes: { type: 'number', description: `Cap on this slice, up to ${config.maxPatchBytes} bytes.` },
537
+ offset: { type: 'number', description: 'Byte offset to start this slice at. Use the nextOffset from a truncated reply to continue.' },
538
+ },
539
+ output: {
540
+ schema: PATCH_SCHEMA,
541
+ render: (_args, value) => [{ type: 'text', text: renderPatch(value) }],
542
+ },
543
+ isConcurrencySafe: () => true,
544
+ async execute(args, exec) {
545
+ const id = sessionIdOf(args.session);
546
+ const activities = await client.listAllActivities(id, { signal: exec.signal, maxPages: config.maxActivityPages, pageSize: 100 });
547
+ const gitPatch = latestPatch(activities);
548
+ const full = gitPatch?.unidiffPatch ?? '';
549
+ const limit = Math.max(1_000, Math.min(Math.trunc(args.maxBytes ?? config.maxPatchBytes), config.maxPatchBytes));
550
+ if (full.length === 0) {
551
+ return {
552
+ id, found: false, patch: '', bytes: 0, truncated: false,
553
+ offset: 0, nextOffset: 0, files: [],
554
+ baseCommitId: '', suggestedCommitMessage: '', source: '',
555
+ };
556
+ }
557
+ const offset = Math.max(0, Math.min(Math.trunc(args.offset ?? 0), Math.max(0, full.length - 1)));
558
+ const slice = full.slice(offset, offset + limit);
559
+ const truncated = offset + slice.length < full.length;
560
+ return {
561
+ id,
562
+ found: true,
563
+ patch: slice,
564
+ // The size of the whole diff, so one slice still says how much there is.
565
+ bytes: full.length,
566
+ truncated,
567
+ offset,
568
+ nextOffset: truncated ? offset + slice.length : 0,
569
+ files: patchFiles(full),
570
+ baseCommitId: gitPatch?.baseCommitId ?? '',
571
+ suggestedCommitMessage: gitPatch?.suggestedCommitMessage ?? '',
572
+ source: '',
573
+ };
574
+ },
575
+ }));
576
+ }