Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The OpenCode Goal Plugin adds:

- `/goal <objective>` 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.
Expand Down
43 changes: 42 additions & 1 deletion dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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.",
Expand Down
21 changes: 20 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createGoal,
estimateTokensFromText,
formatGoalHistory,
getAllGoals,
getGoal,
getGoalInternal,
markGoalUnmet,
Expand Down Expand Up @@ -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<TaskState>(["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.'
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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]
Expand Down
23 changes: 23 additions & 0 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const TOOL_NAMES = [
"create_goal",
"get_goal",
"get_goal_history",
"list_all_goals",
"set_goal",
"update_goal",
"update_goal_objective",
Expand Down Expand Up @@ -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 })
Expand Down
32 changes: 32 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
{
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 47 additions & 1 deletion test/state.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -7,6 +7,7 @@ import {
clearGoal,
completeGoal,
createGoal,
getAllGoals,
markPendingContinuationStarted,
recordAssistantProgress,
getGoal,
Expand Down Expand Up @@ -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")
Expand Down