jules-orchestrator-kit 0.36.0 → 0.38.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
@@ -104,7 +104,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
104
104
 
105
105
  * **🔒 Zero Runtime Dependencies:** Built exclusively on Node.js 20+ built-ins (`node:fs`, `node:child_process`, `node:crypto`, `node:path`, `node:http`, `node:tty`, `node:test`). Zero third-party npm packages mean zero supply-chain CVE risk.
106
106
 
107
- * **🛡️ Fail-Closed Security Gatekeeper:** Unconditionally evaluates explicit Deny rules *before* Allow rules, matching against **canonicalised, case-folded paths** so `./`, `..`, mixed separators or a `.GitHub/` spelling cannot walk past a rule (the same repo is checked out on case-insensitive macOS and Windows filesystems). Redacts high-entropy secrets and PII from dry-runs and git diffs, blocks unsupported Node.js native module imports in Edge environments (Cloudflare Workers, Vercel Edge, Netlify Edge), and rejects PRs exceeding the 75 KB Diff Payload governor.
107
+ * **🛡️ Fail-Closed Security Gatekeeper:** Unconditionally evaluates explicit Deny rules *before* Allow rules, matching against **canonicalised, case-folded paths** so `./`, `..`, mixed separators or a `.GitHub/` spelling cannot walk past a rule (the same repo is checked out on case-insensitive macOS and Windows filesystems). Redacts high-entropy secrets and PII from dry-runs and git diffs — **including credentials wrapped in base64**, so a key inside a Kubernetes `Secret` manifest is not invisible to a line-oriented scanner — blocks unsupported Node.js native module imports in Edge environments (Cloudflare Workers, Vercel Edge, Netlify Edge), and rejects PRs exceeding the 75 KB Diff Payload governor.
108
108
 
109
109
  * **🔄 Autonomous OODA Self-Healing:** Captures test stderr/stdout, normalizes failure fingerprints, and feeds structured error contexts back into repair iterations (up to 3 automatic attempts) before human escalation.
110
110
 
@@ -122,7 +122,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
122
122
 
123
123
  * **🚀 Zero-Test Bootstrapping (`agentctl bootstrap`):** Synthesizes deterministic syntax-check and smoke-test verification oracles for untested legacy repositories so agents always operate against a falsifiable feedback loop.
124
124
 
125
- * **📈 Proven Scale & Reliability:** Empirically tested with **532 unit tests across 79 suites passing in < 10.0s**. An adversarial red-team suite (`test/adversarial-claims.test.mjs`) continuously attempts to falsify the safety guarantees documented above — including cross-platform probes for the case-insensitive filesystems on macOS and Windows — and a documentation-sync gate (`scripts/doc-sync-check.mjs`) blocks any release whose docs have drifted from the code.
125
+ * **📈 Proven Scale & Reliability:** Empirically tested with **555 unit tests across 81 suites passing in < 10.0s**. An adversarial red-team suite (`test/adversarial-claims.test.mjs`) continuously attempts to falsify the safety guarantees documented above — every probe in it currently holds, with no open gaps — including cross-platform probes for the case-insensitive filesystems on macOS and Windows — and a documentation-sync gate (`scripts/doc-sync-check.mjs`) blocks any release whose docs have drifted from the code.
126
126
 
127
127
  <br/>
128
128
 
