@hasna/loops 0.3.54 → 0.3.55

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
@@ -331,6 +331,15 @@ function optionalPositiveInteger(value, label) {
331
331
  throw new Error(`${label} must be a positive integer`);
332
332
  return value;
333
333
  }
334
+ function optionalTimeoutMs(value, label) {
335
+ if (value === undefined)
336
+ return;
337
+ if (value === null)
338
+ return null;
339
+ if (!Number.isInteger(value) || value <= 0)
340
+ throw new Error(`${label} must be a positive integer or null for unlimited`);
341
+ return value;
342
+ }
334
343
  function optionalBoolean(value, label) {
335
344
  if (value === undefined)
336
345
  return;
@@ -419,7 +428,7 @@ function validateTarget(value, label, opts) {
419
428
  assertObject(value, label);
420
429
  if (value.type === "command") {
421
430
  assertString(value.command, `${label}.command`);
422
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
431
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
423
432
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
424
433
  if (value.shell !== true && /\s/.test(value.command.trim())) {
425
434
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
@@ -438,7 +447,7 @@ function validateTarget(value, label, opts) {
438
447
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
439
448
  if (!providers.includes(value.provider))
440
449
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
441
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
450
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
442
451
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
443
452
  if (value.authProfile !== undefined) {
444
453
  assertString(value.authProfile, `${label}.authProfile`);
@@ -561,7 +570,7 @@ function normalizeCreateWorkflowInput(input, opts = {}) {
561
570
  target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
562
571
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
563
572
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
564
- timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
573
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
565
574
  account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
566
575
  };
567
576
  });
@@ -1307,6 +1316,17 @@ class Store {
1307
1316
  const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1308
1317
  return row ? rowToLoop(row) : undefined;
1309
1318
  }
1319
+ requireUniqueLoop(idOrName) {
1320
+ const byId = this.getLoop(idOrName);
1321
+ if (byId)
1322
+ return byId;
1323
+ const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1324
+ if (rows.length === 0)
1325
+ throw new Error(`loop not found: ${idOrName}`);
1326
+ if (rows.length > 1)
1327
+ throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
1328
+ return rowToLoop(rows[0]);
1329
+ }
1310
1330
  requireLoop(idOrName) {
1311
1331
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1312
1332
  throw new Error(`loop not found: ${idOrName}`);
@@ -1381,6 +1401,130 @@ class Store {
1381
1401
  throw new Error(`loop not found after update: ${id}`);
1382
1402
  return after;
1383
1403
  }
1404
+ activeLoopReferenceCount(workflowId) {
1405
+ const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
1406
+ let count = 0;
1407
+ for (const row of rows) {
1408
+ try {
1409
+ const target = JSON.parse(row.target_json);
1410
+ if (target.type === "workflow" && target.workflowId === workflowId)
1411
+ count += 1;
1412
+ } catch {}
1413
+ }
1414
+ return count;
1415
+ }
1416
+ archiveWorkflowIfUnreferenced(workflowId, updated) {
1417
+ if (this.activeLoopReferenceCount(workflowId) > 0)
1418
+ return;
1419
+ const workflow = this.getWorkflow(workflowId);
1420
+ if (!workflow || workflow.status !== "active")
1421
+ return;
1422
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
1423
+ if (res.changes !== 1)
1424
+ return;
1425
+ return this.getWorkflow(workflowId);
1426
+ }
1427
+ retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
1428
+ const updated = (opts.now ?? new Date).toISOString();
1429
+ this.db.exec("BEGIN IMMEDIATE");
1430
+ try {
1431
+ const current = this.requireLoop(idOrName);
1432
+ if (current.target.type !== "workflow")
1433
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1434
+ if (this.hasRunningRun(current.id))
1435
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1436
+ const workflow = this.requireWorkflow(workflowId);
1437
+ const target = { ...current.target, workflowId: workflow.id };
1438
+ if (opts.workflowTimeoutMs !== undefined)
1439
+ target.timeoutMs = opts.workflowTimeoutMs;
1440
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1441
+ WHERE id=$id
1442
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1443
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1444
+ ))`).run({
1445
+ $id: current.id,
1446
+ $target: JSON.stringify(target),
1447
+ $updated: updated,
1448
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1449
+ $now: updated
1450
+ });
1451
+ if (res.changes !== 1)
1452
+ throw new Error("daemon lease lost");
1453
+ this.db.exec("COMMIT");
1454
+ const after = this.getLoop(current.id);
1455
+ if (!after)
1456
+ throw new Error(`loop not found after retarget: ${current.id}`);
1457
+ return after;
1458
+ } catch (error) {
1459
+ try {
1460
+ this.db.exec("ROLLBACK");
1461
+ } catch {}
1462
+ throw error;
1463
+ }
1464
+ }
1465
+ createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
1466
+ const normalized = normalizeCreateWorkflowInput(workflowInput);
1467
+ const updated = (opts.now ?? new Date).toISOString();
1468
+ this.db.exec("BEGIN IMMEDIATE");
1469
+ try {
1470
+ const current = this.requireUniqueLoop(idOrName);
1471
+ if (current.target.type !== "workflow")
1472
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1473
+ if (this.hasRunningRun(current.id))
1474
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1475
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1476
+ const workflow = {
1477
+ id: genId(),
1478
+ name: normalized.name,
1479
+ description: normalized.description,
1480
+ version: normalized.version ?? 1,
1481
+ status: "active",
1482
+ goal: normalized.goal,
1483
+ steps: normalized.steps,
1484
+ createdAt: updated,
1485
+ updatedAt: updated
1486
+ };
1487
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1488
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
1489
+ $id: workflow.id,
1490
+ $name: workflow.name,
1491
+ $description: workflow.description ?? null,
1492
+ $version: workflow.version,
1493
+ $status: workflow.status,
1494
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
1495
+ $steps: JSON.stringify(workflow.steps),
1496
+ $created: workflow.createdAt,
1497
+ $updated: workflow.updatedAt
1498
+ });
1499
+ const target = { ...current.target, workflowId: workflow.id };
1500
+ if (opts.workflowTimeoutMs !== undefined)
1501
+ target.timeoutMs = opts.workflowTimeoutMs;
1502
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1503
+ WHERE id=$id
1504
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1505
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1506
+ ))`).run({
1507
+ $id: current.id,
1508
+ $target: JSON.stringify(target),
1509
+ $updated: updated,
1510
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1511
+ $now: updated
1512
+ });
1513
+ if (res.changes !== 1)
1514
+ throw new Error("daemon lease lost");
1515
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1516
+ this.db.exec("COMMIT");
1517
+ const loop = this.getLoop(current.id);
1518
+ if (!loop)
1519
+ throw new Error(`loop not found after retarget: ${current.id}`);
1520
+ return { loop, workflow, previousWorkflow, archivedOld };
1521
+ } catch (error) {
1522
+ try {
1523
+ this.db.exec("ROLLBACK");
1524
+ } catch {}
1525
+ throw error;
1526
+ }
1527
+ }
1384
1528
  renameLoop(id, name, opts = {}) {
1385
1529
  const current = this.getLoop(id);
1386
1530
  if (!current)
@@ -3606,7 +3750,7 @@ function commandSpec(target) {
3606
3750
  cwd: commandTarget.cwd,
3607
3751
  shell: commandTarget.shell,
3608
3752
  env: commandTarget.env,
3609
- timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3753
+ timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
3610
3754
  idleTimeoutMs: commandTarget.idleTimeoutMs,
3611
3755
  account: commandTarget.account,
3612
3756
  accountTool: commandTarget.account?.tool
@@ -3617,7 +3761,7 @@ function commandSpec(target) {
3617
3761
  command: providerCommand(agentTarget.provider),
3618
3762
  args: agentArgs(agentTarget),
3619
3763
  cwd: agentTarget.cwd,
3620
- timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3764
+ timeoutMs: agentTarget.timeoutMs ?? null,
3621
3765
  idleTimeoutMs: agentTarget.idleTimeoutMs,
3622
3766
  account: agentTarget.account,
3623
3767
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
@@ -3805,12 +3949,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3805
3949
  if (opts.signal?.aborted)
3806
3950
  abortHandler();
3807
3951
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3808
- const timer = setTimeout(() => {
3952
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3809
3953
  timedOut = true;
3810
3954
  if (child.pid)
3811
3955
  killProcessGroup(child.pid);
3812
- }, spec.timeoutMs);
3813
- timer.unref();
3956
+ }, spec.timeoutMs) : undefined;
3957
+ timer?.unref();
3814
3958
  let idleTimer;
3815
3959
  const resetIdleTimer = () => {
3816
3960
  if (!spec.idleTimeoutMs)
@@ -3842,7 +3986,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3842
3986
  } catch (err) {
3843
3987
  error = err instanceof Error ? err.message : String(err);
3844
3988
  } finally {
3845
- clearTimeout(timer);
3989
+ if (timer)
3990
+ clearTimeout(timer);
3846
3991
  if (idleTimer)
3847
3992
  clearTimeout(idleTimer);
3848
3993
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -3984,12 +4129,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3984
4129
  if (opts.signal?.aborted)
3985
4130
  abortHandler();
3986
4131
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3987
- const timer = setTimeout(() => {
4132
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3988
4133
  timedOut = true;
3989
4134
  if (child.pid)
3990
4135
  killProcessGroup(child.pid);
3991
- }, spec.timeoutMs);
3992
- timer.unref();
4136
+ }, spec.timeoutMs) : undefined;
4137
+ timer?.unref();
3993
4138
  let idleTimer;
3994
4139
  const resetIdleTimer = () => {
3995
4140
  if (!spec.idleTimeoutMs)
@@ -4021,7 +4166,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
4021
4166
  } catch (err) {
4022
4167
  error = err instanceof Error ? err.message : String(err);
4023
4168
  } finally {
4024
- clearTimeout(timer);
4169
+ if (timer)
4170
+ clearTimeout(timer);
4025
4171
  if (idleTimer)
4026
4172
  clearTimeout(idleTimer);
4027
4173
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -4446,7 +4592,7 @@ ${result.stderr}`);
4446
4592
  // src/lib/workflow-runner.ts
4447
4593
  function targetWithStepAccount(step) {
4448
4594
  const account = step.account ?? step.target.account;
4449
- const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
4595
+ const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
4450
4596
  if (!account && timeoutMs === step.target.timeoutMs)
4451
4597
  return step.target;
4452
4598
  return { ...step.target, account, timeoutMs };
@@ -4742,7 +4888,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4742
4888
  })
4743
4889
  });
4744
4890
  }
4745
- const controller = loop.target.timeoutMs ? new AbortController : undefined;
4891
+ const workflowTimeoutMs = typeof loop.target.timeoutMs === "number" ? loop.target.timeoutMs : undefined;
4892
+ const controller = workflowTimeoutMs !== undefined ? new AbortController : undefined;
4746
4893
  let workflowTimedOut = false;
4747
4894
  const externalAbort = () => controller?.abort();
4748
4895
  if (controller && opts.signal?.aborted)
@@ -4752,13 +4899,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4752
4899
  const timer = controller ? setTimeout(() => {
4753
4900
  workflowTimedOut = true;
4754
4901
  controller.abort();
4755
- }, loop.target.timeoutMs) : undefined;
4902
+ }, workflowTimeoutMs) : undefined;
4756
4903
  timer?.unref();
4757
4904
  try {
4758
4905
  return await executeWorkflow(store, workflow, {
4759
4906
  ...opts,
4760
4907
  signal: controller?.signal ?? opts.signal,
4761
- signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
4908
+ signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${workflowTimeoutMs}ms` : undefined,
4762
4909
  loop,
4763
4910
  loopRun: run,
4764
4911
  scheduledFor: run.scheduledFor,
@@ -5534,7 +5681,11 @@ function runDoctor(store) {
5534
5681
  if (loop.target.type === "workflow") {
5535
5682
  const workflow = store.requireWorkflow(loop.target.workflowId);
5536
5683
  for (const step of workflowExecutionOrder(workflow)) {
5537
- 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 });
5684
+ preflightTarget({
5685
+ ...step.target,
5686
+ account: step.account ?? step.target.account,
5687
+ timeoutMs: step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs
5688
+ }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id }, { machine: loop.machine });
5538
5689
  }
5539
5690
  } else {
5540
5691
  preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });
