@hasna/loops 0.3.56 → 0.3.57

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.
@@ -461,6 +461,9 @@ function validateTarget(value, label, opts) {
461
461
  }
462
462
  if (value.model !== undefined)
463
463
  assertString(value.model, `${label}.model`);
464
+ if (value.provider === "opencode" && (typeof value.model !== "string" || value.model.trim() === "")) {
465
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
466
+ }
464
467
  if (value.variant !== undefined)
465
468
  assertString(value.variant, `${label}.variant`);
466
469
  if (value.agent !== undefined)
@@ -1763,7 +1766,7 @@ class Store {
1763
1766
  if (!sourceDedupeKey)
1764
1767
  throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1765
1768
  const now = nowIso();
1766
- const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1769
+ const claimableStatuses = ["queued", "deferred"];
1767
1770
  const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1768
1771
  const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1769
1772
  const result = this.db.query(`UPDATE workflow_invocations
@@ -1844,28 +1847,35 @@ class Store {
1844
1847
  project_group=excluded.project_group,
1845
1848
  priority=excluded.priority,
1846
1849
  status=CASE
1847
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1850
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
1848
1851
  THEN workflow_work_items.status
1849
1852
  ELSE excluded.status
1850
1853
  END,
1851
1854
  workflow_id=CASE
1852
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1855
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
1853
1856
  ELSE NULL
1854
1857
  END,
1855
1858
  loop_id=CASE
1856
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1859
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
1857
1860
  ELSE NULL
1858
1861
  END,
1859
1862
  workflow_run_id=CASE
1860
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1863
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
1861
1864
  ELSE NULL
1862
1865
  END,
1863
1866
  lease_expires_at=CASE
1864
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1867
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
1865
1868
  ELSE NULL
1866
1869
  END,
1867
1870
  next_attempt_at=excluded.next_attempt_at,
1868
- last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1871
+ last_reason=CASE
1872
+ WHEN workflow_work_items.attempts > 0
1873
+ AND workflow_work_items.status IN ('queued', 'deferred')
1874
+ AND workflow_work_items.last_reason IS NOT NULL
1875
+ AND excluded.last_reason IS NOT NULL
1876
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
1877
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
1878
+ END,
1869
1879
  updated_at=excluded.updated_at`).run({
1870
1880
  $id: id,
1871
1881
  $routeKey: input.routeKey,
@@ -1918,11 +1928,39 @@ class Store {
1918
1928
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
1919
1929
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1920
1930
  }
1931
+ requeueWorkflowWorkItem(id, patch = {}) {
1932
+ const current = this.getWorkflowWorkItem(id);
1933
+ if (!current)
1934
+ throw new Error(`workflow work item not found: ${id}`);
1935
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
1936
+ if (!requeueableStatuses.includes(current.status)) {
1937
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
1938
+ }
1939
+ const now = nowIso();
1940
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
1941
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
1942
+ const res = this.db.query(`UPDATE workflow_work_items
1943
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
1944
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
1945
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
1946
+ const item = this.getWorkflowWorkItem(id);
1947
+ if (!item)
1948
+ throw new Error(`workflow work item not found after requeue: ${id}`);
1949
+ if (res.changes !== 1)
1950
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
1951
+ return item;
1952
+ }
1921
1953
  admitWorkflowWorkItem(id, patch) {
1922
1954
  const now = nowIso();
1923
1955
  const res = this.db.query(`UPDATE workflow_work_items
1924
1956
  SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1925
- next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1957
+ next_attempt_at=NULL,
1958
+ lease_expires_at=NULL,
1959
+ last_reason=CASE
1960
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
1961
+ ELSE COALESCE($reason, last_reason)
1962
+ END,
1963
+ updated_at=$updated
1926
1964
  WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1927
1965
  $id: id,
1928
1966
  $workflowId: patch.workflowId,
@@ -3464,6 +3502,9 @@ function assertSupportedAgentOptions(target) {
3464
3502
  assertStringOption(target.agent, `${target.provider}.agent`);
3465
3503
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3466
3504
  assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3505
+ if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
3506
+ throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
3507
+ }
3467
3508
  if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3468
3509
  throw new Error(`${target.provider}.configIsolation must be safe or none`);
3469
3510
  }
@@ -4236,6 +4277,78 @@ function sameBlockerKey(values) {
4236
4277
  return values.map((value) => value.trim()).filter(Boolean).join(`
4237
4278
  `) || "goal completion remains unproven";
4238
4279
  }
4280
+ function planStatusForGoal(goal) {
4281
+ if (goal.status === "usageLimited")
4282
+ return "blocked";
4283
+ return goal.status;
4284
+ }
4285
+ function syncReadyFlags(store, goal, nodes, opts) {
4286
+ const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
4287
+ for (const node of withReady) {
4288
+ const current = nodes.find((entry) => entry.key === node.key);
4289
+ if (current && current.ready !== node.ready) {
4290
+ store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
4291
+ }
4292
+ }
4293
+ return withReady;
4294
+ }
4295
+ function summarizeLabels(values, limit = 5) {
4296
+ if (values.length <= limit)
4297
+ return values.join(", ");
4298
+ return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
4299
+ }
4300
+ function noReadyDiagnostic(goal, nodes) {
4301
+ const planStatus = planStatusForGoal(goal);
4302
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
4303
+ const pendingNodes = nodes.filter((node) => node.status === "pending");
4304
+ const incompleteNodes = nodes.filter((node) => node.status !== "complete");
4305
+ const pendingDetails = pendingNodes.map((node) => ({
4306
+ key: node.key,
4307
+ status: node.status,
4308
+ ready: node.ready,
4309
+ tokenBudget: node.tokenBudget,
4310
+ tokensUsed: node.tokensUsed,
4311
+ budgetExhausted: nodeBudgetExhausted(node),
4312
+ unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
4313
+ }));
4314
+ const incompleteDetails = incompleteNodes.map((node) => ({
4315
+ key: node.key,
4316
+ status: node.status,
4317
+ ready: node.ready
4318
+ }));
4319
+ let owner = "goal-plan";
4320
+ let cause = "goal plan has no ready nodes";
4321
+ if (planStatus !== "active") {
4322
+ owner = "goal";
4323
+ cause = `goal status is ${goal.status}`;
4324
+ } else if (pendingNodes.length === 0) {
4325
+ owner = "goal-plan";
4326
+ cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
4327
+ } else if (pendingDetails.every((node) => node.budgetExhausted)) {
4328
+ owner = "goal-plan-node";
4329
+ cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
4330
+ } else {
4331
+ const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
4332
+ if (missingDependencies.length > 0) {
4333
+ owner = "goal-plan";
4334
+ cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
4335
+ } else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
4336
+ owner = "goal-plan-node";
4337
+ const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
4338
+ const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
4339
+ cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
4340
+ }
4341
+ }
4342
+ const blocker = `${cause}; owner=${owner}`;
4343
+ return {
4344
+ owner,
4345
+ blocker,
4346
+ planStatus,
4347
+ rollup: rollupSummary(nodes),
4348
+ pendingNodes: pendingDetails,
4349
+ incompleteNodes: incompleteDetails
4350
+ };
4351
+ }
4239
4352
  function metadataFor(goal, node, context) {
4240
4353
  return {
4241
4354
  loopId: context?.loopId,
@@ -4296,13 +4409,14 @@ async function planGoal(store, goal, spec, model, opts) {
4296
4409
  }, { daemonLeaseId: opts.daemonLeaseId });
4297
4410
  return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
4298
4411
  }
4299
- function stdoutFor(goal, nodes, evidence, validation) {
4412
+ function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
4300
4413
  return JSON.stringify({
4301
4414
  goal,
4302
4415
  rollup: rollupSummary(nodes),
4303
4416
  nodes,
4304
4417
  evidence,
4305
- validation
4418
+ validation,
4419
+ diagnostics
4306
4420
  }, null, 2);
4307
4421
  }
4308
4422
  async function runGoal(store, input, opts = {}) {
@@ -4335,6 +4449,7 @@ async function runGoal(store, input, opts = {}) {
4335
4449
  let validation;
4336
4450
  let lastBlocker = "";
4337
4451
  let repeatedBlockerCount = 0;
4452
+ let lastDiagnostic;
4338
4453
  if (budgetExhausted(goal)) {
4339
4454
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4340
4455
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
@@ -4345,13 +4460,13 @@ async function runGoal(store, input, opts = {}) {
4345
4460
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
4346
4461
  }
4347
4462
  goal = store.requireGoal(goal.goalId);
4348
- nodes = store.listGoalPlanNodes(goal.goalId);
4463
+ nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
4349
4464
  if (budgetExhausted(goal)) {
4350
4465
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4351
4466
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
4352
4467
  }
4353
4468
  const readyKeys = readyNodeKeys({
4354
- status: goal.status === "active" ? "active" : goal.status === "budgetLimited" ? "budgetLimited" : "blocked",
4469
+ status: planStatusForGoal(goal),
4355
4470
  nodes
4356
4471
  });
4357
4472
  if (readyKeys.length > 0) {
@@ -4450,7 +4565,9 @@ ${result.stderr}`);
4450
4565
  }
4451
4566
  continue;
4452
4567
  }
4453
- const blocker = "no ready goal nodes and goal plan is incomplete";
4568
+ const diagnostic = noReadyDiagnostic(goal, nodes);
4569
+ lastDiagnostic = diagnostic;
4570
+ const blocker = diagnostic.blocker;
4454
4571
  if (blocker === lastBlocker)
4455
4572
  repeatedBlockerCount += 1;
4456
4573
  else {
@@ -4462,15 +4579,16 @@ ${result.stderr}`);
4462
4579
  turn,
4463
4580
  phase: "status",
4464
4581
  status: repeatedBlockerCount >= 3 ? "blocked" : "active",
4465
- evidence: { blocker }
4582
+ evidence: diagnostic
4466
4583
  }, { daemonLeaseId: opts.daemonLeaseId });
4467
4584
  if (repeatedBlockerCount >= 3) {
4468
4585
  goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
4469
- return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
4586
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
4470
4587
  }
4471
4588
  }
4472
4589
  goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
4473
- return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation), "goal max turns exhausted", startedAt);
4590
+ const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
4591
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
4474
4592
  }
