@hasna/loops 0.3.46 → 0.3.48

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/lib/store.js CHANGED
@@ -327,6 +327,13 @@ function optionalPositiveInteger(value, label) {
327
327
  throw new Error(`${label} must be a positive integer`);
328
328
  return value;
329
329
  }
330
+ function optionalBoolean(value, label) {
331
+ if (value === undefined)
332
+ return;
333
+ if (typeof value !== "boolean")
334
+ throw new Error(`${label} must be a boolean`);
335
+ return value;
336
+ }
330
337
  function optionalStringArray(value, label) {
331
338
  if (value === undefined)
332
339
  return;
@@ -338,6 +345,18 @@ function optionalStringArray(value, label) {
338
345
  }).filter(Boolean);
339
346
  return values.length ? values : undefined;
340
347
  }
348
+ function optionalAccountRef(value, label) {
349
+ if (value === undefined)
350
+ return;
351
+ assertObject(value, label);
352
+ assertString(value.profile, `${label}.profile`);
353
+ if (value.tool !== undefined)
354
+ assertString(value.tool, `${label}.tool`);
355
+ return {
356
+ profile: value.profile.trim(),
357
+ tool: typeof value.tool === "string" ? value.tool.trim() : undefined
358
+ };
359
+ }
341
360
  function normalizeGoalSpec(value, label = "goal") {
342
361
  if (value === undefined)
343
362
  return;
@@ -384,8 +403,21 @@ function validateTarget(value, label) {
384
403
  if (value.provider !== "codewith")
385
404
  throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
386
405
  }
406
+ if (value.configIsolation !== undefined) {
407
+ assertString(value.configIsolation, `${label}.configIsolation`);
408
+ if (!["safe", "none"].includes(value.configIsolation))
409
+ throw new Error(`${label}.configIsolation must be safe or none`);
410
+ }
411
+ if (value.model !== undefined)
412
+ assertString(value.model, `${label}.model`);
387
413
  if (value.variant !== undefined)
388
414
  assertString(value.variant, `${label}.variant`);
415
+ if (value.agent !== undefined)
416
+ assertString(value.agent, `${label}.agent`);
417
+ if (value.provider === "cursor" && value.variant !== undefined)
418
+ throw new Error(`${label}.variant is not supported for provider cursor`);
419
+ if (value.provider === "codex" && value.agent !== undefined)
420
+ throw new Error(`${label}.agent is not supported for provider codex`);
389
421
  optionalStringArray(value.addDirs, `${label}.addDirs`);
390
422
  if (Array.isArray(value.addDirs) && value.addDirs.length > 0 && !["codewith", "codex"].includes(value.provider)) {
391
423
  throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
@@ -482,8 +514,10 @@ function normalizeCreateWorkflowInput(input) {
482
514
  id: step.id,
483
515
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
484
516
  target: validateTarget(step.target, `workflow.steps[${index}].target`),
485
- dependsOn: step.dependsOn ?? [],
486
- continueOnFailure: step.continueOnFailure ?? false
517
+ dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
518
+ continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
519
+ timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
520
+ account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
487
521
  };
488
522
  });
