Add built-in goal management plugin

Adds the always-enabled _goal plugin with per-chat goal storage, WebUI goal strip, /goal slash command, and agent-facing goal tools. Includes command-picker integration, focused plugin tests, and the generated 256x256 thumbnail asset.

Alessandro committed Jul 9, 2026 at 17:09 UTC 84c13dab01cc9ffe7089c6d8614af8f06110d5a1
26 files changed +1199
plugins/AGENTS.md
+1
@@ -78,6 +78,7 @@ Direct child DOX files:
78 | [_editor/AGENTS.md](_editor/AGENTS.md) | Native Markdown editor surface and sessions. |
79 | [_email_integration/AGENTS.md](_email_integration/AGENTS.md) | IMAP/Exchange polling and SMTP reply integration. |
80 | [_error_retry/AGENTS.md](_error_retry/AGENTS.md) | Critical exception retry lifecycle hooks. |
81 +| [_goal/AGENTS.md](_goal/AGENTS.md) | Built-in chat goal strip, `/goal` slash command, and agent-facing goal tools. |
82 | [_infection_check/AGENTS.md](_infection_check/AGENTS.md) | Prompt-injection safety analysis before tool execution. |
83 | [_kokoro_tts/AGENTS.md](_kokoro_tts/AGENTS.md) | Kokoro text-to-speech integration. |
84 | [_memory/AGENTS.md](_memory/AGENTS.md) | Optional persistent recall plugin, knowledge import, tools, and dashboard; do not assume it is enabled outside this plugin. |
plugins/_commands/AGENTS.md
+1
@@ -28,6 +28,7 @@
28 - Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
29 - On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
30 - Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
31 +- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
32
33 ## Work Guidance
34
plugins/_commands/webui/commands-slash-store.js
+13
@@ -353,6 +353,7 @@ const model = {
353 const effects = Array.isArray(result.effects) ? result.effects : [];
354 let hadToast = false;
355 let hadError = false;
356 + let shouldSend = false;
357
358 for (const effect of effects) {
359 if (!effect || typeof effect !== "object") continue;
@@ -366,6 +367,11 @@ const model = {
367 nextText = nextText ? `${nextText}\n${chunk}` : chunk;
368 continue;
369 }
370 + if (type === "send_message") {
371 + nextText = String(effect.text || nextText || "");
372 + shouldSend = true;
373 + continue;
374 + }
375 if (type === "toast") {
376 hadToast = true;
377 const level = String(effect.level || "info").toLowerCase();
@@ -413,6 +419,10 @@ const model = {
419 );
420 continue;
421 }
422 + if (type === "goal_changed") {
423 + window.dispatchEvent(new CustomEvent("goal:changed", { detail: effect }));
424 + continue;
425 + }
426 if (type === "open_plugin_config") {
427 const pluginName = String(effect.plugin || "").trim();
428 if (pluginName) {
@@ -463,6 +473,9 @@ const model = {
473 this.rawArguments = "";
474 this.rawMessage = nextText;
475 this.selectedIndex = 0;
476 + if (shouldSend && nextText.trim()) {
477 + await chatInputStore?.sendMessage?.();
478 + }
479 return { hadToast, hadError };
480 },
481
plugins/_goal/AGENTS.md new
+41
@@ -0,0 +1,41 @@
1 +# Goal Plugin DOX
2 +
3 +## Purpose
4 +
5 +- Own the built-in chat goal strip, `/goal` slash command, goal state API, and agent-facing goal tools.
6 +- Keep chat goals scoped to the active chat context and stored as user data outside tracked plugin code.
7 +
8 +## Ownership
9 +
10 +- `plugin.yaml` owns the always-enabled `_goal` plugin metadata.
11 +- `helpers/goals.py` owns file-backed goal storage under `usr/plugins/_goal/goals/` and goal status normalization.
12 +- `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
13 +- `commands/` owns the `/goal` slash command contributed to `_commands`.
14 +- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
15 +- `tools/` and `prompts/` own agent-facing goal inspection, creation, and status update behavior.
16 +- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
17 +
18 +## Local Contracts
19 +
20 +- Goal status values are `active`, `paused`, `complete`, and `blocked`.
21 +- Active goals are injected into agent extras; paused and blocked goals remain visible in the UI, while complete goals are hidden.
22 +- Goal records track accumulated active time with `elapsed_seconds` and `active_since`; pausing freezes elapsed time until resume.
23 +- User controls may pause, resume, edit, or delete a goal; destructive delete uses inline confirmation. Model tools may create goals and mark them complete or blocked.
24 +- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
25 +- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
26 +- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
27 +
28 +## Work Guidance
29 +
30 +- Keep goal state in `usr/plugins/_goal/`; do not store runtime goal data in tracked files.
31 +- Keep the goal strip mounted through WebUI extension points instead of modifying core composer templates.
32 +- Keep `_commands` compatibility in mind: `/goal` is a plugin-contributed command and should remain read-only in the command manager.
33 +
34 +## Verification
35 +
36 +- Run `conda run -n a0 pytest plugins/_goal/tests` after changing `_goal` backend behavior.
37 +- Run `_commands` discovery tests when changing the `/goal` command contribution contract.
38 +
39 +## Child DOX Index
40 +
41 +No child DOX files.
plugins/_goal/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_goal/api/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_goal/api/goal.py new
+55
@@ -0,0 +1,55 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +
5 +from plugins._goal.helpers import goals
6 +
7 +
8 +class Goal(ApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + action = str(input.get("action", "") or "").strip().lower()
11 + context_id = str(input.get("context_id", "") or "").strip()
12 +
13 + try:
14 + if action == "get":
15 + return {"ok": True, "goal": goals.public_goal(goals.get_goal(context_id))}
16 + if action in {"set", "create"}:
17 + return self._set(context_id, input)
18 + if action == "update":
19 + return self._update(context_id, input)
20 + if action == "pause":
21 + return self._status(context_id, "paused")
22 + if action == "resume":
23 + return self._status(context_id, "active")
24 + if action == "delete":
25 + goals.delete_goal(context_id)
26 + return {"ok": True, "goal": None}
27 + except FileNotFoundError:
28 + return Response(status=404, response="Goal not found")
29 + except ValueError as error:
30 + return Response(status=400, response=str(error))
31 +
32 + return Response(status=400, response=f"Unknown action: {action}")
33 +
34 + def _set(self, context_id: str, input: dict) -> dict:
35 + goal = goals.create_goal(
36 + context_id,
37 + str(input.get("objective") or ""),
38 + created_by=str(input.get("created_by") or "user"),
39 + token_budget=input.get("token_budget"),
40 + )
41 + return {"ok": True, "goal": goals.public_goal(goal)}
42 +
43 + def _update(self, context_id: str, input: dict) -> dict:
44 + goal = goals.update_goal(
45 + context_id,
46 + objective=input.get("objective") if "objective" in input else None,
47 + status=input.get("status") if "status" in input else None,
48 + note=input.get("note") if "note" in input else None,
49 + token_budget=input.get("token_budget") if "token_budget" in input else None,
50 + )
51 + return {"ok": True, "goal": goals.public_goal(goal)}
52 +
53 + def _status(self, context_id: str, status: str) -> dict:
54 + goal = goals.update_goal(context_id, status=status)
55 + return {"ok": True, "goal": goals.public_goal(goal)}
plugins/_goal/commands/goal.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: goal
2 +description: Create, inspect, pause, resume, edit, or delete this chat goal.
3 +argument_hint: "[objective|status|pause|resume|edit <goal>|delete|auto]"
4 +type: script
5 +script_path: goal_command.py
plugins/_goal/commands/goal_command.py new
+82
@@ -0,0 +1,82 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from plugins._goal.helpers import goals
6 +
7 +
8 +def run(payload: dict[str, Any]) -> dict[str, Any]:
9 + invocation = payload.get("invocation") or {}
10 + raw_args = str(invocation.get("raw_arguments") or "").strip()
11 + tokens = ((invocation.get("arguments") or {}).get("tokens") or [])
12 + context_id = str((payload.get("context") or {}).get("context_id") or "").strip()
13 +
14 + if not context_id:
15 + return _effects(_toast("Open or create a chat context first.", level="error"))
16 +
17 + action = str(tokens[0] if tokens else "").strip().lower()
18 +
19 + try:
20 + if action in {"", "status", "show"}:
21 + return _show_markdown("Goal", goals.summarize_goal(goals.get_goal(context_id)))
22 + if action in {"pause", "paused"}:
23 + goal = goals.update_goal(context_id, status="paused")
24 + return _changed("Goal paused.", goal)
25 + if action in {"resume", "start", "active"}:
26 + goal = goals.update_goal(context_id, status="active")
27 + return _changed("Goal resumed.", goal)
28 + if action in {"delete", "clear", "remove"}:
29 + goals.delete_goal(context_id)
30 + return _changed("Goal deleted.", None)
31 + if action in {"complete", "done"}:
32 + goal = goals.update_goal(context_id, status="complete")
33 + return _changed("Goal marked complete.", goal)
34 + if action == "blocked":
35 + note = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
36 + goal = goals.update_goal(context_id, status="blocked", note=note)
37 + return _changed("Goal marked blocked.", goal)
38 + if action == "edit":
39 + objective = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
40 + goal = goals.update_goal(context_id, objective=objective, status="active")
41 + return _changed("Goal updated.", goal)
42 + if action in {"auto", "ask", "model"}:
43 + hint = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
44 + return _auto_prompt(hint)
45 +
46 + goal = goals.create_goal(context_id, raw_args, created_by="user")
47 + return _changed("Goal set.", goal, send_text=raw_args)
48 + except FileNotFoundError:
49 + return _effects(_toast("No goal is set for this chat.", level="error"))
50 + except ValueError as error:
51 + return _effects(_toast(str(error), level="error"))
52 +
53 +
54 +def _auto_prompt(hint: str) -> dict[str, Any]:
55 + prompt = (
56 + "Please create and manage a goal for this chat. Use the goal tools to inspect "
57 + "any current goal, create a concise goal objective, and update it when the work "
58 + "is complete or genuinely blocked."
59 + )
60 + if hint:
61 + prompt += f"\n\nUser hint: {hint}"
62 + return {"text": prompt, "effects": []}
63 +
64 +
65 +def _changed(message: str, goal: dict[str, Any] | None, *, send_text: str = "") -> dict[str, Any]:
66 + return _effects(
67 + _toast(message),
68 + {"type": "goal_changed", "goal": goals.public_goal(goal)},
69 + {"type": "send_message", "text": send_text} if send_text else {},
70 + )
71 +
72 +
73 +def _effects(*effects: dict[str, Any]) -> dict[str, Any]:
74 + return {"text": "", "effects": [effect for effect in effects if effect]}
75 +
76 +
77 +def _toast(message: str, *, level: str = "success") -> dict[str, Any]:
78 + return {"type": "toast", "message": message, "level": level}
79 +
80 +
81 +def _show_markdown(title: str, content: str) -> dict[str, Any]:
82 + return _effects({"type": "show_markdown", "title": title, "content": content})
plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py new
+28
@@ -0,0 +1,28 @@
1 +from __future__ import annotations
2 +
3 +from agent import LoopData
4 +from helpers.extension import Extension
5 +from plugins._goal.helpers import goals
6 +
7 +
8 +class IncludeGoal(Extension):
9 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 + if not self.agent:
11 + return
12 +
13 + try:
14 + goal = goals.get_goal(self.agent.context.id)
15 + except ValueError:
16 + goal = None
17 +
18 + if not goal or goal.get("status") != "active":
19 + loop_data.extras_temporary.pop("current_goal", None)
20 + return
21 +
22 + loop_data.extras_temporary["current_goal"] = self.agent.read_prompt(
23 + "agent.extras.goal.md",
24 + status=goal.get("status", ""),
25 + objective=goal.get("objective", ""),
26 + created_by=goal.get("created_by", ""),
27 + updated_at=goal.get("updated_at", ""),
28 + )
plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html new
+207
@@ -0,0 +1,207 @@
1 +<script type="module">
2 + import { store } from "/plugins/_goal/webui/goal-store.js";
3 +</script>
4 +
5 +<div x-data
6 + class="goal-strip-root"
7 + x-create="$store.goalBar.onMount()"
8 + x-init="$watch('$store.chats.selected', () => $store.goalBar.refresh(true))"
9 + x-destroy="$store.goalBar.cleanup()">
10 + <template x-if="$store.goalBar && $store.goalBar.visible">
11 + <div class="goal-strip" :class="`is-${$store.goalBar.goal?.status || 'active'}`">
12 + <span class="material-symbols-outlined goal-strip-icon" x-text="$store.goalBar.statusIcon" aria-hidden="true"></span>
13 +
14 + <template x-if="!$store.goalBar.editing">
15 + <div class="goal-strip-copy">
16 + <span class="goal-strip-status" x-text="$store.goalBar.statusLabel"></span>
17 + <span class="goal-strip-objective" x-text="$store.goalBar.goal?.objective || ''"></span>
18 + <span class="goal-strip-time" x-text="`• ${$store.goalBar.elapsedLabel}`"></span>
19 + </div>
20 + </template>
21 +
22 + <template x-if="$store.goalBar.editing">
23 + <div class="goal-strip-edit">
24 + <input class="goal-strip-input"
25 + type="text"
26 + x-model="$store.goalBar.draft"
27 + @keydown.enter.prevent="$store.goalBar.saveEdit()"
28 + @keydown.escape.prevent="$store.goalBar.cancelEdit()"
29 + aria-label="Goal objective" />
30 + <button type="button" class="goal-strip-action" title="Save goal" @click="$store.goalBar.saveEdit()" :disabled="$store.goalBar.saving">
31 + <span class="material-symbols-outlined" aria-hidden="true">check</span>
32 + </button>
33 + <button type="button" class="goal-strip-action" title="Cancel" @click="$store.goalBar.cancelEdit()" :disabled="$store.goalBar.saving">
34 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
35 + </button>
36 + </div>
37 + </template>
38 +
39 + <div class="goal-strip-actions" x-show="!$store.goalBar.editing">
40 + <button type="button" class="goal-strip-action" title="Edit goal" @click="$store.goalBar.startEdit()" :disabled="$store.goalBar.saving">
41 + <span class="material-symbols-outlined" aria-hidden="true">edit</span>
42 + </button>
43 + <button type="button" class="goal-strip-action"
44 + :title="$store.goalBar.goal?.status === 'active' ? 'Pause goal' : 'Resume goal'"
45 + @click="$store.goalBar.pauseOrResume()"
46 + :disabled="$store.goalBar.saving">
47 + <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.goalBar.goal?.status === 'active' ? 'pause_circle' : 'play_circle'"></span>
48 + </button>
49 + <button type="button" class="goal-strip-action" title="Delete goal" @click="$confirmClick($event, () => $store.goalBar.deleteGoal())" :disabled="$store.goalBar.saving">
50 + <span class="material-symbols-outlined" aria-hidden="true">delete</span>
51 + </button>
52 + </div>
53 + </div>
54 + </template>
55 +</div>
56 +
57 +<style>
58 + #progress-bar-box.has-goal-bar {
59 + flex-wrap: wrap;
60 + row-gap: var(--spacing-xs);
61 + }
62 +
63 + #progress-bar-box.has-goal-bar .goal-strip-root {
64 + flex: 0 0 100%;
65 + min-width: 0;
66 + display: flex;
67 + width: 100%;
68 + }
69 +
70 + .goal-strip {
71 + width: 100%;
72 + min-height: 2rem;
73 + display: flex;
74 + align-items: center;
75 + gap: 0.42rem;
76 + padding: 0.18rem var(--spacing-sm);
77 + color: var(--color-text);
78 + background: transparent;
79 + border: none;
80 + border-radius: 0;
81 + box-shadow: none;
82 + }
83 +
84 + .goal-strip-icon {
85 + flex: 0 0 auto;
86 + font-size: 1rem;
87 + opacity: 0.75;
88 + }
89 +
90 + .goal-strip-copy {
91 + min-width: 0;
92 + display: flex;
93 + align-items: baseline;
94 + gap: 0.3rem;
95 + flex: 1 1 auto;
96 + overflow: hidden;
97 + }
98 +
99 + .goal-strip-status {
100 + flex: 0 0 auto;
101 + font-size: 0.76rem;
102 + font-weight: 600;
103 + }
104 +
105 + .goal-strip-objective {
106 + min-width: 0;
107 + overflow: hidden;
108 + text-overflow: ellipsis;
109 + white-space: nowrap;
110 + color: color-mix(in srgb, var(--color-text) 64%, transparent);
111 + font-size: 0.76rem;
112 + }
113 +
114 + .goal-strip-time {
115 + flex: 0 0 auto;
116 + color: color-mix(in srgb, var(--color-text) 44%, transparent);
117 + font-size: 0.76rem;
118 + white-space: nowrap;
119 + }
120 +
121 + .goal-strip-actions,
122 + .goal-strip-edit {
123 + display: flex;
124 + align-items: center;
125 + gap: 0.18rem;
126 + min-width: 0;
127 + }
128 +
129 + .goal-strip-edit {
130 + flex: 1 1 auto;
131 + }
132 +
133 + .goal-strip-input {
134 + flex: 1 1 auto;
135 + min-width: 8rem;
136 + height: 1.55rem;
137 + padding: 0 0.35rem;
138 + border: 1px solid color-mix(in srgb, var(--color-border) 82%, transparent);
139 + border-radius: 6px;
140 + background: color-mix(in srgb, var(--color-background) 80%, transparent);
141 + color: var(--color-text);
142 + font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
143 + font-size: 0.76rem;
144 + outline: none;
145 + }
146 +
147 + .goal-strip-input:focus {
148 + border-color: color-mix(in srgb, var(--color-text) 28%, var(--color-border));
149 + }
150 +
151 + .goal-strip-action {
152 + width: 1.55rem;
153 + height: 1.55rem;
154 + display: inline-flex;
155 + align-items: center;
156 + justify-content: center;
157 + padding: 0;
158 + border: none;
159 + border-radius: 6px;
160 + background: transparent;
161 + color: var(--color-text);
162 + opacity: 0.68;
163 + cursor: pointer;
164 + }
165 +
166 + .goal-strip-action:hover:not(:disabled) {
167 + opacity: 1;
168 + background: color-mix(in srgb, var(--color-border) 34%, transparent);
169 + }
170 +
171 + .goal-strip-action.confirming:not(:disabled) {
172 + opacity: 1;
173 + color: var(--color-warning, var(--color-text));
174 + background: color-mix(in srgb, var(--color-warning, var(--color-text)) 16%, transparent);
175 + }
176 +
177 + .goal-strip-action:disabled {
178 + opacity: 0.35;
179 + cursor: not-allowed;
180 + }
181 +
182 + .goal-strip-action .material-symbols-outlined {
183 + font-size: 1rem;
184 + }
185 +
186 + .goal-strip.is-paused .goal-strip-icon,
187 + .goal-strip.is-blocked .goal-strip-icon {
188 + color: var(--color-warning, var(--color-text));
189 + }
190 +
191 + .goal-strip.is-complete .goal-strip-icon {
192 + color: var(--color-success, var(--color-text));
193 + }
194 +
195 + @media (max-width: 768px) {
196 + .goal-strip {
197 + width: 100%;
198 + }
199 +
200 + .goal-strip-status,
201 + .goal-strip-objective,
202 + .goal-strip-time,
203 + .goal-strip-input {
204 + font-size: 0.72rem;
205 + }
206 + }
207 +</style>
plugins/_goal/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_goal/helpers/goals.py new
+304
@@ -0,0 +1,304 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +import re
5 +from datetime import datetime, timezone
6 +from pathlib import Path
7 +from typing import Any
8 +
9 +from helpers import files
10 +
11 +
12 +PLUGIN_NAME = "_goal"
13 +GOALS_DIR = "goals"
14 +ACTIVE_STATUSES = {"active", "paused"}
15 +FINAL_STATUSES = {"complete", "blocked"}
16 +VALID_STATUSES = ACTIVE_STATUSES | FINAL_STATUSES
17 +
18 +
19 +def get_goal(context_id: str) -> dict[str, Any] | None:
20 + context_id = _require_context_id(context_id)
21 + path = _goal_path(context_id)
22 + if not Path(path).is_file():
23 + return None
24 +
25 + try:
26 + raw = json.loads(files.read_file(path))
27 + except (OSError, json.JSONDecodeError):
28 + return None
29 + if not isinstance(raw, dict):
30 + return None
31 +
32 + goal = _normalize_goal(raw, context_id=context_id)
33 + if not goal.get("objective"):
34 + return None
35 + return goal
36 +
37 +
38 +def list_goals() -> list[dict[str, Any]]:
39 + directory = _goals_dir()
40 + if not Path(directory).is_dir():
41 + return []
42 +
43 + goals: list[dict[str, Any]] = []
44 + for goal_file in sorted(Path(directory).glob("*.json")):
45 + try:
46 + raw = json.loads(files.read_file(str(goal_file)))
47 + except (OSError, json.JSONDecodeError):
48 + continue
49 + if not isinstance(raw, dict):
50 + continue
51 + context_id = str(raw.get("context_id") or "").strip()
52 + if not context_id:
53 + continue
54 + goal = _normalize_goal(raw, context_id=context_id)
55 + if goal.get("objective"):
56 + goals.append(goal)
57 + return goals
58 +
59 +
60 +def create_goal(
61 + context_id: str,
62 + objective: str,
63 + *,
64 + created_by: str = "user",
65 + token_budget: int | None = None,
66 +) -> dict[str, Any]:
67 + context_id = _require_context_id(context_id)
68 + objective = _clean_objective(objective)
69 + if not objective:
70 + raise ValueError("Goal objective is required")
71 +
72 + now = _now()
73 + existing = get_goal(context_id)
74 + goal = {
75 + "context_id": context_id,
76 + "objective": objective,
77 + "status": "active",
78 + "created_by": _clean_created_by(created_by),
79 + "token_budget": _clean_token_budget(token_budget),
80 + "created_at": existing.get("created_at") if existing else now,
81 + "active_since": now,
82 + "elapsed_seconds": 0,
83 + "updated_at": now,
84 + "note": "",
85 + }
86 + _write_goal(goal)
87 + return goal
88 +
89 +
90 +def update_goal(
91 + context_id: str,
92 + *,
93 + objective: str | None = None,
94 + status: str | None = None,
95 + note: str | None = None,
96 + token_budget: int | None = None,
97 +) -> dict[str, Any]:
98 + context_id = _require_context_id(context_id)
99 + goal = get_goal(context_id)
100 + if not goal:
101 + raise FileNotFoundError("Goal not found")
102 +
103 + if objective is not None:
104 + cleaned_objective = _clean_objective(objective)
105 + if not cleaned_objective:
106 + raise ValueError("Goal objective is required")
107 + goal["objective"] = cleaned_objective
108 +
109 + if status is not None:
110 + _apply_status(goal, _normalize_status(status))
111 +
112 + if note is not None:
113 + goal["note"] = str(note or "").strip()
114 +
115 + if token_budget is not None:
116 + goal["token_budget"] = _clean_token_budget(token_budget)
117 +
118 + goal["updated_at"] = _now()
119 + _write_goal(goal)
120 + return goal
121 +
122 +
123 +def delete_goal(context_id: str) -> None:
124 + context_id = _require_context_id(context_id)
125 + files.delete_file(_goal_path(context_id))
126 +
127 +
128 +def public_goal(goal: dict[str, Any] | None) -> dict[str, Any] | None:
129 + if not goal:
130 + return None
131 + return {
132 + "context_id": str(goal.get("context_id") or ""),
133 + "objective": str(goal.get("objective") or ""),
134 + "status": str(goal.get("status") or "active"),
135 + "created_by": str(goal.get("created_by") or "user"),
136 + "token_budget": goal.get("token_budget"),
137 + "created_at": str(goal.get("created_at") or ""),
138 + "active_since": str(goal.get("active_since") or ""),
139 + "elapsed_seconds": _clean_elapsed_seconds(goal.get("elapsed_seconds")),
140 + "updated_at": str(goal.get("updated_at") or ""),
141 + "note": str(goal.get("note") or ""),
142 + }
143 +
144 +
145 +def summarize_goal(goal: dict[str, Any] | None) -> str:
146 + if not goal:
147 + return "No goal is set for this chat."
148 +
149 + status = str(goal.get("status") or "active")
150 + objective = str(goal.get("objective") or "").strip()
151 + elapsed = _format_elapsed(_elapsed_seconds(goal))
152 + updated = str(goal.get("updated_at") or "").strip()
153 + lines = [f"Status: {status}", f"Goal: {objective}", f"Active time: {elapsed}"]
154 + if updated:
155 + lines.append(f"Updated: {updated}")
156 + note = str(goal.get("note") or "").strip()
157 + if note:
158 + lines.append(f"Note: {note}")
159 + return "\n".join(lines)
160 +
161 +
162 +def _write_goal(goal: dict[str, Any]) -> None:
163 + files.write_file(
164 + _goal_path(str(goal["context_id"])),
165 + json.dumps(public_goal(goal), indent=2, ensure_ascii=False) + "\n",
166 + )
167 +
168 +
169 +def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:
170 + status = str(raw.get("status") or "active").strip().lower()
171 + if status not in VALID_STATUSES:
172 + status = "active"
173 +
174 + created_at = str(raw.get("created_at") or "")
175 + updated_at = str(raw.get("updated_at") or "")
176 + active_since = str(raw.get("active_since") or "")
177 + elapsed_seconds = _clean_elapsed_seconds(raw.get("elapsed_seconds"))
178 + if "elapsed_seconds" not in raw and status != "active":
179 + elapsed_seconds = _seconds_between(created_at, updated_at)
180 + if status == "active" and not active_since:
181 + active_since = created_at or updated_at
182 +
183 + return {
184 + "context_id": context_id,
185 + "objective": _clean_objective(str(raw.get("objective") or "")),
186 + "status": status,
187 + "created_by": _clean_created_by(str(raw.get("created_by") or "user")),
188 + "token_budget": _clean_token_budget(raw.get("token_budget")),
189 + "created_at": created_at,
190 + "active_since": active_since,
191 + "elapsed_seconds": elapsed_seconds,
192 + "updated_at": updated_at,
193 + "note": str(raw.get("note") or "").strip(),
194 + }
195 +
196 +
197 +def _goals_dir() -> str:
198 + return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, PLUGIN_NAME, GOALS_DIR)
199 +
200 +
201 +def _goal_path(context_id: str) -> str:
202 + return files.get_abs_path(_goals_dir(), f"{_safe_context_id(context_id)}.json")
203 +
204 +
205 +def _require_context_id(context_id: str) -> str:
206 + context_id = str(context_id or "").strip()
207 + if not context_id:
208 + raise ValueError("A chat context is required")
209 + return context_id
210 +
211 +
212 +def _safe_context_id(context_id: str) -> str:
213 + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", context_id).strip("._")
214 + if not safe:
215 + raise ValueError("A chat context is required")
216 + return safe[:180]
217 +
218 +
219 +def _clean_objective(objective: str) -> str:
220 + return re.sub(r"\s+", " ", str(objective or "")).strip()
221 +
222 +
223 +def _normalize_status(status: str) -> str:
224 + cleaned = str(status or "").strip().lower()
225 + if cleaned not in VALID_STATUSES:
226 + raise ValueError("Goal status must be active, paused, complete, or blocked")
227 + return cleaned
228 +
229 +
230 +def _clean_created_by(created_by: str) -> str:
231 + cleaned = str(created_by or "").strip().lower()
232 + return cleaned if cleaned in {"user", "model"} else "user"
233 +
234 +
235 +def _clean_token_budget(token_budget: Any) -> int | None:
236 + if token_budget in (None, ""):
237 + return None
238 + try:
239 + value = int(token_budget)
240 + except (TypeError, ValueError):
241 + return None
242 + return value if value > 0 else None
243 +
244 +
245 +def _apply_status(goal: dict[str, Any], status: str) -> None:
246 + current = str(goal.get("status") or "active")
247 + if current == "active" and status != "active":
248 + goal["elapsed_seconds"] = _elapsed_seconds(goal)
249 + goal["active_since"] = ""
250 + elif current != "active" and status == "active":
251 + goal["active_since"] = _now()
252 + goal["status"] = status
253 +
254 +
255 +def _elapsed_seconds(goal: dict[str, Any]) -> int:
256 + seconds = _clean_elapsed_seconds(goal.get("elapsed_seconds"))
257 + if str(goal.get("status") or "active") == "active":
258 + seconds += _seconds_between(str(goal.get("active_since") or ""), _now())
259 + return seconds
260 +
261 +
262 +def _clean_elapsed_seconds(value: Any) -> int:
263 + try:
264 + seconds = int(value)
265 + except (TypeError, ValueError):
266 + return 0
267 + return max(0, seconds)
268 +
269 +
270 +def _seconds_between(start: str, end: str) -> int:
271 + start_dt = _parse_time(start)
272 + end_dt = _parse_time(end)
273 + if not start_dt or not end_dt:
274 + return 0
275 + return max(0, int((end_dt - start_dt).total_seconds()))
276 +
277 +
278 +def _parse_time(value: str) -> datetime | None:
279 + text = str(value or "").strip()
280 + if not text:
281 + return None
282 + if text.endswith("Z"):
283 + text = f"{text[:-1]}+00:00"
284 + try:
285 + parsed = datetime.fromisoformat(text)
286 + except ValueError:
287 + return None
288 + if parsed.tzinfo is None:
289 + parsed = parsed.replace(tzinfo=timezone.utc)
290 + return parsed.astimezone(timezone.utc)
291 +
292 +
293 +def _format_elapsed(seconds: int) -> str:
294 + hours, remainder = divmod(max(0, int(seconds)), 3600)
295 + minutes, seconds = divmod(remainder, 60)
296 + if hours:
297 + return f"{hours}h {minutes}m"
298 + if minutes:
299 + return f"{minutes}m {seconds}s"
300 + return f"{seconds}s"
301 +
302 +
303 +def _now() -> str:
304 + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
plugins/_goal/plugin.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: _goal
2 +title: Goal
3 +description: Built-in chat goal tracking and goal tools.
4 +version: 0.1.0
5 +always_enabled: true
plugins/_goal/prompts/agent.extras.goal.md new
+7
@@ -0,0 +1,7 @@
1 +## current goal
2 +status: {{status}}
3 +objective: {{objective}}
4 +created by: {{created_by}}
5 +updated: {{updated_at}}
6 +
7 +Follow this goal while it is active. When the work is complete, call `update_goal` with `status="complete"` before your final answer. If progress is genuinely blocked, call `update_goal` with `status="blocked"` and explain what is missing.
plugins/_goal/prompts/agent.system.tool.create_goal.md new
+23
@@ -0,0 +1,23 @@
1 +### create_goal
2 +Create or replace the current chat goal.
3 +
4 +Use only when the user asks for a goal, asks you to manage a goal, or `/goal auto` asks you to create one.
5 +
6 +Args: `objective`, optional `token_budget`.
7 +
8 +Rules:
9 +- Create one concise objective that describes the current work, not a generic plan.
10 +- The new goal becomes active.
11 +- Do not create goals for casual replies or ordinary one-shot answers.
12 +
13 +Example:
14 +~~~json
15 +{
16 + "thoughts": ["The user asked me to manage this task as a goal."],
17 + "headline": "Creating goal",
18 + "tool_name": "create_goal",
19 + "tool_args": {
20 + "objective": "Add the built-in goal plugin with a Web UI strip and slash command"
21 + }
22 +}
23 +~~~
plugins/_goal/prompts/agent.system.tool.get_goal.md new
+10
@@ -0,0 +1,10 @@
1 +### get_goal
2 +Inspect the current chat goal.
3 +
4 +Use this when the user asks about the goal, asks you to manage a goal, or you need to check whether a goal already exists before creating one.
5 +
6 +Args: none.
7 +
8 +Rules:
9 +- Do not invent a goal if none exists.
10 +- If a goal is paused, complete, or blocked, treat it as state to report unless the user asks you to resume or replace it.
plugins/_goal/prompts/agent.system.tool.update_goal.md new
+11
@@ -0,0 +1,11 @@
1 +### update_goal
2 +Mark the current chat goal complete or blocked.
3 +
4 +Use when the active goal is actually achieved, or when progress is genuinely blocked by missing user input or an external-state change.
5 +
6 +Args: `status` (`complete` or `blocked`), optional `objective`, optional `note`.
7 +
8 +Rules:
9 +- Mark `complete` only when the objective has been achieved.
10 +- Mark `blocked` only when meaningful progress cannot continue without user input or an external-state change.
11 +- Pause, resume, edit, and delete are user controls; do not claim to perform them with this tool.
plugins/_goal/tests/conftest.py new
+8
@@ -0,0 +1,8 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +
5 +a0_root = str(Path(__file__).resolve().parents[3])
6 +while a0_root in sys.path:
7 + sys.path.remove(a0_root)
8 +sys.path.insert(0, a0_root)
plugins/_goal/tests/test_goal_plugin.py new
+124
@@ -0,0 +1,124 @@
1 +from __future__ import annotations
2 +
3 +import uuid
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +
8 +from helpers import files
9 +from plugins._goal.api.goal import Goal
10 +from plugins._goal.commands import goal_command
11 +from plugins._goal.helpers import goals
12 +from plugins._goal.tools.create_goal import CreateGoal
13 +from plugins._goal.tools.get_goal import GetGoal
14 +from plugins._goal.tools.update_goal import UpdateGoal
15 +
16 +
17 +@pytest.fixture()
18 +def context_id():
19 + context_id = f"goal-test-{uuid.uuid4().hex}"
20 + yield context_id
21 + goals.delete_goal(context_id)
22 +
23 +
24 +def _payload(context_id: str, command_text: str) -> dict:
25 + from plugins._commands.helpers.commands import parse_slash_invocation
26 +
27 + return {
28 + "invocation": parse_slash_invocation(command_text),
29 + "context": {"context_id": context_id},
30 + }
31 +
32 +
33 +def test_goal_storage_round_trip(context_id: str):
34 + goal = goals.create_goal(context_id, "Ship the goal plugin", token_budget=1200)
35 +
36 + loaded = goals.get_goal(context_id)
37 + assert loaded == goal
38 + assert loaded["status"] == "active"
39 + assert loaded["token_budget"] == 1200
40 + assert loaded["active_since"]
41 + assert loaded["elapsed_seconds"] == 0
42 +
43 + updated = goals.update_goal(context_id, status="paused", objective="Polish the goal strip")
44 + assert updated["status"] == "paused"
45 + assert updated["objective"] == "Polish the goal strip"
46 + assert updated["active_since"] == ""
47 + paused_seconds = updated["elapsed_seconds"]
48 +
49 + resumed = goals.update_goal(context_id, status="active")
50 + assert resumed["status"] == "active"
51 + assert resumed["active_since"]
52 + assert resumed["elapsed_seconds"] == paused_seconds
53 +
54 + goals.delete_goal(context_id)
55 + assert goals.get_goal(context_id) is None
56 +
57 +
58 +def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
59 + created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
60 + assert created["effects"][0]["message"] == "Goal set."
61 + assert created["effects"][2] == {"type": "send_message", "text": "Add current goal support"}
62 + assert goals.get_goal(context_id)["objective"] == "Add current goal support"
63 +
64 + paused = goal_command.run(_payload(context_id, "/goal pause"))
65 + assert paused["effects"][0]["message"] == "Goal paused."
66 + assert goals.get_goal(context_id)["status"] == "paused"
67 +
68 + resumed = goal_command.run(_payload(context_id, "/goal resume"))
69 + assert resumed["effects"][0]["message"] == "Goal resumed."
70 + assert goals.get_goal(context_id)["status"] == "active"
71 +
72 + deleted = goal_command.run(_payload(context_id, "/goal delete"))
73 + assert deleted["effects"][0]["message"] == "Goal deleted."
74 + assert goals.get_goal(context_id) is None
75 +
76 +
77 +def test_goal_auto_fills_prompt(context_id: str):
78 + result = goal_command.run(_payload(context_id, "/goal auto keep this tight"))
79 +
80 + assert "Please create and manage a goal" in result["text"]
81 + assert "User hint: keep this tight" in result["text"]
82 + assert result["effects"] == []
83 +
84 +
85 +def test_goal_files_stay_under_user_plugin_state(context_id: str):
86 + goals.create_goal(context_id, "Keep state in usr")
87 + goal_path = files.get_abs_path(
88 + files.USER_DIR,
89 + files.PLUGINS_DIR,
90 + goals.PLUGIN_NAME,
91 + goals.GOALS_DIR,
92 + f"{context_id}.json",
93 + )
94 +
95 + assert files.exists(goal_path)
96 +
97 +
98 +@pytest.mark.asyncio
99 +async def test_goal_api_and_agent_tools(context_id: str):
100 + handler = object.__new__(Goal)
101 + created = await handler.process(
102 + {
103 + "action": "set",
104 + "context_id": context_id,
105 + "objective": "Exercise API path",
106 + },
107 + None,
108 + )
109 + assert created["ok"] is True
110 + assert created["goal"]["objective"] == "Exercise API path"
111 +
112 + fake_agent = SimpleNamespace(context=SimpleNamespace(id=context_id))
113 + get_tool = GetGoal(fake_agent, "get_goal", None, {}, "", None)
114 + get_response = await get_tool.execute()
115 + assert "Exercise API path" in get_response.message
116 +
117 + update_tool = UpdateGoal(fake_agent, "update_goal", None, {}, "", None)
118 + update_response = await update_tool.execute(status="complete")
119 + assert "Status: complete" in update_response.message
120 +
121 + create_tool = CreateGoal(fake_agent, "create_goal", None, {}, "", None)
122 + create_response = await create_tool.execute(objective="Exercise tool path")
123 + assert "Goal created: Exercise tool path" == create_response.message
124 + assert goals.get_goal(context_id)["created_by"] == "model"
plugins/_goal/tools/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_goal/tools/create_goal.py new
+23
@@ -0,0 +1,23 @@
1 +from __future__ import annotations
2 +
3 +from helpers.tool import Response, Tool
4 +from plugins._goal.helpers import goals
5 +
6 +
7 +class CreateGoal(Tool):
8 + async def execute(
9 + self,
10 + objective: str = "",
11 + token_budget: int | None = None,
12 + **kwargs,
13 + ) -> Response:
14 + goal = goals.create_goal(
15 + self.agent.context.id,
16 + objective,
17 + created_by="model",
18 + token_budget=token_budget,
19 + )
20 + return Response(
21 + message=f"Goal created: {goal['objective']}",
22 + break_loop=False,
23 + )
plugins/_goal/tools/get_goal.py new
+10
@@ -0,0 +1,10 @@
1 +from __future__ import annotations
2 +
3 +from helpers.tool import Response, Tool
4 +from plugins._goal.helpers import goals
5 +
6 +
7 +class GetGoal(Tool):
8 + async def execute(self, **kwargs) -> Response:
9 + goal = goals.get_goal(self.agent.context.id)
10 + return Response(message=goals.summarize_goal(goal), break_loop=False)
plugins/_goal/tools/update_goal.py new
+31
@@ -0,0 +1,31 @@
1 +from __future__ import annotations
2 +
3 +from helpers.tool import Response, Tool
4 +from plugins._goal.helpers import goals
5 +
6 +
7 +class UpdateGoal(Tool):
8 + async def execute(
9 + self,
10 + status: str = "",
11 + objective: str = "",
12 + note: str = "",
13 + **kwargs,
14 + ) -> Response:
15 + normalized_status = str(status or "").strip().lower()
16 + if normalized_status not in {"complete", "blocked"}:
17 + return Response(
18 + message="Model-managed goal updates may only mark goals complete or blocked.",
19 + break_loop=False,
20 + )
21 +
22 + goal = goals.update_goal(
23 + self.agent.context.id,
24 + status=normalized_status,
25 + objective=objective if objective else None,
26 + note=note if note else None,
27 + )
28 + return Response(
29 + message=goals.summarize_goal(goal),
30 + break_loop=False,
31 + )
plugins/_goal/webui/goal-store.js new
+206
@@ -0,0 +1,206 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 +import {
5 + toastFrontendError,
6 + toastFrontendSuccess,
7 +} from "/components/notifications/notification-store.js";
8 +
9 +const GOAL_API_PATH = "/plugins/_goal/goal";
10 +
11 +const model = {
12 + goal: null,
13 + loading: false,
14 + saving: false,
15 + editing: false,
16 + draft: "",
17 + lastContextId: "",
18 + intervalId: null,
19 + clockIntervalId: null,
20 + goalChangedHandler: null,
21 + now: Date.now(),
22 +
23 + get visible() {
24 + return Boolean(this.goal?.objective && this.goal.status !== "complete");
25 + },
26 +
27 + get contextId() {
28 + return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
29 + },
30 +
31 + get statusLabel() {
32 + const status = this.goal?.status || "active";
33 + if (status === "paused") return "Goal paused";
34 + if (status === "complete") return "Goal complete";
35 + if (status === "blocked") return "Goal blocked";
36 + return "Pursuing goal";
37 + },
38 +
39 + get statusIcon() {
40 + const status = this.goal?.status || "active";
41 + if (status === "paused") return "pause_circle";
42 + if (status === "complete") return "check_circle";
43 + if (status === "blocked") return "error";
44 + return "track_changes";
45 + },
46 +
47 + get elapsedSeconds() {
48 + if (!this.goal) return 0;
49 + const status = this.goal.status || "active";
50 + const storedSeconds = Number.parseInt(this.goal.elapsed_seconds, 10);
51 + let seconds = Number.isNaN(storedSeconds) ? 0 : storedSeconds;
52 + if (Number.isNaN(storedSeconds) && status !== "active") {
53 + seconds = this.secondsBetween(this.goal.created_at, this.goal.updated_at);
54 + }
55 + if (status === "active") {
56 + seconds += this.secondsBetween(
57 + this.goal.active_since || this.goal.created_at || this.goal.updated_at,
58 + this.now,
59 + );
60 + }
61 + return Math.max(0, seconds);
62 + },
63 +
64 + secondsBetween(start, end) {
65 + const startMs = Date.parse(start || "");
66 + const endMs = typeof end === "number" ? end : Date.parse(end || "");
67 + if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0;
68 + return Math.max(0, Math.floor((endMs - startMs) / 1000));
69 + },
70 +
71 + get elapsedLabel() {
72 + const seconds = this.elapsedSeconds;
73 + const hours = Math.floor(seconds / 3600);
74 + const minutes = Math.floor((seconds % 3600) / 60);
75 + const remainingSeconds = seconds % 60;
76 + if (hours) return `${hours}h ${minutes}m`;
77 + if (minutes) return `${minutes}m ${remainingSeconds}s`;
78 + return `${remainingSeconds}s`;
79 + },
80 +
81 + onMount() {
82 + document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
83 + this.goalChangedHandler = (event) => {
84 + const detail = event?.detail || {};
85 + if (detail.goal === null) {
86 + this.goal = null;
87 + }
88 + void this.refresh(true);
89 + };
90 + window.addEventListener("goal:changed", this.goalChangedHandler);
91 + this.clockIntervalId = window.setInterval(() => {
92 + this.now = Date.now();
93 + }, 1000);
94 + this.intervalId = window.setInterval(() => this.refresh(), 3000);
95 + void this.refresh(true);
96 + },
97 +
98 + cleanup() {
99 + document.getElementById("progress-bar-box")?.classList.remove("has-goal-bar");
100 + if (this.goalChangedHandler) {
101 + window.removeEventListener("goal:changed", this.goalChangedHandler);
102 + }
103 + if (this.intervalId) {
104 + window.clearInterval(this.intervalId);
105 + }
106 + if (this.clockIntervalId) {
107 + window.clearInterval(this.clockIntervalId);
108 + }
109 + this.goalChangedHandler = null;
110 + this.intervalId = null;
111 + this.clockIntervalId = null;
112 + },
113 +
114 + async refresh(force = false) {
115 + const contextId = this.contextId;
116 + if (!contextId) {
117 + this.goal = null;
118 + this.lastContextId = "";
119 + return;
120 + }
121 + if (!force && this.loading) return;
122 +
123 + this.loading = true;
124 + try {
125 + const response = await callJsonApi(GOAL_API_PATH, {
126 + action: "get",
127 + context_id: contextId,
128 + });
129 + this.goal = response?.goal || null;
130 + this.now = Date.now();
131 + this.lastContextId = contextId;
132 + if (!this.goal) this.editing = false;
133 + } catch (error) {
134 + console.error("Failed to load goal:", error);
135 + this.goal = null;
136 + this.lastContextId = contextId;
137 + } finally {
138 + this.loading = false;
139 + }
140 + },
141 +
142 + startEdit() {
143 + if (!this.goal) return;
144 + this.draft = this.goal.objective || "";
145 + this.editing = true;
146 + requestAnimationFrame(() => {
147 + document.querySelector(".goal-strip-input")?.focus?.();
148 + document.querySelector(".goal-strip-input")?.select?.();
149 + });
150 + },
151 +
152 + cancelEdit() {
153 + this.editing = false;
154 + this.draft = "";
155 + },
156 +
157 + async saveEdit() {
158 + const objective = (this.draft || "").trim();
159 + if (!objective) {
160 + void toastFrontendError("Goal objective is required.", "Goal");
161 + return;
162 + }
163 + await this.update({ action: "update", objective, status: "active" }, "Goal updated.");
164 + this.editing = false;
165 + },
166 +
167 + async pauseOrResume() {
168 + if (!this.goal) return;
169 + const action = this.goal.status === "active" ? "pause" : "resume";
170 + await this.update(
171 + { action },
172 + action === "pause" ? "Goal paused." : "Goal resumed.",
173 + );
174 + },
175 +
176 + async deleteGoal() {
177 + await this.update({ action: "delete" }, "Goal deleted.");
178 + },
179 +
180 + async update(payload, successMessage) {
181 + const contextId = this.contextId;
182 + if (!contextId || this.saving) return;
183 +
184 + this.saving = true;
185 + try {
186 + const response = await callJsonApi(GOAL_API_PATH, {
187 + ...payload,
188 + context_id: contextId,
189 + });
190 + this.goal = response?.goal || null;
191 + this.now = Date.now();
192 + this.lastContextId = contextId;
193 + window.dispatchEvent(new CustomEvent("goal:changed", {
194 + detail: { goal: this.goal, context_id: contextId },
195 + }));
196 + void toastFrontendSuccess(successMessage, "Goal");
197 + } catch (error) {
198 + console.error("Failed to update goal:", error);
199 + void toastFrontendError(error?.message || "Failed to update goal.", "Goal");
200 + } finally {
201 + this.saving = false;
202 + }
203 + },
204 +};
205 +
206 +export const store = createStore("goalBar", model);
plugins/_goal/webui/thumbnail.png
Binary files /dev/null and b/plugins/_goal/webui/thumbnail.png differ