@@ -6014,7 +6165,7 @@ function buildScriptInventoryReport(store, opts = {}) {
6014
6165
  // package.json
6015
6166
  var package_default = {
6016
6167
  name: "@hasna/loops",
6017
- version: "0.3.54",
6168
+ version: "0.3.55",
6018
6169
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
6019
6170
  type: "module",
6020
6171
  main: "dist/index.js",
@@ -6144,7 +6295,8 @@ var TEMPLATE_SUMMARIES = [
6144
6295
  { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
6145
6296
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6146
6297
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6147
- { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
6298
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." },
6299
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6148
6300
  ]
6149
6301
  },
6150
6302
  {
@@ -6174,7 +6326,8 @@ var TEMPLATE_SUMMARIES = [
6174
6326
  { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
6175
6327
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6176
6328
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6177
- { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
6329
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." },
6330
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6178
6331
  ]
6179
6332
  },
6180
6333
  {
@@ -6202,7 +6355,7 @@ var TEMPLATE_SUMMARIES = [
6202
6355
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6203
6356
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6204
6357
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
6205
- { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
6358
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6206
6359
  ]
6207
6360
  },
6208
6361
  {
@@ -6221,7 +6374,8 @@ var TEMPLATE_SUMMARIES = [
6221
6374
  { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
6222
6375
  { name: "provider", default: "codewith", description: "Agent provider." },
6223
6376
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6224
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6377
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6378
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6225
6379
  ]
6226
6380
  },
6227
6381
  {
@@ -6326,6 +6480,30 @@ function taskLabel(input) {
6326
6480
  const head = input.taskTitle?.trim() || input.taskId;
6327
6481
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
6328
6482
  }
6483
+ var UNLIMITED_AGENT_TIMEOUT_MS = null;
6484
+ function agentTimeoutMs(input) {
6485
+ return input.timeoutMs === undefined ? UNLIMITED_AGENT_TIMEOUT_MS : input.timeoutMs;
6486
+ }
6487
+ function parseTemplateTimeoutMs(raw) {
6488
+ if (raw === undefined || raw.trim() === "")
6489
+ return;
6490
+ const normalized = raw.trim().toLowerCase();
6491
+ if (["unlimited", "none", "null", "never", "off", "false"].includes(normalized))
6492
+ return null;
6493
+ const value = Number(raw);
6494
+ if (!Number.isInteger(value) || value <= 0) {
6495
+ throw new Error("timeoutMs must be a positive integer number of milliseconds, or unlimited/none/null");
6496
+ }
6497
+ return value;
6498
+ }
6499
+ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
6500
+ if (raw === undefined || raw.trim() === "")
6501
+ return fallbackMs;
6502
+ const value = Number(raw);
6503
+ if (!Number.isInteger(value) || value <= 0)
6504
+ throw new Error(`${label} must be a positive integer number of milliseconds`);
6505
+ return value;
6506
+ }
6329
6507
  function stableIndex(seed, size) {
6330
6508
  let hash = 2166136261;
6331
6509
  for (let i = 0;i < seed.length; i += 1) {
@@ -6597,7 +6775,7 @@ function agentTarget(input, prompt, role, seed, plan) {
6597
6775
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
6598
6776
  },
6599
6777
  account: accountForRole(input, role, seed),
6600
- timeoutMs: 45 * 60000
6778
+ timeoutMs: agentTimeoutMs(input)
6601
6779
  };
6602
6780
  }
6603
6781
  function workflowStepsWithWorktree(plan, steps) {
@@ -7040,18 +7218,15 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
7040
7218
  name: "Worker",
7041
7219
  description: "Implement the todos task and record evidence.",
7042
7220
  target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
7043
- timeoutMs: 45 * 60000
7221
+ timeoutMs: agentTimeoutMs(input)
7044
7222
  },
7045
7223
  {
7046
7224
  id: "verifier",
7047
7225
  name: "Verifier",
7048
7226
  description: "Adversarially verify worker output and update todos.",
7049
7227
  dependsOn: ["worker"],
7050
- target: {
7051
- ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
7052
- idleTimeoutMs: 10 * 60000
7053
- },
7054
- timeoutMs: 30 * 60000
7228
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
7229
+ timeoutMs: agentTimeoutMs(input)
7055
7230
  }
7056
7231
  ])
7057
7232
  };
@@ -7212,7 +7387,7 @@ function renderTaskLifecycleWorkflow(input) {
7212
7387
  name: "Triage",
7213
7388
  description: "Check task eligibility, duplicates, dependencies, and automation gates.",
7214
7389
  target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
7215
- timeoutMs: 20 * 60000
7390
+ timeoutMs: agentTimeoutMs(input)
7216
7391
  },
7217
7392
  {
7218
7393
  id: "triage-gate",
@@ -7234,7 +7409,7 @@ function renderTaskLifecycleWorkflow(input) {
7234
7409
  description: "Create a concise implementation plan and split unsafe scope before work starts.",
7235
7410
  dependsOn: ["triage-gate"],
7236
7411
  target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
7237
- timeoutMs: 25 * 60000
7412
+ timeoutMs: agentTimeoutMs(input)
7238
7413
  },
7239
7414
  {
7240
7415
  id: "planner-gate",
@@ -7256,18 +7431,15 @@ function renderTaskLifecycleWorkflow(input) {
7256
7431
  description: "Implement the todos task according to triage and planner evidence.",
7257
7432
  dependsOn: ["planner-gate"],
7258
7433
  target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
7259
- timeoutMs: 45 * 60000
7434
+ timeoutMs: agentTimeoutMs(input)
7260
7435
  },
7261
7436
  {
7262
7437
  id: "verifier",
7263
7438
  name: "Verifier",
7264
7439
  description: "Adversarially verify worker output and update todos.",
7265
7440
  dependsOn: ["worker"],
7266
- target: {
7267
- ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
7268
- idleTimeoutMs: 10 * 60000
7269
- },
7270
- timeoutMs: 30 * 60000
7441
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
7442
+ timeoutMs: agentTimeoutMs(input)
7271
7443
  }
7272
7444
  ])
7273
7445
  };
@@ -7337,18 +7509,15 @@ function renderEventWorkerVerifierWorkflow(input) {
7337
7509
  name: "Worker",
7338
7510
  description: "Handle the Hasna event and record evidence.",
7339
7511
  target: agentTarget(input, workerPrompt, "worker", seed, plan),
7340
- timeoutMs: 45 * 60000
7512
+ timeoutMs: agentTimeoutMs(input)
7341
7513
  },
7342
7514
  {
7343
7515
  id: "verifier",
7344
7516
  name: "Verifier",
7345
7517
  description: "Adversarially verify event handling.",
7346
7518
  dependsOn: ["worker"],
7347
- target: {
7348
- ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
7349
- idleTimeoutMs: 10 * 60000
7350
- },
7351
- timeoutMs: 30 * 60000
7519
+ target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
7520
+ timeoutMs: agentTimeoutMs(input)
7352
7521
  }
7353
7522
  ])
7354
7523
  };
