@bojackduy/opencode-loopd 1.6.1 → 1.7.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 (2026-08-25) — Scheduled Intervals
4
+
5
+ > Interval requeue for repetitive dialogues — one `/goal` covers `monitor deploy` `periodic tests` `keep going` without manual re-prompt. `5s` `createScheduleWorker` resurrects `complete → active` with `Scheduled tick N/M`.
6
+
7
+ ### Added
8
+
9
+ - **Scheduled intervals** — `GoalConfig.schedule {everyMs, maxRuns}` (`src/domain/goal.ts:100`), `scheduleEveryMs`/`scheduleMaxRuns` args on `loopd_create_goal` (`src/server/goal-tools.ts:56`), min 1000, `maxRuns` total incl initial (`src/server/goal-tools.ts:83` validation).
10
+ - **Schedule worker** — `src/application/schedule-worker.ts:28` `createScheduleWorker` `intervalMs 5000` scans `complete` scheduled goals, `skip-if-running` (lease/`phase` `running/queued/compacting` + `workspaceWrite` single-writer gate `src/application/goal-service.ts:83`), resurrects `complete→active` `phase=idle` `lastScheduleAt`, injects inbox `Scheduled tick N/M — re-execute objective`, then `continueTurn`.
11
+ - **State v6** — `CURRENT_VERSION 6` `src/infrastructure/state-repository.ts:12`, `scheduleRunCount`/`nextRunAt`/`lastScheduleAt` on `GoalRuntimeState` (`src/domain/runtime.ts:115`), `v5→v6` migration with validation, `createRuntimeState` init `scheduleRunCount 0` (`src/application/goal-service.ts:225`), `complete_goal` counts `scheduleRunCount` and sets `nextRunAt=now+everyMs` while `count < maxRuns` and clears stale lease `leaseExpiresAt`/`activeRunID` (`src/server/goal-tools.ts:377` fix).
12
+ - **Plugin wiring** — `createScheduleWorker` started lazily with `engine`+`worker` in `src/server/plugin.ts:31`, `dispose` stops it.
13
+ - **Owner visibility** — `inspect_background_goal` exposes `config.schedule` + `runtime scheduleRunCount/nextRunAt/lastScheduleAt` (`src/server/owner-tools.ts:105`, `src/server/goal-tools.ts:540`).
14
+ - **Tests** — `test/application/schedule-worker.test.ts:1` 6 tests (resurrect due, skip-if-running, maxRuns, writer serialization, concurrent `workspaceWrite:false`, future `nextRunAt`); `test/infrastructure/state-store.test.ts:42` updated to `version 6`; total `165` pass.
15
+
16
+ ### Fixed
17
+
18
+ - **Stale lease blocked schedule** — `complete_goal` now clears `leaseExpiresAt`/`turnStartedAt`/`activeRunID`/`activePromptMessageID` on `complete`; previous left `5m` lease valid while `phase idle`, so `schedule.tick` saw `leaseIsValid` and skipped until expiry.
19
+
20
+ ### Live proof
21
+
22
+ - `sched-live-verify` `09cb837c` `10s` `maxRuns 3` `workspaceWrite:false` → `tick.txt` `3` lines `02:00:56` `02:03:43` `02:05:47` `runCount 3` `scheduleRunCount 3` `nextRunAt None` `165` tests still pass; `schedule.tick` events `02:01:29` `02:04:29`.
23
+
3
24
  ## 1.6.0 (2026-08-24) — Hardened Lifecycle
4
25
 
5
26
  > Merge `feat/goal-evaluation` (43 files, +5516 / -1001 since `1.5.3` `5461e0d`). Hardens prompt correlation, maintenance recovery, workspace serialization, and evaluator verification.
package/README.md CHANGED
@@ -19,13 +19,14 @@
19
19
  - **Codex-style engine-driven loop** — idle detection → continuation steering with accumulated context (progress history, transcript tail, inbox). No prompt spam in the parent.
20
20
  - **Parent ↔ child visibility** — `list/inspect/read_transcript/send_input` give the parent full observability. Bidirectional inbox lets you steer mid-run.
