@prevalentware/opencode-goal-plugin 0.1.3 → 0.1.4
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 +22 -1
- package/dist/server.js +108 -20
- package/package.json +1 -1
- package/src/tui.tsx +83 -31
package/README.md
CHANGED
|
@@ -7,8 +7,10 @@ This plugin adds:
|
|
|
7
7
|
- `/goal` in the OpenCode TUI.
|
|
8
8
|
- A sidebar goal indicator with status, elapsed time, token usage, remaining budget, and objective.
|
|
9
9
|
- Agent tools: `get_goal`, `create_goal`, `update_goal`, and `clear_goal`.
|
|
10
|
+
- Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker.
|
|
10
11
|
- Persistent per-session goal state.
|
|
11
12
|
- Optional automatic continuation on `session.idle`.
|
|
13
|
+
- Compaction context so active goals are preserved when OpenCode summarizes a long session.
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -71,6 +73,25 @@ Defaults:
|
|
|
71
73
|
- `max_auto_turns`: `25`
|
|
72
74
|
- `min_continue_interval_seconds`: `3`
|
|
73
75
|
|
|
76
|
+
## Goal Workflow
|
|
77
|
+
|
|
78
|
+
Use `/goal` from an OpenCode TUI session to set, refresh, or clear the goal. New goals support budget presets:
|
|
79
|
+
|
|
80
|
+
- No budget
|
|
81
|
+
- `250K`
|
|
82
|
+
- `1M`
|
|
83
|
+
- `2M`
|
|
84
|
+
- Custom positive integer
|
|
85
|
+
|
|
86
|
+
When setting the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
|
|
87
|
+
|
|
88
|
+
The `update_goal` tool can close a goal in two ways:
|
|
89
|
+
|
|
90
|
+
- `status: "complete"` with `evidence` when every requirement is actually achieved.
|
|
91
|
+
- `status: "unmet"` with `blocker` when the objective cannot be achieved or is blocked by missing external input.
|
|
92
|
+
|
|
93
|
+
Budget exhaustion does not close a goal by itself. It only marks the goal `budgetLimited` and asks the agent to wrap up with remaining work or blockers.
|
|
94
|
+
|
|
74
95
|
## State
|
|
75
96
|
|
|
76
97
|
Goal state is stored at:
|
|
@@ -124,4 +145,4 @@ OpenCode plugin modules are target-specific. This package exports separate modul
|
|
|
124
145
|
}
|
|
125
146
|
```
|
|
126
147
|
|
|
127
|
-
Codex goal mode has deeper runtime integration for
|
|
148
|
+
Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode's `session.idle` event.
|
package/dist/server.js
CHANGED
|
@@ -60,6 +60,17 @@ function validateBudget(tokenBudget) {
|
|
|
60
60
|
}
|
|
61
61
|
return tokenBudget;
|
|
62
62
|
}
|
|
63
|
+
function validateEvidence(evidence, label) {
|
|
64
|
+
const value = evidence?.trim();
|
|
65
|
+
if (!value)
|
|
66
|
+
throw new Error(`${label} must not be empty`);
|
|
67
|
+
if ([...value].length > 4000)
|
|
68
|
+
throw new Error(`${label} must be at most 4000 characters`);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function isClosed(status) {
|
|
72
|
+
return status === "complete" || status === "unmet";
|
|
73
|
+
}
|
|
63
74
|
function snapshot(goal) {
|
|
64
75
|
const activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, nowSeconds() - goal.lastAccountedAt) : 0;
|
|
65
76
|
const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds;
|
|
@@ -72,6 +83,9 @@ function snapshot(goal) {
|
|
|
72
83
|
timeUsedSeconds,
|
|
73
84
|
createdAt: goal.createdAt,
|
|
74
85
|
updatedAt: goal.updatedAt,
|
|
86
|
+
completionEvidence: goal.completionEvidence ?? null,
|
|
87
|
+
blocker: goal.blocker ?? null,
|
|
88
|
+
closedAt: goal.closedAt ?? null,
|
|
75
89
|
remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed)
|
|
76
90
|
};
|
|
77
91
|
}
|
|
@@ -85,8 +99,8 @@ async function createGoal(sessionID, objective, tokenBudget) {
|
|
|
85
99
|
const budget = validateBudget(tokenBudget);
|
|
86
100
|
return mutate((state) => {
|
|
87
101
|
const existing = state.goals[sessionID];
|
|
88
|
-
if (existing && existing.status
|
|
89
|
-
throw new Error("cannot create a new goal because this session already has a non-
|
|
102
|
+
if (existing && !isClosed(existing.status)) {
|
|
103
|
+
throw new Error("cannot create a new goal because this session already has a non-closed goal");
|
|
90
104
|
}
|
|
91
105
|
const now = nowSeconds();
|
|
92
106
|
const goal = {
|
|
@@ -98,6 +112,9 @@ async function createGoal(sessionID, objective, tokenBudget) {
|
|
|
98
112
|
timeUsedSeconds: 0,
|
|
99
113
|
createdAt: now,
|
|
100
114
|
updatedAt: now,
|
|
115
|
+
completionEvidence: null,
|
|
116
|
+
blocker: null,
|
|
117
|
+
closedAt: null,
|
|
101
118
|
lastAccountedAt: now,
|
|
102
119
|
autoTurns: 0,
|
|
103
120
|
lastContinuationAt: null
|
|
@@ -106,20 +123,32 @@ async function createGoal(sessionID, objective, tokenBudget) {
|
|
|
106
123
|
return snapshot(goal);
|
|
107
124
|
});
|
|
108
125
|
}
|
|
109
|
-
async function
|
|
126
|
+
async function closeGoal(sessionID, input) {
|
|
110
127
|
return mutate((state) => {
|
|
111
128
|
const goal = state.goals[sessionID];
|
|
112
129
|
if (!goal)
|
|
113
130
|
throw new Error("cannot update goal because this session has no goal");
|
|
114
131
|
accountWallClock(goal);
|
|
115
|
-
|
|
116
|
-
goal.
|
|
117
|
-
goal.
|
|
132
|
+
const now = nowSeconds();
|
|
133
|
+
goal.status = input.status;
|
|
134
|
+
goal.updatedAt = now;
|
|
135
|
+
goal.closedAt = now;
|
|
136
|
+
goal.lastAccountedAt = null;
|
|
137
|
+
if (input.status === "complete") {
|
|
138
|
+
goal.completionEvidence = validateEvidence(input.evidence, "completion evidence");
|
|
139
|
+
goal.blocker = null;
|
|
140
|
+
} else {
|
|
141
|
+
goal.blocker = validateEvidence(input.blocker, "blocker");
|
|
142
|
+
goal.completionEvidence = null;
|
|
143
|
+
}
|
|
118
144
|
return snapshot(goal);
|
|
119
145
|
});
|
|
120
146
|
}
|
|
121
|
-
async function completeGoal(sessionID) {
|
|
122
|
-
return
|
|
147
|
+
async function completeGoal(sessionID, evidence) {
|
|
148
|
+
return closeGoal(sessionID, { status: "complete", evidence });
|
|
149
|
+
}
|
|
150
|
+
async function markGoalUnmet(sessionID, blocker) {
|
|
151
|
+
return closeGoal(sessionID, { status: "unmet", blocker });
|
|
123
152
|
}
|
|
124
153
|
async function clearGoal(sessionID) {
|
|
125
154
|
return mutate((state) => {
|
|
@@ -182,13 +211,18 @@ function formatGoal(goal) {
|
|
|
182
211
|
if (!goal)
|
|
183
212
|
return "No goal is set for this session.";
|
|
184
213
|
const budget = goal.tokenBudget == null ? "none" : `${goal.tokensUsed} / ${goal.tokenBudget}`;
|
|
185
|
-
|
|
214
|
+
const lines = [
|
|
186
215
|
`Objective: ${goal.objective}`,
|
|
187
216
|
`Status: ${goal.status}`,
|
|
188
217
|
`Tokens: ${budget}`,
|
|
189
218
|
`Remaining tokens: ${goal.remainingTokens ?? "n/a"}`,
|
|
190
219
|
`Time used: ${goal.timeUsedSeconds}s`
|
|
191
|
-
]
|
|
220
|
+
];
|
|
221
|
+
if (goal.completionEvidence)
|
|
222
|
+
lines.push(`Completion evidence: ${goal.completionEvidence}`);
|
|
223
|
+
if (goal.blocker)
|
|
224
|
+
lines.push(`Blocker: ${goal.blocker}`);
|
|
225
|
+
return lines.join(`
|
|
192
226
|
`);
|
|
193
227
|
}
|
|
194
228
|
|
|
@@ -218,7 +252,7 @@ Before deciding that the goal is achieved, perform a completion audit against th
|
|
|
218
252
|
- Identify any missing, incomplete, weakly verified, or uncovered requirement.
|
|
219
253
|
- Treat uncertainty as not achieved; do more verification or continue the work.
|
|
220
254
|
|
|
221
|
-
Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only call update_goal with status "complete" when the objective has actually been achieved and no required work remains.`;
|
|
255
|
+
Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only call update_goal with status "complete" when the objective has actually been achieved and no required work remains, and include concise evidence. If the objective is impossible or blocked by missing external input, call update_goal with status "unmet" and include the blocker.`;
|
|
222
256
|
}
|
|
223
257
|
function budgetLimitedPrompt(goal) {
|
|
224
258
|
return `The active session goal has reached its token budget.
|
|
@@ -234,13 +268,13 @@ Budget:
|
|
|
234
268
|
- Tokens used: ${goal.tokensUsed}
|
|
235
269
|
- Token budget: ${goal.tokenBudget ?? "none"}
|
|
236
270
|
|
|
237
|
-
Goal mode has marked the goal as budgetLimited, so do not start new substantive work for this goal. Wrap up soon with useful progress, remaining work or blockers, and a clear next step. Do not call update_goal unless the goal is actually complete.`;
|
|
271
|
+
Goal mode has marked the goal as budgetLimited, so do not start new substantive work for this goal. Wrap up soon with useful progress, remaining work or blockers, and a clear next step. Do not call update_goal unless the goal is actually complete or objectively unmet.`;
|
|
238
272
|
}
|
|
239
273
|
function systemReminder(goal) {
|
|
240
274
|
if (!goal) {
|
|
241
275
|
return `OpenCode goal mode is available through get_goal, create_goal, and update_goal tools.
|
|
242
276
|
|
|
243
|
-
Create a goal only when explicitly requested by the user or system/developer instructions. Do not infer goals from ordinary tasks.`;
|
|
277
|
+
Create a goal only when explicitly requested by the user or system/developer instructions. Do not infer goals from ordinary tasks. When closing a goal, update_goal requires evidence for status "complete" or a blocker for status "unmet".`;
|
|
244
278
|
}
|
|
245
279
|
if (goal.status === "active")
|
|
246
280
|
return continuationPrompt(goal);
|
|
@@ -252,6 +286,13 @@ ${formatGoal(goal)}
|
|
|
252
286
|
|
|
253
287
|
If the user resumes the goal, continue from the objective and current evidence.`;
|
|
254
288
|
}
|
|
289
|
+
function compactionContext(goal) {
|
|
290
|
+
return `OpenCode goal mode is tracking this session goal across compaction.
|
|
291
|
+
|
|
292
|
+
${formatGoal(goal)}
|
|
293
|
+
|
|
294
|
+
Preserve the goal objective, status, budget, elapsed time, token count, and any completion evidence or blocker in the compacted context. After compaction, continue from the next concrete unfinished step. Before closing the goal, audit real artifacts and command outputs; close with update_goal status "complete" only with evidence, or status "unmet" only with a concrete blocker.`;
|
|
295
|
+
}
|
|
255
296
|
|
|
256
297
|
// src/server.ts
|
|
257
298
|
var DEFAULT_MAX_AUTO_TURNS = 25;
|
|
@@ -271,6 +312,39 @@ function estimateMessages(messages) {
|
|
|
271
312
|
return sum + (message.parts ?? []).reduce((partSum, part) => partSum + estimateTokensFromText(textFromPart(part)), 0);
|
|
272
313
|
}, 0);
|
|
273
314
|
}
|
|
315
|
+
function tokensFromRecord(value) {
|
|
316
|
+
if (!value || typeof value !== "object")
|
|
317
|
+
return;
|
|
318
|
+
const tokens = value;
|
|
319
|
+
if (typeof tokens.total === "number")
|
|
320
|
+
return tokens.total;
|
|
321
|
+
const cache = tokens.cache && typeof tokens.cache === "object" ? tokens.cache : {};
|
|
322
|
+
const fields = [tokens.input, tokens.output, tokens.reasoning, cache.read, cache.write];
|
|
323
|
+
if (!fields.some((field) => typeof field === "number"))
|
|
324
|
+
return;
|
|
325
|
+
return fields.reduce((sum, field) => sum + (typeof field === "number" && Number.isFinite(field) ? field : 0), 0);
|
|
326
|
+
}
|
|
327
|
+
function exactTokensFromPart(part) {
|
|
328
|
+
if (!part || typeof part !== "object")
|
|
329
|
+
return;
|
|
330
|
+
const value = part;
|
|
331
|
+
if (value.type !== "step-finish")
|
|
332
|
+
return;
|
|
333
|
+
return tokensFromRecord(value.tokens);
|
|
334
|
+
}
|
|
335
|
+
function exactTokensFromMessage(message) {
|
|
336
|
+
const partTotal = (message.parts ?? []).reduce((sum, part) => sum + (exactTokensFromPart(part) ?? 0), 0);
|
|
337
|
+
if (partTotal > 0)
|
|
338
|
+
return partTotal;
|
|
339
|
+
if (message.info && typeof message.info === "object") {
|
|
340
|
+
return tokensFromRecord(message.info.tokens);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
function tokensFromMessages(messages) {
|
|
345
|
+
const exactTotal = messages.reduce((sum, message) => sum + (exactTokensFromMessage(message) ?? 0), 0);
|
|
346
|
+
return exactTotal > 0 ? exactTotal : estimateMessages(messages);
|
|
347
|
+
}
|
|
274
348
|
async function sendContinuation(client, sessionID, prompt) {
|
|
275
349
|
await client.session.promptAsync({
|
|
276
350
|
path: { id: sessionID },
|
|
@@ -305,14 +379,22 @@ var server = async ({ client }, options) => {
|
|
|
305
379
|
}
|
|
306
380
|
},
|
|
307
381
|
update_goal: {
|
|
308
|
-
description: "
|
|
382
|
+
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 the budget is exhausted or because work is stopping.",
|
|
309
383
|
args: {
|
|
310
|
-
status: z.enum(["complete"]).describe("Required.
|
|
384
|
+
status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."),
|
|
385
|
+
evidence: z.string().min(1).max(4000).optional().describe("Required when status is complete. Summarize the concrete evidence verified."),
|
|
386
|
+
blocker: z.string().min(1).max(4000).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.")
|
|
311
387
|
},
|
|
312
|
-
async execute(
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
388
|
+
async execute(args, context) {
|
|
389
|
+
const input = args;
|
|
390
|
+
if (input.status === "complete") {
|
|
391
|
+
const goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
392
|
+
const report2 = goal2.tokenBudget == null ? `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds. Evidence: ${goal2.completionEvidence}.` : `Goal achieved. Tokens used: ${goal2.tokensUsed} of ${goal2.tokenBudget}; time used: ${goal2.timeUsedSeconds} seconds. Evidence: ${goal2.completionEvidence}.`;
|
|
393
|
+
return JSON.stringify({ goal: goal2, remaining_tokens: goal2.remainingTokens, completion_budget_report: report2 }, null, 2);
|
|
394
|
+
}
|
|
395
|
+
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
396
|
+
const report = goal.tokenBudget == null ? `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.` : `Goal unmet. Tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}; time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
397
|
+
return JSON.stringify({ goal, remaining_tokens: goal.remainingTokens, unmet_report: report }, null, 2);
|
|
316
398
|
}
|
|
317
399
|
},
|
|
318
400
|
clear_goal: {
|
|
@@ -327,13 +409,19 @@ var server = async ({ client }, options) => {
|
|
|
327
409
|
const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
|
|
328
410
|
if (!sessionID)
|
|
329
411
|
return;
|
|
330
|
-
await accountUsage(sessionID,
|
|
412
|
+
await accountUsage(sessionID, tokensFromMessages(output.messages));
|
|
331
413
|
},
|
|
332
414
|
async "experimental.chat.system.transform"(input, output) {
|
|
333
415
|
if (typeof input.sessionID !== "string")
|
|
334
416
|
return;
|
|
335
417
|
output.system.push(systemReminder(await getGoal(input.sessionID)));
|
|
336
418
|
},
|
|
419
|
+
async "experimental.session.compacting"(input, output) {
|
|
420
|
+
const goal = await getGoal(input.sessionID);
|
|
421
|
+
if (!goal)
|
|
422
|
+
return;
|
|
423
|
+
output.context.push(compactionContext(goal));
|
|
424
|
+
},
|
|
337
425
|
async event({ event }) {
|
|
338
426
|
if (!autoContinue || event.type !== "session.idle")
|
|
339
427
|
return;
|
package/package.json
CHANGED
package/src/tui.tsx
CHANGED
|
@@ -5,12 +5,15 @@ import { createMemo, Show } from "solid-js"
|
|
|
5
5
|
type GoalSnapshot = {
|
|
6
6
|
sessionID: string
|
|
7
7
|
objective: string
|
|
8
|
-
status: "active" | "paused" | "budgetLimited" | "complete"
|
|
8
|
+
status: "active" | "paused" | "budgetLimited" | "complete" | "unmet"
|
|
9
9
|
tokenBudget: number | null
|
|
10
10
|
tokensUsed: number
|
|
11
11
|
timeUsedSeconds: number
|
|
12
12
|
createdAt: number
|
|
13
13
|
updatedAt: number
|
|
14
|
+
completionEvidence?: string | null
|
|
15
|
+
blocker?: string | null
|
|
16
|
+
closedAt?: number | null
|
|
14
17
|
remainingTokens: number | null
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -58,42 +61,84 @@ function clearGoalPrompt() {
|
|
|
58
61
|
return "Clear the current session goal by calling clear_goal. Report whether a goal was cleared."
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
function showCustomBudget(api: TuiPluginApi, sessionID: string, objective: string) {
|
|
65
|
+
const DialogPrompt = api.ui.DialogPrompt
|
|
66
|
+
api.ui.dialog.replace(() =>
|
|
67
|
+
DialogPrompt({
|
|
68
|
+
title: "Custom budget",
|
|
69
|
+
placeholder: "Positive integer",
|
|
70
|
+
onConfirm(rawBudget) {
|
|
71
|
+
const value = rawBudget.trim()
|
|
72
|
+
const budget = Number(value)
|
|
73
|
+
if (!Number.isInteger(budget) || budget <= 0) {
|
|
74
|
+
toast(api, "Token budget must be a positive integer.", "warning")
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
void sendGoalPrompt(api, sessionID, createGoalPrompt(objective, budget))
|
|
78
|
+
.then(() => {
|
|
79
|
+
api.ui.dialog.clear()
|
|
80
|
+
toast(api, "Goal request sent.", "success")
|
|
81
|
+
})
|
|
82
|
+
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
|
|
83
|
+
},
|
|
84
|
+
onCancel() {
|
|
85
|
+
api.ui.dialog.clear()
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function showBudgetSelect(api: TuiPluginApi, sessionID: string, objective: string) {
|
|
92
|
+
const DialogSelect = api.ui.DialogSelect
|
|
93
|
+
const budgets = [
|
|
94
|
+
{ title: "No budget", value: "none", budget: null, description: "Track progress without a token limit" },
|
|
95
|
+
{ title: "250K", value: "250k", budget: 250_000, description: "Short focused goal" },
|
|
96
|
+
{ title: "1M", value: "1m", budget: 1_000_000, description: "Default long-running goal" },
|
|
97
|
+
{ title: "2M", value: "2m", budget: 2_000_000, description: "Large investigation or migration" },
|
|
98
|
+
{ title: "Custom", value: "custom", budget: undefined, description: "Enter an exact token budget" },
|
|
99
|
+
]
|
|
100
|
+
api.ui.dialog.replace(() =>
|
|
101
|
+
DialogSelect({
|
|
102
|
+
title: "Token budget",
|
|
103
|
+
placeholder: "Choose a budget",
|
|
104
|
+
options: budgets.map((item) => ({
|
|
105
|
+
title: item.title,
|
|
106
|
+
value: item.value,
|
|
107
|
+
description: item.description,
|
|
108
|
+
onSelect: () => {
|
|
109
|
+
if (item.budget === undefined) {
|
|
110
|
+
showCustomBudget(api, sessionID, objective)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
void sendGoalPrompt(api, sessionID, createGoalPrompt(objective, item.budget))
|
|
114
|
+
.then(() => {
|
|
115
|
+
api.ui.dialog.clear()
|
|
116
|
+
toast(api, "Goal request sent.", "success")
|
|
117
|
+
})
|
|
118
|
+
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
|
|
119
|
+
},
|
|
120
|
+
})),
|
|
121
|
+
onSelect(option) {
|
|
122
|
+
option.onSelect?.()
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
61
128
|
function showSetGoal(api: TuiPluginApi, sessionID: string) {
|
|
62
129
|
const DialogPrompt = api.ui.DialogPrompt
|
|
63
130
|
api.ui.dialog.setSize("medium")
|
|
64
131
|
api.ui.dialog.replace(() =>
|
|
65
132
|
DialogPrompt({
|
|
66
133
|
title: "Set goal",
|
|
67
|
-
placeholder: "
|
|
134
|
+
placeholder: "Objective, scope, non-goals, verification path",
|
|
68
135
|
onConfirm(objective) {
|
|
69
136
|
const trimmed = objective.trim()
|
|
70
137
|
if (!trimmed) {
|
|
71
138
|
toast(api, "Goal objective is required.", "warning")
|
|
72
139
|
return
|
|
73
140
|
}
|
|
74
|
-
api
|
|
75
|
-
DialogPrompt({
|
|
76
|
-
title: "Token budget",
|
|
77
|
-
placeholder: "Optional positive integer",
|
|
78
|
-
onConfirm(rawBudget) {
|
|
79
|
-
const value = rawBudget.trim()
|
|
80
|
-
const budget = value ? Number(value) : null
|
|
81
|
-
if (budget != null && (!Number.isInteger(budget) || budget <= 0)) {
|
|
82
|
-
toast(api, "Token budget must be a positive integer.", "warning")
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
void sendGoalPrompt(api, sessionID, createGoalPrompt(trimmed, budget))
|
|
86
|
-
.then(() => {
|
|
87
|
-
api.ui.dialog.clear()
|
|
88
|
-
toast(api, "Goal request sent.", "success")
|
|
89
|
-
})
|
|
90
|
-
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
|
|
91
|
-
},
|
|
92
|
-
onCancel() {
|
|
93
|
-
api.ui.dialog.clear()
|
|
94
|
-
},
|
|
95
|
-
}),
|
|
96
|
-
)
|
|
141
|
+
showBudgetSelect(api, sessionID, trimmed)
|
|
97
142
|
},
|
|
98
143
|
onCancel() {
|
|
99
144
|
api.ui.dialog.clear()
|
|
@@ -189,12 +234,15 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
|
|
|
189
234
|
if (!isRecord(value)) return false
|
|
190
235
|
if (typeof value.sessionID !== "string") return false
|
|
191
236
|
if (typeof value.objective !== "string") return false
|
|
192
|
-
if (!["active", "paused", "budgetLimited", "complete"].includes(String(value.status))) return false
|
|
237
|
+
if (!["active", "paused", "budgetLimited", "complete", "unmet"].includes(String(value.status))) return false
|
|
193
238
|
if (value.tokenBudget !== null && typeof value.tokenBudget !== "number") return false
|
|
194
239
|
if (typeof value.tokensUsed !== "number") return false
|
|
195
240
|
if (typeof value.timeUsedSeconds !== "number") return false
|
|
196
241
|
if (typeof value.createdAt !== "number") return false
|
|
197
242
|
if (typeof value.updatedAt !== "number") return false
|
|
243
|
+
if (value.completionEvidence != null && typeof value.completionEvidence !== "string") return false
|
|
244
|
+
if (value.blocker != null && typeof value.blocker !== "string") return false
|
|
245
|
+
if (value.closedAt != null && typeof value.closedAt !== "number") return false
|
|
198
246
|
if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
|
|
199
247
|
return true
|
|
200
248
|
}
|
|
@@ -231,13 +279,16 @@ function goalFromSession(api: TuiPluginApi, sessionID: string) {
|
|
|
231
279
|
function formatGoal(goal: GoalSnapshot | null) {
|
|
232
280
|
if (!goal) return "No recent goal state found in this session."
|
|
233
281
|
const budget = goal.tokenBudget == null ? "none" : `${goal.tokensUsed} / ${goal.tokenBudget}`
|
|
234
|
-
|
|
282
|
+
const lines = [
|
|
235
283
|
`Objective: ${goal.objective}`,
|
|
236
284
|
`Status: ${goal.status}`,
|
|
237
285
|
`Tokens: ${budget}`,
|
|
238
286
|
`Remaining tokens: ${goal.remainingTokens ?? "n/a"}`,
|
|
239
287
|
`Time used: ${goal.timeUsedSeconds}s`,
|
|
240
|
-
]
|
|
288
|
+
]
|
|
289
|
+
if (goal.completionEvidence) lines.push(`Completion evidence: ${goal.completionEvidence}`)
|
|
290
|
+
if (goal.blocker) lines.push(`Blocker: ${goal.blocker}`)
|
|
291
|
+
return lines.join("\n")
|
|
241
292
|
}
|
|
242
293
|
|
|
243
294
|
function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
@@ -266,7 +317,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
266
317
|
<Show when={goal()}>
|
|
267
318
|
{(value: () => GoalSnapshot) => (
|
|
268
319
|
<Show
|
|
269
|
-
when={value().status === "complete"}
|
|
320
|
+
when={value().status === "complete" || value().status === "unmet"}
|
|
270
321
|
fallback={
|
|
271
322
|
<box>
|
|
272
323
|
<text fg={theme().text}>
|
|
@@ -280,8 +331,9 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
280
331
|
</box>
|
|
281
332
|
}
|
|
282
333
|
>
|
|
283
|
-
<text fg={theme().primary}>
|
|
284
|
-
<b>Goal achieved</b> (
|
|
334
|
+
<text fg={value().status === "complete" ? theme().primary : theme().textMuted}>
|
|
335
|
+
<b>{value().status === "complete" ? "Goal achieved" : "Goal unmet"}</b> (
|
|
336
|
+
{formatDurationBadge(value().timeUsedSeconds)})
|
|
285
337
|
</text>
|
|
286
338
|
</Show>
|
|
287
339
|
)}
|