@hasna/loops 0.3.0 → 0.3.1

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
@@ -203,10 +203,11 @@ On Linux this writes a user systemd service. On macOS it writes a LaunchAgent pl
203
203
  The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:
204
204
 
205
205
  - Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
206
- - Codewith uses `codewith exec --json --ephemeral --ask-for-approval never`.
206
+ - Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`.
207
207
  - AI Copilot and OpenCode use `run --format json --pure`.
208
208
  - Cursor is CLI-first for now via `cursor-agent -p`; treat output as less stable until a stronger public SDK contract is selected.
209
209
  - Codex uses `codex exec --json --ephemeral --ask-for-approval never`.
210
210
  - When `--account` or a step `account` is set, OpenLoops resolves `accounts env <profile> --tool <tool>` before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt.
211
+ - Daemon and scheduled runs prepend common user executable directories such as `~/.local/bin` and `~/.bun/bin` before resolving provider CLIs.
211
212
 
212
213
  For production loops that can mutate repos, prefer disposable worktrees and explicit prompts that name allowed write scope.
package/dist/cli/index.js CHANGED
@@ -1305,10 +1305,11 @@ class Store {
1305
1305
  }
1306
1306
 
1307
1307
  // src/cli/index.ts
1308
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
1308
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
1309
1309
  import { Command } from "commander";
1310
1310
 
1311
1311
  // src/lib/format.ts
1312
+ var TEXT_OUTPUT_LIMIT = 32 * 1024;
1312
1313
  function redact(value, visible = 80) {
1313
1314
  if (!value)
1314
1315
  return value;
@@ -1316,6 +1317,29 @@ function redact(value, visible = 80) {
1316
1317
  return value;
1317
1318
  return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
1318
1319
  }
1320
+ function truncateTextOutput(value) {
1321
+ if (value.length <= TEXT_OUTPUT_LIMIT)
1322
+ return value;
1323
+ return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
1324
+ [truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
1325
+ }
1326
+ function textOutputBlocks(value, opts = {}) {
1327
+ const indent = opts.indent ?? "";
1328
+ const nested = `${indent} `;
1329
+ const blocks = [];
1330
+ for (const [label, output] of [
1331
+ ["stdout", value.stdout],
1332
+ ["stderr", value.stderr]
1333
+ ]) {
1334
+ if (!output)
1335
+ continue;
1336
+ blocks.push(`${indent}${label}:`);
1337
+ for (const line of truncateTextOutput(output).replace(/\s+$/, "").split(/\r?\n/)) {
1338
+ blocks.push(`${nested}${line}`);
1339
+ }
1340
+ }
1341
+ return blocks;
1342
+ }
1319
1343
  function publicLoop(loop) {
1320
1344
  const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : loop.target.type === "agent" ? { ...loop.target, prompt: redact(loop.target.prompt) } : loop.target;
1321
1345
  return {
@@ -1354,9 +1378,8 @@ function publicWorkflowEvent(event) {
1354
1378
  }
1355
1379
 
1356
1380
  // src/lib/executor.ts
1357
- import { spawn, spawnSync as spawnSync2 } from "child_process";
1381
+ import { spawn } from "child_process";
1358
1382
  import { once } from "events";
1359
- import { existsSync as existsSync2 } from "fs";
1360
1383
 
1361
1384
  // src/lib/accounts.ts
1362
1385
  import { spawnSync } from "child_process";
@@ -1454,6 +1477,65 @@ function resolveAccountEnv(account, toolHint, env) {
1454
1477
  };
1455
1478
  }
1456
1479
 
1480
+ // src/lib/env.ts
1481
+ import { accessSync, constants } from "fs";
1482
+ import { homedir as homedir2 } from "os";
1483
+ import { delimiter, join as join2 } from "path";
1484
+ function compactPathParts(parts) {
1485
+ const seen = new Set;
1486
+ const result = [];
1487
+ for (const part of parts) {
1488
+ const value = part?.trim();
1489
+ if (!value || seen.has(value))
1490
+ continue;
1491
+ seen.add(value);
1492
+ result.push(value);
1493
+ }
1494
+ return result;
1495
+ }
1496
+ function commonExecutableDirs(env = process.env) {
1497
+ const home = env.HOME || homedir2();
1498
+ return compactPathParts([
1499
+ join2(home, ".local", "bin"),
1500
+ join2(home, ".bun", "bin"),
1501
+ join2(home, ".cargo", "bin"),
1502
+ join2(home, ".npm-global", "bin"),
1503
+ join2(home, "bin"),
1504
+ env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
1505
+ env.PNPM_HOME,
1506
+ env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
1507
+ "/opt/homebrew/bin",
1508
+ "/usr/local/bin",
1509
+ "/usr/bin",
1510
+ "/bin",
1511
+ "/usr/sbin",
1512
+ "/sbin"
1513
+ ]);
1514
+ }
1515
+ function normalizeExecutionPath(env = process.env) {
1516
+ return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
1517
+ }
1518
+ function isExecutable(path) {
1519
+ try {
1520
+ accessSync(path, constants.X_OK);
1521
+ return true;
1522
+ } catch {
1523
+ return false;
1524
+ }
1525
+ }
1526
+ function executableExists(command, env = process.env) {
1527
+ if (command.includes("/"))
1528
+ return isExecutable(command);
1529
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
1530
+ if (dir && isExecutable(join2(dir, command)))
1531
+ return true;
1532
+ }
1533
+ return false;
1534
+ }
1535
+ function commandNotFoundMessage(command, env = process.env) {
1536
+ return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1537
+ }
1538
+
1457
1539
  // src/lib/executor.ts
1458
1540
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1459
1541
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1539,7 +1621,7 @@ function agentArgs(target) {
1539
1621
  args.push(...target.extraArgs ?? [], target.prompt);
1540
1622
  return args;
1541
1623
  case "codewith":
1542
- args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1624
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1543
1625
  if (isolation === "safe")
1544
1626
  args.push("--ignore-rules");
1545
1627
  if (target.cwd)
@@ -1619,6 +1701,7 @@ function executionEnv(spec, metadata, opts) {
1619
1701
  Object.assign(env, accountEnv);
1620
1702
  }
1621
1703
  Object.assign(env, spec.env ?? {});
1704
+ env.PATH = normalizeExecutionPath(env);
1622
1705
  if (metadata.loopId)
1623
1706
  env.LOOPS_LOOP_ID = metadata.loopId;
1624
1707
  if (metadata.loopName)
@@ -1637,20 +1720,11 @@ function executionEnv(spec, metadata, opts) {
1637
1720
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1638
1721
  return env;
1639
1722
  }
1640
- function commandExists(command, env) {
1641
- if (command.includes("/") && existsSync2(command))
1642
- return true;
1643
- const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
1644
- env,
1645
- stdio: "ignore"
1646
- });
1647
- return (result.status ?? 1) === 0;
1648
- }
1649
1723
  function preflightTarget(target, metadata = {}, opts = {}) {
1650
1724
  const spec = commandSpec(target);
1651
1725
  const env = executionEnv(spec, metadata, opts);
1652
- if (!spec.shell && !commandExists(spec.command, env)) {
1653
- throw new Error(`Executable not found in PATH: ${spec.command}`);
1726
+ if (!spec.shell && !executableExists(spec.command, env)) {
1727
+ throw new Error(commandNotFoundMessage(spec.command, env));
1654
1728
  }
1655
1729
  return {
1656
1730
  command: spec.command,
@@ -1668,12 +1742,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1668
1742
  let exitCode;
1669
1743
  let error;
1670
1744
  const env = executionEnv(spec, metadata, opts);
1671
- if (!spec.shell && !commandExists(spec.command, env)) {
1745
+ if (!spec.shell && !executableExists(spec.command, env)) {
1672
1746
  return {
1673
1747
  status: "failed",
1674
1748
  stdout: "",
1675
1749
  stderr: "",
1676
- error: `Executable not found in PATH: ${spec.command}`,
1750
+ error: commandNotFoundMessage(spec.command, env),
1677
1751
  startedAt,
1678
1752
  finishedAt: nowIso(),
1679
1753
  durationMs: 0
@@ -2112,7 +2186,7 @@ async function tick(deps) {
2112
2186
  }
2113
2187
 
2114
2188
  // src/daemon/control.ts
2115
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2189
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2116
2190
  import { hostname } from "os";
2117
2191
  import { dirname as dirname2 } from "path";
2118
2192
 
@@ -2140,7 +2214,7 @@ async function runLoop(opts) {
2140
2214
 
2141
2215
  // src/daemon/control.ts
2142
2216
  function readPid(path = pidFilePath()) {
2143
- if (!existsSync3(path))
2217
+ if (!existsSync2(path))
2144
2218
  return;
2145
2219
  try {
2146
2220
  const pid = Number(readFileSync(path, "utf8").trim());
@@ -2376,10 +2450,11 @@ async function startDaemon(opts) {
2376
2450
 
2377
2451
  // src/daemon/install.ts
2378
2452
  import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
2379
- import { spawnSync as spawnSync3 } from "child_process";
2453
+ import { spawnSync as spawnSync2 } from "child_process";
2380
2454
  import { dirname as dirname3 } from "path";
2381
2455
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
2382
2456
  const command = [execPath, cliEntry, ...args].join(" ");
2457
+ const pathEnv = normalizeExecutionPath(process.env);
2383
2458
  if (process.platform === "linux") {
2384
2459
  const path = systemdServicePath();
2385
2460
  mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
@@ -2392,7 +2467,7 @@ Type=simple
2392
2467
  ExecStart=${command}
2393
2468
  Restart=always
2394
2469
  RestartSec=5
2395
- Environment=PATH=${process.env.PATH ?? ""}
2470
+ Environment=PATH=${pathEnv}
2396
2471
 
2397
2472
  [Install]
2398
2473
  WantedBy=default.target
@@ -2424,6 +2499,10 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2424
2499
  </array>
2425
2500
  <key>RunAtLoad</key><true/>
2426
2501
  <key>KeepAlive</key><true/>
2502
+ <key>EnvironmentVariables</key>
2503
+ <dict>
2504
+ <key>PATH</key><string>${pathEnv}</string>
2505
+ </dict>
2427
2506
  <key>StandardOutPath</key><string>${daemonLogPath()}</string>
2428
2507
  <key>StandardErrorPath</key><string>${daemonLogPath()}</string>
2429
2508
  </dict>
@@ -2441,7 +2520,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2441
2520
  function enableStartup(result) {
2442
2521
  const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
2443
2522
  return commands.map((command) => {
2444
- const run = spawnSync3("sh", ["-c", command], {
2523
+ const run = spawnSync2("sh", ["-c", command], {
2445
2524
  encoding: "utf8",
2446
2525
  stdio: ["ignore", "pipe", "pipe"]
2447
2526
  });
@@ -2455,8 +2534,8 @@ function enableStartup(result) {
2455
2534
  }
2456
2535
 
2457
2536
  // src/lib/doctor.ts
2458
- import { spawnSync as spawnSync4 } from "child_process";
2459
- import { accessSync, constants } from "fs";
2537
+ import { spawnSync as spawnSync3 } from "child_process";
2538
+ import { accessSync as accessSync2, constants as constants2 } from "fs";
2460
2539
  var PROVIDER_COMMANDS = [
2461
2540
  "claude",
2462
2541
  "cursor-agent",
@@ -2466,11 +2545,11 @@ var PROVIDER_COMMANDS = [
2466
2545
  "codex"
2467
2546
  ];
2468
2547
  function hasCommand(command) {
2469
- const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
2548
+ const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
2470
2549
  return (result.status ?? 1) === 0;
2471
2550
  }
2472
2551
  function commandVersion(command) {
2473
- const result = spawnSync4(command, ["--version"], {
2552
+ const result = spawnSync3(command, ["--version"], {
2474
2553
  encoding: "utf8",
2475
2554
  stdio: ["ignore", "pipe", "pipe"]
2476
2555
  });
@@ -2482,7 +2561,7 @@ function runDoctor(store) {
2482
2561
  const checks = [];
2483
2562
  try {
2484
2563
  const dir = ensureDataDir();
2485
- accessSync(dir, constants.R_OK | constants.W_OK);
2564
+ accessSync2(dir, constants2.R_OK | constants2.W_OK);
2486
2565
  checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
2487
2566
  } catch (error) {
2488
2567
  checks.push({
@@ -2531,7 +2610,7 @@ function runDoctor(store) {
2531
2610
 
2532
2611
  // src/cli/index.ts
2533
2612
  var program = new Command;
2534
- program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.0");
2613
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.1");
2535
2614
  program.option("-j, --json", "print JSON");
2536
2615
  function isJson() {
2537
2616
  return Boolean(program.opts().json);
@@ -2542,6 +2621,10 @@ function print(value, human) {
2542
2621
  else
2543
2622
  console.log(human);
2544
2623
  }
2624
+ function printTextOutput(value) {
2625
+ for (const line of textOutputBlocks(value, { indent: " " }))
2626
+ console.log(line);
2627
+ }
2545
2628
  function parseSchedule(opts) {
2546
2629
  const count = [opts.at, opts.every, opts.cron, opts.dynamic ? "dynamic" : undefined].filter(Boolean).length;
2547
2630
  if (count !== 1)
@@ -2772,6 +2855,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2772
2855
  console.log(`${run?.id ?? workflow.id} ${result.status}`);
2773
2856
  for (const step of steps) {
2774
2857
  console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
2858
+ if (opts.showOutput)
2859
+ printTextOutput(step);
2775
2860
  }
2776
2861
  }
2777
2862
  } finally {
@@ -2872,6 +2957,8 @@ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("
2872
2957
  else {
2873
2958
  for (const run of runs) {
2874
2959
  console.log(`${run.id} ${run.status.padEnd(10)} attempt=${run.attempt} slot=${run.scheduledFor} ${run.loopName}`);
2960
+ if (opts.showOutput)
2961
+ printTextOutput(run);
2875
2962
  }
2876
2963
  }
2877
2964
  } finally {
@@ -2916,6 +3003,8 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
2916
3003
  advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
2917
3004
  }
2918
3005
  print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
3006
+ if (!isJson() && opts.showOutput)
3007
+ printTextOutput(run);
2919
3008
  } finally {
2920
3009
  store.close();
2921
3010
  }
@@ -2978,7 +3067,7 @@ ${result.instructions.join(`
2978
3067
  });