@@ -7360,7 +7529,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
7360
7529
  throw new Error("projectPath is required");
7361
7530
  const seed = `${input.projectPath}:${input.objective}`;
7362
7531
  const plan = worktreePlan(input, seed);
7363
- const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
7532
+ const timeoutMs = agentTimeoutMs(input);
7364
7533
  const workerPrompt = [
7365
7534
  `/goal ${input.objective}`,
7366
7535
  "",
@@ -7398,11 +7567,8 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
7398
7567
  name: "Verifier",
7399
7568
  description: "Adversarially verify the bounded objective result.",
7400
7569
  dependsOn: ["worker"],
7401
- target: {
7402
- ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
7403
- idleTimeoutMs: 10 * 60000
7404
- },
7405
- timeoutMs: Math.min(timeoutMs, 30 * 60000)
7570
+ target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
7571
+ timeoutMs
7406
7572
  }
7407
7573
  ])
7408
7574
  };
@@ -7431,7 +7597,7 @@ function renderLifecycleBoundedTemplate(id, values) {
7431
7597
  worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
7432
7598
  worktreeRoot: values.worktreeRoot,
7433
7599
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7434
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
7600
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
7435
7601
  };
7436
7602
  if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
7437
7603
  const taskId = values.taskId ?? "";
@@ -7468,6 +7634,7 @@ function renderLifecycleBoundedTemplate(id, values) {
7468
7634
  worktreeMode: values.worktreeMode ?? "required",
7469
7635
  worktreeRoot: values.worktreeRoot,
7470
7636
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7637
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
7471
7638
  eventId: values.eventId,
7472
7639
  eventType: values.eventType
7473
7640
  });
