@hasna/loops 0.3.3 → 0.3.4

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/dist/cli/index.js CHANGED
@@ -347,6 +347,7 @@ function rowToLoop(row) {
347
347
  status: row.status,
348
348
  schedule: JSON.parse(row.schedule_json),
349
349
  target: JSON.parse(row.target_json),
350
+ machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
350
351
  nextRunAt: row.next_run_at ?? undefined,
351
352
  retryScheduledFor: row.retry_scheduled_for ?? undefined,
352
353
  catchUp: row.catch_up,
@@ -489,6 +490,7 @@ class Store {
489
490
  status TEXT NOT NULL,
490
491
  schedule_json TEXT NOT NULL,
491
492
  target_json TEXT NOT NULL,
493
+ machine_json TEXT,
492
494
  next_run_at TEXT,
493
495
  retry_scheduled_for TEXT,
494
496
  catch_up TEXT NOT NULL,
@@ -610,10 +612,14 @@ class Store {
610
612
  );
611
613
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
612
614
  `);
615
+ try {
616
+ this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
617
+ } catch {}
613
618
  try {
614
619
  this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
615
620
  } catch {}
616
621
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
622
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
617
623
  }
618
624
  assertDaemonLeaseFence(opts = {}, now = nowIso()) {
619
625
  if (!opts.daemonLeaseId)
@@ -631,6 +637,7 @@ class Store {
631
637
  status: "active",
632
638
  schedule: input.schedule,
633
639
  target: input.target,
640
+ machine: input.machine,
634
641
  nextRunAt: initialNextRun(input.schedule, from),
635
642
  catchUp: input.catchUp ?? "latest",
636
643
  catchUpLimit: input.catchUpLimit ?? 50,
@@ -642,9 +649,9 @@ class Store {
642
649
  createdAt: now,
643
650
  updatedAt: now
644
651
  };
645
- this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
652
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
646
653
  catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
647
- VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
654
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
648
655
  $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
649
656
  $id: loop.id,
650
657
  $name: loop.name,
@@ -652,6 +659,7 @@ class Store {
652
659
  $status: loop.status,
653
660
  $schedule: JSON.stringify(loop.schedule),
654
661
  $target: JSON.stringify(loop.target),
662
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
655
663
  $nextRun: loop.nextRunAt ?? null,
656
664
  $catchUp: loop.catchUp,
657
665
  $catchUpLimit: loop.catchUpLimit,
@@ -1602,8 +1610,9 @@ function publicWorkflowEvent(event) {
1602
1610
  }
1603
1611
 
1604
1612
  // src/lib/executor.ts
1605
- import { spawn } from "child_process";
1613
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1606
1614
  import { once } from "events";
1615
+ import { resolveMachineCommand } from "@hasna/machines/consumer";
1607
1616
 
1608
1617
  // src/lib/accounts.ts
1609
1618
  import { spawnSync } from "child_process";
@@ -1760,6 +1769,59 @@ function commandNotFoundMessage(command, env = process.env) {
1760
1769
  return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1761
1770
  }
1762
1771
 
1772
+ // src/lib/machines.ts
1773
+ import {
1774
+ discoverMachineTopology,
1775
+ resolveMachineRoute
1776
+ } from "@hasna/machines/consumer";
1777
+ function compact(value) {
1778
+ const text = value?.trim();
1779
+ return text ? text : undefined;
1780
+ }
1781
+ function entryToSummary(entry, topology) {
1782
+ return {
1783
+ id: entry.machine_id,
1784
+ hostname: compact(entry.hostname),
1785
+ platform: compact(entry.platform),
1786
+ user: compact(entry.user),
1787
+ workspacePath: compact(entry.workspace_path),
1788
+ route: entry.ssh.route,
1789
+ local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
1790
+ heartbeatStatus: entry.heartbeat_status,
1791
+ tailscaleOnline: entry.tailscale.online,
1792
+ tags: entry.tags
1793
+ };
1794
+ }
1795
+ function machineFromRoute(route, topology) {
1796
+ if (!route.ok || !route.machine_id) {
1797
+ throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
1798
+ }
1799
+ const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
1800
+ return {
1801
+ id: route.machine_id,
1802
+ requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
1803
+ route: route.route,
1804
+ local: route.local,
1805
+ confidence: route.confidence,
1806
+ workspacePath: compact(entry?.workspace_path),
1807
+ resolvedAt: route.generated_at,
1808
+ packageVersion: route.package.version,
1809
+ warnings: route.warnings.length ? route.warnings : undefined
1810
+ };
1811
+ }
1812
+ function listOpenMachines() {
1813
+ const topology = discoverMachineTopology();
1814
+ return topology.machines.map((entry) => entryToSummary(entry, topology));
1815
+ }
1816
+ function resolveLoopMachine(machineId) {
1817
+ const topology = discoverMachineTopology();
1818
+ const route = resolveMachineRoute(machineId, { topology });
1819
+ return machineFromRoute(route, topology);
1820
+ }
1821
+ function refreshLoopMachine(machine) {
1822
+ return resolveLoopMachine(machine.id);
1823
+ }
1824
+
1763
1825
  // src/lib/executor.ts
1764
1826
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1765
1827
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1780,6 +1842,23 @@ var AUTH_ENV_KEYS = [
1780
1842
  "XDG_STATE_HOME",
1781
1843
  "XDG_CACHE_HOME"
1782
1844
  ];
1845
+ var TRANSPORT_ENV_KEYS = new Set([
1846
+ "BUN_INSTALL",
1847
+ "HOME",
1848
+ "LANG",
1849
+ "LANGUAGE",
1850
+ "LOGNAME",
1851
+ "PATH",
1852
+ "SHELL",
1853
+ "SSH_AGENT_PID",
1854
+ "SSH_AUTH_SOCK",
1855
+ "TERM",
1856
+ "TMP",
1857
+ "TMPDIR",
1858
+ "TEMP",
1859
+ "USER",
1860
+ "XDG_RUNTIME_DIR"
1861
+ ]);
1783
1862
  function appendBounded(current, chunk, maxBytes) {
1784
1863
  const next = current + chunk.toString("utf8");
1785
1864
  if (Buffer.byteLength(next, "utf8") <= maxBytes)
@@ -1806,6 +1885,29 @@ function killProcessGroup(pid) {
1806
1885
  }
1807
1886
  }, 2000).unref();
1808
1887
  }
1888
+ function shellQuote(value) {
1889
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1890
+ }
1891
+ function metadataEnv(metadata) {
1892
+ const env = {};
1893
+ if (metadata.loopId)
1894
+ env.LOOPS_LOOP_ID = metadata.loopId;
1895
+ if (metadata.loopName)
1896
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1897
+ if (metadata.runId)
1898
+ env.LOOPS_RUN_ID = metadata.runId;
1899
+ if (metadata.scheduledFor)
1900
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1901
+ if (metadata.workflowId)
1902
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1903
+ if (metadata.workflowName)
1904
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1905
+ if (metadata.workflowRunId)
1906
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1907
+ if (metadata.workflowStepId)
1908
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1909
+ return env;
1910
+ }
1809
1911
  function providerCommand(provider) {
1810
1912
  switch (provider) {
1811
1913
  case "claude":
@@ -1927,26 +2029,213 @@ function executionEnv(spec, metadata, opts) {
1927
2029
  }
1928
2030
  Object.assign(env, spec.env ?? {});
1929
2031
  env.PATH = normalizeExecutionPath(env);
1930
- if (metadata.loopId)
1931
- env.LOOPS_LOOP_ID = metadata.loopId;
1932
- if (metadata.loopName)
1933
- env.LOOPS_LOOP_NAME = metadata.loopName;
1934
- if (metadata.runId)
1935
- env.LOOPS_RUN_ID = metadata.runId;
1936
- if (metadata.scheduledFor)
1937
- env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1938
- if (metadata.workflowId)
1939
- env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1940
- if (metadata.workflowName)
1941
- env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1942
- if (metadata.workflowRunId)
1943
- env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1944
- if (metadata.workflowStepId)
1945
- env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
2032
+ Object.assign(env, metadataEnv(metadata));
1946
2033
  return env;
1947
2034
  }
2035
+ function resolvedMachine(opts) {
2036
+ if (!opts.machine)
2037
+ return;
2038
+ return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
2039
+ }
2040
+ function commandForShell(spec) {
2041
+ if (!spec.args.length)
2042
+ return spec.command;
2043
+ return [spec.command, ...spec.args.map(shellQuote)].join(" ");
2044
+ }
2045
+ function hereDoc(value) {
2046
+ let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
2047
+ while (value.split(/\r?\n/).includes(delimiter2)) {
2048
+ delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
2049
+ }
2050
+ return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
2051
+ }
2052
+ function remoteBootstrapLines(spec, metadata) {
2053
+ const lines = [
2054
+ "set -e",
2055
+ 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
2056
+ ];
2057
+ if (spec.cwd)
2058
+ lines.push(`cd ${shellQuote(spec.cwd)}`);
2059
+ if (spec.account) {
2060
+ if (!spec.accountTool)
2061
+ throw new Error("account.tool is required when no provider tool can be inferred");
2062
+ lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
2063
+ }
2064
+ for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
2065
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
2066
+ continue;
2067
+ lines.push(`export ${key}=${shellQuote(value)}`);
2068
+ }
2069
+ return lines;
2070
+ }
2071
+ function remoteScript(spec, metadata) {
2072
+ const lines = remoteBootstrapLines(spec, metadata);
2073
+ let stdinRedirect = "";
2074
+ if (spec.stdin !== undefined) {
2075
+ lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
2076
+ lines.push(...hereDoc(spec.stdin));
2077
+ stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
2078
+ }
2079
+ const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
2080
+ lines.push(invocation);
2081
+ return `${lines.join(`
2082
+ `)}
2083
+ `;
2084
+ }
2085
+ function remotePreflightScript(spec, metadata) {
2086
+ return [
2087
+ ...remoteBootstrapLines(spec, metadata),
2088
+ "command -v bash >/dev/null 2>&1",
2089
+ `command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
2090
+ ].join(`
2091
+ `);
2092
+ }
2093
+ function transportEnv(opts) {
2094
+ const source = opts.env ?? process.env;
2095
+ const env = {};
2096
+ for (const [key, value] of Object.entries(source)) {
2097
+ if (value === undefined)
2098
+ continue;
2099
+ if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
2100
+ env[key] = value;
2101
+ }
2102
+ env.PATH = normalizeExecutionPath(env);
2103
+ return env;
2104
+ }
2105
+ function preflightRemoteSpec(spec, machine, metadata, opts) {
2106
+ const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2107
+ const result = spawnSync2(plan.command, plan.args, {
2108
+ encoding: "utf8",
2109
+ env: transportEnv(opts),
2110
+ input: remotePreflightScript(spec, metadata),
2111
+ stdio: ["pipe", "pipe", "pipe"],
2112
+ timeout: 15000
2113
+ });
2114
+ if (result.error)
2115
+ throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
2116
+ if ((result.status ?? 1) !== 0) {
2117
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2118
+ throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
2119
+ }
2120
+ }
2121
+ async function executeRemoteSpec(spec, machine, metadata, opts) {
2122
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2123
+ const startedAt = nowIso();
2124
+ let stdout = "";
2125
+ let stderr = "";
2126
+ let timedOut = false;
2127
+ let exitCode;
2128
+ let error;
2129
+ let plan;
2130
+ let script;
2131
+ try {
2132
+ plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2133
+ script = remoteScript(spec, metadata);
2134
+ } catch (err) {
2135
+ return {
2136
+ status: "failed",
2137
+ stdout: "",
2138
+ stderr: "",
2139
+ error: err instanceof Error ? err.message : String(err),
2140
+ startedAt,
2141
+ finishedAt: nowIso(),
2142
+ durationMs: 0
2143
+ };
2144
+ }
2145
+ const child = spawn(plan.command, plan.args, {
2146
+ env: transportEnv(opts),
2147
+ detached: true,
2148
+ stdio: ["pipe", "pipe", "pipe"]
2149
+ });
2150
+ if (child.pid)
2151
+ opts.onSpawn?.(child.pid);
2152
+ child.stdin?.on("error", (err) => {
2153
+ if (err.code !== "EPIPE")
2154
+ error = err.message;
2155
+ });
2156
+ child.stdin?.end(script);
2157
+ const abortHandler = () => {
2158
+ error = "cancelled";
2159
+ if (child.pid)
2160
+ killProcessGroup(child.pid);
2161
+ };
2162
+ if (opts.signal?.aborted)
2163
+ abortHandler();
2164
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
2165
+ child.stdout?.on("data", (chunk) => {
2166
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
2167
+ });
2168
+ child.stderr?.on("data", (chunk) => {
2169
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
2170
+ });
2171
+ const timer = setTimeout(() => {
2172
+ timedOut = true;
2173
+ if (child.pid)
2174
+ killProcessGroup(child.pid);
2175
+ }, spec.timeoutMs);
2176
+ timer.unref();
2177
+ try {
2178
+ const [code, signal] = await once(child, "exit");
2179
+ if (typeof code === "number")
2180
+ exitCode = code;
2181
+ if (signal)
2182
+ error = `terminated by ${signal}`;
2183
+ } catch (err) {
2184
+ error = err instanceof Error ? err.message : String(err);
2185
+ } finally {
2186
+ clearTimeout(timer);
2187
+ opts.signal?.removeEventListener("abort", abortHandler);
2188
+ }
2189
+ const finishedAt = nowIso();
2190
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
2191
+ if (timedOut) {
2192
+ return {
2193
+ status: "timed_out",
2194
+ exitCode,
2195
+ stdout,
2196
+ stderr,
2197
+ error: `timed out after ${spec.timeoutMs}ms`,
2198
+ pid: child.pid,
2199
+ startedAt,
2200
+ finishedAt,
2201
+ durationMs
2202
+ };
2203
+ }
2204
+ if (error || exitCode !== 0) {
2205
+ return {
2206
+ status: "failed",
2207
+ exitCode,
2208
+ stdout,
2209
+ stderr,
2210
+ error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
2211
+ pid: child.pid,
2212
+ startedAt,
2213
+ finishedAt,
2214
+ durationMs
2215
+ };
2216
+ }
2217
+ return {
2218
+ status: "succeeded",
2219
+ exitCode,
2220
+ stdout,
2221
+ stderr,
2222
+ pid: child.pid,
2223
+ startedAt,
2224
+ finishedAt,
2225
+ durationMs
2226
+ };
2227
+ }
1948
2228
  function preflightTarget(target, metadata = {}, opts = {}) {
1949
2229
  const spec = commandSpec(target);
2230
+ const machine = resolvedMachine(opts);
2231
+ if (machine && !machine.local) {
2232
+ preflightRemoteSpec(spec, machine, metadata, opts);
2233
+ return {
2234
+ command: spec.command,
2235
+ accountProfile: spec.account?.profile,
2236
+ accountTool: spec.accountTool
2237
+ };
2238
+ }
1950
2239
  const env = executionEnv(spec, metadata, opts);
1951
2240
  if (!spec.shell && !executableExists(spec.command, env)) {
1952
2241
  throw new Error(commandNotFoundMessage(spec.command, env));
@@ -1959,6 +2248,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
1959
2248
  }
1960
2249
  async function executeTarget(target, metadata = {}, opts = {}) {
1961
2250
  const spec = commandSpec(target);
2251
+ const machine = resolvedMachine(opts);
2252
+ if (machine && !machine.local)
2253
+ return executeRemoteSpec(spec, machine, metadata, opts);
1962
2254
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1963
2255
  const startedAt = nowIso();
1964
2256
  let stdout = "";
@@ -2074,7 +2366,7 @@ async function executeLoop(loop, run, opts = {}) {
2074
2366
  loopName: loop.name,
2075
2367
  runId: run.id,
2076
2368
  scheduledFor: run.scheduledFor
2077
- }, opts);
2369
+ }, { ...opts, machine: opts.machine ?? loop.machine });
2078
2370
  }