2979
3068
  daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
2980
3069
  const path = daemonLogPath();
2981
- if (!existsSync4(path)) {
3070
+ if (!existsSync3(path)) {
2982
3071
  console.log("");
2983
3072
  return;
2984
3073
  }
@@ -1313,9 +1313,8 @@ import { hostname as hostname2 } from "os";
1313
1313
  import { spawn as spawn2 } from "child_process";
1314
1314
 
1315
1315
  // src/lib/executor.ts
1316
- import { spawn, spawnSync as spawnSync2 } from "child_process";
1316
+ import { spawn } from "child_process";
1317
1317
  import { once } from "events";
1318
- import { existsSync as existsSync2 } from "fs";
1319
1318
 
1320
1319
  // src/lib/accounts.ts
1321
1320
  import { spawnSync } from "child_process";
@@ -1413,6 +1412,65 @@ function resolveAccountEnv(account, toolHint, env) {
1413
1412
  };
1414
1413
  }
1415
1414
 
1415
+ // src/lib/env.ts
1416
+ import { accessSync, constants } from "fs";
1417
+ import { homedir as homedir2 } from "os";
1418
+ import { delimiter, join as join2 } from "path";
1419
+ function compactPathParts(parts) {
1420
+ const seen = new Set;
1421
+ const result = [];
1422
+ for (const part of parts) {
1423
+ const value = part?.trim();
1424
+ if (!value || seen.has(value))
1425
+ continue;
1426
+ seen.add(value);
1427
+ result.push(value);
1428
+ }
1429
+ return result;
1430
+ }
1431
+ function commonExecutableDirs(env = process.env) {
1432
+ const home = env.HOME || homedir2();
1433
+ return compactPathParts([
1434
+ join2(home, ".local", "bin"),
1435
+ join2(home, ".bun", "bin"),
1436
+ join2(home, ".cargo", "bin"),
1437
+ join2(home, ".npm-global", "bin"),
1438
+ join2(home, "bin"),
1439
+ env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
1440
+ env.PNPM_HOME,
1441
+ env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
1442
+ "/opt/homebrew/bin",
1443
+ "/usr/local/bin",
1444
+ "/usr/bin",
1445
+ "/bin",
1446
+ "/usr/sbin",
1447
+ "/sbin"
1448
+ ]);
1449
+ }
1450
+ function normalizeExecutionPath(env = process.env) {
1451
+ return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
1452
+ }
1453
+ function isExecutable(path) {
1454
+ try {
1455
+ accessSync(path, constants.X_OK);
1456
+ return true;
1457
+ } catch {
1458
+ return false;
1459
+ }
1460
+ }
1461
+ function executableExists(command, env = process.env) {
1462
+ if (command.includes("/"))
1463
+ return isExecutable(command);
1464
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
1465
+ if (dir && isExecutable(join2(dir, command)))
1466
+ return true;
1467
+ }
1468
+ return false;
1469
+ }
1470
+ function commandNotFoundMessage(command, env = process.env) {
1471
+ return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1472
+ }
1473
+
1416
1474
  // src/lib/executor.ts
