@hasna/loops 0.3.53 → 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/index.js CHANGED
@@ -312,6 +312,8 @@ function updateReadyFlags(nodes, planStatus) {
312
312
  }
313
313
 
314
314
  // src/lib/workflow-spec.ts
315
+ import { readFileSync } from "fs";
316
+ import { isAbsolute, resolve } from "path";
315
317
  function assertObject(value, label) {
316
318
  if (!value || typeof value !== "object" || Array.isArray(value))
317
319
  throw new Error(`${label} must be an object`);
@@ -327,6 +329,15 @@ function optionalPositiveInteger(value, label) {
327
329
  throw new Error(`${label} must be a positive integer`);
328
330
  return value;
329
331
  }
332
+ function optionalTimeoutMs(value, label) {
333
+ if (value === undefined)
334
+ return;
335
+ if (value === null)
336
+ return null;
337
+ if (!Number.isInteger(value) || value <= 0)
338
+ throw new Error(`${label} must be a positive integer or null for unlimited`);
339
+ return value;
340
+ }
330
341
  function optionalBoolean(value, label) {
331
342
  if (value === undefined)
332
343
  return;
@@ -357,6 +368,38 @@ function optionalAccountRef(value, label) {
357
368
  tool: typeof value.tool === "string" ? value.tool.trim() : undefined
358
369
  };
359
370
  }
371
+ function promptFilePath(rawPath, opts) {
372
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
373
+ }
374
+ function readPromptFile(rawPath, label, opts) {
375
+ assertString(rawPath, `${label}.promptFile`);
376
+ const path = promptFilePath(rawPath.trim(), opts);
377
+ let prompt;
378
+ try {
379
+ prompt = readFileSync(path, "utf8");
380
+ } catch (error) {
381
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
382
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
383
+ }
384
+ if (prompt.trim() === "")
385
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
386
+ return { prompt, promptSource: { type: "file", path } };
387
+ }
388
+ function matchingPromptSource(value, prompt, opts) {
389
+ if (!value || typeof value !== "object" || Array.isArray(value))
390
+ return;
391
+ if (value.type !== "file")
392
+ return;
393
+ const rawPath = value.path;
394
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
395
+ return;
396
+ const path = promptFilePath(rawPath.trim(), opts);
397
+ try {
398
+ return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
399
+ } catch {
400
+ return;
401
+ }
402
+ }
360
403
  function normalizeGoalSpec(value, label = "goal") {
361
404
  if (value === undefined)
362
405
  return;
@@ -379,11 +422,11 @@ function normalizeGoalSpec(value, label = "goal") {
379
422
  autoExecute
380
423
  };
381
424
  }
382
- function validateTarget(value, label) {
425
+ function validateTarget(value, label, opts) {
383
426
  assertObject(value, label);
384
427
  if (value.type === "command") {
385
428
  assertString(value.command, `${label}.command`);
386
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
429
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
387
430
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
388
431
  if (value.shell !== true && /\s/.test(value.command.trim())) {
389
432
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
@@ -392,11 +435,17 @@ function validateTarget(value, label) {
392
435
  }
393
436
  if (value.type === "agent") {
394
437
  assertString(value.provider, `${label}.provider`);
395
- assertString(value.prompt, `${label}.prompt`);
438
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
439
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
440
+ if (hasPrompt && hasPromptFile)
441
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
442
+ if (!hasPrompt && !hasPromptFile)
443
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
444
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
396
445
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
397
446
  if (!providers.includes(value.provider))
398
447
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
399
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
448
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
400
449
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
401
450
  if (value.authProfile !== undefined) {
402
451
  assertString(value.authProfile, `${label}.authProfile`);
@@ -493,11 +542,14 @@ function validateTarget(value, label) {
493
542
  if (value.routing.eventSource !== undefined)
494
543
  assertString(value.routing.eventSource, `${label}.routing.eventSource`);
495
544
  }
496
- return value;
545
+ const target = { ...value };
546
+ delete target.promptFile;
547
+ delete target.promptSource;
548
+ return { ...target, ...promptFields };
497
549
  }
498
550
  throw new Error(`${label}.type must be command or agent`);
499
551
  }
500
- function normalizeCreateWorkflowInput(input) {
552
+ function normalizeCreateWorkflowInput(input, opts = {}) {
501
553
  assertString(input.name, "workflow.name");
502
554
  const goal = normalizeGoalSpec(input.goal, "goal");
503
555
  if (!Array.isArray(input.steps) || input.steps.length === 0)
@@ -513,10 +565,10 @@ function normalizeCreateWorkflowInput(input) {
513
565
  ...step,
514
566
  id: step.id,
515
567
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
516
- target: validateTarget(step.target, `workflow.steps[${index}].target`),
568
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
517
569
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
518
570
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
519
- timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
571
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
520
572
  account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
521
573
  };
522
574
  });
@@ -556,7 +608,7 @@ function workflowExecutionOrder(workflow) {
556
608
  visit(step);
557
609
  return order;
558
610
  }
559
- function workflowBodyFromJson(value, fallbackName) {
611
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
560
612
  assertObject(value, "workflow file");
561
613
  const rawName = fallbackName ?? value.name;
562
614
  assertString(rawName, "workflow.name");
@@ -568,7 +620,7 @@ function workflowBodyFromJson(value, fallbackName) {
568
620
  goal: normalizeGoalSpec(value.goal, "goal"),
569
621
  version: typeof value.version === "number" ? value.version : undefined,
570
622
  steps: value.steps
571
- });
623
+ }, opts);
572
624
  }
573
625
 
574
626
  // src/lib/run-artifacts.ts
@@ -1205,13 +1257,17 @@ class Store {
1205
1257
  }
1206
1258
  createLoop(input, from = new Date) {
1207
1259
  const now = nowIso();
1260
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1261
+ name: "loop-target-validation",
1262
+ steps: [{ id: "target", target: input.target }]
1263
+ }).steps[0].target;
1208
1264
  const loop = {
1209
1265
  id: genId(),
1210
1266
  name: input.name,
1211
1267
  description: input.description,
1212
1268
  status: "active",
1213
1269
  schedule: input.schedule,
1214
- target: input.target,
1270
+ target,
1215
1271
  goal: input.goal,
1216
1272
  machine: input.machine,
1217
1273
  nextRunAt: initialNextRun(input.schedule, from),
@@ -1258,6 +1314,17 @@ class Store {
1258
1314
  const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1259
1315
  return row ? rowToLoop(row) : undefined;
1260
1316
  }
1317
+ requireUniqueLoop(idOrName) {
1318
+ const byId = this.getLoop(idOrName);
1319
+ if (byId)
1320
+ return byId;
1321
+ const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1322
+ if (rows.length === 0)
1323
+ throw new Error(`loop not found: ${idOrName}`);
1324
+ if (rows.length > 1)
1325
+ throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
1326
+ return rowToLoop(rows[0]);
1327
+ }
1261
1328
  requireLoop(idOrName) {
1262
1329
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1263
1330
  throw new Error(`loop not found: ${idOrName}`);
@@ -1332,6 +1399,130 @@ class Store {
1332
1399
  throw new Error(`loop not found after update: ${id}`);
1333
1400
  return after;
1334
1401
  }
1402
+ activeLoopReferenceCount(workflowId) {
1403
+ const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
1404
+ let count = 0;
1405
+ for (const row of rows) {
1406
+ try {
1407
+ const target = JSON.parse(row.target_json);
1408
+ if (target.type === "workflow" && target.workflowId === workflowId)
1409
+ count += 1;
1410
+ } catch {}
1411
+ }
1412
+ return count;
1413
+ }
1414
+ archiveWorkflowIfUnreferenced(workflowId, updated) {
1415
+ if (this.activeLoopReferenceCount(workflowId) > 0)
1416
+ return;
1417
+ const workflow = this.getWorkflow(workflowId);
1418
+ if (!workflow || workflow.status !== "active")
1419
+ return;
1420
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
1421
+ if (res.changes !== 1)
1422
+ return;
1423
+ return this.getWorkflow(workflowId);
1424
+ }
1425
+ retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
1426
+ const updated = (opts.now ?? new Date).toISOString();
1427
+ this.db.exec("BEGIN IMMEDIATE");
1428
+ try {
1429
+ const current = this.requireLoop(idOrName);
1430
+ if (current.target.type !== "workflow")
1431
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1432
+ if (this.hasRunningRun(current.id))
1433
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1434
+ const workflow = this.requireWorkflow(workflowId);
1435
+ const target = { ...current.target, workflowId: workflow.id };
1436
+ if (opts.workflowTimeoutMs !== undefined)
1437
+ target.timeoutMs = opts.workflowTimeoutMs;
1438
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1439
+ WHERE id=$id
1440
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1441
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1442
+ ))`).run({
1443
+ $id: current.id,
1444
+ $target: JSON.stringify(target),
1445
+ $updated: updated,
1446
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1447
+ $now: updated
1448
+ });
1449
+ if (res.changes !== 1)
1450
+ throw new Error("daemon lease lost");
1451
+ this.db.exec("COMMIT");
1452
+ const after = this.getLoop(current.id);
1453
+ if (!after)
1454
+ throw new Error(`loop not found after retarget: ${current.id}`);
1455
+ return after;
1456
+ } catch (error) {
1457
+ try {
1458
+ this.db.exec("ROLLBACK");
1459
+ } catch {}
1460
+ throw error;
1461
+ }
1462
+ }
1463
+ createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
1464
+ const normalized = normalizeCreateWorkflowInput(workflowInput);
1465
+ const updated = (opts.now ?? new Date).toISOString();
1466
+ this.db.exec("BEGIN IMMEDIATE");
1467
+ try {
1468
+ const current = this.requireUniqueLoop(idOrName);
1469
+ if (current.target.type !== "workflow")
1470
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1471
+ if (this.hasRunningRun(current.id))
1472
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1473
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1474
+ const workflow = {
1475
+ id: genId(),
1476
+ name: normalized.name,
1477
+ description: normalized.description,
1478
+ version: normalized.version ?? 1,
1479
+ status: "active",
1480
+ goal: normalized.goal,
1481
+ steps: normalized.steps,
1482
+ createdAt: updated,
1483
+ updatedAt: updated
1484
+ };
1485
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1486
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
1487
+ $id: workflow.id,
1488
+ $name: workflow.name,
1489
+ $description: workflow.description ?? null,
1490
+ $version: workflow.version,
1491
+ $status: workflow.status,
1492
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
1493
+ $steps: JSON.stringify(workflow.steps),
1494
+ $created: workflow.createdAt,
1495
+ $updated: workflow.updatedAt
1496
+ });
1497
+ const target = { ...current.target, workflowId: workflow.id };
1498
+ if (opts.workflowTimeoutMs !== undefined)
1499
+ target.timeoutMs = opts.workflowTimeoutMs;
1500
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1501
+ WHERE id=$id
1502
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1503
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1504
+ ))`).run({
1505
+ $id: current.id,
1506
+ $target: JSON.stringify(target),
1507
+ $updated: updated,
1508
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1509
+ $now: updated
1510
+ });
1511
+ if (res.changes !== 1)
1512
+ throw new Error("daemon lease lost");
1513
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1514
+ this.db.exec("COMMIT");
1515
+ const loop = this.getLoop(current.id);
1516
+ if (!loop)
1517
+ throw new Error(`loop not found after retarget: ${current.id}`);
1518
+ return { loop, workflow, previousWorkflow, archivedOld };
1519
+ } catch (error) {
1520
+ try {
1521
+ this.db.exec("ROLLBACK");
1522
+ } catch {}
1523
+ throw error;
1524
+ }
1525
+ }
1335
1526
  renameLoop(id, name, opts = {}) {
1336
1527
  const current = this.getLoop(id);
1337
1528
  if (!current)
@@ -3433,7 +3624,7 @@ function commandSpec(target) {
3433
3624
  cwd: commandTarget.cwd,
3434
3625
  shell: commandTarget.shell,
3435
3626
  env: commandTarget.env,
3436
- timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3627
+ timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
3437
3628
  idleTimeoutMs: commandTarget.idleTimeoutMs,
3438
3629
  account: commandTarget.account,
3439
3630
  accountTool: commandTarget.account?.tool
@@ -3444,7 +3635,7 @@ function commandSpec(target) {
3444
3635
  command: providerCommand(agentTarget.provider),
3445
3636
  args: agentArgs(agentTarget),
3446
3637
  cwd: agentTarget.cwd,
3447
- timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3638
+ timeoutMs: agentTarget.timeoutMs ?? null,
3448
3639
  idleTimeoutMs: agentTarget.idleTimeoutMs,
3449
3640
  account: agentTarget.account,
3450
3641
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
@@ -3632,12 +3823,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3632
3823
  if (opts.signal?.aborted)
3633
3824
  abortHandler();
3634
3825
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3635
- const timer = setTimeout(() => {
3826
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3636
3827
  timedOut = true;
3637
3828
  if (child.pid)
3638
3829
  killProcessGroup(child.pid);
3639
- }, spec.timeoutMs);
3640
- timer.unref();
3830
+ }, spec.timeoutMs) : undefined;
3831
+ timer?.unref();
3641
3832
  let idleTimer;
3642
3833
  const resetIdleTimer = () => {
3643
3834
  if (!spec.idleTimeoutMs)
@@ -3669,7 +3860,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3669
3860
  } catch (err) {
3670
3861
  error = err instanceof Error ? err.message : String(err);
3671
3862
  } finally {
3672
- clearTimeout(timer);
3863
+ if (timer)
3864
+ clearTimeout(timer);
3673
3865
  if (idleTimer)
3674
3866
  clearTimeout(idleTimer);
3675
3867
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -3811,12 +4003,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3811
4003
  if (opts.signal?.aborted)
3812
4004
  abortHandler();
3813
4005
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3814
- const timer = setTimeout(() => {
4006
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3815
4007
  timedOut = true;
3816
4008
  if (child.pid)
3817
4009
  killProcessGroup(child.pid);
3818
- }, spec.timeoutMs);
3819
- timer.unref();
4010
+ }, spec.timeoutMs) : undefined;
4011
+ timer?.unref();
3820
4012
  let idleTimer;
