jules-orchestrator-kit 0.51.0 → 0.52.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/README.md CHANGED
@@ -143,7 +143,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
143
143
  * **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
144
144
  * **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
145
145
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
146
- * **Verified Test Suite:** Tested with **790 unit tests across 91 suites passing in < 10.0s**.
146
+ * **Verified Test Suite:** Tested with **797 unit tests across 91 suites passing in < 10.0s**.
147
147
 
148
148
  <br/>
149
149
 
@@ -165,6 +165,9 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
165
165
  | `dispatch` | `agentctl dispatch [<prompt>] [-p <prompt>] [-f <file>] [-r <role>] [-t <tier>] [--author <name>] [--check-premise] [--auto-pr] [--repoless] [--dry-run]` | Dispatches autonomous task to the active provider with pre-flight idempotency checks, payload limits, and role prompt resolution. `--dry-run` stops short of the provider call and reports itself as a rehearsal rather than a dispatch. | `0` (Dispatched), `1` (Error) |
166
166
  | `plan approve` | `agentctl plan approve <sessionId> [--dry-run] [--json]` | Approves pending execution plan for an active Jules session (`:approvePlan`) with automatic 404/503 retry backoff. | `0` (Approved), `1` (Error) |
167
167
  | `session get` | `agentctl session get <sessionId> [--dry-run] [--json]` | Retrieves live session lifecycle state from provider REST API with token rotation. | `0` (Fetched), `1` (Error) |
168
+ | `patch` | `agentctl patch <sessionId> [--apply] [--save <path>] [--json]` | Extracts raw git diff patch from a completed Jules session and tests or applies it locally with `git apply --check` safety. | `0` (Clean/Applied), `1` (Conflict/Error) |
169
+ | `retry` | `agentctl retry <sessionId> [--role <role>] [--with-failure] [--json]` | Fetches error traces and activity logs from a failed session and synthesizes a targeted OODA retry dispatch. | `0` (Dispatched), `1` (Error) |
170
+ | `prune` | `agentctl prune [--age 7d] [--state <state>] [--delete] [--yes] [--json]` | Queries and batch-archives or deletes stale/completed sessions via Jules v1alpha API to keep workspaces clean. | `0` (Cleaned) |
168
171
  | `pr harvest` | `agentctl pr harvest [--tier r0,r1] [--limit <n>] [--auto] [--allow-no-checks] [--dry-run]` | Discovers open agent PRs, evaluates CI checks & risk tiers, and auto-squashes green low-risk changes autonomously. A PR reporting **no** CI checks is skipped unless `--allow-no-checks` is passed, and an unavailable changed-file list blocks rather than classifying as low risk. | `0` (Triaged/Merged), `1` (Error) |
169
172
  | `doctor` | `agentctl doctor [--json]` | Diagnostic DAG check runner & automated transactional self-repair engine. | `0` (Healthy), `1` (Failures) |
170
173
  | `queue` | `agentctl queue [--dag] [--concurrency <n>] [--dry-run] [--json]` | Consumes and executes task envelopes in `.agent/jules-queue/` with Kahn's DAG dependency resolution. Non-task files (manifests, `README.md`) are skipped, and `--dry-run` previews without moving anything. | `0` (Complete) |
package/bin/agentctl.mjs CHANGED
@@ -69,6 +69,9 @@ Commands:
69
69
  resume Resume warm session with human response (--response "<text>")
70
70
  plan approve <id> Approve pending plan for a Jules session (:approvePlan)
71
71
  session get <id> Retrieve remote session status from provider API
72
+ patch <id> Extract and test/apply git patch from a Jules session (--apply, --save)
73
+ retry <id> Retry failed session with automated failure-trace injection (--role)
74
+ prune Batch-archive or delete stale sessions via Jules API (--age 7d, --yes)
72
75
  pr harvest Triage, verify CI, and auto-merge low-risk agent PRs (--auto, --tier r0,r1)
73
76
  escalate Dispatch or manage webhook escalation incidents (--flush, --status, --clear)
74
77
  flaky Manage Wilson-quarantined tests and dispatch healing swarm (status | heal | reset)
@@ -720,6 +723,147 @@ async function main() {
720
723
  break;
721
724
  }
722
725
 
