@hasna/loops 0.3.13 → 0.3.15

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
@@ -482,6 +482,8 @@ function rowToLoop(row) {
482
482
  name: row.name,
483
483
  description: row.description ?? undefined,
484
484
  status: row.status,
485
+ archivedAt: row.archived_at ?? undefined,
486
+ archivedFromStatus: row.archived_from_status ? row.archived_from_status : undefined,
485
487
  schedule: JSON.parse(row.schedule_json),
486
488
  target: JSON.parse(row.target_json),
487
489
  goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
@@ -691,6 +693,8 @@ class Store {
691
693
  name TEXT NOT NULL,
692
694
  description TEXT,
693
695
  status TEXT NOT NULL,
696
+ archived_at TEXT,
697
+ archived_from_status TEXT,
694
698
  schedule_json TEXT NOT NULL,
695
699
  target_json TEXT NOT NULL,
696
700
  goal_json TEXT,
@@ -891,6 +895,8 @@ class Store {
891
895
  `);
892
896
  this.addColumnIfMissing("loops", "machine_json", "TEXT");
893
897
  this.addColumnIfMissing("loops", "goal_json", "TEXT");
898
+ this.addColumnIfMissing("loops", "archived_at", "TEXT");
899
+ this.addColumnIfMissing("loops", "archived_from_status", "TEXT");
894
900
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
895
901
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
896
902
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
@@ -899,6 +905,7 @@ class Store {
899
905
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
900
906
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
901
907
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
908
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
902
909
  }
903
910
  addColumnIfMissing(table, column, definition) {
904
911
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -975,12 +982,26 @@ class Store {
975
982
  }
976
983
  listLoops(opts = {}) {
977
984
  const limit = opts.limit ?? 200;
978
- const rows = opts.status ? this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
985
+ let rows;
986
+ if (opts.status && opts.archived) {
987
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
988
+ } else if (opts.status && opts.includeArchived) {
989
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
990
+ } else if (opts.status) {
991
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
992
+ } else if (opts.archived) {
993
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
994
+ } else if (opts.includeArchived) {
995
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
996
+ } else {
997
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
998
+ }
979
999
  return rows.map(rowToLoop);
980
1000
  }
981
1001
  dueLoops(now) {
982
1002
  const rows = this.db.query(`SELECT * FROM loops
983
1003
  WHERE status = 'active'
1004
+ AND archived_at IS NULL
984
1005
  AND next_run_at IS NOT NULL
985
1006
  AND next_run_at <= ?
986
1007
  ORDER BY next_run_at ASC`).all(now.toISOString());
@@ -1012,6 +1033,44 @@ class Store {
1012
1033
  throw new Error(`loop not found after update: ${id}`);
1013
1034
  return after;
1014
1035
  }
1036
+ archiveLoop(idOrName) {
1037
+ const loop = this.requireLoop(idOrName);
1038
+ if (loop.archivedAt)
1039
+ return loop;
1040
+ const updated = nowIso();
1041
+ const archivedStatus = loop.status === "active" ? "paused" : loop.status;
1042
+ this.db.query(`UPDATE loops
1043
+ SET status=$status, archived_at=$archivedAt, archived_from_status=$archivedFromStatus, updated_at=$updated
1044
+ WHERE id=$id`).run({
1045
+ $id: loop.id,
1046
+ $status: archivedStatus,
1047
+ $archivedAt: updated,
1048
+ $archivedFromStatus: loop.status,
1049
+ $updated: updated
1050
+ });
1051
+ const archived = this.getLoop(loop.id);
1052
+ if (!archived)
1053
+ throw new Error(`loop not found after archive: ${loop.id}`);
1054
+ return archived;
1055
+ }
1056
+ unarchiveLoop(idOrName) {
1057
+ const loop = this.requireLoop(idOrName);
1058
+ if (!loop.archivedAt)
1059
+ return loop;
1060
+ const updated = nowIso();
1061
+ const restoredStatus = loop.archivedFromStatus ?? loop.status;
1062
+ this.db.query(`UPDATE loops
1063
+ SET status=$status, archived_at=NULL, archived_from_status=NULL, updated_at=$updated
1064
+ WHERE id=$id`).run({
1065
+ $id: loop.id,
1066
+ $status: restoredStatus,
1067
+ $updated: updated
1068
+ });
1069
+ const unarchived = this.getLoop(loop.id);
1070
+ if (!unarchived)
1071
+ throw new Error(`loop not found after unarchive: ${loop.id}`);
1072
+ return unarchived;
1073
+ }
1015
1074
  deleteLoop(idOrName) {
1016
1075
  const loop = this.requireLoop(idOrName);
1017
1076
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
@@ -1786,10 +1845,16 @@ class Store {
1786
1845
  }
1787
1846
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1788
1847
  const startedAt = now.toISOString();
1789
- const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1790
1848
  this.db.exec("BEGIN IMMEDIATE");
1791
1849
  try {
1792
1850
  this.assertDaemonLeaseFence(opts, startedAt);
1851
+ const currentLoop = this.getLoop(loop.id);
1852
+ if (!currentLoop || currentLoop.archivedAt) {
1853
+ this.db.exec("COMMIT");
1854
+ return;
1855
+ }
1856
+ loop = currentLoop;
1857
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1793
1858
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1794
1859
  if (existing) {
1795
1860
  if (existing.status === "running") {
@@ -2007,7 +2072,7 @@ class Store {
2007
2072
  return recovered;
2008
2073
  }
2009
2074
  expireLoops(now = new Date, opts = {}) {
2010
- const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2075
+ const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND archived_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2011
2076
  const expired = [];
2012
2077
  for (const row of rows) {
2013
2078
  const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
@@ -2016,8 +2081,21 @@ class Store {
2016
2081
  }
2017
2082
  return expired;
2018
2083
  }
2019
- countLoops(status) {
2020
- const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2084
+ countLoops(status, opts = {}) {
2085
+ let row;
2086
+ if (status && opts.archived) {
2087
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NOT NULL").get(status);
2088
+ } else if (status && opts.includeArchived) {
2089
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status);
2090
+ } else if (status) {
2091
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NULL").get(status);
2092
+ } else if (opts.archived) {
2093
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NOT NULL").get();
2094
+ } else if (opts.includeArchived) {
2095
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2096
+ } else {
2097
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NULL").get();
2098
+ }
2021
2099
  return row?.count ?? 0;
2022
2100
  }
2023
2101
  countRuns(status) {
@@ -2074,6 +2152,7 @@ class Store {
2074
2152
  }
2075
2153
 
2076
2154
  // src/cli/index.ts
2155
+ import { createHash } from "crypto";
2077
2156
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2078
2157
  import { Command } from "commander";
2079
2158
 
@@ -3702,12 +3781,16 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
3702
3781
 
3703
3782
  // src/lib/scheduler.ts
3704
3783
  function manualRunScheduledFor(loop, now = new Date) {
3784
+ if (loop.archivedAt)
3785
+ return now.toISOString();
3705
3786
  if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
3706
3787
  return loop.retryScheduledFor ?? loop.nextRunAt;
3707
3788
  }
3708
3789
  return now.toISOString();
3709
3790
  }
3710
3791
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3792
+ if (loop.archivedAt)
3793
+ return false;
3711
3794
  if (loop.status !== "active")
3712
3795
  return false;
3713
3796
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3715,6 +3798,8 @@ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3715
3798
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
3716
3799
  }
3717
3800
  function manualRunSource(loop, scheduledFor, now = new Date) {
3801
+ if (loop.archivedAt)
3802
+ return "ad_hoc";
3718
3803
  if (loop.status !== "active")
3719
3804
  return "ad_hoc";
3720
3805
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3733,7 +3818,7 @@ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
3733
3818
  if (run.status === "running")
3734
3819
  return;
3735
3820
  const current = store.getLoop(loop.id);
3736
- if (!current || current.status !== "active")
3821
+ if (!current || current.status !== "active" || current.archivedAt)
3737
3822
  return;
3738
3823
  if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
3739
3824
  return;
@@ -4068,7 +4153,8 @@ function daemonStatus(store, path = pidFilePath()) {
4068
4153
  active: store.countLoops("active"),
4069
4154
  paused: store.countLoops("paused"),
4070
4155
  stopped: store.countLoops("stopped"),
4071
- expired: store.countLoops("expired")
4156
+ expired: store.countLoops("expired"),
4157
+ archived: store.countLoops(undefined, { archived: true })
4072
4158
  },
4073
4159
  runs: {
4074
4160
  total: store.countRuns(),
@@ -4494,7 +4580,7 @@ function runDoctor(store) {
4494
4580
  // package.json
4495
4581
  var package_default = {
4496
4582
  name: "@hasna/loops",
4497
- version: "0.3.13",
4583
+ version: "0.3.15",
4498
4584
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4499
4585
  type: "module",
4500
4586
  main: "dist/index.js",
@@ -4558,7 +4644,7 @@ var package_default = {
4558
4644
  bun: ">=1.0.0"
4559
4645
  },
4560
4646
  dependencies: {
4561
- "@hasna/events": "^0.1.8",
4647
+ "@hasna/events": "^0.1.9",
4562
4648
  "@hasna/machines": "0.0.49",
4563
4649
  "@openrouter/ai-sdk-provider": "2.9.1",
4564
4650
  ai: "6.0.204",
@@ -4597,6 +4683,10 @@ var TEMPLATE_SUMMARIES = [
4597
4683
  { name: "projectPath", required: true, description: "Repository or project working directory." },
4598
4684
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
4599
4685
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
4686
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
4687
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
4688
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
4689
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
4600
4690
  { name: "model", description: "Provider model." },
4601
4691
  { name: "variant", description: "Provider reasoning/model effort variant." },
4602
4692
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
@@ -4616,6 +4706,10 @@ var TEMPLATE_SUMMARIES = [
4616
4706
  { name: "projectPath", required: true, description: "Repository or project working directory." },
4617
4707
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
4618
4708
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
4709
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
4710
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
4711
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
4712
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
4619
4713
  { name: "model", description: "Provider model." },
4620
4714
  { name: "variant", description: "Provider reasoning/model effort variant." },
4621
4715
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
@@ -4630,7 +4724,37 @@ function taskLabel(input) {
4630
4724
  const head = input.taskTitle?.trim() || input.taskId;
4631
4725
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
4632
4726
  }
4633
- function agentTarget(input, prompt) {
4727
+ function stableIndex(seed, size) {
4728
+ let hash = 2166136261;
4729
+ for (let i = 0;i < seed.length; i += 1) {
4730
+ hash ^= seed.charCodeAt(i);
4731
+ hash = Math.imul(hash, 16777619);
4732
+ }
4733
+ return Math.abs(hash >>> 0) % size;
4734
+ }
4735
+ function rolePoolValue(pool, seed, role) {
4736
+ if (!pool?.length)
4737
+ return;
4738
+ const workerIndex = stableIndex(seed, pool.length);
4739
+ if (role === "worker" || pool.length === 1)
4740
+ return pool[workerIndex];
4741
+ return pool[(workerIndex + 1) % pool.length];
4742
+ }
4743
+ function authProfileForRole(input, role, seed) {
4744
+ if (role === "worker" && input.workerAuthProfile)
4745
+ return input.workerAuthProfile;
4746
+ if (role === "verifier" && input.verifierAuthProfile)
4747
+ return input.verifierAuthProfile;
4748
+ return rolePoolValue(input.authProfilePool, seed, role) ?? input.authProfile;
4749
+ }
4750
+ function accountForRole(input, role, seed) {
4751
+ if (role === "worker" && input.workerAccount)
4752
+ return input.workerAccount;
4753
+ if (role === "verifier" && input.verifierAccount)
4754
+ return input.verifierAccount;
4755
+ return rolePoolValue(input.accountPool, seed, role) ?? input.account;
4756
+ }
4757
+ function agentTarget(input, prompt, role, seed) {
4634
4758
  const provider = input.provider ?? "codewith";
4635
4759
  const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
4636
4760
  return {
@@ -4641,11 +4765,11 @@ function agentTarget(input, prompt) {
4641
4765
  model: input.model,
4642
4766
  variant: input.variant,
4643
4767
  agent: input.agent,
4644
- authProfile: provider === "codewith" ? input.authProfile : undefined,
4768
+ authProfile: provider === "codewith" ? authProfileForRole(input, role, seed) : undefined,
4645
4769
  configIsolation: "safe",
4646
4770
  permissionMode: input.permissionMode ?? "bypass",
4647
4771
  sandbox,
4648
- account: input.account,
4772
+ account: accountForRole(input, role, seed),
4649
4773
  timeoutMs: 45 * 60000
4650
4774
  };
4651
4775
  }
@@ -4699,7 +4823,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4699
4823
  id: "worker",
4700
4824
  name: "Worker",
4701
4825
  description: "Implement the todos task and record evidence.",
4702
- target: agentTarget(input, workerPrompt),
4826
+ target: agentTarget(input, workerPrompt, "worker", input.taskId),
4703
4827
  timeoutMs: 45 * 60000
4704
4828
  },
4705
4829
  {
@@ -4707,7 +4831,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4707
4831
  name: "Verifier",
4708
4832
  description: "Adversarially verify worker output and update todos.",
4709
4833
  dependsOn: ["worker"],
4710
- target: agentTarget(input, verifierPrompt),
4834
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId),
4711
4835
  timeoutMs: 30 * 60000
4712
4836
  }
4713
4837
  ]
@@ -4763,7 +4887,7 @@ function renderEventWorkerVerifierWorkflow(input) {
4763
4887
  id: "worker",
4764
4888
  name: "Worker",
4765
4889
  description: "Handle the Hasna event and record evidence.",
4766
- target: agentTarget(input, workerPrompt),
4890
+ target: agentTarget(input, workerPrompt, "worker", `${input.eventSource}:${input.eventType}:${input.eventId}`),
4767
4891
  timeoutMs: 45 * 60000
4768
4892
  },
4769
4893
  {
@@ -4771,7 +4895,7 @@ function renderEventWorkerVerifierWorkflow(input) {
4771
4895
  name: "Verifier",
4772
4896
  description: "Adversarially verify event handling.",
4773
4897
  dependsOn: ["worker"],
4774
- target: agentTarget(input, verifierPrompt),
4898
+ target: agentTarget(input, verifierPrompt, "verifier", `${input.eventSource}:${input.eventType}:${input.eventId}`),
4775
4899
  timeoutMs: 30 * 60000
4776
4900
  }
4777
4901
  ]
@@ -4786,7 +4910,11 @@ function renderLoopTemplate(id, values) {
4786
4910
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
4787
4911
  provider: values.provider,
4788
4912
  authProfile: values.authProfile,
4913
+ authProfilePool: listVar(values.authProfilePool),
4914
+ workerAuthProfile: values.workerAuthProfile,
4915
+ verifierAuthProfile: values.verifierAuthProfile,
4789
4916
  account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
4917
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
4790
4918
  model: values.model,
4791
4919
  variant: values.variant,
4792
4920
  agent: values.agent,
@@ -4807,7 +4935,11 @@ function renderLoopTemplate(id, values) {
4807
4935
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
4808
4936
  provider: values.provider,
4809
4937
  authProfile: values.authProfile,
4938
+ authProfilePool: listVar(values.authProfilePool),
4939
+ workerAuthProfile: values.workerAuthProfile,
4940
+ verifierAuthProfile: values.verifierAuthProfile,
4810
4941
  account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
4942
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
4811
4943
  model: values.model,
4812
4944
  variant: values.variant,
4813
4945
  agent: values.agent,
@@ -4817,6 +4949,13 @@ function renderLoopTemplate(id, values) {
4817
4949
  }
4818
4950
  throw new Error(`unknown template: ${id}`);
4819
4951
  }
4952
+ function listVar(value) {
4953
+ const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
4954
+ return values?.length ? values : undefined;
4955
+ }
4956
+ function accountPoolVar(value, tool) {
4957
+ return listVar(value)?.map((profile) => ({ profile, tool }));
4958
+ }
4820
4959
 
4821
4960
  // src/cli/index.ts
4822
4961
  var program = new Command;
@@ -4932,10 +5071,21 @@ function goalFromOpts(opts) {
4932
5071
  }, "goal");
4933
5072
  }
4934
5073
  function accountFromOpts(opts) {
4935
- if (!opts.account && opts.accountTool)
4936
- throw new Error("--account-tool requires --account");
5074
+ if (!opts.account && opts.accountTool && !opts.accountPool && !opts.workerAccount && !opts.verifierAccount) {
5075
+ throw new Error("--account-tool requires --account, --account-pool, --worker-account, or --verifier-account");
5076
+ }
4937
5077
  return opts.account ? { profile: opts.account, tool: opts.accountTool } : undefined;
4938
5078
  }
5079
+ function splitList(value) {
5080
+ const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5081
+ return values?.length ? values : undefined;
5082
+ }
5083
+ function accountPoolFromOpts(opts) {
5084
+ return splitList(opts.accountPool)?.map((profile) => ({ profile, tool: opts.accountTool }));
5085
+ }
5086
+ function roleAccountFromOpts(opts, profile) {
5087
+ return profile ? { profile, tool: opts.accountTool } : undefined;
5088
+ }
4939
5089
  function parseVars(values) {
4940
5090
  const vars = {};
4941
5091
  for (const value of values ?? []) {
@@ -4956,12 +5106,21 @@ function eventData(event) {
4956
5106
  return data;
4957
5107
  return {};
4958
5108
  }
5109
+ function eventMetadata(event) {
5110
+ const metadata = event.metadata;
5111
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata))
5112
+ return metadata;
5113
+ return {};
5114
+ }
4959
5115
  function stringField(value) {
4960
5116
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
4961
5117
  }
4962
5118
  function slugSegment(value, fallback = "event") {
4963
5119
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
4964
5120
  }
5121
+ function stableSuffix(value) {
5122
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
5123
+ }
4965
5124
  function taskEventField(data, keys) {
4966
5125
  for (const key of keys) {
4967
5126
  const direct = stringField(data[key]);
@@ -5139,15 +5298,26 @@ templates.command("create-workflow <id>").description("render and store a templa
5139
5298
  }
5140
5299
  });
5141
5300
  var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
5142
- eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--account <profile>", "OpenAccounts profile name").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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5301
+ eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task 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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5143
5302
  const event = await readEventEnvelopeFromStdin();
5144
5303
  const data = eventData(event);
5304
+ const metadata = eventMetadata(event);
5145
5305
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
5146
5306
  if (!taskId)
5147
5307
  throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
5148
5308
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
5149
5309
  const taskDescription = taskEventField(data, ["description", "body"]);
5150
- const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]) ?? process.cwd();
5310
+ const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
5311
+ const metadataProjectPath = taskEventField(metadata, [
5312
+ "working_dir",
5313
+ "workingDir",
5314
+ "project_path",
5315
+ "projectPath",
5316
+ "project_canonical_path",
5317
+ "cwd"
5318
+ ]);
5319
+ const projectPath = opts.projectPath ?? dataProjectPath ?? metadataProjectPath ?? process.cwd();
5320
+ const idempotencyKey = `todos-task:${taskId}:${event.type}`;
5151
5321
  const provider = opts.provider;
5152
5322
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
5153
5323
  throw new Error("unsupported provider");
@@ -5161,7 +5331,13 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5161
5331
  projectPath,
5162
5332
  provider,
5163
5333
  authProfile,
5334
+ authProfilePool: splitList(opts.authProfilePool),
5335
+ workerAuthProfile: opts.workerAuthProfile,
5336
+ verifierAuthProfile: opts.verifierAuthProfile,
5164
5337
  account: accountFromOpts(opts),
5338
+ accountPool: accountPoolFromOpts(opts),
5339
+ workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
5340
+ verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
5165
5341
  model: opts.model,
5166
5342
  variant: opts.variant,
5167
5343
  agent: opts.agent,
@@ -5170,13 +5346,14 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5170
5346
  eventId: event.id,
5171
5347
  eventType: event.type
5172
5348
  });
5173
- const eventSuffix = event.id.slice(0, 8);
5174
- workflowBody.name = `${opts.namePrefix}:${taskId.slice(0, 8)}:${eventSuffix}:workflow`;
5175
- workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}`;
5176
- const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${eventSuffix}:run`;
5349
+ const idempotencySuffix = stableSuffix(idempotencyKey);
5350
+ workflowBody.name = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
5351
+ workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}`;
5352
+ const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
5353
+ const legacyLoopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
5177
5354
  const loopInput = {
5178
5355
  name: loopName,
5179
- description: `Run ${workflowBody.name} once for task ${taskId}`,
5356
+ description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
5180
5357
  schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
5181
5358
  target: { type: "workflow", workflowId: "<created-workflow-id>" },
5182
5359
  overlap: "skip",
@@ -5185,15 +5362,22 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5185
5362
  leaseMs: 90 * 60000
5186
5363
  };
5187
5364
  if (opts.dryRun) {
5188
- print({ event, workflow: workflowBody, loop: loopInput }, `dry-run ${loopName}`);
5365
+ print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput }, `dry-run ${loopName}`);
5189
5366
  return;
5190
5367
  }
5191
5368
  const store = new Store;
5192
5369
  try {
5193
- const existingLoop = store.findLoopByName(loopName);
5370
+ const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
5194
5371
  if (existingLoop) {
5195
5372
  const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
5196
- print({ deduped: true, event, workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
5373
+ print({
5374
+ deduped: true,
5375
+ idempotencyKey,
5376
+ dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
5377
+ event,
5378
+ workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined,
5379
+ loop: publicLoop(existingLoop)
5380
+ }, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
5197
5381
  return;
5198
5382
  }
5199
5383
  const existingWorkflow = store.findWorkflowByName(workflowBody.name);
@@ -5202,12 +5386,12 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5202
5386
  ...loopInput,
5203
5387
  target: { type: "workflow", workflowId: workflow.id }
5204
5388
  });
5205
- print({ deduped: false, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop) }, `created ${loop.id} (${loop.name}) workflow=${workflow.name}`);
5389
+ print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop) }, `created ${loop.id} (${loop.name}) workflow=${workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
5206
5390
  } finally {
5207
5391
  store.close();
5208
5392
  }
5209
5393
  });
5210
- 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("--account <profile>", "OpenAccounts profile name").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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5394
+ 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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5211
5395
  const event = await readEventEnvelopeFromStdin();
5212
5396
  const data = eventData(event);
5213
5397
  const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
@@ -5227,7 +5411,13 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
5227
5411
  projectPath,
5228
5412
  provider,
5229
5413
  authProfile,
5414
+ authProfilePool: splitList(opts.authProfilePool),
5415
+ workerAuthProfile: opts.workerAuthProfile,
5416
+ verifierAuthProfile: opts.verifierAuthProfile,
5230
5417
  account: accountFromOpts(opts),
5418
+ accountPool: accountPoolFromOpts(opts),
5419
+ workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
5420
+ verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
5231
5421
  model: opts.model,
5232
5422
  variant: opts.variant,
5233
5423
  agent: opts.agent,
@@ -5494,16 +5684,19 @@ workflows.command("archive <idOrName>").action((idOrName) => {
5494
5684
  store.close();
5495
5685
  }
5496
5686
  });
5497
- program.command("list").alias("ls").option("--status <status>", "filter by status").action((opts) => {
5687
+ program.command("list").alias("ls").option("--status <status>", "filter by status").option("--archived", "show only archived loops").option("--all", "include archived loops").action((opts) => {
5688
+ if (opts.archived && opts.all)
5689
+ throw new Error("use either --archived or --all, not both");
5498
5690
  const store = new Store;
5499
5691
  try {
5500
- const loops = store.listLoops({ status: opts.status });
5692
+ const loops = store.listLoops({ status: opts.status, archived: opts.archived, includeArchived: opts.all });
5501
5693
  if (isJson())
5502
5694
  print(loops.map(publicLoop));
5503
5695
  else {
5504
5696
  for (const loop of loops) {
5505
5697
  const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
5506
- console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}`);
5698
+ const archive = loop.archivedAt ? ` archived=${loop.archivedAt} from=${loop.archivedFromStatus ?? "-"}` : "";
5699
+ console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
5507
5700
  }
