@hasna/loops 0.3.47 → 0.3.49

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.
@@ -329,6 +329,13 @@ function optionalPositiveInteger(value, label) {
329
329
  throw new Error(`${label} must be a positive integer`);
330
330
  return value;
331
331
  }
332
+ function optionalBoolean(value, label) {
333
+ if (value === undefined)
334
+ return;
335
+ if (typeof value !== "boolean")
336
+ throw new Error(`${label} must be a boolean`);
337
+ return value;
338
+ }
332
339
  function optionalStringArray(value, label) {
333
340
  if (value === undefined)
334
341
  return;
@@ -340,6 +347,18 @@ function optionalStringArray(value, label) {
340
347
  }).filter(Boolean);
341
348
  return values.length ? values : undefined;
342
349
  }
350
+ function optionalAccountRef(value, label) {
351
+ if (value === undefined)
352
+ return;
353
+ assertObject(value, label);
354
+ assertString(value.profile, `${label}.profile`);
355
+ if (value.tool !== undefined)
356
+ assertString(value.tool, `${label}.tool`);
357
+ return {
358
+ profile: value.profile.trim(),
359
+ tool: typeof value.tool === "string" ? value.tool.trim() : undefined
360
+ };
361
+ }
343
362
  function normalizeGoalSpec(value, label = "goal") {
344
363
  if (value === undefined)
345
364
  return;
@@ -386,8 +405,21 @@ function validateTarget(value, label) {
386
405
  if (value.provider !== "codewith")
387
406
  throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
388
407
  }
408
+ if (value.configIsolation !== undefined) {
409
+ assertString(value.configIsolation, `${label}.configIsolation`);
410
+ if (!["safe", "none"].includes(value.configIsolation))
411
+ throw new Error(`${label}.configIsolation must be safe or none`);
412
+ }
413
+ if (value.model !== undefined)
414
+ assertString(value.model, `${label}.model`);
389
415
  if (value.variant !== undefined)
390
416
  assertString(value.variant, `${label}.variant`);
417
+ if (value.agent !== undefined)
418
+ assertString(value.agent, `${label}.agent`);
419
+ if (value.provider === "cursor" && value.variant !== undefined)
420
+ throw new Error(`${label}.variant is not supported for provider cursor`);
421
+ if (value.provider === "codex" && value.agent !== undefined)
422
+ throw new Error(`${label}.agent is not supported for provider codex`);
391
423
  optionalStringArray(value.addDirs, `${label}.addDirs`);
392
424
  if (Array.isArray(value.addDirs) && value.addDirs.length > 0 && !["codewith", "codex"].includes(value.provider)) {
393
425
  throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
@@ -484,8 +516,10 @@ function normalizeCreateWorkflowInput(input) {
484
516
  id: step.id,
485
517
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
486
518
  target: validateTarget(step.target, `workflow.steps[${index}].target`),
487
- dependsOn: step.dependsOn ?? [],
488
- continueOnFailure: step.continueOnFailure ?? false
519
+ dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
520
+ continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
521
+ timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
522
+ account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
489
523
  };
490
524
  });
