@hasna/loops 0.3.47 → 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.
@@ -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,9 @@ 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;
589
626
  function rowToLoop(row) {
590
627
  return {
591
628
  id: row.id,
@@ -852,6 +889,7 @@ class Store {
852
889
  ensurePrivateStorePath(file);
853
890
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
854
891
  this.db = new Database(file);
892
+ this.db.exec("PRAGMA foreign_keys = ON;");
855
893
  this.db.exec("PRAGMA busy_timeout = 5000;");
856
894
  this.db.exec("PRAGMA journal_mode = WAL;");
857
895
  if (file !== ":memory:")
@@ -915,6 +953,7 @@ class Store {
915
953
  );
916
954
  CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
917
955
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
956
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
918
957
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
919
958
 
920
959
  CREATE TABLE IF NOT EXISTS daemon_lease (
@@ -1242,39 +1281,51 @@ class Store {
1242
1281
  }
1243
1282
  return rows.map(rowToLoop);
1244
1283
  }
1245
- dueLoops(now) {
1284
+ dueLoops(now, limit = 500) {
1246
1285
  const rows = this.db.query(`SELECT * FROM loops
1247
1286
  WHERE status = 'active'
1248
1287
  AND archived_at IS NULL
1249
1288
  AND next_run_at IS NOT NULL
1250
1289
  AND next_run_at <= ?
1251
- ORDER BY next_run_at ASC`).all(now.toISOString());
1290
+ ORDER BY next_run_at ASC
1291
+ LIMIT ?`).all(now.toISOString(), limit);
1252
1292
  return rows.map(rowToLoop);
1253
1293
  }
1254
1294
  updateLoop(id, patch, opts = {}) {
1255
- const current = this.getLoop(id);
1256
- if (!current)
1257
- throw new Error(`loop not found: ${id}`);
1258
1295
  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);
1296
+ this.db.exec("BEGIN IMMEDIATE");
1297
+ try {
1298
+ const current = this.getLoop(id);
1299
+ if (!current)
1300
+ throw new Error(`loop not found: ${id}`);
1301
+ const merged = { ...current, ...patch, updatedAt: updated };
1302
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1303
+ expires_at=$expiresAt, updated_at=$updated
1304
+ WHERE id=$id
1305
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1306
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1307
+ ))`).run({
1308
+ $id: id,
1309
+ $status: merged.status,
1310
+ $nextRun: merged.nextRunAt ?? null,
1311
+ $retrySlot: merged.retryScheduledFor ?? null,
1312
+ $expiresAt: merged.expiresAt ?? null,
1313
+ $updated: merged.updatedAt,
1314
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1315
+ $now: updated
1316
+ });
1317
+ if (res.changes !== 1)
1318
+ throw new Error("daemon lease lost");
1319
+ if (patch.status && patch.status !== "active") {
1320
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1321
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1322
+ }
1323
+ this.db.exec("COMMIT");
1324
+ } catch (error) {
1325
+ try {
1326
+ this.db.exec("ROLLBACK");
1327
+ } catch {}
1328
+ throw error;
1278
1329
  }
1279
1330
  const after = this.getLoop(id);
1280
1331
  if (!after)
@@ -2505,14 +2556,40 @@ class Store {
2505
2556
  }
2506
2557
  return rows.map(rowToRun);
2507
2558
  }
2559
+ deferLiveExpiredRun(id, now, opts = {}) {
2560
+ const updated = now.toISOString();
2561
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
2562
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
2563
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
2564
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2565
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2566
+ ))`).run({
2567
+ $id: id,
2568
+ $deferredUntil: deferredUntil,
2569
+ $updated: updated,
2570
+ $now: updated,
2571
+ $daemonLeaseId: opts.daemonLeaseId ?? null
2572
+ });
2573
+ }
2508
2574
  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());
2575
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
2576
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
2577
+ const rows = this.db.query(`SELECT * FROM loop_runs
2578
+ WHERE status = 'running' AND lease_expires_at <= ?
2579
+ ORDER BY lease_expires_at ASC
2580
+ LIMIT ?`).all(now.toISOString(), scanLimit);
2510
2581
  const recovered = [];
2511
2582
  for (const row of rows) {
2512
- if (row.pid && isProcessAlive(row.pid))
2583
+ if (recovered.length >= limit)
2584
+ break;
2585
+ if (row.pid && isProcessAlive(row.pid)) {
2586
+ this.deferLiveExpiredRun(row.id, now, opts);
2513
2587
  continue;
2514
- if (this.hasLiveWorkflowStepProcesses(row.id))
2588
+ }
2589
+ if (this.hasLiveWorkflowStepProcesses(row.id)) {
2590
+ this.deferLiveExpiredRun(row.id, now, opts);
2515
2591
  continue;
2592
+ }
2516
2593
  const finished = now.toISOString();
2517
2594
  this.db.exec("BEGIN IMMEDIATE");
2518
2595
  try {
@@ -2909,6 +2986,7 @@ var AUTH_ENV_KEYS = [
2909
2986
  "CODEWITH_HOME",
2910
2987
  "CODEX_HOME",
2911
2988
  "CURSOR_CONFIG_DIR",
2989
+ "CURSOR_API_KEY",
2912
2990
  "OPENCODE_CONFIG_DIR",
2913
2991
  "AICOPILOT_CONFIG_DIR",
2914
2992
  "ANTHROPIC_API_KEY",
@@ -3038,6 +3116,10 @@ function assertSupportedAgentOptions(target) {
3038
3116
  assertStringOption(target.model, `${target.provider}.model`);
3039
3117
  assertStringOption(target.agent, `${target.provider}.agent`);
3040
3118
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3119
+ assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3120
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3121
+ throw new Error(`${target.provider}.configIsolation must be safe or none`);
3122
+ }
3041
3123
  if (target.authProfile !== undefined && target.provider !== "codewith") {
3042
3124
  throw new Error(`${target.provider}.authProfile is supported only for codewith`);
3043
3125
  }
@@ -3050,6 +3132,8 @@ function assertSupportedAgentOptions(target) {
3050
3132
  if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
3051
3133
  throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
3052
3134
  }
3135
+ if (target.provider === "codex" && target.agent !== undefined)
3136
+ throw new Error("codex.agent is not supported");
3053
3137
  if (["codewith", "codex"].includes(target.provider)) {
3054
3138
  if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
3055
3139
  throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
@@ -3064,6 +3148,8 @@ function assertSupportedAgentOptions(target) {
3064
3148
  return;
3065
3149
  }
3066
3150
  if (target.provider === "cursor") {
3151
+ if (target.variant !== undefined)
3152
+ throw new Error("cursor.variant is not supported");
3067
3153
  if (target.permissionMode === "auto")
3068
3154
  throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
3069
3155
  if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
@@ -3105,10 +3191,8 @@ function agentArgs(target) {
3105
3191
  "set -eu",
3106
3192
  "if command -v agent >/dev/null 2>&1; then",
3107
3193
  ' exec agent "$@"',
3108
- "elif command -v cursor >/dev/null 2>&1; then",
3109
- ' exec cursor agent "$@"',
3110
3194
  "else",
3111
- " echo 'Executable not found in PATH: cursor agent or agent' >&2",
3195
+ " echo 'Executable not found in PATH: agent' >&2",
3112
3196
  " exit 127",
3113
3197
  "fi"
3114
3198
  ].join(`
@@ -3149,7 +3233,7 @@ function agentArgs(target) {
3149
3233
  case "codex":
3150
3234
  if (target.variant)
3151
3235
  args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
3152
- args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3236
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3153
3237
  if (isolation === "safe")
3154
3238
  args.push("--ignore-rules");
3155
3239
  if (target.cwd)
@@ -3219,7 +3303,7 @@ function commandSpec(target) {
3219
3303
  account: agentTarget.account,
3220
3304
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
3221
3305
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
3222
- preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
3306
+ preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
3223
3307
  stdin: agentTarget.prompt,
3224
3308
  allowlist: agentTarget.allowlist
3225
3309
  };
@@ -4602,12 +4686,14 @@ function claimDueRuns(deps) {
4602
4686
  const claimed = [];
4603
4687
  const skipped = [];
4604
4688
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
4689
+ if (maxClaims === 0)
4690
+ return { claims, claimed, completed: [], skipped, recovered, expired };
4605
4691
  for (const loop of deps.store.dueLoops(now)) {
4606
- if (claims.length >= maxClaims)
4692
+ if (claims.length + skipped.length >= maxClaims)
4607
4693
  break;
4608
4694
  const plan = dueSlots(loop, now);
4609
4695
  for (const slot of plan.slots) {
4610
- if (claims.length >= maxClaims)
4696
+ if (claims.length + skipped.length >= maxClaims)
4611
4697
  break;
4612
4698
  const run = claimSlot(deps, loop, slot);
4613
4699
  if (!run)
@@ -5057,7 +5143,7 @@ function enableStartup(result) {
5057
5143
  // package.json
5058
5144
  var package_default = {
5059
5145
  name: "@hasna/loops",
5060
- version: "0.3.47",
5146
+ version: "0.3.48",
5061
5147
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5062
5148
  type: "module",
5063
5149
  main: "dist/index.js",
package/dist/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)
@@ -5868,35 +5954,17 @@ async function stopDaemon(opts = {}) {
5868
5954
  // src/lib/doctor.ts
5869
5955
  var PROVIDER_COMMANDS = [
5870
5956
  "claude",
5871
- "cursor agent",
5957
+ "agent",
5872
5958
  "codewith",
5873
5959
  "aicopilot",
5874
5960
  "opencode",
5875
5961
  "codex"
5876
5962
  ];
5877
5963
  function hasCommand(command) {
5878
- if (command === "cursor agent") {
5879
- return hasCommand("cursor") || hasCommand("agent");
5880
- }
5881
5964
  const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
5882
5965
  return (result.status ?? 1) === 0;
5883
5966
  }
5884
5967
  function commandVersion(command) {
5885
- if (command === "cursor agent") {
5886
- const cursorResult = spawnSync3("cursor", ["agent", "--version"], {
5887
- encoding: "utf8",
5888
- stdio: ["ignore", "pipe", "pipe"]
5889
- });
5890
- if ((cursorResult.status ?? 1) === 0)
5891
- return (cursorResult.stdout || cursorResult.stderr).trim().split(/\r?\n/)[0];
5892
- const agentResult = spawnSync3("agent", ["--version"], {
5893
- encoding: "utf8",
5894
- stdio: ["ignore", "pipe", "pipe"]
5895
- });
5896
- if ((agentResult.status ?? 1) === 0)
5897
- return (agentResult.stdout || agentResult.stderr).trim().split(/\r?\n/)[0];
5898
- return;
5899
- }
5900
5968
  const result = spawnSync3(command, ["--version"], {
5901
5969
  encoding: "utf8",
5902
5970
  stdio: ["ignore", "pipe", "pipe"]
@@ -81,7 +81,7 @@ export declare class Store {
81
81
  archived?: boolean;
82
82
  includeArchived?: boolean;
83
83
  }): Loop[];
84
- dueLoops(now: Date): Loop[];
84
+ dueLoops(now: Date, limit?: number): Loop[];
85
85
  updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>, opts?: DaemonLeaseFence): Loop;
86
86
  renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
87
87
  archiveLoop(idOrName: string): Loop;
@@ -195,7 +195,11 @@ export declare class Store {
195
195
  status?: RunStatus;
196
196
  limit?: number;
197
197
  }): LoopRun[];
198
- recoverExpiredRunLeases(now?: Date, opts?: DaemonLeaseFence): LoopRun[];
198
+ private deferLiveExpiredRun;
199
+ recoverExpiredRunLeases(now?: Date, opts?: DaemonLeaseFence & {
200
+ limit?: number;
201
+ scanLimit?: number;
202
+ }): LoopRun[];
199
203
  expireLoops(now?: Date, opts?: DaemonLeaseFence): Loop[];
200
204
  countLoops(status?: LoopStatus, opts?: {
201
205
  archived?: boolean;