726
+ case "patch": {
727
+ const { applySessionPatch, resolveRoot } = await import("../index.mjs");
728
+ const { values, positionals } = parseArgs({
729
+ args: args.slice(1),
730
+ options: {
731
+ session: { type: "string", short: "s" },
732
+ apply: { type: "boolean", short: "a" },
733
+ save: { type: "string" },
734
+ json: { type: "boolean", short: "j" },
735
+ },
736
+ allowPositionals: true,
737
+ });
738
+
739
+ const sessionId = values.session || positionals[0] || positionals[1];
740
+ if (!sessionId) {
741
+ console.error("❌ Error: Missing required session ID. Usage: agentctl patch <session_id> [--apply] [--save <path>]");
742
+ process.exit(1);
743
+ }
744
+
745
+ const root = resolveRoot();
746
+ const res = await applySessionPatch(sessionId, {
747
+ root,
748
+ apply: Boolean(values.apply),
749
+ save: values.save,
750
+ });
751
+
752
+ if (values.json) {
753
+ console.log(JSON.stringify(res, null, 2));
754
+ } else if (!res.ok) {
755
+ console.error(`\n❌ Failed to extract or apply patch for session ${sessionId}:`);
756
+ console.error(` ${res.error || "Unknown error"}\n`);
757
+ } else {
758
+ console.log(`\n📦 Session Git Patch (${sessionId})`);
759
+ console.log(`------------------------------------------------------------------`);
760
+ console.log(` Modified Files : ${res.files && res.files.length > 0 ? res.files.join(", ") : "None"}`);
761
+ console.log(` git apply --check : ${res.checkPassed ? "✅ CLEAN PASS" : "❌ CONFLICT"}`);
762
+ console.log(` Applied to Disk : ${res.patchApplied ? "✅ APPLIED" : "DRY-RUN ONLY (--apply to write)"}`);
763
+ if (values.save) {
764
+ console.log(` Saved to File : ${values.save}`);
765
+ }
766
+ if (!values.apply && !values.save && res.patch) {
767
+ console.log(`\n--- Patch Content ---\n${res.patch}\n---------------------\n`);
768
+ }
769
+ }
770
+
771
+ process.exit(res.ok ? 0 : 1);
772
+ break;
773
+ }
774
+
775
+ case "retry": {
776
+ const { retrySession, resolveRoot } = await import("../index.mjs");
777
+ const { values, positionals } = parseArgs({
778
+ args: args.slice(1),
779
+ options: {
780
+ session: { type: "string", short: "s" },
781
+ role: { type: "string", short: "r" },
782
+ title: { type: "string", short: "t" },
783
+ "without-failure": { type: "boolean" },
784
+ "dry-run": { type: "boolean", short: "d" },
785
+ json: { type: "boolean", short: "j" },
786
+ },
787
+ allowPositionals: true,
788
+ });
789
+
790
+ const sessionId = values.session || positionals[0] || positionals[1];
791
+ if (!sessionId) {
792
+ console.error("❌ Error: Missing required session ID. Usage: agentctl retry <session_id> [--role <role>]");
793
+ process.exit(1);
794
+ }
795
+
796
+ const root = resolveRoot();
797
+ const res = await retrySession(sessionId, {
798
+ root,
799
+ role: values.role,
800
+ title: values.title,
801
+ withFailure: values["without-failure"] ? false : true,
802
+ dryRun: Boolean(values["dry-run"]),
803
+ });
804
+
805
+ if (values.json) {
806
+ console.log(JSON.stringify(res, null, 2));
807
+ } else {
808
+ console.log(`\n🔄 Session Retry Dispatched!`);
809
+ console.log(`------------------------------------------------------------------`);
810
+ console.log(` Original Session : ${res.originalSessionId}`);
811
+ console.log(` New Session ID : ${res.newSession?.id || "N/A"}`);
812
+ console.log(` Failure Trace Added : ${res.failureReason ? "YES" : "NO"}`);
813
+ console.log(`------------------------------------------------------------------\n`);
814
+ }
815
+
816
+ process.exit(res.ok ? 0 : 1);
817
+ break;
818
+ }
819
+
820
+ case "prune": {
821
+ const { pruneSessions, resolveRoot } = await import("../index.mjs");
822
+ const { values } = parseArgs({
823
+ args: args.slice(1),
824
+ options: {
825
+ age: { type: "string", short: "a" },
826
+ state: { type: "string", short: "s" },
827
+ delete: { type: "boolean" },
828
+ "dry-run": { type: "boolean", short: "d" },
829
+ yes: { type: "boolean", short: "y" },
830
+ json: { type: "boolean", short: "j" },
831
+ },
832
+ allowPositionals: true,
833
+ });
834
+
835
+ const root = resolveRoot();
836
+ const dryRun = values["dry-run"] || (!values.yes && !values.delete);
837
+ const res = await pruneSessions({
838
+ root,
839
+ age: values.age || "7d",
840
+ state: values.state,
841
+ delete: Boolean(values.delete),
842
+ dryRun,
843
+ });
844
+
845
+ if (values.json) {
846
+ console.log(JSON.stringify(res, null, 2));
847
+ } else {
848
+ console.log(`\n🧹 Session Pruning Report (${dryRun ? "DRY RUN" : "EXECUTED"})`);
849
+ console.log(`------------------------------------------------------------------`);
850
+ console.log(` Filter Age Cutoff : ${values.age || "7d"}`);
851
+ console.log(` Filter Target State : ${values.state || "ALL"}`);
852
+ console.log(` Matched Sessions : ${res.matchedCount}`);
853
+ console.log(` Archived / Deleted : ${res.archivedCount}`);
854
+ console.log(`------------------------------------------------------------------`);
855
+ for (const s of res.sessions) {
856
+ console.log(` - [${s.action}] ${s.id} (State: ${s.state || "UNKNOWN"})`);
857
+ }
858
+ if (dryRun && res.matchedCount > 0) {
859
+ console.log(`\n💡 Re-run with --yes to archive matched sessions.\n`);
860
+ }
861
+ }
862
+
863
+ process.exit(0);
864
+ break;
865
+ }
866
+
723
867
  case "assert": {
724
868
  const { runAssertion, parseYaml } = await import("../index.mjs");
725
869
  const { values } = parseArgs({
package/index.mjs CHANGED
@@ -229,3 +229,13 @@ export {
229
229
  getHandoverDir,
230
230
  HandoverError,
231
231
  } from "./src/ops/handover.mjs";
232
+
233
+ // Jules Power-User Session Operations & Lifecycle Engine
234
+ export {
235
+ extractSessionPatch,
236
+ applySessionPatch,
237
+ retrySession,
238
+ pruneSessions,
239
+ parseAgeDuration,
240
+ } from "./src/session-ops.mjs";
241
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for Google Jules (jules) autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/mcp.mjs CHANGED
@@ -8,6 +8,13 @@ import { reapOrphanedIntents, reapStaleMutexDirs } from "./journal.mjs";
8
8
  import { readTelemetry } from "./telemetry.mjs";
9
9
  import { ProgressBus } from "./mcp-progress.mjs";
10
10
  import { KIT_VERSION } from "./version.mjs";
11
+ import { createProvider } from "./provider.mjs";
12
+ import {
13
+ extractSessionPatch,
14
+ applySessionPatch,
15
+ retrySession,
16
+ pruneSessions,
17
+ } from "./session-ops.mjs";
11
18
 
12
19
  export const MCP_SERVER_INFO = {
13
20
  name: "jules-orchestrator-kit",
@@ -196,6 +203,113 @@ export const MCP_TOOLS = [
196
203
  required: ["trigger", "solution"],
197
204
  },
198
205
  },
206
+ {
207
+ name: "jules_list_sessions",
208
+ description: "List remote Jules coding sessions with optional pagination and state filtering.",
209
+ inputSchema: {
210
+ type: "object",
211
+ properties: {
212
+ pageSize: { type: "number", description: "Maximum number of sessions to return (default 50)" },
213
+ pageToken: { type: "string", description: "Page token for cursor pagination" },
214
+ },
215
+ },
216
+ },
217
+ {
218
+ name: "jules_list_activities",
219
+ description: "List execution activities, logs, and error outputs for a specific Jules session.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ sessionId: { type: "string", description: "Jules session ID" },
224
+ pageSize: { type: "number", description: "Maximum number of activities to return" },
225
+ pageToken: { type: "string", description: "Page token for pagination" },
226
+ },
227
+ required: ["sessionId"],
228
+ },
229
+ },
230
+ {
231
+ name: "jules_get_session_output",
232
+ description: "Extract the git diff patch, pull request metadata, and changed files from a Jules session.",
233
+ inputSchema: {
234
+ type: "object",
235
+ properties: {
236
+ sessionId: { type: "string", description: "Jules session ID" },
237
+ },
238
+ required: ["sessionId"],
239
+ },
240
+ },
241
+ {
242
+ name: "jules_archive_session",
243
+ description: "Archive a Jules coding session to clean up the dashboard.",
244
+ inputSchema: {
245
+ type: "object",
246
+ properties: {
247
+ sessionId: { type: "string", description: "Jules session ID to archive" },
248
+ },
249
+ required: ["sessionId"],
250
+ },
251
+ },
252
+ {
253
+ name: "jules_delete_session",
254
+ description: "Permanently delete a Jules coding session.",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ sessionId: { type: "string", description: "Jules session ID to delete" },
259
+ },
260
+ required: ["sessionId"],
261
+ },
262
+ },
263
+ {
264
+ name: "jules_retry_session",
265
+ description: "Retry a failed Jules session by extracting failure diagnostics and dispatching a new OODA repair session.",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ sessionId: { type: "string", description: "Original failed Jules session ID" },
270
+ role: { type: "string", description: "Optional specialist role override" },
271
+ withFailure: { type: "boolean", description: "Include previous failure diagnostic in retry prompt (default true)" },
272
+ },
273
+ required: ["sessionId"],
274
+ },
275
+ },
276
+ {
277
+ name: "jules_apply_patch",
278
+ description: "Extract and apply a Jules session's git patch to the local workspace with git apply safety verification.",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ sessionId: { type: "string", description: "Jules session ID to extract patch from" },
283
+ apply: { type: "boolean", description: "If true, applies the patch; if false, runs git apply --check dry-run" },
284
+ save: { type: "string", description: "Optional file path to save raw patch diff to" },
285
+ },
286
+ required: ["sessionId"],
287
+ },
288
+ },
289
+ {
290
+ name: "jules_list_sources",
291
+ description: "List connected GitHub repository sources available to the Jules account.",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: {
295
+ pageSize: { type: "number", description: "Maximum number of sources to return" },
296
+ pageToken: { type: "string", description: "Page token for pagination" },
297
+ },
298
+ },
299
+ },
300
+ {
301
+ name: "jules_prune_sessions",
302
+ description: "Batch-archive or prune old/completed Jules sessions matching age and state filters.",
303
+ inputSchema: {
304
+ type: "object",
305
+ properties: {
306
+ age: { type: "string", description: "Age cutoff string (e.g. '7d', '24h', '30m')" },
307
+ state: { type: "string", description: "Target session state to filter (e.g. 'FAILED', 'COMPLETED')" },
308
+ dryRun: { type: "boolean", description: "If true, previews sessions without modifying (default true)" },
309
+ delete: { type: "boolean", description: "If true, permanently deletes instead of archiving" },
310
+ },
311
+ },
312
+ },
199
313
  ];
