@hasna/loops 0.3.38 → 0.3.40

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.
@@ -3,8 +3,9 @@
3
3
 
4
4
  // src/lib/store.ts
5
5
  import { Database } from "bun:sqlite";
6
- import { mkdirSync as mkdirSync2 } from "fs";
7
- import { dirname } from "path";
6
+ import { mkdirSync as mkdirSync3, mkdtempSync, rmSync } from "fs";
7
+ import { tmpdir } from "os";
8
+ import { dirname, join as join3 } from "path";
8
9
 
9
10
  // src/lib/ids.ts
10
11
  import { randomBytes } from "crypto";
@@ -365,6 +366,8 @@ function validateTarget(value, label) {
365
366
  assertObject(value, label);
366
367
  if (value.type === "command") {
367
368
  assertString(value.command, `${label}.command`);
369
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
370
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
368
371
  if (value.shell !== true && /\s/.test(value.command.trim())) {
369
372
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
370
373
  }
@@ -376,6 +379,8 @@ function validateTarget(value, label) {
376
379
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
377
380
  if (!providers.includes(value.provider))
378
381
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
382
+ optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
383
+ optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
379
384
  if (value.authProfile !== undefined) {
380
385
  assertString(value.authProfile, `${label}.authProfile`);
381
386
  if (value.provider !== "codewith")
@@ -530,6 +535,52 @@ function workflowBodyFromJson(value, fallbackName) {
530
535
  });
531
536
  }
532
537
 
538
+ // src/lib/run-artifacts.ts
539
+ import { createHash } from "crypto";
540
+ import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
541
+ import { basename, join as join2 } from "path";
542
+ function shortHash(value) {
543
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
544
+ }
545
+ function safeRunPathSlug(value, fallback) {
546
+ const raw = value?.trim() || fallback;
547
+ const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
548
+ return slug || fallback;
549
+ }
550
+ function workflowRunSubjectKey(kind, rawSubjectRef) {
551
+ const raw = rawSubjectRef?.trim() || "subject";
552
+ const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
553
+ const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
554
+ return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
555
+ ${raw}`)}`;
556
+ }
557
+ function workflowRunProjectSlug(projectKey) {
558
+ if (!projectKey?.trim())
559
+ return "global";
560
+ return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
561
+ }
562
+ function writeWorkflowRunManifest(args) {
563
+ const projectSlug = workflowRunProjectSlug(args.projectKey);
564
+ const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
565
+ const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
566
+ mkdirSync2(dir, { recursive: true, mode: 448 });
567
+ const manifestPath = join2(dir, "manifest.json");
568
+ writeFileSync(manifestPath, JSON.stringify({
569
+ version: 1,
570
+ workflowRunId: args.workflowRunId,
571
+ workflowId: args.workflowId,
572
+ workflowName: args.workflowName,
573
+ invocationId: args.invocationId,
574
+ workItemId: args.workItemId,
575
+ projectSlug,
576
+ subjectKey,
577
+ requiredReading: [],
578
+ createdAt: new Date().toISOString(),
579
+ ...args.payload
580
+ }, null, 2), { mode: 384 });
581
+ return manifestPath;
582
+ }
583
+
533
584
  // src/lib/store.ts
534
585
  function rowToLoop(row) {
535
586
  return {
@@ -599,8 +650,11 @@ function rowToWorkflowRun(row) {
599
650
  workflowName: row.workflow_name,
600
651
  loopId: row.loop_id ?? undefined,
601
652
  loopRunId: row.loop_run_id ?? undefined,
653
+ invocationId: row.invocation_id ?? undefined,
654
+ workItemId: row.work_item_id ?? undefined,
602
655
  scheduledFor: row.scheduled_for ?? undefined,
603
656
  idempotencyKey: row.idempotency_key ?? undefined,
657
+ manifestPath: row.manifest_path ?? undefined,
604
658
  status: row.status,
605
659
  startedAt: row.started_at ?? undefined,
606
660
  finishedAt: row.finished_at ?? undefined,
@@ -611,6 +665,44 @@ function rowToWorkflowRun(row) {
611
665
  updatedAt: row.updated_at
612
666
  };
613
667
  }
668
+ function rowToWorkflowInvocation(row) {
669
+ return {
670
+ id: row.id,
671
+ workflowId: row.workflow_id ?? undefined,
672
+ templateId: row.template_id ?? undefined,
673
+ sourceRef: JSON.parse(row.source_json),
674
+ subjectRef: JSON.parse(row.subject_json),
675
+ intent: row.intent,
676
+ scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
677
+ outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
678
+ createdAt: row.created_at,
679
+ updatedAt: row.updated_at
680
+ };
681
+ }
682
+ function rowToWorkflowWorkItem(row) {
683
+ return {
684
+ id: row.id,
685
+ routeKey: row.route_key,
686
+ idempotencyKey: row.idempotency_key,
687
+ invocationId: row.invocation_id,
688
+ sourceType: row.source_type,
689
+ sourceRef: row.source_ref,
690
+ subjectRef: row.subject_ref,
691
+ projectKey: row.project_key ?? undefined,
692
+ projectGroup: row.project_group ?? undefined,
693
+ priority: row.priority,
694
+ status: row.status,
695
+ attempts: row.attempts,
696
+ nextAttemptAt: row.next_attempt_at ?? undefined,
697
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
698
+ workflowId: row.workflow_id ?? undefined,
699
+ loopId: row.loop_id ?? undefined,
700
+ workflowRunId: row.workflow_run_id ?? undefined,
701
+ lastReason: row.last_reason ?? undefined,
702
+ createdAt: row.created_at,
703
+ updatedAt: row.updated_at
704
+ };
705
+ }
614
706
  function rowToWorkflowStepRun(row) {
615
707
  return {
616
708
  id: row.id,
@@ -724,13 +816,23 @@ function rowToLease(row) {
724
816
  updatedAt: row.updated_at
725
817
  };
726
818
  }
819
+ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
820
+ if (status === "succeeded")
821
+ return "succeeded";
822
+ if (["failed", "timed_out", "abandoned"].includes(status)) {
823
+ return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
824
+ }
825
+ return;
826
+ }
727
827
 
728
828
  class Store {
729
829
  db;
830
+ rootDir;
730
831
  constructor(path) {
731
832
  const file = path ?? dbPath();
732
833
  if (file !== ":memory:")
733
- mkdirSync2(dirname(file), { recursive: true, mode: 448 });
834
+ mkdirSync3(dirname(file), { recursive: true, mode: 448 });
835
+ this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
734
836
  this.db = new Database(file);
735
837
  this.db.exec("PRAGMA busy_timeout = 5000;");
736
838
  this.db.exec("PRAGMA journal_mode = WAL;");
@@ -825,8 +927,11 @@ class Store {
825
927
  workflow_name TEXT NOT NULL,
826
928
  loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
827
929
  loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
930
+ invocation_id TEXT,
931
+ work_item_id TEXT,
828
932
  scheduled_for TEXT,
829
933
  idempotency_key TEXT,
934
+ manifest_path TEXT,
830
935
  status TEXT NOT NULL,
831
936
  started_at TEXT,
832
937
  finished_at TEXT,
@@ -843,6 +948,59 @@ class Store {
843
948
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
844
949
  CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
845
950
 
951
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
952
+ id TEXT PRIMARY KEY,
953
+ workflow_id TEXT,
954
+ template_id TEXT,
955
+ source_kind TEXT NOT NULL,
956
+ source_id TEXT,
957
+ source_dedupe_key TEXT,
958
+ source_json TEXT NOT NULL,
959
+ subject_kind TEXT NOT NULL,
960
+ subject_id TEXT,
961
+ subject_path TEXT,
962
+ subject_url TEXT,
963
+ subject_json TEXT NOT NULL,
964
+ intent TEXT NOT NULL,
965
+ scope_json TEXT,
966
+ output_policy_json TEXT,
967
+ created_at TEXT NOT NULL,
968
+ updated_at TEXT NOT NULL
969
+ );
970
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
971
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
972
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
973
+ ON workflow_invocations(source_kind, source_dedupe_key)
974
+ WHERE source_dedupe_key IS NOT NULL;
975
+
976
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
977
+ id TEXT PRIMARY KEY,
978
+ route_key TEXT NOT NULL,
979
+ idempotency_key TEXT NOT NULL,
980
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
981
+ source_type TEXT NOT NULL,
982
+ source_ref TEXT NOT NULL,
983
+ subject_ref TEXT NOT NULL,
984
+ project_key TEXT,
985
+ project_group TEXT,
986
+ priority INTEGER NOT NULL,
987
+ status TEXT NOT NULL,
988
+ attempts INTEGER NOT NULL,
989
+ next_attempt_at TEXT,
990
+ lease_expires_at TEXT,
991
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
992
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
993
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
994
+ last_reason TEXT,
995
+ created_at TEXT NOT NULL,
996
+ updated_at TEXT NOT NULL,
997
+ UNIQUE(route_key, idempotency_key)
998
+ );
999
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
1000
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1001
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1002
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1003
+
846
1004
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
847
1005
  id TEXT PRIMARY KEY,
848
1006
  workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
@@ -955,12 +1113,17 @@ class Store {
955
1113
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
956
1114
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
957
1115
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
1116
+ this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
1117
+ this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
1118
+ this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
958
1119
  this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
959
1120
  this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
1121
+ this.createWorkflowRunBackfillIndexes();
960
1122
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
961
1123
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
962
1124
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
963
1125
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
1126
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
964
1127
  }
965
1128
  addColumnIfMissing(table, column, definition) {
966
1129
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -968,6 +1131,12 @@ class Store {
968
1131
  return;
969
1132
  this.db.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
970
1133
  }
1134
+ createWorkflowRunBackfillIndexes() {
1135
+ this.db.exec(`
1136
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
1137
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
1138
+ `);
1139
+ }
971
1140
  assertDaemonLeaseFence(opts = {}, now = nowIso()) {
972
1141
  if (!opts.daemonLeaseId)
973
1142
  return;
@@ -1083,6 +1252,10 @@ class Store {
1083
1252
  $daemonLeaseId: opts.daemonLeaseId ?? null,
1084
1253
  $now: updated
1085
1254
  });
1255
+ if (patch.status && patch.status !== "active") {
1256
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1257
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1258
+ }
1086
1259
  const after = this.getLoop(id);
1087
1260
  if (!after)
1088
1261
  throw new Error(`loop not found after update: ${id}`);
@@ -1127,6 +1300,7 @@ class Store {
1127
1300
  $archivedFromStatus: loop.status,
1128
1301
  $updated: updated
1129
1302
  });
1303
+ this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
1130
1304
  const archived = this.getLoop(loop.id);
1131
1305
  if (!archived)
1132
1306
  throw new Error(`loop not found after archive: ${loop.id}`);
@@ -1152,6 +1326,7 @@ class Store {
1152
1326
  }
1153
1327
  deleteLoop(idOrName) {
1154
1328
  const loop = this.requireLoop(idOrName);
1329
+ this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
1155
1330
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
1156
1331
  return res.changes > 0;
1157
1332
  }
@@ -1210,6 +1385,185 @@ class Store {
1210
1385
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1211
1386
  return archived;
1212
1387
  }
1388
+ createWorkflowInvocation(input) {
1389
+ const now = nowIso();
1390
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1391
+ if (sourceDedupeKey) {
1392
+ const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
1393
+ if (existing)
1394
+ return rowToWorkflowInvocation(existing);
1395
+ }
1396
+ const id = input.id ?? genId();
1397
+ this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
1398
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
1399
+ output_policy_json, created_at, updated_at)
1400
+ VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
1401
+ $subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
1402
+ $outputPolicyJson, $created, $updated)`).run({
1403
+ $id: id,
1404
+ $workflowId: input.workflowId ?? null,
1405
+ $templateId: input.templateId ?? null,
1406
+ $sourceKind: input.sourceRef.kind,
1407
+ $sourceId: input.sourceRef.id ?? null,
1408
+ $sourceDedupeKey: sourceDedupeKey ?? null,
1409
+ $sourceJson: JSON.stringify(input.sourceRef),
1410
+ $subjectKind: input.subjectRef.kind,
1411
+ $subjectId: input.subjectRef.id ?? null,
1412
+ $subjectPath: input.subjectRef.path ?? null,
1413
+ $subjectUrl: input.subjectRef.url ?? null,
1414
+ $subjectJson: JSON.stringify(input.subjectRef),
1415
+ $intent: input.intent,
1416
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1417
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1418
+ $created: now,
1419
+ $updated: now
1420
+ });
1421
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1422
+ if (!row)
1423
+ throw new Error(`workflow invocation not found after create: ${id}`);
1424
+ return rowToWorkflowInvocation(row);
1425
+ }
1426
+ getWorkflowInvocation(id) {
1427
+ const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1428
+ return row ? rowToWorkflowInvocation(row) : undefined;
1429
+ }
1430
+ listWorkflowInvocations(opts = {}) {
1431
+ const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
1432
+ return rows.map(rowToWorkflowInvocation);
1433
+ }
1434
+ upsertWorkflowWorkItem(input) {
1435
+ const now = nowIso();
1436
+ const id = genId();
1437
+ const status = input.status ?? "queued";
1438
+ this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
1439
+ subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
1440
+ workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
1441
+ VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
1442
+ $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
1443
+ $lastReason, $created, $updated)
1444
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
1445
+ invocation_id=excluded.invocation_id,
1446
+ source_type=excluded.source_type,
1447
+ source_ref=excluded.source_ref,
1448
+ subject_ref=excluded.subject_ref,
1449
+ project_key=excluded.project_key,
1450
+ project_group=excluded.project_group,
1451
+ priority=excluded.priority,
1452
+ status=CASE
1453
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1454
+ THEN workflow_work_items.status
1455
+ ELSE excluded.status
1456
+ END,
1457
+ workflow_id=CASE
1458
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1459
+ ELSE NULL
1460
+ END,
1461
+ loop_id=CASE
1462
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1463
+ ELSE NULL
1464
+ END,
1465
+ workflow_run_id=CASE
1466
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1467
+ ELSE NULL
1468
+ END,
1469
+ lease_expires_at=CASE
1470
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1471
+ ELSE NULL
1472
+ END,
1473
+ next_attempt_at=excluded.next_attempt_at,
1474
+ last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1475
+ updated_at=excluded.updated_at`).run({
1476
+ $id: id,
1477
+ $routeKey: input.routeKey,
1478
+ $idempotencyKey: input.idempotencyKey,
1479
+ $invocationId: input.invocationId,
1480
+ $sourceType: input.sourceType,
1481
+ $sourceRef: input.sourceRef,
1482
+ $subjectRef: input.subjectRef,
1483
+ $projectKey: input.projectKey ?? null,
1484
+ $projectGroup: input.projectGroup ?? null,
1485
+ $priority: input.priority ?? 0,
1486
+ $status: status,
1487
+ $nextAttemptAt: input.nextAttemptAt ?? null,
1488
+ $lastReason: input.lastReason ?? null,
1489
+ $created: now,
1490
+ $updated: now
1491
+ });
1492
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
1493
+ if (!row)
1494
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
1495
+ return rowToWorkflowWorkItem(row);
1496
+ }
1497
+ getWorkflowWorkItem(id) {
1498
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
1499
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1500
+ }
1501
+ findWorkflowWorkItem(routeKey, idempotencyKey) {
1502
+ const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
1503
+ return row ? rowToWorkflowWorkItem(row) : undefined;
1504
+ }
1505
+ listWorkflowWorkItems(opts = {}) {
1506
+ const limit = opts.limit ?? 100;
1507
+ let rows;
1508
+ if (opts.status && opts.routeKey) {
1509
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.routeKey, opts.status, limit);
1510
+ } else if (opts.status) {
1511
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
1512
+ } else if (opts.routeKey) {
1513
+ rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
1514
+ } else {
1515
+ rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
1516
+ }
1517
+ return rows.map(rowToWorkflowWorkItem);
1518
+ }
1519
+ countActiveWorkflowWorkItems(args = {}) {
1520
+ const active = ["admitted", "running"];
1521
+ const placeholders = active.map(() => "?").join(",");
1522
+ const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
1523
+ const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
1524
+ 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;
1525
+ return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1526
+ }
1527
+ admitWorkflowWorkItem(id, patch) {
1528
+ const now = nowIso();
1529
+ const res = this.db.query(`UPDATE workflow_work_items
1530
+ SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1531
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1532
+ WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1533
+ $id: id,
1534
+ $workflowId: patch.workflowId,
1535
+ $loopId: patch.loopId,
1536
+ $reason: patch.reason ?? null,
1537
+ $updated: now
1538
+ });
1539
+ const item = this.getWorkflowWorkItem(id);
1540
+ if (!item)
1541
+ throw new Error(`workflow work item not found after admit: ${id}`);
1542
+ if (res.changes !== 1)
1543
+ throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
1544
+ return item;
1545
+ }
1546
+ setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
1547
+ const placeholders = statuses.map(() => "?").join(",");
1548
+ this.db.query(`UPDATE workflow_work_items
1549
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1550
+ WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
1551
+ }
1552
+ setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
1553
+ const placeholders = statuses.map(() => "?").join(",");
1554
+ this.db.query(`UPDATE workflow_work_items
1555
+ SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
1556
+ WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
1557
+ }
1558
+ setWorkflowWorkItemsForLoopRun(run, reason, updated) {
1559
+ const loop = this.getLoop(run.loopId);
1560
+ const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
1561
+ if (!status)
1562
+ return;
1563
+ const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
1564
+ const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
1565
+ this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
1566
+ }
1213
1567
  createGoal(input, opts = {}) {
1214
1568
  const now = nowIso();
1215
1569
  this.db.exec("BEGIN IMMEDIATE");
@@ -1483,6 +1837,10 @@ class Store {
1483
1837
  }
1484
1838
  createWorkflowRun(input) {
1485
1839
  const now = nowIso();
1840
+ const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
1841
+ const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
1842
+ const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
1843
+ let manifestPath;
1486
1844
  if (input.idempotencyKey) {
1487
1845
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
1488
1846
  if (existing) {
@@ -1501,21 +1859,59 @@ class Store {
1501
1859
  }
1502
1860
  }
1503
1861
  const runId = genId();
1504
- this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
1505
- status, started_at, finished_at, duration_ms, error, created_at, updated_at)
1506
- VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
1507
- 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1862
+ const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
1863
+ const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
1864
+ manifestPath = invocation || workItem ? writeWorkflowRunManifest({
1865
+ loopsDataDir: this.rootDir,
1866
+ workflowRunId: runId,
1867
+ workflowId: input.workflow.id,
1868
+ workflowName: input.workflow.name,
1869
+ invocationId,
1870
+ workItemId,
1871
+ projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
1872
+ subjectKind: invocation?.subjectRef.kind,
1873
+ rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
1874
+ payload: {
1875
+ workflowInvocation: invocation,
1876
+ workflowWorkItem: workItem,
1877
+ loopId: input.loop?.id,
1878
+ loopRunId: input.loopRun?.id,
1879
+ scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
1880
+ }
1881
+ }) : undefined;
1882
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
1883
+ scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
1884
+ created_at, updated_at)
1885
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
1886
+ $idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
1508
1887
  $id: runId,
1509
1888
  $workflowId: input.workflow.id,
1510
1889
  $workflowName: input.workflow.name,
1511
1890
  $loopId: input.loop?.id ?? null,
1512
1891
  $loopRunId: input.loopRun?.id ?? null,
1892
+ $invocationId: invocationId ?? null,
1893
+ $workItemId: workItemId ?? null,
1513
1894
  $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
1514
1895
  $idempotencyKey: input.idempotencyKey ?? null,
1896
+ $manifestPath: manifestPath ?? null,
1515
1897
  $started: now,
1516
1898
  $created: now,
1517
1899
  $updated: now
1518
1900
  });
1901
+ if (workItemId) {
1902
+ const workItemRes = this.db.query(`UPDATE workflow_work_items
1903
+ SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
1904
+ WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
1905
+ $id: workItemId,
1906
+ $workflowRunId: runId,
1907
+ $leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
1908
+ $updated: now
1909
+ });
1910
+ if (workItemRes.changes !== 1) {
1911
+ const current = this.getWorkflowWorkItem(workItemId);
1912
+ throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
1913
+ }
1914
+ }
1519
1915
  input.workflow.steps.forEach((step, sequence) => {
1520
1916
  const account = step.account ?? step.target.account;
1521
1917
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
@@ -1541,7 +1937,10 @@ class Store {
1541
1937
  workflowName: input.workflow.name,
1542
1938
  stepCount: input.workflow.steps.length,
1543
1939
  loopId: input.loop?.id,
1544
- loopRunId: input.loopRun?.id
1940
+ loopRunId: input.loopRun?.id,
1941
+ invocationId,
1942
+ workItemId,
1943
+ manifestPath
1545
1944
  }),
1546
1945
  $created: now
1547
1946
  });