21
21
  - **Safe by default** — per-goal artifact isolation (`.opencode/loopd/goals/<id>/`), `maxTurns`/`maxFailures`/`maxNoProgress`, force-finish → semantic `complete_goal` summary → parent notification via wake-up injection.
22
+ - **Scheduled intervals** — `scheduleEveryMs`/`scheduleMaxRuns` auto-requeues the same goal every N ms (e.g., `10s` monitor, `1h` report) without manual `/goal` spam — `5s` poll, `skip-if-running`, `workspaceWrite` serialization, inbox `Scheduled tick N/M`.
22
23
  - **Modal TUI dashboard** — `<leader>d` or `/loop` opens a focused dialog (no leak to chat prompt). Vim-style navigation, live running indicator, per-status borders.
23
24
 
24
25
  Keywords: `opencode` `opencode-plugin` `background-agent` `autonomous` `subagent` `loop` `goal` `tui` `codex` `claude-code` `worker`
25
26
 
26
27
  ## What it is
27
28
 
28
- opencode-loopd is an **OpenCode plugin** (server + TUI) that adds **background goals** to OpenCode. Each goal owns a **worker (child) subagent** that loops autonomously; the **loop engine** drives continuations; the **dashboard** and **owner tools** keep the parent in control. Think *Codex goals* or *Claude Code loop*, but native to OpenCode's session model.
29
+ opencode-loopd is an **OpenCode plugin** (server + TUI) that adds **background goals** to OpenCode. Each goal owns a **worker (child) subagent** that loops autonomously; the **loop engine** drives continuations; the **schedule worker** requeues the same goal on an interval (`scheduleEveryMs`); the **dashboard** and **owner tools** keep the parent in control. Think *Codex goals* or *Claude Code loop*, but native to OpenCode's session model — with scheduled intervals for repetitive dialogues.
29
30
 
30
31
  ## Install
31
32
 