@@ -7534,6 +7701,8 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
7534
7701
  if (!checkCommand.trim())
7535
7702
  throw new Error("checkCommand is required");
7536
7703
  const seed = `${projectPath}:${checkCommand}`;
7704
+ const timeoutMs = parseDeterministicTimeoutMs(values.timeoutMs, 5 * 60000);
7705
+ const idleTimeoutMs = parseDeterministicTimeoutMs(values.idleTimeoutMs, 60000, "idleTimeoutMs");
7537
7706
  return {
7538
7707
  name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
7539
7708
  description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
@@ -7548,10 +7717,10 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
7548
7717
  command: "bash",
7549
7718
  args: ["-lc", checkCommand],
7550
7719
  cwd: projectPath,
7551
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
7552
- idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
7720
+ timeoutMs,
7721
+ idleTimeoutMs
7553
7722
  },
7554
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
7723
+ timeoutMs
7555
7724
  }
7556
7725
  ]
7557
7726
  };
@@ -7589,6 +7758,7 @@ function renderBuiltinLoopTemplate(id, values) {
7589
7758
  worktreeMode: values.worktreeMode,
7590
7759
  worktreeRoot: values.worktreeRoot,
7591
7760
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7761
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
7592
7762
  eventId: values.eventId,
7593
7763
  eventType: values.eventType
7594
7764
  });
