@hasna/loops 0.4.1 → 0.4.2

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.
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.1",
6
+ version: "0.4.2",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
@@ -43,6 +43,22 @@ var package_default = {
43
43
  "./storage": {
44
44
  types: "./dist/lib/store.d.ts",
45
45
  import: "./dist/lib/store.js"
46
+ },
47
+ "./storage/contract": {
48
+ types: "./dist/lib/storage/contract.d.ts",
49
+ import: "./dist/lib/storage/contract.js"
50
+ },
51
+ "./storage/sqlite": {
52
+ types: "./dist/lib/storage/sqlite.d.ts",
53
+ import: "./dist/lib/storage/sqlite.js"
54
+ },
55
+ "./storage/postgres": {
56
+ types: "./dist/lib/storage/postgres.d.ts",
57
+ import: "./dist/lib/storage/postgres.js"
58
+ },
59
+ "./storage/postgres-schema": {
60
+ types: "./dist/lib/storage/postgres-schema.d.ts",
61
+ import: "./dist/lib/storage/postgres-schema.js"
46
62
  }
47
63
  },
48
64
  files: [
@@ -53,7 +69,7 @@ var package_default = {
53
69
  "LICENSE"
54
70
  ],
55
71
  scripts: {
56
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
72
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
57
73
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
58
74
  typecheck: "tsc --noEmit",
59
75
  test: "bun test",
@@ -254,18 +270,1730 @@ function deploymentStatusLine(status) {
254
270
 
255
271
  // src/runner/index.ts
256
272
  import { Command } from "commander";
273
+
274
+ // src/lib/executor.ts
275
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
276
+ import { randomBytes as randomBytes2 } from "crypto";
277
+ import { once } from "events";
278
+ import { lstatSync, mkdirSync, realpathSync } from "fs";
279
+ import { dirname, resolve } from "path";
280
+
281
+ // src/lib/accounts.ts
282
+ import { spawnSync } from "child_process";
283
+ import { existsSync } from "fs";
284
+
285
+ // src/lib/agent-adapter.ts
286
+ import { spawn } from "child_process";
287
+
288
+ // src/lib/redact.ts
289
+ var SCRUBBED = "[SCRUBBED]";
290
+ var TOKEN_PATTERNS = [
291
+ /\bsk-ant-[A-Za-z0-9_-]{8,}/g,
292
+ /\bsk-proj-[A-Za-z0-9_-]{8,}/g,
293
+ /\bAKIA[0-9A-Z]{16}\b/g,
294
+ /\bghp_[A-Za-z0-9]{16,}\b/g,
295
+ /\bgithub_pat_[A-Za-z0-9_]{16,}\b/g,
296
+ /\bxox[a-z]-[A-Za-z0-9-]{8,}/g,
297
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{10,}\b/g
298
+ ];
299
+ var AUTHORIZATION_PATTERN = /(\bAuthorization(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{16,}/gi;
300
+ var PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g;
301
+ var ASSIGNMENT_PATTERN = /([A-Za-z0-9_.-]{0,64}(?:key|token|secret|passwd|password|passphrase|credentials?)(?:\\?["'])?\s*[:=]\s*)(\\"([^"\\\r\n]{12,})\\"|"((?:[^"\\\r\n]|\\[^\r\n]){12,})"|'([^'\\\r\n]{12,})'|([A-Za-z0-9+/_.=-]{16,}))/gi;
302
+ var BENIGN_KEY_NAME_PATTERN = /(?:^|[._-])(?:idempotency|dedupe|route)[_-]?key$/i;
303
+ var ASSIGNMENT_HINTS = ["key", "token", "secret", "pass", "credential"];
304
+ function mayContainAssignment(text) {
305
+ const lowered = text.toLowerCase();
306
+ return ASSIGNMENT_HINTS.some((hint) => lowered.includes(hint));
307
+ }
308
+ function shannonEntropy(value) {
309
+ const counts = new Map;
310
+ for (const char of value)
311
+ counts.set(char, (counts.get(char) ?? 0) + 1);
312
+ let entropy = 0;
313
+ for (const count of counts.values()) {
314
+ const probability = count / value.length;
315
+ entropy -= probability * Math.log2(probability);
316
+ }
317
+ return entropy;
318
+ }
319
+ function isLikelySecretValue(value) {
320
+ if (value.length < 12)
321
+ return false;
322
+ if (value.includes(SCRUBBED))
323
+ return false;
324
+ if (value.startsWith("/") || value.startsWith("~") || value.startsWith("./"))
325
+ return false;
326
+ return shannonEntropy(value) >= 3.2;
327
+ }
328
+ function scrubSecrets(text) {
329
+ if (!text)
330
+ return text;
331
+ let scrubbed = text;
332
+ for (const pattern of TOKEN_PATTERNS)
333
+ scrubbed = scrubbed.replace(pattern, SCRUBBED);
334
+ scrubbed = scrubbed.replace(AUTHORIZATION_PATTERN, `$1${SCRUBBED}`);
335
+ scrubbed = scrubbed.replace(PEM_PATTERN, SCRUBBED);
336
+ if (!mayContainAssignment(scrubbed))
337
+ return scrubbed;
338
+ scrubbed = scrubbed.replace(ASSIGNMENT_PATTERN, (match, prefix, quotedValue, escapedQuoted, doubleQuoted, singleQuoted, bare) => {
339
+ const value = escapedQuoted ?? doubleQuoted ?? singleQuoted ?? bare ?? "";
340
+ const keyName = /^[A-Za-z0-9_.-]*/.exec(prefix)?.[0] ?? "";
341
+ if (BENIGN_KEY_NAME_PATTERN.test(keyName))
342
+ return match;
343
+ if (!isLikelySecretValue(value))
344
+ return match;
345
+ const quote = quotedValue.startsWith("\\\"") ? "\\\"" : quotedValue.startsWith('"') ? '"' : quotedValue.startsWith("'") ? "'" : "";
346
+ return `${prefix}${quote}${SCRUBBED}${quote}`;
347
+ });
348
+ return scrubbed;
349
+ }
350
+ function scrubSecretsDeep(value) {
351
+ if (typeof value === "string")
352
+ return scrubSecrets(value);
353
+ if (Array.isArray(value))
354
+ return value.map((entry) => scrubSecretsDeep(entry));
355
+ if (value !== null && typeof value === "object") {
356
+ const toJSON = value.toJSON;
357
+ if (typeof toJSON === "function")
358
+ return scrubSecretsDeep(toJSON.call(value));
359
+ const scrubbed = {};
360
+ for (const [key, entry] of Object.entries(value))
361
+ scrubbed[key] = scrubSecretsDeep(entry);
362
+ return scrubbed;
363
+ }
364
+ return value;
365
+ }
366
+
367
+ // src/lib/agent-adapter.ts
368
+ var UNSAFE_CODEWITH_DURABLE_EXTRA_ARGS = new Set([
369
+ "e",
370
+ "exec",
371
+ "agent",
372
+ "start",
373
+ "--ephemeral",
374
+ "--ignore-rules",
375
+ "--skip-git-repo-check",
376
+ "--json",
377
+ "--output-last-message",
378
+ "-o",
379
+ "--output-schema",
380
+ "--dangerously-bypass-approvals-and-sandbox"
381
+ ]);
382
+ var CODEX_LIKE_SANDBOXES = ["read-only", "workspace-write", "danger-full-access"];
383
+ var CURSOR_SANDBOXES = ["enabled", "disabled"];
384
+ var PERMISSION_MODES = ["default", "plan", "auto", "bypass"];
385
+ function assertOptionalNonEmptyString(value, label) {
386
+ if (value === undefined)
387
+ return;
388
+ if (typeof value !== "string" || value.trim() === "")
389
+ throw new Error(`${label} must be a non-empty string`);
390
+ }
391
+ function validateAgentOptions(target, label, capabilities) {
392
+ const provider = target.provider;
393
+ if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
394
+ throw new Error(`${label}.prompt must be a non-empty string`);
395
+ }
396
+ assertOptionalNonEmptyString(target.model, `${label}.model`);
397
+ assertOptionalNonEmptyString(target.variant, `${label}.variant`);
398
+ assertOptionalNonEmptyString(target.agent, `${label}.agent`);
399
+ assertOptionalNonEmptyString(target.authProfile, `${label}.authProfile`);
400
+ assertOptionalNonEmptyString(target.configIsolation, `${label}.configIsolation`);
401
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
402
+ throw new Error(`${label}.configIsolation must be safe or none`);
403
+ }
404
+ if (target.authProfile !== undefined && provider !== "codewith") {
405
+ throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
406
+ }
407
+ if (provider === "opencode" && (typeof target.model !== "string" || target.model.trim() === "")) {
408
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
409
+ }
410
+ if (provider === "cursor" && target.variant !== undefined) {
411
+ throw new Error(`${label}.variant is not supported for provider cursor`);
412
+ }
413
+ if (provider === "codex" && target.agent !== undefined) {
414
+ throw new Error(`${label}.agent is not supported for provider codex`);
415
+ }
416
+ if (provider === "codewith" && target.agent !== undefined) {
417
+ throw new Error(`${label}.agent is not supported for provider codewith durable background-agent execution`);
418
+ }
419
+ if (provider === "codewith") {
420
+ const unsafe = target.extraArgs?.find((arg) => UNSAFE_CODEWITH_DURABLE_EXTRA_ARGS.has(arg));
421
+ if (unsafe) {
422
+ throw new Error(`${label}.extraArgs cannot include ${unsafe}; codewith agent steps use durable agent start, not exec/ephemeral flags`);
423
+ }
424
+ }
425
+ if (target.addDirs?.length && !["codewith", "codex"].includes(provider)) {
426
+ throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
427
+ }
428
+ if (target.permissionMode !== undefined) {
429
+ if (!PERMISSION_MODES.includes(target.permissionMode)) {
430
+ throw new Error(`${label}.permissionMode must be one of ${PERMISSION_MODES.join(", ")}`);
431
+ }
432
+ if (target.permissionMode === "plan" && !["claude", "cursor"].includes(provider)) {
433
+ throw new Error(`${label}.permissionMode plan is currently supported only for provider claude or cursor`);
434
+ }
435
+ if (target.permissionMode === "auto" && provider !== "claude") {
436
+ throw new Error(`${label}.permissionMode auto is currently supported only for provider claude`);
437
+ }
438
+ }
439
+ if (target.sandbox !== undefined) {
440
+ if (!capabilities.sandbox.length) {
441
+ throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
442
+ }
443
+ if (!capabilities.sandbox.includes(target.sandbox)) {
444
+ throw new Error(`${label}.sandbox must be one of ${capabilities.sandbox.join(", ")}`);
445
+ }
446
+ }
447
+ }
448
+ function codewithLikeSandbox(target) {
449
+ return target.sandbox ?? (target.permissionMode === "bypass" ? "danger-full-access" : "workspace-write");
450
+ }
451
+ function configStringValue(value) {
452
+ return JSON.stringify(value);
453
+ }
454
+ function buildAgentInvocation(target) {
455
+ const isolation = target.configIsolation ?? "safe";
456
+ const permissionMode = target.permissionMode ?? "default";
457
+ const args = [];
458
+ switch (target.provider) {
459
+ case "claude": {
460
+ if (isolation === "safe")
461
+ args.push("--safe-mode", "--setting-sources", "local", "--no-session-persistence");
462
+ if (permissionMode !== "default") {
463
+ const mode = permissionMode === "bypass" ? "bypassPermissions" : permissionMode;
464
+ args.push("--permission-mode", mode);
465
+ }
466
+ args.push("-p", "--output-format", "json");
467
+ if (target.model)
468
+ args.push("--model", target.model);
469
+ if (target.variant)
470
+ args.push("--effort", target.variant);
471
+ if (target.agent)
472
+ args.push("--agent", target.agent);
473
+ args.push(...target.extraArgs ?? []);
474
+ return { command: "claude", args, stdin: target.prompt };
475
+ }
476
+ case "cursor": {
477
+ args.push("-c", [
478
+ "set -eu",
479
+ "if command -v agent >/dev/null 2>&1; then",
480
+ ' exec agent "$@"',
481
+ "else",
482
+ " echo 'Executable not found in PATH: agent' >&2",
483
+ " exit 127",
484
+ "fi"
485
+ ].join(`
486
+ `), "openloops-cursor", "-p");
487
+ if (permissionMode === "plan")
488
+ args.push("--mode", "plan");
489
+ if (permissionMode === "bypass")
490
+ args.push("--force");
491
+ const cursorSandbox = target.sandbox ?? (isolation === "safe" ? "enabled" : undefined);
492
+ if (cursorSandbox)
493
+ args.push("--sandbox", cursorSandbox);
494
+ if (target.model)
495
+ args.push("--model", target.model);
496
+ if (target.agent)
497
+ args.push("--agent", target.agent);
498
+ args.push(...target.extraArgs ?? []);
499
+ return { command: "sh", args, stdin: target.prompt, preflightAnyOf: ["agent"] };
500
+ }
501
+ case "codewith": {
502
+ args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : []);
503
+ if (target.variant)
504
+ args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
505
+ args.push("--ask-for-approval", "never", "--sandbox", codewithLikeSandbox(target));
506
+ if (target.cwd)
507
+ args.push("--cd", target.cwd);
508
+ for (const dir of target.addDirs ?? [])
509
+ args.push("--add-dir", dir);
510
+ if (target.model)
511
+ args.push("--model", target.model);
512
+ args.push(...target.extraArgs ?? []);
513
+ args.push("agent", "start");
514
+ if (target.cwd)
515
+ args.push("--cwd", target.cwd);
516
+ args.push(target.prompt);
517
+ return { command: "codewith", args };
518
+ }
519
+ case "codex": {
520
+ if (target.variant)
521
+ args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
522
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
523
+ if (isolation === "safe")
524
+ args.push("--ignore-rules");
525
+ if (target.cwd)
526
+ args.push("--cd", target.cwd);
527
+ for (const dir of target.addDirs ?? [])
528
+ args.push("--add-dir", dir);
529
+ if (target.model)
530
+ args.push("--model", target.model);
531
+ args.push(...target.extraArgs ?? []);
532
+ return { command: "codex", args, stdin: target.prompt };
533
+ }
534
+ case "aicopilot":
535
+ case "opencode": {
536
+ args.push("run", "--format", "json");
537
+ if (isolation === "safe")
538
+ args.push("--pure");
539
+ if (permissionMode === "bypass")
540
+ args.push("--dangerously-skip-permissions");
541
+ if (target.cwd)
542
+ args.push("--dir", target.cwd);
543
+ if (target.model)
544
+ args.push("--model", target.model);
545
+ if (target.variant)
546
+ args.push("--variant", target.variant);
547
+ if (target.agent)
548
+ args.push("--agent", target.agent);
549
+ args.push(...target.extraArgs ?? []);
550
+ return { command: target.provider, args, stdin: target.prompt };
551
+ }
552
+ }
553
+ }
554
+ function adapterFor(provider, capabilities) {
555
+ return {
556
+ provider,
557
+ capabilities,
558
+ validate(target, label = provider) {
559
+ validateAgentOptions(target, label, capabilities);
560
+ },
561
+ buildInvocation(target) {
562
+ validateAgentOptions(target, provider, capabilities);
563
+ return buildAgentInvocation(target);
564
+ }
565
+ };
566
+ }
567
+ var PROVIDER_ADAPTERS = {
568
+ claude: adapterFor("claude", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" }),
569
+ cursor: adapterFor("cursor", { sandbox: CURSOR_SANDBOXES, durable: false, remote: true, promptChannel: "stdin" }),
570
+ codewith: adapterFor("codewith", { sandbox: CODEX_LIKE_SANDBOXES, durable: true, remote: false, promptChannel: "argv" }),
571
+ codex: adapterFor("codex", { sandbox: CODEX_LIKE_SANDBOXES, durable: false, remote: true, promptChannel: "stdin" }),
572
+ aicopilot: adapterFor("aicopilot", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" }),
573
+ opencode: adapterFor("opencode", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" })
574
+ };
575
+ var AGENT_PROVIDERS = Object.keys(PROVIDER_ADAPTERS);
576
+ function providerAdapter(provider) {
577
+ const adapter = PROVIDER_ADAPTERS[provider];
578
+ if (!adapter)
579
+ throw new Error(`unsupported agent provider: ${String(provider)}`);
580
+ return adapter;
581
+ }
582
+ var DEFAULT_CAPTURE_MAX_OUTPUT_BYTES = 256 * 1024;
583
+ function killProcessGroup(pgid) {
584
+ try {
585
+ process.kill(-pgid, "SIGTERM");
586
+ } catch {
587
+ try {
588
+ process.kill(pgid, "SIGTERM");
589
+ } catch {}
590
+ }
591
+ setTimeout(() => {
592
+ try {
593
+ process.kill(-pgid, "SIGKILL");
594
+ } catch {
595
+ try {
596
+ process.kill(pgid, "SIGKILL");
597
+ } catch {}
598
+ }
599
+ }, 2000).unref();
600
+ }
601
+
602
+ class BoundedOutputBuffer {
603
+ maxBytes;
604
+ text = "";
605
+ truncatedBytes = 0;
606
+ constructor(maxBytes) {
607
+ this.maxBytes = maxBytes;
608
+ }
609
+ append(chunk) {
610
+ if (!chunk)
611
+ return;
612
+ this.text += chunk;
613
+ if (Buffer.byteLength(this.text, "utf8") <= this.maxBytes)
614
+ return;
615
+ this.text = scrubSecrets(this.text);
616
+ const encoded = Buffer.from(this.text, "utf8");
617
+ if (encoded.length <= this.maxBytes)
618
+ return;
619
+ let start = encoded.length - this.maxBytes;
620
+ while (start < encoded.length && (encoded[start] & 192) === 128)
621
+ start++;
622
+ this.truncatedBytes += start;
623
+ this.text = encoded.subarray(start).toString("utf8");
624
+ }
625
+ value() {
626
+ if (this.truncatedBytes === 0)
627
+ return this.text;
628
+ return `[truncated ${this.truncatedBytes} bytes]
629
+ ${this.text}`;
630
+ }
631
+ }
632
+ async function spawnCapture(command, args, opts) {
633
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_CAPTURE_MAX_OUTPUT_BYTES;
634
+ const stdout = new BoundedOutputBuffer(maxOutputBytes);
635
+ const stderr = new BoundedOutputBuffer(maxOutputBytes);
636
+ let timedOut = false;
637
+ let error;
638
+ const child = spawn(command, args, {
639
+ cwd: opts.cwd,
640
+ env: opts.env,
641
+ detached: true,
642
+ stdio: ["ignore", "pipe", "pipe"]
643
+ });
644
+ const timer = setTimeout(() => {
645
+ timedOut = true;
646
+ if (child.pid)
647
+ killProcessGroup(child.pid);
648
+ }, opts.timeoutMs);
649
+ timer.unref();
650
+ child.stdout?.setEncoding("utf8");
651
+ child.stderr?.setEncoding("utf8");
652
+ child.stdout?.on("data", (chunk) => stdout.append(chunk));
653
+ child.stderr?.on("data", (chunk) => stderr.append(chunk));
654
+ const [status, signal] = await new Promise((resolve) => {
655
+ child.once("error", (err) => {
656
+ error = err.message;
657
+ resolve([null, null]);
658
+ });
659
+ child.once("close", (code, sig) => resolve([code, sig]));
660
+ });
661
+ clearTimeout(timer);
662
+ if (timedOut && !error)
663
+ error = `timed out after ${opts.timeoutMs}ms`;
664
+ return { status, signal, stdout: stdout.value(), stderr: stderr.value(), error, timedOut };
665
+ }
666
+
667
+ // src/lib/accounts.ts
668
+ var EXPORT_RE = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
669
+ var ACCOUNTS_ENV_TIMEOUT_MS = 30000;
670
+ function accountToolForProvider(provider) {
671
+ switch (provider) {
672
+ case "claude":
673
+ return "claude";
674
+ case "cursor":
675
+ return "cursor";
676
+ case "codewith":
677
+ return "codewith";
678
+ case "aicopilot":
679
+ return "aicopilot";
680
+ case "opencode":
681
+ return "opencode";
682
+ case "codex":
683
+ return "codex";
684
+ }
685
+ }
686
+ function parseExportValue(raw) {
687
+ try {
688
+ return JSON.parse(raw);
689
+ } catch {
690
+ return raw.replace(/^['"]|['"]$/g, "");
691
+ }
692
+ }
693
+ function parseAccountExportLines(output) {
694
+ const env = {};
695
+ for (const line of output.split(/\r?\n/)) {
696
+ const match = EXPORT_RE.exec(line.trim());
697
+ if (!match)
698
+ continue;
699
+ env[match[1]] = parseExportValue(match[2]);
700
+ }
701
+ return env;
702
+ }
703
+ function primaryAccountDir(output) {
704
+ for (const line of output.split(/\r?\n/)) {
705
+ const match = EXPORT_RE.exec(line.trim());
706
+ if (!match)
707
+ continue;
708
+ return parseExportValue(match[2]);
709
+ }
710
+ return;
711
+ }
712
+ function accountDirEnvVar(tool) {
713
+ switch (tool) {
714
+ case "claude":
715
+ return "CLAUDE_CONFIG_DIR";
716
+ case "codex":
717
+ case "codex-app":
718
+ return "CODEX_HOME";
719
+ case "cursor":
720
+ return "CURSOR_CONFIG_DIR";
721
+ case "opencode":
722
+ return "OPENCODE_CONFIG_DIR";
723
+ case "codewith":
724
+ return "CODEWITH_HOME";
725
+ case "aicopilot":
726
+ return "AICOPILOT_CONFIG_DIR";
727
+ default:
728
+ return;
729
+ }
730
+ }
731
+ function requiredAccountTool(account, toolHint) {
732
+ const tool = account.tool ?? toolHint;
733
+ if (!tool)
734
+ throw new Error("account.tool is required when no provider tool can be inferred");
735
+ return tool;
736
+ }
737
+ function accountEnvFromResult(account, tool, result) {
738
+ if (result.error) {
739
+ throw new Error(`failed to run accounts env for ${account.profile}/${tool}: ${result.error}`);
740
+ }
741
+ if ((result.status ?? 1) !== 0) {
742
+ const stderr = result.stderr.trim();
743
+ throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
744
+ }
745
+ const accountEnv = parseAccountExportLines(result.stdout);
746
+ const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
747
+ if (!profileDir)
748
+ throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
749
+ if (!existsSync(profileDir))
750
+ throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
751
+ return {
752
+ ...accountEnv,
753
+ LOOPS_ACCOUNT_PROFILE: account.profile,
754
+ LOOPS_ACCOUNT_TOOL: tool
755
+ };
756
+ }
757
+ async function resolveAccountEnv(account, toolHint, env) {
758
+ if (!account)
759
+ return {};
760
+ const tool = requiredAccountTool(account, toolHint);
761
+ const result = await spawnCapture("accounts", ["env", account.profile, "--tool", tool], {
762
+ env,
763
+ timeoutMs: ACCOUNTS_ENV_TIMEOUT_MS
764
+ });
765
+ return accountEnvFromResult(account, tool, result);
766
+ }
767
+ function resolveAccountEnvSync(account, toolHint, env) {
768
+ if (!account)
769
+ return {};
770
+ const tool = requiredAccountTool(account, toolHint);
771
+ const result = spawnSync("accounts", ["env", account.profile, "--tool", tool], {
772
+ encoding: "utf8",
773
+ env,
774
+ stdio: ["ignore", "pipe", "pipe"],
775
+ timeout: ACCOUNTS_ENV_TIMEOUT_MS
776
+ });
777
+ return accountEnvFromResult(account, tool, {
778
+ status: result.status,
779
+ stdout: result.stdout ?? "",
780
+ stderr: result.stderr ?? "",
781
+ error: result.error?.message
782
+ });
783
+ }
784
+
785
+ // src/lib/env.ts
786
+ import { accessSync, constants } from "fs";
787
+ import { homedir } from "os";
788
+ import { delimiter, join } from "path";
789
+ function compactPathParts(parts) {
790
+ const seen = new Set;
791
+ const result = [];
792
+ for (const part of parts) {
793
+ const value = part?.trim();
794
+ if (!value || seen.has(value))
795
+ continue;
796
+ seen.add(value);
797
+ result.push(value);
798
+ }
799
+ return result;
800
+ }
801
+ function commonExecutableDirs(env = process.env) {
802
+ const home = env.HOME || homedir();
803
+ return compactPathParts([
804
+ join(home, ".local", "bin"),
805
+ join(home, ".bun", "bin"),
806
+ join(home, ".cargo", "bin"),
807
+ join(home, ".npm-global", "bin"),
808
+ join(home, "bin"),
809
+ env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin") : undefined,
810
+ env.PNPM_HOME,
811
+ env.NPM_CONFIG_PREFIX ? join(env.NPM_CONFIG_PREFIX, "bin") : undefined,
812
+ "/opt/homebrew/bin",
813
+ "/usr/local/bin",
814
+ "/usr/bin",
815
+ "/bin",
816
+ "/usr/sbin",
817
+ "/sbin"
818
+ ]);
819
+ }
820
+ function normalizeExecutionPath(env = process.env) {
821
+ return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
822
+ }
823
+ function isExecutable(path) {
824
+ try {
825
+ accessSync(path, constants.X_OK);
826
+ return true;
827
+ } catch {
828
+ return false;
829
+ }
830
+ }
831
+ function executableExists(command, env = process.env) {
832
+ if (command.includes("/"))
833
+ return isExecutable(command);
834
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
835
+ if (dir && isExecutable(join(dir, command)))
836
+ return true;
837
+ }
838
+ return false;
839
+ }
840
+ function commandNotFoundMessage(command, env = process.env) {
841
+ return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
842
+ }
843
+
844
+ // src/lib/ids.ts
845
+ import { randomBytes } from "crypto";
846
+ var TIME_HEX_LENGTH = 12;
847
+ function genId() {
848
+ const time = Date.now().toString(16).padStart(TIME_HEX_LENGTH, "0");
849
+ return `${time}${randomBytes(10).toString("hex")}`;
850
+ }
851
+ function nowIso() {
852
+ return new Date().toISOString();
853
+ }
854
+
855
+ // src/lib/machines.ts
856
+ import { createRequire } from "module";
857
+ var consumerModule;
858
+ function machinesConsumer() {
859
+ if (!consumerModule) {
860
+ try {
861
+ const resolved = Bun.resolveSync("@hasna/machines/consumer", import.meta.dir);
862
+ consumerModule = createRequire(import.meta.url)(resolved);
863
+ } catch (error) {
864
+ const detail = error instanceof Error ? error.message : String(error);
865
+ throw new Error(`@hasna/machines is not available; install the optional dependency to use machine-assigned loops: ${detail}`);
866
+ }
867
+ }
868
+ return consumerModule;
869
+ }
870
+ function compact(value) {
871
+ const text = value?.trim();
872
+ return text ? text : undefined;
873
+ }
874
+ function entryToSummary(entry, topology) {
875
+ return {
876
+ id: entry.machine_id,
877
+ hostname: compact(entry.hostname),
878
+ platform: compact(entry.platform),
879
+ user: compact(entry.user),
880
+ workspacePath: compact(entry.workspace_path),
881
+ route: entry.ssh.route,
882
+ local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
883
+ heartbeatStatus: entry.heartbeat_status,
884
+ tailscaleOnline: entry.tailscale.online,
885
+ tags: entry.tags
886
+ };
887
+ }
888
+ function machineFromRoute(route, topology) {
889
+ if (!route.ok || !route.machine_id) {
890
+ throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
891
+ }
892
+ const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
893
+ return {
894
+ id: route.machine_id,
895
+ requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
896
+ route: route.route,
897
+ local: route.local,
898
+ confidence: route.confidence,
899
+ workspacePath: compact(entry?.workspace_path),
900
+ resolvedAt: route.generated_at,
901
+ packageVersion: route.package.version,
902
+ warnings: route.warnings.length ? route.warnings : undefined
903
+ };
904
+ }
905
+ function listOpenMachines() {
906
+ const topology = machinesConsumer().discoverMachineTopology();
907
+ return topology.machines.map((entry) => entryToSummary(entry, topology));
908
+ }
909
+ function resolveLoopMachine(machineId) {
910
+ const consumer = machinesConsumer();
911
+ const topology = consumer.discoverMachineTopology();
912
+ const route = consumer.resolveMachineRoute(machineId, { topology });
913
+ return machineFromRoute(route, topology);
914
+ }
915
+ function resolveMachineCommand(machineId, command) {
916
+ return machinesConsumer().resolveMachineCommand(machineId, command);
917
+ }
918
+ function refreshLoopMachine(machine) {
919
+ return resolveLoopMachine(machine.id);
920
+ }
921
+
922
+ // src/lib/executor.ts
923
+ var DEFAULT_TIMEOUT_MS = 30 * 60000;
924
+ var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
925
+ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 30 * 60000;
926
+ var BUFFERED_OUTPUT_PROVIDERS = new Set(["claude", "codewith", "opencode", "aicopilot"]);
927
+ var DEFAULT_BUFFERED_AGENT_IDLE_TIMEOUT_MS = 4 * 60 * 60000;
928
+ var WORKTREE_GIT_TIMEOUT_MS = 5 * 60000;
929
+ var AUTH_ENV_KEYS = [
930
+ "CLAUDE_CONFIG_DIR",
931
+ "CODEWITH_HOME",
932
+ "CODEX_HOME",
933
+ "CURSOR_CONFIG_DIR",
934
+ "CURSOR_API_KEY",
935
+ "OPENCODE_CONFIG_DIR",
936
+ "AICOPILOT_CONFIG_DIR",
937
+ "ANTHROPIC_API_KEY",
938
+ "OPENAI_API_KEY",
939
+ "OPENROUTER_API_KEY",
940
+ "GITHUB_TOKEN",
941
+ "GH_TOKEN",
942
+ "XDG_CONFIG_HOME",
943
+ "XDG_DATA_HOME",
944
+ "XDG_STATE_HOME",
945
+ "XDG_CACHE_HOME"
946
+ ];
947
+ var TRANSPORT_ENV_KEYS = new Set([
948
+ "BUN_INSTALL",
949
+ "HOME",
950
+ "LANG",
951
+ "LANGUAGE",
952
+ "LOGNAME",
953
+ "PATH",
954
+ "SHELL",
955
+ "SSH_AGENT_PID",
956
+ "SSH_AUTH_SOCK",
957
+ "TERM",
958
+ "TMP",
959
+ "TMPDIR",
960
+ "TEMP",
961
+ "USER",
962
+ "XDG_RUNTIME_DIR"
963
+ ]);
964
+ function boundedText(text, maxBytes) {
965
+ const buffer = new BoundedOutputBuffer(maxBytes);
966
+ buffer.append(text);
967
+ return buffer.value();
968
+ }
969
+ function buildResult(status, startedAt, fields = {}) {
970
+ const finishedAt = fields.finishedAt ?? nowIso();
971
+ return {
972
+ status,
973
+ exitCode: fields.exitCode,
974
+ stdout: fields.stdout ?? "",
975
+ stderr: fields.stderr ?? "",
976
+ error: fields.error,
977
+ pid: fields.pid,
978
+ startedAt,
979
+ finishedAt,
980
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime()
981
+ };
982
+ }
983
+ function failureResult(startedAt, error, fields = {}) {
984
+ return buildResult("failed", startedAt, { ...fields, error });
985
+ }
986
+ function timeoutResult(startedAt, error, fields = {}) {
987
+ return buildResult("timed_out", startedAt, { ...fields, error });
988
+ }
989
+ function successResult(startedAt, fields = {}) {
990
+ return buildResult("succeeded", startedAt, fields);
991
+ }
992
+ function notifySpawn(pid, opts) {
993
+ if (!pid)
994
+ return;
995
+ opts.onSpawn?.(pid);
996
+ opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
997
+ }
998
+ function shellQuote(value) {
999
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1000
+ }
1001
+ function metadataEnv(metadata) {
1002
+ const env = {};
1003
+ if (metadata.loopId)
1004
+ env.LOOPS_LOOP_ID = metadata.loopId;
1005
+ if (metadata.loopName)
1006
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1007
+ if (metadata.runId)
1008
+ env.LOOPS_RUN_ID = metadata.runId;
1009
+ if (metadata.scheduledFor)
1010
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1011
+ if (metadata.workflowId)
1012
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1013
+ if (metadata.workflowName)
1014
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1015
+ if (metadata.workflowRunId)
1016
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1017
+ if (metadata.workflowStepId)
1018
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1019
+ if (metadata.goalId)
1020
+ env.LOOPS_GOAL_ID = metadata.goalId;
1021
+ if (metadata.goalObjective)
1022
+ env.LOOPS_GOAL_OBJECTIVE = metadata.goalObjective;
1023
+ if (metadata.goalNodeKey)
1024
+ env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
1025
+ return env;
1026
+ }
1027
+ function codewithAgentIdempotencyKey(metadata) {
1028
+ const parts = [
1029
+ "openloops",
1030
+ metadata.workflowRunId ? `workflow-run:${metadata.workflowRunId}` : undefined,
1031
+ metadata.workflowStepId ? `step:${metadata.workflowStepId}` : undefined,
1032
+ metadata.runId ? `loop-run:${metadata.runId}` : undefined,
1033
+ metadata.loopId ? `loop:${metadata.loopId}` : undefined,
1034
+ metadata.scheduledFor ? `scheduled:${metadata.scheduledFor}` : undefined,
1035
+ metadata.goalId ? `goal:${metadata.goalId}` : undefined,
1036
+ metadata.goalNodeKey ? `node:${metadata.goalNodeKey}` : undefined
1037
+ ].filter(Boolean);
1038
+ if (parts.length > 1)
1039
+ return parts.join(":");
1040
+ return `openloops:adhoc:${randomBytes2(16).toString("hex")}`;
1041
+ }
1042
+ function allowlistEnv(allowlist) {
1043
+ const env = {};
1044
+ if (allowlist?.tools?.length)
1045
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
1046
+ if (allowlist?.commands?.length)
1047
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
1048
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
1049
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
1050
+ return env;
1051
+ }
1052
+ function defaultAgentIdleTimeoutMs(target, opts) {
1053
+ if (target.timeoutMs !== undefined || target.idleTimeoutMs !== undefined)
1054
+ return;
1055
+ const raw = opts.env?.LOOPS_AGENT_IDLE_TIMEOUT_MS ?? process.env.LOOPS_AGENT_IDLE_TIMEOUT_MS;
1056
+ if (raw !== undefined && raw !== "") {
1057
+ const normalized = raw.trim().toLowerCase();
1058
+ if (normalized === "0" || normalized === "none" || normalized === "off")
1059
+ return;
1060
+ const parsed = Number(normalized);
1061
+ if (Number.isFinite(parsed) && parsed > 0)
1062
+ return parsed;
1063
+ }
1064
+ return BUFFERED_OUTPUT_PROVIDERS.has(target.provider) ? DEFAULT_BUFFERED_AGENT_IDLE_TIMEOUT_MS : DEFAULT_AGENT_IDLE_TIMEOUT_MS;
1065
+ }
1066
+ function commandSpec(target, opts) {
1067
+ if (target.type === "command") {
1068
+ const commandTarget = target;
1069
+ return {
1070
+ command: commandTarget.command,
1071
+ args: commandTarget.args ?? [],
1072
+ cwd: commandTarget.cwd,
1073
+ shell: commandTarget.shell,
1074
+ env: commandTarget.env,
1075
+ timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
1076
+ idleTimeoutMs: commandTarget.idleTimeoutMs,
1077
+ account: commandTarget.account,
1078
+ accountTool: commandTarget.account?.tool
1079
+ };
1080
+ }
1081
+ const agentTarget = target;
1082
+ const adapter = providerAdapter(agentTarget.provider);
1083
+ const invocation = adapter.buildInvocation(agentTarget);
1084
+ return {
1085
+ command: invocation.command,
1086
+ args: invocation.args,
1087
+ cwd: agentTarget.cwd,
1088
+ timeoutMs: agentTarget.timeoutMs ?? null,
1089
+ idleTimeoutMs: agentTarget.idleTimeoutMs ?? defaultAgentIdleTimeoutMs(agentTarget, opts),
1090
+ account: agentTarget.account,
1091
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
1092
+ nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
1093
+ preflightAnyOf: invocation.preflightAnyOf,
1094
+ stdin: invocation.stdin,
1095
+ allowlist: agentTarget.allowlist,
1096
+ worktree: agentTarget.worktree,
1097
+ codewithDurableAgent: adapter.capabilities.durable ? { target: agentTarget } : undefined
1098
+ };
1099
+ }
1100
+ function composeExecutionEnv(spec, metadata, opts, accountEnv) {
1101
+ const env = { ...opts.env ?? process.env };
1102
+ if (accountEnv) {
1103
+ for (const key of AUTH_ENV_KEYS)
1104
+ delete env[key];
1105
+ Object.assign(env, accountEnv);
1106
+ }
1107
+ Object.assign(env, spec.env ?? {});
1108
+ Object.assign(env, allowlistEnv(spec.allowlist));
1109
+ env.PATH = normalizeExecutionPath(env);
1110
+ Object.assign(env, metadataEnv(metadata));
1111
+ return env;
1112
+ }
1113
+ async function executionEnv(spec, metadata, opts) {
1114
+ const accountEnv = spec.account ? await resolveAccountEnv(spec.account, spec.accountTool, { ...opts.env ?? process.env }) : undefined;
1115
+ return composeExecutionEnv(spec, metadata, opts, accountEnv);
1116
+ }
1117
+ function executionEnvSync(spec, metadata, opts) {
1118
+ const accountEnv = spec.account ? resolveAccountEnvSync(spec.account, spec.accountTool, { ...opts.env ?? process.env }) : undefined;
1119
+ return composeExecutionEnv(spec, metadata, opts, accountEnv);
1120
+ }
1121
+ function resolvedMachine(opts) {
1122
+ if (!opts.machine)
1123
+ return;
1124
+ return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
1125
+ }
1126
+ function commandForShell(spec) {
1127
+ if (!spec.args.length)
1128
+ return spec.command;
1129
+ return [spec.command, ...spec.args.map(shellQuote)].join(" ");
1130
+ }
1131
+ function hereDoc(value) {
1132
+ let delimiter2 = `__OPENLOOPS_STDIN_${randomBytes2(8).toString("hex").toUpperCase()}__`;
1133
+ while (value.split(/\r?\n/).includes(delimiter2)) {
1134
+ delimiter2 = `__OPENLOOPS_STDIN_${randomBytes2(8).toString("hex").toUpperCase()}__`;
1135
+ }
1136
+ return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
1137
+ }
1138
+ function remoteBootstrapLines(spec, metadata, opts = {}) {
1139
+ const lines = [
1140
+ "set -e",
1141
+ 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
1142
+ ];
1143
+ const worktree = spec.worktree;
1144
+ const worktreeManaged = worktree?.enabled && (worktree.mode === "auto" || worktree.mode === "required");
1145
+ if (opts.worktree && worktree && worktreeManaged) {
1146
+ lines.push(...remoteWorktreePrepareLines(worktree));
1147
+ lines.push(...remoteWorktreeEnterLines(worktree, spec.cwd));
1148
+ } else if (worktree && worktreeManaged) {
1149
+ lines.push(`cd ${shellQuote(worktree.originalCwd)}`);
1150
+ } else if (spec.cwd) {
1151
+ lines.push(`cd ${shellQuote(spec.cwd)}`);
1152
+ }
1153
+ if (spec.account) {
1154
+ if (!spec.accountTool)
1155
+ throw new Error("account.tool is required when no provider tool can be inferred");
1156
+ lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
1157
+ }
1158
+ for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
1159
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
1160
+ continue;
1161
+ lines.push(`export ${key}=${shellQuote(value)}`);
1162
+ }
1163
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
1164
+ lines.push(`export ${key}=${shellQuote(value)}`);
1165
+ }
1166
+ return lines;
1167
+ }
1168
+ function remoteWorktreePrepareLines(worktree) {
1169
+ const { repoRoot, path, branch } = worktree;
1170
+ if (!repoRoot || !path || !branch) {
1171
+ return [
1172
+ "__openloops_prepare_worktree() {",
1173
+ ` echo ${shellQuote("worktree preparation requires repoRoot, path, and branch metadata")} >&2`,
1174
+ " return 1",
1175
+ "}"
1176
+ ];
1177
+ }
1178
+ return [
1179
+ "__openloops_prepare_worktree() {",
1180
+ ` local repo=${shellQuote(repoRoot)} path=${shellQuote(path)} branch=${shellQuote(branch)}`,
1181
+ " local top expected_common actual_common current",
1182
+ ' if [ -L "$path" ]; then echo "refusing symlinked worktree path $path" >&2; return 1; fi',
1183
+ ' if [ -e "$path" ]; then',
1184
+ ' top="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || { echo "refusing to reuse non-worktree path: $path" >&2; return 1; }',
1185
+ ' if [ "$(cd "$top" 2>/dev/null && pwd -P)" != "$(cd "$path" 2>/dev/null && pwd -P)" ]; then echo "existing worktree top-level mismatch for $path: $top" >&2; return 1; fi',
1186
+ ' expected_common="$(cd "$repo" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
1187
+ ' actual_common="$(cd "$path" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
1188
+ ' if [ -z "$expected_common" ] || [ "$expected_common" != "$actual_common" ]; then echo "existing worktree $path belongs to a different git common dir" >&2; return 1; fi',
1189
+ ' current="$(git -C "$path" branch --show-current 2>/dev/null)"',
1190
+ ' if [ "$current" != "$branch" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch" >&2; return 1; fi',
1191
+ " return 0",
1192
+ " fi",
1193
+ ' git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "worktree repoRoot is not a git repository: $repo" >&2; return 1; }',
1194
+ ' mkdir -p "$(dirname "$path")" || return 1',
1195
+ " # Preparation chatter goes to stderr so run stdout stays the agent's.",
1196
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
1197
+ ' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
1198
+ " else",
1199
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
1200
+ " fi",
1201
+ "}"
1202
+ ];
1203
+ }
1204
+ function remoteWorktreeEnterLines(worktree, cwd) {
1205
+ const workdir = cwd ?? worktree.cwd;
1206
+ if (worktree.mode === "required") {
1207
+ return [
1208
+ "if ! __openloops_prepare_worktree; then",
1209
+ ` echo ${shellQuote("worktree preparation failed (mode=required)")} >&2`,
1210
+ " exit 1",
1211
+ "fi",
1212
+ "__OPENLOOPS_WORKTREE_OK=1",
1213
+ `cd ${shellQuote(workdir)}`
1214
+ ];
1215
+ }
1216
+ return [
1217
+ "if __openloops_prepare_worktree; then",
1218
+ " __OPENLOOPS_WORKTREE_OK=1",
1219
+ ` cd ${shellQuote(workdir)}`,
1220
+ "else",
1221
+ ` echo ${shellQuote(`worktree preparation failed (mode=${worktree.mode}); falling back to ${worktree.originalCwd}`)} >&2`,
1222
+ " __OPENLOOPS_WORKTREE_OK=0",
1223
+ ` cd ${shellQuote(worktree.originalCwd)}`,
1224
+ "fi"
1225
+ ];
1226
+ }
1227
+ function remoteScript(spec, metadata, fallbackSpec) {
1228
+ const lines = remoteBootstrapLines(spec, metadata, { worktree: true });
1229
+ let stdinRedirect = "";
1230
+ if (spec.stdin !== undefined) {
1231
+ lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
1232
+ lines.push(...hereDoc(spec.stdin));
1233
+ stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
1234
+ }
1235
+ const invocationFor = (invocationSpec) => invocationSpec.shell ? `sh -c ${shellQuote(commandForShell(invocationSpec))}${stdinRedirect}` : `${[invocationSpec.command, ...invocationSpec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
1236
+ if (spec.worktree?.enabled && spec.worktree.mode === "auto" && fallbackSpec) {
1237
+ lines.push('if [ "${__OPENLOOPS_WORKTREE_OK:-0}" = 1 ]; then', ` ${invocationFor(spec)}`, "else", ` ${invocationFor(fallbackSpec)}`, "fi");
1238
+ } else {
1239
+ lines.push(invocationFor(spec));
1240
+ }
1241
+ return `${lines.join(`
1242
+ `)}
1243
+ `;
1244
+ }
1245
+ function remotePreflightScript(spec, metadata) {
1246
+ const lines = [
1247
+ ...remoteBootstrapLines(spec, metadata),
1248
+ "command -v bash >/dev/null 2>&1",
1249
+ `command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
1250
+ ];
1251
+ if (spec.preflightAnyOf?.length) {
1252
+ lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
1253
+ }
1254
+ if (spec.nativeAuthProfile?.provider === "codewith") {
1255
+ lines.push(`__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
1256
+ }
1257
+ return lines.join(`
1258
+ `);
1259
+ }
1260
+ function transportEnv(opts) {
1261
+ const source = opts.env ?? process.env;
1262
+ const env = {};
1263
+ for (const [key, value] of Object.entries(source)) {
1264
+ if (value === undefined)
1265
+ continue;
1266
+ if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
1267
+ env[key] = value;
1268
+ }
1269
+ env.PATH = normalizeExecutionPath(env);
1270
+ return env;
1271
+ }
1272
+ function assertCodewithProfileListed(profile, result) {
1273
+ if (result.error) {
1274
+ throw new Error(`codewith auth profile preflight failed: ${result.error}`);
1275
+ }
1276
+ if ((result.status ?? 1) !== 0) {
1277
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
1278
+ throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
1279
+ }
1280
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
1281
+ if (!profiles.has(profile)) {
1282
+ throw new Error(`codewith auth profile not found: ${profile}`);
1283
+ }
1284
+ }
1285
+ function preflightNativeAuthProfileSync(spec, env) {
1286
+ if (spec.nativeAuthProfile?.provider !== "codewith")
1287
+ return;
1288
+ const result = spawnSync2(spec.command, ["profile", "list"], {
1289
+ encoding: "utf8",
1290
+ env,
1291
+ stdio: ["ignore", "pipe", "pipe"],
1292
+ timeout: 15000
1293
+ });
1294
+ assertCodewithProfileListed(spec.nativeAuthProfile.profile, {
1295
+ status: result.status,
1296
+ stdout: result.stdout ?? "",
1297
+ stderr: result.stderr ?? "",
1298
+ error: result.error?.message
1299
+ });
1300
+ }
1301
+ async function preflightNativeAuthProfile(spec, env) {
1302
+ if (spec.nativeAuthProfile?.provider !== "codewith")
1303
+ return;
1304
+ const result = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
1305
+ assertCodewithProfileListed(spec.nativeAuthProfile.profile, result);
1306
+ }
1307
+ function codewithAgentStartArgs(target, idempotencyKey) {
1308
+ const args = providerAdapter(target.provider).buildInvocation(target).args;
1309
+ const startIndex = args.findIndex((arg, index) => arg === "start" && args[index - 1] === "agent");
1310
+ if (startIndex === -1)
1311
+ throw new Error("internal error: codewith durable agent args missing agent start");
1312
+ args.splice(startIndex + 1, 0, "--idempotency-key", idempotencyKey);
1313
+ return args;
1314
+ }
1315
+ function codewithAgentControlArgs(target, command, agentId) {
1316
+ return [
1317
+ ...target.authProfile ? ["--auth-profile", target.authProfile] : [],
1318
+ "agent",
1319
+ command,
1320
+ ...command === "logs" ? ["--limit", "20"] : [],
1321
+ agentId
1322
+ ];
1323
+ }
1324
+ function parseJsonOutput(stdout, label) {
1325
+ try {
1326
+ const value = JSON.parse(stdout || "{}");
1327
+ if (!value || typeof value !== "object" || Array.isArray(value))
1328
+ throw new Error("not an object");
1329
+ return value;
1330
+ } catch (error) {
1331
+ throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
1332
+ }
1333
+ }
1334
+ function recordField(value, key) {
1335
+ if (!value || typeof value !== "object" || Array.isArray(value))
1336
+ return;
1337
+ const field = value[key];
1338
+ return field && typeof field === "object" && !Array.isArray(field) ? field : undefined;
1339
+ }
1340
+ function stringField(value, key) {
1341
+ const field = value?.[key];
1342
+ return typeof field === "string" ? field : undefined;
1343
+ }
1344
+ function numberField(value, key) {
1345
+ const field = value?.[key];
1346
+ return typeof field === "number" && Number.isFinite(field) ? field : undefined;
1347
+ }
1348
+ function codewithAgentStatus(readJson) {
1349
+ return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
1350
+ }
1351
+ function codewithAgentEvidence(startJson, readJson, logsJson) {
1352
+ const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
1353
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
1354
+ const events = Array.isArray(logsJson?.data) ? logsJson.data.filter((event) => Boolean(event) && typeof event === "object" && !Array.isArray(event)).map((event) => ({
1355
+ seq: numberField(event, "seq"),
1356
+ eventType: stringField(event, "eventType"),
1357
+ createdAt: numberField(event, "createdAt")
1358
+ })) : undefined;
1359
+ return JSON.stringify({
1360
+ codewithAgent: {
1361
+ agentId: stringField(agent, "agentId"),
1362
+ status: stringField(agent, "status"),
1363
+ desiredState: stringField(agent, "desiredState"),
1364
+ statusReason: stringField(agent, "statusReason"),
1365
+ threadId: stringField(agent, "threadId"),
1366
+ rolloutPath: stringField(agent, "rolloutPath"),
1367
+ pid: numberField(agent, "pid"),
1368
+ exitCode: numberField(agent, "exitCode"),
1369
+ created: typeof startJson.created === "boolean" ? startJson.created : undefined
1370
+ },
1371
+ statusSnapshot: statusSnapshot ? {
1372
+ seq: numberField(statusSnapshot, "seq"),
1373
+ status: stringField(statusSnapshot, "status"),
1374
+ summary: stringField(statusSnapshot, "summary"),
1375
+ pendingInteractionCount: numberField(statusSnapshot, "pendingInteractionCount"),
1376
+ lastEventSeq: numberField(statusSnapshot, "lastEventSeq")
1377
+ } : undefined,
1378
+ events
1379
+ }, null, 2);
1380
+ }
1381
+ function sleep(ms) {
1382
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1383
+ }
1384
+ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt) {
1385
+ const target = spec.codewithDurableAgent?.target;
1386
+ if (!target)
1387
+ throw new Error("internal error: missing codewith durable target");
1388
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1389
+ const idempotencyKey = codewithAgentIdempotencyKey(metadata);
1390
+ const start = await spawnCapture(spec.command, codewithAgentStartArgs(target, idempotencyKey), {
1391
+ cwd: spec.cwd,
1392
+ env,
1393
+ timeoutMs: 30000,
1394
+ maxOutputBytes
1395
+ });
1396
+ const stderr = new BoundedOutputBuffer(maxOutputBytes);
1397
+ stderr.append(start.stderr);
1398
+ if (start.error || (start.status ?? 1) !== 0) {
1399
+ return failureResult(startedAt, start.error ?? `codewith agent start exited with code ${start.status ?? "unknown"}`, {
1400
+ exitCode: start.status ?? undefined,
1401
+ stdout: start.stdout,
1402
+ stderr: stderr.value()
1403
+ });
1404
+ }
1405
+ let startJson;
1406
+ try {
1407
+ startJson = parseJsonOutput(start.stdout || "{}", "codewith agent start");
1408
+ } catch (error) {
1409
+ return failureResult(startedAt, error instanceof Error ? error.message : String(error), { stdout: start.stdout, stderr: stderr.value() });
1410
+ }
1411
+ const agentId = stringField(recordField(startJson, "agent"), "agentId");
1412
+ if (!agentId) {
1413
+ return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
1414
+ }
1415
+ const stopAgent = async () => {
1416
+ await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
1417
+ cwd: spec.cwd,
1418
+ env,
1419
+ timeoutMs: 15000,
1420
+ maxOutputBytes
1421
+ });
1422
+ };
1423
+ const pollMs = Math.max(100, Number(opts.env?.LOOPS_CODEWITH_AGENT_POLL_MS ?? process.env.LOOPS_CODEWITH_AGENT_POLL_MS ?? 2000) || 2000);
1424
+ let lastReadJson = startJson;
1425
+ let lastLogsJson;
1426
+ let lastFingerprint;
1427
+ let lastProgressAt = Date.now();
1428
+ const evidence = () => boundedText(codewithAgentEvidence(startJson, lastReadJson, lastLogsJson), maxOutputBytes);
1429
+ while (true) {
1430
+ if (opts.signal?.aborted) {
1431
+ await stopAgent();
1432
+ return failureResult(startedAt, "cancelled", { stdout: evidence(), stderr: stderr.value() });
1433
+ }
1434
+ const read = await spawnCapture(spec.command, codewithAgentControlArgs(target, "read", agentId), {
1435
+ cwd: spec.cwd,
1436
+ env,
1437
+ timeoutMs: 30000,
1438
+ maxOutputBytes
1439
+ });
1440
+ stderr.append(read.stderr);
1441
+ if (read.error || (read.status ?? 1) !== 0) {
1442
+ return failureResult(startedAt, read.error ?? `codewith agent read exited with code ${read.status ?? "unknown"}`, {
1443
+ exitCode: read.status ?? undefined,
1444
+ stdout: evidence(),
1445
+ stderr: stderr.value()
1446
+ });
1447
+ }
1448
+ try {
1449
+ lastReadJson = parseJsonOutput(read.stdout || "{}", "codewith agent read");
1450
+ } catch (error) {
1451
+ return failureResult(startedAt, error instanceof Error ? error.message : String(error), {
1452
+ stdout: boundedText(read.stdout, maxOutputBytes),
1453
+ stderr: stderr.value()
1454
+ });
1455
+ }
1456
+ const status = codewithAgentStatus(lastReadJson);
1457
+ const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
1458
+ if (fingerprint !== lastFingerprint) {
1459
+ lastFingerprint = fingerprint;
1460
+ lastProgressAt = Date.now();
1461
+ }
1462
+ if (status === "completed" || status === "failed" || status === "cancelled") {
1463
+ const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
1464
+ cwd: spec.cwd,
1465
+ env,
1466
+ timeoutMs: 30000,
1467
+ maxOutputBytes
1468
+ });
1469
+ if (!logs.error && (logs.status ?? 1) === 0) {
1470
+ try {
1471
+ lastLogsJson = parseJsonOutput(logs.stdout || "{}", "codewith agent logs");
1472
+ } catch {
1473
+ lastLogsJson = undefined;
1474
+ }
1475
+ } else {
1476
+ stderr.append(logs.stderr || logs.error || "");
1477
+ }
1478
+ if (status === "completed") {
1479
+ return successResult(startedAt, { exitCode: 0, stdout: evidence(), stderr: stderr.value() });
1480
+ }
1481
+ return failureResult(startedAt, stringField(recordField(lastReadJson, "agent"), "statusReason") ?? `codewith agent ${status}`, { exitCode: 1, stdout: evidence(), stderr: stderr.value() });
1482
+ }
1483
+ if (typeof spec.timeoutMs === "number" && new Date(nowIso()).getTime() - new Date(startedAt).getTime() >= spec.timeoutMs) {
1484
+ await stopAgent();
1485
+ return timeoutResult(startedAt, `timed out after ${spec.timeoutMs}ms`, { stdout: evidence(), stderr: stderr.value() });
1486
+ }
1487
+ if (typeof spec.idleTimeoutMs === "number" && Date.now() - lastProgressAt >= spec.idleTimeoutMs) {
1488
+ await stopAgent();
1489
+ return timeoutResult(startedAt, `idle timed out after ${spec.idleTimeoutMs}ms without agent progress`, {
1490
+ stdout: evidence(),
1491
+ stderr: stderr.value()
1492
+ });
1493
+ }
1494
+ await sleep(pollMs);
1495
+ }
1496
+ }
1497
+ function resolvedDirEquals(left, right) {
1498
+ try {
1499
+ return realpathSync(left) === realpathSync(right);
1500
+ } catch {
1501
+ return false;
1502
+ }
1503
+ }
1504
+ async function ensureLocalWorktree(worktree, env) {
1505
+ const { repoRoot, path, branch } = worktree;
1506
+ if (!repoRoot || !path || !branch) {
1507
+ return { error: "worktree preparation requires repoRoot, path, and branch metadata" };
1508
+ }
1509
+ const git = (args) => spawnCapture("git", args, { env, timeoutMs: WORKTREE_GIT_TIMEOUT_MS });
1510
+ let stats;
1511
+ try {
1512
+ stats = lstatSync(path);
1513
+ } catch {
1514
+ stats = undefined;
1515
+ }
1516
+ if (stats?.isSymbolicLink())
1517
+ return { error: `refusing symlinked worktree path ${path}` };
1518
+ const commonDir = async (base) => {
1519
+ const result = await git(["-C", base, "rev-parse", "--git-common-dir"]);
1520
+ if (result.error || (result.status ?? 1) !== 0)
1521
+ return;
1522
+ const raw = result.stdout.trim();
1523
+ if (!raw)
1524
+ return;
1525
+ try {
1526
+ return realpathSync(resolve(base, raw));
1527
+ } catch {
1528
+ return resolve(base, raw);
1529
+ }
1530
+ };
1531
+ if (stats) {
1532
+ const top = await git(["-C", path, "rev-parse", "--show-toplevel"]);
1533
+ if (top.error || (top.status ?? 1) !== 0) {
1534
+ return { error: `refusing to reuse non-worktree path: ${path}` };
1535
+ }
1536
+ if (!resolvedDirEquals(top.stdout.trim(), path)) {
1537
+ return { error: `existing worktree top-level mismatch for ${path}: ${top.stdout.trim()}` };
1538
+ }
1539
+ const expectedCommon = await commonDir(repoRoot);
1540
+ const actualCommon = await commonDir(path);
1541
+ if (!expectedCommon || expectedCommon !== actualCommon) {
1542
+ return { error: `existing worktree ${path} belongs to a different git common dir` };
1543
+ }
1544
+ const current = await git(["-C", path, "branch", "--show-current"]);
1545
+ const actualBranch = current.stdout.trim();
1546
+ if (current.error || (current.status ?? 1) !== 0 || actualBranch !== branch) {
1547
+ return { error: `existing worktree ${path} is on branch ${actualBranch || "unknown"}, expected ${branch}` };
1548
+ }
1549
+ return { cwd: worktree.cwd };
1550
+ }
1551
+ const inside = await git(["-C", repoRoot, "rev-parse", "--is-inside-work-tree"]);
1552
+ if (inside.error || (inside.status ?? 1) !== 0) {
1553
+ return { error: `worktree repoRoot is not a git repository: ${repoRoot}` };
1554
+ }
1555
+ try {
1556
+ mkdirSync(dirname(path), { recursive: true });
1557
+ } catch (error) {
1558
+ return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
1559
+ }
1560
+ const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
1561
+ const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
1562
+ if (add.error || (add.status ?? 1) !== 0) {
1563
+ const detail = (add.stderr || add.stdout || add.error || "").toString().trim();
1564
+ return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
1565
+ }
1566
+ return { cwd: worktree.cwd };
1567
+ }
1568
+ async function enterWorktree(spec, opts, env, startedAt) {
1569
+ const worktree = spec.worktree;
1570
+ if (!worktree?.enabled || worktree.mode === "off" || worktree.mode === "main")
1571
+ return;
1572
+ const prepared = await ensureLocalWorktree(worktree, env);
1573
+ if (prepared.error) {
1574
+ if (worktree.mode === "required") {
1575
+ return { failure: failureResult(startedAt, `worktree preparation failed (mode=required): ${prepared.error}`) };
1576
+ }
1577
+ opts.log?.(`worktree preparation failed (mode=${worktree.mode}); falling back to ${worktree.originalCwd}: ${prepared.error}`);
1578
+ spec.cwd = worktree.originalCwd;
1579
+ return { fallbackCwd: worktree.originalCwd };
1580
+ }
1581
+ spec.cwd = prepared.cwd ?? spec.cwd;
1582
+ opts.log?.(`entered worktree ${worktree.path ?? spec.cwd}${worktree.branch ? ` branch ${worktree.branch}` : ""}`);
1583
+ return;
1584
+ }
1585
+ function worktreeFallbackSpec(target, opts, fallbackCwd) {
1586
+ if (target.type !== "agent")
1587
+ return;
1588
+ const agentTarget = target;
1589
+ const fallbackTarget = {
1590
+ ...agentTarget,
1591
+ cwd: fallbackCwd,
1592
+ worktree: agentTarget.worktree ? { ...agentTarget.worktree, enabled: false, cwd: fallbackCwd, reason: "auto worktree preparation failed" } : undefined
1593
+ };
1594
+ return commandSpec(fallbackTarget, opts);
1595
+ }
1596
+ function preflightRemoteSpec(spec, machine, metadata, opts) {
1597
+ const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
1598
+ const result = spawnSync2(plan.command, plan.args, {
1599
+ encoding: "utf8",
1600
+ env: transportEnv(opts),
1601
+ input: remotePreflightScript(spec, metadata),
1602
+ stdio: ["pipe", "pipe", "pipe"],
1603
+ timeout: 15000
1604
+ });
1605
+ if (result.error)
1606
+ throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
1607
+ if ((result.status ?? 1) !== 0) {
1608
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
1609
+ throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
1610
+ }
1611
+ }
1612
+ async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
1613
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1614
+ const startedAt = nowIso();
1615
+ const stdout = new BoundedOutputBuffer(maxOutputBytes);
1616
+ const stderr = new BoundedOutputBuffer(maxOutputBytes);
1617
+ let timedOut = false;
1618
+ let idleTimedOut = false;
1619
+ let exitCode;
1620
+ let error;
1621
+ let plan;
1622
+ let script;
1623
+ try {
1624
+ plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
1625
+ script = remoteScript(spec, metadata, fallbackSpec);
1626
+ } catch (err) {
1627
+ return failureResult(startedAt, err instanceof Error ? err.message : String(err));
1628
+ }
1629
+ const child = spawn2(plan.command, plan.args, {
1630
+ env: transportEnv(opts),
1631
+ detached: true,
1632
+ stdio: ["pipe", "pipe", "pipe"]
1633
+ });
1634
+ notifySpawn(child.pid, opts);
1635
+ child.stdin?.on("error", (err) => {
1636
+ if (err.code !== "EPIPE")
1637
+ error = err.message;
1638
+ });
1639
+ child.stdin?.end(script);
1640
+ const abortHandler = () => {
1641
+ error = "cancelled";
1642
+ if (child.pid)
1643
+ killProcessGroup(child.pid);
1644
+ };
1645
+ if (opts.signal?.aborted)
1646
+ abortHandler();
1647
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
1648
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
1649
+ timedOut = true;
1650
+ if (child.pid)
1651
+ killProcessGroup(child.pid);
1652
+ }, spec.timeoutMs) : undefined;
1653
+ timer?.unref();
1654
+ let idleTimer;
1655
+ const resetIdleTimer = () => {
1656
+ if (!spec.idleTimeoutMs)
1657
+ return;
1658
+ if (idleTimer)
1659
+ clearTimeout(idleTimer);
1660
+ idleTimer = setTimeout(() => {
1661
+ idleTimedOut = true;
1662
+ if (child.pid)
1663
+ killProcessGroup(child.pid);
1664
+ }, spec.idleTimeoutMs);
1665
+ idleTimer.unref();
1666
+ };
1667
+ resetIdleTimer();
1668
+ child.stdout?.setEncoding("utf8");
1669
+ child.stderr?.setEncoding("utf8");
1670
+ child.stdout?.on("data", (chunk) => {
1671
+ stdout.append(chunk);
1672
+ resetIdleTimer();
1673
+ });
1674
+ child.stderr?.on("data", (chunk) => {
1675
+ stderr.append(chunk);
1676
+ resetIdleTimer();
1677
+ });
1678
+ try {
1679
+ const [code, signal] = await once(child, "exit");
1680
+ if (typeof code === "number")
1681
+ exitCode = code;
1682
+ if (signal)
1683
+ error = `terminated by ${signal}`;
1684
+ } catch (err) {
1685
+ error = err instanceof Error ? err.message : String(err);
1686
+ } finally {
1687
+ if (timer)
1688
+ clearTimeout(timer);
1689
+ if (idleTimer)
1690
+ clearTimeout(idleTimer);
1691
+ opts.signal?.removeEventListener("abort", abortHandler);
1692
+ }
1693
+ const fields = { exitCode, stdout: stdout.value(), stderr: stderr.value(), pid: child.pid };
1694
+ if (timedOut || idleTimedOut) {
1695
+ return timeoutResult(startedAt, idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`, fields);
1696
+ }
1697
+ if (error || exitCode !== 0) {
1698
+ return failureResult(startedAt, error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`, fields);
1699
+ }
1700
+ return successResult(startedAt, fields);
1701
+ }
1702
+ function preflightTarget(target, metadata = {}, opts = {}) {
1703
+ const spec = commandSpec(target, opts);
1704
+ const machine = resolvedMachine(opts);
1705
+ if (machine && !machine.local) {
1706
+ preflightRemoteSpec(spec, machine, metadata, opts);
1707
+ return {
1708
+ command: spec.command,
1709
+ accountProfile: spec.account?.profile,
1710
+ accountTool: spec.accountTool
1711
+ };
1712
+ }
1713
+ const env = executionEnvSync(spec, metadata, opts);
1714
+ if (!spec.shell && !executableExists(spec.command, env)) {
1715
+ throw new Error(commandNotFoundMessage(spec.command, env));
1716
+ }
1717
+ if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
1718
+ throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
1719
+ }
1720
+ preflightNativeAuthProfileSync(spec, env);
1721
+ return {
1722
+ command: spec.command,
1723
+ accountProfile: spec.account?.profile,
1724
+ accountTool: spec.accountTool
1725
+ };
1726
+ }
1727
+ async function executeTarget(target, metadata = {}, opts = {}) {
1728
+ let spec = commandSpec(target, opts);
1729
+ const machine = resolvedMachine(opts);
1730
+ if (machine && !machine.local && spec.codewithDurableAgent) {
1731
+ return failureResult(nowIso(), "remote Codewith durable background-agent steps require remote status polling support; run this Codewith step locally or add remote durable readback before dispatch");
1732
+ }
1733
+ if (machine && !machine.local) {
1734
+ const remoteFallbackSpec = spec.worktree?.enabled && spec.worktree.mode === "auto" ? worktreeFallbackSpec(target, opts, spec.worktree.originalCwd) : undefined;
1735
+ return executeRemoteSpec(spec, machine, metadata, opts, remoteFallbackSpec);
1736
+ }
1737
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1738
+ const startedAt = nowIso();
1739
+ const stdout = new BoundedOutputBuffer(maxOutputBytes);
1740
+ const stderr = new BoundedOutputBuffer(maxOutputBytes);
1741
+ let timedOut = false;
1742
+ let idleTimedOut = false;
1743
+ let exitCode;
1744
+ let error;
1745
+ const env = await executionEnv(spec, metadata, opts);
1746
+ if (!spec.shell && !executableExists(spec.command, env)) {
1747
+ return failureResult(startedAt, commandNotFoundMessage(spec.command, env));
1748
+ }
1749
+ if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
1750
+ return failureResult(startedAt, `none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
1751
+ }
1752
+ try {
1753
+ await preflightNativeAuthProfile(spec, env);
1754
+ } catch (err) {
1755
+ return failureResult(startedAt, err instanceof Error ? err.message : String(err));
1756
+ }
1757
+ const worktreeEntry = await enterWorktree(spec, opts, env, startedAt);
1758
+ if (worktreeEntry?.failure)
1759
+ return worktreeEntry.failure;
1760
+ if (worktreeEntry?.fallbackCwd) {
1761
+ spec = worktreeFallbackSpec(target, opts, worktreeEntry.fallbackCwd) ?? spec;
1762
+ }
1763
+ if (spec.codewithDurableAgent) {
1764
+ return executeCodewithDurableAgent(spec, metadata, opts, env, startedAt);
1765
+ }
1766
+ const child = spawn2(spec.command, spec.args, {
1767
+ cwd: spec.cwd,
1768
+ env,
1769
+ shell: spec.shell ?? false,
1770
+ detached: true,
1771
+ stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
1772
+ });
1773
+ notifySpawn(child.pid, opts);
1774
+ if (spec.stdin !== undefined && child.stdin) {
1775
+ child.stdin.on("error", (err) => {
1776
+ if (err.code !== "EPIPE")
1777
+ error = err.message;
1778
+ });
1779
+ child.stdin.end(spec.stdin);
1780
+ }
1781
+ const abortHandler = () => {
1782
+ error = "cancelled";
1783
+ if (child.pid)
1784
+ killProcessGroup(child.pid);
1785
+ };
1786
+ if (opts.signal?.aborted)
1787
+ abortHandler();
1788
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
1789
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
1790
+ timedOut = true;
1791
+ if (child.pid)
1792
+ killProcessGroup(child.pid);
1793
+ }, spec.timeoutMs) : undefined;
1794
+ timer?.unref();
1795
+ let idleTimer;
1796
+ const resetIdleTimer = () => {
1797
+ if (!spec.idleTimeoutMs)
1798
+ return;
1799
+ if (idleTimer)
1800
+ clearTimeout(idleTimer);
1801
+ idleTimer = setTimeout(() => {
1802
+ idleTimedOut = true;
1803
+ if (child.pid)
1804
+ killProcessGroup(child.pid);
1805
+ }, spec.idleTimeoutMs);
1806
+ idleTimer.unref();
1807
+ };
1808
+ resetIdleTimer();
1809
+ child.stdout?.setEncoding("utf8");
1810
+ child.stderr?.setEncoding("utf8");
1811
+ child.stdout?.on("data", (chunk) => {
1812
+ stdout.append(chunk);
1813
+ resetIdleTimer();
1814
+ });
1815
+ child.stderr?.on("data", (chunk) => {
1816
+ stderr.append(chunk);
1817
+ resetIdleTimer();
1818
+ });
1819
+ try {
1820
+ const [code, signal] = await once(child, "exit");
1821
+ if (typeof code === "number")
1822
+ exitCode = code;
1823
+ if (signal)
1824
+ error = `terminated by ${signal}`;
1825
+ } catch (err) {
1826
+ error = err instanceof Error ? err.message : String(err);
1827
+ } finally {
1828
+ if (timer)
1829
+ clearTimeout(timer);
1830
+ if (idleTimer)
1831
+ clearTimeout(idleTimer);
1832
+ opts.signal?.removeEventListener("abort", abortHandler);
1833
+ }
1834
+ const fields = { exitCode, stdout: stdout.value(), stderr: stderr.value(), pid: child.pid };
1835
+ if (timedOut || idleTimedOut) {
1836
+ return timeoutResult(startedAt, idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`, fields);
1837
+ }
1838
+ if (error || exitCode !== 0) {
1839
+ return failureResult(startedAt, error ?? `process exited with code ${exitCode ?? "unknown"}`, fields);
1840
+ }
1841
+ return successResult(startedAt, fields);
1842
+ }
1843
+ async function executeLoop(loop, run, opts = {}) {
1844
+ if (loop.target.type === "workflow") {
1845
+ throw new Error("workflow loop targets must be executed with executeLoopTarget");
1846
+ }
1847
+ if (loop.target.preflight?.beforeRun) {
1848
+ const startedAt = nowIso();
1849
+ try {
1850
+ preflightTarget(loop.target, {
1851
+ loopId: loop.id,
1852
+ loopName: loop.name,
1853
+ runId: run.id,
1854
+ scheduledFor: run.scheduledFor
1855
+ }, { ...opts, machine: opts.machine ?? loop.machine });
1856
+ } catch (error) {
1857
+ return failureResult(startedAt, `runtime preflight failed: ${error instanceof Error ? error.message : String(error)}`);
1858
+ }
1859
+ }
1860
+ return executeTarget(loop.target, {
1861
+ loopId: loop.id,
1862
+ loopName: loop.name,
1863
+ runId: run.id,
1864
+ scheduledFor: run.scheduledFor
1865
+ }, { ...opts, machine: opts.machine ?? loop.machine });
1866
+ }
1867
+
1868
+ // src/runner/index.ts
257
1869
  var program = new Command;
1870
+ var DEFAULT_RUNNER_ID = `runner:${process.pid}`;
1871
+ var MIN_RUNNER_LEASE_MS = 1000;
258
1872
  program.name("loops-runner").description("OpenLoops control-plane runner foundation").version(packageVersion()).option("-j, --json", "print JSON");
1873
+ function configuredApiUrl(env = process.env) {
1874
+ return env.LOOPS_API_URL?.trim() || env.HASNA_LOOPS_API_URL?.trim() || env.LOOPS_CLOUD_API_URL?.trim() || env.HASNA_LOOPS_CLOUD_API_URL?.trim();
1875
+ }
1876
+ function configuredApiToken(env = process.env) {
1877
+ return env.LOOPS_API_TOKEN?.trim() || env.HASNA_LOOPS_API_TOKEN?.trim() || env.LOOPS_CLOUD_TOKEN?.trim() || env.HASNA_LOOPS_CLOUD_TOKEN?.trim();
1878
+ }
1879
+ function isLocalApiUrl(value) {
1880
+ try {
1881
+ const url = new URL(value);
1882
+ return ["127.0.0.1", "localhost", "::1"].includes(url.hostname);
1883
+ } catch {
1884
+ return false;
1885
+ }
1886
+ }
259
1887
  function runnerStatus(machineId = process.env.LOOPS_RUNNER_MACHINE_ID || process.env.HASNA_MACHINE_ID) {
260
1888
  const deployment = buildDeploymentStatus();
1889
+ const local = deployment.deploymentMode === "local";
1890
+ const apiUrl = configuredApiUrl();
1891
+ const token = configuredApiToken();
1892
+ const apiReady = Boolean(apiUrl && (isLocalApiUrl(apiUrl) || token));
261
1893
  return {
262
- ok: deployment.deploymentMode === "local" || deployment.controlPlane.configured,
1894
+ ok: local || apiReady,
263
1895
  service: "loops-runner",
264
1896
  machineId,
265
1897
  deployment,
266
- state: deployment.deploymentMode === "local" ? "local_daemon_authoritative" : deployment.controlPlane.configured ? "ready_for_control_plane" : "missing_control_plane_configuration"
1898
+ state: local ? "local_daemon_authoritative" : apiReady ? "control_plane_ready" : apiUrl ? "missing_control_plane_token" : "missing_control_plane_api_url"
267
1899
  };
268
1900
  }
1901
+ function resolveRunnerConfig(opts) {
1902
+ const env = opts.env ?? process.env;
1903
+ const apiUrl = opts.apiUrl ?? configuredApiUrl(env);
1904
+ if (!apiUrl)
1905
+ throw new Error("loops-runner requires LOOPS_API_URL or HASNA_LOOPS_API_URL");
1906
+ const token = opts.apiToken ?? configuredApiToken(env);
1907
+ if (!isLocalApiUrl(apiUrl) && !token)
1908
+ throw new Error("non-local loops-runner requires LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
1909
+ return {
1910
+ apiUrl,
1911
+ token,
1912
+ runnerId: opts.runnerId ?? env.LOOPS_RUNNER_ID ?? env.LOOPS_RUNNER_MACHINE_ID ?? env.HASNA_MACHINE_ID ?? DEFAULT_RUNNER_ID,
1913
+ machineId: opts.machineId ?? env.LOOPS_RUNNER_MACHINE_ID ?? env.HASNA_MACHINE_ID
1914
+ };
1915
+ }
1916
+ function endpoint(base, path) {
1917
+ return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
1918
+ }
1919
+ async function postJson(fetchImpl, config, path, body) {
1920
+ const response = await fetchImpl(endpoint(config.apiUrl, path), {
1921
+ method: "POST",
1922
+ headers: {
1923
+ "content-type": "application/json",
1924
+ ...config.token ? { authorization: `Bearer ${config.token}` } : {}
1925
+ },
1926
+ body: JSON.stringify(body)
1927
+ });
1928
+ const payload = await response.json().catch(() => ({}));
1929
+ if (!response.ok)
1930
+ throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
1931
+ return payload;
1932
+ }
1933
+ async function runRunnerOnce(opts = {}) {
1934
+ const config = resolveRunnerConfig(opts);
1935
+ const fetchImpl = opts.fetchImpl ?? fetch;
1936
+ const runnerBody = {
1937
+ runnerId: config.runnerId,
1938
+ machineId: config.machineId,
1939
+ now: (opts.now ?? new Date).toISOString(),
1940
+ maxClaims: 1
1941
+ };
1942
+ await postJson(fetchImpl, config, "/v1/runners/register", runnerBody);
1943
+ const claimed = await postJson(fetchImpl, config, "/v1/runners/claim", runnerBody);
1944
+ const claims = Array.isArray(claimed.claims) ? claimed.claims : [];
1945
+ const completed = [];
1946
+ for (const claim of claims) {
1947
+ const result = await executeClaimWithHeartbeat(fetchImpl, config, claim, opts);
1948
+ const finalized = await postJson(fetchImpl, config, `/v1/runs/${claim.run.id}/finalize`, {
1949
+ claimToken: claim.claimToken,
1950
+ status: result.status,
1951
+ finishedAt: result.finishedAt,
1952
+ durationMs: result.durationMs,
1953
+ stdout: result.stdout,
1954
+ stderr: result.stderr,
1955
+ error: result.error,
1956
+ exitCode: result.exitCode,
1957
+ pid: result.pid
1958
+ });
1959
+ const run = finalized.run ?? claim.run;
1960
+ completed.push(run);
1961
+ }
1962
+ return { ok: completed.every((run) => run.status === "succeeded"), claimed: claims.length, completed };
1963
+ }
1964
+ async function executeClaimWithHeartbeat(fetchImpl, config, claim, opts) {
1965
+ const execute = opts.execute ?? executeLoop;
1966
+ const leaseMs = runnerLeaseMs(claim.loop.leaseMs);
1967
+ const heartbeatIntervalMs = runnerHeartbeatIntervalMs(leaseMs, opts.heartbeatIntervalMs);
1968
+ const heartbeat = async () => {
1969
+ await postJson(fetchImpl, config, `/v1/runs/${claim.run.id}/heartbeat`, {
1970
+ claimToken: claim.claimToken,
1971
+ leaseMs
1972
+ });
1973
+ };
1974
+ await heartbeat();
1975
+ let lastHeartbeatError;
1976
+ const timer = setInterval(() => {
1977
+ heartbeat().catch((error) => {
1978
+ lastHeartbeatError = error;
1979
+ });
1980
+ }, heartbeatIntervalMs);
1981
+ try {
1982
+ const result = await execute(claim.loop, claim.run);
1983
+ return result;
1984
+ } finally {
1985
+ clearInterval(timer);
1986
+ }
1987
+ }
1988
+ function runnerLeaseMs(leaseMs) {
1989
+ return Math.max(MIN_RUNNER_LEASE_MS, leaseMs);
1990
+ }
1991
+ function runnerHeartbeatIntervalMs(leaseMs, configured) {
1992
+ const boundedLeaseMs = Number.isFinite(leaseMs) && leaseMs > 0 ? leaseMs : 30000;
1993
+ const safeDefault = Math.max(1, Math.floor(boundedLeaseMs / 2));
1994
+ const requested = configured === undefined ? safeDefault : Math.max(1, Math.floor(configured));
1995
+ return Math.min(30000, safeDefault, requested);
1996
+ }
269
1997
  function wantsJson(opts) {
270
1998
  return Boolean(program.opts().json || opts?.json);
271
1999
  }
@@ -283,6 +2011,19 @@ async function main(argv = process.argv) {
283
2011
  }
284
2012
  program.action(() => printStatus());
285
2013
  program.command("status").option("-j, --json", "print JSON").action((opts) => printStatus(opts));
2014
+ program.command("run-once").description("claim and execute one control-plane run").option("--api-url <url>", "control-plane API URL").option("--runner-id <id>", "runner id").option("--machine-id <id>", "machine id").option("-j, --json", "print JSON").action(async (opts) => {
2015
+ const result = await runRunnerOnce({
2016
+ apiUrl: opts.apiUrl,
2017
+ runnerId: opts.runnerId,
2018
+ machineId: opts.machineId
2019
+ });
2020
+ if (wantsJson(opts))
2021
+ console.log(JSON.stringify(result, null, 2));
2022
+ else
2023
+ console.log(`claimed=${result.claimed} completed=${result.completed.length}`);
2024
+ if (!result.ok)
2025
+ process.exitCode = 1;
2026
+ });
286
2027
  if (import.meta.main) {
287
2028
  main().catch((error) => {
288
2029
  console.error(error instanceof Error ? error.message : String(error));
@@ -291,5 +2032,6 @@ if (import.meta.main) {
291
2032
  }
292
2033
  export {
293
2034
  runnerStatus,
2035
+ runRunnerOnce,
294
2036
  main
295
2037
  };