2079
2371
 
2080
2372
  // src/lib/workflow-runner.ts
@@ -2176,6 +2468,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
2176
2468
  try {
2177
2469
  result = await executeTarget(targetWithStepAccount(step), metadata, {
2178
2470
  ...opts,
2471
+ machine: opts.machine ?? opts.loop?.machine,
2179
2472
  signal: controller.signal,
2180
2473
  onSpawn: (pid) => {
2181
2474
  opts.beforePersist?.();
@@ -2778,7 +3071,7 @@ async function startDaemon(opts) {
2778
3071
 
2779
3072
  // src/daemon/install.ts
2780
3073
  import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
2781
- import { spawnSync as spawnSync2 } from "child_process";
3074
+ import { spawnSync as spawnSync3 } from "child_process";
2782
3075
  import { dirname as dirname3 } from "path";
2783
3076
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
2784
3077
  const command = [execPath, cliEntry, ...args].join(" ");
@@ -2848,7 +3141,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2848
3141
  function enableStartup(result) {
2849
3142
  const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
2850
3143
  return commands.map((command) => {
2851
- const run = spawnSync2("sh", ["-c", command], {
3144
+ const run = spawnSync3("sh", ["-c", command], {
2852
3145
  encoding: "utf8",
2853
3146
  stdio: ["ignore", "pipe", "pipe"]
2854
3147
  });
@@ -2862,7 +3155,7 @@ function enableStartup(result) {
2862
3155
  }
2863
3156
 
2864
3157
  // src/lib/doctor.ts
2865
- import { spawnSync as spawnSync3 } from "child_process";
3158
+ import { spawnSync as spawnSync4 } from "child_process";
2866
3159
  import { accessSync as accessSync2, constants as constants2 } from "fs";
2867
3160
  var PROVIDER_COMMANDS = [
2868
3161
  "claude",
@@ -2873,11 +3166,11 @@ var PROVIDER_COMMANDS = [
2873
3166
  "codex"
2874
3167
  ];
2875
3168
  function hasCommand(command) {
2876
- const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
3169
+ const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
2877
3170
  return (result.status ?? 1) === 0;
2878
3171
  }
2879
3172
  function commandVersion(command) {
2880
- const result = spawnSync3(command, ["--version"], {
3173
+ const result = spawnSync4(command, ["--version"], {
2881
3174
  encoding: "utf8",
2882
3175
  stdio: ["ignore", "pipe", "pipe"]
2883
3176
  });
@@ -2903,6 +3196,23 @@ function runDoctor(store) {
2903
3196
  checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
2904
3197
  const accountsVersion = commandVersion("accounts");
2905
3198
  checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
3199
+ try {
3200
+ const machines = listOpenMachines();
3201
+ const local = machines.find((machine) => machine.local);
3202
+ checks.push({
3203
+ id: "machines",
3204
+ status: "ok",
3205
+ message: `OpenMachines topology available (${machines.length} machine(s))`,
3206
+ detail: local ? `local=${local.id}` : undefined
3207
+ });
3208
+ } catch (error) {
3209
+ checks.push({
3210
+ id: "machines",
3211
+ status: "warn",
3212
+ message: "OpenMachines topology is not available; machine-assigned loops will fail",
3213
+ detail: error instanceof Error ? error.message : String(error)
3214
+ });
3215
+ }
2906
3216
  for (const command of PROVIDER_COMMANDS) {
2907
3217
  checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
2908
3218
  }
@@ -2915,16 +3225,16 @@ function runDoctor(store) {
2915
3225
  if (loop.target.type === "workflow") {
2916
3226
  const workflow = store.requireWorkflow(loop.target.workflowId);
2917
3227
  for (const step of workflowExecutionOrder(workflow)) {
2918
- preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
3228
+ preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id }, { machine: loop.machine });
2919
3229
  }
2920
3230
  } else {
2921
- preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
3231
+ preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });
2922
3232
  }
2923
3233
  checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
2924
3234
  } catch (error) {
2925
3235
  checks.push({
2926
3236
  id: `loop:${loop.id}:preflight`,
2927
- status: "warn",
3237
+ status: "fail",
2928
3238
  message: `active loop target preflight failed: ${loop.name}`,
2929
3239
  detail: error instanceof Error ? error.message : String(error)
2930
3240
  });
@@ -2938,7 +3248,7 @@ function runDoctor(store) {
2938
3248
 
2939
3249
  // src/cli/index.ts
2940
3250
  var program = new Command;
2941
- program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.3");
3251
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.4");
2942
3252
  program.option("-j, --json", "print JSON");
2943
3253
  function isJson() {
2944
3254
  return Boolean(program.opts().json);
@@ -3017,6 +3327,7 @@ function baseCreateInput(name, opts, target) {
3017
3327
  description: typeof opts.description === "string" ? opts.description : undefined,
3018
3328
  schedule,
3019
3329
  target,
3330
+ machine: typeof opts.machine === "string" ? resolveLoopMachine(opts.machine) : undefined,
3020
3331
  ...policy,
3021
3332
  expiresAt: typeof opts.expiresAt === "string" ? new Date(opts.expiresAt).toISOString() : undefined
3022
3333
  };
@@ -3027,6 +3338,9 @@ function addScheduleOptions(command) {
3027
3338
  function addAccountOptions(command) {
3028
3339
  return command.option("--account <profile>", "OpenAccounts profile name for this target").option("--account-tool <tool>", "OpenAccounts tool id; defaults from provider for agents");
3029
3340
  }
3341
+ function addMachineOptions(command) {
3342
+ return command.option("--machine <id>", "OpenMachines machine id to assign this loop to");
3343
+ }
3030
3344
  function accountFromOpts(opts) {
3031
3345
  if (!opts.account && opts.accountTool)
3032
3346
  throw new Error("--account-tool requires --account");
@@ -3040,7 +3354,7 @@ function providerAuthProfileFromOpts(opts, provider) {
3040
3354
  return opts.authProfile;
3041
3355
  }
3042
3356
  var create = program.command("create").description("create loops");
3043
- 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) => {
3357
+ addAccountOptions(addMachineOptions(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) => {
3044
3358
  const store = new Store;
3045
3359
  try {
3046
3360
  const target = {
@@ -3057,7 +3371,7 @@ addAccountOptions(addScheduleOptions(create.command("command <name>").descriptio
3057
3371
  store.close();
3058
3372
  }
3059
3373
  });
3060
- 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) => {
3374
+ addAccountOptions(addMachineOptions(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) => {
3061
3375
  const provider = opts.provider;
3062
3376
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
3063
3377
  throw new Error("unsupported provider");
@@ -3085,7 +3399,7 @@ addAccountOptions(addScheduleOptions(create.command("agent <name>").description(
3085
3399
  store.close();
3086
3400
  }
3087
3401
  });
3088
- addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")).action((name, opts) => {
3402
+ addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name"))).action((name, opts) => {
3089
3403
  const store = new Store;
3090
3404
  try {
3091
3405
  const workflow = store.requireWorkflow(opts.workflow);
@@ -3100,6 +3414,21 @@ addScheduleOptions(create.command("workflow <name>").description("schedule a sto
3100
3414
  }
3101
3415
  });
3102
3416
  var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
3417
+ var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
3418
+ machines.command("list").alias("ls").description("list known machines").action(() => {
3419
+ const values = listOpenMachines();
3420
+ if (isJson())
3421
+ print(values);
3422
+ else {
3423
+ for (const machine of values) {
3424
+ const route = machine.local ? "local" : machine.route ?? "-";
3425
+ console.log(`${machine.id.padEnd(12)} ${route.padEnd(10)} workspace=${machine.workspacePath ?? "-"} host=${machine.hostname ?? "-"}`);
3426
+ }
3427
+ }
3428
+ });
3429
+ machines.command("show <id>").description("resolve a machine assignment").action((id) => {
3430
+ print(resolveLoopMachine(id));
3431
+ });
3103
3432
  workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
3104
3433
  const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
3105
3434
  const now = new Date().toISOString();
@@ -3270,7 +3599,8 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
3270
3599
  print(loops.map(publicLoop));
3271
3600
  else {
3272
3601
  for (const loop of loops) {
3273
- console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}`);
3602
+ const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
3603
+ console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}`);
3274
3604
  }
3275
3605
  }
3276
3606
  } finally {
@@ -3380,9 +3710,9 @@ program.command("doctor").description("check local OpenLoops runtime dependencie
3380
3710
  const marker = check.status === "ok" ? "ok" : check.status === "warn" ? "warn" : "fail";
3381
3711
  console.log(`${marker.padEnd(4)} ${check.id.padEnd(22)} ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
3382
3712
  }
3383
- if (!report.ok)
3384
- process.exitCode = 1;
3385
3713
  }
3714
+ if (!report.ok)
3715
+ process.exitCode = 1;
3386
3716
  } finally {
3387
3717
  store.close();
3388
3718
  }