5508
5701
  }
5509
5702
  } finally {
@@ -5543,6 +5736,8 @@ function updateStatus(idOrName, status) {
5543
5736
  const store = new Store;
5544
5737
  try {
5545
5738
  const loop = store.requireLoop(idOrName);
5739
+ if (loop.archivedAt)
5740
+ throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' first`);
5546
5741
  const updated = store.updateLoop(loop.id, { status, nextRunAt: status === "stopped" ? undefined : loop.nextRunAt });
5547
5742
  print(publicLoop(updated), `${updated.id} ${updated.status}`);
5548
5743
  } finally {
@@ -5558,10 +5753,30 @@ program.command("remove <idOrName>").alias("rm").action((idOrName) => {
5558
5753
  store.close();
5559
5754
  }
5560
5755
  });
5756
+ program.command("archive <idOrName>").description("archive a loop without deleting history").action((idOrName) => {
5757
+ const store = new Store;
5758
+ try {
5759
+ const loop = store.archiveLoop(idOrName);
5760
+ print(publicLoop(loop), `${loop.id} archived`);
5761
+ } finally {
5762
+ store.close();
5763
+ }
5764
+ });
5765
+ program.command("unarchive <idOrName>").alias("restore").description("restore an archived loop").action((idOrName) => {
5766
+ const store = new Store;
5767
+ try {
5768
+ const loop = store.unarchiveLoop(idOrName);
5769
+ print(publicLoop(loop), `${loop.id} ${loop.status}`);
5770
+ } finally {
5771
+ store.close();
5772
+ }
5773
+ });
5561
5774
  program.command("run-now <idOrName>").option("--show-output", "show stdout/stderr").action(async (idOrName, opts) => {
5562
5775
  const store = new Store;
5563
5776
  try {
5564
5777
  const loop = store.requireLoop(idOrName);
5778
+ if (loop.archivedAt)
5779
+ throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' before running it`);
5565
5780
  const runnerId = `manual:${process.pid}`;
5566
5781
  const now = new Date;
5567
5782
  let scheduledFor = manualRunScheduledFor(loop, now);
@@ -18,6 +18,7 @@ export interface DaemonStatus extends DaemonProcessState {
18
18
  paused: number;
19
19
  stopped: number;
20
20
  expired: number;
21
+ archived: number;
21
22
  };
22
23
  runs: {
23
24
  total: number;