@@ -211,6 +212,7 @@ Completion is semantic: the child writes the summary; the plugin forwards it to
211
212
  ```
212
213
 
213
214
  - **Engine-driven loop** (like Codex): `session.idle` → lease + retry + polling → re-prompt child. Not a parent-driven re-prompt machine.
215
+ - **Scheduled requeue** (1.7): `createScheduleWorker` `5s` scans `complete` goals with `scheduleEveryMs`/`maxRuns`; `complete→active` resurrect with `Scheduled tick N/M` inbox, `skip-if-running` and `workspaceWrite` gate.
214
216
  - **Accumulated context**: each continuation includes progress history, transcript tail, inbox messages, and artifact dir (unless objective names another dir).
215
217
  - **Parent wake-up**: when the child calls `complete_goal`/`block_goal`, `tool.execute.after` injects the child's semantic summary into the owner's session — you see "Loop goal … completed: …" without polling.
216
218
 
@@ -245,6 +247,8 @@ Goals accept:
245
247
  | `maxNoProgress` | `3` | Turns without progress before force-finish |
246
248
  | `timeoutMs` | `300000` | Per-turn lease (5 min) |
247
249
  | `compactEvery` | — | Compact child every N turns |
250
+ | `scheduleEveryMs` | — | Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction without manual re-prompt |
251
+ | `scheduleMaxRuns` | — | Maximum total runs including the initial run. Undefined = unlimited. Requires `scheduleEveryMs` |
248
252
  | `checks` | `[]` | Shell commands that must pass for `complete_goal` |
249
253
  | `checkCwd` | artifact directory | Directory where completion checks run; workspace writes default to project root |
250
254
  | `workspaceWrite` | `true` | Marks shared-workspace mutation; only one active workspace writer is allowed. Set `false` explicitly for artifact-only/read-only work |
@@ -259,6 +263,29 @@ prevent concurrent agents or `git stash` operations from overwriting each
259
263
  other. Artifact-only research goals may run concurrently by setting
260
264
  `workspaceWrite: false` explicitly.
261
265
 
266
+ ## Scheduled goals — repetitive dialogues without manual re-prompt
267
+
268
+ One `/goal` can now loop on an interval — the engine requeues the **same goal** after each `complete`:
269
+
270
+ ```
271
+ loopd_create_goal({
272
+ name: "hourly-report",
273
+ objective: "Append date -u to tick.txt each run",
274
+ agent: "smart-agent",
275
+ checks: ["test -f tick.txt"],
276
+ scheduleEveryMs: 3600000, // 1h, min 1000
277
+ scheduleMaxRuns: 24, // total incl initial, omit for unlimited
278
+ workspaceWrite: false
279
+ })
280
+ ```
281
+
282
+ - **Poll:** `5s` `createScheduleWorker` scans `complete` scheduled goals; `skip-if-running` (lease/`phase`/`workspaceWrite` gate).
283
+ - **Resurrect:** `complete → active` `phase=idle`, injects inbox `Scheduled tick N/M — re-execute objective`, then `continueTurn`.
284
+ - **State:** `scheduleRunCount`/`nextRunAt`/`lastScheduleAt` (`v6` `state.json`), `schedule.tick` events, `schedule.resurrected` server log.
285
+ - **Live proof:** `sched-live-verify` `09cb837c` `10s` `maxRuns 3` → `tick.txt` `3` lines `02:00:56` `02:03:43` `02:05:47` with `runCount 3`.
286
+
287
+ Without schedule you’d send `/goal` 3× manually; with schedule one creation covers `monitor deploy`, `periodic tests`, `keep going until done`.
288
+
262
289
  ## Dashboard tips
263
290
 
264
291
  - Press `:` then type bare text → sent as `send_goal_input` to the selected goal.
package/dist/server.js CHANGED
@@ -147,7 +147,7 @@ import { randomUUID } from "crypto";
147
147
  import { promises as fs } from "fs";
148
148
  import path from "path";
149
149
  import os from "os";
150
- var CURRENT_VERSION = 5;
150
+ var CURRENT_VERSION = 6;
151
151
  function emptyState() {
152
152
  return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
153
153
  }
@@ -310,6 +310,32 @@ function migrate(state) {
310
310
  workerUnreachableNotifiedAt: rt.workerUnreachableNotifiedAt ?? undefined
311
311
  }));
312
312
  }
313
+ if (result.version < 6) {
314
+ result.version = 6;
315
+ result.goals = result.goals.map((goal) => ({
316
+ ...goal,
317
+ config: {
318
+ ...goal.config,
319
+ schedule: goal.config?.schedule ?? undefined
320
+ }
321
+ }));
322
+ result.goals = result.goals.map((goal) => {
323
+ const s = goal.config?.schedule;
324
+ if (s && typeof s.everyMs === "number" && s.everyMs >= 1000)
325
+ return goal;
326
+ if (s) {
327
+ const { schedule: _s, ...restConfig } = goal.config;
328
+ return { ...goal, config: restConfig };
329
+ }
330
+ return goal;
331
+ });
332
+ result.runtimes = result.runtimes.map((rt) => ({
333
+ ...rt,
334
+ scheduleRunCount: typeof rt.scheduleRunCount === "number" ? rt.scheduleRunCount : 0,
335
+ nextRunAt: rt.nextRunAt ?? undefined,
336
+ lastScheduleAt: rt.lastScheduleAt ?? undefined
337
+ }));
338
+ }
313
339
  return result;
314
340
  }
315
341
  async function writeAtomic(target, contents) {
@@ -470,14 +496,7 @@ function resolveGoalCreationConfig(input) {
470
496
  const defaults = input.defaults || {};
471
497
  const explicitAgent = cleanText(requested.agent);
472
498
  const defaultAgent = cleanText(defaults.defaultAgent);
473
- const agent = explicitAgent || defaultAgent;
474
- if (!agent) {
475
- return {
476
- ok: false,
477
- errorCode: "missing_agent",
478
- message: "An agent is required. Pass agent explicitly or configure plugin option defaultAgent."
479
- };
480
- }
499
+ const agent = explicitAgent || defaultAgent || undefined;
481
500
  const workspaceWrite = requested.workspaceWrite ?? true;
482
501
  const explicitChecks = cleanList(requested.checks);
483
502
  const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks) : [];
@@ -1799,7 +1818,13 @@ function createGoalService(host) {
1799
1818
  const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
1800
1819
  assertWorkspaceWriteAvailable(state, goal);
1801
1820
  state.goals.push(goal);
1802
- state.runtimes.push(createRuntimeState(id));
1821
+ const rt = createRuntimeState(id);
1822
+ if (goal.config.schedule) {
1823
+ rt.scheduleRunCount = 0;
1824
+ rt.nextRunAt = undefined;
1825
+ rt.lastScheduleAt = undefined;
1826
+ }
1827
+ state.runtimes.push(rt);
1803
1828
  const runtime2 = state.runtimes.find((r) => r.goalID === id);
1804
1829
  if (runtime2)
1805
1830
  runtime2.phase = "queued";
@@ -2281,6 +2306,119 @@ function createGoalService(host) {
2281
2306
  return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
2282
2307
  }
2283
2308
 
2309
+ // src/application/schedule-worker.ts
2310
+ import { randomUUID as randomUUID4 } from "crypto";
2311
+ function createScheduleWorker(options) {
2312
+ const { directory, goalService } = options;
2313
+ const intervalMs = options.intervalMs ?? 5000;
2314
+ let running = false;
2315
+ let timer;
2316
+ function start() {
2317
+ if (running)
2318
+ return;
2319
+ running = true;
2320
+ timer = setInterval(() => {
2321
+ tick().catch(() => {});
2322
+ }, intervalMs);
2323
+ }
2324
+ function stop() {
2325
+ running = false;
2326
+ if (timer)
2327
+ clearInterval(timer);
2328
+ timer = undefined;
2329
+ }
2330
+ function isRunning() {
2331
+ return running;
2332
+ }
2333
+ async function tick() {
2334
+ const state = await readState(directory);
2335
+ let resurrected = 0;
2336
+ for (const goal of state.goals) {
2337
+ const schedule = goal.config.schedule;
2338
+ if (!schedule || typeof schedule.everyMs !== "number" || schedule.everyMs < 1000)
2339
+ continue;
2340
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2341
+ if (!runtime)
2342
+ continue;
2343
+ if (goal.status !== "complete")
2344
+ continue;
2345
+ const count = runtime.scheduleRunCount ?? 0;
2346
+ const max = schedule.maxRuns;
2347
+ if (typeof max === "number" && count >= max)
2348
+ continue;
2349
+ const nextAt = runtime.nextRunAt;
2350
+ if (!nextAt)
2351
+ continue;
2352
+ if (Date.now() < Date.parse(nextAt))
2353
+ continue;
2354
+ const activeWriter = state.goals.find((g) => g.id !== goal.id && g.status === "active" && g.config.workspaceWrite);
2355
+ if (goal.config.workspaceWrite && activeWriter) {
2356
+ await logServerEvent(directory, "schedule.skipped-writer-active", {
2357
+ goalID: goal.id,
2358
+ activeWriter: activeWriter.id
2359
+ });
2360
+ continue;
2361
+ }
2362
+ if (runtime.phase === "running" || runtime.phase === "queued" || runtime.phase === "compacting")
2363
+ continue;
2364
+ if (leaseIsValid(runtime))
2365
+ continue;
2366
+ const didResurrect = await mutateState(directory, `schedule.tick:${goal.id}`, async (s) => {
2367
+ const g = s.goals.find((x) => x.id === goal.id);
2368
+ const rt = s.runtimes.find((x) => x.goalID === goal.id);
2369
+ if (!g || !rt)
2370
+ return s;
2371
+ if (g.status !== "complete")
2372
+ return s;
2373
+ const curCount = rt.scheduleRunCount ?? 0;
2374
+ if (typeof max === "number" && curCount >= max)
2375
+ return s;
2376
+ const curNext = rt.nextRunAt;
2377
+ if (!curNext || Date.now() < Date.parse(curNext))
2378
+ return s;
2379
+ g.status = "active";
2380
+ g.updatedAt = new Date().toISOString();
2381
+ g.blocker = undefined;
2382
+ rt.phase = "idle";
2383
+ rt.consecutiveFailures = 0;
2384
+ rt.noProgressCount = 0;
2385
+ rt.progressDuringTurn = false;
2386
+ rt.forceFinishRequested = undefined;
2387
+ rt.lastError = undefined;
2388
+ rt.lastScheduleAt = new Date().toISOString();
2389
+ rt.updatedAt = new Date().toISOString();
2390
+ return s;
2391
+ });
2392
+ const after = didResurrect.goals.find((g) => g.id === goal.id);
2393
+ if (!after || after.status !== "active")
2394
+ continue;
2395
+ await appendEvent(directory, {
2396
+ version: 1,
2397
+ eventID: randomUUID4(),
2398
+ goalID: goal.id,
2399
+ type: "schedule.tick",
2400
+ scheduleRunCount: count,
2401
+ nextRunAt: nextAt,
2402
+ timestamp: new Date().toISOString(),
2403
+ revision: didResurrect.revision
2404
+ });
2405
+ await logServerEvent(directory, "schedule.resurrected", {
2406
+ goalID: goal.id,
2407
+ scheduleRunCount: count,
2408
+ nextRunAt: nextAt
2409
+ });
2410
+ const maxLabel = typeof max === "number" ? `/${max}` : "";
2411
+ await appendGoalInbox(directory, goal.id, "user", `Scheduled tick ${count + 1}${maxLabel} \u2014 re-execute the objective now. Previous completion: ${runtime.scheduleRunCount ?? 0} runs. Ensure artifact checks pass for this tick (e.g., append timestamp to tick.txt).`);
2412
+ try {
2413
+ await goalService.continueTurn(directory, goal.id);
2414
+ resurrected++;
2415
+ } catch {}
2416
+ }
2417
+ return resurrected;
2418
+ }
2419
+ return { start, stop, isRunning, tick };
2420
+ }
2421
+
2284
2422
  // src/server/host-adapter.ts
2285
2423
  var recentParentNotifies = new Map;
2286
2424
  function shouldDedupParentNotify(ownerSessionID, message) {
@@ -2431,7 +2569,7 @@ async function withTimeout(promise, timeoutMs, operation) {
2431
2569
  }
2432
2570
 
2433
2571
  // src/server/goal-tools.ts
2434
- import { randomUUID as randomUUID4 } from "crypto";
2572
+ import { randomUUID as randomUUID5 } from "crypto";
2435
2573
  import { tool } from "@opencode-ai/plugin/tool";
2436
2574
  // src/domain/verification.ts
2437
2575
  var MAX_RECENT_ATTEMPTS = 10;
@@ -2450,11 +2588,11 @@ var execAsync = promisify(execChild);
2450
2588
  function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2451
2589
  return {
2452
2590
  loopd_create_goal: tool({
2453
- description: "Create a new background loop goal (contract: objective + checks + agent + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Specify 'agent' or configure plugin defaultAgent.",
2591
+ description: "Create a new background loop goal (contract: objective + checks + agent + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "agent is optional \u2014 uses the parent session's agent if omitted, or configure plugin defaultAgent in opencode.jsonc.",
2454
2592
  args: {
2455
2593
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
2456
2594
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
2457
- agent: tool.schema.string().optional().describe("Agent to run the worker as. Required unless the plugin has defaultAgent configured."),
2595
+ agent: tool.schema.string().optional().describe("Agent to run the worker as. Optional \u2014 uses parent session's agent if omitted, or configure plugin defaultAgent in opencode.jsonc."),
2458
2596
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
2459
2597
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
2460
2598
  workspaceWrite: tool.schema.boolean().optional().describe("Whether this goal edits the shared project workspace. Defaults to true; explicitly set false for artifact-only/read-only work."),
@@ -2463,7 +2601,9 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2463
2601
  maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
2464
2602
  maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
2465
2603
  compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
2466
- timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms.")
2604
+ timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
2605
+ scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
2606
+ scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
2467
2607
  },
2468
2608
  execute: async (args, context) => {
2469
2609
  const sessionID = context?.sessionID || hostSessionID;
@@ -2499,6 +2639,28 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2499
2639
  config.compactEvery = args.compactEvery;
2500
2640
  if (args.timeoutMs !== undefined)
2501
2641
  config.timeoutMs = args.timeoutMs;
2642
+ if (args.scheduleEveryMs !== undefined) {
2643
+ const everyMs = args.scheduleEveryMs;
2644
+ if (typeof everyMs !== "number" || !Number.isFinite(everyMs) || everyMs < 1000) {
2645
+ return {
2646
+ title: "Goal not created",
2647
+ output: JSON.stringify({ ok: false, message: "scheduleEveryMs must be a number >= 1000", errorCode: "invalid_schedule" })
2648
+ };
2649
+ }
2650
+ const maxRuns = args.scheduleMaxRuns;
2651
+ if (maxRuns !== undefined && (typeof maxRuns !== "number" || !Number.isFinite(maxRuns) || maxRuns < 1 || Math.floor(maxRuns) !== maxRuns)) {
2652
+ return {
2653
+ title: "Goal not created",
2654
+ output: JSON.stringify({ ok: false, message: "scheduleMaxRuns must be an integer >= 1", errorCode: "invalid_schedule" })
2655
+ };
2656
+ }
2657
+ config.schedule = { everyMs, ...maxRuns !== undefined ? { maxRuns } : {} };
2658
+ } else if (args.scheduleMaxRuns !== undefined) {
2659
+ return {
2660
+ title: "Goal not created",
2661
+ output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
2662
+ };
2663
+ }
2502
2664
  const resolution = resolveGoalCreationConfig({
2503
2665
  directory: dir,
2504
2666
  objective: args.objective,
@@ -2604,7 +2766,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2604
2766
  await writeState(dir, state);
2605
2767
  const event = {
2606
2768
  version: 1,
2607
- eventID: randomUUID4(),
2769
+ eventID: randomUUID5(),
2608
2770
  goalID: goal.id,
2609
2771
  type: "goal.progress",
2610
2772
  summary: args.summary,
@@ -2662,7 +2824,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
2662
2824
  Working directory: ${cwd}
2663
2825
 
2664
2826
  ${failureDetails}`;
