@prevalentware/opencode-goal-plugin 0.1.29 → 0.1.31
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/README.md +58 -5
- package/dist/server.js +580 -42
- package/package.json +2 -1
- package/src/tui.ts +179 -16
package/README.md
CHANGED
|
@@ -38,7 +38,18 @@ Common use cases:
|
|
|
38
38
|
|
|
39
39
|
## Install
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
Choose the instructions that match the CLI you run:
|
|
42
|
+
|
|
43
|
+
| OpenCode version | How to identify it | Instructions |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| OpenCode 1 stable | You run `opencode` and `opencode --version` prints `1.x` | [OpenCode 1](#opencode-1-stable) |
|
|
46
|
+
| OpenCode 2 beta | You run `opencode2` | [OpenCode 2](#opencode-2-beta) |
|
|
47
|
+
|
|
48
|
+
Do not mix the configuration formats. OpenCode 1 uses `plugin` and `tui.json`; OpenCode 2 uses `plugins` and the global `cli.json`.
|
|
49
|
+
|
|
50
|
+
### OpenCode 1 Stable
|
|
51
|
+
|
|
52
|
+
Install for the current project:
|
|
42
53
|
|
|
43
54
|
```bash
|
|
44
55
|
opencode plugin @prevalentware/opencode-goal-plugin
|
|
@@ -52,9 +63,7 @@ opencode plugin -g @prevalentware/opencode-goal-plugin
|
|
|
52
63
|
|
|
53
64
|
OpenCode detects both package entrypoints and writes the plugin into the server and TUI config targets.
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
If you configure it manually, add the package to both config files.
|
|
66
|
+
For manual installation, add the package to both V1 config files.
|
|
58
67
|
|
|
59
68
|
`opencode.json`:
|
|
60
69
|
|
|
@@ -72,9 +81,35 @@ If you configure it manually, add the package to both config files.
|
|
|
72
81
|
}
|
|
73
82
|
```
|
|
74
83
|
|
|
84
|
+
### OpenCode 2 Beta
|
|
85
|
+
|
|
86
|
+
Use this section only when running `opencode2`. Plugin release `0.1.30` and newer supports OpenCode 2 preview `0.0.0-next-17055` while remaining compatible with OpenCode 1.
|
|
87
|
+
|
|
88
|
+
Add the package to both V2 plugin lists:
|
|
89
|
+
|
|
90
|
+
`opencode.json`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"plugins": ["@prevalentware/opencode-goal-plugin"]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`~/.config/opencode/cli.json`:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"plugins": ["@prevalentware/opencode-goal-plugin"]
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
OpenCode 2 does not read the V1 `tui.json` file. The server entrypoint comes from `opencode.json`, while the sidebar and palette integration come from `~/.config/opencode/cli.json`.
|
|
107
|
+
|
|
108
|
+
OpenCode 2 plugin APIs are still beta. This package pins its V2 development contract to the preview version above; later previews may require a compatible plugin update. V2 currently supports the goal command, tools, persistent state, usage accounting, idle continuation, Plan-mode safety, and TUI sidebar/palette integration. Goal-specific compaction context and recovery of already-running child sessions after a plugin restart remain V1-only because the current V2 plugin context does not expose equivalent hooks or history queries.
|
|
109
|
+
|
|
75
110
|
## Options
|
|
76
111
|
|
|
77
|
-
|
|
112
|
+
In OpenCode 1, server options use the package-and-options tuple in `opencode.json`:
|
|
78
113
|
|
|
79
114
|
```json
|
|
80
115
|
{
|
|
@@ -100,6 +135,24 @@ Server options can be configured in `opencode.json`:
|
|
|
100
135
|
}
|
|
101
136
|
```
|
|
102
137
|
|
|
138
|
+
In OpenCode 2, use the plugin object form instead:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"plugins": [
|
|
143
|
+
{
|
|
144
|
+
"package": "@prevalentware/opencode-goal-plugin",
|
|
145
|
+
"options": {
|
|
146
|
+
"auto_continue": true,
|
|
147
|
+
"max_auto_turns": 25,
|
|
148
|
+
"default_token_budget": 200000,
|
|
149
|
+
"restricted_agents": ["plan"]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
103
156
|
Defaults:
|
|
104
157
|
|
|
105
158
|
- `auto_continue`: `true`
|
package/dist/server.js
CHANGED
|
@@ -1043,7 +1043,7 @@ class TaskTracker {
|
|
|
1043
1043
|
snapshotIdleHolds = new Map;
|
|
1044
1044
|
settledSnapshotIdleTasks = new Set;
|
|
1045
1045
|
noteTaskCall(input) {
|
|
1046
|
-
if (typeof input.tool !== "string" || input.tool.toLowerCase()
|
|
1046
|
+
if (typeof input.tool !== "string" || !["task", "subagent"].includes(input.tool.toLowerCase()))
|
|
1047
1047
|
return;
|
|
1048
1048
|
if (typeof input.sessionID !== "string")
|
|
1049
1049
|
return;
|
|
@@ -1051,7 +1051,7 @@ class TaskTracker {
|
|
|
1051
1051
|
this.pendingTaskCalls.set(input.callID, input.sessionID);
|
|
1052
1052
|
}
|
|
1053
1053
|
noteTaskOutput(input, output) {
|
|
1054
|
-
if (typeof input.tool !== "string" || input.tool.toLowerCase()
|
|
1054
|
+
if (typeof input.tool !== "string" || !["task", "subagent"].includes(input.tool.toLowerCase()))
|
|
1055
1055
|
return;
|
|
1056
1056
|
const parentSessionID = typeof input.callID === "string" ? this.pendingTaskCalls.get(input.callID) ?? input.sessionID : input.sessionID;
|
|
1057
1057
|
if (typeof input.callID === "string")
|
|
@@ -1307,6 +1307,66 @@ function getGoalToolResult(goal) {
|
|
|
1307
1307
|
}
|
|
1308
1308
|
return JSON.stringify(result, null, 2);
|
|
1309
1309
|
}
|
|
1310
|
+
async function createGoalFromTool(input, context, services) {
|
|
1311
|
+
const planningOnly = services.isPlanAgent(context.agent);
|
|
1312
|
+
const goal = await createGoal(context.sessionID, input.objective, {
|
|
1313
|
+
tokenBudget: input.token_budget ?? services.options.default_token_budget ?? null,
|
|
1314
|
+
maxAutoTurns: input.max_auto_turns ?? null,
|
|
1315
|
+
maxDurationSeconds: input.max_duration_seconds ?? services.options.max_goal_duration_seconds ?? null,
|
|
1316
|
+
noProgressTokenThreshold: services.options.no_progress_token_threshold ?? null,
|
|
1317
|
+
maxNoProgressTurns: services.options.max_no_progress_turns ?? null,
|
|
1318
|
+
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1319
|
+
initialStatus: planningOnly ? "paused" : "active"
|
|
1320
|
+
});
|
|
1321
|
+
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1322
|
+
}
|
|
1323
|
+
async function updateGoalObjectiveFromTool(input, context, services) {
|
|
1324
|
+
const requested = input.status ?? "active";
|
|
1325
|
+
const planningOnly = requested === "active" && services.isPlanAgent(context.agent);
|
|
1326
|
+
const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, {
|
|
1327
|
+
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1328
|
+
planModePause: planningOnly
|
|
1329
|
+
});
|
|
1330
|
+
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1331
|
+
}
|
|
1332
|
+
async function closeGoalFromTool(input, context) {
|
|
1333
|
+
if (input.status === "complete") {
|
|
1334
|
+
const goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
1335
|
+
const budget = goal2.tokenBudget == null ? "" : ` Token usage: ${goal2.tokensUsed}/${goal2.tokenBudget}.`;
|
|
1336
|
+
const report2 = `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds.${budget} Evidence: ${goal2.completionEvidence}.`;
|
|
1337
|
+
return JSON.stringify({ goal: goal2, completion_report: report2 }, null, 2);
|
|
1338
|
+
}
|
|
1339
|
+
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
1340
|
+
const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
1341
|
+
return JSON.stringify({ goal, unmet_report: report }, null, 2);
|
|
1342
|
+
}
|
|
1343
|
+
async function updateGoalStatusFromTool(input, context, services) {
|
|
1344
|
+
if (input.status === "active" && services.isPlanAgent(context.agent)) {
|
|
1345
|
+
throw new Error("cannot resume the goal while the session is in Plan mode; ask the user to switch to Build mode and resume the goal from there");
|
|
1346
|
+
}
|
|
1347
|
+
const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null);
|
|
1348
|
+
return JSON.stringify({ goal }, null, 2);
|
|
1349
|
+
}
|
|
1350
|
+
function v2ObjectSchema(properties, required = []) {
|
|
1351
|
+
return {
|
|
1352
|
+
type: "object",
|
|
1353
|
+
properties,
|
|
1354
|
+
required,
|
|
1355
|
+
additionalProperties: false
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
function textFromToolResult(result) {
|
|
1359
|
+
if (typeof result.output === "string")
|
|
1360
|
+
return result.output;
|
|
1361
|
+
if (typeof result.content === "string")
|
|
1362
|
+
return result.content;
|
|
1363
|
+
if (Array.isArray(result.content)) {
|
|
1364
|
+
const text = result.content.map(textFromPart).filter(Boolean).join(`
|
|
1365
|
+
`).trim();
|
|
1366
|
+
return text || undefined;
|
|
1367
|
+
}
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1310
1370
|
var server = async ({ client }, options) => {
|
|
1311
1371
|
const autoContinue = options?.auto_continue ?? true;
|
|
1312
1372
|
const deferWhileTasksActive = options?.defer_while_tasks_active ?? true;
|
|
@@ -1323,19 +1383,7 @@ var server = async ({ client }, options) => {
|
|
|
1323
1383
|
const busySessions = new Set;
|
|
1324
1384
|
const planAgents = restrictedAgentSet(options);
|
|
1325
1385
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1326
|
-
|
|
1327
|
-
const planningOnly = isPlanAgent(context.agent);
|
|
1328
|
-
const goal = await createGoal(context.sessionID, input.objective, {
|
|
1329
|
-
tokenBudget: input.token_budget ?? options?.default_token_budget ?? null,
|
|
1330
|
-
maxAutoTurns: input.max_auto_turns ?? null,
|
|
1331
|
-
maxDurationSeconds: input.max_duration_seconds ?? options?.max_goal_duration_seconds ?? null,
|
|
1332
|
-
noProgressTokenThreshold: options?.no_progress_token_threshold ?? null,
|
|
1333
|
-
maxNoProgressTurns: options?.max_no_progress_turns ?? null,
|
|
1334
|
-
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1335
|
-
initialStatus: planningOnly ? "paused" : "active"
|
|
1336
|
-
});
|
|
1337
|
-
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1338
|
-
}
|
|
1386
|
+
const goalServices = { options: options ?? {}, isPlanAgent };
|
|
1339
1387
|
async function taskBlockStatus(sessionID) {
|
|
1340
1388
|
if (!deferWhileTasksActive)
|
|
1341
1389
|
return false;
|
|
@@ -1519,7 +1567,7 @@ var server = async ({ client }, options) => {
|
|
|
1519
1567
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
1520
1568
|
},
|
|
1521
1569
|
async execute(args, context) {
|
|
1522
|
-
return createGoalFromTool(args, context);
|
|
1570
|
+
return createGoalFromTool(args, context, goalServices);
|
|
1523
1571
|
}
|
|
1524
1572
|
},
|
|
1525
1573
|
set_goal: {
|
|
@@ -1531,7 +1579,7 @@ var server = async ({ client }, options) => {
|
|
|
1531
1579
|
max_duration_seconds: z.number().int().positive().nullable().optional().describe("Optional per-goal duration limit.")
|
|
1532
1580
|
},
|
|
1533
1581
|
async execute(args, context) {
|
|
1534
|
-
return createGoalFromTool(args, context);
|
|
1582
|
+
return createGoalFromTool(args, context, goalServices);
|
|
1535
1583
|
}
|
|
1536
1584
|
},
|
|
1537
1585
|
update_goal_objective: {
|
|
@@ -1541,14 +1589,7 @@ var server = async ({ client }, options) => {
|
|
|
1541
1589
|
status: z.enum(["active", "paused"]).optional().describe("Whether the edited goal should be active or paused.")
|
|
1542
1590
|
},
|
|
1543
1591
|
async execute(args, context) {
|
|
1544
|
-
|
|
1545
|
-
const requested = input.status ?? "active";
|
|
1546
|
-
const planningOnly = requested === "active" && isPlanAgent(context.agent);
|
|
1547
|
-
const goal = await updateGoalObjective(context.sessionID, input.objective, planningOnly ? "paused" : requested, {
|
|
1548
|
-
agent: typeof context.agent === "string" ? context.agent : null,
|
|
1549
|
-
planModePause: planningOnly
|
|
1550
|
-
});
|
|
1551
|
-
return JSON.stringify(planningOnly ? { goal, plan_mode_notice: PLAN_MODE_CREATE_NOTICE } : { goal }, null, 2);
|
|
1592
|
+
return updateGoalObjectiveFromTool(args, context, goalServices);
|
|
1552
1593
|
}
|
|
1553
1594
|
},
|
|
1554
1595
|
update_goal: {
|
|
@@ -1559,16 +1600,7 @@ var server = async ({ client }, options) => {
|
|
|
1559
1600
|
blocker: z.string().min(1).max(4000).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.")
|
|
1560
1601
|
},
|
|
1561
1602
|
async execute(args, context) {
|
|
1562
|
-
|
|
1563
|
-
if (input.status === "complete") {
|
|
1564
|
-
const goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
1565
|
-
const budget = goal2.tokenBudget == null ? "" : ` Token usage: ${goal2.tokensUsed}/${goal2.tokenBudget}.`;
|
|
1566
|
-
const report2 = `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds.${budget} Evidence: ${goal2.completionEvidence}.`;
|
|
1567
|
-
return JSON.stringify({ goal: goal2, completion_report: report2 }, null, 2);
|
|
1568
|
-
}
|
|
1569
|
-
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
1570
|
-
const report = `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
1571
|
-
return JSON.stringify({ goal, unmet_report: report }, null, 2);
|
|
1603
|
+
return closeGoalFromTool(args, context);
|
|
1572
1604
|
}
|
|
1573
1605
|
},
|
|
1574
1606
|
update_goal_status: {
|
|
@@ -1577,12 +1609,7 @@ var server = async ({ client }, options) => {
|
|
|
1577
1609
|
status: z.enum(["active", "paused"]).describe("active resumes a goal; paused pauses it without clearing it.")
|
|
1578
1610
|
},
|
|
1579
1611
|
async execute(args, context) {
|
|
1580
|
-
|
|
1581
|
-
if (input.status === "active" && isPlanAgent(context.agent)) {
|
|
1582
|
-
throw new Error("cannot resume the goal while the session is in Plan mode; ask the user to switch to Build mode and resume the goal from there");
|
|
1583
|
-
}
|
|
1584
|
-
const goal = await setGoalStatus(context.sessionID, input.status, typeof context.agent === "string" ? context.agent : null);
|
|
1585
|
-
return JSON.stringify({ goal }, null, 2);
|
|
1612
|
+
return updateGoalStatusFromTool(args, context, goalServices);
|
|
1586
1613
|
}
|
|
1587
1614
|
},
|
|
1588
1615
|
clear_goal: {
|
|
@@ -1681,9 +1708,520 @@ var server = async ({ client }, options) => {
|
|
|
1681
1708
|
}
|
|
1682
1709
|
};
|
|
1683
1710
|
};
|
|
1711
|
+
function v2ErrorLog(message, error) {
|
|
1712
|
+
try {
|
|
1713
|
+
console.error(`[opencode-goal-plugin] ${message}:`, error instanceof Error ? error.message : String(error));
|
|
1714
|
+
} catch {}
|
|
1715
|
+
}
|
|
1716
|
+
async function setupV2(context) {
|
|
1717
|
+
const options = context.options ?? {};
|
|
1718
|
+
const autoContinue = options.auto_continue ?? true;
|
|
1719
|
+
const deferWhileTasksActive = options.defer_while_tasks_active ?? true;
|
|
1720
|
+
const maxAutoTurns = positiveIntegerOrNull2(options.max_auto_turns) ?? DEFAULT_MAX_AUTO_TURNS;
|
|
1721
|
+
const minInterval = positiveIntegerOrNull2(options.min_continue_interval_seconds) ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
|
|
1722
|
+
const maxTurnTimeMs = timeoutMillisecondsFromSeconds(options.max_turn_time);
|
|
1723
|
+
const maxPromptFailures = positiveIntegerOrNull2(options.max_prompt_failures) ?? DEFAULT_MAX_PROMPT_FAILURES;
|
|
1724
|
+
const registerCommand = options.register_command ?? true;
|
|
1725
|
+
const commandName = commandNameFromOptions(options);
|
|
1726
|
+
const taskTracker = new TaskTracker;
|
|
1727
|
+
const taskDeferredSessions = new Set;
|
|
1728
|
+
const scheduledContinuations = new Map;
|
|
1729
|
+
const turnWatchdogs = new Map;
|
|
1730
|
+
const busySessions = new Set;
|
|
1731
|
+
const planAgents = restrictedAgentSet(options);
|
|
1732
|
+
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
1733
|
+
const goalServices = { options, isPlanAgent };
|
|
1734
|
+
const activeContinuationsV2 = new Set;
|
|
1735
|
+
const latestStepBySession = new Map;
|
|
1736
|
+
const stepTextBuffers = new Map;
|
|
1737
|
+
const stepTokenSums = new Map;
|
|
1738
|
+
const registrations = [];
|
|
1739
|
+
function stepKey(sessionID, messageID2) {
|
|
1740
|
+
return `${sessionID}\x00${messageID2}`;
|
|
1741
|
+
}
|
|
1742
|
+
async function sendContinuation2(sessionID, prompt, agent) {
|
|
1743
|
+
await context.session.prompt({
|
|
1744
|
+
sessionID,
|
|
1745
|
+
text: prompt,
|
|
1746
|
+
...agent ? { agents: [{ name: agent }] } : {}
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
function taskBlockStatus(sessionID) {
|
|
1750
|
+
if (!deferWhileTasksActive)
|
|
1751
|
+
return false;
|
|
1752
|
+
return {
|
|
1753
|
+
blocked: taskTracker.hasBlockingTasks(sessionID),
|
|
1754
|
+
retryAt: taskTracker.nextSnapshotIdleRetryAt(sessionID)
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
function clearTurnWatchdog(sessionID) {
|
|
1758
|
+
const watchdog = turnWatchdogs.get(sessionID);
|
|
1759
|
+
if (!watchdog)
|
|
1760
|
+
return;
|
|
1761
|
+
clearTimeout(watchdog.timer);
|
|
1762
|
+
turnWatchdogs.delete(sessionID);
|
|
1763
|
+
}
|
|
1764
|
+
function armTurnWatchdog(sessionID) {
|
|
1765
|
+
if (maxTurnTimeMs == null)
|
|
1766
|
+
return;
|
|
1767
|
+
clearTurnWatchdog(sessionID);
|
|
1768
|
+
const watchdog = {
|
|
1769
|
+
timer: setTimeout(() => void runTurnWatchdog(sessionID, watchdog), maxTurnTimeMs)
|
|
1770
|
+
};
|
|
1771
|
+
const maybeUnref = watchdog.timer;
|
|
1772
|
+
if (typeof maybeUnref.unref === "function")
|
|
1773
|
+
maybeUnref.unref();
|
|
1774
|
+
turnWatchdogs.set(sessionID, watchdog);
|
|
1775
|
+
}
|
|
1776
|
+
async function runTurnWatchdog(sessionID, watchdog) {
|
|
1777
|
+
let claimedContinuation = false;
|
|
1778
|
+
try {
|
|
1779
|
+
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
|
|
1780
|
+
return;
|
|
1781
|
+
const goal = await getGoal(sessionID);
|
|
1782
|
+
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
|
|
1783
|
+
return;
|
|
1784
|
+
if (goal?.status !== "active" || isPlanAgent(goal.lastPromptAgent))
|
|
1785
|
+
return;
|
|
1786
|
+
const latestStep = latestStepBySession.get(sessionID);
|
|
1787
|
+
if (isPlanAgent(latestStep?.agent))
|
|
1788
|
+
return;
|
|
1789
|
+
const taskStatus = taskBlockStatus(sessionID);
|
|
1790
|
+
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
|
|
1791
|
+
return;
|
|
1792
|
+
if (taskStatus && taskStatus.blocked)
|
|
1793
|
+
return;
|
|
1794
|
+
const current = await getGoal(sessionID);
|
|
1795
|
+
if (turnWatchdogs.get(sessionID) !== watchdog || !busySessions.has(sessionID))
|
|
1796
|
+
return;
|
|
1797
|
+
if (current?.status !== "active" || isPlanAgent(current.lastPromptAgent) || activeContinuationsV2.has(sessionID))
|
|
1798
|
+
return;
|
|
1799
|
+
turnWatchdogs.delete(sessionID);
|
|
1800
|
+
activeContinuationsV2.add(sessionID);
|
|
1801
|
+
claimedContinuation = true;
|
|
1802
|
+
await sendContinuation2(sessionID, continuationPrompt(current), current.lastPromptAgent ?? latestStep?.agent ?? null);
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
v2ErrorLog("Turn watchdog retry failed", error);
|
|
1805
|
+
} finally {
|
|
1806
|
+
if (claimedContinuation)
|
|
1807
|
+
activeContinuationsV2.delete(sessionID);
|
|
1808
|
+
if (turnWatchdogs.get(sessionID) === watchdog)
|
|
1809
|
+
turnWatchdogs.delete(sessionID);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function scheduleSettledContinuation(sessionID, delayMs = TASK_SETTLE_DELAY_MS) {
|
|
1813
|
+
if (scheduledContinuations.has(sessionID))
|
|
1814
|
+
return;
|
|
1815
|
+
const timer = setTimeout(() => {
|
|
1816
|
+
scheduledContinuations.delete(sessionID);
|
|
1817
|
+
runAutoContinue(sessionID, true);
|
|
1818
|
+
}, Math.max(0, delayMs));
|
|
1819
|
+
const maybeUnref = timer;
|
|
1820
|
+
if (typeof maybeUnref.unref === "function")
|
|
1821
|
+
maybeUnref.unref();
|
|
1822
|
+
scheduledContinuations.set(sessionID, timer);
|
|
1823
|
+
}
|
|
1824
|
+
async function runAutoContinue(sessionID, fromTaskDeferral = false) {
|
|
1825
|
+
if (busySessions.has(sessionID))
|
|
1826
|
+
return;
|
|
1827
|
+
if (activeContinuationsV2.has(sessionID))
|
|
1828
|
+
return;
|
|
1829
|
+
activeContinuationsV2.add(sessionID);
|
|
1830
|
+
try {
|
|
1831
|
+
const latestStep = latestStepBySession.get(sessionID);
|
|
1832
|
+
if (latestStep?.messageID) {
|
|
1833
|
+
taskTracker.observeAssistantMessage(sessionID, { info: { id: latestStep.messageID, role: "assistant" } });
|
|
1834
|
+
}
|
|
1835
|
+
const taskStatus = taskBlockStatus(sessionID);
|
|
1836
|
+
if (taskStatus && taskStatus.blocked) {
|
|
1837
|
+
taskDeferredSessions.add(sessionID);
|
|
1838
|
+
if (taskStatus.retryAt != null)
|
|
1839
|
+
scheduleSettledContinuation(sessionID, taskStatus.retryAt - Date.now());
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
if (busySessions.has(sessionID))
|
|
1843
|
+
return;
|
|
1844
|
+
if (latestStep) {
|
|
1845
|
+
await recordAssistantProgress(sessionID, {
|
|
1846
|
+
messageID: latestStep.messageID,
|
|
1847
|
+
text: latestStep.text,
|
|
1848
|
+
outputTokens: latestStep.outputTokens,
|
|
1849
|
+
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
1850
|
+
maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns),
|
|
1851
|
+
evaluateContinuation: true
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
const current = await getGoal(sessionID);
|
|
1855
|
+
if (!current)
|
|
1856
|
+
return;
|
|
1857
|
+
const latestTurnAgent = latestStep?.agent;
|
|
1858
|
+
if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
|
|
1859
|
+
if (current.status === "active")
|
|
1860
|
+
await pauseGoalForPlanMode(sessionID);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
if (busySessions.has(sessionID))
|
|
1864
|
+
return;
|
|
1865
|
+
if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) {
|
|
1866
|
+
scheduleSettledContinuation(sessionID);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
taskDeferredSessions.delete(sessionID);
|
|
1870
|
+
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
1871
|
+
if (!goal)
|
|
1872
|
+
return;
|
|
1873
|
+
await sendContinuation2(sessionID, goal.status === "active" ? continuationPrompt(goal) : limitPrompt(goal), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
1874
|
+
await recordContinuationResult(sessionID, "success", maxPromptFailures);
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
await recordContinuationResult(sessionID, "failure", maxPromptFailures);
|
|
1877
|
+
v2ErrorLog("Auto-continue failed", error);
|
|
1878
|
+
} finally {
|
|
1879
|
+
activeContinuationsV2.delete(sessionID);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
async function handleV2Event(event) {
|
|
1883
|
+
const data = event.data;
|
|
1884
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
1885
|
+
switch (event.type) {
|
|
1886
|
+
case "session.created": {
|
|
1887
|
+
const parentID = data.parentID;
|
|
1888
|
+
if (sessionID && typeof parentID === "string") {
|
|
1889
|
+
taskTracker.observeSessionCreated({ properties: { info: { id: sessionID, parentID } } });
|
|
1890
|
+
}
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
case "session.status": {
|
|
1894
|
+
const status = data.status;
|
|
1895
|
+
if (sessionID && isRecord(status) && typeof status.type === "string") {
|
|
1896
|
+
if (status.type === "busy")
|
|
1897
|
+
busySessions.add(sessionID);
|
|
1898
|
+
if (status.type === "busy")
|
|
1899
|
+
armTurnWatchdog(sessionID);
|
|
1900
|
+
if (status.type === "idle") {
|
|
1901
|
+
busySessions.delete(sessionID);
|
|
1902
|
+
clearTurnWatchdog(sessionID);
|
|
1903
|
+
}
|
|
1904
|
+
if (status.type === "retry")
|
|
1905
|
+
clearTurnWatchdog(sessionID);
|
|
1906
|
+
taskTracker.observeSessionStatus(sessionID, status.type);
|
|
1907
|
+
}
|
|
1908
|
+
if (autoContinue && sessionID && isRecord(status) && status.type === "idle") {
|
|
1909
|
+
await runAutoContinue(sessionID);
|
|
1910
|
+
}
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
case "session.idle": {
|
|
1914
|
+
if (sessionID) {
|
|
1915
|
+
busySessions.delete(sessionID);
|
|
1916
|
+
clearTurnWatchdog(sessionID);
|
|
1917
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
1918
|
+
}
|
|
1919
|
+
if (autoContinue && sessionID)
|
|
1920
|
+
await runAutoContinue(sessionID);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
case "session.deleted": {
|
|
1924
|
+
if (!sessionID)
|
|
1925
|
+
return;
|
|
1926
|
+
busySessions.delete(sessionID);
|
|
1927
|
+
clearTurnWatchdog(sessionID);
|
|
1928
|
+
const scheduled = scheduledContinuations.get(sessionID);
|
|
1929
|
+
if (scheduled)
|
|
1930
|
+
clearTimeout(scheduled);
|
|
1931
|
+
scheduledContinuations.delete(sessionID);
|
|
1932
|
+
taskDeferredSessions.delete(sessionID);
|
|
1933
|
+
taskTracker.observeSessionDeleted(sessionID);
|
|
1934
|
+
latestStepBySession.delete(sessionID);
|
|
1935
|
+
stepTokenSums.delete(sessionID);
|
|
1936
|
+
for (const key of [...stepTextBuffers.keys()]) {
|
|
1937
|
+
if (key.startsWith(`${sessionID}\x00`))
|
|
1938
|
+
stepTextBuffers.delete(key);
|
|
1939
|
+
}
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
case "session.agent.selected": {
|
|
1943
|
+
if (sessionID && typeof data.agent === "string")
|
|
1944
|
+
await recordPromptAgent(sessionID, data.agent);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
case "session.step.started": {
|
|
1948
|
+
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
1949
|
+
return;
|
|
1950
|
+
const messageID2 = data.assistantMessageID;
|
|
1951
|
+
const agent = typeof data.agent === "string" ? data.agent : undefined;
|
|
1952
|
+
if (agent)
|
|
1953
|
+
await recordPromptAgent(sessionID, agent);
|
|
1954
|
+
taskTracker.observeAssistantMessage(sessionID, {
|
|
1955
|
+
info: { id: messageID2, role: "assistant", time: { completed: event.created } }
|
|
1956
|
+
});
|
|
1957
|
+
if (!stepTextBuffers.has(stepKey(sessionID, messageID2)))
|
|
1958
|
+
stepTextBuffers.set(stepKey(sessionID, messageID2), "");
|
|
1959
|
+
latestStepBySession.set(sessionID, { messageID: messageID2, agent, text: "", outputTokens: null });
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
case "session.text.delta": {
|
|
1963
|
+
if (sessionID && typeof data.assistantMessageID === "string" && typeof data.delta === "string") {
|
|
1964
|
+
const key = stepKey(sessionID, data.assistantMessageID);
|
|
1965
|
+
stepTextBuffers.set(key, (stepTextBuffers.get(key) ?? "") + data.delta);
|
|
1966
|
+
}
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
case "session.text.ended": {
|
|
1970
|
+
if (sessionID && typeof data.assistantMessageID === "string" && typeof data.text === "string") {
|
|
1971
|
+
stepTextBuffers.set(stepKey(sessionID, data.assistantMessageID), data.text);
|
|
1972
|
+
}
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
case "session.step.ended": {
|
|
1976
|
+
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
1977
|
+
return;
|
|
1978
|
+
const messageID2 = data.assistantMessageID;
|
|
1979
|
+
const tokens = tokensFromRecord(data.tokens);
|
|
1980
|
+
if (typeof tokens === "number") {
|
|
1981
|
+
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
1982
|
+
stepTokenSums.set(sessionID, sum);
|
|
1983
|
+
await accountUsage(sessionID, sum);
|
|
1984
|
+
}
|
|
1985
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID2)) ?? "";
|
|
1986
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID2));
|
|
1987
|
+
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
1988
|
+
await recordAssistantProgress(sessionID, {
|
|
1989
|
+
messageID: messageID2,
|
|
1990
|
+
text,
|
|
1991
|
+
outputTokens,
|
|
1992
|
+
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
1993
|
+
maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns)
|
|
1994
|
+
});
|
|
1995
|
+
latestStepBySession.set(sessionID, {
|
|
1996
|
+
messageID: messageID2,
|
|
1997
|
+
agent: latestStepBySession.get(sessionID)?.agent,
|
|
1998
|
+
text,
|
|
1999
|
+
outputTokens
|
|
2000
|
+
});
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
case "session.step.failed": {
|
|
2004
|
+
if (!sessionID || typeof data.assistantMessageID !== "string")
|
|
2005
|
+
return;
|
|
2006
|
+
const messageID2 = data.assistantMessageID;
|
|
2007
|
+
const tokens = tokensFromRecord(data.tokens);
|
|
2008
|
+
if (typeof tokens === "number") {
|
|
2009
|
+
const sum = (stepTokenSums.get(sessionID) ?? 0) + tokens;
|
|
2010
|
+
stepTokenSums.set(sessionID, sum);
|
|
2011
|
+
await accountUsage(sessionID, sum);
|
|
2012
|
+
}
|
|
2013
|
+
const text = stepTextBuffers.get(stepKey(sessionID, messageID2)) ?? "";
|
|
2014
|
+
stepTextBuffers.delete(stepKey(sessionID, messageID2));
|
|
2015
|
+
const outputTokens = outputTokensFromRecord(data.tokens) ?? null;
|
|
2016
|
+
await recordAssistantProgress(sessionID, {
|
|
2017
|
+
messageID: messageID2,
|
|
2018
|
+
text,
|
|
2019
|
+
outputTokens,
|
|
2020
|
+
noProgressTokenThreshold: positiveIntegerOrNull2(options.no_progress_token_threshold),
|
|
2021
|
+
maxNoProgressTurns: positiveIntegerOrNull2(options.max_no_progress_turns)
|
|
2022
|
+
});
|
|
2023
|
+
latestStepBySession.set(sessionID, {
|
|
2024
|
+
messageID: messageID2,
|
|
2025
|
+
agent: latestStepBySession.get(sessionID)?.agent,
|
|
2026
|
+
text,
|
|
2027
|
+
outputTokens
|
|
2028
|
+
});
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
case "session.usage.updated": {
|
|
2032
|
+
if (!sessionID)
|
|
2033
|
+
return;
|
|
2034
|
+
const tokens = tokensFromRecord(data.tokens);
|
|
2035
|
+
if (typeof tokens === "number")
|
|
2036
|
+
await accountUsage(sessionID, tokens);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
if (registerCommand) {
|
|
2042
|
+
registrations.push(await context.command.transform((draft) => {
|
|
2043
|
+
if (draft.get(commandName))
|
|
2044
|
+
return;
|
|
2045
|
+
draft.update(commandName, (command) => {
|
|
2046
|
+
command.description = "Set or view the long-running session goal";
|
|
2047
|
+
command.template = goalCommandTemplate(commandName);
|
|
2048
|
+
});
|
|
2049
|
+
}));
|
|
2050
|
+
}
|
|
2051
|
+
registrations.push(await context.tool.transform((draft) => {
|
|
2052
|
+
for (const tool of goalToolsV2(goalServices))
|
|
2053
|
+
draft.add(tool);
|
|
2054
|
+
}));
|
|
2055
|
+
registrations.push(await context.tool.hook("execute.before", (input) => {
|
|
2056
|
+
taskTracker.noteTaskCall({ tool: input.tool, sessionID: input.sessionID, callID: input.id });
|
|
2057
|
+
}));
|
|
2058
|
+
registrations.push(await context.tool.hook("execute.after", (input) => {
|
|
2059
|
+
if (input.status !== "completed")
|
|
2060
|
+
return;
|
|
2061
|
+
taskTracker.noteTaskOutput({ tool: input.tool, sessionID: input.sessionID, callID: input.id }, { output: textFromToolResult(input.result) });
|
|
2062
|
+
}));
|
|
2063
|
+
registrations.push(await context.session.hook("context", (sessionContext) => {
|
|
2064
|
+
const reminder = systemReminder();
|
|
2065
|
+
if (sessionContext.system.some((part) => part.type === "text" && part.text.includes(reminder)))
|
|
2066
|
+
return;
|
|
2067
|
+
sessionContext.system.push({ type: "text", text: reminder });
|
|
2068
|
+
}));
|
|
2069
|
+
const abortController = new AbortController;
|
|
2070
|
+
let eventIterator;
|
|
2071
|
+
const consumer = (async () => {
|
|
2072
|
+
const subscription = context.event.subscribe({ signal: abortController.signal });
|
|
2073
|
+
const iterator = subscription[Symbol.asyncIterator]();
|
|
2074
|
+
eventIterator = iterator;
|
|
2075
|
+
try {
|
|
2076
|
+
while (true) {
|
|
2077
|
+
const { done, value } = await iterator.next();
|
|
2078
|
+
if (done)
|
|
2079
|
+
break;
|
|
2080
|
+
await handleV2Event(value);
|
|
2081
|
+
}
|
|
2082
|
+
} catch (error) {
|
|
2083
|
+
if (!abortController.signal.aborted)
|
|
2084
|
+
v2ErrorLog("V2 event consumer stopped", error);
|
|
2085
|
+
}
|
|
2086
|
+
})();
|
|
2087
|
+
return async () => {
|
|
2088
|
+
abortController.abort();
|
|
2089
|
+
for (const timer of scheduledContinuations.values())
|
|
2090
|
+
clearTimeout(timer);
|
|
2091
|
+
scheduledContinuations.clear();
|
|
2092
|
+
for (const watchdog of turnWatchdogs.values())
|
|
2093
|
+
clearTimeout(watchdog.timer);
|
|
2094
|
+
turnWatchdogs.clear();
|
|
2095
|
+
activeContinuationsV2.clear();
|
|
2096
|
+
for (const registration of registrations)
|
|
2097
|
+
await registration.dispose();
|
|
2098
|
+
const termination = Promise.allSettled([consumer, eventIterator?.return?.()]);
|
|
2099
|
+
await Promise.race([termination, new Promise((resolve) => setTimeout(resolve, 2000))]);
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
function goalToolsV2(services) {
|
|
2103
|
+
return [
|
|
2104
|
+
{
|
|
2105
|
+
name: "get_goal",
|
|
2106
|
+
description: "Get the current goal for this OpenCode session, including status, observed token usage, elapsed-time usage, budgets, checkpoints, and history.",
|
|
2107
|
+
input: v2ObjectSchema({}),
|
|
2108
|
+
options: { codemode: false },
|
|
2109
|
+
execute: async (_args, toolContext) => ({
|
|
2110
|
+
content: await getGoalToolResult(await getGoal(toolContext.sessionID))
|
|
2111
|
+
})
|
|
2112
|
+
},
|
|
2113
|
+
{
|
|
2114
|
+
name: "get_goal_history",
|
|
2115
|
+
description: "Get the current goal lifecycle history and recent checkpoints for this OpenCode session.",
|
|
2116
|
+
input: v2ObjectSchema({}),
|
|
2117
|
+
options: { codemode: false },
|
|
2118
|
+
execute: async (_args, toolContext) => {
|
|
2119
|
+
const goal = await getGoal(toolContext.sessionID);
|
|
2120
|
+
return { content: JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2) };
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
{
|
|
2124
|
+
name: "create_goal",
|
|
2125
|
+
description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
2126
|
+
input: v2ObjectSchema({
|
|
2127
|
+
objective: { type: "string", minLength: 1, maxLength: 4000, description: "The concrete objective to start pursuing." },
|
|
2128
|
+
token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." },
|
|
2129
|
+
max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." },
|
|
2130
|
+
max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }
|
|
2131
|
+
}, ["objective"]),
|
|
2132
|
+
options: { codemode: false },
|
|
2133
|
+
execute: async (args, toolContext) => ({
|
|
2134
|
+
content: await createGoalFromTool(args, toolContext, services)
|
|
2135
|
+
})
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
name: "set_goal",
|
|
2139
|
+
description: "Set a new goal when the user explicitly asks the agent to formulate and set its own goal. The model should write the objective itself based on the user's explicit request. Fails if a non-complete goal exists. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.",
|
|
2140
|
+
input: v2ObjectSchema({
|
|
2141
|
+
objective: {
|
|
2142
|
+
type: "string",
|
|
2143
|
+
minLength: 1,
|
|
2144
|
+
maxLength: 4000,
|
|
2145
|
+
description: "The model-formulated concrete objective to start pursuing."
|
|
2146
|
+
},
|
|
2147
|
+
token_budget: { type: ["integer", "null"], minimum: 1, description: "Optional positive token budget." },
|
|
2148
|
+
max_auto_turns: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal auto-continue limit." },
|
|
2149
|
+
max_duration_seconds: { type: ["integer", "null"], minimum: 1, description: "Optional per-goal duration limit." }
|
|
2150
|
+
}, ["objective"]),
|
|
2151
|
+
options: { codemode: false },
|
|
2152
|
+
execute: async (args, toolContext) => ({
|
|
2153
|
+
content: await createGoalFromTool(args, toolContext, services)
|
|
2154
|
+
})
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
name: "update_goal_objective",
|
|
2158
|
+
description: "Edit the current OpenCode goal objective when the user explicitly asks to edit or replace it.",
|
|
2159
|
+
input: v2ObjectSchema({
|
|
2160
|
+
objective: { type: "string", minLength: 1, maxLength: 4000, description: "The updated concrete objective." },
|
|
2161
|
+
status: { type: "string", enum: ["active", "paused"], description: "Whether the edited goal should be active or paused." }
|
|
2162
|
+
}, ["objective"]),
|
|
2163
|
+
options: { codemode: false },
|
|
2164
|
+
execute: async (args, toolContext) => ({
|
|
2165
|
+
content: await updateGoalObjectiveFromTool(args, toolContext, services)
|
|
2166
|
+
})
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
name: "update_goal",
|
|
2170
|
+
description: "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because work is stopping.",
|
|
2171
|
+
input: v2ObjectSchema({
|
|
2172
|
+
status: {
|
|
2173
|
+
type: "string",
|
|
2174
|
+
enum: ["complete", "unmet"],
|
|
2175
|
+
description: "Required. complete means achieved; unmet means blocked or impossible."
|
|
2176
|
+
},
|
|
2177
|
+
evidence: {
|
|
2178
|
+
type: "string",
|
|
2179
|
+
minLength: 1,
|
|
2180
|
+
maxLength: 4000,
|
|
2181
|
+
description: "Required when status is complete. Summarize the concrete evidence verified."
|
|
2182
|
+
},
|
|
2183
|
+
blocker: {
|
|
2184
|
+
type: "string",
|
|
2185
|
+
minLength: 1,
|
|
2186
|
+
maxLength: 4000,
|
|
2187
|
+
description: "Required when status is unmet. Explain the concrete blocker or impossibility."
|
|
2188
|
+
}
|
|
2189
|
+
}, ["status"]),
|
|
2190
|
+
options: { codemode: false },
|
|
2191
|
+
execute: async (args, toolContext) => ({
|
|
2192
|
+
content: await closeGoalFromTool(args, toolContext)
|
|
2193
|
+
})
|
|
2194
|
+
},
|
|
2195
|
+
{
|
|
2196
|
+
name: "update_goal_status",
|
|
2197
|
+
description: "Pause or resume the current OpenCode goal when the user explicitly asks to pause or resume it. Resuming is not allowed while the session is in Plan mode; the user must switch to Build mode first.",
|
|
2198
|
+
input: v2ObjectSchema({
|
|
2199
|
+
status: {
|
|
2200
|
+
type: "string",
|
|
2201
|
+
enum: ["active", "paused"],
|
|
2202
|
+
description: "active resumes a goal; paused pauses it without clearing it."
|
|
2203
|
+
}
|
|
2204
|
+
}, ["status"]),
|
|
2205
|
+
options: { codemode: false },
|
|
2206
|
+
execute: async (args, toolContext) => ({
|
|
2207
|
+
content: await updateGoalStatusFromTool(args, toolContext, services)
|
|
2208
|
+
})
|
|
2209
|
+
},
|
|
2210
|
+
{
|
|
2211
|
+
name: "clear_goal",
|
|
2212
|
+
description: "Clear the current OpenCode goal for this session when the user explicitly asks to clear it.",
|
|
2213
|
+
input: v2ObjectSchema({}),
|
|
2214
|
+
options: { codemode: false },
|
|
2215
|
+
execute: async (_args, toolContext) => ({
|
|
2216
|
+
content: JSON.stringify({ cleared: await clearGoal(toolContext.sessionID) }, null, 2)
|
|
2217
|
+
})
|
|
2218
|
+
}
|
|
2219
|
+
];
|
|
2220
|
+
}
|
|
1684
2221
|
var server_default = {
|
|
1685
2222
|
id: "local.goal-mode.server",
|
|
1686
|
-
server
|
|
2223
|
+
server,
|
|
2224
|
+
setup: setupV2
|
|
1687
2225
|
};
|
|
1688
2226
|
export {
|
|
1689
2227
|
server_default as default
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prevalentware/opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@eslint/js": "^10.0.1",
|
|
64
|
+
"@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-next-17055",
|
|
64
65
|
"@opentui/core": "^0.4.0",
|
|
65
66
|
"@types/bun": "^1.3.13",
|
|
66
67
|
"eslint": "^10.3.0",
|
package/src/tui.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { TuiCommand, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import type { Plugin as TuiPluginV2 } from "@opencode-ai/plugin-v2/tui"
|
|
3
|
+
import type { SessionMessageInfo } from "@opencode-ai/client"
|
|
2
4
|
import { createElement, insert, setProp } from "@opentui/solid"
|
|
3
|
-
import { createSignal, onCleanup } from "solid-js"
|
|
5
|
+
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
4
6
|
|
|
5
7
|
type GoalCheckpoint = {
|
|
6
8
|
summary: string
|
|
@@ -63,7 +65,7 @@ type GoalSessionState = {
|
|
|
63
65
|
goal: GoalSnapshot | null
|
|
64
66
|
messageIndex: number
|
|
65
67
|
}
|
|
66
|
-
type ElementChild = string | number | boolean | null | undefined | object | (() =>
|
|
68
|
+
type ElementChild = string | number | boolean | null | undefined | object | (() => ElementChild)
|
|
67
69
|
|
|
68
70
|
type ModernTuiApi = TuiPluginApi & {
|
|
69
71
|
keymap?: {
|
|
@@ -83,6 +85,17 @@ type ModernTuiApi = TuiPluginApi & {
|
|
|
83
85
|
|
|
84
86
|
const goalCache = new Map<string, GoalSnapshot>()
|
|
85
87
|
|
|
88
|
+
const GOAL_TOOL_NAMES: readonly string[] = [
|
|
89
|
+
"get_goal",
|
|
90
|
+
"get_goal_history",
|
|
91
|
+
"create_goal",
|
|
92
|
+
"set_goal",
|
|
93
|
+
"update_goal",
|
|
94
|
+
"update_goal_objective",
|
|
95
|
+
"update_goal_status",
|
|
96
|
+
"clear_goal",
|
|
97
|
+
]
|
|
98
|
+
|
|
86
99
|
function element(tag: string, props: Record<string, unknown>, children: ElementChild[] = []) {
|
|
87
100
|
const node = createElement(tag)
|
|
88
101
|
for (const [key, value] of Object.entries(props)) if (value !== undefined) setProp(node, key, value)
|
|
@@ -280,19 +293,7 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
|
|
|
280
293
|
|
|
281
294
|
function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
|
|
282
295
|
if (part.type !== "tool") return undefined
|
|
283
|
-
if (
|
|
284
|
-
![
|
|
285
|
-
"get_goal",
|
|
286
|
-
"get_goal_history",
|
|
287
|
-
"create_goal",
|
|
288
|
-
"set_goal",
|
|
289
|
-
"update_goal",
|
|
290
|
-
"update_goal_objective",
|
|
291
|
-
"update_goal_status",
|
|
292
|
-
"clear_goal",
|
|
293
|
-
].includes(part.tool ?? "")
|
|
294
|
-
)
|
|
295
|
-
return undefined
|
|
296
|
+
if (!GOAL_TOOL_NAMES.includes(part.tool ?? "")) return undefined
|
|
296
297
|
if (part.state?.status !== "completed") return undefined
|
|
297
298
|
if (part.tool === "clear_goal") return null
|
|
298
299
|
if (typeof part.state.output !== "string") return undefined
|
|
@@ -419,9 +420,171 @@ const tui: TuiPlugin = async (api) => {
|
|
|
419
420
|
})
|
|
420
421
|
}
|
|
421
422
|
|
|
422
|
-
|
|
423
|
+
// --- V2 TUI plugin ---
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Scans the V2 session message list for the newest completed goal tool result.
|
|
427
|
+
* Assistant tool content entries carry `name` plus a completed `state.content`
|
|
428
|
+
* array; goal tool output is serialized in text ToolContent parts. Returns
|
|
429
|
+
* `undefined` when no goal tool output is present (so callers can fall back to
|
|
430
|
+
* a cached snapshot), `null` after a completed clear_goal, or the snapshot.
|
|
431
|
+
*/
|
|
432
|
+
export function goalFromV2Messages(messages: readonly SessionMessageInfo[]): GoalSnapshot | null | undefined {
|
|
433
|
+
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
434
|
+
const message = messages[messageIndex]
|
|
435
|
+
if (!message || message.type !== "assistant") continue
|
|
436
|
+
const parts = message.content
|
|
437
|
+
for (let partIndex = parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
|
438
|
+
const part = parts[partIndex]
|
|
439
|
+
if (!part || part.type !== "tool") continue
|
|
440
|
+
if (!GOAL_TOOL_NAMES.includes(part.name)) continue
|
|
441
|
+
if (part.state.status !== "completed") continue
|
|
442
|
+
if (part.name === "clear_goal") return null
|
|
443
|
+
const textContent = part.state.content.find((entry) => entry.type === "text")
|
|
444
|
+
if (!textContent) continue
|
|
445
|
+
try {
|
|
446
|
+
const parsed: unknown = JSON.parse(textContent.text)
|
|
447
|
+
if (!isRecord(parsed)) continue
|
|
448
|
+
if (parsed.goal === null) return null
|
|
449
|
+
if (isGoalSnapshot(parsed.goal)) return parsed.goal
|
|
450
|
+
} catch {
|
|
451
|
+
// Malformed tool output: keep scanning older tool entries.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return undefined
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function currentSessionIDV2(api: TuiPluginV2.Context) {
|
|
459
|
+
const route = api.ui.router.current()
|
|
460
|
+
if (route.type !== "session") return undefined
|
|
461
|
+
return route.sessionID
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function toastV2(api: TuiPluginV2.Context, message: string, variant: "info" | "success" | "warning" | "error" = "info") {
|
|
465
|
+
api.ui.toast.show({ title: "Goal", message, variant, duration: 2500 })
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function showSummaryV2(api: TuiPluginV2.Context, sessionID: string, goal: GoalSnapshot | null) {
|
|
469
|
+
const options = [
|
|
470
|
+
{ title: "Refresh", value: "refresh", description: "Ask the agent to read the current goal state" },
|
|
471
|
+
...(goal
|
|
472
|
+
? [
|
|
473
|
+
{ title: "History", value: "history", description: "Ask the agent to show lifecycle history" },
|
|
474
|
+
...(goal.status === "active"
|
|
475
|
+
? [{ title: "Pause", value: "pause", description: "Pause auto-continuation without clearing" }]
|
|
476
|
+
: []),
|
|
477
|
+
...(goal.status === "paused" || goal.status === "budgetLimited" || goal.status === "usageLimited"
|
|
478
|
+
? [{ title: "Resume", value: "resume", description: "Resume the goal and continue" }]
|
|
479
|
+
: []),
|
|
480
|
+
{ title: "Clear", value: "clear", description: "Ask the agent to clear this session goal" },
|
|
481
|
+
]
|
|
482
|
+
: []),
|
|
483
|
+
]
|
|
484
|
+
api.ui.dialog.set({ size: "large" })
|
|
485
|
+
const selected = await api.ui.dialog.select({ title: "Goal", placeholder: formatGoal(goal), options })
|
|
486
|
+
const prompt = selected === "refresh" ? refreshGoalPrompt()
|
|
487
|
+
: selected === "history" ? historyGoalPrompt()
|
|
488
|
+
: selected === "pause" ? pauseGoalPrompt()
|
|
489
|
+
: selected === "resume" ? resumeGoalPrompt()
|
|
490
|
+
: selected === "clear" ? clearGoalPrompt()
|
|
491
|
+
: undefined
|
|
492
|
+
if (!prompt) return
|
|
493
|
+
try {
|
|
494
|
+
await api.client.session.prompt({ sessionID, text: prompt })
|
|
495
|
+
} catch (error) {
|
|
496
|
+
toastV2(api, error instanceof Error ? error.message : String(error), "error")
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function GoalSidebarV2(api: TuiPluginV2.Context, sessionID: string) {
|
|
501
|
+
const theme = api.theme
|
|
502
|
+
const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, {
|
|
503
|
+
initial: { goal: null },
|
|
504
|
+
})
|
|
505
|
+
const goal = createMemo<GoalSnapshot | null>(() => {
|
|
506
|
+
const found = goalFromV2Messages(api.data.session.message.list(sessionID))
|
|
507
|
+
return found === undefined ? cache.goal : found
|
|
508
|
+
})
|
|
509
|
+
createEffect(() =>
|
|
510
|
+
setCache((draft) => {
|
|
511
|
+
draft.goal = goal()
|
|
512
|
+
}),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
|
|
516
|
+
createEffect(() => {
|
|
517
|
+
if (goal()?.status !== "active") return
|
|
518
|
+
const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
|
|
519
|
+
onCleanup(() => clearInterval(timer))
|
|
520
|
+
})
|
|
521
|
+
return box({}, [() => {
|
|
522
|
+
const snapshot = goal()
|
|
523
|
+
if (!snapshot) return null
|
|
524
|
+
if (snapshot.status === "complete" || snapshot.status === "unmet") {
|
|
525
|
+
const elapsed = liveTimeUsedSeconds(snapshot)
|
|
526
|
+
return text({ fg: snapshot.status === "complete" ? theme.primary : theme.textMuted }, [
|
|
527
|
+
`${snapshot.status === "complete" ? "Goal achieved" : "Goal unmet"} (${formatDurationBadge(elapsed)})`,
|
|
528
|
+
])
|
|
529
|
+
}
|
|
530
|
+
return box({}, [
|
|
531
|
+
text({ fg: theme.text }, ["Goal"]),
|
|
532
|
+
text({ fg: theme.textMuted }, [`Status: ${snapshot.status}`]),
|
|
533
|
+
text({ fg: theme.textMuted }, [`Time: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]),
|
|
534
|
+
text({ fg: theme.textMuted }, [`Tokens: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`]),
|
|
535
|
+
text({ fg: theme.textMuted }, [`Auto-continues: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`]),
|
|
536
|
+
...(snapshot.lastCheckpoint ? [text({ fg: theme.textMuted }, [`Checkpoint: ${snapshot.lastCheckpoint.summary}`])] : []),
|
|
537
|
+
...(snapshot.stopReason ? [text({ fg: theme.textMuted }, [`Stop: ${snapshot.stopReason}`])] : []),
|
|
538
|
+
...(snapshot.lastStatus ? [text({ fg: theme.textMuted }, [snapshot.lastStatus])] : []),
|
|
539
|
+
text({ fg: theme.textMuted }, [snapshot.objective]),
|
|
540
|
+
])
|
|
541
|
+
}])
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function GoalKeymapLayerV2(api: TuiPluginV2.Context) {
|
|
545
|
+
api.keymap.layer(() => ({
|
|
546
|
+
commands: [
|
|
547
|
+
{
|
|
548
|
+
id: "goal.show",
|
|
549
|
+
title: "Goal",
|
|
550
|
+
description: "View, pause, resume, or clear the long-running session goal",
|
|
551
|
+
group: "Goal",
|
|
552
|
+
palette: true,
|
|
553
|
+
run: () => {
|
|
554
|
+
const sessionID = currentSessionIDV2(api)
|
|
555
|
+
if (!sessionID) {
|
|
556
|
+
toastV2(api, "Open a session before viewing goal state.", "warning")
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
void showSummaryV2(api, sessionID, goalFromV2Messages(api.data.session.message.list(sessionID)) ?? null)
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
}))
|
|
564
|
+
return null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* V2 TUI setup: registers the goal sidebar via `ui.slot` and a palette command
|
|
569
|
+
* through a keymap layer mounted from the global `app` slot. `keymap.layer`
|
|
570
|
+
* must be invoked from a Solid component scope, so the layer lives inside a
|
|
571
|
+
* component rendered by the `app` slot; its cleanup is owned by that component
|
|
572
|
+
* and released automatically when the slot unmounts. The setup cleanup only
|
|
573
|
+
* needs to dispose the two `ui.slot` registrations.
|
|
574
|
+
*/
|
|
575
|
+
export function setupTuiV2(context: TuiPluginV2.Context): TuiPluginV2.Cleanup {
|
|
576
|
+
const offSidebar = context.ui.slot("sidebar.content", (props) => GoalSidebarV2(context, props.sessionID))
|
|
577
|
+
const offApp = context.ui.slot("app", () => GoalKeymapLayerV2(context))
|
|
578
|
+
return () => {
|
|
579
|
+
offSidebar()
|
|
580
|
+
offApp()
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const plugin: TuiPluginModule & TuiPluginV2.Definition = {
|
|
423
585
|
id: "local.goal-mode.tui",
|
|
424
586
|
tui,
|
|
587
|
+
setup: setupTuiV2,
|
|
425
588
|
}
|
|
426
589
|
|
|
427
590
|
export default plugin
|