@bojackduy/opencode-loopd 1.5.3 → 1.6.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 +51 -8
- package/README.md +23 -0
- package/commands/goal.md +15 -10
- package/dist/server.js +1349 -462
- package/dist/tui.js +121 -71
- package/package.json +1 -1
- package/skills/loopd/SKILL.md +76 -23
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 = 5;
|
|
26
26
|
function emptyState() {
|
|
27
27
|
return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
|
|
28
28
|
}
|
|
@@ -76,6 +76,53 @@ function migrate(state) {
|
|
|
76
76
|
blocker: g.blocker ?? undefined
|
|
77
77
|
}));
|
|
78
78
|
}
|
|
79
|
+
if (result.version < 3) {
|
|
80
|
+
result.version = 3;
|
|
81
|
+
result.runtimes = result.runtimes.map((rt) => {
|
|
82
|
+
const oldTurnCount = rt.turnCount ?? 0;
|
|
83
|
+
const { turnCount: _deprecatedTurnCount, ...rest } = rt;
|
|
84
|
+
return {
|
|
85
|
+
...rest,
|
|
86
|
+
budgetTurnCount: rest.budgetTurnCount ?? oldTurnCount,
|
|
87
|
+
runCount: rest.runCount ?? oldTurnCount,
|
|
88
|
+
runGeneration: rest.runGeneration ?? 0,
|
|
89
|
+
freeRetryPending: rest.freeRetryPending ?? false,
|
|
90
|
+
lastRejectionDetails: rest.lastRejectionDetails ?? undefined,
|
|
91
|
+
activePromptMessageID: rest.activePromptMessageID ?? undefined,
|
|
92
|
+
lastActivityAt: rest.lastActivityAt ?? undefined,
|
|
93
|
+
idleCandidateAt: rest.idleCandidateAt ?? undefined,
|
|
94
|
+
activeToolCallIDs: rest.activeToolCallIDs ?? []
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (result.version < 4) {
|
|
99
|
+
result.version = 4;
|
|
100
|
+
result.runtimes = result.runtimes.map((rt) => ({
|
|
101
|
+
...rt,
|
|
102
|
+
lastVerificationAttempt: rt.lastVerificationAttempt ?? undefined,
|
|
103
|
+
recentVerificationAttempts: rt.recentVerificationAttempts ?? []
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
if (result.version < 5) {
|
|
107
|
+
result.version = 5;
|
|
108
|
+
result.goals = result.goals.map((goal) => ({
|
|
109
|
+
...goal,
|
|
110
|
+
config: {
|
|
111
|
+
...goal.config,
|
|
112
|
+
workspaceWrite: goal.config?.workspaceWrite ?? true
|
|
113
|
+
}
|
|
114
|
+
}));
|
|
115
|
+
result.runtimes = result.runtimes.map((rt) => ({
|
|
116
|
+
...rt,
|
|
117
|
+
activePromptObservedAt: rt.activePromptObservedAt ?? undefined,
|
|
118
|
+
activeAssistantMessageID: rt.activeAssistantMessageID ?? undefined,
|
|
119
|
+
activeAssistantCompletedAt: rt.activeAssistantCompletedAt ?? undefined,
|
|
120
|
+
idleCandidateGeneration: rt.idleCandidateGeneration ?? undefined,
|
|
121
|
+
unknownStatusCount: rt.unknownStatusCount ?? 0,
|
|
122
|
+
lastUnknownStatusAt: rt.lastUnknownStatusAt ?? undefined,
|
|
123
|
+
workerUnreachableNotifiedAt: rt.workerUnreachableNotifiedAt ?? undefined
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
79
126
|
return result;
|
|
80
127
|
}
|
|
81
128
|
async function writeAtomic(target, contents) {
|
|
@@ -643,7 +690,7 @@ function LoopDashboard(props) {
|
|
|
643
690
|
return;
|
|
644
691
|
}
|
|
645
692
|
});
|
|
646
|
-
const goals = () => state()?.goals.filter((g) => g.status !== "complete") || [];
|
|
693
|
+
const goals = () => state()?.goals.filter((g) => showCompleted() || g.status !== "complete") || [];
|
|
647
694
|
async function executeCommand(cmd) {
|
|
648
695
|
debugLog("executeCommand raw=", JSON.stringify(cmd));
|
|
649
696
|
const parsed = parseCommand(cmd);
|
|
@@ -1203,7 +1250,7 @@ function LoopDashboard(props) {
|
|
|
1203
1250
|
const turnColor = () => {
|
|
1204
1251
|
if (!runtime() || !maxTurns)
|
|
1205
1252
|
return phaseColor(runtime()?.phase || "idle", theme());
|
|
1206
|
-
const ratio = runtime().
|
|
1253
|
+
const ratio = runtime().budgetTurnCount / maxTurns;
|
|
1207
1254
|
if (ratio >= 1)
|
|
1208
1255
|
return theme().error;
|
|
1209
1256
|
if (ratio >= 0.8)
|
|
@@ -1250,7 +1297,7 @@ function LoopDashboard(props) {
|
|
|
1250
1297
|
})(), (() => {
|
|
1251
1298
|
var _el$98 = _$createElement("span"), _el$99 = _$createTextNode(` `);
|
|
1252
1299
|
_$insertNode(_el$98, _el$99);
|
|
1253
|
-
_$insert(_el$98, () => runtime().
|
|
1300
|
+
_$insert(_el$98, () => runtime().budgetTurnCount, null);
|
|
1254
1301
|
_$insert(_el$98, maxTurns ? `/${maxTurns}` : "", null);
|
|
1255
1302
|
_$effect((_$p) => _$setProp(_el$98, "style", {
|
|
1256
1303
|
fg: turnColor()
|
|
@@ -1366,9 +1413,12 @@ function LoopDashboard(props) {
|
|
|
1366
1413
|
}, _$p));
|
|
1367
1414
|
return _el$118;
|
|
1368
1415
|
})(), (() => {
|
|
1369
|
-
var _el$120 = _$createElement("span"), _el$121 = _$createTextNode(`
|
|
1416
|
+
var _el$120 = _$createElement("span"), _el$121 = _$createTextNode(` run `), _el$122 = _$createTextNode(` (budget `), _el$123 = _$createTextNode(`)`);
|
|
1370
1417
|
_$insertNode(_el$120, _el$121);
|
|
1371
|
-
_$
|
|
1418
|
+
_$insertNode(_el$120, _el$122);
|
|
1419
|
+
_$insertNode(_el$120, _el$123);
|
|
1420
|
+
_$insert(_el$120, () => rt().runCount, _el$122);
|
|
1421
|
+
_$insert(_el$120, () => rt().budgetTurnCount, _el$123);
|
|
1372
1422
|
_$effect((_$p) => _$setProp(_el$120, "style", {
|
|
1373
1423
|
fg: theme().textMuted
|
|
1374
1424
|
}, _$p));
|
|
@@ -1379,113 +1429,113 @@ function LoopDashboard(props) {
|
|
|
1379
1429
|
_$insert(_el$109, (() => {
|
|
1380
1430
|
var _c$8 = _$memo(() => !!lp());
|
|
1381
1431
|
return () => _c$8() && [(() => {
|
|
1382
|
-
var _el$
|
|
1432
|
+
var _el$124 = _$createElement("span"), _el$125 = _$createTextNode(`
|
|
1383
1433
|
\u2714 `);
|
|
1384
|
-
_$insertNode(_el$
|
|
1385
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1434
|
+
_$insertNode(_el$124, _el$125);
|
|
1435
|
+
_$effect((_$p) => _$setProp(_el$124, "style", {
|
|
1386
1436
|
fg: theme().success
|
|
1387
1437
|
}, _$p));
|
|
1388
|
-
return _el$
|
|
1438
|
+
return _el$124;
|
|
1389
1439
|
})(), (() => {
|
|
1390
|
-
var _el$
|
|
1391
|
-
_$insert(_el$
|
|
1392
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1440
|
+
var _el$127 = _$createElement("span");
|
|
1441
|
+
_$insert(_el$127, () => lp().summary.slice(0, 100));
|
|
1442
|
+
_$effect((_$p) => _$setProp(_el$127, "style", {
|
|
1393
1443
|
fg: theme().text
|
|
1394
1444
|
}, _$p));
|
|
1395
|
-
return _el$
|
|
1445
|
+
return _el$127;
|
|
1396
1446
|
})(), (() => {
|
|
1397
|
-
var _el$
|
|
1398
|
-
_$insertNode(_el$
|
|
1399
|
-
_$insert(_el$
|
|
1400
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1447
|
+
var _el$128 = _$createElement("span"), _el$129 = _$createTextNode(` \u2192 `);
|
|
1448
|
+
_$insertNode(_el$128, _el$129);
|
|
1449
|
+
_$insert(_el$128, () => lp().next?.slice(0, 60) || "", null);
|
|
1450
|
+
_$effect((_$p) => _$setProp(_el$128, "style", {
|
|
1401
1451
|
fg: theme().textMuted
|
|
1402
1452
|
}, _$p));
|
|
1403
|
-
return _el$
|
|
1453
|
+
return _el$128;
|
|
1404
1454
|
})()];
|
|
1405
1455
|
})(), null);
|
|
1406
1456
|
_$insert(_el$109, (() => {
|
|
1407
1457
|
var _c$9 = _$memo(() => !!blk());
|
|
1408
1458
|
return () => _c$9() && [(() => {
|
|
1409
|
-
var _el$
|
|
1459
|
+
var _el$130 = _$createElement("span"), _el$131 = _$createTextNode(`
|
|
1410
1460
|
\u2716 blocked: `);
|
|
1411
|
-
_$insertNode(_el$
|
|
1412
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1461
|
+
_$insertNode(_el$130, _el$131);
|
|
1462
|
+
_$effect((_$p) => _$setProp(_el$130, "style", {
|
|
1413
1463
|
fg: theme().error,
|
|
1414
1464
|
bold: true
|
|
1415
1465
|
}, _$p));
|
|
1416
|
-
return _el$
|
|
1466
|
+
return _el$130;
|
|
1417
1467
|
})(), (() => {
|
|
1418
|
-
var _el$
|
|
1419
|
-
_$insert(_el$
|
|
1420
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1468
|
+
var _el$133 = _$createElement("span");
|
|
1469
|
+
_$insert(_el$133, () => blk().reason.slice(0, 140));
|
|
1470
|
+
_$effect((_$p) => _$setProp(_el$133, "style", {
|
|
1421
1471
|
fg: theme().error
|
|
1422
1472
|
}, _$p));
|
|
1423
|
-
return _el$
|
|
1473
|
+
return _el$133;
|
|
1424
1474
|
})(), (() => {
|
|
1425
|
-
var _el$
|
|
1426
|
-
_$insertNode(_el$
|
|
1427
|
-
_$insert(_el$
|
|
1428
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1475
|
+
var _el$134 = _$createElement("span"), _el$135 = _$createTextNode(` \u2014 `);
|
|
1476
|
+
_$insertNode(_el$134, _el$135);
|
|
1477
|
+
_$insert(_el$134, () => blk().needed.slice(0, 60), null);
|
|
1478
|
+
_$effect((_$p) => _$setProp(_el$134, "style", {
|
|
1429
1479
|
fg: theme().textMuted
|
|
1430
1480
|
}, _$p));
|
|
1431
|
-
return _el$
|
|
1481
|
+
return _el$134;
|
|
1432
1482
|
})()];
|
|
1433
1483
|
})(), null);
|
|
1434
1484
|
_$insert(_el$109, (() => {
|
|
1435
1485
|
var _c$0 = _$memo(() => !!goal().config.artifactDir);
|
|
1436
1486
|
return () => _c$0() && [(() => {
|
|
1437
|
-
var _el$
|
|
1487
|
+
var _el$136 = _$createElement("span"), _el$137 = _$createTextNode(`
|
|
1438
1488
|
\uD83D\uDCC1 `);
|
|
1439
|
-
_$insertNode(_el$
|
|
1440
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1489
|
+
_$insertNode(_el$136, _el$137);
|
|
1490
|
+
_$effect((_$p) => _$setProp(_el$136, "style", {
|
|
1441
1491
|
fg: theme().accent
|
|
1442
1492
|
}, _$p));
|
|
1443
|
-
return _el$
|
|
1493
|
+
return _el$136;
|
|
1444
1494
|
})(), (() => {
|
|
1445
|
-
var _el$
|
|
1446
|
-
_$insert(_el$
|
|
1447
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1495
|
+
var _el$139 = _$createElement("span");
|
|
1496
|
+
_$insert(_el$139, () => String(goal().config.artifactDir).replace(String(props.directory), "."));
|
|
1497
|
+
_$effect((_$p) => _$setProp(_el$139, "style", {
|
|
1448
1498
|
fg: theme().textMuted
|
|
1449
1499
|
}, _$p));
|
|
1450
|
-
return _el$
|
|
1500
|
+
return _el$139;
|
|
1451
1501
|
})()];
|
|
1452
1502
|
})(), null);
|
|
1453
1503
|
_$insert(_el$109, (() => {
|
|
1454
1504
|
var _c$1 = _$memo(() => (goal().config.checks?.length ?? 0) > 0);
|
|
1455
1505
|
return () => _c$1() ? [(() => {
|
|
1456
|
-
var _el$
|
|
1506
|
+
var _el$140 = _$createElement("span"), _el$141 = _$createTextNode(`
|
|
1457
1507
|
\u25A3 checks: `);
|
|
1458
|
-
_$insertNode(_el$
|
|
1459
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1508
|
+
_$insertNode(_el$140, _el$141);
|
|
1509
|
+
_$effect((_$p) => _$setProp(_el$140, "style", {
|
|
1460
1510
|
fg: theme().warning
|
|
1461
1511
|
}, _$p));
|
|
1462
|
-
return _el$
|
|
1512
|
+
return _el$140;
|
|
1463
1513
|
})(), (() => {
|
|
1464
|
-
var _el$
|
|
1465
|
-
_$insert(_el$
|
|
1466
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1514
|
+
var _el$143 = _$createElement("span");
|
|
1515
|
+
_$insert(_el$143, () => goal().config.checks.join(", ").slice(0, 100));
|
|
1516
|
+
_$effect((_$p) => _$setProp(_el$143, "style", {
|
|
1467
1517
|
fg: theme().textMuted
|
|
1468
1518
|
}, _$p));
|
|
1469
|
-
return _el$
|
|
1519
|
+
return _el$143;
|
|
1470
1520
|
})()] : null;
|
|
1471
1521
|
})(), null);
|
|
1472
1522
|
_$insert(_el$109, (() => {
|
|
1473
1523
|
var _c$10 = _$memo(() => !!rt()?.lastError);
|
|
1474
1524
|
return () => _c$10() && [(() => {
|
|
1475
|
-
var _el$
|
|
1525
|
+
var _el$144 = _$createElement("span"), _el$145 = _$createTextNode(`
|
|
1476
1526
|
\u26A0 `);
|
|
1477
|
-
_$insertNode(_el$
|
|
1478
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1527
|
+
_$insertNode(_el$144, _el$145);
|
|
1528
|
+
_$effect((_$p) => _$setProp(_el$144, "style", {
|
|
1479
1529
|
fg: theme().error
|
|
1480
1530
|
}, _$p));
|
|
1481
|
-
return _el$
|
|
1531
|
+
return _el$144;
|
|
1482
1532
|
})(), (() => {
|
|
1483
|
-
var _el$
|
|
1484
|
-
_$insert(_el$
|
|
1485
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1533
|
+
var _el$147 = _$createElement("span");
|
|
1534
|
+
_$insert(_el$147, () => rt().lastError.slice(0, 120));
|
|
1535
|
+
_$effect((_$p) => _$setProp(_el$147, "style", {
|
|
1486
1536
|
fg: theme().error
|
|
1487
1537
|
}, _$p));
|
|
1488
|
-
return _el$
|
|
1538
|
+
return _el$147;
|
|
1489
1539
|
})()];
|
|
1490
1540
|
})(), null);
|
|
1491
1541
|
_$effect((_p$) => {
|
|
@@ -1535,29 +1585,29 @@ function LoopDashboard(props) {
|
|
|
1535
1585
|
},
|
|
1536
1586
|
children: (ev) => [`
|
|
1537
1587
|
`, (() => {
|
|
1538
|
-
var _el$
|
|
1539
|
-
_$insert(_el$
|
|
1540
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1588
|
+
var _el$148 = _$createElement("span");
|
|
1589
|
+
_$insert(_el$148, () => String(ev.type));
|
|
1590
|
+
_$effect((_$p) => _$setProp(_el$148, "style", {
|
|
1541
1591
|
fg: eventColor(String(ev.type), theme()),
|
|
1542
1592
|
bold: true
|
|
1543
1593
|
}, _$p));
|
|
1544
|
-
return _el$
|
|
1594
|
+
return _el$148;
|
|
1545
1595
|
})(), (() => {
|
|
1546
|
-
var _el$
|
|
1547
|
-
_$insertNode(_el$
|
|
1548
|
-
_$insert(_el$
|
|
1549
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1596
|
+
var _el$149 = _$createElement("span"), _el$150 = _$createTextNode(` `);
|
|
1597
|
+
_$insertNode(_el$149, _el$150);
|
|
1598
|
+
_$insert(_el$149, () => ev.goalID?.slice(0, 8), null);
|
|
1599
|
+
_$effect((_$p) => _$setProp(_el$149, "style", {
|
|
1550
1600
|
fg: theme().textMuted
|
|
1551
1601
|
}, _$p));
|
|
1552
|
-
return _el$
|
|
1602
|
+
return _el$149;
|
|
1553
1603
|
})(), _$memo(() => _$memo(() => !!ev.summary)() && (() => {
|
|
1554
|
-
var _el$
|
|
1555
|
-
_$insertNode(_el$
|
|
1556
|
-
_$insert(_el$
|
|
1557
|
-
_$effect((_$p) => _$setProp(_el$
|
|
1604
|
+
var _el$151 = _$createElement("span"), _el$152 = _$createTextNode(` \u2014 `);
|
|
1605
|
+
_$insertNode(_el$151, _el$152);
|
|
1606
|
+
_$insert(_el$151, () => String(ev.summary).slice(0, 60), null);
|
|
1607
|
+
_$effect((_$p) => _$setProp(_el$151, "style", {
|
|
1558
1608
|
fg: theme().text
|
|
1559
1609
|
}, _$p));
|
|
1560
|
-
return _el$
|
|
1610
|
+
return _el$151;
|
|
1561
1611
|
})())]
|
|
1562
1612
|
}), null);
|
|
1563
1613
|
_$effect((_p$) => {
|
|
@@ -1726,7 +1776,7 @@ var tui = async (api) => {
|
|
|
1726
1776
|
run: open
|
|
1727
1777
|
}],
|
|
1728
1778
|
bindings: [{
|
|
1729
|
-
key: "<leader>
|
|
1779
|
+
key: "<leader>o",
|
|
1730
1780
|
cmd: "opencode.loopd.dashboard",
|
|
1731
1781
|
desc: "Open loop dashboard"
|
|
1732
1782
|
}]
|
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.6.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",
|
package/skills/loopd/SKILL.md
CHANGED
|
@@ -31,27 +31,59 @@ Do not use loopd when:
|
|
|
31
31
|
```
|
|
32
32
|
created → active → complete
|
|
33
33
|
→ blocked (needs user intervention)
|
|
34
|
+
→ budget_limited / usage_limited (engine limits)
|
|
34
35
|
paused (user-initiated, can resume)
|
|
36
|
+
retry (blocked → active, clears failures)
|
|
35
37
|
```
|
|
36
38
|
|
|
39
|
+
Only `active` goals own live worker runs. `blocked`/`paused`/`budget_limited` wait for an explicit owner transition (`resume`/`retry`/`nudge`). The loop is engine-driven, not parent-driven.
|
|
40
|
+
|
|
41
|
+
## Contract & Evaluation Semantics
|
|
42
|
+
|
|
43
|
+
Every goal has an **immutable contract** at creation — the source of truth for completion:
|
|
44
|
+
|
|
45
|
+
* **Objective** — semantic requirements (free text, self-contained). The worker derives concrete requirements from it.
|
|
46
|
+
* **Checks** — deterministic shell commands that **must pass** for `complete_goal` to be accepted. For `workspaceWrite:true` goals they are **mandatory** (explicit `checks` or plugin `defaultChecks`), and they run from `checkCwd` (writers default to project root; artifact-only jobs run from their `artifactDir`).
|
|
47
|
+
* **Agent** — which subagent model runs the worker (`sloppy-agent`, `smart-agent`, …). Required unless `defaultAgent` is configured in `opencode.jsonc` plugin options.
|
|
48
|
+
* **WorkspaceWrite** — `true` (default) = may touch the shared repo; **only one active writer at a time** is allowed (enforced on `start`/`resume`/`retry` with rollback). Set `false` explicitly for artifact-only/read-only work to allow concurrency.
|
|
49
|
+
* **Limits** — `maxTurns` (default 50), `maxNoProgress`, `maxFailures`, `maxEvaluatorRejections` (default 3), `timeoutMs`, `compactEvery`, `progressFile`.
|
|
50
|
+
|
|
51
|
+
**Who decides completion:**
|
|
52
|
+
* **Host is the acceptance authority** — it runs `checks` deterministically. If any check fails, `complete_goal` is **rejected** (`ok:false`, `rejectionCount++`, `freeRetryPending=true` for <3 rejections, `blocked` after 3). The worker gets the exact failure in the next steering.
|
|
53
|
+
* **Model is the proposer** — it must self-audit via `## COMPLETION REVIEW` (derive requirements → locate evidence → judge `proves|contradicts|incomplete|missing`) and only call `complete_goal` when every requirement is proved. If objective and checks conflict, it must `block_goal`, not silently violate either.
|
|
54
|
+
|
|
55
|
+
**Hardened loop guarantees (1da88dc):**
|
|
56
|
+
* **Prompt correlation** — each turn’s prompt gets a `msg-` UUID (`activePromptMessageID`); the engine correlates `message.updated`/`message.part.updated` by that ID and by `runGeneration`. Stale events never release a newer lease.
|
|
57
|
+
* **Generation fencing** — `idleCandidateAt` is tied to `idleCandidateGeneration`; a new prompt clears the candidate. Finalization also requires the transcript anchor: the latest user prompt must be the engine’s prompt **and** its assistant response must be completed.
|
|
58
|
+
* **Maintenance (30s)** — auto-repairs `phase=idle + activeRunID` (stale lease), bounds `unknown` status polls (`unknownStatusCount` threshold 3 → one `notifyOwner` per episode, `workerUnreachableNotifiedAt` deduped), and recovers counters on success. `active`-only polling; `blocked`/`paused` never auto-continue.
|
|
59
|
+
* **Per-goal mutex** — `withGoalOperation(goalID)` serializes `continueTurn`/`pause`/`resume`/`retry`/`clear`/`nudge` per goal, and `turn.acquire` re-checks `leaseIsValid` inside the transaction for exclusivity.
|
|
60
|
+
* **Lease** — `acquireLease`/`releaseLease` + `timeoutMs` (default 5 min); prompt failures release the lease and schedule `waiting_retry` with exponential backoff.
|
|
61
|
+
|
|
37
62
|
## Creating a Goal
|
|
38
63
|
|
|
39
|
-
Use `loopd_create_goal` after clarifying the objective with the user:
|
|
64
|
+
Use `loopd_create_goal` **after** clarifying the objective with the user (what / where / how to verify). The tool validates the contract before spawning:
|
|
40
65
|
|
|
41
66
|
```
|
|
42
67
|
loopd_create_goal({
|
|
43
68
|
name: "short-name",
|
|
44
69
|
objective: "Detailed description of what the goal should accomplish.",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
agent: "smart-agent", // required unless plugin defaultAgent is configured
|
|
71
|
+
checks: ["npm test"], // mandatory if workspaceWrite:true (or configure defaultChecks)
|
|
72
|
+
checkCwd: "/project/root", // optional; writers default to project root, readers to artifactDir
|
|
73
|
+
workspaceWrite: true, // default true; set false explicitly for artifact-only/read-only
|
|
74
|
+
progressFile: ".opencode/loopd/progress.md", // optional; defaults to <artifactDir>/progress.md
|
|
75
|
+
maxTurns: 50, // optional; ≥50 enforces FINAL REPORT REQUIRED
|
|
76
|
+
maxNoProgress: 5, // optional; auto-block without progress
|
|
77
|
+
maxFailures: 3, // optional; auto-block on failures
|
|
78
|
+
maxEvaluatorRejections: 3, // optional; block after N check failures (default 3)
|
|
79
|
+
compactEvery: 3, // optional; compact worker session every N turns
|
|
80
|
+
timeoutMs: 300000, // optional; per-turn lease
|
|
51
81
|
})
|
|
52
82
|
```
|
|
53
83
|
|
|
54
|
-
|
|
84
|
+
Returns `ok:true` with `goalID`, `workerSessionID`, `artifactDir`, `agent`, `checks`, `workspaceWrite`, `defaultsApplied:{agent,checks}`. On contract violation you get `ok:false` with `errorCode: "missing_agent"` or `"missing_checks"` or `"already active"` (writer serialization).
|
|
85
|
+
|
|
86
|
+
The goal starts immediately. The user can monitor it via `/loop` (<leader>d). Plugin options `defaultAgent` / `defaultChecks` in `opencode.jsonc` can supply defaults so callers don’t have to repeat them.
|
|
55
87
|
|
|
56
88
|
## Worker Tools (Running Inside the Goal)
|
|
57
89
|
|
|
@@ -111,23 +143,34 @@ block_goal({
|
|
|
111
143
|
|
|
112
144
|
## Owner Tools (Parent Chat)
|
|
113
145
|
|
|
114
|
-
|
|
146
|
+
All are filtered by `ownerSessionID` (only the session that created the goal sees it). They are the **only** way to recover a stuck worker — they mutate the engine state (Entity B) and, when needed, force the bridge to the real subagent session (Entity A).
|
|
115
147
|
|
|
116
148
|
### list_background_goals
|
|
117
149
|
|
|
118
|
-
Lists all active goals owned by this session
|
|
150
|
+
Lists all `active` goals owned by this session (name, status, phase, turn, last progress, blocker).
|
|
119
151
|
|
|
120
152
|
### inspect_background_goal
|
|
121
153
|
|
|
122
|
-
Shows detailed
|
|
154
|
+
Shows detailed contract + runtime: `objective`, `config{agent,checks,checkCwd,workspaceWrite,limits,artifactDir}`, `lastProgress`, `completionEvidence`, `blocker`, `runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,lastActivityAt,activePromptMessageID,unknownStatusCount}`.
|
|
123
155
|
|
|
124
156
|
### read_goal_transcript
|
|
125
157
|
|
|
126
|
-
Reads the last N messages from the worker session.
|
|
158
|
+
Reads the last N messages from the worker session (`role`, `content`, `timestamp`, `messageID`). Use to see if the worker’s steering contained `HOST VERDICT`.
|
|
127
159
|
|
|
128
160
|
### send_goal_input
|
|
129
161
|
|
|
130
|
-
|
|
162
|
+
Appends to the goal’s inbox file; the next `continueTurn` injects it as `## USER INSTRUCTIONS`. **Does not itself re-prompt** — the engine does on next idle/maintenance.
|
|
163
|
+
|
|
164
|
+
### nudge_goal
|
|
165
|
+
|
|
166
|
+
Force re-prompts a stuck worker even if `sessionStatus` is not `idle`. Clears stale `activeRunID`/`idleCandidateAt`/`activePromptMessageID`/lease, sets `phase=idle`, and calls `continueTurn({force:true})`. Use when `unknownStatusCount` ≥3 or the worker is `running` with no activity. Returns `{ok, message}`.
|
|
167
|
+
|
|
168
|
+
### pause_goal / resume_goal / retry / clear
|
|
169
|
+
|
|
170
|
+
* `pause_goal` — `active → paused`, `releaseLease`, `abortWorker`. Frees the writer slot.
|
|
171
|
+
* `resume_goal` — `paused → active`, reuses the existing worker session if `sessionStatus` is still `idle`/`busy` (preserves transcript), otherwise creates a new one. Fails with `already active` if another writer is active.
|
|
172
|
+
* `retry` (via `resume` on `blocked`) — `blocked → active`, resets `consecutiveFailures`/`forceFinishRequested`.
|
|
173
|
+
* `clear_goal` — aborts worker and removes `goal` + `runtime` + ledger entry (cannot be undone).
|
|
131
174
|
|
|
132
175
|
## Dashboard Commands
|
|
133
176
|
|
|
@@ -165,13 +208,16 @@ Open the dashboard with `/loop` or <leader>d.
|
|
|
165
208
|
|
|
166
209
|
## Safety Patterns
|
|
167
210
|
|
|
168
|
-
1. **Always call `get_goal` first** — Read the objective before doing any work.
|
|
169
|
-
2. **Progress after durable changes** — Call `report_goal_progress` after file writes or verifications, not after thinking.
|
|
170
|
-
3. **Complete with evidence** — Never call `complete_goal` without concrete proof
|
|
171
|
-
4. **Block for real blockers only** — Don
|
|
172
|
-
5. **Use checks for verification** —
|
|
173
|
-
6. **
|
|
174
|
-
7. **
|
|
211
|
+
1. **Always call `get_goal` first** — Read the full contract (objective + checks + limits) before doing any work. The `COMPLETION REVIEW` and `HOST VERDICT` in steering are authoritative.
|
|
212
|
+
2. **Progress after durable changes** — Call `report_goal_progress` after file writes or verifications (resets `consecutiveFailures`/`noProgressCount`), not after thinking.
|
|
213
|
+
3. **Complete with evidence — host decides** — Never call `complete_goal` without concrete proof. The host will reject it if `checks` fail; you’ll get `Rejection #N` with exact `stderr` and a **free retry** (`budgetTurnCount` not charged for N<3). After 3 rejections the goal is `blocked`.
|
|
214
|
+
4. **Block for real blockers only** — Don’t block for things you can figure out. If objective and `checks` appear contradictory, `block_goal` — don’t silently violate either.
|
|
215
|
+
5. **Use checks for verification — mandatory for writers** — `workspaceWrite:true` goals **require** `checks` (explicit or `defaultChecks`). Checks run from `checkCwd` (writers → project root by default). `["npm test","bun run typecheck"]` is a good default.
|
|
216
|
+
6. **Serialize workspace edits** — Keep `workspaceWrite:true` (the default) for code/repo changes. The engine allows **only one active writer**; a second `start`/`resume`/`retry` fails with `already active`. Use `workspaceWrite:false` explicitly for artifact-only research to allow concurrency.
|
|
217
|
+
7. **Set safety budgets** — `maxTurns` (default 50), `maxNoProgress`, `maxFailures` prevent runaways. On `maxTurns`/`maxNoProgress` the engine injects `FINAL REPORT REQUIRED`; if ignored, it `blocked (force-finish ignored)` after the next idle.
|
|
218
|
+
8. **Compact periodically** — `compactEvery` keeps the worker session’s context manageable.
|
|
219
|
+
9. **Recover stuck workers explicitly** — If `inspect` shows `phase=running` with `unknownStatusCount ≥3` or `lastActivityAt` far in the past, use `nudge_goal` (force re-prompt) or `pause`/`resume`. `send_goal_input` alone does not re-prompt.
|
|
220
|
+
10. **One writer, one check suite** — Don’t run concurrent `start` calls that touch the same files from parallel chats; chain them sequentially or mark the second as `workspaceWrite:false`.
|
|
175
221
|
|
|
176
222
|
## Example: Creating and Monitoring a Goal
|
|
177
223
|
|
|
@@ -179,17 +225,24 @@ Open the dashboard with `/loop` or <leader>d.
|
|
|
179
225
|
```
|
|
180
226
|
User: Write a README for this project and create a changelog.
|
|
181
227
|
|
|
182
|
-
Agent: I'll set up a background goal for this.
|
|
228
|
+
Agent: I'll set up a background goal for this. What's the verification?
|
|
229
|
+
|
|
230
|
+
User: Just that both files exist.
|
|
231
|
+
|
|
232
|
+
Agent: Got it — creating with a contract.
|
|
183
233
|
|
|
184
234
|
loopd_create_goal({
|
|
185
235
|
name: "docs-write",
|
|
186
236
|
objective: "Write a README.md covering: what it is, install, usage, architecture, and examples. Then create CHANGELOG.md with a v1.0.0 entry.",
|
|
237
|
+
agent: "smart-agent",
|
|
187
238
|
checks: ["test -f README.md", "test -f CHANGELOG.md"],
|
|
239
|
+
checkCwd: "/Users/you/project", // explicit for writers; defaults to project root
|
|
240
|
+
workspaceWrite: true, // default true — serialized
|
|
188
241
|
progressFile: ".opencode/loopd/docs-progress.md",
|
|
189
242
|
maxTurns: 20
|
|
190
243
|
})
|
|
191
|
-
|
|
192
|
-
# Goal starts. User can monitor with /loop.
|
|
244
|
+
# → {ok:true, artifactDir: ".opencode/loopd/goals/<id>", defaultsApplied:{...}}
|
|
245
|
+
# Goal starts. User can monitor with /loop. If checks fail, the worker gets HOST VERDICT with exact stderr and a free retry.
|
|
193
246
|
```
|
|
194
247
|
|
|
195
248
|
**Worker session (automatic):**
|