diff --git a/README.md b/README.md index ba18234..7c022d3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The OpenCode Goal Plugin adds: - `/goal ` as an OpenCode command for TUI, desktop, and web. - A sidebar goal indicator with status, elapsed time, and objective. -- Agent tools: `get_goal`, `get_goal_history`, `create_goal`, `set_goal`, `update_goal_objective`, `update_goal`, and `clear_goal`. +- Agent tools: `get_goal`, `get_goal_history`, `list_all_goals`, `create_goal`, `set_goal`, `update_goal_objective`, `update_goal`, and `clear_goal`. - Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker. - Persistent per-session goal state with history, checkpoints, budgets, and owner-only file permissions. - Optional automatic continuation on `session.idle` / `session.status`, with no-progress pause and budget wrap-up safeguards. diff --git a/dist/server.js b/dist/server.js index cdf2e55..f70626d 100644 --- a/dist/server.js +++ b/dist/server.js @@ -127,6 +127,7 @@ class StateWriteError extends Data.TaggedError("StateWriteError") { } var MAX_HISTORY_ENTRIES = 50; var MAX_CHECKPOINTS = 8; +var MAX_LISTED_GOALS = 50; var CHECKPOINT_CHAR_LIMIT = 280; var DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 50; var DEFAULT_MAX_NO_PROGRESS_TURNS = 2; @@ -456,6 +457,30 @@ async function getGoal(sessionID) { const goal = state.goals[sessionID]; return goal ? snapshot(goal) : null; } +async function getAllGoals() { + const state = await readState(); + const sorted = Object.values(state.goals).sort((left, right) => right.updatedAt - left.updatedAt || (left.sessionID < right.sessionID ? -1 : left.sessionID > right.sessionID ? 1 : 0)); + const goals = sorted.slice(0, MAX_LISTED_GOALS).map(goalListItem); + return { goals, total: sorted.length, truncated: sorted.length > goals.length }; +} +function goalListItem(goal) { + return { + sessionID: goal.sessionID, + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + tokensUsed: goal.tokensUsed, + timeUsedSeconds: goal.timeUsedSeconds, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + closedAt: goal.closedAt ?? null, + maxAutoTurns: goal.maxAutoTurns, + maxDurationSeconds: goal.maxDurationSeconds, + autoTurns: goal.autoTurns, + stopReason: goal.stopReason, + remainingTokens: remainingTokens(goal) + }; +} async function getGoalInternal(sessionID) { const state = await readState(); const goal = state.goals[sessionID]; @@ -1140,7 +1165,7 @@ var STALE_PENDING_MS = 30000; var RETRY_SETTLE_MS = 25; var TRANSPORT_ERROR_PATTERN = /\b(?:network|fetch|socket|connect|connection|timeout|timed out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|transport|stream|websocket|offline|internet|request failed|proxy)\b/i; var NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/i; -var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history"]); +var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history", "list_all_goals"]); var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]); var PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.'; var LIMITED_GOAL_NOTICE = "Safety limit reached. Do not start or continue substantive work for this goal. Summarize useful progress, remaining work, and blockers, then wait for the user to resume or edit the goal."; @@ -2171,6 +2196,13 @@ var server = async ({ client }, options) => { return JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2); } }, + list_all_goals: { + description: "List up to 50 public goal summaries across all sessions in this state file, ordered by most recently updated first. Elapsed time is the last persisted value; total and truncated report omitted older goals.", + args: {}, + async execute() { + return JSON.stringify(await getAllGoals(), null, 2); + } + }, create_goal: { description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", args: { @@ -3046,6 +3078,15 @@ function goalToolsV2(services) { return { content: JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2) }; } }, + { + name: "list_all_goals", + description: "List up to 50 public goal summaries across all sessions in this state file, ordered by most recently updated first. Elapsed time is the last persisted value; total and truncated report omitted older goals.", + input: v2ObjectSchema({}), + options: { codemode: false }, + execute: async () => ({ + content: JSON.stringify(await getAllGoals(), null, 2) + }) + }, { name: "create_goal", description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", diff --git a/src/server.ts b/src/server.ts index c0d6478..c63c4a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import { createGoal, estimateTokensFromText, formatGoalHistory, + getAllGoals, getGoal, getGoalInternal, markGoalUnmet, @@ -78,7 +79,7 @@ const RETRY_SETTLE_MS = 25 const TRANSPORT_ERROR_PATTERN = /\b(?:network|fetch|socket|connect|connection|timeout|timed out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|transport|stream|websocket|offline|internet|request failed|proxy)\b/i const NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/i -const NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history"]) +const NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history", "list_all_goals"]) const TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]) const PLAN_MODE_CREATE_NOTICE = 'Goal recorded while the session is in Plan mode, so execution is paused. Do not start implementation work now. Ask the user to switch to Build mode and resume the goal (for example with "/goal resume") to begin execution.' @@ -1265,6 +1266,14 @@ const server: Plugin = async ({ client }, options?: Options) => { return JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2) }, }, + list_all_goals: { + description: + "List up to 50 public goal summaries across all sessions in this state file, ordered by most recently updated first. Elapsed time is the last persisted value; total and truncated report omitted older goals.", + args: {}, + async execute() { + return JSON.stringify(await getAllGoals(), null, 2) + }, + }, create_goal: { description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. If any non-closed goal exists, this returns the existing goal as either reused or conflicting and must not be retried. While the session is in Plan mode, the goal is recorded as paused and execution requires the user to switch to Build mode.", @@ -2208,6 +2217,16 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] { return { content: JSON.stringify({ goal, history_report: formatGoalHistory(goal) }, null, 2) } }, }, + { + name: "list_all_goals", + description: + "List up to 50 public goal summaries across all sessions in this state file, ordered by most recently updated first. Elapsed time is the last persisted value; total and truncated report omitted older goals.", + input: v2ObjectSchema({}), + options: { codemode: false }, + execute: async () => ({ + content: JSON.stringify(await getAllGoals(), null, 2), + }), + }, { name: "create_goal", description: diff --git a/src/state.ts b/src/state.ts index d150581..bb810ec 100644 --- a/src/state.ts +++ b/src/state.ts @@ -140,6 +140,7 @@ class StateWriteError extends Data.TaggedError("StateWriteError")<{ const MAX_HISTORY_ENTRIES = 50 const MAX_CHECKPOINTS = 8 +const MAX_LISTED_GOALS = 50 const CHECKPOINT_CHAR_LIMIT = 280 const DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 50 const DEFAULT_MAX_NO_PROGRESS_TURNS = 2 @@ -244,6 +245,23 @@ export type InternalGoalSnapshot = GoalSnapshot & { pendingAttempt: PendingAttempt | null } +export type GoalListItem = Pick< + Goal, + | "sessionID" + | "objective" + | "status" + | "tokenBudget" + | "tokensUsed" + | "timeUsedSeconds" + | "createdAt" + | "updatedAt" + | "closedAt" + | "maxAutoTurns" + | "maxDurationSeconds" + | "autoTurns" + | "stopReason" +> & { remainingTokens: number | null } + function defaultStateFile() { const dataHome = process.env.XDG_DATA_HOME || @@ -573,6 +591,35 @@ export async function getGoal(sessionID: string) { return goal ? snapshot(goal) : null } +export async function getAllGoals() { + const state = await readState() + const sorted = Object.values(state.goals).sort( + (left, right) => + right.updatedAt - left.updatedAt || (left.sessionID < right.sessionID ? -1 : left.sessionID > right.sessionID ? 1 : 0), + ) + const goals = sorted.slice(0, MAX_LISTED_GOALS).map(goalListItem) + return { goals, total: sorted.length, truncated: sorted.length > goals.length } +} + +function goalListItem(goal: Goal): GoalListItem { + return { + sessionID: goal.sessionID, + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + tokensUsed: goal.tokensUsed, + timeUsedSeconds: goal.timeUsedSeconds, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + closedAt: goal.closedAt ?? null, + maxAutoTurns: goal.maxAutoTurns, + maxDurationSeconds: goal.maxDurationSeconds, + autoTurns: goal.autoTurns, + stopReason: goal.stopReason, + remainingTokens: remainingTokens(goal), + } +} + export async function getGoalInternal(sessionID: string) { const state = await readState() const goal = state.goals[sessionID] diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 5a2a216..0151ff2 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -10,6 +10,7 @@ const TOOL_NAMES = [ "create_goal", "get_goal", "get_goal_history", + "list_all_goals", "set_goal", "update_goal", "update_goal_objective", @@ -227,6 +228,28 @@ test("V2 setup registers goal tools with JSON Schema inputs, codemode:false, and expect(mock.promptCalls).toHaveLength(0) }) +test("V2 list_all_goals returns goals from other sessions", async () => { + const mock = makeMockContext({ auto_continue: false }) + const cleanup = await plugin.setup(mock as never) + await goalTool(mock, "create_goal").execute( + { objective: "first V2 session goal" }, + toolContext("ses_first"), + ) + await goalTool(mock, "create_goal").execute( + { objective: "second V2 session goal" }, + toolContext("ses_second"), + ) + + const listed = await goalTool(mock, "list_all_goals").execute({}, toolContext("ses_observer")) + + expect(contentOf(listed)).toContain('"sessionID": "ses_first"') + expect(contentOf(listed)).toContain('"sessionID": "ses_second"') + expect(contentOf(listed)).not.toContain("usageTrackers") + expect(contentOf(listed)).not.toContain("pendingAttempt") + mock.stream.end() + await cleanup() +}) + test("V2 create_goal recovers from a zero-filled state file", async () => { await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "\u0000\u0000", "utf8") const mock = makeMockContext({ auto_continue: false }) diff --git a/test/server.test.ts b/test/server.test.ts index b1df3fb..791d0ec 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -74,6 +74,7 @@ test("server plugin exposes Codex-style goal tools", async () => { "create_goal", "get_goal", "get_goal_history", + "list_all_goals", "set_goal", "update_goal", "update_goal_objective", @@ -97,6 +98,32 @@ test("server plugin exposes Codex-style goal tools", async () => { expect(calls).toHaveLength(0) }) +test("list_all_goals returns goals from other sessions", async () => { + const hooks = await plugin.server( + { client: { session: { promptAsync: async () => {} } } } as never, + { auto_continue: false }, + ) + const tools = hooks.tool! + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "first session goal" }, + { sessionID: "ses_first" } as never, + ) + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "second session goal" }, + { sessionID: "ses_second" } as never, + ) + + const listed = await requireTool(tools.list_all_goals, "list_all_goals").execute( + {}, + { sessionID: "ses_observer" } as never, + ) + + expect(String(listed)).toContain('"sessionID": "ses_first"') + expect(String(listed)).toContain('"sessionID": "ses_second"') + expect(String(listed)).not.toContain("usageTrackers") + expect(String(listed)).not.toContain("pendingAttempt") +}) + test("set goal lets the agent formulate the goal objective", async () => { const hooks = await plugin.server( { @@ -2770,6 +2797,11 @@ test("tool progress honors completed states and never resets on failed or incomp { title: "get_goal", output: '{"goal":{"status":"active"}}', metadata: {} } as never, ) expect((await getGoal("ses_1"))?.continuationFailures).toBe(1) + await hooks["tool.execute.after"]!( + { tool: "list_all_goals", sessionID: "ses_1", callID: "call_list_all_goals", args: {} } as never, + { title: "list_all_goals", output: '{"goals":[]}', metadata: {} } as never, + ) + expect((await getGoal("ses_1"))?.continuationFailures).toBe(1) // Incomplete, failed, cancelled, and aborted states must not reset even // without an error string. diff --git a/test/state.test.ts b/test/state.test.ts index bf5e5e5..433b0c9 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, spyOn, test } from "bun:test" +import { afterEach, beforeEach, expect, setSystemTime, spyOn, test } from "bun:test" import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -7,6 +7,7 @@ import { clearGoal, completeGoal, createGoal, + getAllGoals, markPendingContinuationStarted, recordAssistantProgress, getGoal, @@ -54,6 +55,51 @@ test("creates, reads, pauses, resumes, completes, and clears a goal", async () = expect(await getGoal("ses_1")).toBeNull() }) +test("lists public goals across sessions by most recent update", async () => { + expect(await getAllGoals()).toEqual({ goals: [], total: 0, truncated: false }) + + try { + setSystemTime(new Date(100_000)) + await createGoal("ses_old", "older goal", null) + await accountUsage("ses_old", 500, { cumulative: true, source: "private-test-source" }) + await reserveContinuation("ses_old", 10, 0) + + setSystemTime(new Date(200_000)) + await createGoal("ses_new", "newer goal", null) + + const listed = await getAllGoals() + expect(listed).toMatchObject({ total: 2, truncated: false }) + expect(listed.goals.map((goal) => goal.sessionID)).toEqual(["ses_new", "ses_old"]) + expect(listed.goals.map((goal) => goal.objective)).toEqual(["newer goal", "older goal"]) + expect(listed.goals.find((goal) => goal.sessionID === "ses_old")?.timeUsedSeconds).toBe(0) + for (const goal of listed.goals) { + expect(goal).not.toHaveProperty("usageTrackers") + expect(goal).not.toHaveProperty("pendingAttempt") + expect(goal).not.toHaveProperty("history") + expect(goal).not.toHaveProperty("checkpoints") + expect(goal).not.toHaveProperty("lastAssistantText") + expect(goal).not.toHaveProperty("completionEvidence") + expect(goal).not.toHaveProperty("blocker") + } + } finally { + setSystemTime() + } +}) + +test("caps cross-session goal listings and reports truncation", async () => { + for (let index = 50; index >= 0; index -= 1) { + await createGoal(`ses_${String(index).padStart(2, "0")}`, `goal ${index}`, null) + } + + const listed = await getAllGoals() + + expect(listed.total).toBe(51) + expect(listed.truncated).toBe(true) + expect(listed.goals).toHaveLength(50) + expect(listed.goals[0]?.sessionID).toBe("ses_00") + expect(listed.goals.at(-1)?.sessionID).toBe("ses_49") +}) + test("marks a goal unmet with a blocker and allows a new goal afterward", async () => { await createGoal("ses_1", "ship the plugin", 100) const unmet = await markGoalUnmet("ses_1", "missing external credentials")