@@ -1554,6 +1953,8 @@ class Store {
1554
1953
  try {
1555
1954
  this.db.exec("ROLLBACK");
1556
1955
  } catch {}
1956
+ if (manifestPath)
1957
+ rmSync(manifestPath, { force: true });
1557
1958
  throw error;
1558
1959
  }
1559
1960
  }
@@ -1766,6 +2167,10 @@ class Store {
1766
2167
  changed = res.changes === 1;
1767
2168
  if (changed)
1768
2169
  this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
2170
+ if (changed) {
2171
+ const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2172
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2173
+ }
1769
2174
  this.db.exec("COMMIT");
1770
2175
  } catch (error) {
1771
2176
  try {
@@ -1790,6 +2195,7 @@ class Store {
1790
2195
  this.db.query(`UPDATE workflow_step_runs
1791
2196
  SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
1792
2197
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2198
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
1793
2199
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
1794
2200
  }
1795
2201
  this.db.exec("COMMIT");
@@ -2043,6 +2449,8 @@ class Store {
2043
2449
  throw new Error(`run not found after finalize: ${id}`);
2044
2450
  if (opts.claimedBy && res.changes !== 1)
2045
2451
  return run;
2452
+ if (res.changes === 1)
2453
+ this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2046
2454
  return run;
2047
2455
  }
2048
2456
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2136,6 +2544,14 @@ class Store {
2136
2544
  error: "parent loop run lease expired before completion",
2137
2545
  loopRunId: row.id
2138
2546
  });
2547
+ this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2548
+ }
2549
+ const loop = this.getLoop(row.loop_id);
2550
+ const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
2551
+ if (itemStatus) {
2552
+ const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2553
+ const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2554
+ this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2139
2555
  }
2140
2556
  this.db.exec("COMMIT");
2141
2557
  } catch (error) {
@@ -2356,7 +2772,7 @@ function resolveAccountEnv(account, toolHint, env) {
2356
2772
  // src/lib/env.ts
2357
2773
  import { accessSync, constants } from "fs";
2358
2774
  import { homedir as homedir2 } from "os";
2359
- import { delimiter, join as join2 } from "path";
2775
+ import { delimiter, join as join4 } from "path";
2360
2776
  function compactPathParts(parts) {
2361
2777
  const seen = new Set;
2362
2778
  const result = [];
@@ -2372,14 +2788,14 @@ function compactPathParts(parts) {
2372
2788
  function commonExecutableDirs(env = process.env) {
2373
2789
  const home = env.HOME || homedir2();
2374
2790
  return compactPathParts([
2375
- join2(home, ".local", "bin"),
2376
- join2(home, ".bun", "bin"),
2377
- join2(home, ".cargo", "bin"),
2378
- join2(home, ".npm-global", "bin"),
2379
- join2(home, "bin"),
2380
- env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
2791
+ join4(home, ".local", "bin"),
2792
+ join4(home, ".bun", "bin"),
2793
+ join4(home, ".cargo", "bin"),
2794
+ join4(home, ".npm-global", "bin"),
2795
+ join4(home, "bin"),
2796
+ env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
2381
2797
  env.PNPM_HOME,
2382
- env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2798
+ env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
2383
2799
  "/opt/homebrew/bin",
2384
2800
  "/usr/local/bin",
2385
2801
  "/usr/bin",
@@ -2403,7 +2819,7 @@ function executableExists(command, env = process.env) {
2403
2819
  if (command.includes("/"))
2404
2820
  return isExecutable(command);
2405
2821
  for (const dir of (env.PATH ?? "").split(delimiter)) {
2406
- if (dir && isExecutable(join2(dir, command)))
2822
+ if (dir && isExecutable(join4(dir, command)))
2407
2823
  return true;
2408
2824
  }
2409
2825
  return false;
@@ -2761,6 +3177,7 @@ function commandSpec(target) {
2761
3177
  shell: commandTarget.shell,
2762
3178
  env: commandTarget.env,
2763
3179
  timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3180
+ idleTimeoutMs: commandTarget.idleTimeoutMs,
2764
3181
  account: commandTarget.account,
2765
3182
  accountTool: commandTarget.account?.tool
2766
3183
  };
@@ -2771,6 +3188,7 @@ function commandSpec(target) {
2771
3188
  args: agentArgs(agentTarget),
2772
3189
  cwd: agentTarget.cwd,
2773
3190
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3191
+ idleTimeoutMs: agentTarget.idleTimeoutMs,
2774
3192
  account: agentTarget.account,
2775
3193
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2776
3194
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
@@ -2918,6 +3336,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
2918
3336
  let stdout = "";
2919
3337
  let stderr = "";
2920
3338
  let timedOut = false;
3339
+ let idleTimedOut = false;
2921
3340
  let exitCode;
2922
3341
  let error;
2923
3342
  let plan;
@@ -2956,18 +3375,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
2956
3375
  if (opts.signal?.aborted)
2957
3376
  abortHandler();
2958
3377
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
2959
- child.stdout?.on("data", (chunk) => {
2960
- stdout = appendBounded(stdout, chunk, maxOutputBytes);
2961
- });
2962
- child.stderr?.on("data", (chunk) => {
2963
- stderr = appendBounded(stderr, chunk, maxOutputBytes);
2964
- });
2965
3378
  const timer = setTimeout(() => {
2966
3379
  timedOut = true;
2967
3380
  if (child.pid)
2968
3381
  killProcessGroup(child.pid);
2969
3382
  }, spec.timeoutMs);
2970
3383
  timer.unref();
3384
+ let idleTimer;
3385
+ const resetIdleTimer = () => {
3386
+ if (!spec.idleTimeoutMs)
3387
+ return;
3388
+ if (idleTimer)
3389
+ clearTimeout(idleTimer);
3390
+ idleTimer = setTimeout(() => {
3391
+ idleTimedOut = true;
3392
+ if (child.pid)
3393
+ killProcessGroup(child.pid);
3394
+ }, spec.idleTimeoutMs);
3395
+ idleTimer.unref();
3396
+ };
3397
+ resetIdleTimer();
3398
+ child.stdout?.on("data", (chunk) => {
3399
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3400
+ resetIdleTimer();
3401
+ });
3402
+ child.stderr?.on("data", (chunk) => {
3403
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3404
+ resetIdleTimer();
3405
+ });
2971
3406
  try {
2972
3407
  const [code, signal] = await once(child, "exit");
2973
3408
  if (typeof code === "number")
@@ -2978,17 +3413,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
2978
3413
  error = err instanceof Error ? err.message : String(err);
2979
3414
  } finally {
2980
3415
  clearTimeout(timer);
3416
+ if (idleTimer)
3417
+ clearTimeout(idleTimer);
2981
3418
  opts.signal?.removeEventListener("abort", abortHandler);
2982
3419
  }
2983
3420
  const finishedAt = nowIso();
2984
3421
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
2985
- if (timedOut) {
3422
+ if (timedOut || idleTimedOut) {
2986
3423
  return {
2987
3424
  status: "timed_out",
2988
3425
  exitCode,
2989
3426
  stdout,
2990
3427
  stderr,
2991
- error: `timed out after ${spec.timeoutMs}ms`,
3428
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
2992
3429
  pid: child.pid,
2993
3430
  startedAt,
2994
3431
  finishedAt,
@@ -3054,6 +3491,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3054
3491
  let stdout = "";
3055
3492
  let stderr = "";
3056
3493
  let timedOut = false;
3494
+ let idleTimedOut = false;
3057
3495
  let exitCode;
3058
3496
  let error;
3059
3497
  const env = executionEnv(spec, metadata, opts);
@@ -3116,18 +3554,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3116
3554
  if (opts.signal?.aborted)
3117
3555
  abortHandler();
3118
3556
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3119
- child.stdout?.on("data", (chunk) => {
3120
- stdout = appendBounded(stdout, chunk, maxOutputBytes);
3121
- });
3122
- child.stderr?.on("data", (chunk) => {
3123
- stderr = appendBounded(stderr, chunk, maxOutputBytes);
3124
- });
3125
3557
  const timer = setTimeout(() => {
3126
3558
  timedOut = true;
3127
3559
  if (child.pid)
3128
3560
  killProcessGroup(child.pid);
3129
3561
  }, spec.timeoutMs);
3130
3562
  timer.unref();
3563
+ let idleTimer;
3564
+ const resetIdleTimer = () => {
3565
+ if (!spec.idleTimeoutMs)
3566
+ return;
3567
+ if (idleTimer)
3568
+ clearTimeout(idleTimer);
3569
+ idleTimer = setTimeout(() => {
3570
+ idleTimedOut = true;
3571
+ if (child.pid)
3572
+ killProcessGroup(child.pid);
3573
+ }, spec.idleTimeoutMs);
3574
+ idleTimer.unref();
3575
+ };
3576
+ resetIdleTimer();
3577
+ child.stdout?.on("data", (chunk) => {
3578
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
3579
+ resetIdleTimer();
3580
+ });
3581
+ child.stderr?.on("data", (chunk) => {
3582
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
3583
+ resetIdleTimer();
3584
+ });
3131
3585
  try {
3132
3586
  const [code, signal] = await once(child, "exit");
3133
3587
  if (typeof code === "number")
@@ -3138,17 +3592,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3138
3592
  error = err instanceof Error ? err.message : String(err);
3139
3593
  } finally {
3140
3594
  clearTimeout(timer);
3595
+ if (idleTimer)
3596
+ clearTimeout(idleTimer);
3141
3597
  opts.signal?.removeEventListener("abort", abortHandler);
3142
3598
  }
3143
3599
  const finishedAt = nowIso();
3144
3600
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
3145
- if (timedOut) {
3601
+ if (timedOut || idleTimedOut) {
3146
3602
  return {
3147
3603
  status: "timed_out",
3148
3604
  exitCode,
3149
3605
  stdout,
3150
3606
  stderr,
3151
- error: `timed out after ${spec.timeoutMs}ms`,
3607
+ error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
3152
3608
  pid: child.pid,
3153
3609
  startedAt,
3154
3610
  finishedAt,
@@ -4190,7 +4646,7 @@ async function tick(deps) {
4190
4646
  }
4191
4647
 
4192
4648
  // src/daemon/control.ts
4193
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
4649
+ import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4194
4650
  import { hostname } from "os";
4195
4651
  import { dirname as dirname2 } from "path";
4196
4652
 
@@ -4228,11 +4684,11 @@ function readPid(path = pidFilePath()) {
4228
4684
  }
4229
4685
  }
4230
4686
  function writePid(pid = process.pid, path = pidFilePath()) {
4231
- mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
4232
- writeFileSync(path, String(pid));
4687
+ mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
4688
+ writeFileSync2(path, String(pid));
4233
4689
  }
4234
4690
  function removePid(path = pidFilePath()) {
4235
- rmSync(path, { force: true });
4691
+ rmSync2(path, { force: true });
4236
4692
  }
4237
4693
  function isAlive(pid) {
4238
4694
  try {
@@ -4488,7 +4944,7 @@ async function startDaemon(opts) {
4488
4944
  }
4489
4945
 
4490
4946
  // src/daemon/install.ts
4491
- import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
4947
+ import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
4492
4948
  import { spawnSync as spawnSync3 } from "child_process";
4493
4949
  import { dirname as dirname3 } from "path";
4494
4950
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
@@ -4496,8 +4952,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
4496
4952
  const pathEnv = normalizeExecutionPath(process.env);
4497
4953
  if (process.platform === "linux") {
4498
4954
  const path = systemdServicePath();
4499
- mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
4500
- writeFileSync2(path, `[Unit]
4955
+ mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
4956
+ writeFileSync3(path, `[Unit]
4501
4957
  Description=Hasna OpenLoops daemon
4502
4958
  After=default.target
4503
4959
 
@@ -4523,8 +4979,8 @@ WantedBy=default.target
4523
4979
  }
4524
4980
  if (process.platform === "darwin") {
4525
4981
  const path = launchdPlistPath();
4526
- mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
4527
- writeFileSync2(path, `<?xml version="1.0" encoding="UTF-8"?>
4982
+ mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
4983
+ writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
4528
4984
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4529
4985
  <plist version="1.0">
4530
4986
  <dict>
@@ -4574,7 +5030,7 @@ function enableStartup(result) {
4574
5030
  // package.json
4575
5031
  var package_default = {
4576
5032
  name: "@hasna/loops",
4577
- version: "0.3.38",
5033
+ version: "0.3.40",
4578
5034
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4579
5035
  type: "module",
4580
5036
  main: "dist/index.js",