@prevalentware/opencode-goal-plugin 0.1.29 → 0.1.31

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