200
314
 
201
315
  export async function handleMcpRequest(request, opts = {}) {
@@ -431,6 +545,169 @@ export async function handleMcpRequest(request, opts = {}) {
431
545
  };
432
546
  }
433
547
 
548
+ if (toolName === "jules_list_sessions") {
549
+ const provider = createProvider(config.provider || "jules", config);
550
+ const res = await provider.listSessions({ root, pageSize: args.pageSize, pageToken: args.pageToken });
551
+ return {
552
+ jsonrpc: "2.0",
553
+ id,
554
+ result: {
555
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...res }, null, 2) }],
556
+ },
557
+ };
558
+ }
559
+
560
+ if (toolName === "jules_list_activities") {
561
+ if (!args.sessionId) {
562
+ return {
563
+ jsonrpc: "2.0",
564
+ id,
565
+ error: { code: -32602, message: "Missing required parameter 'sessionId'" },
566
+ };
567
+ }
568
+ const provider = createProvider(config.provider || "jules", config);
569
+ const res = await provider.listActivities(args.sessionId, { root, pageSize: args.pageSize, pageToken: args.pageToken });
570
+ return {
571
+ jsonrpc: "2.0",
572
+ id,
573
+ result: {
574
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...res }, null, 2) }],
575
+ },
576
+ };
577
+ }
578
+
579
+ if (toolName === "jules_get_session_output") {
580
+ if (!args.sessionId) {
581
+ return {
582
+ jsonrpc: "2.0",
583
+ id,
584
+ error: { code: -32602, message: "Missing required parameter 'sessionId'" },
585
+ };
586
+ }
587
+ const res = await extractSessionPatch(args.sessionId, { root, config });
588
+ return {
589
+ jsonrpc: "2.0",
590
+ id,
591
+ result: {
592
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
593
+ },
594
+ };
595
+ }
596
+
597
+ if (toolName === "jules_archive_session") {
598
+ if (!args.sessionId) {
599
+ return {
600
+ jsonrpc: "2.0",
601
+ id,
602
+ error: { code: -32602, message: "Missing required parameter 'sessionId'" },
603
+ };
604
+ }
605
+ const provider = createProvider(config.provider || "jules", config);
606
+ const res = await provider.archiveSession(args.sessionId, { root });
607
+ return {
608
+ jsonrpc: "2.0",
609
+ id,
610
+ result: {
611
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...res }, null, 2) }],
612
+ },
613
+ };
614
+ }
615
+
616
+ if (toolName === "jules_delete_session") {
617
+ if (!args.sessionId) {
618
+ return {
619
+ jsonrpc: "2.0",
620
+ id,
621
+ error: { code: -32602, message: "Missing required parameter 'sessionId'" },
622
+ };
623
+ }
624
+ const provider = createProvider(config.provider || "jules", config);
625
+ const res = await provider.deleteSession(args.sessionId, { root });
626
+ return {
627
+ jsonrpc: "2.0",
628
+ id,
629
+ result: {
630
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...res }, null, 2) }],
631
+ },
632
+ };
633
+ }
634
+
635
+ if (toolName === "jules_retry_session") {
636
+ if (!args.sessionId) {
637
+ return {
638
+ jsonrpc: "2.0",
639
+ id,
640
+ error: { code: -32602, message: "Missing required parameter 'sessionId'" },
641
+ };
642
+ }
643
+ const res = await retrySession(args.sessionId, {
644
+ root,
645
+ config,
646
+ role: args.role,
647
+ withFailure: args.withFailure !== false,
648
+ });
649
+ return {
650
+ jsonrpc: "2.0",
651
+ id,
652
+ result: {
653
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
654
+ },
655
+ };
656
+ }
657
+
658
+ if (toolName === "jules_apply_patch") {
659
+ if (!args.sessionId) {
660
+ return {
661
+ jsonrpc: "2.0",
662
+ id,
663
+ error: { code: -32602, message: "Missing required parameter 'sessionId'" },
664
+ };
665
+ }
666
+ const res = await applySessionPatch(args.sessionId, {
667
+ root,
668
+ config,
669
+ apply: Boolean(args.apply),
670
+ save: args.save,
671
+ });
672
+ return {
673
+ jsonrpc: "2.0",
674
+ id,
675
+ result: {
676
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
677
+ },
678
+ };
679
+ }
680
+
681
+ if (toolName === "jules_list_sources") {
682
+ const provider = createProvider(config.provider || "jules", config);
683
+ const res = await provider.listSources({ root, pageSize: args.pageSize, pageToken: args.pageToken });
684
+ return {
685
+ jsonrpc: "2.0",
686
+ id,
687
+ result: {
688
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...res }, null, 2) }],
689
+ },
690
+ };
691
+ }
692
+
693
+ if (toolName === "jules_prune_sessions") {
694
+ const res = await pruneSessions({
695
+ root,
696
+ config,
697
+ age: args.age || "7d",
698
+ state: args.state,
699
+ delete: Boolean(args.delete),
700
+ dryRun: args.dryRun !== false,
701
+ });
702
+ return {
703
+ jsonrpc: "2.0",
704
+ id,
705
+ result: {
706
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
707
+ },
708
+ };
709
+ }
710
+
434
711
  return {
435
712
  jsonrpc: "2.0",
436
713
  id,
package/src/provider.mjs CHANGED
@@ -691,9 +691,9 @@ export function createProvider(spec = "jules", config = {}) {
691
691
  }
692
692
 
693
693
  if (providerSpec.type === "http") {
694
- const getSessionUrlTemplate = providerSpec.getSessionUrl || `${providerSpec.url}/${sessionId}`;
694
+ const getSessionUrlTemplate = ctx.customUrl || providerSpec.getSessionUrl || `${providerSpec.url}/${sessionId}`;
695
695
  const urlData = { sessionId, ...ctx };
696
- const url = interpolateString(getSessionUrlTemplate, urlData);
696
+ const url = ctx.customUrl || interpolateString(getSessionUrlTemplate, urlData);
697
697
 
698
698
  const headerData = { ...urlData, token: rawToken };
699
699
  const headers = {};
@@ -720,8 +720,9 @@ export function createProvider(spec = "jules", config = {}) {
720
720
  let res;
721
721
  try {
722
722
  res = await fetch(url, {
723
- method: "GET",
723
+ method: ctx.method || "GET",
724
724
  headers,
725
+ body: ctx.body ? JSON.stringify(ctx.body) : undefined,
725
726
  signal: AbortSignal.timeout(timeoutMs),
726
727
  });
727
728
  } catch (err) {
@@ -816,9 +817,9 @@ export function createProvider(spec = "jules", config = {}) {
816
817
  }
817
818
 
818
819
  if (providerSpec.type === "http") {
819
- const approvePlanUrlTemplate = providerSpec.approvePlanUrl || `${providerSpec.url}/${sessionId}:approvePlan`;
820
+ const approvePlanUrlTemplate = ctx.customUrl || providerSpec.approvePlanUrl || `${providerSpec.url}/${sessionId}:approvePlan`;
820
821
  const urlData = { sessionId, ...ctx };
821
- const url = interpolateString(approvePlanUrlTemplate, urlData);
822
+ const url = ctx.customUrl || interpolateString(approvePlanUrlTemplate, urlData);
822
823
 
823
824
  const headerData = { ...urlData, token: rawToken };
824
825
  const headers = {};
@@ -918,6 +919,94 @@ export function createProvider(spec = "jules", config = {}) {
918
919
 
919
920
  throw new Error(`Unsupported provider type: ${providerSpec.type}`);
920
921
  },
922
+
923
+ async listSessions(ctx = {}) {
924
+ if (!ctx || typeof ctx !== "object") ctx = {};
925
+ if (ctx.dryRun) return { sessions: [], raw: {} };
926
+ if (providerSpec.type === "http") {
927
+ const baseUrl = providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions";
928
+ const params = new URLSearchParams();
929
+ if (ctx.pageSize) params.set("pageSize", String(ctx.pageSize));
930
+ if (ctx.pageToken) params.set("pageToken", String(ctx.pageToken));
931
+ const url = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
932
+ const res = await this.getSession("", { ...ctx, customUrl: url });
933
+ return {
934
+ sessions: res?.raw?.sessions || [],
935
+ nextPageToken: res?.raw?.nextPageToken,
936
+ raw: res?.raw || {},
937
+ };
938
+ }
939
+ return { sessions: [], raw: {} };
940
+ },
941
+
942
+ async listActivities(sessionId, ctx = {}) {
943
+ if (!sessionId || typeof sessionId !== "string") {
944
+ throw new TypeError("listActivities() requires a valid sessionId string");
945
+ }
946
+ if (!ctx || typeof ctx !== "object") ctx = {};
947
+ if (ctx.dryRun) return { activities: [], raw: {} };
948
+ if (providerSpec.type === "http") {
949
+ const baseUrl = `${providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions"}/${sessionId}/activities`;
950
+ const params = new URLSearchParams();
951
+ if (ctx.pageSize) params.set("pageSize", String(ctx.pageSize));
952
+ if (ctx.pageToken) params.set("pageToken", String(ctx.pageToken));
953
+ const url = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
954
+ const res = await this.getSession("", { ...ctx, customUrl: url });
955
+ return {
956
+ activities: res?.raw?.activities || [],
957
+ nextPageToken: res?.raw?.nextPageToken,
958
+ raw: res?.raw || {},
959
+ };
960
+ }
961
+ return { activities: [], raw: {} };
962
+ },
963
+
964
+ async archiveSession(sessionId, ctx = {}) {
965
+ if (!sessionId || typeof sessionId !== "string") {
966
+ throw new TypeError("archiveSession() requires a valid sessionId string");
967
+ }
968
+ if (!ctx || typeof ctx !== "object") ctx = {};
969
+ if (ctx.dryRun) return { id: sessionId, archived: true, raw: {} };
970
+ if (providerSpec.type === "http") {
971
+ const url = `${providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions"}/${sessionId}:archive`;
972
+ const res = await this.approvePlan(sessionId, { ...ctx, customUrl: url });
973
+ return { id: sessionId, archived: true, raw: res?.raw || {} };
974
+ }
975
+ return { id: sessionId, archived: true, raw: {} };
976
+ },
977
+
978
+ async deleteSession(sessionId, ctx = {}) {
979
+ if (!sessionId || typeof sessionId !== "string") {
980
+ throw new TypeError("deleteSession() requires a valid sessionId string");
981
+ }
982
+ if (!ctx || typeof ctx !== "object") ctx = {};
983
+ if (ctx.dryRun) return { id: sessionId, deleted: true, raw: {} };
984
+ if (providerSpec.type === "http") {
985
+ const url = `${providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions"}/${sessionId}`;
986
+ const res = await this.getSession("", { ...ctx, customUrl: url, method: "DELETE" });
987
+ return { id: sessionId, deleted: true, raw: res?.raw || {} };
988
+ }
989
+ return { id: sessionId, deleted: true, raw: {} };
990
+ },
991
+
992
+ async listSources(ctx = {}) {
993
+ if (!ctx || typeof ctx !== "object") ctx = {};
994
+ if (ctx.dryRun) return { sources: [], raw: {} };
995
+ if (providerSpec.type === "http") {
996
+ const baseUrl = "https://jules.googleapis.com/v1alpha/sources";
997
+ const params = new URLSearchParams();
998
+ if (ctx.pageSize) params.set("pageSize", String(ctx.pageSize));
999
+ if (ctx.pageToken) params.set("pageToken", String(ctx.pageToken));
1000
+ const url = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
1001
+ const res = await this.getSession("", { ...ctx, customUrl: url });
1002
+ return {
1003
+ sources: res?.raw?.sources || [],
1004
+ nextPageToken: res?.raw?.nextPageToken,
1005
+ raw: res?.raw || {},
1006
+ };
1007
+ }
1008
+ return { sources: [], raw: {} };
1009
+ },
921
1010
  };
922
1011
  }
923
1012
 
@@ -1038,6 +1127,26 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
1038
1127
  }
1039
1128
  },
1040
1129
 
1130
+ async listSessions(ctx = {}) {
1131
+ return providerList[0].listSessions(ctx);
1132
+ },
1133
+
1134
+ async listActivities(sessionId, ctx = {}) {
1135
+ return providerList[0].listActivities(sessionId, ctx);
1136
+ },
1137
+
1138
+ async archiveSession(sessionId, ctx = {}) {
1139
+ return providerList[0].archiveSession(sessionId, ctx);
1140
+ },
1141
+
1142
+ async deleteSession(sessionId, ctx = {}) {
1143
+ return providerList[0].deleteSession(sessionId, ctx);
1144
+ },
1145
+
1146
+ async listSources(ctx = {}) {
1147
+ return providerList[0].listSources(ctx);
1148
+ },
1149
+
1041
1150
  validate() {
1042
1151
  return providerList.every((p) => typeof p.validate !== "function" || p.validate());
1043
1152
  },
@@ -1108,5 +1217,25 @@ export function createSyntaxVerifiedProvider(fastProvider, complexProvider, conf
1108
1217
  approvePlan(...args) {
1109
1218
  return fastProvider.approvePlan(...args);
1110
1219
  },
1220
+
1221
+ listSessions(...args) {
1222
+ return typeof fastProvider.listSessions === "function" ? fastProvider.listSessions(...args) : { sessions: [] };
1223
+ },
1224
+
1225
+ listActivities(...args) {
1226
+ return typeof fastProvider.listActivities === "function" ? fastProvider.listActivities(...args) : { activities: [] };
1227
+ },
1228
+
1229
+ archiveSession(...args) {
1230
+ return typeof fastProvider.archiveSession === "function" ? fastProvider.archiveSession(...args) : { id: args[0], archived: true };
1231
+ },
1232
+
1233
+ deleteSession(...args) {
1234
+ return typeof fastProvider.deleteSession === "function" ? fastProvider.deleteSession(...args) : { id: args[0], deleted: true };
1235
+ },
1236
+
1237
+ listSources(...args) {
1238
+ return typeof fastProvider.listSources === "function" ? fastProvider.listSources(...args) : { sources: [] };
1239
+ },
1111
1240
  };
1112
1241
  }