2665
- const attemptID = randomUUID4();
2827
+ const attemptID = randomUUID5();
2666
2828
  const verificationAttempt = {
2667
2829
  id: attemptID,
2668
2830
  sequence: runtime2.evaluatorRejectionCount,
@@ -2684,7 +2846,7 @@ ${failureDetails}`;
2684
2846
  runtime2.recentVerificationAttempts = appendVerificationAttempt(runtime2.recentVerificationAttempts || [], verificationAttempt);
2685
2847
  const rejectEvent = {
2686
2848
  version: 1,
2687
- eventID: randomUUID4(),
2849
+ eventID: randomUUID5(),
2688
2850
  goalID: goal.id,
2689
2851
  type: "goal.completion_rejected",
2690
2852
  attemptID,
@@ -2708,7 +2870,7 @@ ${failureDetails.slice(0, 500)}`,
2708
2870
  runtime2.forceFinishRequested = undefined;
2709
2871
  await appendEvent(dir, {
2710
2872
  version: 1,
2711
- eventID: randomUUID4(),
2873
+ eventID: randomUUID5(),
2712
2874
  goalID: goal.id,
2713
2875
  type: "goal.blocked",
2714
2876
  reason: goal.blocker.reason,
@@ -2745,8 +2907,33 @@ ${failureDetails.slice(0, 500)}`,
2745
2907
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2746
2908
  if (runtime) {
2747
2909
  runtime.phase = "idle";
2910
+ runtime.leaseExpiresAt = undefined;
2911
+ runtime.turnStartedAt = undefined;
2912
+ runtime.activeRunID = undefined;
2913
+ runtime.activePromptMessageID = undefined;
2914
+ runtime.activePromptObservedAt = undefined;
2915
+ runtime.activeAssistantMessageID = undefined;
2916
+ runtime.activeAssistantCompletedAt = undefined;
2917
+ runtime.idleCandidateAt = undefined;
2918
+ runtime.idleCandidateGeneration = undefined;
2919
+ runtime.activeToolCallIDs = [];
2748
2920
  runtime.lastError = undefined;
2749
- const attemptID = randomUUID4();
2921
+ const schedule = goal.config.schedule;
2922
+ if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
2923
+ const cur = typeof runtime.scheduleRunCount === "number" ? runtime.scheduleRunCount : 0;
2924
+ const nextCount = cur + 1;
2925
+ runtime.scheduleRunCount = nextCount;
2926
+ const max = schedule.maxRuns;
2927
+ const hasMore = typeof max === "number" ? nextCount < max : true;
2928
+ if (hasMore) {
2929
+ runtime.nextRunAt = new Date(Date.now() + schedule.everyMs).toISOString();
2930
+ runtime.lastScheduleAt = new Date().toISOString();
2931
+ } else {
2932
+ runtime.nextRunAt = undefined;
2933
+ }
2934
+ runtime.updatedAt = new Date().toISOString();
2935
+ }
2936
+ const attemptID = randomUUID5();
2750
2937
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
2751
2938
  const checks = (goal.config.checks || []).map((cmd) => ({
2752
2939
  command: cmd,
@@ -2770,7 +2957,7 @@ ${failureDetails.slice(0, 500)}`,
2770
2957
  await writeState(dir, state);
2771
2958
  const event = {
2772
2959
  version: 1,
2773
- eventID: randomUUID4(),
2960
+ eventID: randomUUID5(),
2774
2961
  goalID: goal.id,
2775
2962
  type: "goal.completed",
2776
2963
  summary: args.summary,
@@ -2822,7 +3009,7 @@ ${failureDetails.slice(0, 500)}`,
2822
3009
  await writeState(dir, state);
2823
3010
  const event = {
2824
3011
  version: 1,
2825
- eventID: randomUUID4(),
3012
+ eventID: randomUUID5(),
2826
3013
  goalID: goal.id,
2827
3014
  type: "goal.blocked",
2828
3015
  reason: args.reason,
@@ -2870,7 +3057,8 @@ function formatGoalStructured(goal, runtime) {
2870
3057
  maxNoProgress: goal.config.maxNoProgress,
2871
3058
  maxFailures: goal.config.maxFailures,
2872
3059
  compactEvery: goal.config.compactEvery,
2873
- timeoutMs: goal.config.timeoutMs
3060
+ timeoutMs: goal.config.timeoutMs,
3061
+ schedule: goal.config.schedule
2874
3062
  },
2875
3063
  lastProgress: goal.lastProgress,
2876
3064
  completionEvidence: goal.completionEvidence,
@@ -2902,7 +3090,10 @@ function formatGoalStructured(goal, runtime) {
2902
3090
  lastUnknownStatusAt: runtime.lastUnknownStatusAt,
2903
3091
  workerUnreachableNotifiedAt: runtime.workerUnreachableNotifiedAt,
2904
3092
  lastVerificationAttempt: runtime.lastVerificationAttempt,
2905
- recentVerificationAttempts: runtime.recentVerificationAttempts
3093
+ recentVerificationAttempts: runtime.recentVerificationAttempts,
3094
+ scheduleRunCount: runtime.scheduleRunCount,
3095
+ nextRunAt: runtime.nextRunAt,
3096
+ lastScheduleAt: runtime.lastScheduleAt
2906
3097
  };
2907
3098
  }
2908
3099
  return JSON.stringify(output, null, 2);
@@ -3008,7 +3199,8 @@ function ownerTools(options) {
3008
3199
  checks: goal.config.checks,
3009
3200
  checkCwd: goal.config.checkCwd,
3010
3201
  workspaceWrite: goal.config.workspaceWrite,
3011
- agent: goal.config.agent
3202
+ agent: goal.config.agent,
3203
+ schedule: goal.config.schedule
3012
3204
  },
3013
3205
  lastProgress: goal.lastProgress,
3014
3206
  completionEvidence: goal.completionEvidence,
@@ -3025,7 +3217,10 @@ function ownerTools(options) {
3025
3217
  consecutiveFailures: runtime.consecutiveFailures,
3026
3218
  lastError: runtime.lastError,
3027
3219
  lastProgressAt: runtime.lastProgressAt,
3028
- lastRunAt: runtime.lastRunAt
3220
+ lastRunAt: runtime.lastRunAt,
3221
+ scheduleRunCount: runtime.scheduleRunCount,
3222
+ nextRunAt: runtime.nextRunAt,
3223
+ lastScheduleAt: runtime.lastScheduleAt
3029
3224
  } : undefined
3030
3225
  }, null, 2)
3031
3226
  };
@@ -3294,6 +3489,11 @@ var server = async ({ client, directory }, pluginOptions) => {
3294
3489
  goalService,
3295
3490
  pollIntervalMs: 30000
3296
3491
  });
3492
+ const scheduleWorker = createScheduleWorker({
3493
+ directory,
3494
+ goalService,
3495
+ intervalMs: 5000
3496
+ });
3297
3497
  let started = false;
3298
3498
  let reconciliationStarted = false;
3299
3499
  function ensureStarted() {
@@ -3302,6 +3502,7 @@ var server = async ({ client, directory }, pluginOptions) => {
3302
3502
  started = true;
3303
3503
  engine.start();
3304
3504
  worker.start();
3505
+ scheduleWorker.start();
3305
3506
  }
3306
3507
  function reconcileInBackground() {
3307
3508
  if (reconciliationStarted)
@@ -3400,6 +3601,7 @@ var server = async ({ client, directory }, pluginOptions) => {
3400
3601
  dispose: async () => {
3401
3602
  engine.stop();
3402
3603
  await worker.stop();
3604
+ scheduleWorker.stop();
3403
3605
  }
3404
3606
  };
3405
3607
  };
package/dist/tui.js CHANGED
@@ -22,7 +22,7 @@ import { useKeyboard } from "@opentui/solid";
22
22
  // src/infrastructure/state-repository.ts
23
23
  import { promises as fs } from "fs";
24
24
  import path from "path";
25
- var CURRENT_VERSION = 5;
25
+ var CURRENT_VERSION = 6;
26
26
  function emptyState() {
27
27
  return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
28
28
  }
@@ -123,6 +123,32 @@ function migrate(state) {
123
123
  workerUnreachableNotifiedAt: rt.workerUnreachableNotifiedAt ?? undefined
124
124
  }));
125
125
  }
126
+ if (result.version < 6) {
127
+ result.version = 6;
128
+ result.goals = result.goals.map((goal) => ({
129
+ ...goal,
130
+ config: {
131
+ ...goal.config,
132
+ schedule: goal.config?.schedule ?? undefined
133
+ }
134
+ }));
135
+ result.goals = result.goals.map((goal) => {
136
+ const s = goal.config?.schedule;
137
+ if (s && typeof s.everyMs === "number" && s.everyMs >= 1000)
138
+ return goal;
139
+ if (s) {
140
+ const { schedule: _s, ...restConfig } = goal.config;
141
+ return { ...goal, config: restConfig };
142
+ }
143
+ return goal;
144
+ });
145
+ result.runtimes = result.runtimes.map((rt) => ({
146
+ ...rt,
147
+ scheduleRunCount: typeof rt.scheduleRunCount === "number" ? rt.scheduleRunCount : 0,
148
+ nextRunAt: rt.nextRunAt ?? undefined,
149
+ lastScheduleAt: rt.lastScheduleAt ?? undefined
150
+ }));
151
+ }
126
152
  return result;
127
153
  }
128
154
  async function writeAtomic(target, contents) {
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.6.1",
4
+ "version": "1.7.1",
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",