3821
4013
  const resetIdleTimer = () => {
3822
4014
  if (!spec.idleTimeoutMs)
@@ -3848,7 +4040,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3848
4040
  } catch (err) {
3849
4041
  error = err instanceof Error ? err.message : String(err);
3850
4042
  } finally {
3851
- clearTimeout(timer);
4043
+ if (timer)
4044
+ clearTimeout(timer);
3852
4045
  if (idleTimer)
3853
4046
  clearTimeout(idleTimer);
3854
4047
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -4273,7 +4466,7 @@ ${result.stderr}`);
4273
4466
  // src/lib/workflow-runner.ts
4274
4467
  function targetWithStepAccount(step) {
4275
4468
  const account = step.account ?? step.target.account;
4276
- const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
4469
+ const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
4277
4470
  if (!account && timeoutMs === step.target.timeoutMs)
4278
4471
  return step.target;
4279
4472
  return { ...step.target, account, timeoutMs };
@@ -4569,7 +4762,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4569
4762
  })
4570
4763
  });
4571
4764
  }
4572
- const controller = loop.target.timeoutMs ? new AbortController : undefined;
4765
+ const workflowTimeoutMs = typeof loop.target.timeoutMs === "number" ? loop.target.timeoutMs : undefined;
4766
+ const controller = workflowTimeoutMs !== undefined ? new AbortController : undefined;
4573
4767
  let workflowTimedOut = false;
4574
4768
  const externalAbort = () => controller?.abort();
4575
4769
  if (controller && opts.signal?.aborted)
@@ -4579,13 +4773,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4579
4773
  const timer = controller ? setTimeout(() => {
4580
4774
  workflowTimedOut = true;
4581
4775
  controller.abort();
4582
- }, loop.target.timeoutMs) : undefined;
4776
+ }, workflowTimeoutMs) : undefined;
4583
4777
  timer?.unref();
4584
4778
  try {
4585
4779
  return await executeWorkflow(store, workflow, {
4586
4780
  ...opts,
4587
4781
  signal: controller?.signal ?? opts.signal,
4588
- signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
4782
+ signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${workflowTimeoutMs}ms` : undefined,
4589
4783
  loop,
4590
4784
  loopRun: run,
4591
4785
  scheduledFor: run.scheduledFor,
@@ -5036,9 +5230,9 @@ function openAutomationsRuntimeBinding(overrides = {}) {
5036
5230
  }
5037
5231
  // src/lib/templates.ts
5038
5232
  import { execFileSync } from "child_process";
5039
- import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
5233
+ import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
5040
5234
  import { homedir as homedir3 } from "os";
5041
- import { basename as basename2, isAbsolute, join as join5, relative, resolve } from "path";
5235
+ import { basename as basename2, isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
5042
5236
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
5043
5237
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5044
5238
  var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
@@ -5076,7 +5270,8 @@ var TEMPLATE_SUMMARIES = [
5076
5270
  { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5077
5271
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5078
5272
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5079
- { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
5273
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." },
5274
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
5080
5275
  ]
5081
5276
  },
5082
5277
  {
@@ -5106,7 +5301,8 @@ var TEMPLATE_SUMMARIES = [
5106
5301
  { name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
5107
5302
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5108
5303
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5109
- { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
5304
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." },
5305
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
5110
5306
  ]
5111
5307
  },
5112
5308
  {
@@ -5134,7 +5330,7 @@ var TEMPLATE_SUMMARIES = [
5134
5330
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5135
5331
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5136
5332
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
5137
- { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
5333
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
5138
5334
  ]
5139
5335
  },
5140
5336
  {
@@ -5153,7 +5349,8 @@ var TEMPLATE_SUMMARIES = [
5153
5349
  { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
5154
5350
  { name: "provider", default: "codewith", description: "Agent provider." },
5155
5351
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
5156
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
5352
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
5353
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
5157
5354
  ]
5158
5355
  },
5159
5356
  {
@@ -5258,6 +5455,30 @@ function taskLabel(input) {
5258
5455
  const head = input.taskTitle?.trim() || input.taskId;
5259
5456
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
5260
5457
  }
5458
+ var UNLIMITED_AGENT_TIMEOUT_MS = null;
5459
+ function agentTimeoutMs(input) {
5460
+ return input.timeoutMs === undefined ? UNLIMITED_AGENT_TIMEOUT_MS : input.timeoutMs;
5461
+ }
5462
+ function parseTemplateTimeoutMs(raw) {
5463
+ if (raw === undefined || raw.trim() === "")
5464
+ return;
5465
+ const normalized = raw.trim().toLowerCase();
5466
+ if (["unlimited", "none", "null", "never", "off", "false"].includes(normalized))
5467
+ return null;
5468
+ const value = Number(raw);
5469
+ if (!Number.isInteger(value) || value <= 0) {
5470
+ throw new Error("timeoutMs must be a positive integer number of milliseconds, or unlimited/none/null");
5471
+ }
5472
+ return value;
5473
+ }
5474
+ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
5475
+ if (raw === undefined || raw.trim() === "")
5476
+ return fallbackMs;
5477
+ const value = Number(raw);
5478
+ if (!Number.isInteger(value) || value <= 0)
5479
+ throw new Error(`${label} must be a positive integer number of milliseconds`);
5480
+ return value;
5481
+ }
5261
5482
  function stableIndex(seed, size) {
5262
5483
  let hash = 2166136261;
5263
5484
  for (let i = 0;i < seed.length; i += 1) {
@@ -5320,7 +5541,7 @@ function normalizeWorktreeMode(mode) {
5320
5541
  function defaultWorktreeRoot(root) {
5321
5542
  if (root?.trim()) {
5322
5543
  const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
5323
- return isAbsolute(expanded) ? expanded : resolve(expanded);
5544
+ return isAbsolute2(expanded) ? expanded : resolve2(expanded);
5324
5545
  }
5325
5546
  return join5(homedir3(), ".hasna", "loops", "worktrees");
5326
5547
  }
@@ -5427,7 +5648,7 @@ function worktreePlan(input, seed) {
5427
5648
  const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
5428
5649
  const worktreePath = join5(root, repoSlug, seedSlug);
5429
5650
  const relativeCwd = relative(repoRoot, originalCwd);
5430
- const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
5651
+ const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute2(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
5431
5652
  const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
5432
5653
  const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
5433
5654
  const prepareStep = {
@@ -5529,7 +5750,7 @@ function agentTarget(input, prompt, role, seed, plan) {
5529
5750
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
5530
5751
  },
5531
5752
  account: accountForRole(input, role, seed),
5532
- timeoutMs: 45 * 60000
5753
+ timeoutMs: agentTimeoutMs(input)
5533
5754
  };
5534
5755
  }
5535
5756
  function workflowStepsWithWorktree(plan, steps) {
@@ -5662,9 +5883,24 @@ function assertNoImplicitDangerFullAccess(value, label) {
5662
5883
  assertNoImplicitDangerFullAccess(entry, `${label}.${key}`);
5663
5884
  }
5664
5885
  }
5886
+ function assertNoCustomTemplatePromptFiles(value, label) {
5887
+ if (!value || typeof value !== "object")
5888
+ return;
5889
+ if (Array.isArray(value)) {
5890
+ value.forEach((entry, index) => assertNoCustomTemplatePromptFiles(entry, `${label}[${index}]`));
5891
+ return;
5892
+ }
5893
+ for (const [key, entry] of Object.entries(value)) {
5894
+ if (key === "promptFile") {
5895
+ throw new Error(`${label}.${key} is not allowed in custom templates; use direct workflow JSON for prompt-file-backed workflows`);
5896
+ }
5897
+ assertNoCustomTemplatePromptFiles(entry, `${label}.${key}`);
5898
+ }
5899
+ }
5665
5900
  function assertCustomTemplateSafety(value, label) {
5666
5901
  assertNoDangerousCustomTemplateScalars(value, label);
5667
5902
  assertNoImplicitDangerFullAccess(value, label);
5903
+ assertNoCustomTemplatePromptFiles(value, label);
5668
5904
  }
5669
5905
  function customTemplateDefinitionFromJson(value, sourcePath) {
5670
5906
  assertRecord(value, sourcePath);
@@ -5694,7 +5930,7 @@ function customTemplateSummary(definition, sourcePath) {
5694
5930
  function readCustomTemplateFile(file) {
5695
5931
  let parsed;
5696
5932
  try {
5697
- parsed = JSON.parse(readFileSync(file, "utf8"));
5933
+ parsed = JSON.parse(readFileSync2(file, "utf8"));
5698
5934
  } catch (error) {
5699
5935
  const message = error instanceof Error ? error.message : String(error);
5700
5936
  throw new Error(`failed to read custom template ${file}: ${message}`);
@@ -5833,19 +6069,19 @@ function renderCustomLoopTemplate(entry, values) {
5833
6069
  return workflow;
5834
6070
  }
5835
6071
  function validateCustomLoopTemplateFile(file) {
5836
- const source = resolve(file);
6072
+ const source = resolve2(file);
5837
6073
  const entry = readCustomTemplateFile(source);
5838
- const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== source);
6074
+ const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== source);
5839
6075
  assertNoTemplateCollisions([...existing, entry]);
5840
6076
  return structuredClone(entry.summary);
5841
6077
  }
5842
6078
  function importCustomLoopTemplate(file, opts = {}) {
5843
- const source = resolve(file);
6079
+ const source = resolve2(file);
5844
6080
  const entry = readCustomTemplateFile(source);
5845
6081
  const dir = ensureCustomLoopTemplatesDir();
5846
6082
  const destination = join5(dir, `${entry.definition.id}.json`);
5847
6083
  const replaced = existsSync3(destination);
5848
- const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== resolve(destination));
6084
+ const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== resolve2(destination));
5849
6085
  assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
5850
6086
  if (replaced) {
5851
6087
  const stat = lstatSync(destination);
@@ -5957,18 +6193,15 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5957
6193
  name: "Worker",
5958
6194
  description: "Implement the todos task and record evidence.",
5959
6195
  target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
5960
- timeoutMs: 45 * 60000
6196
+ timeoutMs: agentTimeoutMs(input)
5961
6197
  },
5962
6198
  {
5963
6199
  id: "verifier",
5964
6200
  name: "Verifier",
5965
6201
  description: "Adversarially verify worker output and update todos.",
5966
6202
  dependsOn: ["worker"],
5967
- target: {
5968
- ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
5969
- idleTimeoutMs: 10 * 60000
5970
- },
5971
- timeoutMs: 30 * 60000
6203
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6204
+ timeoutMs: agentTimeoutMs(input)
5972
6205
  }
5973
6206
  ])
5974
6207
  };
@@ -6129,7 +6362,7 @@ function renderTaskLifecycleWorkflow(input) {
6129
6362
  name: "Triage",
6130
6363
  description: "Check task eligibility, duplicates, dependencies, and automation gates.",
6131
6364
  target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
6132
- timeoutMs: 20 * 60000
6365
+ timeoutMs: agentTimeoutMs(input)
6133
6366
  },
6134
6367
  {
6135
6368
  id: "triage-gate",
@@ -6151,7 +6384,7 @@ function renderTaskLifecycleWorkflow(input) {
6151
6384
  description: "Create a concise implementation plan and split unsafe scope before work starts.",
6152
6385
  dependsOn: ["triage-gate"],
6153
6386
  target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
6154
- timeoutMs: 25 * 60000
6387
+ timeoutMs: agentTimeoutMs(input)
6155
6388
  },
6156
6389
  {
6157
6390
  id: "planner-gate",
@@ -6173,18 +6406,15 @@ function renderTaskLifecycleWorkflow(input) {
6173
6406
  description: "Implement the todos task according to triage and planner evidence.",
6174
6407
  dependsOn: ["planner-gate"],
6175
6408
  target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
6176
- timeoutMs: 45 * 60000
6409
+ timeoutMs: agentTimeoutMs(input)
6177
6410
  },
6178
6411
  {
6179
6412
  id: "verifier",
6180
6413
  name: "Verifier",
6181
6414
  description: "Adversarially verify worker output and update todos.",
6182
6415
  dependsOn: ["worker"],
6183
- target: {
6184
- ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6185
- idleTimeoutMs: 10 * 60000
6186
- },
6187
- timeoutMs: 30 * 60000
6416
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6417
+ timeoutMs: agentTimeoutMs(input)
6188
6418
  }
6189
6419
  ])
6190
6420
  };
@@ -6254,18 +6484,15 @@ function renderEventWorkerVerifierWorkflow(input) {
6254
6484
  name: "Worker",
6255
6485
  description: "Handle the Hasna event and record evidence.",
6256
6486
  target: agentTarget(input, workerPrompt, "worker", seed, plan),
6257
- timeoutMs: 45 * 60000
6487
+ timeoutMs: agentTimeoutMs(input)
6258
6488
  },
6259
6489
  {
6260
6490
  id: "verifier",
6261
6491
  name: "Verifier",
6262
6492
  description: "Adversarially verify event handling.",
6263
6493
  dependsOn: ["worker"],
6264
- target: {
6265
- ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
6266
- idleTimeoutMs: 10 * 60000
6267
- },
6268
- timeoutMs: 30 * 60000
6494
+ target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
6495
+ timeoutMs: agentTimeoutMs(input)
6269
6496
  }
6270
6497
  ])
6271
6498
  };
@@ -6277,7 +6504,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
6277
6504
  throw new Error("projectPath is required");
6278
6505
  const seed = `${input.projectPath}:${input.objective}`;
6279
6506
  const plan = worktreePlan(input, seed);
6280
- const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
6507
+ const timeoutMs = agentTimeoutMs(input);
6281
6508
  const workerPrompt = [
6282
6509
  `/goal ${input.objective}`,
6283
6510
  "",
@@ -6315,11 +6542,8 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
6315
6542
  name: "Verifier",
6316
6543
  description: "Adversarially verify the bounded objective result.",
6317
6544
  dependsOn: ["worker"],
6318
- target: {
6319
- ...agentTarget(input, verifierPrompt, "verifier", seed, plan),
6320
- idleTimeoutMs: 10 * 60000
6321
- },
6322
- timeoutMs: Math.min(timeoutMs, 30 * 60000)
6545
+ target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
6546
+ timeoutMs
6323
6547
  }
6324
6548
  ])
6325
6549
  };
@@ -6348,7 +6572,7 @@ function renderLifecycleBoundedTemplate(id, values) {
6348
6572
  worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
6349
6573
  worktreeRoot: values.worktreeRoot,
6350
6574
  worktreeBranchPrefix: values.worktreeBranchPrefix,
6351
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
6575
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
6352
6576
  };
6353
6577
  if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
6354
6578
  const taskId = values.taskId ?? "";
@@ -6385,6 +6609,7 @@ function renderLifecycleBoundedTemplate(id, values) {
6385
6609
  worktreeMode: values.worktreeMode ?? "required",
6386
6610
  worktreeRoot: values.worktreeRoot,
6387
6611
  worktreeBranchPrefix: values.worktreeBranchPrefix,
6612
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
6388
6613
  eventId: values.eventId,
6389
6614
  eventType: values.eventType
6390
6615
  });
@@ -6451,6 +6676,8 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
6451
6676
  if (!checkCommand.trim())
6452
6677
  throw new Error("checkCommand is required");
6453
6678
  const seed = `${projectPath}:${checkCommand}`;
6679
+ const timeoutMs = parseDeterministicTimeoutMs(values.timeoutMs, 5 * 60000);
6680
+ const idleTimeoutMs = parseDeterministicTimeoutMs(values.idleTimeoutMs, 60000, "idleTimeoutMs");
6454
6681
  return {
6455
6682
  name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
6456
6683
  description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
@@ -6465,10 +6692,10 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
6465
6692
  command: "bash",
6466
6693
  args: ["-lc", checkCommand],
6467
6694
  cwd: projectPath,
6468
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
6469
- idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
6695
+ timeoutMs,
6696
+ idleTimeoutMs
6470
6697
  },
6471
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
6698
+ timeoutMs
6472
6699
  }
