@hasna/loops 0.4.13 → 0.4.22

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +99 -3
  2. package/README.md +171 -39
  3. package/dist/api/index.d.ts +36 -0
  4. package/dist/api/index.js +1518 -15
  5. package/dist/cli/index.js +7280 -3213
  6. package/dist/cli/ui.d.ts +44 -0
  7. package/dist/daemon/index.js +1433 -303
  8. package/dist/generated/storage-kit/health.d.ts +19 -0
  9. package/dist/generated/storage-kit/index.d.ts +7 -0
  10. package/dist/generated/storage-kit/migrations.d.ts +47 -0
  11. package/dist/generated/storage-kit/mode.d.ts +47 -0
  12. package/dist/generated/storage-kit/pool.d.ts +33 -0
  13. package/dist/generated/storage-kit/query.d.ts +35 -0
  14. package/dist/generated/storage-kit/tls.d.ts +25 -0
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +8689 -4837
  17. package/dist/lib/cloud/mode.d.ts +17 -0
  18. package/dist/lib/cloud/resolve.d.ts +16 -0
  19. package/dist/lib/cloud/storage.d.ts +82 -0
  20. package/dist/lib/cloud/transport.d.ts +148 -0
  21. package/dist/lib/format.d.ts +2 -1
  22. package/dist/lib/health.d.ts +83 -3
  23. package/dist/lib/migration.d.ts +40 -0
  24. package/dist/lib/mode.js +15 -3
  25. package/dist/lib/route/index.d.ts +2 -0
  26. package/dist/lib/route/policies.d.ts +51 -0
  27. package/dist/lib/route/provider-admission.d.ts +37 -0
  28. package/dist/lib/route/throttle.d.ts +4 -0
  29. package/dist/lib/route/types.d.ts +8 -0
  30. package/dist/lib/run-receipts.d.ts +14 -0
  31. package/dist/lib/scheduler.d.ts +5 -2
  32. package/dist/lib/storage/contract.d.ts +7 -1
  33. package/dist/lib/storage/index.d.ts +1 -0
  34. package/dist/lib/storage/index.js +2028 -231
  35. package/dist/lib/storage/pg-executor.d.ts +27 -0
  36. package/dist/lib/storage/pg-runner-claim.d.ts +40 -0
  37. package/dist/lib/storage/postgres-loop-storage.d.ts +120 -0
  38. package/dist/lib/storage/postgres-schema.js +29 -0
  39. package/dist/lib/storage/postgres.js +29 -0
  40. package/dist/lib/storage/sqlite.d.ts +6 -0
  41. package/dist/lib/storage/sqlite.js +748 -26
  42. package/dist/lib/store/index.d.ts +268 -0
  43. package/dist/lib/store.d.ts +282 -1
  44. package/dist/lib/store.js +746 -26
  45. package/dist/lib/template-kit.d.ts +25 -0
  46. package/dist/lib/templates.d.ts +16 -1
  47. package/dist/mcp/http.d.ts +16 -0
  48. package/dist/mcp/index.js +2885 -634
  49. package/dist/runner/index.js +198 -32
  50. package/dist/sdk/http.d.ts +182 -0
  51. package/dist/sdk/http.js +166 -0
  52. package/dist/sdk/index.d.ts +55 -18
  53. package/dist/sdk/index.js +2734 -617
  54. package/dist/serve/index.d.ts +4 -0
  55. package/dist/serve/index.js +9095 -0
  56. package/dist/types.d.ts +52 -0
  57. package/docs/AUTOMATION_RUNTIME_DESIGN.md +442 -1
  58. package/docs/CUTOVER-RUNBOOK.md +78 -0
  59. package/docs/DEPLOYMENT_MODES.md +35 -18
  60. package/docs/RUNTIME_BOUNDARY.md +203 -0
  61. package/docs/USAGE.md +195 -38
  62. package/package.json +15 -3
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.13",
6
+ version: "0.4.22",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
@@ -12,6 +12,7 @@ var package_default = {
12
12
  loops: "dist/cli/index.js",
13
13
  "loops-daemon": "dist/daemon/index.js",
14
14
  "loops-api": "dist/api/index.js",
15
+ "loops-serve": "dist/serve/index.js",
15
16
  "loops-runner": "dist/runner/index.js",
16
17
  "loops-mcp": "dist/mcp/index.js"
17
18
  },
