@hasna/loops 0.3.0 → 0.3.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.
- package/README.md +15 -2
- package/dist/cli/index.js +134 -30
- package/dist/daemon/index.js +82 -22
- package/dist/index.js +78 -23
- package/dist/lib/env.d.ts +4 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/store.js +5 -0
- package/dist/sdk/index.js +71 -16
- package/dist/types.d.ts +1 -0
- package/docs/USAGE.md +15 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,17 @@ loops create agent supply-chain-watch \
|
|
|
76
76
|
--prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
Run a Codewith loop with a Codewith-native auth profile:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
loops create agent supply-chain-watch \
|
|
83
|
+
--provider codewith \
|
|
84
|
+
--auth-profile account001 \
|
|
85
|
+
--every 15m \
|
|
86
|
+
--cwd /path/to/repo \
|
|
87
|
+
--prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
|
|
88
|
+
```
|
|
89
|
+
|
|
79
90
|
For `codewith` and `aicopilot` account isolation, register matching OpenAccounts tools first if they are not built in on the machine:
|
|
80
91
|
|
|
81
92
|
```bash
|
|
@@ -157,7 +168,7 @@ loops remove <id-or-name>
|
|
|
157
168
|
loops run-now <id-or-name>
|
|
158
169
|
```
|
|
159
170
|
|
|
160
|
-
Use `--json` for machine-readable output. Prompt bodies and run stdout/stderr are redacted by default in status output.
|
|
171
|
+
Use `--json` for machine-readable output. Prompt bodies and run stdout/stderr are redacted by default in status output. `loops run-now` exits non-zero when the recorded run fails or times out.
|
|
161
172
|
|
|
162
173
|
## Daemon
|
|
163
174
|
|
|
@@ -203,10 +214,12 @@ On Linux this writes a user systemd service. On macOS it writes a LaunchAgent pl
|
|
|
203
214
|
The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:
|
|
204
215
|
|
|
205
216
|
- Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
|
|
206
|
-
- Codewith uses `codewith exec --json --ephemeral --
|
|
217
|
+
- Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`.
|
|
207
218
|
- AI Copilot and OpenCode use `run --format json --pure`.
|
|
208
219
|
- Cursor is CLI-first for now via `cursor-agent -p`; treat output as less stable until a stronger public SDK contract is selected.
|
|
209
220
|
- Codex uses `codex exec --json --ephemeral --ask-for-approval never`.
|
|
210
221
|
- 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.
|
|
222
|
+
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` before `exec`; they do not call OpenAccounts.
|
|
223
|
+
- Daemon and scheduled runs prepend common user executable directories such as `~/.local/bin` and `~/.bun/bin` before resolving provider CLIs.
|
|
211
224
|
|
|
212
225
|
For production loops that can mutate repos, prefer disposable worktrees and explicit prompts that name allowed write scope.
|
package/dist/cli/index.js
CHANGED
|
@@ -260,6 +260,11 @@ function validateTarget(value, label) {
|
|
|
260
260
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
261
261
|
if (!providers.includes(value.provider))
|
|
262
262
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
263
|
+
if (value.authProfile !== undefined) {
|
|
264
|
+
assertString(value.authProfile, `${label}.authProfile`);
|
|
265
|
+
if (value.provider !== "codewith")
|
|
266
|
+
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
267
|
+
}
|
|
263
268
|
return value;
|
|
264
269
|
}
|
|
265
270
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -1305,10 +1310,11 @@ class Store {
|
|
|
1305
1310
|
}
|
|
1306
1311
|
|
|
1307
1312
|
// src/cli/index.ts
|
|
1308
|
-
import { existsSync as
|
|
1313
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
1309
1314
|
import { Command } from "commander";
|
|
1310
1315
|
|
|
1311
1316
|
// src/lib/format.ts
|
|
1317
|
+
var TEXT_OUTPUT_LIMIT = 32 * 1024;
|
|
1312
1318
|
function redact(value, visible = 80) {
|
|
1313
1319
|
if (!value)
|
|
1314
1320
|
return value;
|
|
@@ -1316,6 +1322,29 @@ function redact(value, visible = 80) {
|
|
|
1316
1322
|
return value;
|
|
1317
1323
|
return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
|
|
1318
1324
|
}
|
|
1325
|
+
function truncateTextOutput(value) {
|
|
1326
|
+
if (value.length <= TEXT_OUTPUT_LIMIT)
|
|
1327
|
+
return value;
|
|
1328
|
+
return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
|
|
1329
|
+
[truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
|
|
1330
|
+
}
|
|
1331
|
+
function textOutputBlocks(value, opts = {}) {
|
|
1332
|
+
const indent = opts.indent ?? "";
|
|
1333
|
+
const nested = `${indent} `;
|
|
1334
|
+
const blocks = [];
|
|
1335
|
+
for (const [label, output] of [
|
|
1336
|
+
["stdout", value.stdout],
|
|
1337
|
+
["stderr", value.stderr]
|
|
1338
|
+
]) {
|
|
1339
|
+
if (!output)
|
|
1340
|
+
continue;
|
|
1341
|
+
blocks.push(`${indent}${label}:`);
|
|
1342
|
+
for (const line of truncateTextOutput(output).replace(/\s+$/, "").split(/\r?\n/)) {
|
|
1343
|
+
blocks.push(`${nested}${line}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return blocks;
|
|
1347
|
+
}
|
|
1319
1348
|
function publicLoop(loop) {
|
|
1320
1349
|
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
1350
|
return {
|
|
@@ -1354,9 +1383,8 @@ function publicWorkflowEvent(event) {
|
|
|
1354
1383
|
}
|
|
1355
1384
|
|
|
1356
1385
|
// src/lib/executor.ts
|
|
1357
|
-
import { spawn
|
|
1386
|
+
import { spawn } from "child_process";
|
|
1358
1387
|
import { once } from "events";
|
|
1359
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1360
1388
|
|
|
1361
1389
|
// src/lib/accounts.ts
|
|
1362
1390
|
import { spawnSync } from "child_process";
|
|
@@ -1454,6 +1482,65 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1454
1482
|
};
|
|
1455
1483
|
}
|
|
1456
1484
|
|
|
1485
|
+
// src/lib/env.ts
|
|
1486
|
+
import { accessSync, constants } from "fs";
|
|
1487
|
+
import { homedir as homedir2 } from "os";
|
|
1488
|
+
import { delimiter, join as join2 } from "path";
|
|
1489
|
+
function compactPathParts(parts) {
|
|
1490
|
+
const seen = new Set;
|
|
1491
|
+
const result = [];
|
|
1492
|
+
for (const part of parts) {
|
|
1493
|
+
const value = part?.trim();
|
|
1494
|
+
if (!value || seen.has(value))
|
|
1495
|
+
continue;
|
|
1496
|
+
seen.add(value);
|
|
1497
|
+
result.push(value);
|
|
1498
|
+
}
|
|
1499
|
+
return result;
|
|
1500
|
+
}
|
|
1501
|
+
function commonExecutableDirs(env = process.env) {
|
|
1502
|
+
const home = env.HOME || homedir2();
|
|
1503
|
+
return compactPathParts([
|
|
1504
|
+
join2(home, ".local", "bin"),
|
|
1505
|
+
join2(home, ".bun", "bin"),
|
|
1506
|
+
join2(home, ".cargo", "bin"),
|
|
1507
|
+
join2(home, ".npm-global", "bin"),
|
|
1508
|
+
join2(home, "bin"),
|
|
1509
|
+
env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
|
|
1510
|
+
env.PNPM_HOME,
|
|
1511
|
+
env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
1512
|
+
"/opt/homebrew/bin",
|
|
1513
|
+
"/usr/local/bin",
|
|
1514
|
+
"/usr/bin",
|
|
1515
|
+
"/bin",
|
|
1516
|
+
"/usr/sbin",
|
|
1517
|
+
"/sbin"
|
|
1518
|
+
]);
|
|
1519
|
+
}
|
|
1520
|
+
function normalizeExecutionPath(env = process.env) {
|
|
1521
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
1522
|
+
}
|
|
1523
|
+
function isExecutable(path) {
|
|
1524
|
+
try {
|
|
1525
|
+
accessSync(path, constants.X_OK);
|
|
1526
|
+
return true;
|
|
1527
|
+
} catch {
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
function executableExists(command, env = process.env) {
|
|
1532
|
+
if (command.includes("/"))
|
|
1533
|
+
return isExecutable(command);
|
|
1534
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
1535
|
+
if (dir && isExecutable(join2(dir, command)))
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
return false;
|
|
1539
|
+
}
|
|
1540
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
1541
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1457
1544
|
// src/lib/executor.ts
|
|
1458
1545
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1459
1546
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1539,7 +1626,7 @@ function agentArgs(target) {
|
|
|
1539
1626
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1540
1627
|
return args;
|
|
1541
1628
|
case "codewith":
|
|
1542
|
-
args.push(
|
|
1629
|
+
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
1543
1630
|
if (isolation === "safe")
|
|
1544
1631
|
args.push("--ignore-rules");
|
|
1545
1632
|
if (target.cwd)
|
|
@@ -1619,6 +1706,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1619
1706
|
Object.assign(env, accountEnv);
|
|
1620
1707
|
}
|
|
1621
1708
|
Object.assign(env, spec.env ?? {});
|
|
1709
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1622
1710
|
if (metadata.loopId)
|
|
1623
1711
|
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1624
1712
|
if (metadata.loopName)
|
|
@@ -1637,20 +1725,11 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1637
1725
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1638
1726
|
return env;
|
|
1639
1727
|
}
|
|
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
1728
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1650
1729
|
const spec = commandSpec(target);
|
|
1651
1730
|
const env = executionEnv(spec, metadata, opts);
|
|
1652
|
-
if (!spec.shell && !
|
|
1653
|
-
throw new Error(
|
|
1731
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1732
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1654
1733
|
}
|
|
1655
1734
|
return {
|
|
1656
1735
|
command: spec.command,
|
|
@@ -1668,12 +1747,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1668
1747
|
let exitCode;
|
|
1669
1748
|
let error;
|
|
1670
1749
|
const env = executionEnv(spec, metadata, opts);
|
|
1671
|
-
if (!spec.shell && !
|
|
1750
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1672
1751
|
return {
|
|
1673
1752
|
status: "failed",
|
|
1674
1753
|
stdout: "",
|
|
1675
1754
|
stderr: "",
|
|
1676
|
-
error:
|
|
1755
|
+
error: commandNotFoundMessage(spec.command, env),
|
|
1677
1756
|
startedAt,
|
|
1678
1757
|
finishedAt: nowIso(),
|
|
1679
1758
|
durationMs: 0
|
|
@@ -2112,7 +2191,7 @@ async function tick(deps) {
|
|
|
2112
2191
|
}
|
|
2113
2192
|
|
|
2114
2193
|
// src/daemon/control.ts
|
|
2115
|
-
import { existsSync as
|
|
2194
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2116
2195
|
import { hostname } from "os";
|
|
2117
2196
|
import { dirname as dirname2 } from "path";
|
|
2118
2197
|
|
|
@@ -2140,7 +2219,7 @@ async function runLoop(opts) {
|
|
|
2140
2219
|
|
|
2141
2220
|
// src/daemon/control.ts
|
|
2142
2221
|
function readPid(path = pidFilePath()) {
|
|
2143
|
-
if (!
|
|
2222
|
+
if (!existsSync2(path))
|
|
2144
2223
|
return;
|
|
2145
2224
|
try {
|
|
2146
2225
|
const pid = Number(readFileSync(path, "utf8").trim());
|
|
@@ -2376,10 +2455,11 @@ async function startDaemon(opts) {
|
|
|
2376
2455
|
|
|
2377
2456
|
// src/daemon/install.ts
|
|
2378
2457
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2379
|
-
import { spawnSync as
|
|
2458
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2380
2459
|
import { dirname as dirname3 } from "path";
|
|
2381
2460
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2382
2461
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
2462
|
+
const pathEnv = normalizeExecutionPath(process.env);
|
|
2383
2463
|
if (process.platform === "linux") {
|
|
2384
2464
|
const path = systemdServicePath();
|
|
2385
2465
|
mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
|
|
@@ -2392,7 +2472,7 @@ Type=simple
|
|
|
2392
2472
|
ExecStart=${command}
|
|
2393
2473
|
Restart=always
|
|
2394
2474
|
RestartSec=5
|
|
2395
|
-
Environment=PATH=${
|
|
2475
|
+
Environment=PATH=${pathEnv}
|
|
2396
2476
|
|
|
2397
2477
|
[Install]
|
|
2398
2478
|
WantedBy=default.target
|
|
@@ -2424,6 +2504,10 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2424
2504
|
</array>
|
|
2425
2505
|
<key>RunAtLoad</key><true/>
|
|
2426
2506
|
<key>KeepAlive</key><true/>
|
|
2507
|
+
<key>EnvironmentVariables</key>
|
|
2508
|
+
<dict>
|
|
2509
|
+
<key>PATH</key><string>${pathEnv}</string>
|
|
2510
|
+
</dict>
|
|
2427
2511
|
<key>StandardOutPath</key><string>${daemonLogPath()}</string>
|
|
2428
2512
|
<key>StandardErrorPath</key><string>${daemonLogPath()}</string>
|
|
2429
2513
|
</dict>
|
|
@@ -2441,7 +2525,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2441
2525
|
function enableStartup(result) {
|
|
2442
2526
|
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
2527
|
return commands.map((command) => {
|
|
2444
|
-
const run =
|
|
2528
|
+
const run = spawnSync2("sh", ["-c", command], {
|
|
2445
2529
|
encoding: "utf8",
|
|
2446
2530
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2447
2531
|
});
|
|
@@ -2455,8 +2539,8 @@ function enableStartup(result) {
|
|
|
2455
2539
|
}
|
|
2456
2540
|
|
|
2457
2541
|
// src/lib/doctor.ts
|
|
2458
|
-
import { spawnSync as
|
|
2459
|
-
import { accessSync, constants } from "fs";
|
|
2542
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2543
|
+
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
2460
2544
|
var PROVIDER_COMMANDS = [
|
|
2461
2545
|
"claude",
|
|
2462
2546
|
"cursor-agent",
|
|
@@ -2466,11 +2550,11 @@ var PROVIDER_COMMANDS = [
|
|
|
2466
2550
|
"codex"
|
|
2467
2551
|
];
|
|
2468
2552
|
function hasCommand(command) {
|
|
2469
|
-
const result =
|
|
2553
|
+
const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2470
2554
|
return (result.status ?? 1) === 0;
|
|
2471
2555
|
}
|
|
2472
2556
|
function commandVersion(command) {
|
|
2473
|
-
const result =
|
|
2557
|
+
const result = spawnSync3(command, ["--version"], {
|
|
2474
2558
|
encoding: "utf8",
|
|
2475
2559
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2476
2560
|
});
|
|
@@ -2482,7 +2566,7 @@ function runDoctor(store) {
|
|
|
2482
2566
|
const checks = [];
|
|
2483
2567
|
try {
|
|
2484
2568
|
const dir = ensureDataDir();
|
|
2485
|
-
|
|
2569
|
+
accessSync2(dir, constants2.R_OK | constants2.W_OK);
|
|
2486
2570
|
checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
|
|
2487
2571
|
} catch (error) {
|
|
2488
2572
|
checks.push({
|
|
@@ -2531,7 +2615,7 @@ function runDoctor(store) {
|
|
|
2531
2615
|
|
|
2532
2616
|
// src/cli/index.ts
|
|
2533
2617
|
var program = new Command;
|
|
2534
|
-
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.
|
|
2618
|
+
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.2");
|
|
2535
2619
|
program.option("-j, --json", "print JSON");
|
|
2536
2620
|
function isJson() {
|
|
2537
2621
|
return Boolean(program.opts().json);
|
|
@@ -2542,6 +2626,10 @@ function print(value, human) {
|
|
|
2542
2626
|
else
|
|
2543
2627
|
console.log(human);
|
|
2544
2628
|
}
|
|
2629
|
+
function printTextOutput(value) {
|
|
2630
|
+
for (const line of textOutputBlocks(value, { indent: " " }))
|
|
2631
|
+
console.log(line);
|
|
2632
|
+
}
|
|
2545
2633
|
function parseSchedule(opts) {
|
|
2546
2634
|
const count = [opts.at, opts.every, opts.cron, opts.dynamic ? "dynamic" : undefined].filter(Boolean).length;
|
|
2547
2635
|
if (count !== 1)
|
|
@@ -2621,6 +2709,13 @@ function accountFromOpts(opts) {
|
|
|
2621
2709
|
throw new Error("--account-tool requires --account");
|
|
2622
2710
|
return opts.account ? { profile: opts.account, tool: opts.accountTool } : undefined;
|
|
2623
2711
|
}
|
|
2712
|
+
function providerAuthProfileFromOpts(opts, provider) {
|
|
2713
|
+
if (!opts.authProfile)
|
|
2714
|
+
return;
|
|
2715
|
+
if (provider !== "codewith")
|
|
2716
|
+
throw new Error("--auth-profile is currently supported only for --provider codewith");
|
|
2717
|
+
return opts.authProfile;
|
|
2718
|
+
}
|
|
2624
2719
|
var create = program.command("create").description("create loops");
|
|
2625
2720
|
addAccountOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))).action((name, opts) => {
|
|
2626
2721
|
const store = new Store;
|
|
@@ -2639,7 +2734,7 @@ addAccountOptions(addScheduleOptions(create.command("command <name>").descriptio
|
|
|
2639
2734
|
store.close();
|
|
2640
2735
|
}
|
|
2641
2736
|
});
|
|
2642
|
-
addAccountOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
|
|
2737
|
+
addAccountOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
|
|
2643
2738
|
const provider = opts.provider;
|
|
2644
2739
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
|
|
2645
2740
|
throw new Error("unsupported provider");
|
|
@@ -2656,6 +2751,7 @@ addAccountOptions(addScheduleOptions(create.command("agent <name>").description(
|
|
|
2656
2751
|
cwd: opts.cwd,
|
|
2657
2752
|
model: opts.model,
|
|
2658
2753
|
agent: opts.agent,
|
|
2754
|
+
authProfile: providerAuthProfileFromOpts(opts, provider),
|
|
2659
2755
|
timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
|
|
2660
2756
|
configIsolation: opts.configIsolation,
|
|
2661
2757
|
account: accountFromOpts(opts)
|
|
@@ -2772,6 +2868,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
|
|
|
2772
2868
|
console.log(`${run?.id ?? workflow.id} ${result.status}`);
|
|
2773
2869
|
for (const step of steps) {
|
|
2774
2870
|
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
|
|
2871
|
+
if (opts.showOutput)
|
|
2872
|
+
printTextOutput(step);
|
|
2775
2873
|
}
|
|
2776
2874
|
}
|
|
2777
2875
|
} finally {
|
|
@@ -2872,6 +2970,8 @@ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("
|
|
|
2872
2970
|
else {
|
|
2873
2971
|
for (const run of runs) {
|
|
2874
2972
|
console.log(`${run.id} ${run.status.padEnd(10)} attempt=${run.attempt} slot=${run.scheduledFor} ${run.loopName}`);
|
|
2973
|
+
if (opts.showOutput)
|
|
2974
|
+
printTextOutput(run);
|
|
2875
2975
|
}
|
|
2876
2976
|
}
|
|
2877
2977
|
} finally {
|
|
@@ -2916,6 +3016,10 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
|
|
|
2916
3016
|
advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
2917
3017
|
}
|
|
2918
3018
|
print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
|
|
3019
|
+
if (!isJson() && opts.showOutput)
|
|
3020
|
+
printTextOutput(run);
|
|
3021
|
+
if (run.status !== "succeeded")
|
|
3022
|
+
process.exitCode = 1;
|
|
2919
3023
|
} finally {
|
|
2920
3024
|
store.close();
|
|
2921
3025
|
}
|
|
@@ -2978,7 +3082,7 @@ ${result.instructions.join(`
|
|
|
2978
3082
|
});
|
|
2979
3083
|
daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
|
|
2980
3084
|
const path = daemonLogPath();
|
|
2981
|
-
if (!
|
|
3085
|
+
if (!existsSync3(path)) {
|
|
2982
3086
|
console.log("");
|
|
2983
3087
|
return;
|
|
2984
3088
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -260,6 +260,11 @@ function validateTarget(value, label) {
|
|
|
260
260
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
261
261
|
if (!providers.includes(value.provider))
|
|
262
262
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
263
|
+
if (value.authProfile !== undefined) {
|
|
264
|
+
assertString(value.authProfile, `${label}.authProfile`);
|
|
265
|
+
if (value.provider !== "codewith")
|
|
266
|
+
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
267
|
+
}
|
|
263
268
|
return value;
|
|
264
269
|
}
|
|
265
270
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -1313,9 +1318,8 @@ import { hostname as hostname2 } from "os";
|
|
|
1313
1318
|
import { spawn as spawn2 } from "child_process";
|
|
1314
1319
|
|
|
1315
1320
|
// src/lib/executor.ts
|
|
1316
|
-
import { spawn
|
|
1321
|
+
import { spawn } from "child_process";
|
|
1317
1322
|
import { once } from "events";
|
|
1318
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1319
1323
|
|
|
1320
1324
|
// src/lib/accounts.ts
|
|
1321
1325
|
import { spawnSync } from "child_process";
|
|
@@ -1413,6 +1417,65 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1413
1417
|
};
|
|
1414
1418
|
}
|
|
1415
1419
|
|
|
1420
|
+
// src/lib/env.ts
|
|
1421
|
+
import { accessSync, constants } from "fs";
|
|
1422
|
+
import { homedir as homedir2 } from "os";
|
|
1423
|
+
import { delimiter, join as join2 } from "path";
|
|
1424
|
+
function compactPathParts(parts) {
|
|
1425
|
+
const seen = new Set;
|
|
1426
|
+
const result = [];
|
|
1427
|
+
for (const part of parts) {
|
|
1428
|
+
const value = part?.trim();
|
|
1429
|
+
if (!value || seen.has(value))
|
|
1430
|
+
continue;
|
|
1431
|
+
seen.add(value);
|
|
1432
|
+
result.push(value);
|
|
1433
|
+
}
|
|
1434
|
+
return result;
|
|
1435
|
+
}
|
|
1436
|
+
function commonExecutableDirs(env = process.env) {
|
|
1437
|
+
const home = env.HOME || homedir2();
|
|
1438
|
+
return compactPathParts([
|
|
1439
|
+
join2(home, ".local", "bin"),
|
|
1440
|
+
join2(home, ".bun", "bin"),
|
|
1441
|
+
join2(home, ".cargo", "bin"),
|
|
1442
|
+
join2(home, ".npm-global", "bin"),
|
|
1443
|
+
join2(home, "bin"),
|
|
1444
|
+
env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
|
|
1445
|
+
env.PNPM_HOME,
|
|
1446
|
+
env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
1447
|
+
"/opt/homebrew/bin",
|
|
1448
|
+
"/usr/local/bin",
|
|
1449
|
+
"/usr/bin",
|
|
1450
|
+
"/bin",
|
|
1451
|
+
"/usr/sbin",
|
|
1452
|
+
"/sbin"
|
|
1453
|
+
]);
|
|
1454
|
+
}
|
|
1455
|
+
function normalizeExecutionPath(env = process.env) {
|
|
1456
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
1457
|
+
}
|
|
1458
|
+
function isExecutable(path) {
|
|
1459
|
+
try {
|
|
1460
|
+
accessSync(path, constants.X_OK);
|
|
1461
|
+
return true;
|
|
1462
|
+
} catch {
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function executableExists(command, env = process.env) {
|
|
1467
|
+
if (command.includes("/"))
|
|
1468
|
+
return isExecutable(command);
|
|
1469
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
1470
|
+
if (dir && isExecutable(join2(dir, command)))
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
1476
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1416
1479
|
// src/lib/executor.ts
|
|
1417
1480
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1418
1481
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1498,7 +1561,7 @@ function agentArgs(target) {
|
|
|
1498
1561
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1499
1562
|
return args;
|
|
1500
1563
|
case "codewith":
|
|
1501
|
-
args.push(
|
|
1564
|
+
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
1502
1565
|
if (isolation === "safe")
|
|
1503
1566
|
args.push("--ignore-rules");
|
|
1504
1567
|
if (target.cwd)
|
|
@@ -1578,6 +1641,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1578
1641
|
Object.assign(env, accountEnv);
|
|
1579
1642
|
}
|
|
1580
1643
|
Object.assign(env, spec.env ?? {});
|
|
1644
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1581
1645
|
if (metadata.loopId)
|
|
1582
1646
|
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1583
1647
|
if (metadata.loopName)
|
|
@@ -1596,20 +1660,11 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1596
1660
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1597
1661
|
return env;
|
|
1598
1662
|
}
|
|
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
1663
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1609
1664
|
const spec = commandSpec(target);
|
|
1610
1665
|
const env = executionEnv(spec, metadata, opts);
|
|
1611
|
-
if (!spec.shell && !
|
|
1612
|
-
throw new Error(
|
|
1666
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1667
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1613
1668
|
}
|
|
1614
1669
|
return {
|
|
1615
1670
|
command: spec.command,
|
|
@@ -1627,12 +1682,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1627
1682
|
let exitCode;
|
|
1628
1683
|
let error;
|
|
1629
1684
|
const env = executionEnv(spec, metadata, opts);
|
|
1630
|
-
if (!spec.shell && !
|
|
1685
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1631
1686
|
return {
|
|
1632
1687
|
status: "failed",
|
|
1633
1688
|
stdout: "",
|
|
1634
1689
|
stderr: "",
|
|
1635
|
-
error:
|
|
1690
|
+
error: commandNotFoundMessage(spec.command, env),
|
|
1636
1691
|
startedAt,
|
|
1637
1692
|
finishedAt: nowIso(),
|
|
1638
1693
|
durationMs: 0
|
|
@@ -2071,7 +2126,7 @@ async function tick(deps) {
|
|
|
2071
2126
|
}
|
|
2072
2127
|
|
|
2073
2128
|
// src/daemon/control.ts
|
|
2074
|
-
import { existsSync as
|
|
2129
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2075
2130
|
import { hostname } from "os";
|
|
2076
2131
|
import { dirname as dirname2 } from "path";
|
|
2077
2132
|
|
|
@@ -2099,7 +2154,7 @@ async function runLoop(opts) {
|
|
|
2099
2154
|
|
|
2100
2155
|
// src/daemon/control.ts
|
|
2101
2156
|
function readPid(path = pidFilePath()) {
|
|
2102
|
-
if (!
|
|
2157
|
+
if (!existsSync2(path))
|
|
2103
2158
|
return;
|
|
2104
2159
|
try {
|
|
2105
2160
|
const pid = Number(readFileSync(path, "utf8").trim());
|
|
@@ -2332,10 +2387,11 @@ async function startDaemon(opts) {
|
|
|
2332
2387
|
|
|
2333
2388
|
// src/daemon/install.ts
|
|
2334
2389
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2335
|
-
import { spawnSync as
|
|
2390
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2336
2391
|
import { dirname as dirname3 } from "path";
|
|
2337
2392
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2338
2393
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
2394
|
+
const pathEnv = normalizeExecutionPath(process.env);
|
|
2339
2395
|
if (process.platform === "linux") {
|
|
2340
2396
|
const path = systemdServicePath();
|
|
2341
2397
|
mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
|
|
@@ -2348,7 +2404,7 @@ Type=simple
|
|
|
2348
2404
|
ExecStart=${command}
|
|
2349
2405
|
Restart=always
|
|
2350
2406
|
RestartSec=5
|
|
2351
|
-
Environment=PATH=${
|
|
2407
|
+
Environment=PATH=${pathEnv}
|
|
2352
2408
|
|
|
2353
2409
|
[Install]
|
|
2354
2410
|
WantedBy=default.target
|
|
@@ -2380,6 +2436,10 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2380
2436
|
</array>
|
|
2381
2437
|
<key>RunAtLoad</key><true/>
|
|
2382
2438
|
<key>KeepAlive</key><true/>
|
|
2439
|
+
<key>EnvironmentVariables</key>
|
|
2440
|
+
<dict>
|
|
2441
|
+
<key>PATH</key><string>${pathEnv}</string>
|
|
2442
|
+
</dict>
|
|
2383
2443
|
<key>StandardOutPath</key><string>${daemonLogPath()}</string>
|
|
2384
2444
|
<key>StandardErrorPath</key><string>${daemonLogPath()}</string>
|
|
2385
2445
|
</dict>
|
|
@@ -2397,7 +2457,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2397
2457
|
function enableStartup(result) {
|
|
2398
2458
|
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
2459
|
return commands.map((command) => {
|
|
2400
|
-
const run =
|
|
2460
|
+
const run = spawnSync2("sh", ["-c", command], {
|
|
2401
2461
|
encoding: "utf8",
|
|
2402
2462
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2403
2463
|
});
|
|
@@ -2412,7 +2472,7 @@ function enableStartup(result) {
|
|
|
2412
2472
|
|
|
2413
2473
|
// src/daemon/index.ts
|
|
2414
2474
|
var program = new Command;
|
|
2415
|
-
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.
|
|
2475
|
+
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.2");
|
|
2416
2476
|
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
|
|
2417
2477
|
program.command("start").action(async () => {
|
|
2418
2478
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|
package/dist/index.js
CHANGED
|
@@ -258,6 +258,11 @@ function validateTarget(value, label) {
|
|
|
258
258
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
259
259
|
if (!providers.includes(value.provider))
|
|
260
260
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
261
|
+
if (value.authProfile !== undefined) {
|
|
262
|
+
assertString(value.authProfile, `${label}.authProfile`);
|
|
263
|
+
if (value.provider !== "codewith")
|
|
264
|
+
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
265
|
+
}
|
|
261
266
|
return value;
|
|
262
267
|
}
|
|
263
268
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -1303,9 +1308,8 @@ class Store {
|
|
|
1303
1308
|
}
|
|
1304
1309
|
|
|
1305
1310
|
// src/lib/executor.ts
|
|
1306
|
-
import { spawn
|
|
1311
|
+
import { spawn } from "child_process";
|
|
1307
1312
|
import { once } from "events";
|
|
1308
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1309
1313
|
|
|
1310
1314
|
// src/lib/accounts.ts
|
|
1311
1315
|
import { spawnSync } from "child_process";
|
|
@@ -1403,6 +1407,65 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1403
1407
|
};
|
|
1404
1408
|
}
|
|
1405
1409
|
|
|
1410
|
+
// src/lib/env.ts
|
|
1411
|
+
import { accessSync, constants } from "fs";
|
|
1412
|
+
import { homedir as homedir2 } from "os";
|
|
1413
|
+
import { delimiter, join as join2 } from "path";
|
|
1414
|
+
function compactPathParts(parts) {
|
|
1415
|
+
const seen = new Set;
|
|
1416
|
+
const result = [];
|
|
1417
|
+
for (const part of parts) {
|
|
1418
|
+
const value = part?.trim();
|
|
1419
|
+
if (!value || seen.has(value))
|
|
1420
|
+
continue;
|
|
1421
|
+
seen.add(value);
|
|
1422
|
+
result.push(value);
|
|
1423
|
+
}
|
|
1424
|
+
return result;
|
|
1425
|
+
}
|
|
1426
|
+
function commonExecutableDirs(env = process.env) {
|
|
1427
|
+
const home = env.HOME || homedir2();
|
|
1428
|
+
return compactPathParts([
|
|
1429
|
+
join2(home, ".local", "bin"),
|
|
1430
|
+
join2(home, ".bun", "bin"),
|
|
1431
|
+
join2(home, ".cargo", "bin"),
|
|
1432
|
+
join2(home, ".npm-global", "bin"),
|
|
1433
|
+
join2(home, "bin"),
|
|
1434
|
+
env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
|
|
1435
|
+
env.PNPM_HOME,
|
|
1436
|
+
env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
1437
|
+
"/opt/homebrew/bin",
|
|
1438
|
+
"/usr/local/bin",
|
|
1439
|
+
"/usr/bin",
|
|
1440
|
+
"/bin",
|
|
1441
|
+
"/usr/sbin",
|
|
1442
|
+
"/sbin"
|
|
1443
|
+
]);
|
|
1444
|
+
}
|
|
1445
|
+
function normalizeExecutionPath(env = process.env) {
|
|
1446
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
1447
|
+
}
|
|
1448
|
+
function isExecutable(path) {
|
|
1449
|
+
try {
|
|
1450
|
+
accessSync(path, constants.X_OK);
|
|
1451
|
+
return true;
|
|
1452
|
+
} catch {
|
|
1453
|
+
return false;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function executableExists(command, env = process.env) {
|
|
1457
|
+
if (command.includes("/"))
|
|
1458
|
+
return isExecutable(command);
|
|
1459
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
1460
|
+
if (dir && isExecutable(join2(dir, command)))
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
1466
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1406
1469
|
// src/lib/executor.ts
|
|
1407
1470
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1408
1471
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1488,7 +1551,7 @@ function agentArgs(target) {
|
|
|
1488
1551
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1489
1552
|
return args;
|
|
1490
1553
|
case "codewith":
|
|
1491
|
-
args.push(
|
|
1554
|
+
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
1492
1555
|
if (isolation === "safe")
|
|
1493
1556
|
args.push("--ignore-rules");
|
|
1494
1557
|
if (target.cwd)
|
|
@@ -1568,6 +1631,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1568
1631
|
Object.assign(env, accountEnv);
|
|
1569
1632
|
}
|
|
1570
1633
|
Object.assign(env, spec.env ?? {});
|
|
1634
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1571
1635
|
if (metadata.loopId)
|
|
1572
1636
|
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1573
1637
|
if (metadata.loopName)
|
|
@@ -1586,20 +1650,11 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1586
1650
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1587
1651
|
return env;
|
|
1588
1652
|
}
|
|
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
1653
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1599
1654
|
const spec = commandSpec(target);
|
|
1600
1655
|
const env = executionEnv(spec, metadata, opts);
|
|
1601
|
-
if (!spec.shell && !
|
|
1602
|
-
throw new Error(
|
|
1656
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1657
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1603
1658
|
}
|
|
1604
1659
|
return {
|
|
1605
1660
|
command: spec.command,
|
|
@@ -1617,12 +1672,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1617
1672
|
let exitCode;
|
|
1618
1673
|
let error;
|
|
1619
1674
|
const env = executionEnv(spec, metadata, opts);
|
|
1620
|
-
if (!spec.shell && !
|
|
1675
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1621
1676
|
return {
|
|
1622
1677
|
status: "failed",
|
|
1623
1678
|
stdout: "",
|
|
1624
1679
|
stderr: "",
|
|
1625
|
-
error:
|
|
1680
|
+
error: commandNotFoundMessage(spec.command, env),
|
|
1626
1681
|
startedAt,
|
|
1627
1682
|
finishedAt: nowIso(),
|
|
1628
1683
|
durationMs: 0
|
|
@@ -2123,11 +2178,11 @@ function loops(opts = {}) {
|
|
|
2123
2178
|
return new LoopsClient(opts);
|
|
2124
2179
|
}
|
|
2125
2180
|
// src/lib/doctor.ts
|
|
2126
|
-
import { spawnSync as
|
|
2127
|
-
import { accessSync, constants } from "fs";
|
|
2181
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2182
|
+
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
2128
2183
|
|
|
2129
2184
|
// src/daemon/control.ts
|
|
2130
|
-
import { existsSync as
|
|
2185
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2131
2186
|
import { hostname } from "os";
|
|
2132
2187
|
import { dirname as dirname2 } from "path";
|
|
2133
2188
|
|
|
@@ -2155,7 +2210,7 @@ async function runLoop(opts) {
|
|
|
2155
2210
|
|
|
2156
2211
|
// src/daemon/control.ts
|
|
2157
2212
|
function readPid(path = pidFilePath()) {
|
|
2158
|
-
if (!
|
|
2213
|
+
if (!existsSync2(path))
|
|
2159
2214
|
return;
|
|
2160
2215
|
try {
|
|
2161
2216
|
const pid = Number(readFileSync(path, "utf8").trim());
|
|
@@ -2261,11 +2316,11 @@ var PROVIDER_COMMANDS = [
|
|
|
2261
2316
|
"codex"
|
|
2262
2317
|
];
|
|
2263
2318
|
function hasCommand(command) {
|
|
2264
|
-
const result =
|
|
2319
|
+
const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2265
2320
|
return (result.status ?? 1) === 0;
|
|
2266
2321
|
}
|
|
2267
2322
|
function commandVersion(command) {
|
|
2268
|
-
const result =
|
|
2323
|
+
const result = spawnSync2(command, ["--version"], {
|
|
2269
2324
|
encoding: "utf8",
|
|
2270
2325
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2271
2326
|
});
|
|
@@ -2277,7 +2332,7 @@ function runDoctor(store) {
|
|
|
2277
2332
|
const checks = [];
|
|
2278
2333
|
try {
|
|
2279
2334
|
const dir = ensureDataDir();
|
|
2280
|
-
|
|
2335
|
+
accessSync2(dir, constants2.R_OK | constants2.W_OK);
|
|
2281
2336
|
checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
|
|
2282
2337
|
} catch (error) {
|
|
2283
2338
|
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;
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -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/lib/store.js
CHANGED
|
@@ -258,6 +258,11 @@ function validateTarget(value, label) {
|
|
|
258
258
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
259
259
|
if (!providers.includes(value.provider))
|
|
260
260
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
261
|
+
if (value.authProfile !== undefined) {
|
|
262
|
+
assertString(value.authProfile, `${label}.authProfile`);
|
|
263
|
+
if (value.provider !== "codewith")
|
|
264
|
+
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
265
|
+
}
|
|
261
266
|
return value;
|
|
262
267
|
}
|
|
263
268
|
throw new Error(`${label}.type must be command or agent`);
|
package/dist/sdk/index.js
CHANGED
|
@@ -258,6 +258,11 @@ function validateTarget(value, label) {
|
|
|
258
258
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
259
259
|
if (!providers.includes(value.provider))
|
|
260
260
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
261
|
+
if (value.authProfile !== undefined) {
|
|
262
|
+
assertString(value.authProfile, `${label}.authProfile`);
|
|
263
|
+
if (value.provider !== "codewith")
|
|
264
|
+
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
265
|
+
}
|
|
261
266
|
return value;
|
|
262
267
|
}
|
|
263
268
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -1303,9 +1308,8 @@ class Store {
|
|
|
1303
1308
|
}
|
|
1304
1309
|
|
|
1305
1310
|
// src/lib/executor.ts
|
|
1306
|
-
import { spawn
|
|
1311
|
+
import { spawn } from "child_process";
|
|
1307
1312
|
import { once } from "events";
|
|
1308
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1309
1313
|
|
|
1310
1314
|
// src/lib/accounts.ts
|
|
1311
1315
|
import { spawnSync } from "child_process";
|
|
@@ -1403,6 +1407,65 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1403
1407
|
};
|
|
1404
1408
|
}
|
|
1405
1409
|
|
|
1410
|
+
// src/lib/env.ts
|
|
1411
|
+
import { accessSync, constants } from "fs";
|
|
1412
|
+
import { homedir as homedir2 } from "os";
|
|
1413
|
+
import { delimiter, join as join2 } from "path";
|
|
1414
|
+
function compactPathParts(parts) {
|
|
1415
|
+
const seen = new Set;
|
|
1416
|
+
const result = [];
|
|
1417
|
+
for (const part of parts) {
|
|
1418
|
+
const value = part?.trim();
|
|
1419
|
+
if (!value || seen.has(value))
|
|
1420
|
+
continue;
|
|
1421
|
+
seen.add(value);
|
|
1422
|
+
result.push(value);
|
|
1423
|
+
}
|
|
1424
|
+
return result;
|
|
1425
|
+
}
|
|
1426
|
+
function commonExecutableDirs(env = process.env) {
|
|
1427
|
+
const home = env.HOME || homedir2();
|
|
1428
|
+
return compactPathParts([
|
|
1429
|
+
join2(home, ".local", "bin"),
|
|
1430
|
+
join2(home, ".bun", "bin"),
|
|
1431
|
+
join2(home, ".cargo", "bin"),
|
|
1432
|
+
join2(home, ".npm-global", "bin"),
|
|
1433
|
+
join2(home, "bin"),
|
|
1434
|
+
env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
|
|
1435
|
+
env.PNPM_HOME,
|
|
1436
|
+
env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
1437
|
+
"/opt/homebrew/bin",
|
|
1438
|
+
"/usr/local/bin",
|
|
1439
|
+
"/usr/bin",
|
|
1440
|
+
"/bin",
|
|
1441
|
+
"/usr/sbin",
|
|
1442
|
+
"/sbin"
|
|
1443
|
+
]);
|
|
1444
|
+
}
|
|
1445
|
+
function normalizeExecutionPath(env = process.env) {
|
|
1446
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
1447
|
+
}
|
|
1448
|
+
function isExecutable(path) {
|
|
1449
|
+
try {
|
|
1450
|
+
accessSync(path, constants.X_OK);
|
|
1451
|
+
return true;
|
|
1452
|
+
} catch {
|
|
1453
|
+
return false;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function executableExists(command, env = process.env) {
|
|
1457
|
+
if (command.includes("/"))
|
|
1458
|
+
return isExecutable(command);
|
|
1459
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
1460
|
+
if (dir && isExecutable(join2(dir, command)))
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
1466
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1406
1469
|
// src/lib/executor.ts
|
|
1407
1470
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1408
1471
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1488,7 +1551,7 @@ function agentArgs(target) {
|
|
|
1488
1551
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1489
1552
|
return args;
|
|
1490
1553
|
case "codewith":
|
|
1491
|
-
args.push(
|
|
1554
|
+
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
1492
1555
|
if (isolation === "safe")
|
|
1493
1556
|
args.push("--ignore-rules");
|
|
1494
1557
|
if (target.cwd)
|
|
@@ -1568,6 +1631,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1568
1631
|
Object.assign(env, accountEnv);
|
|
1569
1632
|
}
|
|
1570
1633
|
Object.assign(env, spec.env ?? {});
|
|
1634
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1571
1635
|
if (metadata.loopId)
|
|
1572
1636
|
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1573
1637
|
if (metadata.loopName)
|
|
@@ -1586,20 +1650,11 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1586
1650
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1587
1651
|
return env;
|
|
1588
1652
|
}
|
|
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
1653
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1599
1654
|
const spec = commandSpec(target);
|
|
1600
1655
|
const env = executionEnv(spec, metadata, opts);
|
|
1601
|
-
if (!spec.shell && !
|
|
1602
|
-
throw new Error(
|
|
1656
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1657
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1603
1658
|
}
|
|
1604
1659
|
return {
|
|
1605
1660
|
command: spec.command,
|
|
@@ -1617,12 +1672,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1617
1672
|
let exitCode;
|
|
1618
1673
|
let error;
|
|
1619
1674
|
const env = executionEnv(spec, metadata, opts);
|
|
1620
|
-
if (!spec.shell && !
|
|
1675
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1621
1676
|
return {
|
|
1622
1677
|
status: "failed",
|
|
1623
1678
|
stdout: "",
|
|
1624
1679
|
stderr: "",
|
|
1625
|
-
error:
|
|
1680
|
+
error: commandNotFoundMessage(spec.command, env),
|
|
1626
1681
|
startedAt,
|
|
1627
1682
|
finishedAt: nowIso(),
|
|
1628
1683
|
durationMs: 0
|
package/dist/types.d.ts
CHANGED
package/docs/USAGE.md
CHANGED
|
@@ -76,6 +76,17 @@ loops create agent supply-chain-watch \
|
|
|
76
76
|
--prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
Run a Codewith loop with a Codewith-native auth profile:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
loops create agent supply-chain-watch \
|
|
83
|
+
--provider codewith \
|
|
84
|
+
--auth-profile account001 \
|
|
85
|
+
--every 15m \
|
|
86
|
+
--cwd /path/to/repo \
|
|
87
|
+
--prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
|
|
88
|
+
```
|
|
89
|
+
|
|
79
90
|
For `codewith` and `aicopilot` account isolation, register matching OpenAccounts tools first if they are not built in on the machine:
|
|
80
91
|
|
|
81
92
|
```bash
|
|
@@ -157,7 +168,7 @@ loops remove <id-or-name>
|
|
|
157
168
|
loops run-now <id-or-name>
|
|
158
169
|
```
|
|
159
170
|
|
|
160
|
-
Use `--json` for machine-readable output. Prompt bodies and run stdout/stderr are redacted by default in status output.
|
|
171
|
+
Use `--json` for machine-readable output. Prompt bodies and run stdout/stderr are redacted by default in status output. `loops run-now` exits non-zero when the recorded run fails or times out.
|
|
161
172
|
|
|
162
173
|
## Daemon
|
|
163
174
|
|
|
@@ -203,10 +214,12 @@ On Linux this writes a user systemd service. On macOS it writes a LaunchAgent pl
|
|
|
203
214
|
The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:
|
|
204
215
|
|
|
205
216
|
- Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
|
|
206
|
-
- Codewith uses `codewith exec --json --ephemeral --
|
|
217
|
+
- Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`.
|
|
207
218
|
- AI Copilot and OpenCode use `run --format json --pure`.
|
|
208
219
|
- Cursor is CLI-first for now via `cursor-agent -p`; treat output as less stable until a stronger public SDK contract is selected.
|
|
209
220
|
- Codex uses `codex exec --json --ephemeral --ask-for-approval never`.
|
|
210
221
|
- 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.
|
|
222
|
+
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` before `exec`; they do not call OpenAccounts.
|
|
223
|
+
- Daemon and scheduled runs prepend common user executable directories such as `~/.local/bin` and `~/.bun/bin` before resolving provider CLIs.
|
|
211
224
|
|
|
212
225
|
For production loops that can mutate repos, prefer disposable worktrees and explicit prompts that name allowed write scope.
|