@@ -7620,7 +7790,8 @@ function renderBuiltinLoopTemplate(id, values) {
7620
7790
  manualBreakGlass: booleanVar(values.manualBreakGlass),
7621
7791
  worktreeMode: values.worktreeMode,
7622
7792
  worktreeRoot: values.worktreeRoot,
7623
- worktreeBranchPrefix: values.worktreeBranchPrefix
7793
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
7794
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
7624
7795
  });
7625
7796
  }
7626
7797
  if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
@@ -7648,7 +7819,7 @@ function renderBuiltinLoopTemplate(id, values) {
7648
7819
  worktreeMode: values.worktreeMode,
7649
7820
  worktreeRoot: values.worktreeRoot,
7650
7821
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7651
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
7822
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
7652
7823
  });
7653
7824
  }
7654
7825
  throw new Error(`unknown template: ${id}`);
@@ -7793,6 +7964,43 @@ function publicWorkflowBody(body) {
7793
7964
  const { id: _id, status: _status, createdAt: _createdAt, updatedAt: _updatedAt, ...bodyOnly } = value;
7794
7965
  return bodyOnly;
7795
7966
  }
7967
+ function workflowWithAgentTimeouts(workflow, timeoutMs, opts = {}) {
7968
+ let changed = false;
7969
+ const agentStepIds = [];
7970
+ const steps = workflow.steps.map((step) => {
7971
+ if (step.target.type !== "agent")
7972
+ return step;
7973
+ agentStepIds.push(step.id);
7974
+ const target = { ...step.target, timeoutMs };
7975
+ if (timeoutMs === null && target.idleTimeoutMs !== undefined) {
7976
+ delete target.idleTimeoutMs;
7977
+ changed = true;
7978
+ }
7979
+ if (step.timeoutMs !== timeoutMs || step.target.timeoutMs !== timeoutMs)
7980
+ changed = true;
7981
+ return {
7982
+ ...step,
7983
+ target,
7984
+ timeoutMs
7985
+ };
7986
+ });
7987
+ return {
7988
+ body: {
7989
+ name: opts.name ?? workflow.name,
7990
+ description: workflow.description,
7991
+ version: workflow.version,
7992
+ goal: workflow.goal,
7993
+ steps
7994
+ },
7995
+ changed,
7996
+ agentStepIds
7997
+ };
7998
+ }
7999
+ function workflowTimeoutMigrationName(workflow, timeoutMs) {
8000
+ const policy = timeoutMs === null ? "agent-timeout-unlimited" : `agent-timeout-${timeoutMs}ms`;
8001
+ const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8002
+ return `${workflow.name}-${policy}-${suffix}`;
8003
+ }
7796
8004
  function printTextOutput(value) {
7797
8005
  for (const line of textOutputBlocks(value, { indent: " " }))
7798
8006
  console.log(line);
@@ -7833,6 +8041,17 @@ function positiveDuration(raw, label) {
7833
8041
  throw new Error(`${label} must be greater than zero`);
7834
8042
  return value;
7835
8043
  }
8044
+ function timeoutDuration(raw, label) {
8045
+ if (raw === undefined)
8046
+ return;
8047
+ const normalized = raw.trim().toLowerCase();
8048
+ if (["none", "unlimited", "null", "never", "off", "false"].includes(normalized))
8049
+ return null;
8050
+ const value = parseDuration(raw);
8051
+ if (!Number.isFinite(value) || value <= 0)
8052
+ throw new Error(`${label} must be a duration greater than zero, or none/unlimited`);
8053
+ return value;
8054
+ }
7836
8055
  function parsePolicy(opts) {
7837
8056
  const catchUp = opts.catchUp ?? "latest";
7838
8057
  if (!["none", "latest", "all"].includes(catchUp))
@@ -8563,6 +8782,7 @@ function routeTodosTaskEvent(event, opts) {
8563
8782
  variant: opts.variant,
8564
8783
  agent: opts.agent,
8565
8784
  addDirs: listFromRepeatedOpts(opts.addDir),
8785
+ timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
8566
8786
  permissionMode,
8567
8787
  sandbox,
8568
8788
  manualBreakGlass: Boolean(opts.manualBreakGlass),
@@ -8785,6 +9005,7 @@ function routeGenericEvent(event, opts) {
8785
9005
  variant: opts.variant,
8786
9006
  agent: opts.agent,
8787
9007
  addDirs: listFromRepeatedOpts(opts.addDir),
9008
+ timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
8788
9009
  permissionMode,
8789
9010
  sandbox,
8790
9011
  manualBreakGlass: Boolean(opts.manualBreakGlass),
@@ -9133,7 +9354,7 @@ function permissionModeFromOpts(opts, provider) {
9133
9354
  return mode;
9134
9355
  }
9135
9356
  var create = program.command("create").description("create loops");
9136
- addGoalOptions(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").option("--preflight-each-run", "check target executables/accounts before every scheduled run").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
9357
+ addGoalOptions(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; use none/unlimited for no timeout").option("--no-shell", "execute without a shell").option("--preflight-each-run", "check target executables/accounts before every scheduled run").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
9137
9358
  const store = new Store;
9138
9359
  try {
9139
9360
  const target = {
@@ -9141,7 +9362,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
9141
9362
  command: opts.cmd,
9142
9363
  cwd: opts.cwd,
9143
9364
  shell: opts.shell,
9144
- timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
9365
+ timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
9145
9366
  account: accountFromOpts(opts),
9146
9367
  preflight: runtimePreflightFromOpts(opts)
9147
9368
  };
@@ -9153,7 +9374,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
9153
9374
  store.close();
9154
9375
  }
9155
9376
  });
9156
- addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").option("--prompt <prompt>", "agent prompt").option("--prompt-file <file>", "read the agent prompt from a markdown/text file").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe").option("--preflight-each-run", "check provider/account readiness before every scheduled run").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
9377
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").option("--prompt <prompt>", "agent prompt").option("--prompt-file <file>", "read the agent prompt from a markdown/text file").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "run timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe").option("--preflight-each-run", "check provider/account readiness before every scheduled run").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
9157
9378
  const provider = opts.provider;
9158
9379
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
9159
9380
  throw new Error("unsupported provider");
@@ -9174,7 +9395,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
9174
9395
  agent: opts.agent,
9175
9396
  authProfile: providerAuthProfileFromOpts(opts, provider),
9176
9397
  addDirs: listFromRepeatedOpts(opts.addDir),
9177
- timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
9398
+ timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
9178
9399
  configIsolation: opts.configIsolation,
9179
9400
  permissionMode: permissionModeFromOpts(opts, provider),
9180
9401
  sandbox: sandboxFromOpts(opts, provider),
@@ -9190,13 +9411,14 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
9190
9411
  store.close();
9191
9412
  }
9192
9413
  });
