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