4475
4593
 
4476
4594
  // src/lib/workflow-runner.ts
@@ -5493,14 +5611,15 @@ function enableStartup(result) {
5493
5611
  // package.json
5494
5612
  var package_default = {
5495
5613
  name: "@hasna/loops",
5496
- version: "0.3.56",
5614
+ version: "0.3.57",
5497
5615
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5498
5616
  type: "module",
5499
5617
  main: "dist/index.js",
5500
5618
  types: "dist/index.d.ts",
5501
5619
  bin: {
5502
5620
  loops: "dist/cli/index.js",
5503
- "loops-daemon": "dist/daemon/index.js"
5621
+ "loops-daemon": "dist/daemon/index.js",
5622
+ "loops-mcp": "dist/mcp/index.js"
5504
5623
  },
5505
5624
  exports: {
5506
5625
  ".": {
@@ -5511,6 +5630,10 @@ var package_default = {
5511
5630
  types: "./dist/sdk/index.d.ts",
5512
5631
  import: "./dist/sdk/index.js"
5513
5632
  },
5633
+ "./mcp": {
5634
+ types: "./dist/mcp/index.d.ts",
5635
+ import: "./dist/mcp/index.js"
5636
+ },
5514
5637
  "./storage": {
5515
5638
  types: "./dist/lib/store.d.ts",
5516
5639
  import: "./dist/lib/store.js"
@@ -5523,7 +5646,7 @@ var package_default = {
5523
5646
  "LICENSE"
5524
5647
  ],
5525
5648
  scripts: {
5526
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
5649
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
5527
5650
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
5528
5651
  typecheck: "tsc --noEmit",
5529
5652
  test: "bun test",
@@ -5559,6 +5682,7 @@ var package_default = {
5559
5682
  dependencies: {
5560
5683
  "@hasna/events": "^0.1.9",
5561
5684
  "@hasna/machines": "0.0.49",
5685
+ "@modelcontextprotocol/sdk": "^1.29.0",
5562
5686
  "@openrouter/ai-sdk-provider": "2.9.1",
5563
5687
  ai: "6.0.204",
5564
5688
  commander: "^13.1.0",
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./types.js";
2
2
  export { LoopsClient, loops, openAutomationsRuntimeBinding } from "./sdk/index.js";
3
3
  export type { LoopsClientOptions } from "./sdk/index.js";
4
+ export { createLoopsMcpServer, listToolsForCli, LOOPS_MCP_TOOLS } from "./mcp/index.js";
5
+ export type { LoopsMcpToolMetadata } from "./mcp/index.js";
4
6
  export { Store } from "./lib/store.js";
5
7
  export { parseDuration, parseCron, nextCronRun, initialNextRun, computeNextAfter } from "./lib/schedule.js";
6
8
  export { executeLoop, executeTarget, preflightTarget } from "./lib/executor.js";