9193
- addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name").option("--preflight-each-run", "check workflow steps before every scheduled run").option("--preflight", "check workflow step executables/accounts before storing the loop")))).action((name, opts) => {
9414
+ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name").option("--timeout <duration>", "workflow run timeout; use none/unlimited for no workflow-level timeout").option("--preflight-each-run", "check workflow steps before every scheduled run").option("--preflight", "check workflow step executables/accounts before storing the loop")))).action((name, opts) => {
9194
9415
  const store = new Store;
9195
9416
  try {
9196
9417
  const workflow = store.requireWorkflow(opts.workflow);
9197
9418
  const target = {
9198
9419
  type: "workflow",
9199
9420
  workflowId: workflow.id,
9421
+ timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
9200
9422
  preflight: runtimePreflightFromOpts(opts)
9201
9423
  };
9202
9424
  const input = baseCreateInput(name, opts, target);
@@ -9214,10 +9436,10 @@ var events = program.command("events").description("handle Hasna event envelopes
9214
9436
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
9215
9437
  var goal = program.command("goal").description("inspect goal runs");
9216
9438
  function addRouteEventOptions(command) {
9217
- return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
9439
+ return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
9218
9440
  }
9219
9441
  function addTodosDrainOptions(command, opts = {}) {
9220
- let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
9442
+ let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
9221
9443
  if (opts.includeDryRun ?? true) {
9222
9444
  configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
9223
9445
  }
@@ -9270,6 +9492,7 @@ function routeDrainArgs(opts) {
9270
9492
  add("--agent", opts.agent);
9271
9493
  for (const dir of listFromRepeatedOpts(opts.addDir) ?? [])
9272
9494
  add("--add-dir", dir);
9495
+ add("--timeout", opts.timeout);
9273
9496
  add("--permission-mode", opts.permissionMode);
9274
9497
  add("--sandbox", opts.sandbox);
9275
9498
  addBool("--manual-break-glass", opts.manualBreakGlass);
@@ -9581,7 +9804,7 @@ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>")
9581
9804
  }
9582
9805
  });
9583
9806
  var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
9584
- eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
9807
+ eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
9585
9808
  const event = await readEventEnvelopeFromStdin();
9586
9809
  const result = routeTodosTaskEvent(event, opts);
9587
9810
  print(result.value, result.human);
@@ -9590,7 +9813,7 @@ var eventsDrain = events.command("drain").description("drain durable source queu
9590
9813
  addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
9591
9814
  drainTodosTaskRoutes(opts);
9592
9815
  });
