@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/sdk/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,
package/dist/types.d.ts CHANGED
@@ -70,11 +70,12 @@ export interface CommandTarget {
70
70
  cwd?: string;
71
71
  shell?: boolean;
72
72
  env?: Record<string, string>;
73
- timeoutMs?: number;
73
+ timeoutMs?: TimeoutMs;
74
74
  idleTimeoutMs?: number;
75
75
  account?: AccountRef;
76
76
  preflight?: RuntimePreflightPolicy;
77
77
  }
78
+ export type TimeoutMs = number | null;
78
79
  export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "opencode" | "codex";
79
80
  export type AgentConfigIsolation = "safe" | "none";
80
81
  export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
@@ -104,10 +105,13 @@ export interface AgentRoutingSpec {
104
105
  eventType?: string;
105
106
  eventSource?: string;
106
107
  }
107
- export interface AgentTarget {
108
+ export interface AgentPromptSource {
109
+ type: "file";
110
+ path: string;
111
+ }
112
+ export interface AgentTargetBase {
108
113
  type: "agent";
109
114
  provider: AgentProvider;
110
- prompt: string;
111
115
  cwd?: string;
112
116
  model?: string;
113
117
  variant?: string;
@@ -115,7 +119,7 @@ export interface AgentTarget {
115
119
  authProfile?: string;
116
120
  extraArgs?: string[];
117
121
  addDirs?: string[];
118
- timeoutMs?: number;
122
+ timeoutMs?: TimeoutMs;
119
123
  idleTimeoutMs?: number;
120
124
  configIsolation?: AgentConfigIsolation;
121
125
  permissionMode?: AgentPermissionMode;
@@ -126,15 +130,24 @@ export interface AgentTarget {
126
130
  account?: AccountRef;
127
131
  preflight?: RuntimePreflightPolicy;
128
132
  }
133
+ export interface AgentTarget extends AgentTargetBase {
134
+ prompt: string;
135
+ promptSource?: AgentPromptSource;
136
+ }
137
+ export interface PromptFileAgentTarget extends AgentTargetBase {
138
+ promptFile: string;
139
+ }
129
140
  export interface WorkflowTarget {
130
141
  type: "workflow";
131
142
  workflowId: string;
132
143
  input?: Record<string, string>;
133
- timeoutMs?: number;
144
+ timeoutMs?: TimeoutMs;
134
145
  preflight?: RuntimePreflightPolicy;
135
146
  }
136
147
  export type ExecutableTarget = CommandTarget | AgentTarget;
148
+ export type ExecutableTargetInput = CommandTarget | AgentTarget | PromptFileAgentTarget;
137
149
  export type LoopTarget = ExecutableTarget | WorkflowTarget;
150
+ export type LoopTargetInput = ExecutableTargetInput | WorkflowTarget;
138
151
  export type WorkflowStatus = "active" | "archived";
139
152
  export type WorkflowRunStatus = "running" | "succeeded" | "failed" | "timed_out" | "cancelled";
140
153
  export type WorkflowStepRunStatus = "pending" | "running" | "succeeded" | "failed" | "timed_out" | "skipped" | "cancelled";
@@ -230,9 +243,12 @@ export interface WorkflowStep {
230
243
  goal?: GoalSpec;
231
244
  dependsOn?: string[];
232
245
  continueOnFailure?: boolean;
233
- timeoutMs?: number;
246
+ timeoutMs?: TimeoutMs;
234
247
  account?: AccountRef;
235
248
  }
249
+ export interface WorkflowStepInput extends Omit<WorkflowStep, "target"> {
250
+ target: ExecutableTargetInput;
251
+ }
236
252
  export interface WorkflowSpec {
237
253
  id: string;
238
254
  name: string;
@@ -248,7 +264,7 @@ export interface CreateWorkflowInput {
248
264
  name: string;
249
265
  description?: string;
250
266
  goal?: GoalSpec;
251
- steps: WorkflowStep[];
267
+ steps: WorkflowStepInput[];
252
268
  version?: number;
253
269
  }
254
270
  export type LoopTemplateKind = "workflow" | "loop";
@@ -367,7 +383,7 @@ export interface CreateLoopInput {
367
383
  name: string;
368
384
  description?: string;
369
385
  schedule: ScheduleSpec;
370
- target: LoopTarget;
386
+ target: LoopTargetInput;
371
387
  goal?: GoalSpec;
372
388
  machine?: LoopMachineRef;
373
389
  catchUp?: CatchUpPolicy;
package/docs/USAGE.md CHANGED
@@ -148,6 +148,56 @@ accounts tools add codewith --label "Codewith" --env-var CODEWITH_HOME --bin cod
148
148
  accounts tools add aicopilot --label "AI Copilot" --env-var AICOPILOT_CONFIG_DIR --bin aicopilot
149
149
  ```
150
150
 
151
+ ## Prompt Files
152
+
153
+ Use prompt files for production agent prompts instead of long inline strings.
154
+ This keeps the durable prompt source reviewable and prevents shell history from
155
+ becoming the only copy of the prompt.
156
+
157
+ ```bash
158
+ mkdir -p ~/.hasna/loops/prompts
159
+ $EDITOR ~/.hasna/loops/prompts/repo-morning-check.md
160
+
161
+ loops create agent morning-check \
162
+ --provider codewith \
163
+ --auth-profile account001 \
164
+ --cron "0 8 * * *" \
165
+ --cwd /path/to/repo \
166
+ --prompt-file ~/.hasna/loops/prompts/repo-morning-check.md
167
+ ```
168
+
169
+ Workflow JSON supports `promptFile` on agent targets. Relative paths resolve
170
+ from the workflow JSON file's directory:
171
+
172
+ ```json
173
+ {
174
+ "name": "repo-morning",
175
+ "steps": [
176
+ {
177
+ "id": "review",
178
+ "target": {
179
+ "type": "agent",
180
+ "provider": "codewith",
181
+ "cwd": "/path/to/repo",
182
+ "promptFile": "prompts/repo-morning-review.md"
183
+ }
184
+ }
185
+ ]
186
+ }
187
+ ```
188
+
189
+ OpenLoops stores the resolved prompt text for execution and records
190
+ `promptSource` metadata with the absolute file path. Public CLI output from
191
+ `show`, `list`, `workflows list`, `workflows show`, `workflows validate`,
192
+ `workflows create`, and `templates render` redacts prompt bodies by default
193
+ while keeping `promptSource` visible. Use
194
+ `~/.hasna/loops/prompts/<stable-name>.md` as the default prompt store for local
195
+ production loops.
196
+
197
+ Reusable custom templates cannot contain `promptFile`. Keep prompt files in
198
+ direct workflow JSON or agent loop creation; use template variables only for
199
+ non-secret routing/configuration data.
200
+
151
201
  ## Workflows
152
202
 
153
203
  Create a workflow JSON file:
@@ -195,6 +245,11 @@ loops workflows recover <workflow-run-id>
195
245
  loops create workflow repo-morning-loop --workflow repo-morning --cron "0 8 * * *"
196
246
  ```
197
247
 
248
+ Use `recover` only for interrupted `running` workflow runs whose recorded child
249
+ process is gone. Terminal `timed_out` task/event workflow runs are audit
250
+ history; requeue them through the original task/event route after fixing the
251
+ cause.
252
+
198
253
  Workflow specs are stored separately from loops. A loop can schedule a workflow, but workflow runs and step runs have their own durable rows and events. Steps run in dependency order and a scheduled workflow run is idempotent per loop slot.
199
254
 
200
255
  For command steps, `command` is the executable when `shell` is not true. Put flags in `args`:
@@ -260,6 +315,30 @@ Custom reusable workflow templates live under the OpenLoops app data directory:
260
315
  showing, and rendering templates never executes workflow steps or mutates the
261
316
  registry.
262
317
 
318
+ Timeout policy is explicit. Deterministic command/check steps should normally
319
+ keep finite `timeoutMs`/`idleTimeoutMs` guards so broken shell work cannot run
320
+ forever. Agentic work steps default to no timeout in built-in worker/verifier
321
+ and task-lifecycle templates; use `timeoutMs: null` in workflow JSON, or
322
+ `--timeout none` / `--timeout unlimited` for CLI-created targets, when a step
323
+ may need hours or days. Use a positive numeric `timeoutMs` only when an agentic
324
+ step is intentionally bounded.
325
+
326
+ To migrate existing workflow loops, do not edit `workflow_specs.steps_json`
327
+ directly because historical workflow runs must keep pointing at their original
328
+ spec. Use the append-only migrator:
329
+
330
+ ```bash
331
+ loops workflows migrate-agent-timeouts --loop <loop-id-or-name>
332
+ loops workflows migrate-agent-timeouts --loop <loop-id-or-name> --apply
333
+ ```
334
+
335
+ The command dry-runs by default. With `--apply`, it creates a new workflow spec
336
+ with the requested agent timeout policy, retargets only future executions of
337
+ eligible non-running workflow loops, and leaves terminal timed-out workflow runs
338
+ as audit history. Use `loops workflows recover` only for interrupted `running`
339
+ workflow runs whose recorded child process is gone; terminal `timed_out` runs
340
+ must be requeued by re-delivering or draining the original task/event route.
341
+
263
342
  ```json
264
343
  {
265
344
  "id": "custom-report",
@@ -268,8 +347,7 @@ registry.
268
347
  "kind": "workflow",
269
348
  "variables": [
270
349
  { "name": "objective", "required": true, "description": "Report objective." },
271
- { "name": "projectPath", "required": true, "description": "Working directory." },
272
- { "name": "timeoutMs", "default": "300000", "type": "number" }
350
+ { "name": "projectPath", "required": true, "description": "Working directory." }
273
351
  ],
274
352
  "workflow": {
275
353
  "name": "custom-report-${objective}",
@@ -284,9 +362,9 @@ registry.
284
362
  "configIsolation": "safe",
285
363
  "permissionMode": "bypass",
286
364
  "sandbox": "workspace-write",
287
- "timeoutMs": "${timeoutMs}"
365
+ "timeoutMs": null
288
366
  },
289
- "timeoutMs": "${timeoutMs}"
367
+ "timeoutMs": null
290
368
  }
291
369
  ]
292
370
  }
@@ -48,7 +48,7 @@
48
48
  "type": "agent",
49
49
  "provider": "codewith",
50
50
  "cwd": "/path/to/repo",
51
- "timeoutMs": 1800000,
51
+ "timeoutMs": null,
52
52
  "prompt": "Read .openloops/transcripts/latest-transcript.json and create or update docs/loop-backlog.md. Extract only recurring work that is useful enough to schedule through OpenLoops. For each candidate include cadence, target provider, allowed write scope, expected artifacts, verification command, stop condition, and the specific transcript insight that justifies it. Prefer loops for code review/security, customer feedback triage, maintenance PRs, CI optimization, knowledge capture, and workflow hygiene."
53
53
  }
54
54
  },
@@ -60,7 +60,7 @@
60
60
  "type": "agent",
61
61
  "provider": "codewith",
62
62
  "cwd": "/path/to/repo",
63
- "timeoutMs": 1800000,
63
+ "timeoutMs": null,
64
64
  "prompt": "Use docs/loop-backlog.md to author or update workflow JSON specs under docs/workflows/generated. Each workflow must use deterministic command steps for checks, agent steps for judgment or code changes, and workflow or step goals only when the outcome needs planning. Keep prompts scoped to the target repo, name allowed paths, and include validation evidence."
65
65
  }
66
66
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.53",
3
+ "version": "0.3.55",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",