@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 +21 -0
- package/README.md +28 -1
- package/dist/server.js +227 -25
- package/dist/tui.js +27 -1
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
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
|
|
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. " + "
|
|
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.
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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 =
|
|
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.
|
|
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",
|