1417
1475
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1418
1476
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1498,7 +1556,7 @@ function agentArgs(target) {
1498
1556
  args.push(...target.extraArgs ?? [], target.prompt);
1499
1557
  return args;
1500
1558
  case "codewith":
1501
- args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1559
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1502
1560
  if (isolation === "safe")
1503
1561
  args.push("--ignore-rules");
1504
1562
  if (target.cwd)
@@ -1578,6 +1636,7 @@ function executionEnv(spec, metadata, opts) {
1578
1636
  Object.assign(env, accountEnv);
1579
1637
  }
1580
1638
  Object.assign(env, spec.env ?? {});
1639
+ env.PATH = normalizeExecutionPath(env);
1581
1640
  if (metadata.loopId)
1582
1641
  env.LOOPS_LOOP_ID = metadata.loopId;
1583
1642
  if (metadata.loopName)
@@ -1596,20 +1655,11 @@ function executionEnv(spec, metadata, opts) {
1596
1655
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1597
1656
  return env;
1598
1657
  }
1599
- function commandExists(command, env) {
1600
- if (command.includes("/") && existsSync2(command))
1601
- return true;
1602
- const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
1603
- env,
1604
- stdio: "ignore"
1605
- });
1606
- return (result.status ?? 1) === 0;
1607
- }
1608
1658
  function preflightTarget(target, metadata = {}, opts = {}) {
1609
1659
  const spec = commandSpec(target);
1610
1660
  const env = executionEnv(spec, metadata, opts);
1611
- if (!spec.shell && !commandExists(spec.command, env)) {
1612
- throw new Error(`Executable not found in PATH: ${spec.command}`);
1661
+ if (!spec.shell && !executableExists(spec.command, env)) {
1662
+ throw new Error(commandNotFoundMessage(spec.command, env));
1613
1663
  }
1614
1664
  return {
1615
1665
  command: spec.command,
@@ -1627,12 +1677,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1627
1677
  let exitCode;
1628
1678
  let error;
1629
1679
  const env = executionEnv(spec, metadata, opts);
1630
- if (!spec.shell && !commandExists(spec.command, env)) {
1680
+ if (!spec.shell && !executableExists(spec.command, env)) {
1631
1681
  return {
1632
1682
  status: "failed",
1633
1683
  stdout: "",
1634
1684
  stderr: "",
1635
- error: `Executable not found in PATH: ${spec.command}`,
1685
+ error: commandNotFoundMessage(spec.command, env),
1636
1686
  startedAt,
1637
1687
  finishedAt: nowIso(),
1638
1688
  durationMs: 0
@@ -2071,7 +2121,7 @@ async function tick(deps) {
2071
2121
  }
2072
2122
 
2073
2123
  // src/daemon/control.ts
2074
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2124
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2075
2125
  import { hostname } from "os";
2076
2126
  import { dirname as dirname2 } from "path";
2077
2127
 
@@ -2099,7 +2149,7 @@ async function runLoop(opts) {
2099
2149
 
2100
2150
  // src/daemon/control.ts
2101
2151
  function readPid(path = pidFilePath()) {
2102
- if (!existsSync3(path))
2152
+ if (!existsSync2(path))
2103
2153
  return;
2104
2154
  try {
2105
2155
  const pid = Number(readFileSync(path, "utf8").trim());
@@ -2332,10 +2382,11 @@ async function startDaemon(opts) {
2332
2382
 
2333
2383
  // src/daemon/install.ts
2334
2384
  import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
2335
- import { spawnSync as spawnSync3 } from "child_process";
2385
+ import { spawnSync as spawnSync2 } from "child_process";
2336
2386
  import { dirname as dirname3 } from "path";
2337
2387
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
2338
2388
  const command = [execPath, cliEntry, ...args].join(" ");
2389
+ const pathEnv = normalizeExecutionPath(process.env);
2339
2390
  if (process.platform === "linux") {
2340
2391
  const path = systemdServicePath();
2341
2392
  mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
@@ -2348,7 +2399,7 @@ Type=simple
2348
2399
  ExecStart=${command}
2349
2400
  Restart=always
2350
2401
  RestartSec=5
2351
- Environment=PATH=${process.env.PATH ?? ""}
2402
+ Environment=PATH=${pathEnv}
2352
2403
 
2353
2404
  [Install]
2354
2405
  WantedBy=default.target
@@ -2380,6 +2431,10 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2380
2431
  </array>
2381
2432
  <key>RunAtLoad</key><true/>
2382
2433
  <key>KeepAlive</key><true/>
2434
+ <key>EnvironmentVariables</key>
2435
+ <dict>
2436
+ <key>PATH</key><string>${pathEnv}</string>
2437
+ </dict>
2383
2438
  <key>StandardOutPath</key><string>${daemonLogPath()}</string>
2384
2439
  <key>StandardErrorPath</key><string>${daemonLogPath()}</string>
2385
2440
  </dict>
@@ -2397,7 +2452,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2397
2452
  function enableStartup(result) {
2398
2453
  const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
2399
2454
  return commands.map((command) => {
2400
- const run = spawnSync3("sh", ["-c", command], {
2455
+ const run = spawnSync2("sh", ["-c", command], {
2401
2456
  encoding: "utf8",
2402
2457
  stdio: ["ignore", "pipe", "pipe"]
2403
2458
  });
@@ -2412,7 +2467,7 @@ function enableStartup(result) {
2412
2467
 
2413
2468
  // src/daemon/index.ts
2414
2469
  var program = new Command;
2415
- program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.0");
2470
+ program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.1");
2416
2471
  program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
2417
2472
  program.command("start").action(async () => {
2418
2473
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
package/dist/index.js CHANGED
@@ -1303,9 +1303,8 @@ class Store {
1303
1303
  }
1304
1304
 
1305
1305
  // src/lib/executor.ts
1306
- import { spawn, spawnSync as spawnSync2 } from "child_process";
1306
+ import { spawn } from "child_process";
1307
1307
  import { once } from "events";
1308
- import { existsSync as existsSync2 } from "fs";
1309
1308
 
1310
1309
  // src/lib/accounts.ts
1311
1310
  import { spawnSync } from "child_process";
@@ -1403,6 +1402,65 @@ function resolveAccountEnv(account, toolHint, env) {
1403
1402
  };
1404
1403
  }
1405
1404
 
1405
+ // src/lib/env.ts
1406
+ import { accessSync, constants } from "fs";
1407
+ import { homedir as homedir2 } from "os";
1408
+ import { delimiter, join as join2 } from "path";
1409
+ function compactPathParts(parts) {
1410
+ const seen = new Set;
1411
+ const result = [];
1412
+ for (const part of parts) {
1413
+ const value = part?.trim();
1414
+ if (!value || seen.has(value))
1415
+ continue;
1416
+ seen.add(value);
1417
+ result.push(value);
1418
+ }
1419
+ return result;
1420
+ }
1421
+ function commonExecutableDirs(env = process.env) {
1422
+ const home = env.HOME || homedir2();
1423
+ return compactPathParts([
1424
+ join2(home, ".local", "bin"),
1425
+ join2(home, ".bun", "bin"),
1426
+ join2(home, ".cargo", "bin"),
1427
+ join2(home, ".npm-global", "bin"),
1428
+ join2(home, "bin"),
1429
+ env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
1430
+ env.PNPM_HOME,
1431
+ env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
1432
+ "/opt/homebrew/bin",
1433
+ "/usr/local/bin",
1434
+ "/usr/bin",
1435
+ "/bin",
1436
+ "/usr/sbin",
1437
+ "/sbin"
1438
+ ]);
1439
+ }
1440
+ function normalizeExecutionPath(env = process.env) {
1441
+ return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
1442
+ }
1443
+ function isExecutable(path) {
1444
+ try {
1445
+ accessSync(path, constants.X_OK);
1446
+ return true;
1447
+ } catch {
1448
+ return false;
1449
+ }
1450
+ }
1451
+ function executableExists(command, env = process.env) {
1452
+ if (command.includes("/"))
1453
+ return isExecutable(command);
1454
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
1455
+ if (dir && isExecutable(join2(dir, command)))
1456
+ return true;
1457
+ }
1458
+ return false;
1459
+ }
1460
+ function commandNotFoundMessage(command, env = process.env) {
1461
+ return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1462
+ }
1463
+
1406
1464
  // src/lib/executor.ts
1407
1465
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1408
1466
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1488,7 +1546,7 @@ function agentArgs(target) {
1488
1546
  args.push(...target.extraArgs ?? [], target.prompt);
1489
1547
  return args;
1490
1548
  case "codewith":
1491
- args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1549
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1492
1550
  if (isolation === "safe")
1493
1551
  args.push("--ignore-rules");
1494
1552
  if (target.cwd)
@@ -1568,6 +1626,7 @@ function executionEnv(spec, metadata, opts) {
1568
1626
  Object.assign(env, accountEnv);
1569
1627
  }
1570
1628
  Object.assign(env, spec.env ?? {});
1629
+ env.PATH = normalizeExecutionPath(env);
1571
1630
  if (metadata.loopId)
1572
1631
  env.LOOPS_LOOP_ID = metadata.loopId;
1573
1632
  if (metadata.loopName)
@@ -1586,20 +1645,11 @@ function executionEnv(spec, metadata, opts) {
1586
1645
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1587
1646
  return env;
1588
1647
  }
1589
- function commandExists(command, env) {
1590
- if (command.includes("/") && existsSync2(command))
1591
- return true;
1592
- const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
1593
- env,
1594
- stdio: "ignore"
1595
- });
1596
- return (result.status ?? 1) === 0;
1597
- }
1598
1648
  function preflightTarget(target, metadata = {}, opts = {}) {
1599
1649
  const spec = commandSpec(target);
1600
1650
  const env = executionEnv(spec, metadata, opts);
1601
- if (!spec.shell && !commandExists(spec.command, env)) {
1602
- throw new Error(`Executable not found in PATH: ${spec.command}`);
1651
+ if (!spec.shell && !executableExists(spec.command, env)) {
1652
+ throw new Error(commandNotFoundMessage(spec.command, env));
1603
1653
  }
1604
1654
  return {
1605
1655
  command: spec.command,
@@ -1617,12 +1667,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1617
1667
  let exitCode;
1618
1668
  let error;
1619
1669
  const env = executionEnv(spec, metadata, opts);
1620
- if (!spec.shell && !commandExists(spec.command, env)) {
1670
+ if (!spec.shell && !executableExists(spec.command, env)) {
1621
1671
  return {
1622
1672
  status: "failed",
1623
1673
  stdout: "",
1624
1674
  stderr: "",
1625
- error: `Executable not found in PATH: ${spec.command}`,
1675
+ error: commandNotFoundMessage(spec.command, env),
1626
1676
  startedAt,
1627
1677
  finishedAt: nowIso(),
1628
1678
  durationMs: 0
@@ -2123,11 +2173,11 @@ function loops(opts = {}) {
2123
2173
  return new LoopsClient(opts);
2124
2174
  }
2125
2175
  // src/lib/doctor.ts
2126
- import { spawnSync as spawnSync3 } from "child_process";
2127
- import { accessSync, constants } from "fs";
2176
+ import { spawnSync as spawnSync2 } from "child_process";
2177
+ import { accessSync as accessSync2, constants as constants2 } from "fs";
2128
2178
 
2129
2179
  // src/daemon/control.ts
2130
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2180
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2131
2181
  import { hostname } from "os";
2132
2182
  import { dirname as dirname2 } from "path";
2133
2183
 
@@ -2155,7 +2205,7 @@ async function runLoop(opts) {
2155
2205
 
2156
2206
  // src/daemon/control.ts
2157
2207
  function readPid(path = pidFilePath()) {
2158
- if (!existsSync3(path))
2208
+ if (!existsSync2(path))
2159
2209
  return;
2160
2210
  try {
2161
2211
  const pid = Number(readFileSync(path, "utf8").trim());
@@ -2261,11 +2311,11 @@ var PROVIDER_COMMANDS = [
2261
2311
  "codex"
2262
2312
  ];
2263
2313
  function hasCommand(command) {
2264
- const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
2314
+ const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
2265
2315
  return (result.status ?? 1) === 0;
2266
2316
  }
2267
2317
  function commandVersion(command) {
2268
- const result = spawnSync3(command, ["--version"], {
2318
+ const result = spawnSync2(command, ["--version"], {
2269
2319
  encoding: "utf8",
2270
2320
  stdio: ["ignore", "pipe", "pipe"]
2271
2321
  });
@@ -2277,7 +2327,7 @@ function runDoctor(store) {
2277
2327
  const checks = [];
2278
2328
  try {
2279
2329
  const dir = ensureDataDir();
2280
- accessSync(dir, constants.R_OK | constants.W_OK);
2330
+ accessSync2(dir, constants2.R_OK | constants2.W_OK);
2281
2331
  checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
2282
2332
  } catch (error) {
2283
2333
  checks.push({
@@ -0,0 +1,4 @@
1
+ export declare function commonExecutableDirs(env?: NodeJS.ProcessEnv): string[];
2
+ export declare function normalizeExecutionPath(env?: NodeJS.ProcessEnv): string;
3
+ export declare function executableExists(command: string, env?: NodeJS.ProcessEnv): boolean;
4
+ export declare function commandNotFoundMessage(command: string, env?: NodeJS.ProcessEnv): string;
@@ -1,5 +1,8 @@
1
1
  import type { Loop, LoopRun, WorkflowEvent, WorkflowRun, WorkflowSpec, WorkflowStepRun } from "../types.js";
2
2
  export declare function redact(value: string | undefined, visible?: number): string | undefined;
3
+ export declare function textOutputBlocks(value: Pick<LoopRun | WorkflowStepRun, "stdout" | "stderr">, opts?: {
4
+ indent?: string;
5
+ }): string[];
3
6
  export declare function publicLoop(loop: Loop): Record<string, unknown>;
4
7
  export declare function publicRun(run: LoopRun, showOutput?: boolean): Record<string, unknown>;
5
8
  export declare function publicWorkflow(workflow: WorkflowSpec): Record<string, unknown>;
package/dist/sdk/index.js CHANGED
@@ -1303,9 +1303,8 @@ class Store {
1303
1303
  }
1304
1304
 
1305
1305
  // src/lib/executor.ts
1306
- import { spawn, spawnSync as spawnSync2 } from "child_process";
1306
+ import { spawn } from "child_process";
1307
1307
  import { once } from "events";
1308
- import { existsSync as existsSync2 } from "fs";
1309
1308
 
1310
1309
  // src/lib/accounts.ts
1311
1310
  import { spawnSync } from "child_process";
@@ -1403,6 +1402,65 @@ function resolveAccountEnv(account, toolHint, env) {
1403
1402
  };
1404
1403
  }
1405
1404
 
1405
+ // src/lib/env.ts
1406
+ import { accessSync, constants } from "fs";
1407
+ import { homedir as homedir2 } from "os";
1408
+ import { delimiter, join as join2 } from "path";
1409
+ function compactPathParts(parts) {
1410
+ const seen = new Set;
1411
+ const result = [];
1412
+ for (const part of parts) {
1413
+ const value = part?.trim();
1414
+ if (!value || seen.has(value))
1415
+ continue;
1416
+ seen.add(value);
1417
+ result.push(value);
1418
+ }
1419
+ return result;
1420
+ }
1421
+ function commonExecutableDirs(env = process.env) {
1422
+ const home = env.HOME || homedir2();
1423
+ return compactPathParts([
1424
+ join2(home, ".local", "bin"),
1425
+ join2(home, ".bun", "bin"),
1426
+ join2(home, ".cargo", "bin"),
1427
+ join2(home, ".npm-global", "bin"),
1428
+ join2(home, "bin"),
1429
+ env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
1430
+ env.PNPM_HOME,
1431
+ env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
1432
+ "/opt/homebrew/bin",
1433
+ "/usr/local/bin",
1434
+ "/usr/bin",
1435
+ "/bin",
1436
+ "/usr/sbin",
1437
+ "/sbin"
1438
+ ]);
1439
+ }
1440
+ function normalizeExecutionPath(env = process.env) {
1441
+ return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
1442
+ }
1443
+ function isExecutable(path) {
1444
+ try {
1445
+ accessSync(path, constants.X_OK);
1446
+ return true;
1447
+ } catch {
1448
+ return false;
1449
+ }
1450
+ }
1451
+ function executableExists(command, env = process.env) {
1452
+ if (command.includes("/"))
1453
+ return isExecutable(command);
1454
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
1455
+ if (dir && isExecutable(join2(dir, command)))
1456
+ return true;
1457
+ }
1458
+ return false;
1459
+ }
1460
+ function commandNotFoundMessage(command, env = process.env) {
1461
+ return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1462
+ }
1463
+
1406
1464
  // src/lib/executor.ts
1407
1465
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1408
1466
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1488,7 +1546,7 @@ function agentArgs(target) {
1488
1546
  args.push(...target.extraArgs ?? [], target.prompt);
1489
1547
  return args;
1490
1548
  case "codewith":
1491
- args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1549
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1492
1550
  if (isolation === "safe")
1493
1551
  args.push("--ignore-rules");
1494
1552
  if (target.cwd)
@@ -1568,6 +1626,7 @@ function executionEnv(spec, metadata, opts) {
1568
1626
  Object.assign(env, accountEnv);
1569
1627
  }
1570
1628
  Object.assign(env, spec.env ?? {});
1629
+ env.PATH = normalizeExecutionPath(env);
1571
1630
  if (metadata.loopId)
1572
1631
  env.LOOPS_LOOP_ID = metadata.loopId;
1573
1632
  if (metadata.loopName)
@@ -1586,20 +1645,11 @@ function executionEnv(spec, metadata, opts) {
1586
1645
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1587
1646
  return env;
1588
1647
  }
1589
- function commandExists(command, env) {
1590
- if (command.includes("/") && existsSync2(command))
1591
- return true;
1592
- const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
1593
- env,
1594
- stdio: "ignore"
1595
- });
1596
- return (result.status ?? 1) === 0;
1597
- }
1598
1648
  function preflightTarget(target, metadata = {}, opts = {}) {
1599
1649
  const spec = commandSpec(target);
1600
1650
  const env = executionEnv(spec, metadata, opts);
1601
- if (!spec.shell && !commandExists(spec.command, env)) {
1602
- throw new Error(`Executable not found in PATH: ${spec.command}`);
1651
+ if (!spec.shell && !executableExists(spec.command, env)) {
1652
+ throw new Error(commandNotFoundMessage(spec.command, env));
1603
1653
  }
1604
1654
  return {
1605
1655
  command: spec.command,
@@ -1617,12 +1667,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1617
1667
  let exitCode;
1618
1668
  let error;
1619
1669
  const env = executionEnv(spec, metadata, opts);
1620
- if (!spec.shell && !commandExists(spec.command, env)) {
1670
+ if (!spec.shell && !executableExists(spec.command, env)) {
1621
1671
  return {
1622
1672
  status: "failed",
1623
1673
  stdout: "",
1624
1674
  stderr: "",
1625
- error: `Executable not found in PATH: ${spec.command}`,
1675
+ error: commandNotFoundMessage(spec.command, env),
1626
1676
  startedAt,
1627
1677
  finishedAt: nowIso(),
1628
1678
  durationMs: 0
package/docs/USAGE.md CHANGED
@@ -203,10 +203,11 @@ On Linux this writes a user systemd service. On macOS it writes a LaunchAgent pl
203
203
  The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:
204
204
 
205
205
  - Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
206
- - Codewith uses `codewith exec --json --ephemeral --ask-for-approval never`.
206
+ - Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`.
207
207
  - AI Copilot and OpenCode use `run --format json --pure`.
208
208
  - Cursor is CLI-first for now via `cursor-agent -p`; treat output as less stable until a stronger public SDK contract is selected.
209
209
  - Codex uses `codex exec --json --ephemeral --ask-for-approval never`.
210
210
  - When `--account` or a step `account` is set, OpenLoops resolves `accounts env <profile> --tool <tool>` before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt.
211
+ - Daemon and scheduled runs prepend common user executable directories such as `~/.local/bin` and `~/.bun/bin` before resolving provider CLIs.
211
212
 
212
213
  For production loops that can mutate repos, prefer disposable worktrees and explicit prompts that name allowed write scope.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",