@hasna/loops 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.9",
6
+ version: "0.4.10",
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",
package/dist/cli/index.js CHANGED
@@ -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,
@@ -4218,7 +4237,7 @@ class Store {
4218
4237
  // package.json
4219
4238
  var package_default = {
4220
4239
  name: "@hasna/loops",
4221
- version: "0.4.9",
4240
+ version: "0.4.10",
4222
4241
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4223
4242
  type: "module",
4224
4243
  main: "dist/index.js",
@@ -4938,6 +4957,20 @@ function notifySpawn(pid, opts) {
4938
4957
  function shellQuote(value) {
4939
4958
  return `'${value.replace(/'/g, `'\\''`)}'`;
4940
4959
  }
4960
+ function codewithProfileCandidateFromLine(line) {
4961
+ const cols = line.trim().split(/\s+/);
4962
+ if (cols[0] === "*")
4963
+ return cols[1];
4964
+ return cols[0];
4965
+ }
4966
+ function codewithProfileForError(profile) {
4967
+ return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
4968
+ }
4969
+ function assertCodewithAuthProfileSupported(profile) {
4970
+ if (profile.includes("\x00")) {
4971
+ throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
4972
+ }
4973
+ }
4941
4974
  function metadataEnv(metadata) {
4942
4975
  const env = {};
4943
4976
  if (metadata.loopId)
@@ -5004,6 +5037,9 @@ function commandSpec(target, opts) {
5004
5037
  };
5005
5038
  }
5006
5039
  const agentTarget = target;
5040
+ if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
5041
+ assertCodewithAuthProfileSupported(agentTarget.authProfile);
5042
+ }
5007
5043
  const adapter = providerAdapter(agentTarget.provider);
5008
5044
  const invocation = adapter.buildInvocation(agentTarget);
5009
5045
  return {
@@ -5176,7 +5212,8 @@ function remotePreflightScript(spec, metadata) {
5176
5212
  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");
5177
5213
  }
5178
5214
  if (spec.nativeAuthProfile?.provider === "codewith") {
5179
- 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");
5215
+ const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
5216
+ 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");
5180
5217
  }
5181
5218
  return lines.join(`
5182
5219
  `);
@@ -5201,9 +5238,9 @@ function assertCodewithProfileListed(profile, result) {
5201
5238
  const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
5202
5239
  throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
5203
5240
  }
5204
- const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
5241
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
5205
5242
  if (!profiles.has(profile)) {
5206
- throw new Error(`codewith auth profile not found: ${profile}`);
5243
+ throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
5207
5244
  }
5208
5245
  }
5209
5246
  function preflightNativeAuthProfileSync(spec, env) {
@@ -11376,11 +11413,18 @@ var PR_REVIEW_REQUIRED_FIELDS = [
11376
11413
  "branch_protection_review_required",
11377
11414
  "branchProtectionReviewRequired"
11378
11415
  ];
11416
+ var GITHUB_USER_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
11379
11417
  function githubLogin(value) {
11380
11418
  if (!value?.trim())
11381
11419
  return;
11382
- const login = value.trim().replace(/^@/, "");
11383
- return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) ? login : undefined;
11420
+ let login = value.trim().replace(/^@/, "");
11421
+ const appActor = /^app\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/.exec(login);
11422
+ if (appActor)
11423
+ login = `${appActor[1]}[bot]`;
11424
+ const bot = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\[bot\]$/.exec(login);
11425
+ if (bot)
11426
+ return `${bot[1]}[bot]`;
11427
+ return GITHUB_USER_LOGIN.test(login) ? login : undefined;
11384
11428
  }
11385
11429
  function githubLogins(values) {
11386
11430
  const seen = new Set;
@@ -11439,6 +11483,27 @@ function routeTextFieldValues(record, field) {
11439
11483
  }
11440
11484
  return values;
11441
11485
  }
11486
+ var PR_STATE_FIELDS = [
11487
+ "pr_state",
11488
+ "prState",
11489
+ "pull_request_state",
11490
+ "pullRequestState",
11491
+ "state",
11492
+ "pr_status",
11493
+ "prStatus"
11494
+ ];
11495
+ var CLOSED_PR_STATES = new Set(["MERGED", "CLOSED"]);
11496
+ function normalizePrState(value) {
11497
+ const trimmed = value?.trim().toUpperCase();
11498
+ return trimmed ? trimmed : undefined;
11499
+ }
11500
+ function prStateFromEvidence(records, text) {
11501
+ const field = normalizePrState(firstRouteField(records, PR_STATE_FIELDS));
11502
+ if (field)
11503
+ return field;
11504
+ const match = /\b(?:pr[_\s-]?state|pull[_\s-]?request[_\s-]?state|state)\s*[:=]\s*(MERGED|CLOSED|OPEN)\b/i.exec(text);
11505
+ return match ? match[1].toUpperCase() : undefined;
11506
+ }
11442
11507
  function prReferenceFrom(text) {
11443
11508
  const url = /github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)/i.exec(text);
11444
11509
  if (url)
@@ -11454,6 +11519,17 @@ function ghAuthorResolver(ref) {
11454
11519
  return;
11455
11520
  return githubLogin((result.stdout ?? "").trim());
11456
11521
  }
11522
+ function ghStateResolver(ref) {
11523
+ const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "state,mergeStateStatus"], { encoding: "utf8", timeout: 20000 });
11524
+ if (result.error || result.status !== 0)
11525
+ return;
11526
+ try {
11527
+ const parsed = JSON.parse(result.stdout ?? "{}");
11528
+ return { state: parsed.state, mergeStateStatus: parsed.mergeStateStatus };
11529
+ } catch {
11530
+ return;
11531
+ }
11532
+ }
11457
11533
  function authorFromPrText(text) {
11458
11534
  const patterns = [
11459
11535
  /\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
@@ -11480,7 +11556,7 @@ function reviewersFromPrText(text) {
11480
11556
  }
11481
11557
  return githubLogins(reviewers);
11482
11558
  }
11483
- function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorResolver) {
11559
+ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorResolver, resolveState = ghStateResolver) {
11484
11560
  const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
11485
11561
  const text = routeEvidenceText(records);
11486
11562
  const signals = [];
@@ -11500,6 +11576,21 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
11500
11576
  const required = hasPrReference && (reviewRequiredByField || reviewRequiredByText || mergeBlockedByText || approvalIntent);
11501
11577
  if (!required)
11502
11578
  return { required: false, allowed: true, reviewers: [], signals };
11579
+ const prRef = prReferenceFrom(text);
11580
+ let prState = prStateFromEvidence(records, text);
11581
+ if (!prState && prRef) {
11582
+ const live = resolveState(prRef);
11583
+ prState = normalizePrState(live?.state);
11584
+ }
11585
+ if (prState && CLOSED_PR_STATES.has(prState)) {
11586
+ return {
11587
+ required: true,
11588
+ allowed: false,
11589
+ reason: `PR is already ${prState.toLowerCase()}; skipping merge/review route (freshness gate)`,
11590
+ reviewers: [],
11591
+ signals: [...signals, "pr-not-open"]
11592
+ };
11593
+ }
11503
11594
  const reviewers = githubLogins([
11504
11595
  opts.githubReviewer,
11505
11596
  ...splitList(opts.githubReviewerPool) ?? [],
@@ -11508,14 +11599,11 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
11508
11599
  ...reviewersFromPrText(text)
11509
11600
  ]);
11510
11601
  let author = githubLogin(firstRouteField(records, PR_AUTHOR_FIELDS)) ?? authorFromPrText(text);
11511
- if (!author) {
11512
- const ref = prReferenceFrom(text);
11513
- if (ref) {
11514
- const derived = githubLogin(resolveAuthor(ref));
11515
- if (derived) {
11516
- author = derived;
11517
- signals.push("author-derived-gh");
11518
- }
11602
+ if (!author && prRef) {
11603
+ const derived = githubLogin(resolveAuthor(prRef));
11604
+ if (derived) {
11605
+ author = derived;
11606
+ signals.push("author-derived-gh");
11519
11607
  }
11520
11608
  }
11521
11609
  const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
@@ -11711,6 +11799,34 @@ var UNCLEARED_ROUTE_WORK_ITEM_STATUSES = new Set([
11711
11799
  function isUnclearedRouteWorkItem(item) {
11712
11800
  return UNCLEARED_ROUTE_WORK_ITEM_STATUSES.has(item.status);
11713
11801
  }
11802
+ var REACTIVATABLE_TERMINAL_STATUSES = new Set([
11803
+ "succeeded",
11804
+ "failed",
11805
+ "dead_letter",
11806
+ "cancelled"
11807
+ ]);
11808
+ var MAX_TODOS_TASK_ROUTE_REDISPATCHES = 8;
11809
+ function todosTaskRouteRedispatchBackoffMs(attempts) {
11810
+ const base = 2 * 60000;
11811
+ const cap = 30 * 60000;
11812
+ const exp = Math.max(0, Math.min(attempts - 1, 10));
11813
+ return Math.min(cap, base * 2 ** exp);
11814
+ }
11815
+ function reactivateStaleTodosTaskWorkItem(store, routeKey, item, now = Date.now()) {
11816
+ if (routeKey !== "todos-task")
11817
+ return;
11818
+ if (!REACTIVATABLE_TERMINAL_STATUSES.has(item.status))
11819
+ return;
11820
+ if (item.attempts >= MAX_TODOS_TASK_ROUTE_REDISPATCHES)
11821
+ return;
11822
+ const finishedAt = Date.parse(item.updatedAt);
11823
+ if (Number.isFinite(finishedAt) && now - finishedAt < todosTaskRouteRedispatchBackoffMs(item.attempts)) {
11824
+ return;
11825
+ }
11826
+ return store.requeueWorkflowWorkItem(item.id, {
11827
+ reason: `re-admitted from ${item.status}: todos task still actionable after prior run (attempt ${item.attempts + 1}/${MAX_TODOS_TASK_ROUTE_REDISPATCHES})`
11828
+ });
11829
+ }
11714
11830
  function todosTaskRouteTemplateId(opts) {
11715
11831
  const id = (opts.template ?? TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).trim();
11716
11832
  if (!TODOS_TASK_ROUTE_TEMPLATE_IDS.has(id)) {
@@ -11803,9 +11919,11 @@ function routeEvent(plan) {
11803
11919
  const invocation = store.createWorkflowInvocation(plan.invocationInput);
11804
11920
  const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
11805
11921
  if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
11806
- const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11807
- const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11808
- return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
11922
+ if (!reactivateStaleTodosTaskWorkItem(store, plan.routeKey, existingItem)) {
11923
+ const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11924
+ const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11925
+ return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
11926
+ }
11809
11927
  }
11810
11928
  const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, { projectPath: plan.routeProjectPath, projectGroup: plan.projectGroup, limits: plan.throttleLimits }) : undefined;
11811
11929
  const workItem = store.upsertWorkflowWorkItem({
@@ -11937,10 +12055,12 @@ function routeTodosTaskEvent(event, opts) {
11937
12055
  try {
11938
12056
  const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
11939
12057
  if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
11940
- const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11941
- const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11942
- const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
11943
- return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
12058
+ if (!reactivateStaleTodosTaskWorkItem(store, "todos-task", existingItem)) {
12059
+ const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
12060
+ const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
12061
+ const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
12062
+ return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
12063
+ }
11944
12064
  }
11945
12065
  } finally {
11946
12066
  store.close();
@@ -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) {
@@ -7715,7 +7752,7 @@ function enableStartup(result) {
7715
7752
  // package.json
7716
7753
  var package_default = {
7717
7754
  name: "@hasna/loops",
7718
- version: "0.4.9",
7755
+ version: "0.4.10",
7719
7756
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7720
7757
  type: "module",
7721
7758
  main: "dist/index.js",
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.10",
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) {
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.10",
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",