6473
6700
  ]
6474
6701
  };
@@ -6506,6 +6733,7 @@ function renderBuiltinLoopTemplate(id, values) {
6506
6733
  worktreeMode: values.worktreeMode,
6507
6734
  worktreeRoot: values.worktreeRoot,
6508
6735
  worktreeBranchPrefix: values.worktreeBranchPrefix,
6736
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
6509
6737
  eventId: values.eventId,
6510
6738
  eventType: values.eventType
6511
6739
  });
@@ -6537,7 +6765,8 @@ function renderBuiltinLoopTemplate(id, values) {
6537
6765
  manualBreakGlass: booleanVar(values.manualBreakGlass),
6538
6766
  worktreeMode: values.worktreeMode,
6539
6767
  worktreeRoot: values.worktreeRoot,
6540
- worktreeBranchPrefix: values.worktreeBranchPrefix
6768
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
6769
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
6541
6770
  });
6542
6771
  }
6543
6772
  if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
@@ -6565,7 +6794,7 @@ function renderBuiltinLoopTemplate(id, values) {
6565
6794
  worktreeMode: values.worktreeMode,
6566
6795
  worktreeRoot: values.worktreeRoot,
6567
6796
  worktreeBranchPrefix: values.worktreeBranchPrefix,
6568
- timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
6797
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
6569
6798
  });