491
525
  for (const step of steps) {
@@ -586,6 +620,11 @@ function writeWorkflowRunManifest(args) {
586
620
  }
587
621
 
588
622
  // src/lib/store.ts
623
+ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
624
+ var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
625
+ var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
626
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
627
+ var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
589
628
  function rowToLoop(row) {
590
629
  return {
591
630
  id: row.id,
@@ -852,6 +891,7 @@ class Store {
852
891
  ensurePrivateStorePath(file);
853
892
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
854
893
  this.db = new Database(file);
894
+ this.db.exec("PRAGMA foreign_keys = ON;");
855
895
  this.db.exec("PRAGMA busy_timeout = 5000;");
856
896
  this.db.exec("PRAGMA journal_mode = WAL;");
857
897
  if (file !== ":memory:")
@@ -915,6 +955,7 @@ class Store {
915
955
  );
916
956
  CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
917
957
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
958
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
918
959
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
919
960
 
920
961
  CREATE TABLE IF NOT EXISTS daemon_lease (
@@ -1242,39 +1283,51 @@ class Store {
1242
1283
  }
1243
1284
  return rows.map(rowToLoop);
1244
1285
  }
1245
- dueLoops(now) {
1286
+ dueLoops(now, limit = 500) {
1246
1287
  const rows = this.db.query(`SELECT * FROM loops
1247
1288
  WHERE status = 'active'
1248
1289
  AND archived_at IS NULL
1249
1290
  AND next_run_at IS NOT NULL
1250
1291
  AND next_run_at <= ?
1251
- ORDER BY next_run_at ASC`).all(now.toISOString());
1292
+ ORDER BY next_run_at ASC
1293
+ LIMIT ?`).all(now.toISOString(), limit);
1252
1294
  return rows.map(rowToLoop);
1253
1295
  }
1254
1296
  updateLoop(id, patch, opts = {}) {
1255
- const current = this.getLoop(id);
1256
- if (!current)
1257
- throw new Error(`loop not found: ${id}`);
1258
1297
  const updated = (opts.now ?? new Date).toISOString();
1259
- const merged = { ...current, ...patch, updatedAt: updated };
1260
- this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1261
- expires_at=$expiresAt, updated_at=$updated
1262
- WHERE id=$id
1263
- AND ($daemonLeaseId IS NULL OR EXISTS (
1264
- SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1265
- ))`).run({
1266
- $id: id,
1267
- $status: merged.status,
1268
- $nextRun: merged.nextRunAt ?? null,
1269
- $retrySlot: merged.retryScheduledFor ?? null,
1270
- $expiresAt: merged.expiresAt ?? null,
1271
- $updated: merged.updatedAt,
1272
- $daemonLeaseId: opts.daemonLeaseId ?? null,
1273
- $now: updated
1274
- });
1275
- if (patch.status && patch.status !== "active") {
1276
- const status = patch.status === "paused" ? "deferred" : "cancelled";
1277
- this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1298
+ this.db.exec("BEGIN IMMEDIATE");
1299
+ try {
1300
+ const current = this.getLoop(id);
1301
+ if (!current)
1302
+ throw new Error(`loop not found: ${id}`);
1303
+ const merged = { ...current, ...patch, updatedAt: updated };
1304
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1305
+ expires_at=$expiresAt, updated_at=$updated
1306
+ WHERE id=$id
1307
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1308
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1309
+ ))`).run({
1310
+ $id: id,
1311
+ $status: merged.status,
1312
+ $nextRun: merged.nextRunAt ?? null,
1313
+ $retrySlot: merged.retryScheduledFor ?? null,
1314
+ $expiresAt: merged.expiresAt ?? null,
1315
+ $updated: merged.updatedAt,
1316
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1317
+ $now: updated
1318
+ });
1319
+ if (res.changes !== 1)
1320
+ throw new Error("daemon lease lost");
1321
+ if (patch.status && patch.status !== "active") {
1322
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1323
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1324
+ }
1325
+ this.db.exec("COMMIT");
1326
+ } catch (error) {
1327
+ try {
1328
+ this.db.exec("ROLLBACK");
1329
+ } catch {}
1330
+ throw error;
1278
1331
  }
1279
1332
  const after = this.getLoop(id);
1280
1333
  if (!after)
@@ -1392,10 +1445,23 @@ class Store {
1392
1445
  })();
1393
1446
  }
1394
1447
  listWorkflows(opts = {}) {
1395
- const limit = opts.limit ?? 200;
1396
- const rows = opts.status ? this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ?").all(limit);
1448
+ const offset = Math.max(0, opts.offset ?? 0);
1449
+ let rows;
1450
+ if (opts.status && opts.limit !== undefined) {
1451
+ rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
1452
+ } else if (opts.status) {
1453
+ rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
1454
+ } else if (opts.limit !== undefined) {
1455
+ rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
1456
+ } else {
1457
+ rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
1458
+ }
1397
1459
  return rows.map(rowToWorkflow);
1398
1460
  }
1461
+ countWorkflows(opts = {}) {
1462
+ const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
1463
+ return row?.count ?? 0;
1464
+ }
1399
1465
  archiveWorkflow(idOrName) {
1400
1466
  const workflow = this.requireWorkflow(idOrName);
1401
1467
  const updated = nowIso();
@@ -1405,6 +1471,64 @@ class Store {
1405
1471
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1406
1472
  return archived;
1407
1473
  }
1474
+ generatedRouteArchiveContext(args) {
1475
+ if (!args.loopId || !args.workItemId)
1476
+ return;
1477
+ const workItem = this.getWorkflowWorkItem(args.workItemId);
1478
+ if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
1479
+ return;
1480
+ const invocation = this.getWorkflowInvocation(workItem.invocationId);
1481
+ if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
1482
+ return;
1483
+ const loop = this.getLoop(args.loopId);
1484
+ if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
1485
+ return;
1486
+ const input = loop.target.input ?? {};
1487
+ if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
1488
+ return;
1489
+ if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
1490
+ return;
1491
+ if (workItem.workflowId && workItem.workflowId !== args.workflowId)
1492
+ return;
1493
+ const workflow = this.getWorkflow(args.workflowId);
1494
+ if (!workflow)
1495
+ return;
1496
+ return { workflow, loop, workItem, invocation };
1497
+ }
1498
+ maybeArchiveGeneratedRouteWorkflow(args) {
1499
+ const context = this.generatedRouteArchiveContext(args);
1500
+ if (!context)
1501
+ return;
1502
+ const { workflow, loop, workItem } = context;
1503
+ if (!workflow || workflow.status !== "active")
1504
+ return;
1505
+ const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
1506
+ WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
1507
+ if (nonTerminal > 0)
1508
+ return;
1509
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
1510
+ if (res.changes === 1 && args.workflowRunId) {
1511
+ this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
1512
+ workflowId: args.workflowId,
1513
+ loopId: loop.id,
1514
+ workItemId: workItem.id,
1515
+ routeKey: workItem.routeKey,
1516
+ reason: "terminal generated one-shot route workflow"
1517
+ });
1518
+ }
1519
+ }
1520
+ maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
1521
+ const run = this.getWorkflowRun(workflowRunId);
1522
+ if (!run)
1523
+ return;
1524
+ this.maybeArchiveGeneratedRouteWorkflow({
1525
+ workflowId: run.workflowId,
1526
+ loopId: run.loopId,
1527
+ workItemId: run.workItemId,
1528
+ workflowRunId,
1529
+ updated
1530
+ });
1531
+ }
1408
1532
  createWorkflowInvocation(input) {
1409
1533
  const now = nowIso();
1410
1534
  const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
@@ -2190,6 +2314,7 @@ class Store {
2190
2314
  if (changed) {
2191
2315
  const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2192
2316
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2317
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
2193
2318
  }
2194
2319
  this.db.exec("COMMIT");
2195
2320
  } catch (error) {
@@ -2217,6 +2342,7 @@ class Store {
2217
2342
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2218
2343
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
2219
2344
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
2345
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
2220
2346
  }
2221
2347
  this.db.exec("COMMIT");
2222
2348
  return this.requireWorkflowRun(workflowRunId);
@@ -2469,8 +2595,20 @@ class Store {
2469
2595
  throw new Error(`run not found after finalize: ${id}`);
2470
2596
  if (opts.claimedBy && res.changes !== 1)
2471
2597
  return run;
2472
- if (res.changes === 1)
2598
+ if (res.changes === 1) {
2473
2599
  this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2600
+ const loop = this.getLoop(run.loopId);
2601
+ const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
2602
+ if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
2603
+ const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
2604
+ this.maybeArchiveGeneratedRouteWorkflow({
2605
+ workflowId: loop.target.workflowId,
2606
+ loopId: loop.id,
2607
+ workItemId,
2608
+ updated: finishedAt
2609
+ });
2610
+ }
2611
+ }
2474
2612
  return run;
2475
2613
  }
2476
2614
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2505,14 +2643,40 @@ class Store {
2505
2643
  }
2506
2644
  return rows.map(rowToRun);
2507
2645
  }
2646
+ deferLiveExpiredRun(id, now, opts = {}) {
2647
+ const updated = now.toISOString();
2648
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
2649
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
2650
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
2651
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2652
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2653
+ ))`).run({
2654
+ $id: id,
2655
+ $deferredUntil: deferredUntil,
2656
+ $updated: updated,
2657
+ $now: updated,
2658
+ $daemonLeaseId: opts.daemonLeaseId ?? null
2659
+ });
2660
+ }
2508
2661
  recoverExpiredRunLeases(now = new Date, opts = {}) {
2509
- const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
2662
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
2663
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
2664
+ const rows = this.db.query(`SELECT * FROM loop_runs
2665
+ WHERE status = 'running' AND lease_expires_at <= ?
2666
+ ORDER BY lease_expires_at ASC
2667
+ LIMIT ?`).all(now.toISOString(), scanLimit);
2510
2668
  const recovered = [];
2511
2669
  for (const row of rows) {
2512
- if (row.pid && isProcessAlive(row.pid))
2670
+ if (recovered.length >= limit)
2671
+ break;
2672
+ if (row.pid && isProcessAlive(row.pid)) {
2673
+ this.deferLiveExpiredRun(row.id, now, opts);
2513
2674
  continue;
2514
- if (this.hasLiveWorkflowStepProcesses(row.id))
2675
+ }
2676
+ if (this.hasLiveWorkflowStepProcesses(row.id)) {
2677
+ this.deferLiveExpiredRun(row.id, now, opts);
2515
2678
  continue;
2679
+ }
2516
2680
  const finished = now.toISOString();
2517
2681
  this.db.exec("BEGIN IMMEDIATE");
2518
2682
  try {
@@ -2565,6 +2729,7 @@ class Store {
2565
2729
  loopRunId: row.id
2566
2730
  });
2567
2731
  this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2732
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
2568
2733
  }
2569
2734
  const loop = this.getLoop(row.loop_id);
2570
2735
  const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
@@ -2572,6 +2737,15 @@ class Store {
2572
2737
  const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2573
2738
  const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2574
2739
  this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2740
+ if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
2741
+ const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
2742
+ this.maybeArchiveGeneratedRouteWorkflow({
2743
+ workflowId: loop.target.workflowId,
2744
+ loopId: loop.id,
2745
+ workItemId,
2746
+ updated: finished
2747
+ });
2748
+ }
2575
2749
  }
2576
2750
  this.db.exec("COMMIT");
2577
2751
  } catch (error) {
@@ -2909,6 +3083,7 @@ var AUTH_ENV_KEYS = [
2909
3083
  "CODEWITH_HOME",
2910
3084
  "CODEX_HOME",
2911
3085
  "CURSOR_CONFIG_DIR",
3086
+ "CURSOR_API_KEY",
2912
3087
  "OPENCODE_CONFIG_DIR",
2913
3088
  "AICOPILOT_CONFIG_DIR",
2914
3089
  "ANTHROPIC_API_KEY",
@@ -3038,6 +3213,10 @@ function assertSupportedAgentOptions(target) {
3038
3213
  assertStringOption(target.model, `${target.provider}.model`);
3039
3214
  assertStringOption(target.agent, `${target.provider}.agent`);
3040
3215
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3216
+ assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3217
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3218
+ throw new Error(`${target.provider}.configIsolation must be safe or none`);
3219
+ }
3041
3220
  if (target.authProfile !== undefined && target.provider !== "codewith") {
3042
3221
  throw new Error(`${target.provider}.authProfile is supported only for codewith`);
3043
3222
  }
@@ -3050,6 +3229,8 @@ function assertSupportedAgentOptions(target) {
3050
3229
  if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
3051
3230
  throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
3052
3231
  }
3232
+ if (target.provider === "codex" && target.agent !== undefined)
3233
+ throw new Error("codex.agent is not supported");
3053
3234
  if (["codewith", "codex"].includes(target.provider)) {
3054
3235
  if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
3055
3236
  throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
@@ -3064,6 +3245,8 @@ function assertSupportedAgentOptions(target) {
3064
3245
  return;
3065
3246
  }
3066
3247
  if (target.provider === "cursor") {
3248
+ if (target.variant !== undefined)
3249
+ throw new Error("cursor.variant is not supported");
3067
3250
  if (target.permissionMode === "auto")
3068
3251
  throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
3069
3252
  if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
@@ -3105,10 +3288,8 @@ function agentArgs(target) {
3105
3288
  "set -eu",
3106
3289
  "if command -v agent >/dev/null 2>&1; then",
3107
3290
  ' exec agent "$@"',
3108
- "elif command -v cursor >/dev/null 2>&1; then",
3109
- ' exec cursor agent "$@"',
3110
3291
  "else",
3111
- " echo 'Executable not found in PATH: cursor agent or agent' >&2",
3292
+ " echo 'Executable not found in PATH: agent' >&2",
3112
3293
  " exit 127",
3113
3294
  "fi"
3114
3295
  ].join(`
@@ -3149,7 +3330,7 @@ function agentArgs(target) {
3149
3330
  case "codex":
3150
3331
  if (target.variant)
3151
3332
  args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
3152
- args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3333
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3153
3334
  if (isolation === "safe")
3154
3335
  args.push("--ignore-rules");
3155
3336
  if (target.cwd)
@@ -3219,7 +3400,7 @@ function commandSpec(target) {
3219
3400
  account: agentTarget.account,
3220
3401
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
3221
3402
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
3222
- preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
3403
+ preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
3223
3404
  stdin: agentTarget.prompt,
3224
3405
  allowlist: agentTarget.allowlist
3225
3406
  };
@@ -4602,12 +4783,14 @@ function claimDueRuns(deps) {
4602
4783
  const claimed = [];
4603
4784
  const skipped = [];
4604
4785
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
4786
+ if (maxClaims === 0)
4787
+ return { claims, claimed, completed: [], skipped, recovered, expired };
4605
4788
  for (const loop of deps.store.dueLoops(now)) {
4606
- if (claims.length >= maxClaims)
4789
+ if (claims.length + skipped.length >= maxClaims)
4607
4790
  break;
4608
4791
  const plan = dueSlots(loop, now);
4609
4792
  for (const slot of plan.slots) {
4610
- if (claims.length >= maxClaims)
4793
+ if (claims.length + skipped.length >= maxClaims)
4611
4794
  break;
4612
4795
  const run = claimSlot(deps, loop, slot);
4613
4796
  if (!run)
@@ -5057,7 +5240,7 @@ function enableStartup(result) {
5057
5240
  // package.json
5058
5241
  var package_default = {
5059
5242
  name: "@hasna/loops",
5060
- version: "0.3.47",
5243
+ version: "0.3.49",
5061
5244
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5062
5245
  type: "module",
5063
5246
  main: "dist/index.js",