@@ -0,0 +1,312 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createProvider } from "./provider.mjs";
5
+ import { loadConfig, resolveRoot } from "./config.mjs";
6
+ import { redactSecrets } from "./security.mjs";
7
+
8
+ /**
9
+ * Parses human-readable duration strings (e.g. "7d", "24h", "30m", "60s") into milliseconds.
10
+ *
11
+ * @param {string|number} duration
12
+ * @returns {number} Milliseconds
13
+ */
14
+ export function parseAgeDuration(duration) {
15
+ if (typeof duration === "number" && Number.isFinite(duration)) return duration;
16
+ if (!duration || typeof duration !== "string") return 0;
17
+ const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i);
18
+ if (!match) {
19
+ const num = Number(duration);
20
+ return Number.isFinite(num) ? num : 0;
21
+ }
22
+ const val = parseFloat(match[1]);
23
+ const unit = match[2].toLowerCase();
24
+ switch (unit) {
25
+ case "s": return val * 1000;
26
+ case "m": return val * 60 * 1000;
27
+ case "h": return val * 60 * 60 * 1000;
28
+ case "d": return val * 24 * 60 * 60 * 1000;
29
+ case "w": return val * 7 * 24 * 60 * 60 * 1000;
30
+ default: return 0;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extracts git diff patch, PR details, and modified files from a Jules session.
36
+ *
37
+ * @param {string} sessionId
38
+ * @param {object} opts
39
+ * @returns {Promise<{ ok: boolean, id: string, patch: string, pr: object|null, files: string[], error?: string }>}
40
+ */
41
+ export async function extractSessionPatch(sessionId, opts = {}) {
42
+ if (!sessionId || typeof sessionId !== "string") {
43
+ throw new TypeError("extractSessionPatch requires a valid sessionId string");
44
+ }
45
+ const root = opts.root || resolveRoot();
46
+ const config = opts.config || loadConfig(root);
47
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
48
+
49
+ const session = await provider.getSession(sessionId, opts);
50
+ const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
51
+ const activities = activitiesRes.activities || [];
52
+
53
+ let patch = "";
54
+ let pr = null;
55
+ const files = new Set();
56
+
57
+ // 1. Inspect session outputs
58
+ const outputs = session?.raw?.outputs || session?.outputs || [];
59
+ for (const out of outputs) {
60
+ if (out.pullRequest) {
61
+ pr = out.pullRequest;
62
+ }
63
+ if (out.gitPatch || out.patch || out.diff) {
64
+ patch = out.gitPatch || out.patch || out.diff;
65
+ }
66
+ }
67
+
68
+ // 2. Inspect activity artifacts and logs if patch wasn't in top-level output
69
+ for (const act of activities) {
70
+ if (act.pullRequest) pr = act.pullRequest;
71
+ if (act.gitPatch || act.patch || act.diff) {
72
+ patch = patch || act.gitPatch || act.patch || act.diff;
73
+ }
74
+ if (act.artifacts && Array.isArray(act.artifacts)) {
75
+ for (const art of act.artifacts) {
76
+ if (art.patch || art.diff || (typeof art.content === "string" && art.content.startsWith("diff --git"))) {
77
+ patch = patch || art.patch || art.diff || art.content;
78
+ }
79
+ if (art.path || art.filename) {
80
+ files.add(art.path || art.filename);
81
+ }
82
+ }
83
+ }
84
+ if (act.changedFiles && Array.isArray(act.changedFiles)) {
85
+ act.changedFiles.forEach((f) => files.add(f));
86
+ }
87
+ }
88
+
89
+ // Extract changed files from patch headers if files set is empty
90
+ if (patch && files.size === 0) {
91
+ const fileMatches = patch.matchAll(/^diff --git a\/(.+?) b\/(.+?)$/gm);
92
+ for (const m of fileMatches) {
93
+ files.add(m[2]);
94
+ }
95
+ }
96
+
97
+ return {
98
+ ok: Boolean(patch || pr),
99
+ id: sessionId,
100
+ patch: patch ? (patch.endsWith("\n") ? patch : patch + "\n") : "",
101
+ pr,
102
+ files: Array.from(files),
103
+ rawSession: session.raw,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Applies a Jules session's patch to the local working tree with git apply safety checks.
109
+ *
110
+ * @param {string} sessionId
111
+ * @param {object} opts
112
+ * @returns {Promise<{ ok: boolean, patchApplied: boolean, checkPassed: boolean, patch: string, files: string[], error?: string }>}
113
+ */
114
+ export async function applySessionPatch(sessionId, opts = {}) {
115
+ const root = opts.root || resolveRoot();
116
+ const res = await extractSessionPatch(sessionId, { ...opts, root });
117
+
118
+ if (!res.patch) {
119
+ return {
120
+ ok: false,
121
+ patchApplied: false,
122
+ checkPassed: false,
123
+ patch: "",
124
+ files: res.files || [],
125
+ error: "No git patch found in session output or activities.",
126
+ };
127
+ }
128
+
129
+ if (opts.save) {
130
+ const savePath = resolve(root, opts.save);
131
+ writeFileSync(savePath, res.patch, "utf-8");
132
+ }
133
+
134
+ // 1. Dry run: git apply --check
135
+ const checkRes = spawnSync("git", ["apply", "--check", "-"], {
136
+ cwd: root,
137
+ input: res.patch,
138
+ encoding: "utf-8",
139
+ });
140
+
141
+ if (checkRes.status !== 0) {
142
+ const stderr = (checkRes.stderr || "").trim();
143
+ return {
144
+ ok: false,
145
+ patchApplied: false,
146
+ checkPassed: false,
147
+ patch: res.patch,
148
+ files: res.files,
149
+ error: `git apply --check failed: ${stderr || "Patch does not cleanly apply to working directory"}`,
150
+ };
151
+ }
152
+
153
+ // 2. If apply requested, execute git apply
154
+ if (opts.apply) {
155
+ const applyRes = spawnSync("git", ["apply", "-"], {
156
+ cwd: root,
157
+ input: res.patch,
158
+ encoding: "utf-8",
159
+ });
160
+
161
+ if (applyRes.status !== 0) {
162
+ const stderr = (applyRes.stderr || "").trim();
163
+ return {
164
+ ok: false,
165
+ patchApplied: false,
166
+ checkPassed: true,
167
+ patch: res.patch,
168
+ files: res.files,
169
+ error: `git apply failed: ${stderr}`,
170
+ };
171
+ }
172
+
173
+ return {
174
+ ok: true,
175
+ patchApplied: true,
176
+ checkPassed: true,
177
+ patch: res.patch,
178
+ files: res.files,
179
+ };
180
+ }
181
+
182
+ return {
183
+ ok: true,
184
+ patchApplied: false,
185
+ checkPassed: true,
186
+ patch: res.patch,
187
+ files: res.files,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Gathers failure context and error output from a failed session and synthesizes a new OODA retry session.
193
+ *
194
+ * @param {string} sessionId
195
+ * @param {object} opts
196
+ * @returns {Promise<{ ok: boolean, originalSessionId: string, newSession: object, failureReason: string }>}
197
+ */
198
+ export async function retrySession(sessionId, opts = {}) {
199
+ if (!sessionId || typeof sessionId !== "string") {
200
+ throw new TypeError("retrySession requires a valid sessionId string");
201
+ }
202
+ const root = opts.root || resolveRoot();
203
+ const config = opts.config || loadConfig(root);
204
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
205
+
206
+ const session = await provider.getSession(sessionId, opts);
207
+ const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
208
+ const activities = activitiesRes.activities || [];
209
+
210
+ // Extract failure diagnostics
211
+ const errors = [];
212
+ for (const act of activities) {
213
+ if (act.error) errors.push(typeof act.error === "string" ? act.error : JSON.stringify(act.error));
214
+ if (act.executionOutput && (act.exitCode !== 0 || act.status === "FAILED")) {
215
+ errors.push(act.executionOutput);
216
+ }
217
+ }
218
+
219
+ const rawPrompt = session?.raw?.prompt || session?.prompt || "";
220
+ const title = session?.raw?.title || `Retry of Session ${sessionId}`;
221
+ const failureReason = errors.join("\n").slice(0, 4000) || "Previous session did not complete cleanly.";
222
+
223
+ let synthesizedPrompt = rawPrompt;
224
+ if (opts.withFailure !== false && failureReason) {
225
+ synthesizedPrompt = `${rawPrompt}\n\n[PREVIOUS_ATTEMPT_FAILURE_DIAGNOSTIC]\nThe previous automated session failed with the following error output:\n\`\`\`\n${redactSecrets(failureReason)}\n\`\`\`\nPlease analyze this failure, fix the root cause, and ensure all tests pass.\n`;
226
+ }
227
+
228
+ const source = session?.raw?.sourceContext?.source || opts.source;
229
+ const branch = session?.raw?.sourceContext?.githubRepoContext?.startingBranch || opts.branch || "main";
230
+
231
+ const dispatchRes = await provider.dispatch(
232
+ {
233
+ title: opts.title || title,
234
+ prompt: synthesizedPrompt,
235
+ role: opts.role,
236
+ source,
237
+ branch,
238
+ },
239
+ opts
240
+ );
241
+
242
+ return {
243
+ ok: true,
244
+ originalSessionId: sessionId,
245
+ newSession: dispatchRes,
246
+ failureReason,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Queries and batch-archives old or completed Jules sessions based on age and state filters.
252
+ *
253
+ * @param {object} opts
254
+ * @returns {Promise<{ ok: boolean, matchedCount: number, archivedCount: number, sessions: Array<{ id: string, state: string, createTime: string, action: string }> }>}
255
+ */
256
+ export async function pruneSessions(opts = {}) {
257
+ const root = opts.root || resolveRoot();
258
+ const config = opts.config || loadConfig(root);
259
+ const provider = opts.provider || createProvider(config.provider || "jules", config);
260
+
261
+ const listRes = await provider.listSessions({ ...opts, pageSize: opts.pageSize || 100 });
262
+ const allSessions = listRes.sessions || [];
263
+
264
+ const maxAgeMs = opts.age ? parseAgeDuration(opts.age) : 0;
265
+ const now = Date.now();
266
+ const targetState = opts.state ? String(opts.state).toUpperCase() : null;
267
+
268
+ const matched = [];
269
+ for (const s of allSessions) {
270
+ const state = (s.state || s.status || "UNKNOWN").toUpperCase();
271
+ if (targetState && state !== targetState) continue;
272
+
273
+ if (maxAgeMs > 0) {
274
+ const timeStr = s.createTime || s.updateTime;
275
+ const sessionTime = timeStr ? new Date(timeStr).getTime() : 0;
276
+ if (sessionTime > 0 && now - sessionTime < maxAgeMs) {
277
+ continue; // Session is newer than the cutoff age
278
+ }
279
+ }
280
+
281
+ matched.push(s);
282
+ }
283
+
284
+ const results = [];
285
+ for (const s of matched) {
286
+ const id = s.id || (s.name ? s.name.split("/").pop() : "");
287
+ if (!id) continue;
288
+
289
+ if (opts.dryRun) {
290
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DRY_RUN_SKIP" });
291
+ } else {
292
+ try {
293
+ if (opts.delete) {
294
+ await provider.deleteSession(id, opts);
295
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DELETED" });
296
+ } else {
297
+ await provider.archiveSession(id, opts);
298
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "ARCHIVED" });
299
+ }
300
+ } catch (err) {
301
+ results.push({ id, state: s.state || s.status, createTime: s.createTime, action: `ERROR: ${err.message}` });
302
+ }
303
+ }
304
+ }
305
+
306
+ return {
307
+ ok: true,
308
+ matchedCount: matched.length,
309
+ archivedCount: results.filter((r) => r.action === "ARCHIVED" || r.action === "DELETED").length,
310
+ sessions: results,
311
+ };
312
+ }