@@ -24,6 +25,14 @@ var package_default = {
24
25
  types: "./dist/sdk/index.d.ts",
25
26
  import: "./dist/sdk/index.js"
26
27
  },
28
+ "./sdk/http": {
29
+ types: "./dist/sdk/http.d.ts",
30
+ import: "./dist/sdk/http.js"
31
+ },
32
+ "./serve": {
33
+ types: "./dist/serve/index.d.ts",
34
+ import: "./dist/serve/index.js"
35
+ },
27
36
  "./mcp": {
28
37
  types: "./dist/mcp/index.d.ts",
29
38
  import: "./dist/mcp/index.js"
@@ -69,7 +78,7 @@ var package_default = {
69
78
  "LICENSE"
70
79
  ],
71
80
  scripts: {
72
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
81
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/serve/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/sdk/http.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/serve/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
73
82
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
74
83
  typecheck: "tsc --noEmit",
75
84
  test: "bun test",
@@ -109,13 +118,16 @@ var package_default = {
109
118
  "@openrouter/ai-sdk-provider": "2.9.1",
110
119
  ai: "6.0.204",
111
120
  commander: "^13.1.0",
121
+ pg: "^8.13.1",
112
122
  zod: "4.4.3"
113
123
  },
114
124
  optionalDependencies: {
115
- "@hasna/machines": "0.0.49"
125
+ "@hasna/machines": "0.0.49",
126
+ "@hasna/contracts": "^0.4.2"
116
127
  },
117
128
  devDependencies: {
118
129
  "@types/bun": "latest",
130
+ "@types/pg": "^8.11.10",
119
131
  typescript: "^5.7.3"
120
132
  },
121
133
  publishConfig: {
@@ -1086,6 +1098,7 @@ var TRANSPORT_ENV_KEYS = new Set([
1086
1098
  "LOGNAME",
1087
1099
  "PATH",
1088
1100
  "SHELL",
1101
+ "SHLVL",
1089
1102
  "SSH_AGENT_PID",
1090
1103
  "SSH_AUTH_SOCK",
1091
1104
  "TERM",
@@ -1118,6 +1131,32 @@ function timeoutResult(startedAt, error, fields = {}) {
1118
1131
  function successResult(startedAt, fields = {}) {
1119
1132
  return buildResult("succeeded", startedAt, fields);
1120
1133
  }
1134
+ function codewithJsonlHasTerminalSuccess(stdout) {
1135
+ for (const line of stdout.split(/\r?\n/)) {
1136
+ const trimmed = line.trim();
1137
+ if (!trimmed || !trimmed.startsWith("{"))
1138
+ continue;
1139
+ let event;
1140
+ try {
1141
+ event = JSON.parse(trimmed);
1142
+ } catch {
1143
+ continue;
1144
+ }
1145
+ if (!event || typeof event !== "object")
1146
+ continue;
1147
+ const record = event;
1148
+ if (record.type === "task_complete")
1149
+ return true;
1150
+ const payload = record.payload;
1151
+ if (payload && typeof payload === "object" && payload.type === "task_complete") {
1152
+ return true;
1153
+ }
1154
+ }
1155
+ return false;
1156
+ }
1157
+ function codewithJsonlReconciledSuccess(spec, fields) {
1158
+ return spec.agentProvider === "codewith" && codewithJsonlHasTerminalSuccess(fields.stdout ?? "");
1159
+ }
1121
1160
  function notifySpawn(pid, opts) {
1122
1161
  if (!pid)
1123
1162
  return;
@@ -1133,10 +1172,48 @@ function shellQuote(value) {
1133
1172
  return `'${value.replace(/'/g, `'\\''`)}'`;
1134
1173
  }
1135
1174
  function codewithProfileCandidateFromLine(line) {
1136
- const cols = line.trim().split(/\s+/);
1137
- if (cols[0] === "*")
1138
- return cols[1];
1139
- return cols[0];
1175
+ const trimmed = line.trim();
1176
+ if (!trimmed || trimmed === "No auth profiles saved.")
1177
+ return;
1178
+ const cols = trimmed.split(/\s+/);
1179
+ const candidate = cols[0] === "*" ? cols[1] : cols[0];
1180
+ if (candidate === "NAME" && (cols[1] === "ACCOUNT" || cols[2] === "ACCOUNT"))
1181
+ return;
1182
+ if (candidate === '"name":') {
1183
+ const jsonName = trimmed.match(/"name"\s*:\s*"([^"]+)"/)?.[1];
1184
+ if (jsonName)
1185
+ return jsonName;
1186
+ }
1187
+ return candidate;
1188
+ }
1189
+ function codewithProfileCandidatesFromJson(value) {
1190
+ const candidates = [];
1191
+ const root = value && typeof value === "object" ? value : undefined;
1192
+ const addProfile = (entry) => {
1193
+ if (!entry || typeof entry !== "object")
1194
+ return;
1195
+ const name = entry.name;
1196
+ if (typeof name === "string" && name)
1197
+ candidates.push(name);
1198
+ };
1199
+ const data = root?.data;
1200
+ if (Array.isArray(data))
1201
+ data.forEach(addProfile);
1202
+ const profiles = root?.profiles;
1203
+ if (Array.isArray(profiles))
1204
+ profiles.forEach(addProfile);
1205
+ return candidates;
1206
+ }
1207
+ function codewithProfileCandidatesFromOutput(output) {
1208
+ const trimmed = output.trim();
1209
+ const jsonStart = trimmed.indexOf("{");
1210
+ const jsonEnd = trimmed.lastIndexOf("}");
1211
+ if (jsonStart >= 0 && jsonEnd >= jsonStart) {
1212
+ try {
1213
+ return codewithProfileCandidatesFromJson(JSON.parse(trimmed.slice(jsonStart, jsonEnd + 1)));
1214
+ } catch {}
1215
+ }
1216
+ return output.split(/\r?\n/).map(codewithProfileCandidateFromLine).filter(Boolean);
1140
1217
  }
1141
1218
  function codewithProfileForError(profile) {
1142
1219
  return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
@@ -1229,6 +1306,7 @@ function commandSpec(target, opts) {
1229
1306
  preflightAnyOf: invocation.preflightAnyOf,
1230
1307
  stdin: invocation.stdin,
1231
1308
  allowlist: agentTarget.allowlist,
1309
+ agentProvider: agentTarget.provider,
1232
1310
  worktree: agentTarget.worktree
1233
1311
  };
1234
1312
  }
@@ -1242,6 +1320,7 @@ function composeExecutionEnv(spec, metadata, opts, accountEnv) {
1242
1320
  Object.assign(env, spec.env ?? {});
1243
1321
  Object.assign(env, allowlistEnv(spec.allowlist));
1244
1322
  env.PATH = normalizeExecutionPath(env);
1323
+ env.SHLVL ||= "1";
1245
1324
  Object.assign(env, metadataEnv(metadata));
1246
1325
  return env;
1247
1326
  }
@@ -1313,7 +1392,7 @@ function remoteWorktreePrepareLines(worktree) {
1313
1392
  return [
1314
1393
  "__openloops_prepare_worktree() {",
1315
1394
  ` local repo=${shellQuote(repoRoot)} path=${shellQuote(path)} branch=${shellQuote(branch)}`,
1316
- " local top expected_common actual_common current",
1395
+ " local top expected_common actual_common current status recovered",
1317
1396
  ' if [ -L "$path" ]; then echo "refusing symlinked worktree path $path" >&2; return 1; fi',
1318
1397
  ' if [ -e "$path" ]; then',
1319
1398
  ' top="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || { echo "refusing to reuse non-worktree path: $path" >&2; return 1; }',
@@ -1322,7 +1401,19 @@ function remoteWorktreePrepareLines(worktree) {
1322
1401
  ' actual_common="$(cd "$path" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
1323
1402
  ' if [ -z "$expected_common" ] || [ "$expected_common" != "$actual_common" ]; then echo "existing worktree $path belongs to a different git common dir" >&2; return 1; fi',
1324
1403
  ' current="$(git -C "$path" branch --show-current 2>/dev/null)"',
1325
- ' if [ "$current" != "$branch" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch" >&2; return 1; fi',
1404
+ ' if [ "$current" != "$branch" ]; then',
1405
+ ' status="$(git -C "$path" status --porcelain=v1 --untracked-files=all 2>/dev/null)" || { echo "existing worktree $path is on branch ${current:-unknown}, expected $branch, and cleanliness could not be checked; inspect or remove the stale worktree" >&2; return 1; }',
1406
+ ' if [ -n "$status" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch, and has local changes; commit/stash them or remove the stale worktree before retrying" >&2; return 1; fi',
1407
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
1408
+ ' git -C "$path" checkout "$branch" 1>&2 || { echo "existing worktree $path is clean but could not switch from branch ${current:-unknown} to expected $branch; remove/prune the stale worktree or free the branch before retrying" >&2; return 1; }',
1409
+ ' elif [ -z "$current" ]; then',
1410
+ ' git -C "$path" checkout -b "$branch" 1>&2 || { echo "existing worktree $path is clean but could not recreate expected branch $branch at the current detached HEAD; remove/prune the stale worktree before retrying" >&2; return 1; }',
1411
+ " else",
1412
+ ' echo "existing worktree $path is on branch $current, expected $branch, but expected branch does not exist; remove/prune the stale worktree or recreate the expected branch before retrying" >&2; return 1',
1413
+ " fi",
1414
+ ' recovered="$(git -C "$path" branch --show-current 2>/dev/null)"',
1415
+ ' if [ "$recovered" != "$branch" ]; then echo "existing worktree $path branch recovery ended on ${recovered:-unknown}, expected $branch; remove/prune the stale worktree before retrying" >&2; return 1; fi',
1416
+ " fi",
1326
1417
  " return 0",
1327
1418
  " fi",
1328
1419
  ' git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "worktree repoRoot is not a git repository: $repo" >&2; return 1; }',
@@ -1388,7 +1479,7 @@ function remotePreflightScript(spec, metadata) {
1388
1479
  }
1389
1480
  if (spec.nativeAuthProfile?.provider === "codewith") {
1390
1481
  const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
1391
- lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", `__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { candidate = ($1 == "*" ? $2 : $1); if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", "fi");
1482
+ lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", "__openloops_codewith_profile_list_contains() {", ` printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk '{ line = $0; gsub(/^[[:space:]]+|[[:space:]]+$/, "", line); if (line == "" || line == "No auth profiles saved.") next; if (line ~ /"(data|profiles)"[[:space:]]*:[[:space:]]*\\[/) { in_profiles = 1; next } if (in_profiles && line ~ /^\\]/) { in_profiles = 0; next } json = line; if (in_profiles && json ~ /"name"[[:space:]]*:[[:space:]]*"/) { sub(/^.*"name"[[:space:]]*:[[:space:]]*"/, "", json); sub(/".*$/, "", json); if (json == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1; next } split(line, cols, /[[:space:]]+/); candidate = (cols[1] == "*" ? cols[2] : cols[1]); if (candidate == "NAME" && (cols[2] == "ACCOUNT" || cols[3] == "ACCOUNT")) next; if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'`, "}", `if __OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list --json 2>/dev/null)" && __openloops_codewith_profile_list_contains; then`, " :", "else", ` __OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", " }", " if ! __openloops_codewith_profile_list_contains; then", ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", " fi", "fi");
1392
1483
  }
1393
1484
  return lines.join(`
1394
1485
  `);
@@ -1403,9 +1494,28 @@ function transportEnv(opts) {
1403
1494
  env[key] = value;
1404
1495
  }
1405
1496
  env.PATH = normalizeExecutionPath(env);
1497
+ env.SHLVL ||= "1";
1406
1498
  return env;
1407
1499
  }
1500
+ function remotePreflightFailureMessage(machineId, detail) {
1501
+ const normalized = detail.trim();
1502
+ if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|Offending .*key in .*known_hosts/i.test(normalized)) {
1503
+ return [
1504
+ `remote preflight failed on ${machineId}: SSH host key verification failed.`,
1505
+ `Verify ${machineId}'s host identity and repair SSH known_hosts/trust material outside OpenLoops;`,
1506
+ "OpenLoops will not disable host-key checking or modify known_hosts automatically.",
1507
+ normalized ? `Transport detail: ${normalized}` : ""
1508
+ ].filter(Boolean).join(" ");
1509
+ }
1510
+ return `remote preflight failed on ${machineId}${normalized ? `: ${normalized}` : ""}`;
1511
+ }
1408
1512
  function assertCodewithProfileListed(profile, result) {
1513
+ const profiles = codewithProfileSetFromResult(result);
1514
+ if (!profiles.has(profile)) {
1515
+ throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
1516
+ }
1517
+ }
1518
+ function codewithProfileSetFromResult(result) {
1409
1519
  if (result.error) {
1410
1520
  throw new Error(`codewith auth profile preflight failed: ${result.error}`);
1411
1521
  }
@@ -1413,32 +1523,45 @@ function assertCodewithProfileListed(profile, result) {
1413
1523
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
1414
1524
  throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
1415
1525
  }
1416
- const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
1417
- if (!profiles.has(profile)) {
1418
- throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
1419
- }
1526
+ return new Set(codewithProfileCandidatesFromOutput(result.stdout || ""));
1420
1527
  }
1421
1528
  function preflightNativeAuthProfileSync(spec, env) {
1422
1529
  if (spec.nativeAuthProfile?.provider !== "codewith")
1423
1530
  return;
1424
- const result = spawnSync3(spec.command, ["profile", "list"], {
1425
- encoding: "utf8",
1426
- env,
1427
- stdio: ["ignore", "pipe", "pipe"],
1428
- timeout: 15000
1429
- });
1430
- assertCodewithProfileListed(spec.nativeAuthProfile.profile, {
1431
- status: result.status,
1432
- stdout: result.stdout ?? "",
1433
- stderr: result.stderr ?? "",
1434
- error: result.error?.message
1435
- });
1531
+ const runProfileList = (args) => {
1532
+ const result = spawnSync3(spec.command, args, {
1533
+ encoding: "utf8",
1534
+ env,
1535
+ stdio: ["ignore", "pipe", "pipe"],
1536
+ timeout: 15000
1537
+ });
1538
+ return {
1539
+ status: result.status,
1540
+ stdout: result.stdout ?? "",
1541
+ stderr: result.stderr ?? "",
1542
+ error: result.error?.message
1543
+ };
1544
+ };
1545
+ const jsonResult = runProfileList(["profile", "list", "--json"]);
1546
+ try {
1547
+ if (codewithProfileSetFromResult(jsonResult).has(spec.nativeAuthProfile.profile))
1548
+ return;
1549
+ } catch {}
1550
+ assertCodewithProfileListed(spec.nativeAuthProfile.profile, runProfileList(["profile", "list"]));
1436
1551
  }
1437
1552
  async function preflightNativeAuthProfile(spec, env) {
1438
1553
  if (spec.nativeAuthProfile?.provider !== "codewith")
1439
1554
  return;
1440
- const result = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
1441
- assertCodewithProfileListed(spec.nativeAuthProfile.profile, result);
1555
+ const jsonResult = await spawnCapture(spec.command, ["profile", "list", "--json"], { env, timeoutMs: 15000 });
1556
+ try {
1557
+ if (codewithProfileSetFromResult(jsonResult).has(spec.nativeAuthProfile.profile))
1558
+ return;
1559
+ } catch {}
1560
+ const tableResult = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
1561
+ assertCodewithProfileListed(spec.nativeAuthProfile.profile, tableResult);
1562
+ }
1563
+ function spawnDetail(result) {
1564
+ return (result.stderr || result.stdout || result.error || "").toString().trim();
1442
1565
  }
1443
1566
  function resolvedDirEquals(left, right) {
1444
1567
  try {
@@ -1489,8 +1612,45 @@ async function ensureLocalWorktree(worktree, env) {
1489
1612
  }
1490
1613
  const current = await git(["-C", path, "branch", "--show-current"]);
1491
1614
  const actualBranch = current.stdout.trim();
1492
- if (current.error || (current.status ?? 1) !== 0 || actualBranch !== branch) {
1493
- return { error: `existing worktree ${path} is on branch ${actualBranch || "unknown"}, expected ${branch}` };
1615
+ if (current.error || (current.status ?? 1) !== 0) {
1616
+ return { error: `existing worktree ${path} branch could not be inspected${spawnDetail(current) ? `: ${spawnDetail(current)}` : ""}` };
1617
+ }
1618
+ if (actualBranch !== branch) {
1619
+ const actualLabel = actualBranch || "unknown";
1620
+ const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
1621
+ if (status.error || (status.status ?? 1) !== 0) {
1622
+ const detail = spawnDetail(status);
1623
+ return {
1624
+ error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and cleanliness could not be checked; inspect or remove the stale worktree${detail ? `: ${detail}` : ""}`
1625
+ };
1626
+ }
1627
+ if (status.stdout.trim()) {
1628
+ return {
1629
+ error: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, and has local changes; commit/stash them or remove the stale worktree before retrying`
1630
+ };
1631
+ }
1632
+ const hasBranch2 = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
1633
+ const checkout = hasBranch2.status === 0 ? await git(["-C", path, "checkout", branch]) : actualBranch ? {
1634
+ status: 1,
1635
+ stdout: "",
1636
+ stderr: `existing worktree ${path} is on branch ${actualLabel}, expected ${branch}, but expected branch does not exist; remove/prune the stale worktree or recreate the expected branch before retrying`,
1637
+ signal: null,
1638
+ timedOut: false
1639
+ } : await git(["-C", path, "checkout", "-b", branch]);
1640
+ if (checkout.error || (checkout.status ?? 1) !== 0) {
1641
+ const detail = spawnDetail(checkout);
1642
+ return {
1643
+ error: `existing worktree ${path} is clean but could not recover branch ${branch} from ${actualLabel}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
1644
+ };
1645
+ }
1646
+ const recovered = await git(["-C", path, "branch", "--show-current"]);
1647
+ if (recovered.error || (recovered.status ?? 1) !== 0 || recovered.stdout.trim() !== branch) {
1648
+ const recoveredBranch = recovered.stdout.trim() || "unknown";
1649
+ const detail = spawnDetail(recovered);
1650
+ return {
1651
+ error: `existing worktree ${path} branch recovery ended on ${recoveredBranch}, expected ${branch}; remove/prune the stale worktree before retrying${detail ? `: ${detail}` : ""}`
1652
+ };
1653
+ }
1494
1654
  }
1495
1655
  return { cwd: worktree.cwd };
1496
1656
  }
@@ -1506,7 +1666,7 @@ async function ensureLocalWorktree(worktree, env) {
1506
1666
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
1507
1667
  const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
1508
1668
  if (add.error || (add.status ?? 1) !== 0) {
1509
- const detail = (add.stderr || add.stdout || add.error || "").toString().trim();
1669
+ const detail = spawnDetail(add);
1510
1670
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
1511
1671
  }
1512
1672
  return { cwd: worktree.cwd };
@@ -1552,7 +1712,7 @@ function preflightRemoteSpec(spec, machine, metadata, opts) {
1552
1712
  throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
1553
1713
  if ((result.status ?? 1) !== 0) {
1554
1714
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
1555
- throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
1715
+ throw new Error(remotePreflightFailureMessage(machine.id, detail));
1556
1716
  }
1557
1717
  }
1558
1718
  async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
@@ -1640,6 +1800,9 @@ async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
1640
1800
  if (timedOut || idleTimedOut) {
1641
1801
  return timeoutResult(startedAt, idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`, fields);
1642
1802
  }
1803
+ if (!error && exitCode !== 0 && codewithJsonlReconciledSuccess(spec, fields)) {
1804
+ return successResult(startedAt, fields);
1805
+ }
1643
1806
  if (error || exitCode !== 0) {
1644
1807
  return failureResult(startedAt, error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`, fields);
1645
1808
  }
@@ -1775,6 +1938,9 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1775
1938
  if (timedOut || idleTimedOut) {
1776
1939
  return timeoutResult(startedAt, idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`, fields);
1777
1940
  }
1941
+ if (!error && exitCode !== 0 && codewithJsonlReconciledSuccess(spec, fields)) {
1942
+ return successResult(startedAt, fields);
1943
+ }
1778
1944
  if (error || exitCode !== 0) {
1779
1945
  return failureResult(startedAt, error ?? `process exited with code ${exitCode ?? "unknown"}`, fields);
1780
1946
  }
@@ -0,0 +1,182 @@
1
+ export interface Foundation {
2
+ "status": string;
3
+ "version": string;
4
+ "mode": string;
5
+ "service"?: string;
6
+ "detail"?: string;
7
+ }
8
+ export interface Loop {
9
+ "id": string;
10
+ "name": string;
11
+ "description"?: string | null;
12
+ "status": string;
13
+ "schedule"?: Record<string, unknown>;
14
+ "target"?: Record<string, unknown>;
15
+ "nextRunAt"?: string | null;
16
+ "createdAt"?: string;
17
+ "updatedAt"?: string;
18
+ }
19
+ export interface CreateLoopInput {
20
+ "name": string;
21
+ "description"?: string;
22
+ "schedule": Record<string, unknown>;
23
+ "target": Record<string, unknown>;
24
+ }
25
+ export interface UpdateLoopInput {
26
+ "status"?: "active" | "paused" | "stopped" | "expired";
27
+ "nextRunAt"?: string | null;
28
+ "retryScheduledFor"?: string | null;
29
+ "expiresAt"?: string | null;
30
+ }
31
+ export interface Run {
32
+ "id": string;
33
+ "loopId": string;
34
+ "status": string;
35
+ "attempt"?: number;
36
+ "scheduledFor"?: string;
37
+ "startedAt"?: string | null;
38
+ "finishedAt"?: string | null;
39
+ }
40
+ export interface LoopResponse {
41
+ "ok": boolean;
42
+ "loop": Loop;
43
+ }
44
+ export interface LoopListResponse {
45
+ "ok": boolean;
46
+ "loops": Array<Loop>;
47
+ }
48
+ export interface RunResponse {
49
+ "ok": boolean;
50
+ "run": Run;
51
+ }
52
+ export interface RunListResponse {
53
+ "ok": boolean;
54
+ "runs": Array<Run>;
55
+ }
56
+ export interface DeleteResponse {
57
+ "ok": boolean;
58
+ "deleted": boolean;
59
+ }
60
+ export type RunReceiptMachine = string | Record<string, unknown>;
61
+ export interface RunReceiptSummary {
62
+ "text"?: string;
63
+ "stdout_bytes": number;
64
+ "stderr_bytes": number;
65
+ "stdout_excerpt"?: string;
66
+ "stderr_excerpt"?: string;
67
+ "error"?: string;
68
+ "duration_ms"?: number;
69
+ }
70
+ export interface RunReceipt {
71
+ "loop_id": string;
72
+ "run_id": string;
73
+ "machine": RunReceiptMachine;
74
+ "repo": string;
75
+ "task_ids": Array<string>;
76
+ "knowledge_ids": Array<string>;
77
+ "digest_id": string;
78
+ "started_at": string | null;
79
+ "finished_at": string | null;
80
+ "status": string;
81
+ "exit_code": number | null;
82
+ "summary": RunReceiptSummary;
83
+ "evidence_paths": Array<string>;
84
+ "created_at": string;
85
+ "updated_at": string;
86
+ }
87
+ export interface WriteRunReceiptInput {
88
+ "loop_id"?: string;
89
+ "run_id": string;
90
+ "machine"?: RunReceiptMachine;
91
+ "repo"?: string;
92
+ "task_ids"?: Array<string>;
93
+ "knowledge_ids"?: Array<string>;
94
+ "digest_id"?: string;
95
+ "started_at"?: string | null;
96
+ "finished_at"?: string | null;
97
+ "status"?: string;
98
+ "exit_code"?: number | null;
99
+ "summary"?: string | RunReceiptSummary | null;
100
+ "evidence_paths"?: Array<string>;
101
+ "stdout"?: string;
102
+ "stderr"?: string;
103
+ "error"?: string;
104
+ "duration_ms"?: number;
105
+ }
106
+ export interface RunReceiptResponse {
107
+ "ok": boolean;
108
+ "receipt": RunReceipt;
109
+ }
110
+ export interface RunReceiptListResponse {
111
+ "ok": boolean;
112
+ "receipts": Array<RunReceipt>;
113
+ }
114
+ export interface LoopsClientOptions {
115
+ /** Base URL, e.g. process.env.APP_API_URL. */
116
+ baseUrl: string;
117
+ /** API key, e.g. process.env.APP_API_KEY. Sent as the 'x-api-key' header. */
118
+ apiKey?: string;
119
+ /** Custom fetch (defaults to global fetch). */
120
+ fetch?: typeof fetch;
121
+ /** Extra headers merged into every request. */
122
+ headers?: Record<string, string>;
123
+ }
124
+ export declare class ApiError extends Error {
125
+ readonly status: number;
126
+ readonly body: unknown;
127
+ constructor(status: number, message: string, body: unknown);
128
+ }
129
+ export declare class LoopsClient {
130
+ private readonly baseUrl;
131
+ private readonly apiKey;
132
+ private readonly fetchImpl;
133
+ private readonly baseHeaders;
134
+ constructor(options: LoopsClientOptions);
135
+ private request;
136
+ /** Liveness probe */
137
+ healthCheck(init?: RequestInit): Promise<Foundation>;
138
+ /** Readiness probe (storage reachable + migrated) */
139
+ readyCheck(init?: RequestInit): Promise<Foundation>;
140
+ /** List loops */
141
+ listLoops(query?: {
142
+ "status"?: "active" | "paused" | "stopped" | "expired";
143
+ "limit"?: number;
144
+ "includeArchived"?: boolean;
145
+ "archived"?: boolean;
146
+ }, init?: RequestInit): Promise<LoopListResponse>;
147
+ /** Create a loop */
148
+ createLoop(body: CreateLoopInput, init?: RequestInit): Promise<LoopResponse>;
149
+ /** Get a loop by id */
150
+ getLoop(id: string, init?: RequestInit): Promise<LoopResponse>;
151
+ /** Delete a loop */
152
+ deleteLoop(id: string, init?: RequestInit): Promise<DeleteResponse>;
153
+ /** Update a loop (status / schedule fields) */
154
+ updateLoop(id: string, body: UpdateLoopInput, init?: RequestInit): Promise<LoopResponse>;
155
+ /** Archive a loop */
156
+ archiveLoop(id: string, init?: RequestInit): Promise<LoopResponse>;
157
+ /** Unarchive a loop */
158
+ unarchiveLoop(id: string, init?: RequestInit): Promise<LoopResponse>;
159
+ /** List run receipts */
160
+ listRunReceipts(query?: {
161
+ "loopId"?: string;
162
+ "repo"?: string;
163
+ "taskId"?: string;
164
+ "knowledgeId"?: string;
165
+ "status"?: string;
166
+ "limit"?: number;
167
+ }, init?: RequestInit): Promise<RunReceiptListResponse>;
168
+ /** Write a run receipt */
169
+ writeRunReceipt(body: WriteRunReceiptInput, init?: RequestInit): Promise<RunReceiptResponse>;
170
+ /** Get a run receipt by run id */
171
+ getRunReceipt(runId: string, init?: RequestInit): Promise<RunReceiptResponse>;
172
+ /** List runs */
173
+ listRuns(query?: {
174
+ "loopId"?: string;
175
+ "status"?: string;
176
+ "limit"?: number;
177
+ }, init?: RequestInit): Promise<RunListResponse>;
178
+ /** Get a run by id */
179
+ getRun(id: string, init?: RequestInit): Promise<RunResponse>;
180
+ /** Service version */
181
+ getVersion(init?: RequestInit): Promise<Foundation>;
182
+ }