package/bin/agentctl.mjs CHANGED
@@ -334,18 +334,41 @@ async function main() {
334
334
  // The count is local-only, so an operator who knows their real usage
335
335
  // must be able to correct it. Appending `budget_released` keeps the
336
336
  // hash chain intact — the ledger is corrected forwards, never edited.
337
+ // An unrecognised flag is refused rather than ignored. `reset` writes to
338
+ // the ledger, and a misremembered flag — `--root`, `--force` — silently
339
+ // dropping through to a full release is the kind of misfire that only
340
+ // becomes visible after the count is already gone.
341
+ const known = new Set(["--dry-run", "--yes", "-y", "--all"]);
342
+ const unknown = args.slice(2).filter((a) => !known.has(a));
343
+ if (unknown.length) {
344
+ console.error(`Unrecognised option${unknown.length > 1 ? "s" : ""} for \`budget reset\`: ${unknown.join(", ")}`);
345
+ console.error("Accepted: --dry-run, --yes/-y, --all. Nothing was released.");
346
+ process.exit(2);
347
+ }
337
348
  const dryRun = args.includes("--dry-run");
338
349
  const confirmed = args.includes("--yes") || args.includes("-y");
350
+ // Committed reservations reached the provider, so releasing them makes
351
+ // the local count understate real usage. That has to be deliberate.
352
+ const includeCommitted = args.includes("--all");
339
353
  if (!dryRun && !confirmed) {
340
354
  const open = listOpenReservations(root);
341
- console.log(`Would release ${open.length} open reservation(s) from the last 24 hours.`);
355
+ const committed = open.filter((r) => r.committed).length;
356
+ const target = includeCommitted ? open.length : open.length - committed;
357
+ console.log(`Would release ${target} open reservation(s) from the last 24 hours.`);
358
+ if (committed > 0 && !includeCommitted) {
359
+ console.log(`Keeping ${committed} that reached Jules — those sessions really spent quota.`);
360
+ console.log("Add --all to release them too, if you know the count is still wrong.");
361
+ }
342
362
  console.log("This rewrites nothing — it appends `budget_released` entries.");
343
363
  console.log("Re-run with --yes to confirm, or --dry-run for detail.");
344
364
  process.exit(0);
345
365
  }
346
- const res = releaseOpenReservations({ root, dryRun, reason: "operator-reconcile" });
366
+ const res = releaseOpenReservations({ root, dryRun, includeCommitted, reason: "operator-reconcile" });
347
367
  const verb = dryRun ? "Would release" : "Released";
348
- console.log(`${verb} ${res.released} reservation(s) (${res.committed} committed, ${res.uncommitted} never closed).`);
368
+ console.log(`${verb} ${res.released} of ${res.open} open reservation(s) — ${res.uncommitted} never closed, ${res.committed} committed.`);
369
+ if (res.kept > 0) {
370
+ console.log(`Kept ${res.kept} committed reservation(s); re-run with --all to release those as well.`);
371
+ }
349
372
  if (!dryRun) {
350
373
  const after = budgetStatus(loadConfig(root), root);
351
374
  console.log(`Daily Budget : ${formatBudgetLine(after)}`);
@@ -363,14 +386,17 @@ async function main() {
363
386
  console.log(` ${b.note}`);
364
387
  console.log(` Window opened at ${b.windowStart} — the quota resets ${b.windowHours}h after each task,`);
365
388
  console.log(" not at midnight, so this count spans yesterday's ledger too.");
366
- console.log(` Open reservations in the window: ${listOpenReservations(root).length}`);
389
+ const openNow = listOpenReservations(root);
390
+ const committedNow = openNow.filter((r) => r.committed).length;
391
+ console.log(` Open reservations in the window: ${openNow.length} (${committedNow} confirmed dispatched, ${openNow.length - committedNow} never closed)`);
367
392
  console.log("");
368
393
  console.log(`Worker Slots : ${slots.concurrency} concurrent`);
369
394
  console.log(` ${slots.note}`);
370
395
  console.log("");
371
396
  console.log("The ledger counts this checkout only — sessions started from the Jules");
372
397
  console.log("web UI or another machine spend the same quota without appearing here.");
373
- console.log("Use `agentctl budget reset --yes` to reconcile a count you know is wrong.");
398
+ console.log("Use `agentctl budget reset --yes` to clear the ones that never closed,");
399
+ console.log("or `--all` to also give back the ones that did reach Jules.");
374
400
  process.exit(0);
375
401
  break;
376
402
  }
@@ -1252,6 +1278,10 @@ async function main() {
1252
1278
  }
1253
1279
 
1254
1280
  main().catch((err) => {
1281
+ if (err.name === "WizardCancelledError" || err.code === 130 || (err instanceof Error && err.message?.includes("cancelled by user"))) {
1282
+ console.log(`\n🛑 ${err.message || "Operation cancelled by user."}`);
1283
+ process.exit(130);
1284
+ }
1255
1285
  console.error(`[FATAL ERROR] ${err.message}`);
1256
1286
  const exitCode = typeof err.code === "number" ? err.code : 1;
1257
1287
  process.exit(exitCode);
package/index.mjs CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  matchesGlob,
14
14
  checkScope,
15
15
  scanDiff,
16
+ hasEncodedSecret,
16
17
  checkEdgeRuntimeImports,
17
18
  checkCrossPackageImports,
18
19
  } from "./src/security.mjs";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.36.0",
4
- "description": "Orchestration kit for running Google Jules autonomous agents.",
3
+ "version": "0.38.0",
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",
7
7
  "url": "git+https://github.com/FullThrottle83/jules-orchestrator-kit.git"
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "init": "node bin/init.js",
39
- "test": "node --test test/*.test.mjs",
39
+ "test": "node scripts/run-tests.mjs",
40
40
  "jules:dispatch": "node scripts/jules-dispatch.mjs",
41
41
  "jules:queue": "node bin/agentctl.mjs queue",
42
42
  "jules:create": "node scripts/jules-create.mjs",
@@ -186,6 +186,33 @@ export function checkDocSync(root = process.cwd(), opts = {}) {
186
186
  ? `v${unreleased.join(", v")} marked (Unreleased) but <= shipped v${version}`
187
187
  : "no released version left marked (Unreleased)"
188
188
  );
189
+
190
+ // An unchecked item under "Shipped Milestones" is a claim the release
191
+ // contradicts. v0.37.0 shipped base64 decoding while the deferral note
192
+ // under v0.32.6 still read "still does not decode" — two statements about
193
+ // the same feature, thirty lines apart, disagreeing. Both survived every
194
+ // other check here, because nothing compared a milestone to its own
195
+ // section heading. Deferrals belong under a target milestone, not a
196
+ // shipped one; moving the item is the fix, ticking it off is the other.
197
+ const shippedStart = roadmap.search(/^##\s.*Shipped Milestones/m);
198
+ if (shippedStart === -1) {
199
+ add("ROADMAP shipped items resolved", false, "no `## … Shipped Milestones` heading found");
200
+ } else {
201
+ const rest = roadmap.slice(shippedStart);
202
+ // Search from index 1 so the section's own heading cannot terminate it.
203
+ const nextHeading = rest.slice(1).search(/^##\s/m);
204
+ const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
205
+ const openItems = [...section.matchAll(/^-\s\[ \]\s+(.+)$/gm)].map((m) =>
206
+ m[1].replace(/\*\*/g, "").split("—")[0].trim().slice(0, 60)
207
+ );
208
+ add(
209
+ "ROADMAP shipped items resolved",
210
+ openItems.length === 0,
211
+ openItems.length
212
+ ? `unchecked under Shipped Milestones: ${openItems.join("; ")}`
213
+ : "every item under Shipped Milestones is resolved"
214
+ );
215
+ }
189
216
  }
190
217
 
191
218
  // 3. CHANGELOG.md — a released version needs a matching entry (release.mjs
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ const root = process.cwd();
7
+ const testDir = join(root, "test");
8
+ const testFiles = readdirSync(testDir)
9
+ .filter((f) => f.endsWith(".test.mjs"))
10
+ .sort()
11
+ .map((f) => join("test", f));
12
+
13
+ const res = spawnSync(process.execPath, ["--test", ...testFiles], {
14
+ cwd: root,
15
+ stdio: "inherit",
16
+ env: process.env,
17
+ });
18
+
19
+ process.exit(res.status ?? 1);
package/src/budget.mjs CHANGED
@@ -276,32 +276,54 @@ export function listOpenReservations(root = resolveRoot(), opts = {}) {
276
276
  * forwards — never by editing or deleting the file, which would break the chain
277
277
  * and destroy the audit trail the ledger exists to provide.
278
278
  *
279
- * This is an operator override, not an inference. The kit cannot tell a
280
- * reservation that reached the provider from one whose process died first, so
281
- * only the operator knows whether the local count still reflects reality.
279
+ * This is an operator override, not an inference — but the two kinds of open
280
+ * reservation are not equally likely to be wrong, and by default only one of
281
+ * them is released.
282
+ *
283
+ * An **uncommitted** reservation was taken and never resolved: the process died
284
+ * between reserving the slot and learning what happened to it. That is the
285
+ * phantom this function exists to clear.
286
+ *
287
+ * A **committed** one carries proof that the dispatch reached the provider, so
288
+ * a real session exists and the quota really was spent. Releasing it makes the
289
+ * local count *understate* actual usage, which is the dangerous direction: the
290
+ * kit then dispatches confidently past the provider's real limit and gets
291
+ * refused. `includeCommitted` therefore has to be asked for.
292
+ *
293
+ * Legacy id-less reservations have no `budget_committed` entry that could ever
294
+ * name them, so they always read as uncommitted and are always released.
282
295
  *
283
296
  * @param {object} [opts]
284
297
  * @param {string} [opts.root]
285
298
  * @param {string} [opts.reason] - Recorded on every released entry.
286
299
  * @param {boolean} [opts.dryRun] - Report what would be released, write nothing.
287
- * @returns {{ released: number, committed: number, uncommitted: number, ids: string[], dryRun: boolean }}
300
+ * @param {boolean} [opts.includeCommitted] - Also release reservations that
301
+ * demonstrably reached the provider. Off by default.
302
+ * @returns {{ released: number, kept: number, open: number, committed: number,
303
+ * uncommitted: number, anonymous: number, ids: string[],
304
+ * includeCommitted: boolean, dryRun: boolean }}
288
305
  */
289
306
  export function releaseOpenReservations(opts = {}) {
290
307
  const root = opts.root || resolveRoot();
291
308
  const openRecords = listOpenReservations(root);
292
309
  const committed = openRecords.filter((r) => r.committed).length;
310
+ const includeCommitted = Boolean(opts.includeCommitted);
311
+ const targets = includeCommitted ? openRecords : openRecords.filter((r) => !r.committed);
293
312
 
294
313
  const result = {
295
- released: openRecords.length,
314
+ released: targets.length,
315
+ kept: openRecords.length - targets.length,
316
+ open: openRecords.length,
296
317
  committed,
297
318
  uncommitted: openRecords.length - committed,
298
- anonymous: openRecords.filter((r) => !r.reservationId).length,
299
- ids: openRecords.map((r) => r.reservationId).filter(Boolean),
319
+ anonymous: targets.filter((r) => !r.reservationId).length,
320
+ ids: targets.map((r) => r.reservationId).filter(Boolean),
321
+ includeCommitted,
300
322
  dryRun: Boolean(opts.dryRun),
301
323
  };
302
- if (opts.dryRun || openRecords.length === 0) return result;
324
+ if (opts.dryRun || targets.length === 0) return result;
303
325
 
304
- for (const rec of openRecords) {
326
+ for (const rec of targets) {
305
327
  const entry = { event: "budget_released", reason: opts.reason || "operator-reconcile" };
306
328
  if (rec.reservationId) {
307
329
  entry.reservationId = rec.reservationId;
package/src/git.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { normalizePath } from "./config.mjs";
@@ -49,20 +49,26 @@ export function runCmd(command, opts = {}) {
49
49
  }
50
50
 
51
51
  try {
52
- const isWin = process.platform === "win32";
53
- const execBin = useShell ? (isWin ? (process.env.ComSpec || "cmd.exe") : "/bin/sh") : binary;
54
- const execArgs = useShell ? (isWin ? ["/d", "/s", "/c", shellCmd] : ["-c", shellCmd]) : args;
55
-
56
- const stdout = execFileSync(execBin, execArgs, {
57
- cwd,
58
- encoding: "utf-8",
59
- shell: false,
60
- stdio: ["ignore", "pipe", "pipe"],
61
- env: opts.env || process.env,
62
- timeout,
63
- maxBuffer,
64
- });
65
- return { status: 0, stdout: stdout.trim(), stderr: "" };
52
+ const stdout = useShell
53
+ ? execSync(shellCmd, {
54
+ cwd,
55
+ encoding: "utf-8",
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ env: opts.env || process.env,
58
+ timeout,
59
+ maxBuffer,
60
+ })
61
+ : execFileSync(binary, args, {
62
+ cwd,
63
+ encoding: "utf-8",
64
+ shell: false,
65
+ stdio: ["ignore", "pipe", "pipe"],
66
+ env: opts.env || process.env,
67
+ timeout,
68
+ maxBuffer,
69
+ });
70
+
71
+ return { status: 0, stdout: String(stdout || "").trim(), stderr: "" };
66
72
  } catch (err) {
67
73
  const isTimeout = err.code === "ETIMEDOUT" || (err.signal === "SIGTERM" && err.killed);
68
74
  const isNobufs = err.code === "ENOBUFS" || (err.message && err.message.includes("maxBuffer"));
package/src/security.mjs CHANGED
@@ -142,6 +142,19 @@ export function redactSecrets(text) {
142
142
  sanitized = sanitized.replace(pat, "[REDACTED_BY_SECURITY_GATE]");
143
143
  }
144
144
 
145
+ // A key the scanner can find inside a base64 blob must not survive redaction
146
+ // just because the literal bytes differ — otherwise scanDiff blocks the
147
+ // dispatch and the escalation payload leaks the very value it blocked on. The
148
+ // whole blob goes, not part of it: a partially-redacted encoding still
149
+ // decodes to the key.
150
+ const encoded = new Set();
151
+ decodeBase64Blobs(sanitized, (plain, blob) => {
152
+ if (hasHighConfidenceSecret(plain)) encoded.add(blob);
153
+ });
154
+ for (const blob of encoded) {
155
+ sanitized = sanitized.split(blob).join("[REDACTED_ENCODED_SECRET]");
156
+ }
157
+
145
158
  return sanitized;
146
159
  }
147
160
 
@@ -374,14 +387,118 @@ const STRING_CONCAT_JOIN = /(["'`])\s*\+\s*(["'`])/g;
374
387
  * Produces the variants of the added-line text that secret patterns are run
375
388
  * against: as-written, with invisible characters stripped, and with
376
389
  * source-level string concatenation collapsed.
390
+ *
391
+ * `normalized` is the last of those — every normalisation applied. It is
392
+ * returned separately for checks that are too expensive to run three times and
393
+ * gain nothing from the intermediate forms.
394
+ *
377
395
  * @param {string} addedLines
378
- * @returns {string[]}
396
+ * @returns {{ all: string[], normalized: string }}
379
397
  */
380
398
  function secretScanVariants(addedLines) {
381
399
  const stripped = addedLines.replace(INVISIBLE_CHARS, "");
382
400
  // Collapse `"AAA" +\n "BBB"` into `"AAABBB"` before matching.
383
401
  const dejoined = stripped.replace(/\s*\n\s*/g, " ").replace(STRING_CONCAT_JOIN, "");
384
- return [...new Set([addedLines, stripped, dejoined])];
402
+ return { all: [...new Set([addedLines, stripped, dejoined])], normalized: dejoined };
403
+ }
404
+
405
+ // Base64 is less an evasion technique than a file format. Every value in a
406
+ // Kubernetes Secret manifest is base64 by specification, and whole `.env` files
407
+ // get encoded into a single CI variable. A credential arriving that way is
408
+ // ordinary rather than adversarial — and a line-oriented scanner walks straight
409
+ // past it, which makes this the encoding most likely to carry a live key
410
+ // through the gate.
411
+ const BASE64_CANDIDATE = /[A-Za-z0-9+/]{24,}={0,2}/g;
412
+
413
+ // Decoding is cheap per blob and ruinous per diff if left unbounded. A patch
414
+ // that checks in a binary, a source map or a bundled font is otherwise enough
415
+ // to turn one scan into a memory event, so both the number of candidates and
416
+ // the total decoded size are capped. Exceeding a cap skips the remainder; it
417
+ // does not fail the scan, because a large diff is not evidence of a leak.
418
+ const BASE64_MAX_CANDIDATES = 64;
419
+ const BASE64_MAX_DECODED_BYTES = 64 * 1024;
420
+
421
+ /**
422
+ * Share of characters that are printable ASCII (plus tab/newline/return).
423
+ *
424
+ * `Buffer.from(str, "base64")` never throws — it discards what it cannot parse
425
+ * and returns whatever it managed to decode. So a hex digest or a random
426
+ * identifier of the right length "decodes" successfully into bytes that mean
427
+ * nothing. A wrapped credential, on the other hand, decodes to text: keys,
428
+ * PEM blocks and `.env` bodies are all ASCII. This ratio is what separates the
429
+ * two, and it removes nearly all of the noise before any pattern runs.
430
+ */
431
+ function printableRatio(str) {
432
+ if (!str) return 0;
433
+ let printable = 0;
434
+ for (let i = 0; i < str.length; i++) {
435
+ const c = str.charCodeAt(i);
436
+ if (c === 9 || c === 10 || c === 13 || (c >= 32 && c <= 126)) printable++;
437
+ }
438
+ return printable / str.length;
439
+ }
440
+
441
+ /**
442
+ * Decode the base64-looking blobs in `text` that plausibly hold text.
443
+ *
444
+ * @param {string} text
445
+ * @param {(plain: string, blob: string) => void} [onDecoded] - Called per blob.
446
+ * @returns {string[]}
447
+ */
448
+ function decodeBase64Blobs(text, onDecoded) {
449
+ if (!text) return [];
450
+ const decoded = [];
451
+ let candidates = 0;
452
+ let bytes = 0;
453
+
454
+ BASE64_CANDIDATE.lastIndex = 0;
455
+ let match;
456
+ while ((match = BASE64_CANDIDATE.exec(text)) !== null) {
457
+ if (candidates++ >= BASE64_MAX_CANDIDATES) break;
458
+ const blob = match[0];
459
+ // Valid base64 is a multiple of four characters including padding. This
460
+ // costs nothing and rejects three quarters of the alphanumeric runs — commit
461
+ // hashes, minified identifiers — that would otherwise be decoded for nothing.
462
+ if (blob.length % 4 !== 0) continue;
463
+ // Check the budget against what this blob *would* cost, not against what
464
+ // has already been spent — otherwise the first candidate decodes in full
465
+ // however large it is, and a single checked-in binary costs more than the
466
+ // cap was meant to allow. `continue`, not `break`: one oversized blob must
467
+ // not hide the smaller ones after it.
468
+ if (bytes + (blob.length * 3) / 4 > BASE64_MAX_DECODED_BYTES) continue;
469
+
470
+ let plain;
471
+ try {
472
+ plain = Buffer.from(blob, "base64").toString("utf-8");
473
+ } catch (_) {
474
+ continue;
475
+ }
476
+ bytes += plain.length;
477
+ if (printableRatio(plain) < 0.9) continue;
478
+
479
+ decoded.push(plain);
480
+ if (onDecoded) onDecoded(plain, blob);
481
+ }
482
+ BASE64_CANDIDATE.lastIndex = 0;
483
+ return decoded;
484
+ }
485
+
486
+ /**
487
+ * True when a base64-encoded value on an added line decodes to a structured
488
+ * credential.
489
+ *
490
+ * Deliberately runs the high-confidence patterns *only*. The low-confidence set
491
+ * is entropy- and keyword-driven, and decoded bytes are high-entropy by
492
+ * construction — pointing it at this output would flag close to every encoded
493
+ * blob in every repository. `AKIA[0-9A-Z]{16}` cannot match decoded noise;
494
+ * "looks secret-ish" always can. That asymmetry is the whole reason this check
495
+ * is safe to enable by default.
496
+ *
497
+ * @param {string} text
498
+ * @returns {boolean}
499
+ */
500
+ export function hasEncodedSecret(text) {
501
+ return decodeBase64Blobs(text).some((plain) => hasHighConfidenceSecret(plain));
385
502
  }
386
503
 
387
504
  export function scanDiff(diffTextStr = "", options = {}) {
@@ -392,13 +509,23 @@ export function scanDiff(diffTextStr = "", options = {}) {
392
509
  .map((line) => line.slice(1))
393
510
  .join("\n");
394
511
 
395
- const variants = secretScanVariants(addedLines);
512
+ const { all: variants, normalized } = secretScanVariants(addedLines);
396
513
  const hasHigh = variants.some((v) => hasHighConfidenceSecret(v));
397
- const hasLow = !hasHigh && variants.some((v) => hasLowConfidenceSecret(v));
514
+ // Only worth decoding when nothing was found in the clear, and only against
515
+ // the fully-normalised text: decoding is the expensive step, and the
516
+ // intermediate variants differ from it in ways base64 blobs do not care about.
517
+ const hasEncoded = !hasHigh && hasEncodedSecret(normalized);
518
+ const hasLow = !hasHigh && !hasEncoded && variants.some((v) => hasLowConfidenceSecret(v));
398
519
  const findings = [];
399
520
 
400
521
  if (hasHigh) {
401
522
  findings.push({ severity: "CRITICAL", type: "HIGH_CONFIDENCE_SECRET", description: "High-confidence secret pattern detected in added diff lines" });
523
+ } else if (hasEncoded) {
524
+ // Same type as the cleartext case: every gate that blocks on
525
+ // HIGH_CONFIDENCE_SECRET should block on this too, and a new type would
526
+ // have silently passed through the ones not updated. The description
527
+ // carries the difference the operator needs.
528
+ findings.push({ severity: "CRITICAL", type: "HIGH_CONFIDENCE_SECRET", description: "High-confidence secret pattern detected inside a base64-encoded value on an added diff line" });
402
529
  } else if (hasLow) {
403
530
  findings.push({ severity: "HIGH", type: "LOW_CONFIDENCE_SECRET", description: "Low-confidence secret or authorization token detected in added diff lines" });
404
531
  }
@@ -419,7 +546,7 @@ export function scanDiff(diffTextStr = "", options = {}) {
419
546
  }
420
547
 
421
548
  return {
422
- ok: !hasHigh && !hasLow && edgeRes.ok && crossPkgRes.ok,
549
+ ok: !hasHigh && !hasEncoded && !hasLow && edgeRes.ok && crossPkgRes.ok,
423
550
  findings,
424
551
  };
425
552
  }
package/src/state.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import { join, basename } from "node:path";
14
14
  import { createHash, randomUUID } from "node:crypto";
15
15
  import { hostname } from "node:os";
16
+ import { execFileSync } from "node:child_process";
16
17
  import { resolveRoot } from "./config.mjs";
17
18
  import { redactSecrets } from "./security.mjs";
18
19
 
@@ -478,11 +479,17 @@ export function getProcessStartTime(pid) {
478
479
  return parseProcStat(stat);
479
480
  } catch (_) {}
480
481
  }
482
+ if (process.platform === "darwin") {
483
+ try {
484
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
485
+ return out.trim() || null;
486
+ } catch (_) {}
487
+ }
481
488
  return null;
482
489
  }
483
490
 
484
491
  /**
485
- * Checks if a PID is alive with PID-recycling start time validation on Linux.
492
+ * Checks if a PID is alive with PID-recycling start time validation on Linux and macOS.
486
493
  */
487
494
  export function isPidAlive(pid, expectedStartTime = null) {
488
495
  if (!pid || typeof pid !== "number") return false;
@@ -492,10 +499,10 @@ export function isPidAlive(pid, expectedStartTime = null) {
492
499
  if (err?.code !== "EPERM") return false;
493
500
  }
494
501
 
495
- if (expectedStartTime !== null && expectedStartTime !== undefined && process.platform === "linux") {
502
+ if (expectedStartTime !== null && expectedStartTime !== undefined) {
496
503
  try {
497
504
  const actualStartTime = getProcessStartTime(pid);
498
- if (!actualStartTime || String(actualStartTime) !== String(expectedStartTime)) {
505
+ if (actualStartTime && String(actualStartTime) !== String(expectedStartTime)) {
499
506
  return false;
500
507
  }
501
508
  } catch (_) {
package/src/tui.mjs CHANGED
@@ -42,10 +42,11 @@ export function styleText(text, style) {
42
42
  }
43
43
 
44
44
  import { WizardCancelledError } from "./ux/terminal-session.mjs";
45
+ import { createKeyDecoder } from "./ux/key-decoder.mjs";
45
46
  export { WizardCancelledError };
46
47
 
47
48
  /**
48
- * Read keypress in raw mode from TTY input stream.
49
+ * Read keypress in raw mode from TTY input stream without destroying stream on early return.
49
50
  * @param {import("node:stream").Readable} stream
50
51
  * @returns {AsyncGenerator<string, void, unknown>}
51
52
  */
@@ -54,20 +55,84 @@ async function* readKeypresses(stream = process.stdin) {
54
55
  if (stream.setRawMode) {
55
56
  stream.setRawMode(true);
56
57
  }
58
+
59
+ const decoder = createKeyDecoder();
60
+ const queue = [];
61
+ let waiting = null;
62
+ let error = null;
63
+
64
+ const onData = (chunk) => {
65
+ const events = decoder.push(chunk);
66
+ for (const ev of events) {
67
+ const str = ev.sequence;
68
+ if (waiting) {
69
+ const resolve = waiting;
70
+ waiting = null;
71
+ resolve({ value: str, done: false });
72
+ } else {
73
+ queue.push(str);
74
+ }
75
+ }
76
+ };
77
+
78
+ const onEnd = () => {
79
+ const flushed = decoder.flush();
80
+ for (const ev of flushed) {
81
+ queue.push(ev.sequence);
82
+ }
83
+ if (waiting) {
84
+ const resolve = waiting;
85
+ waiting = null;
86
+ if (queue.length > 0) {
87
+ resolve({ value: queue.shift(), done: false });
88
+ } else {
89
+ resolve({ done: true });
90
+ }
91
+ }
92
+ };
93
+
94
+ const onError = (err) => {
95
+ error = err;
96
+ if (waiting) {
97
+ const reject = waiting;
98
+ waiting = null;
99
+ reject(err);
100
+ }
101
+ };
102
+
103
+ stream.on("data", onData);
104
+ stream.on("end", onEnd);
105
+ stream.on("error", onError);
106
+
57
107
  stream.resume();
58
108
  stream.setEncoding("utf-8");
59
109
 
60
110
  try {
61
- for await (const chunk of stream) {
62
- const str = String(chunk);
63
- if (str === "\u0003") {
111
+ while (true) {
112
+ if (error) throw error;
113
+ let nextVal;
114
+ if (queue.length > 0) {
115
+ nextVal = queue.shift();
116
+ } else {
117
+ const res = await new Promise((resolve) => {
118
+ waiting = resolve;
119
+ });
120
+ if (res.done) break;
121
+ nextVal = res.value;
122
+ }
123
+
124
+ if (nextVal === "\u0003") {
64
125
  // Ctrl+C SIGINT
65
126
  process.stdout.write(ANSI.showCursor + "\n");
66
127
  throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).");
67
128
  }
68
- yield str;
129
+ yield nextVal;
69
130
  }
70
131
  } finally {
132
+ stream.off("data", onData);
133
+ stream.off("end", onEnd);
134
+ stream.off("error", onError);
135
+ stream.pause();
71
136
  if (stream.setRawMode) {
72
137
  stream.setRawMode(Boolean(wasRaw));
73
138
  }
@@ -135,11 +200,11 @@ export async function select(options, prompt, opts = {}) {
135
200
  }
136
201
 
137
202
  let moved = false;
138
- if (key === "\u001b[A" || key === "k") {
203
+ if (key === "\u001b[A" || key === "\u001bOA" || key === "k") {
139
204
  // Up
140
205
  currentIdx = (currentIdx - 1 + options.length) % options.length;
141
206
  moved = true;
142
- } else if (key === "\u001b[B" || key === "j") {
207
+ } else if (key === "\u001b[B" || key === "\u001bOB" || key === "j") {
143
208
  // Down
144
209
  currentIdx = (currentIdx + 1) % options.length;
145
210
  moved = true;
@@ -219,10 +284,10 @@ export async function multiSelect(options, prompt, opts = {}) {
219
284
  }
220
285
 
221
286
  let redraw = false;
222
- if (key === "\u001b[A" || key === "k") {
287
+ if (key === "\u001b[A" || key === "\u001bOA" || key === "k") {
223
288
  currentIdx = (currentIdx - 1 + options.length) % options.length;
224
289
  redraw = true;
225
- } else if (key === "\u001b[B" || key === "j") {
290
+ } else if (key === "\u001b[B" || key === "\u001bOB" || key === "j") {
226
291
  currentIdx = (currentIdx + 1) % options.length;
227
292
  redraw = true;
228
293
  } else if (key === " ") {
@@ -276,7 +341,20 @@ export async function input(prompt, opts = {}) {
276
341
  while (true) {
277
342
  const defaultHint = defaultValue ? ` ${ANSI.dim}(${defaultValue})${ANSI.reset}` : "";
278
343
  const query = `${ANSI.bold}${ANSI.cyan}?${ANSI.reset} ${ANSI.bold}${prompt}${defaultHint}: `;
279
- const rawAns = await rl.question(query);
344
+ let rawAns;
345
+ try {
346
+ rawAns = await rl.question(query);
347
+ } catch (err) {
348
+ if (err.name === "AbortError" || err.code === "ABORT_ERR" || err.message?.includes("Ctrl+C")) {
349
+ stdout.write("\n");
350
+ throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).");
351
+ }
352
+ throw err;
353
+ }
354
+ if (rawAns.includes("\u0003") || rawAns.includes("\x03")) {
355
+ stdout.write("\n");
356
+ throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).");
357
+ }
280
358
  const ans = rawAns.trim() || defaultValue;
281
359
 
282
360
  const validRes = await validate(ans);
@@ -220,11 +220,20 @@ export function loadPresets(root = process.cwd()) {
220
220
  export async function runInitWizard(root = process.cwd(), options = {}) {
221
221
  const interactive = options.interactive !== false && isTTY(options.stdin || process.stdin);
222
222
 
223
- if (!interactive && !options.allowDefaults && !options.tier) {
223
+ // Preserve existing config if present
224
+ let existingConfig = {};
225
+ const existingConfigPath = join(root, ".agent", "config.yml");
226
+ if (existsSync(existingConfigPath)) {
227
+ try {
228
+ existingConfig = parseYaml(readFileSync(existingConfigPath, "utf-8")) || {};
229
+ } catch (_) {}
230
+ }
231
+
232
+ if (!interactive && !options.allowDefaults && !options.tier && !existingConfig.tier) {
224
233
  throw new Error("Non-interactive init requires explicit options or allowDefaults: true");
225
234
  }
226
235
 
227
- let selectedTier = options.tier || "pro";
236
+ let selectedTier = options.tier || existingConfig.tier || "pro";
228
237
  let testCmd = options.testCmd;
229
238
  let buildCmd = options.buildCmd;
230
239
  let selectedPresets = options.presets;
@@ -235,28 +244,35 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
235
244
  await new Promise((resolve) => setTimeout(resolve, 300));
236
245
  sp.stop(`Detected Stack: ${oracle.stack}`);
237
246
 
247
+ const optionsList = tierOptions();
248
+ const defaultTierIdx = Math.max(0, optionsList.findIndex((t) => t.value === selectedTier));
249
+
238
250
  selectedTier = await select(
239
- tierOptions(),
251
+ optionsList,
240
252
  "Which plan does your Jules account use? (limits are adjustable later)",
241
- options
253
+ { ...options, defaultIdx: defaultTierIdx }
242
254
  );
243
255
 
244
256
  testCmd = await input("Verification Test Command", {
245
- defaultValue: oracle.candidates.testCmd || "npm test",
257
+ defaultValue: testCmd || existingConfig.verify?.test || oracle.candidates.testCmd || "npm test",
246
258
  stdin: options.stdin,
247
259
  stdout: options.stdout,
248
260
  });
249
261
 
250
262
  buildCmd = await input("Verification Build Command", {
251
- defaultValue: oracle.candidates.buildCmd || "",
263
+ defaultValue: buildCmd || existingConfig.verify?.build || oracle.candidates.buildCmd || "",
252
264
  stdin: options.stdin,
253
265
  stdout: options.stdout,
254
266
  });
255
267
 
268
+ const defaultPresetSet = new Set(
269
+ selectedPresets || existingConfig.presets || BUILTIN_PRESETS.map((p) => p.id)
270
+ );
271
+
256
272
  const presetOptions = BUILTIN_PRESETS.map((p) => ({
257
273
  label: p.title,
258
274
  value: p.id,
259
- checked: true,
275
+ checked: defaultPresetSet.has(p.id),
260
276
  description: p.description,
261
277
  }));
262
278