6570
6799
  }
6571
6800
  throw new Error(`unknown template: ${id}`);
@@ -6606,13 +6835,13 @@ import { spawnSync as spawnSync3 } from "child_process";
6606
6835
  import { accessSync as accessSync2, constants as constants2 } from "fs";
6607
6836
 
6608
6837
  // src/daemon/control.ts
6609
- import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
6838
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
6610
6839
  import { hostname } from "os";
6611
6840
  import { dirname as dirname2 } from "path";
6612
6841
 
6613
6842
  // src/daemon/loop.ts
6614
6843
  function realSleep(ms) {
6615
- return new Promise((resolve2) => setTimeout(resolve2, ms));
6844
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
6616
6845
  }
6617
6846
  async function runLoop(opts) {
6618
6847
  const sleep = opts.sleep ?? realSleep;
@@ -6637,7 +6866,7 @@ function readPid(path = pidFilePath()) {
6637
6866
  if (!existsSync4(path))
6638
6867
  return;
6639
6868
  try {
6640
- const pid = Number(readFileSync2(path, "utf8").trim());
6869
+ const pid = Number(readFileSync3(path, "utf8").trim());
6641
6870
  return Number.isInteger(pid) && pid > 0 ? pid : undefined;
6642
6871
  } catch {
6643
6872
  return;
@@ -6800,7 +7029,11 @@ function runDoctor(store) {
6800
7029
  if (loop.target.type === "workflow") {
6801
7030
  const workflow = store.requireWorkflow(loop.target.workflowId);
6802
7031
  for (const step of workflowExecutionOrder(workflow)) {
6803
- 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 });
7032
+ preflightTarget({
7033
+ ...step.target,
7034
+ account: step.account ?? step.target.account,
7035
+ timeoutMs: step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs
7036
+ }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id }, { machine: loop.machine });
6804
7037
  }
6805
7038
  } else {
6806
7039
  preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });