@hasna/loops 0.4.9 → 0.4.11

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.
@@ -1,9 +1,15 @@
1
+ import { type SchedulerLane } from "../lib/scheduler.js";
1
2
  import { Store } from "../lib/store.js";
2
3
  import type { ExecutorResult, Loop, LoopRun } from "../types.js";
3
4
  export interface RunDaemonOptions {
4
5
  intervalMs?: number;
5
6
  leaseTtlMs?: number;
7
+ /** Legacy single-pool knob; back-compat alias for the agent/workflow lane budget. */
6
8
  concurrency?: number;
9
+ /** Claim budget for command-target loops (fast loops). Default 4. */
10
+ commandConcurrency?: number;
11
+ /** Claim budget for agent/workflow-target loops (long workers). Default 8; `concurrency`/LOOPS_DAEMON_CONCURRENCY still set it. */
12
+ agentConcurrency?: number;
7
13
  store?: Store;
8
14
  pidPath?: string;
9
15
  execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
@@ -13,6 +19,18 @@ export interface RunDaemonOptions {
13
19
  signal?: AbortSignal;
14
20
  reapGraceMs?: number;
15
21
  }
22
+ export declare const DEFAULT_COMMAND_CONCURRENCY = 4;
23
+ export declare const DEFAULT_AGENT_CONCURRENCY = 8;
24
+ /**
25
+ * Resolve the two separated concurrency lanes. Command loops (fast) and
26
+ * agent/workflow loops (long workers) get independent budgets so a saturated
27
+ * agent lane cannot starve command loops. Precedence per lane is explicit opt >
28
+ * lane env > legacy knob > default. The legacy `concurrency` opt and
29
+ * `LOOPS_DAEMON_CONCURRENCY` env stay wired to the agent lane (the dominant
30
+ * consumer / historical "total" knob), so existing deployments keep their
31
+ * heavy-lane capacity and simply gain a reserved command lane on top.
32
+ */
33
+ export declare function resolveLaneConcurrency(opts?: Pick<RunDaemonOptions, "concurrency" | "commandConcurrency" | "agentConcurrency">): Record<SchedulerLane, number>;
16
34
  export declare const DAEMON_LOG_MAX_BYTES: number;
17
35
  export declare const DAEMON_LOG_KEEP = 2;
18
36
  export declare function daemonLogLine(message: string): string;
@@ -56,8 +56,12 @@ function nowIso() {
56
56
  import { mkdirSync } from "fs";
57
57
  import { homedir } from "os";
58
58
  import { join } from "path";
59
+ function homeDir() {
60
+ const home = process.env.HOME?.trim();
61
+ return home ? home : homedir();
62
+ }
59
63
  function dataDir() {
60
- return process.env.LOOPS_DATA_DIR || join(homedir(), ".hasna", "loops");
64
+ return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
61
65
  }
62
66
  function ensureDataDir() {
63
67
  const dir = dataDir();
@@ -74,10 +78,10 @@ function daemonLogPath() {
74
78
  return join(dataDir(), "daemon.log");
75
79
  }
76
80
  function systemdServicePath() {
77
- return join(homedir(), ".config", "systemd", "user", "loops-daemon.service");
81
+ return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
78
82
  }
79
83
  function launchdPlistPath() {
80
- return join(homedir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
84
+ return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
81
85
  }
82
86
 
83
87
  // src/lib/process-identity.ts
@@ -1474,6 +1478,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1474
1478
  function scrubbedOrNull(value) {
1475
1479
  return value == null ? null : scrubSecrets(value);
1476
1480
  }
1481
+ var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
1482
+ function clampPersistedRunOutput(value) {
1483
+ if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
1484
+ return value;
1485
+ const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
1486
+ const head = value.slice(0, half);
1487
+ const tail = value.slice(value.length - half);
1488
+ const omitted = value.length - head.length - tail.length;
1489
+ return `${head}
1490
+ \u2026[${omitted} chars truncated by loops run-output retention]\u2026
1491
+ ${tail}`;
1492
+ }
1493
+ function persistedRunOutput(value) {
1494
+ return clampPersistedRunOutput(scrubbedOrNull(value));
1495
+ }
1477
1496
  function chmodIfExists(path, mode) {
1478
1497
  try {
1479
1498
  if (existsSync(path))
@@ -3220,8 +3239,8 @@ class Store {
3220
3239
  ))`).run({
3221
3240
  $workflowRunId: workflowRunId,
3222
3241
  $stepId: stepId,
3223
- $stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
3224
- $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
3242
+ $stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
3243
+ $stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
3225
3244
  $updated: now,
3226
3245
  $daemonLeaseId: opts.daemonLeaseId ?? null,
3227
3246
  $now: now
@@ -3282,8 +3301,8 @@ class Store {
3282
3301
  $finished: finishedAt,
3283
3302
  $exitCode: patch.exitCode ?? null,
3284
3303
  $durationMs: patch.durationMs ?? null,
3285
- $stdout: scrubbedOrNull(patch.stdout),
3286
- $stderr: scrubbedOrNull(patch.stderr),
3304
+ $stdout: persistedRunOutput(patch.stdout),
3305
+ $stderr: persistedRunOutput(patch.stderr),
3287
3306
  $error: error ?? null,
3288
3307
  $updated: finishedAt,
3289
3308
  $daemonLeaseId: opts.daemonLeaseId ?? null,
@@ -3661,8 +3680,8 @@ class Store {
3661
3680
  $pid: patch.pid ?? null,
3662
3681
  $exitCode: patch.exitCode ?? null,
3663
3682
  $durationMs: patch.durationMs ?? null,
3664
- $stdout: scrubbedOrNull(patch.stdout),
3665
- $stderr: scrubbedOrNull(patch.stderr),
3683
+ $stdout: persistedRunOutput(patch.stdout),
3684
+ $stderr: persistedRunOutput(patch.stderr),
3666
3685
  $error: error ?? null,
3667
3686
  $updated: finishedAt,
3668
3687
  $claimedBy: opts.claimedBy ?? null,
@@ -4059,8 +4078,8 @@ class Store {
4059
4078
  $processStartedAt: run.processStartedAt ?? null,
4060
4079
  $exitCode: run.exitCode ?? null,
4061
4080
  $durationMs: run.durationMs ?? null,
4062
- $stdout: scrubbedOrNull(run.stdout),
4063
- $stderr: scrubbedOrNull(run.stderr),
4081
+ $stdout: persistedRunOutput(run.stdout),
4082
+ $stderr: persistedRunOutput(run.stderr),
4064
4083
  $error: scrubbedOrNull(run.error),
4065
4084
  $goalRunId: run.goalRunId ?? null,
4066
4085
  $created: run.createdAt,
@@ -5099,6 +5118,20 @@ function notifySpawn(pid, opts) {
5099
5118
  function shellQuote(value) {
5100
5119
  return `'${value.replace(/'/g, `'\\''`)}'`;
5101
5120
  }
5121
+ function codewithProfileCandidateFromLine(line) {
5122
+ const cols = line.trim().split(/\s+/);
5123
+ if (cols[0] === "*")
5124
+ return cols[1];
5125
+ return cols[0];
5126
+ }
5127
+ function codewithProfileForError(profile) {
5128
+ return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
5129
+ }
5130
+ function assertCodewithAuthProfileSupported(profile) {
5131
+ if (profile.includes("\x00")) {
5132
+ throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
5133
+ }
5134
+ }
5102
5135
  function metadataEnv(metadata) {
5103
5136
  const env = {};
5104
5137
  if (metadata.loopId)
@@ -5165,6 +5198,9 @@ function commandSpec(target, opts) {
5165
5198
  };
5166
5199
  }
5167
5200
  const agentTarget = target;
5201
+ if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
5202
+ assertCodewithAuthProfileSupported(agentTarget.authProfile);
5203
+ }
5168
5204
  const adapter = providerAdapter(agentTarget.provider);
5169
5205
  const invocation = adapter.buildInvocation(agentTarget);
5170
5206
  return {
@@ -5337,7 +5373,8 @@ function remotePreflightScript(spec, metadata) {
5337
5373
  lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
5338
5374
  }
5339
5375
  if (spec.nativeAuthProfile?.provider === "codewith") {
5340
- lines.push(`__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 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
5376
+ const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
5377
+ 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");
5341
5378
  }
5342
5379
  return lines.join(`
5343
5380
  `);
@@ -5362,9 +5399,9 @@ function assertCodewithProfileListed(profile, result) {
5362
5399
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
5363
5400
  throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
5364
5401
  }
5365
- const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
5402
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
5366
5403
  if (!profiles.has(profile)) {
5367
- throw new Error(`codewith auth profile not found: ${profile}`);
5404
+ throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
5368
5405
  }
5369
5406
  }
5370
5407
  function preflightNativeAuthProfileSync(spec, env) {
@@ -6668,6 +6705,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
6668
6705
  }
6669
6706
 
6670
6707
  // src/lib/scheduler.ts
6708
+ function loopLane(loop) {
6709
+ return loop.target.type === "command" ? "command" : "agent";
6710
+ }
6671
6711
  function manualRunScheduledFor(loop, now = new Date) {
6672
6712
  if (loop.archivedAt)
6673
6713
  return now.toISOString();
@@ -7060,14 +7100,23 @@ function claimDueRuns(deps) {
7060
7100
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7061
7101
  if (maxClaims === 0)
7062
7102
  return { claims, claimed, completed: [], skipped, recovered, expired };
7103
+ const laneLimits = deps.laneLimits;
7104
+ const laneClaims = { command: 0, agent: 0 };
7105
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7106
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7063
7107
  for (const loop of deps.store.dueLoops(now)) {
7064
7108
  if (claims.length >= maxClaims)
7065
7109
  break;
7110
+ const lane = loopLane(loop);
7111
+ if (laneFull(lane))
7112
+ continue;
7066
7113
  const plan = dueSlots(loop, now);
7067
7114
  let loopSkips = 0;
7068
7115
  for (const slot of plan.slots) {
7069
7116
  if (claims.length >= maxClaims)
7070
7117
  break;
7118
+ if (laneFull(lane))
7119
+ break;
7071
7120
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7072
7121
  break;
7073
7122
  const run = claimSlot(deps, loop, slot);
@@ -7076,6 +7125,7 @@ function claimDueRuns(deps) {
7076
7125
  if ("loop" in run) {
7077
7126
  claims.push(run);
7078
7127
  claimed.push(run.run);
7128
+ laneClaims[lane] += 1;
7079
7129
  } else if (run.status === "skipped") {
7080
7130
  skipped.push(run);
7081
7131
  loopSkips += 1;
@@ -7344,13 +7394,23 @@ function intervalFromEnv() {
7344
7394
  const value = Number(raw);
7345
7395
  return Number.isFinite(value) && value > 0 ? value : undefined;
7346
7396
  }
7347
- function concurrencyFromEnv() {
7348
- const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
7397
+ function positiveIntEnv(name) {
7398
+ const raw = process.env[name];
7349
7399
  if (!raw)
7350
7400
  return;
7351
7401
  const value = Number(raw);
7352
7402
  return Number.isInteger(value) && value > 0 ? value : undefined;
7353
7403
  }
7404
+ function concurrencyFromEnv() {
7405
+ return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
7406
+ }
7407
+ var DEFAULT_COMMAND_CONCURRENCY = 4;
7408
+ var DEFAULT_AGENT_CONCURRENCY = 8;
7409
+ function resolveLaneConcurrency(opts = {}) {
7410
+ const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
7411
+ const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
7412
+ return { command, agent };
7413
+ }
7354
7414
  var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
7355
7415
  var DAEMON_LOG_KEEP = 2;
7356
7416
  function daemonLogLine(message) {
@@ -7396,7 +7456,7 @@ async function runDaemon(opts = {}) {
7396
7456
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
7397
7457
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
7398
7458
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
7399
- const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
7459
+ const laneConcurrency = resolveLaneConcurrency(opts);
7400
7460
  const log = opts.log ?? defaultDaemonLog;
7401
7461
  const sleep = opts.sleep ?? realSleep;
7402
7462
  const lease = store.acquireDaemonLease({
@@ -7408,12 +7468,13 @@ async function runDaemon(opts = {}) {
7408
7468
  if (!lease)
7409
7469
  throw new Error("another loops daemon holds the database lease");
7410
7470
  writePid(process.pid, pidPath);
7411
- log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
7471
+ log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
7412
7472
  let stopFlag = false;
7413
7473
  let leaseLost = false;
7414
7474
  let leaseEpoch = 0;
7415
7475
  let runAbort = new AbortController;
7416
7476
  const activeRuns = new Map;
7477
+ const activeByLane = { command: 0, agent: 0 };
7417
7478
  const requestStop = (message) => {
7418
7479
  stopFlag = true;
7419
7480
  if (!runAbort.signal.aborted)
@@ -7506,7 +7567,12 @@ async function runDaemon(opts = {}) {
7506
7567
  log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
7507
7568
  };
7508
7569
  const startClaim = (claim) => {
7509
- const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => activeRuns.delete(claim.run.id));
7570
+ const lane = loopLane(claim.loop);
7571
+ activeByLane[lane] += 1;
7572
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
7573
+ activeRuns.delete(claim.run.id);
7574
+ activeByLane[lane] -= 1;
7575
+ });
7510
7576
  activeRuns.set(claim.run.id, task);
7511
7577
  };
7512
7578
  try {
@@ -7542,19 +7608,23 @@ async function runDaemon(opts = {}) {
7542
7608
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
7543
7609
  tickFn: async () => {
7544
7610
  ensureLease();
7545
- const available = Math.max(0, concurrency - activeRuns.size);
7611
+ const laneLimits = {
7612
+ command: Math.max(0, laneConcurrency.command - activeByLane.command),
7613
+ agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
7614
+ };
7546
7615
  const result = claimDueRuns({
7547
7616
  store,
7548
7617
  runnerId,
7549
7618
  daemonLeaseId: leaseId,
7550
7619
  beforeRun: () => ensureLease(),
7551
- maxClaims: available
7620
+ maxClaims: laneLimits.command + laneLimits.agent,
7621
+ laneLimits
7552
7622
  });
7553
7623
  for (const claim of result.claims)
7554
7624
  startClaim(claim);
7555
7625
  const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
7556
7626
  if (changed > 0) {
7557
- log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7627
+ log(`tick claimed=${result.claims.length} active=${activeRuns.size} (command=${activeByLane.command} agent=${activeByLane.agent}) skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7558
7628
  }
7559
7629
  await reapAbandoned(result.recovered);
7560
7630
  }
@@ -7715,7 +7785,7 @@ function enableStartup(result) {
7715
7785
  // package.json
7716
7786
  var package_default = {
7717
7787
  name: "@hasna/loops",
7718
- version: "0.4.9",
7788
+ version: "0.4.11",
7719
7789
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7720
7790
  type: "module",
7721
7791
  main: "dist/index.js",
@@ -7846,7 +7916,12 @@ function packageVersion() {
7846
7916
  // src/daemon/index.ts
7847
7917
  var program = new Command;
7848
7918
  program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
7849
- program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "maximum loop runs to execute concurrently", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs, concurrency: opts.concurrency }));
7919
+ program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "legacy total knob; sets the agent/workflow lane budget", (value) => Number(value)).option("--command-concurrency <n>", "claim budget for command-target loops (default 4)", (value) => Number(value)).option("--agent-concurrency <n>", "claim budget for agent/workflow-target loops (default 8)", (value) => Number(value)).action(async (opts) => runDaemon({
7920
+ intervalMs: opts.intervalMs,
7921
+ concurrency: opts.concurrency,
7922
+ commandConcurrency: opts.commandConcurrency,
7923
+ agentConcurrency: opts.agentConcurrency
7924
+ }));
7850
7925
  program.command("start").action(async () => {
7851
7926
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
7852
7927
  console.log(JSON.stringify(result, null, 2));
package/dist/index.js CHANGED
@@ -54,8 +54,12 @@ function nowIso() {
54
54
  import { mkdirSync } from "fs";
55
55
  import { homedir } from "os";
56
56
  import { join } from "path";
57
+ function homeDir() {
58
+ const home = process.env.HOME?.trim();
59
+ return home ? home : homedir();
60
+ }
57
61
  function dataDir() {
58
- return process.env.LOOPS_DATA_DIR || join(homedir(), ".hasna", "loops");
62
+ return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
59
63
  }
60
64
  function ensureDataDir() {
61
65
  const dir = dataDir();
@@ -72,10 +76,10 @@ function daemonLogPath() {
72
76
  return join(dataDir(), "daemon.log");
73
77
  }
74
78
  function systemdServicePath() {
75
- return join(homedir(), ".config", "systemd", "user", "loops-daemon.service");
79
+ return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
76
80
  }
77
81
  function launchdPlistPath() {
78
- return join(homedir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
82
+ return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
79
83
  }
80
84
 
81
85
  // src/lib/process-identity.ts
@@ -1472,6 +1476,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1472
1476
  function scrubbedOrNull(value) {
1473
1477
  return value == null ? null : scrubSecrets(value);
1474
1478
  }
1479
+ var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
1480
+ function clampPersistedRunOutput(value) {
1481
+ if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
1482
+ return value;
1483
+ const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
1484
+ const head = value.slice(0, half);
1485
+ const tail = value.slice(value.length - half);
1486
+ const omitted = value.length - head.length - tail.length;
1487
+ return `${head}
1488
+ \u2026[${omitted} chars truncated by loops run-output retention]\u2026
1489
+ ${tail}`;
1490
+ }
1491
+ function persistedRunOutput(value) {
1492
+ return clampPersistedRunOutput(scrubbedOrNull(value));
1493
+ }
1475
1494
  function chmodIfExists(path, mode) {
1476
1495
  try {
1477
1496
  if (existsSync(path))
@@ -3218,8 +3237,8 @@ class Store {
3218
3237
  ))`).run({
3219
3238
  $workflowRunId: workflowRunId,
3220
3239
  $stepId: stepId,
3221
- $stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
3222
- $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
3240
+ $stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
3241
+ $stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
3223
3242
  $updated: now,
3224
3243
  $daemonLeaseId: opts.daemonLeaseId ?? null,
3225
3244
  $now: now
@@ -3280,8 +3299,8 @@ class Store {
3280
3299
  $finished: finishedAt,
3281
3300
  $exitCode: patch.exitCode ?? null,
3282
3301
  $durationMs: patch.durationMs ?? null,
3283
- $stdout: scrubbedOrNull(patch.stdout),
3284
- $stderr: scrubbedOrNull(patch.stderr),
3302
+ $stdout: persistedRunOutput(patch.stdout),
3303
+ $stderr: persistedRunOutput(patch.stderr),
3285
3304
  $error: error ?? null,
3286
3305
  $updated: finishedAt,
3287
3306
  $daemonLeaseId: opts.daemonLeaseId ?? null,
@@ -3659,8 +3678,8 @@ class Store {
3659
3678
  $pid: patch.pid ?? null,
3660
3679
  $exitCode: patch.exitCode ?? null,
3661
3680
  $durationMs: patch.durationMs ?? null,
3662
- $stdout: scrubbedOrNull(patch.stdout),
3663
- $stderr: scrubbedOrNull(patch.stderr),
3681
+ $stdout: persistedRunOutput(patch.stdout),
3682
+ $stderr: persistedRunOutput(patch.stderr),
3664
3683
  $error: error ?? null,
3665
3684
  $updated: finishedAt,
3666
3685
  $claimedBy: opts.claimedBy ?? null,
@@ -4057,8 +4076,8 @@ class Store {
4057
4076
  $processStartedAt: run.processStartedAt ?? null,
4058
4077
  $exitCode: run.exitCode ?? null,
4059
4078
  $durationMs: run.durationMs ?? null,
4060
- $stdout: scrubbedOrNull(run.stdout),
4061
- $stderr: scrubbedOrNull(run.stderr),
4079
+ $stdout: persistedRunOutput(run.stdout),
4080
+ $stderr: persistedRunOutput(run.stderr),
4062
4081
  $error: scrubbedOrNull(run.error),
4063
4082
  $goalRunId: run.goalRunId ?? null,
4064
4083
  $created: run.createdAt,
@@ -4216,7 +4235,7 @@ class Store {
4216
4235
  // package.json
4217
4236
  var package_default = {
4218
4237
  name: "@hasna/loops",
4219
- version: "0.4.9",
4238
+ version: "0.4.11",
4220
4239
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4221
4240
  type: "module",
4222
4241
  main: "dist/index.js",
@@ -5045,6 +5064,20 @@ function notifySpawn(pid, opts) {
5045
5064
  function shellQuote(value) {
5046
5065
  return `'${value.replace(/'/g, `'\\''`)}'`;
5047
5066
  }
5067
+ function codewithProfileCandidateFromLine(line) {
5068
+ const cols = line.trim().split(/\s+/);
5069
+ if (cols[0] === "*")
5070
+ return cols[1];
5071
+ return cols[0];
5072
+ }
5073
+ function codewithProfileForError(profile) {
5074
+ return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
5075
+ }
5076
+ function assertCodewithAuthProfileSupported(profile) {
5077
+ if (profile.includes("\x00")) {
5078
+ throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
5079
+ }
5080
+ }
5048
5081
  function metadataEnv(metadata) {
5049
5082
  const env = {};
5050
5083
  if (metadata.loopId)
@@ -5111,6 +5144,9 @@ function commandSpec(target, opts) {
5111
5144
  };
5112
5145
  }
5113
5146
  const agentTarget = target;
5147
+ if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
5148
+ assertCodewithAuthProfileSupported(agentTarget.authProfile);
5149
+ }
5114
5150
  const adapter = providerAdapter(agentTarget.provider);
5115
5151
  const invocation = adapter.buildInvocation(agentTarget);
5116
5152
  return {
@@ -5283,7 +5319,8 @@ function remotePreflightScript(spec, metadata) {
5283
5319
  lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
5284
5320
  }
5285
5321
  if (spec.nativeAuthProfile?.provider === "codewith") {
5286
- lines.push(`__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 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
5322
+ const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
5323
+ 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");
5287
5324
  }
5288
5325
  return lines.join(`
5289
5326
  `);
@@ -5308,9 +5345,9 @@ function assertCodewithProfileListed(profile, result) {
5308
5345
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
5309
5346
  throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
5310
5347
  }
5311
- const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
5348
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
5312
5349
  if (!profiles.has(profile)) {
5313
- throw new Error(`codewith auth profile not found: ${profile}`);
5350
+ throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
5314
5351
  }
5315
5352
  }
5316
5353
  function preflightNativeAuthProfileSync(spec, env) {
@@ -7249,6 +7286,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
7249
7286
  }
7250
7287
 
7251
7288
  // src/lib/scheduler.ts
7289
+ function loopLane(loop) {
7290
+ return loop.target.type === "command" ? "command" : "agent";
7291
+ }
7252
7292
  function manualRunScheduledFor(loop, now = new Date) {
7253
7293
  if (loop.archivedAt)
7254
7294
  return now.toISOString();
@@ -7641,14 +7681,23 @@ function claimDueRuns(deps) {
7641
7681
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7642
7682
  if (maxClaims === 0)
7643
7683
  return { claims, claimed, completed: [], skipped, recovered, expired };
7684
+ const laneLimits = deps.laneLimits;
7685
+ const laneClaims = { command: 0, agent: 0 };
7686
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7687
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7644
7688
  for (const loop of deps.store.dueLoops(now)) {
7645
7689
  if (claims.length >= maxClaims)
7646
7690
  break;
7691
+ const lane = loopLane(loop);
7692
+ if (laneFull(lane))
7693
+ continue;
7647
7694
  const plan = dueSlots(loop, now);
7648
7695
  let loopSkips = 0;
7649
7696
  for (const slot of plan.slots) {
7650
7697
  if (claims.length >= maxClaims)
7651
7698
  break;
7699
+ if (laneFull(lane))
7700
+ break;
7652
7701
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7653
7702
  break;
7654
7703
  const run = claimSlot(deps, loop, slot);
@@ -7657,6 +7706,7 @@ function claimDueRuns(deps) {
7657
7706
  if ("loop" in run) {
7658
7707
  claims.push(run);
7659
7708
  claimed.push(run.run);
7709
+ laneClaims[lane] += 1;
7660
7710
  } else if (run.status === "skipped") {
7661
7711
  skipped.push(run);
7662
7712
  loopSkips += 1;
package/dist/lib/mode.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@hasna/loops",
5
- version: "0.4.9",
5
+ version: "0.4.11",
6
6
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7
7
  type: "module",
8
8
  main: "dist/index.js",
@@ -7,6 +7,10 @@ export interface PrReviewRoutingDecision {
7
7
  reviewers: string[];
8
8
  selectedReviewer?: string;
9
9
  signals: string[];
10
+ /** Set when the route was skipped because the PR is definitively MERGED/CLOSED. */
11
+ freshnessSkip?: boolean;
12
+ /** Resolved PR lifecycle state (e.g. MERGED/CLOSED) when the freshness gate fired. */
13
+ prState?: string;
10
14
  }
11
15
  /** A GitHub PR reference resolvable to `owner/repo#number`. */
12
16
  export interface PrReference {
@@ -16,6 +20,25 @@ export interface PrReference {
16
20
  }
17
21
  /** Resolves a PR author login from a concrete `owner/repo#number` reference. */
18
22
  export type PrAuthorResolver = (ref: PrReference) => string | undefined;
23
+ /** Live PR lifecycle state from a concrete `owner/repo#number` reference. */
24
+ export interface PrLiveState {
25
+ state?: string;
26
+ mergeStateStatus?: string;
27
+ }
28
+ /** Resolves live PR state; returns undefined when it cannot be determined. */
29
+ export type PrStateResolver = (ref: PrReference) => PrLiveState | undefined;
19
30
  /** Extracts a concrete owner/repo/number PR reference from route evidence text. */
20
31
  export declare function prReferenceFrom(text: string): PrReference | undefined;
21
- export declare function prReviewRoutingDecision(data: Record<string, unknown>, metadata: Record<string, unknown>, opts: TodosTaskRouteOptions, resolveAuthor?: PrAuthorResolver): PrReviewRoutingDecision;
32
+ /** Canonical `owner/repo#number`, lowercased for case-stable dedupe. */
33
+ export declare function prFingerprint(ref: PrReference): string;
34
+ /**
35
+ * Stable GitHub `owner/repo#number` fingerprint for a PR-subject task. The repos
36
+ * registry maps several local checkouts to one GitHub repo, so a single PR is
37
+ * minted as 2-3 duplicate todos tasks (distinct ids + distinct checkout paths).
38
+ * Keying dedupe on this fingerprint collapses them to one work item instead of
39
+ * burning a full worker cycle per checkout. Prefers an explicit PR fingerprint
40
+ * field, then a canonical PR URL / `github-pr:` handle in route evidence;
41
+ * returns undefined for tasks with no concrete PR reference (keep path/id keying).
42
+ */
43
+ export declare function prFingerprintFromTask(data: Record<string, unknown>, metadata: Record<string, unknown>): string | undefined;
44
+ export declare function prReviewRoutingDecision(data: Record<string, unknown>, metadata: Record<string, unknown>, opts: TodosTaskRouteOptions, resolveAuthor?: PrAuthorResolver, resolveState?: PrStateResolver): PrReviewRoutingDecision;
@@ -29,6 +29,18 @@ export interface ClaimedLoopRun {
29
29
  export interface ClaimDueRunsResult extends TickResult {
30
30
  claims: ClaimedLoopRun[];
31
31
  }
32
+ /**
33
+ * Scheduler concurrency lanes. Command-target loops are typically fast
34
+ * (monitors, digests, syncs); agent/workflow-target loops are long-running
35
+ * headless workers (minutes to over an hour). They draw from separate claim
36
+ * budgets so a saturated agent lane cannot starve fast command loops (and vice
37
+ * versa) — the single shared pool let long workers monopolize every slot.
38
+ */
39
+ export type SchedulerLane = "command" | "agent";
40
+ /** The concurrency lane a loop's target belongs to. */
41
+ export declare function loopLane(loop: Loop): SchedulerLane;
42
+ /** Remaining claim budget per lane for a single `claimDueRuns` pass. */
43
+ export type LaneLimits = Partial<Record<SchedulerLane, number>>;
32
44
  export declare function manualRunScheduledFor(loop: Loop, now?: Date): string;
33
45
  export declare function shouldAdvanceManualRun(loop: Loop, scheduledFor: string, now?: Date): boolean;
34
46
  export type ManualRunSource = "ad_hoc" | "due_slot" | "retry_slot";
@@ -121,5 +133,6 @@ export declare function executeClaimedRun(deps: {
121
133
  }): Promise<LoopRun>;
122
134
  export declare function claimDueRuns(deps: SchedulerDeps & {
123
135
  maxClaims?: number;
136
+ laneLimits?: LaneLimits;
124
137
  }): ClaimDueRunsResult;
125
138
  export declare function tick(deps: SchedulerDeps): Promise<TickResult>;