@bojackduy/opencode-loopd 1.4.0 → 1.5.0

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 (2) hide show
  1. package/dist/server.js +120 -50
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -332,6 +332,76 @@ function delay(ms) {
332
332
  var CURRENT_VERSION = 2, LOCK_STALE_MS = 1e4;
333
333
  var init_state_repository = () => {};
334
334
 
335
+ // src/domain/runtime.ts
336
+ var exports_runtime = {};
337
+ __export(exports_runtime, {
338
+ acquireLease: () => acquireLease,
339
+ createRuntimeState: () => createRuntimeState,
340
+ leaseIsValid: () => leaseIsValid,
341
+ markParentNotified: () => markParentNotified,
342
+ markProgress: () => markProgress,
343
+ releaseLease: () => releaseLease,
344
+ shouldNotifyParent: () => shouldNotifyParent
345
+ });
346
+ function createRuntimeState(goalID) {
347
+ const now = new Date().toISOString();
348
+ return {
349
+ goalID,
350
+ phase: "idle",
351
+ consecutiveFailures: 0,
352
+ runCount: 0,
353
+ turnCount: 0,
354
+ noProgressCount: 0,
355
+ progressDuringTurn: false,
356
+ createdAt: now,
357
+ updatedAt: now
358
+ };
359
+ }
360
+ function acquireLease(rt, timeoutMs) {
361
+ const now = Date.now();
362
+ const expires = new Date(now + timeoutMs).toISOString();
363
+ return {
364
+ ...rt,
365
+ phase: "running",
366
+ leaseExpiresAt: expires,
367
+ turnStartedAt: new Date(now).toISOString(),
368
+ progressDuringTurn: false,
369
+ turnTokensUsed: 0,
370
+ updatedAt: new Date(now).toISOString()
371
+ };
372
+ }
373
+ function releaseLease(rt) {
374
+ return {
375
+ ...rt,
376
+ phase: "idle",
377
+ leaseExpiresAt: undefined,
378
+ turnStartedAt: undefined,
379
+ updatedAt: new Date().toISOString()
380
+ };
381
+ }
382
+ function leaseIsValid(rt) {
383
+ if (!rt.leaseExpiresAt)
384
+ return false;
385
+ return Date.now() < Date.parse(rt.leaseExpiresAt);
386
+ }
387
+ function markProgress(rt) {
388
+ return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
389
+ }
390
+ function shouldNotifyParent(runtime, type) {
391
+ if (!runtime.lastParentNotifiedAt || !runtime.lastParentNotifiedFor)
392
+ return true;
393
+ if (runtime.lastParentNotifiedFor !== type)
394
+ return true;
395
+ const elapsed = Date.now() - Date.parse(runtime.lastParentNotifiedAt);
396
+ return !Number.isFinite(elapsed) || elapsed > PARENT_NOTIFY_DEDUPE_MS;
397
+ }
398
+ function markParentNotified(runtime, type) {
399
+ runtime.lastParentNotifiedFor = type;
400
+ runtime.lastParentNotifiedAt = new Date().toISOString();
401
+ runtime.updatedAt = new Date().toISOString();
402
+ }
403
+ var PARENT_NOTIFY_DEDUPE_MS = 60000;
404
+
335
405
  // src/application/control-worker.ts
336
406
  init_state_repository();
337
407
  import { randomUUID as randomUUID2 } from "crypto";
@@ -707,52 +777,6 @@ function createGoal(input) {
707
777
  return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
708
778
  }
709
779
 
710
- // src/domain/runtime.ts
711
- function createRuntimeState(goalID) {
712
- const now = new Date().toISOString();
713
- return {
714
- goalID,
715
- phase: "idle",
716
- consecutiveFailures: 0,
717
- runCount: 0,
718
- turnCount: 0,
719
- noProgressCount: 0,
720
- progressDuringTurn: false,
721
- createdAt: now,
722
- updatedAt: now
723
- };
724
- }
725
- function acquireLease(rt, timeoutMs) {
726
- const now = Date.now();
727
- const expires = new Date(now + timeoutMs).toISOString();
728
- return {
729
- ...rt,
730
- phase: "running",
731
- leaseExpiresAt: expires,
732
- turnStartedAt: new Date(now).toISOString(),
733
- progressDuringTurn: false,
734
- turnTokensUsed: 0,
735
- updatedAt: new Date(now).toISOString()
736
- };
737
- }
738
- function releaseLease(rt) {
739
- return {
740
- ...rt,
741
- phase: "idle",
742
- leaseExpiresAt: undefined,
743
- turnStartedAt: undefined,
744
- updatedAt: new Date().toISOString()
745
- };
746
- }
747
- function leaseIsValid(rt) {
748
- if (!rt.leaseExpiresAt)
749
- return false;
750
- return Date.now() < Date.parse(rt.leaseExpiresAt);
751
- }
752
- function markProgress(rt) {
753
- return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
754
- }
755
-
756
780
  // src/application/loop-engine.ts
757
781
  var HANDLED_EVENT_TYPES = new Set([
758
782
  "session.idle",
@@ -768,6 +792,7 @@ function createLoopEngine(options) {
768
792
  let knownWorkerSessions = new Set;
769
793
  let knownWorkerSessionsLoaded = false;
770
794
  const inflightContinuations = new Set;
795
+ const recentForceFinishBlocked = new Map;
771
796
  async function loadWorkerSessionsIfneeded() {
772
797
  if (knownWorkerSessionsLoaded)
773
798
  return;
@@ -890,6 +915,12 @@ function createLoopEngine(options) {
890
915
  await goalService.continueTurn(directory, goal.id, { forceFinish: true });
891
916
  return true;
892
917
  }
918
+ const blockedKey = goal.id;
919
+ const nowBlocked = Date.now();
920
+ const lastBlocked = recentForceFinishBlocked.get(blockedKey);
921
+ if (lastBlocked !== undefined && nowBlocked - lastBlocked < 60000)
922
+ return true;
923
+ recentForceFinishBlocked.set(blockedKey, nowBlocked);
893
924
  goal.status = "blocked";
894
925
  goal.updatedAt = new Date().toISOString();
895
926
  goal.blocker = {
@@ -898,6 +929,9 @@ function createLoopEngine(options) {
898
929
  at: new Date().toISOString()
899
930
  };
900
931
  runtime.forceFinishRequested = undefined;
932
+ const shouldNotify = shouldNotifyParent(runtime, "stopped");
933
+ if (shouldNotify)
934
+ markParentNotified(runtime, "stopped");
901
935
  await writeState(directory, state);
902
936
  await appendEvent(directory, {
903
937
  version: 1,
@@ -909,7 +943,9 @@ function createLoopEngine(options) {
909
943
  timestamp: new Date().toISOString(),
910
944
  revision: state.revision
911
945
  });
912
- await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${limitResult.reason} (child did not wrap up). Status: blocked. Last progress: ${goal.lastProgress?.summary || "none"}.`);
946
+ if (shouldNotify) {
947
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${limitResult.reason} (child did not wrap up). Status: blocked. Last progress: ${goal.lastProgress?.summary || "none"}.`);
948
+ }
913
949
  return true;
914
950
  }
915
951
  if (limitResult.stop === "budget") {
@@ -990,7 +1026,10 @@ function createLoopEngine(options) {
990
1026
  timestamp: new Date().toISOString(),
991
1027
  revision: state.revision
992
1028
  });
993
- await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures} failures. Last error: ${message}.`);
1029
+ if (shouldNotifyParent(runtime, "failed")) {
1030
+ markParentNotified(runtime, "failed");
1031
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures} failures. Last error: ${message}.`);
1032
+ }
994
1033
  } else {
995
1034
  const backoffMs = Math.min(30000, 1000 * Math.pow(2, runtime.consecutiveFailures));
996
1035
  runtime.retryAfter = new Date(Date.now() + backoffMs).toISOString();
@@ -1139,7 +1178,8 @@ function createWorkerManager(host) {
1139
1178
  const prompt = buildContinuationSteering(goal, runtime, context);
1140
1179
  await host.promptWorker({
1141
1180
  sessionID: worker.workerSessionID,
1142
- prompt
1181
+ prompt,
1182
+ agent: goal.config.agent
1143
1183
  });
1144
1184
  },
1145
1185
  async isIdle(workerSessionID) {
@@ -1481,6 +1521,8 @@ function createGoalService(host) {
1481
1521
  runtime.consecutiveFailures = 0;
1482
1522
  runtime.lastError = undefined;
1483
1523
  runtime.forceFinishRequested = undefined;
1524
+ runtime.lastParentNotifiedAt = undefined;
1525
+ runtime.lastParentNotifiedFor = undefined;
1484
1526
  runtime.phase = "idle";
1485
1527
  runtime.updatedAt = new Date().toISOString();
1486
1528
  }
@@ -1583,6 +1625,21 @@ function createGoalService(host) {
1583
1625
  }
1584
1626
 
1585
1627
  // src/server/host-adapter.ts
1628
+ var recentParentNotifies = new Map;
1629
+ function shouldDedupParentNotify(ownerSessionID, message) {
1630
+ const key = `${ownerSessionID}:${message.slice(0, 200)}`;
1631
+ const now = Date.now();
1632
+ const last = recentParentNotifies.get(key);
1633
+ if (last !== undefined && now - last < 60000)
1634
+ return true;
1635
+ recentParentNotifies.set(key, now);
1636
+ if (recentParentNotifies.size > 200) {
1637
+ for (const [k, t] of recentParentNotifies.entries())
1638
+ if (now - t > 60000)
1639
+ recentParentNotifies.delete(k);
1640
+ }
1641
+ return false;
1642
+ }
1586
1643
  function createRealHost(client, directory) {
1587
1644
  return {
1588
1645
  async createWorker({ parentID, title, agent }) {
@@ -1674,6 +1731,10 @@ function createRealHost(client, directory) {
1674
1731
  } catch {}
1675
1732
  },
1676
1733
  async notifyOwner(ownerSessionID, message) {
1734
+ if (shouldDedupParentNotify(ownerSessionID, message)) {
1735
+ await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
1736
+ return;
1737
+ }
1677
1738
  try {
1678
1739
  const result = await withTimeout(client.session.promptAsync({
1679
1740
  path: { id: ownerSessionID },
@@ -2417,11 +2478,20 @@ var server = async ({ client, directory }) => {
2417
2478
  const goalID = parsed.goalID;
2418
2479
  if (!goalID)
2419
2480
  return;
2420
- const { readState: readState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
2481
+ const { readState: readState2, writeState: writeState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
2482
+ const { shouldNotifyParent: shouldNotifyParent2, markParentNotified: markParentNotified2 } = await Promise.resolve().then(() => exports_runtime);
2421
2483
  const state = await readState2(directory);
2422
2484
  const goal = state.goals.find((g) => g.id === goalID);
2423
2485
  if (!goal)
2424
2486
  return;
2487
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
2488
+ const notifyType = parsed.status === "complete" ? "complete" : "blocked";
2489
+ if (runtime && !shouldNotifyParent2(runtime, notifyType))
2490
+ return;
2491
+ if (runtime) {
2492
+ markParentNotified2(runtime, notifyType);
2493
+ await writeState2(directory, state);
2494
+ }
2425
2495
  const message = parsed.status === "complete" ? `Loop goal "${goal.name}" completed: ${parsed.summary || ""}. Evidence: ${parsed.evidence || ""}. Artifacts: ${goal.config.artifactDir || "n/a"}.` : `Loop goal "${goal.name}" blocked: ${parsed.reason || ""}. Needed: ${parsed.needed || ""}.`;
2426
2496
  await host.notifyOwner(goal.ownerSessionID, message);
2427
2497
  } catch {}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-loopd",
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "description": "Codex-inspired background goal engine for OpenCode — autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-only",