9593
- eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
9816
+ eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
9594
9817
  const event = await readEventEnvelopeFromStdin();
9595
9818
  const result = routeGenericEvent(event, opts);
9596
9819
  print(result.value, result.human);
@@ -9806,6 +10029,75 @@ workflows.command("recover <runId>").description("reset interrupted running work
9806
10029
  store.close();
9807
10030
  }
9808
10031
  });
10032
+ workflows.command("migrate-agent-timeouts").description("append-only migrate active workflow loops to a new agent timeout policy").option("--loop <idOrName>", "migrate only one loop instead of all active workflow loops").option("--timeout <duration>", "agent timeout policy; use none/unlimited for no timeout", "none").option("--apply", "create new workflow specs and retarget eligible loops").option("--archive-old", "archive old workflow specs after retargeting when no active loops still reference them").action((opts) => {
10033
+ const store = new Store;
10034
+ try {
10035
+ const timeoutMs = timeoutDuration(opts.timeout, "--timeout") ?? null;
10036
+ const candidateLoops = opts.loop ? [store.requireUniqueLoop(opts.loop)] : store.listLoops({ status: "active", limit: 1e4 }).filter((loop) => loop.target.type === "workflow");
10037
+ const rows = [];
10038
+ for (const loop of candidateLoops) {
10039
+ if (loop.archivedAt) {
10040
+ rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is archived" });
10041
+ continue;
10042
+ }
10043
+ if (loop.target.type !== "workflow") {
10044
+ rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is not a workflow loop" });
10045
+ continue;
10046
+ }
10047
+ const workflow = store.requireWorkflow(loop.target.workflowId);
10048
+ const nextWorkflowName = workflowTimeoutMigrationName(workflow, timeoutMs);
10049
+ const migration = workflowWithAgentTimeouts(workflow, timeoutMs, { name: nextWorkflowName });
10050
+ if (migration.agentStepIds.length === 0) {
10051
+ rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "skipped", reason: "workflow has no agent steps" });
10052
+ continue;
10053
+ }
10054
+ if (!migration.changed) {
10055
+ rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "skipped", reason: "agent timeout policy already matches" });
10056
+ continue;
10057
+ }
10058
+ if (store.hasRunningRun(loop.id)) {
10059
+ rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "blocked", reason: "loop has a running run; retry after it finishes" });
10060
+ continue;
10061
+ }
10062
+ if (!opts.apply) {
10063
+ rows.push({
10064
+ loop: publicLoop(loop),
10065
+ workflow: publicWorkflow(workflow),
10066
+ status: "would_migrate",
10067
+ agentStepIds: migration.agentStepIds,
10068
+ nextWorkflowName,
10069
+ timeoutMs
10070
+ });
10071
+ continue;
10072
+ }
10073
+ const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
10074
+ workflowTimeoutMs: timeoutMs,
10075
+ archiveOld: Boolean(opts.archiveOld)
10076
+ });
10077
+ rows.push({
10078
+ loop: publicLoop(migrated.loop),
10079
+ previousWorkflow: publicWorkflow(migrated.previousWorkflow),
10080
+ workflow: publicWorkflow(migrated.workflow),
10081
+ archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
10082
+ status: "migrated",
10083
+ agentStepIds: migration.agentStepIds,
10084
+ timeoutMs
10085
+ });
10086
+ }
10087
+ const summary = {
10088
+ apply: Boolean(opts.apply),
10089
+ timeoutMs,
10090
+ total: rows.length,
10091
+ migrated: rows.filter((row) => row.status === "migrated").length,
10092
+ wouldMigrate: rows.filter((row) => row.status === "would_migrate").length,
10093
+ blocked: rows.filter((row) => row.status === "blocked").length,
10094
+ skipped: rows.filter((row) => row.status === "skipped").length
10095
+ };
10096
+ print({ summary, rows }, opts.apply ? `migrated=${summary.migrated} blocked=${summary.blocked} skipped=${summary.skipped}` : `would_migrate=${summary.wouldMigrate} blocked=${summary.blocked} skipped=${summary.skipped}`);
10097
+ } finally {
10098
+ store.close();
10099
+ }
10100
+ });
9809
10101
  workflows.command("archive <idOrName>").action((idOrName) => {
9810
10102
  const store = new Store;
9811
10103
  try {