489
523
  for (const step of steps) {
@@ -584,6 +618,9 @@ function writeWorkflowRunManifest(args) {
584
618
  }
585
619
 
586
620
  // src/lib/store.ts
621
+ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
622
+ var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
623
+ var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
587
624
  function rowToLoop(row) {
588
625
  return {
589
626
  id: row.id,
@@ -850,6 +887,7 @@ class Store {
850
887
  ensurePrivateStorePath(file);
851
888
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
852
889
  this.db = new Database(file);
890
+ this.db.exec("PRAGMA foreign_keys = ON;");
853
891
  this.db.exec("PRAGMA busy_timeout = 5000;");
854
892
  this.db.exec("PRAGMA journal_mode = WAL;");
855
893
  if (file !== ":memory:")
@@ -913,6 +951,7 @@ class Store {
913
951
  );
914
952
  CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
915
953
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
954
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
916
955
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
917
956
 
918
957
  CREATE TABLE IF NOT EXISTS daemon_lease (
@@ -1240,39 +1279,51 @@ class Store {
1240
1279
  }
1241
1280
  return rows.map(rowToLoop);
1242
1281
  }
1243
- dueLoops(now) {
1282
+ dueLoops(now, limit = 500) {
1244
1283
  const rows = this.db.query(`SELECT * FROM loops
1245
1284
  WHERE status = 'active'
1246
1285
  AND archived_at IS NULL
1247
1286
  AND next_run_at IS NOT NULL
1248
1287
  AND next_run_at <= ?
1249
- ORDER BY next_run_at ASC`).all(now.toISOString());
1288
+ ORDER BY next_run_at ASC
1289
+ LIMIT ?`).all(now.toISOString(), limit);
1250
1290
  return rows.map(rowToLoop);
1251
1291
  }
1252
1292
  updateLoop(id, patch, opts = {}) {
1253
- const current = this.getLoop(id);
1254
- if (!current)
1255
- throw new Error(`loop not found: ${id}`);
1256
1293
  const updated = (opts.now ?? new Date).toISOString();
1257
- const merged = { ...current, ...patch, updatedAt: updated };
1258
- this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1259
- expires_at=$expiresAt, updated_at=$updated
1260
- WHERE id=$id
1261
- AND ($daemonLeaseId IS NULL OR EXISTS (
1262
- SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1263
- ))`).run({
1264
- $id: id,
1265
- $status: merged.status,
1266
- $nextRun: merged.nextRunAt ?? null,
1267
- $retrySlot: merged.retryScheduledFor ?? null,
1268
- $expiresAt: merged.expiresAt ?? null,
1269
- $updated: merged.updatedAt,
1270
- $daemonLeaseId: opts.daemonLeaseId ?? null,
1271
- $now: updated
1272
- });
1273
- if (patch.status && patch.status !== "active") {
1274
- const status = patch.status === "paused" ? "deferred" : "cancelled";
1275
- this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1294
+ this.db.exec("BEGIN IMMEDIATE");
1295
+ try {
1296
+ const current = this.getLoop(id);
1297
+ if (!current)
1298
+ throw new Error(`loop not found: ${id}`);
1299
+ const merged = { ...current, ...patch, updatedAt: updated };
1300
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1301
+ expires_at=$expiresAt, updated_at=$updated
1302
+ WHERE id=$id
1303
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1304
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1305
+ ))`).run({
1306
+ $id: id,
1307
+ $status: merged.status,
1308
+ $nextRun: merged.nextRunAt ?? null,
1309
+ $retrySlot: merged.retryScheduledFor ?? null,
1310
+ $expiresAt: merged.expiresAt ?? null,
1311
+ $updated: merged.updatedAt,
1312
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1313
+ $now: updated
1314
+ });
1315
+ if (res.changes !== 1)
1316
+ throw new Error("daemon lease lost");
1317
+ if (patch.status && patch.status !== "active") {
1318
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1319
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1320
+ }
1321
+ this.db.exec("COMMIT");
1322
+ } catch (error) {
1323
+ try {
1324
+ this.db.exec("ROLLBACK");
1325
+ } catch {}
1326
+ throw error;
1276
1327
  }
1277
1328
  const after = this.getLoop(id);
1278
1329
  if (!after)
@@ -2503,14 +2554,40 @@ class Store {
2503
2554
  }
2504
2555
  return rows.map(rowToRun);
2505
2556
  }
2557
+ deferLiveExpiredRun(id, now, opts = {}) {
2558
+ const updated = now.toISOString();
2559
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
2560
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
2561
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
2562
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2563
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2564
+ ))`).run({
2565
+ $id: id,
2566
+ $deferredUntil: deferredUntil,
2567
+ $updated: updated,
2568
+ $now: updated,
2569
+ $daemonLeaseId: opts.daemonLeaseId ?? null
2570
+ });
2571
+ }
2506
2572
  recoverExpiredRunLeases(now = new Date, opts = {}) {
2507
- const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
2573
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
2574
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
2575
+ const rows = this.db.query(`SELECT * FROM loop_runs
2576
+ WHERE status = 'running' AND lease_expires_at <= ?
2577
+ ORDER BY lease_expires_at ASC
2578
+ LIMIT ?`).all(now.toISOString(), scanLimit);
2508
2579
  const recovered = [];
2509
2580
  for (const row of rows) {
2510
- if (row.pid && isProcessAlive(row.pid))
2581
+ if (recovered.length >= limit)
2582
+ break;
2583
+ if (row.pid && isProcessAlive(row.pid)) {
2584
+ this.deferLiveExpiredRun(row.id, now, opts);
2511
2585
  continue;
2512
- if (this.hasLiveWorkflowStepProcesses(row.id))
2586
+ }
2587
+ if (this.hasLiveWorkflowStepProcesses(row.id)) {
2588
+ this.deferLiveExpiredRun(row.id, now, opts);
2513
2589
  continue;
2590
+ }
2514
2591
  const finished = now.toISOString();
2515
2592
  this.db.exec("BEGIN IMMEDIATE");
2516
2593
  try {
package/dist/sdk/index.js CHANGED
@@ -327,6 +327,13 @@ function optionalPositiveInteger(value, label) {
327
327
  throw new Error(`${label} must be a positive integer`);
328
328
  return value;
329
329
  }
330
+ function optionalBoolean(value, label) {
331
+ if (value === undefined)
332
+ return;
333
+ if (typeof value !== "boolean")
334
+ throw new Error(`${label} must be a boolean`);
335
+ return value;
336
+ }
330
337
  function optionalStringArray(value, label) {
331
338
  if (value === undefined)
332
339
  return;
@@ -338,6 +345,18 @@ function optionalStringArray(value, label) {
338
345
  }).filter(Boolean);
339
346
  return values.length ? values : undefined;
340
347
  }
348
+ function optionalAccountRef(value, label) {
349
+ if (value === undefined)
350
+ return;
351
+ assertObject(value, label);
352
+ assertString(value.profile, `${label}.profile`);
353
+ if (value.tool !== undefined)
354
+ assertString(value.tool, `${label}.tool`);
355
+ return {
356
+ profile: value.profile.trim(),
357
+ tool: typeof value.tool === "string" ? value.tool.trim() : undefined
358
+ };
359
+ }
341
360
  function normalizeGoalSpec(value, label = "goal") {
342
361
  if (value === undefined)
343
362
  return;
@@ -384,8 +403,21 @@ function validateTarget(value, label) {
384
403
  if (value.provider !== "codewith")
385
404
  throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
386
405
  }
406
+ if (value.configIsolation !== undefined) {
407
+ assertString(value.configIsolation, `${label}.configIsolation`);
408
+ if (!["safe", "none"].includes(value.configIsolation))
409
+ throw new Error(`${label}.configIsolation must be safe or none`);
410
+ }
411
+ if (value.model !== undefined)
412
+ assertString(value.model, `${label}.model`);
387
413
  if (value.variant !== undefined)
388
414
  assertString(value.variant, `${label}.variant`);
415
+ if (value.agent !== undefined)
416
+ assertString(value.agent, `${label}.agent`);
417
+ if (value.provider === "cursor" && value.variant !== undefined)
418
+ throw new Error(`${label}.variant is not supported for provider cursor`);
419
+ if (value.provider === "codex" && value.agent !== undefined)
420
+ throw new Error(`${label}.agent is not supported for provider codex`);
389
421
  optionalStringArray(value.addDirs, `${label}.addDirs`);
390
422
  if (Array.isArray(value.addDirs) && value.addDirs.length > 0 && !["codewith", "codex"].includes(value.provider)) {
391
423
  throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
@@ -482,8 +514,10 @@ function normalizeCreateWorkflowInput(input) {
482
514
  id: step.id,
483
515
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
484
516
  target: validateTarget(step.target, `workflow.steps[${index}].target`),
485
- dependsOn: step.dependsOn ?? [],
486
- continueOnFailure: step.continueOnFailure ?? false
517
+ dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
518
+ continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
519
+ timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
520
+ account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
487
521
  };
488
522
  });
489
523
  for (const step of steps) {
@@ -584,6 +618,9 @@ function writeWorkflowRunManifest(args) {
584
618
  }
585
619
 
586
620
  // src/lib/store.ts
621
+ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
622
+ var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
623
+ var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
587
624
  function rowToLoop(row) {
588
625
  return {
589
626
  id: row.id,
@@ -850,6 +887,7 @@ class Store {
850
887
  ensurePrivateStorePath(file);
851
888
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
852
889
  this.db = new Database(file);
890
+ this.db.exec("PRAGMA foreign_keys = ON;");
853
891
  this.db.exec("PRAGMA busy_timeout = 5000;");
854
892
  this.db.exec("PRAGMA journal_mode = WAL;");
855
893
  if (file !== ":memory:")
@@ -913,6 +951,7 @@ class Store {
913
951
  );
914
952
  CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
915
953
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
954
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
916
955
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
917
956
 
918
957
  CREATE TABLE IF NOT EXISTS daemon_lease (
@@ -1240,39 +1279,51 @@ class Store {
1240
1279
  }
1241
1280
  return rows.map(rowToLoop);
1242
1281
  }
1243
- dueLoops(now) {
1282
+ dueLoops(now, limit = 500) {
1244
1283
  const rows = this.db.query(`SELECT * FROM loops
1245
1284
  WHERE status = 'active'
1246
1285
  AND archived_at IS NULL
1247
1286
  AND next_run_at IS NOT NULL
1248
1287
  AND next_run_at <= ?
1249
- ORDER BY next_run_at ASC`).all(now.toISOString());
1288
+ ORDER BY next_run_at ASC
1289
+ LIMIT ?`).all(now.toISOString(), limit);
1250
1290
  return rows.map(rowToLoop);
1251
1291
  }
1252
1292
  updateLoop(id, patch, opts = {}) {
1253
- const current = this.getLoop(id);
1254
- if (!current)
1255
- throw new Error(`loop not found: ${id}`);
1256
1293
  const updated = (opts.now ?? new Date).toISOString();
1257
- const merged = { ...current, ...patch, updatedAt: updated };
1258
- this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1259
- expires_at=$expiresAt, updated_at=$updated
1260
- WHERE id=$id
1261
- AND ($daemonLeaseId IS NULL OR EXISTS (
1262
- SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1263
- ))`).run({
1264
- $id: id,
1265
- $status: merged.status,
1266
- $nextRun: merged.nextRunAt ?? null,
1267
- $retrySlot: merged.retryScheduledFor ?? null,
1268
- $expiresAt: merged.expiresAt ?? null,
1269
- $updated: merged.updatedAt,
1270
- $daemonLeaseId: opts.daemonLeaseId ?? null,
1271
- $now: updated
1272
- });
1273
- if (patch.status && patch.status !== "active") {
1274
- const status = patch.status === "paused" ? "deferred" : "cancelled";
1275
- this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1294
+ this.db.exec("BEGIN IMMEDIATE");
1295
+ try {
1296
+ const current = this.getLoop(id);
1297
+ if (!current)
1298
+ throw new Error(`loop not found: ${id}`);
1299
+ const merged = { ...current, ...patch, updatedAt: updated };
1300
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1301
+ expires_at=$expiresAt, updated_at=$updated
1302
+ WHERE id=$id
1303
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1304
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1305
+ ))`).run({
1306
+ $id: id,
1307
+ $status: merged.status,
1308
+ $nextRun: merged.nextRunAt ?? null,
1309
+ $retrySlot: merged.retryScheduledFor ?? null,
1310
+ $expiresAt: merged.expiresAt ?? null,
1311
+ $updated: merged.updatedAt,
1312
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1313
+ $now: updated
1314
+ });
1315
+ if (res.changes !== 1)
1316
+ throw new Error("daemon lease lost");
1317
+ if (patch.status && patch.status !== "active") {
1318
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1319
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1320
+ }
1321
+ this.db.exec("COMMIT");
1322
+ } catch (error) {
1323
+ try {
1324
+ this.db.exec("ROLLBACK");
1325
+ } catch {}
1326
+ throw error;
1276
1327
  }
1277
1328
  const after = this.getLoop(id);
1278
1329
  if (!after)
@@ -2503,14 +2554,40 @@ class Store {
2503
2554
  }
2504
2555
  return rows.map(rowToRun);
2505
2556
  }
2557
+ deferLiveExpiredRun(id, now, opts = {}) {
2558
+ const updated = now.toISOString();
2559
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
2560
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
2561
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
2562
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2563
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2564
+ ))`).run({
2565
+ $id: id,
2566
+ $deferredUntil: deferredUntil,
2567
+ $updated: updated,
2568
+ $now: updated,
2569
+ $daemonLeaseId: opts.daemonLeaseId ?? null
2570
+ });
2571
+ }
2506
2572
  recoverExpiredRunLeases(now = new Date, opts = {}) {
2507
- const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
2573
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
2574
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
2575
+ const rows = this.db.query(`SELECT * FROM loop_runs
2576
+ WHERE status = 'running' AND lease_expires_at <= ?
2577
+ ORDER BY lease_expires_at ASC
2578
+ LIMIT ?`).all(now.toISOString(), scanLimit);
2508
2579
  const recovered = [];
2509
2580
  for (const row of rows) {
2510
- if (row.pid && isProcessAlive(row.pid))
2581
+ if (recovered.length >= limit)
2582
+ break;
2583
+ if (row.pid && isProcessAlive(row.pid)) {
2584
+ this.deferLiveExpiredRun(row.id, now, opts);
2511
2585
  continue;
2512
- if (this.hasLiveWorkflowStepProcesses(row.id))
2586
+ }
2587
+ if (this.hasLiveWorkflowStepProcesses(row.id)) {
2588
+ this.deferLiveExpiredRun(row.id, now, opts);
2513
2589
  continue;
2590
+ }
2514
2591
  const finished = now.toISOString();
2515
2592
  this.db.exec("BEGIN IMMEDIATE");
2516
2593
  try {
@@ -2899,6 +2976,7 @@ var AUTH_ENV_KEYS = [
2899
2976
  "CODEWITH_HOME",
2900
2977
  "CODEX_HOME",
2901
2978
  "CURSOR_CONFIG_DIR",
2979
+ "CURSOR_API_KEY",
2902
2980
  "OPENCODE_CONFIG_DIR",
2903
2981
  "AICOPILOT_CONFIG_DIR",
2904
2982
  "ANTHROPIC_API_KEY",
@@ -3028,6 +3106,10 @@ function assertSupportedAgentOptions(target) {
3028
3106
  assertStringOption(target.model, `${target.provider}.model`);
3029
3107
  assertStringOption(target.agent, `${target.provider}.agent`);
3030
3108
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3109
+ assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3110
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3111
+ throw new Error(`${target.provider}.configIsolation must be safe or none`);
3112
+ }
3031
3113
  if (target.authProfile !== undefined && target.provider !== "codewith") {
3032
3114
  throw new Error(`${target.provider}.authProfile is supported only for codewith`);
3033
3115
  }
@@ -3040,6 +3122,8 @@ function assertSupportedAgentOptions(target) {
3040
3122
  if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
3041
3123
  throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
3042
3124
  }
3125
+ if (target.provider === "codex" && target.agent !== undefined)
3126
+ throw new Error("codex.agent is not supported");
3043
3127
  if (["codewith", "codex"].includes(target.provider)) {
3044
3128
  if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
3045
3129
  throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
@@ -3054,6 +3138,8 @@ function assertSupportedAgentOptions(target) {
3054
3138
  return;
3055
3139
  }
3056
3140
  if (target.provider === "cursor") {
3141
+ if (target.variant !== undefined)
3142
+ throw new Error("cursor.variant is not supported");
3057
3143
  if (target.permissionMode === "auto")
3058
3144
  throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
3059
3145
  if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
@@ -3095,10 +3181,8 @@ function agentArgs(target) {
3095
3181
  "set -eu",
3096
3182
  "if command -v agent >/dev/null 2>&1; then",
3097
3183
  ' exec agent "$@"',
3098
- "elif command -v cursor >/dev/null 2>&1; then",
3099
- ' exec cursor agent "$@"',
3100
3184
  "else",
3101
- " echo 'Executable not found in PATH: cursor agent or agent' >&2",
3185
+ " echo 'Executable not found in PATH: agent' >&2",
3102
3186
  " exit 127",
3103
3187
  "fi"
3104
3188
  ].join(`
@@ -3139,7 +3223,7 @@ function agentArgs(target) {
3139
3223
  case "codex":
3140
3224
  if (target.variant)
3141
3225
  args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
3142
- args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3226
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3143
3227
  if (isolation === "safe")
3144
3228
  args.push("--ignore-rules");
3145
3229
  if (target.cwd)
@@ -3209,7 +3293,7 @@ function commandSpec(target) {
3209
3293
  account: agentTarget.account,
3210
3294
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
3211
3295
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
3212
- preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
3296
+ preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
3213
3297
  stdin: agentTarget.prompt,
3214
3298
  allowlist: agentTarget.allowlist
3215
3299
  };
@@ -4592,12 +4676,14 @@ function claimDueRuns(deps) {
4592
4676
  const claimed = [];
4593
4677
  const skipped = [];
4594
4678
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
4679
+ if (maxClaims === 0)
4680
+ return { claims, claimed, completed: [], skipped, recovered, expired };
4595
4681
  for (const loop of deps.store.dueLoops(now)) {
4596
- if (claims.length >= maxClaims)
4682
+ if (claims.length + skipped.length >= maxClaims)
4597
4683
  break;
4598
4684
  const plan = dueSlots(loop, now);
4599
4685
  for (const slot of plan.slots) {
4600
- if (claims.length >= maxClaims)
4686
+ if (claims.length + skipped.length >= maxClaims)
4601
4687
  break;
4602
4688
  const run = claimSlot(deps, loop, slot);
4603
4689
  if (!run)
package/docs/USAGE.md CHANGED
@@ -5,7 +5,7 @@ OpenLoops is a local CLI and daemon for persistent loops and workflows: schedule
5
5
  It supports deterministic command loops, JSON-defined workflows, and guarded CLI adapters for headless coding agents:
6
6
 
7
7
  - `claude`
8
- - `cursor agent` or `agent`
8
+ - `agent` (Cursor Agent CLI)
9
9
  - `codewith exec`
10
10
  - `aicopilot run`
11
11
  - `opencode run`
@@ -578,8 +578,8 @@ The adapters intentionally use provider command surfaces instead of pretending e
578
578
  - Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
579
579
  - Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories.
580
580
  - AI Copilot and OpenCode use `run --format json --pure`.
581
- - Cursor is CLI-first for now via standalone `agent -p` when available, with `cursor agent -p` as a compatibility fallback; treat output as less stable until a stronger public SDK contract is selected.
582
- - Codex uses `codex exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories where supported.
581
+ - Cursor is CLI-first for now via the standalone `agent -p` binary. OpenLoops no longer falls back to `cursor agent`; install the standalone Cursor Agent CLI so preflight and scheduled runs use the same executable.
582
+ - Codex uses `codex --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories where supported.
583
583
  - Agent prompts are sent through child stdin instead of argv so prompt bodies do not appear in process listings.
584
584
  - 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.
585
585
  - `--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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
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",