diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b7d16..77992d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Document OpenCode 2 status explicitly: unsupported and untested, with the existing dual-shape session adapter noted and a concrete checklist of what a supported claim would require. Records that this plugin is server-only, so its configuration lives entirely in `opencode.json` on any OpenCode line. +- Add an opt-in session-title status indicator (`sessionTitleStatus`), mirroring live goal status — state icon, objective, turns, elapsed, and tokens — into the OpenCode session title so unattended runs show a continuous heartbeat. The original title is captured before the first overwrite and restored by `/goal clear`; unchanged renders skip the API call, and update failures are logged at debug level without interrupting the goal loop. The indicator refreshes on commands and on idle/compaction/interruption events but never on the `message.updated` events that stream during a turn, keeping API round-trips out of the response path. Status lines written by a previous process are recognized as the plugin's own, so a restart followed by `/goal clear` cannot promote stale goal status to the session's permanent title. + - Hold new goals created while a planning-only agent is active instead of starting them. Previously the idle guard caught a Plan-agent session only on the *next* idle, so `/goal ` in Plan mode created a live goal and the routed command text still told the model to start working. Such a goal is now recorded with stop reason `plan agent active`, keeps its budget, and is announced through a read-only control turn that explicitly tells the model not to begin work. - Add `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`) so the planning-only restriction can name other agents or be released entirely. The default-on behavior is pinned by the mutation contract. diff --git a/README.md b/README.md index c9b2171..5c3fee6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ This project is independently implemented for OpenCode. Product names used elsew | Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows | | Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools | | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests | +| OpenCode 2 | Not supported and not yet tested; the peer/engine pin is `>=1.17.15 <2`. See the [OpenCode 2 section](docs/compatibility.md#opencode-2) | See the [compatibility policy](docs/compatibility.md) for the supported public surface and versioning expectations. @@ -401,6 +402,32 @@ await GoalPlugin( `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. `failurePolicy` defaults to `reject`: an unavailable API, missing child-session ID, provider error, or timeout rejects the audit and pauses the goal for review. Set it to `approve` only as an explicit compatibility escape hatch; an actual negative or malformed verifier verdict still rejects. `auditorOptions` is ignored when a custom `auditor` function is supplied. +## Status indicator + +Unattended runs are easier to trust when you can see the goal is still alive. Set `sessionTitleStatus: true` and the plugin mirrors live goal status into the OpenCode session title, which the TUI renders persistently: + +``` +▶ ship the release · 3/10 · 2m · 45k/200k +``` + +Status icon, objective, auto-continues used / limit, elapsed time, and context tokens / budget. The icon distinguishes running (`▶`), paused (`⏸`), and blocked (`⛔`) — blocked outranks paused because it needs you, not just a resume. A paused goal freezes its elapsed clock rather than running on. + +```json +{ + "plugin": [ + ["opencode-goal-plugin", { "sessionTitleStatus": true }] + ] +} +``` + +The option is **off by default** because it overwrites a user-visible field. When enabled, the session's original title is captured before the first overwrite and restored by `/goal clear`. A render identical to the last one skips the API call, so `/goal status` and other read-only commands cost nothing. Title updates are cosmetic: a failure is logged at debug level and never interrupts the goal loop. + +The indicator refreshes on goal commands and on idle, compaction, and interruption events — **not** on the `message.updated` events that stream during an assistant turn. Streaming refreshes would put an API round-trip in the response path for a cosmetic update, and idle is the cadence a human actually reads the indicator at. + +The captured original title lives in memory only, so a hard process kill leaves the last status line on the session. The plugin recognizes its own status lines and will not mistake one for your title, so `/goal clear` after a restart leaves the host's title alone rather than restoring stale goal status — but it cannot recover the title the session had before the goal started. Rename the session if you want it back. + +This needs no TUI plugin entrypoint, no `@opentui` dependencies, and no build step. + ## Plan-mode safety A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default: diff --git a/docs/compatibility.md b/docs/compatibility.md index b01594d..01a5da9 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -81,6 +81,56 @@ comes from the rewritten turn's escaped reporting frame, fail-closed tool blocking, and parent-correlated lifecycle suppression. The system transform remains registered as additional protection for hosts that support it. +## OpenCode 2 + +**Status: not supported, and not yet tested.** + +The package declares `engines.opencode` and the `@opencode-ai/plugin` peer as +`>=1.17.15 <2`. That bound is deliberate: no claim in this repository is made +without a verified run behind it, and the project has not yet exercised the +plugin against an OpenCode 2 build. Treat OpenCode 2 as unverified rather than +as known-broken. + +### What already exists in this direction + +- `createOpenCodeSessionApi` speaks both the legacy generated-client shape + (`{ path, body, query }`) and the flattened shape (`{ sessionID, ... }`), + selected per operation and remembered after the first success. The + `sdkShape: "flat"` option pins the flattened shape for embedded clients. +- Only read-only operations are ever replayed against the alternate shape, so a + shape probe can never duplicate a mutating call. This invariant is pinned by + the mutation contract. + +### What a supported v2 claim would require + +Before the pin is widened, all of the following need to pass against a real +OpenCode 2 build, not a mock: + +1. Plugin load and hook registration through the v2 plugin entrypoint. +2. `command.execute.before`, `event`, `experimental.chat.system.transform`, + `experimental.session.compacting`, and `experimental.compaction.autocontinue` + firing with the shapes the plugin expects. +3. The execution-context signals (`chat.message`, `chat.params`, + `session.updated`) still reporting the active agent, which the planning-only + restriction depends on. +4. Session-API calls (`messages`, `promptAsync`, `create`, `get`, `update`, + `abort`) under whichever argument shape v2 ships. +5. Goal-specific compaction context and recovery of running child sessions after + a plugin restart, which are the areas most likely to differ. + +### Configuration + +This plugin is **server-only**: `package.json` exports the root and +`opencode-goal-plugin/server`, and there is no TUI plugin entrypoint. Its +configuration therefore lives entirely in `opencode.json` (the `plugin` and +`command` keys) on any OpenCode line. + +Plugins that *do* ship a TUI component are registered in a second file whose +location differs between OpenCode lines, and those formats must not be mixed. +That distinction does not apply here — including for the +[status indicator](../README.md#status-indicator), which reaches the TUI through +the session title rather than through a TUI plugin. + ## Versioning Semantic-versioning intent is: diff --git a/index.d.ts b/index.d.ts index c214fd5..2d502ca 100644 --- a/index.d.ts +++ b/index.d.ts @@ -311,6 +311,18 @@ export interface GoalPluginOptions { /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */ registerAgents?: boolean + /** + * Mirror live goal status into the OpenCode session title, which the TUI + * renders persistently (e.g. `▶ ship the release · 3/10 · 2m · 45k/200k`), + * giving unattended runs a continuous heartbeat without a TUI plugin. + * + * The session's original title is captured before the first overwrite and + * restored by `/goal clear`. Title updates are cosmetic: a failure is logged + * at debug level and never interrupts the goal loop. + * @default false + */ + sessionTitleStatus?: boolean + /** * Agent names treated as planning-only. A goal created while one of these * agents is active is recorded but held paused instead of starting, and diff --git a/src/goal-plugin.js b/src/goal-plugin.js index d4658de..8675c96 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -111,6 +111,11 @@ function createRuntimeState() { seenIdleEventIDs: new Set(), sessionStatuses: new Map(), sessionExecutionContexts: new Map(), + // Session-title indicator: the user's own title, captured before the plugin + // first overwrites it, and the last title the plugin wrote (so an unchanged + // render skips the API call). + sessionTitles: new Map(), + appliedTitles: new Map(), pendingCommandTurns: new Map(), activeCommandTurns: new Map(), commandOutputs: new WeakMap(), @@ -422,6 +427,64 @@ function isPlanAgent(agent) { return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS) } +// Session-title status indicator. OpenCode renders the session title +// persistently, so mirroring goal progress into it gives unattended runs a +// continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it +// overwrites a user-visible field. +const SESSION_TITLE_OBJECTIVE_LIMIT = 48 +const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"] + +// The title sits in a narrow column, so every field is abbreviated hard. +function formatCompactDuration(ms) { + const totalSeconds = Math.max(0, Math.round(ms / 1000)) + if (totalSeconds < 60) return `${totalSeconds}s` + const totalMinutes = Math.floor(totalSeconds / 60) + if (totalMinutes < 60) return `${totalMinutes}m` + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return minutes ? `${hours}h${minutes}m` : `${hours}h` +} + +function formatCompactTokens(tokens) { + const value = toNonNegativeInteger(tokens) + if (value < 1000) return String(value) + if (value < 1_000_000) { + const thousands = value / 1000 + return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k` + } + const millions = value / 1_000_000 + return `${millions < 10 ? millions.toFixed(1) : Math.round(millions)}m` +} + +// Blocked and paused are distinct to a watching human: one needs input, the +// other just needs a resume. +function goalStatusIcon(goal) { + if (goal.blockedReason) return "⛔" + if (goal.stopped) return "⏸" + return "▶" +} + +// One-line goal status for the session title, e.g. +// "▶ ship the release · 3/10 · 2m · 45k/200k". +function buildSessionTitle(goal, now = Date.now()) { + const elapsedMs = Math.max(0, (goal.pausedAt || now) - goal.startedAt) + return [ + `${goalStatusIcon(goal)} ${summarizeText(goal.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`, + `${goal.turnCount}/${goal.options.maxTurns}`, + formatCompactDuration(elapsedMs), + `${formatCompactTokens(goal.totalTokens)}/${formatCompactTokens(goal.options.maxTokens)}`, + ].join(" · ") +} + +// Recognize a title this plugin wrote. The captured "original" is what +// `/goal clear` restores, so capturing one of our own status lines would make +// clear promote a stale status string to the permanent session title. That is +// exactly the state a hard process kill leaves behind. +function looksLikePluginSessionTitle(title) { + const text = typeof title === "string" ? title.trimStart() : "" + return SESSION_TITLE_ICONS.some((icon) => text.startsWith(`${icon} `)) +} + // Stop reason for a goal held because a planning-only agent is active. The // built-in `plan` case keeps its established wording so persisted state and // existing consumers stay stable. @@ -949,6 +1012,8 @@ function clearRuntimeState() { runtime.seenIdleEventIDs.clear() runtime.sessionStatuses.clear() runtime.sessionExecutionContexts.clear() + runtime.sessionTitles.clear() + runtime.appliedTitles.clear() runtime.pendingCommandTurns.clear() runtime.activeCommandTurns.clear() runtime.ownedPluginMessages.clear() @@ -2162,6 +2227,26 @@ async function logPluginWarning(client, message) { return logPluginMessage(client, "warn", message) } +// Cosmetic failures (session-title updates) log at debug and never fall back to +// the console: a title that failed to render must not look like a goal fault. +async function logPluginDebug(client, message, error) { + if (!client?.app?.log) return + try { + await client.app.log({ + body: { + service: "opencode-goal-plugin", + level: "debug", + message, + ...(error === undefined + ? {} + : { extra: { error: error?.message || error?.name || String(error) } }), + }, + }) + } catch { + // Diagnostics must never affect the goal loop. + } +} + function parseGoalArguments(args, defaults) { const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || [] const condition = [] @@ -3990,6 +4075,55 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // agent. Defaults to false: unattended work must not escape Plan mode. const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true + // Opt-in: mirrors live goal status into the OpenCode session title, which the + // TUI renders persistently. Off by default because it overwrites a + // user-visible field. + const sessionTitleStatus = pluginOptions.sessionTitleStatus === true + + // Title updates are cosmetic: every path swallows errors after logging at + // debug level so a failure can never interrupt the goal loop. + const syncSessionTitle = async (sessionID) => { + if (!sessionTitleStatus || !sessionID) return + const goal = goalStates.get(sessionID) + if (!goal) return + const title = buildSessionTitle(goal) + if (currentRuntime().appliedTitles.get(sessionID) === title) return + try { + if (!currentRuntime().sessionTitles.has(sessionID)) { + const session = await sessionApi.get(sessionID) + const existing = typeof session?.title === "string" ? session.title : "" + // A status line left behind by a previous process is not the user's + // title; capture empty so clear leaves the host's title alone rather + // than restoring stale goal status. + currentRuntime().sessionTitles.set( + sessionID, + looksLikePluginSessionTitle(existing) ? "" : existing, + ) + } + await sessionApi.update(sessionID, { title }) + currentRuntime().appliedTitles.set(sessionID, title) + } catch (error) { + await logPluginDebug(client, "Failed to update session title", error) + } + } + + const restoreSessionTitle = async (sessionID) => { + if (!sessionTitleStatus || !sessionID) return + const runtime = currentRuntime() + if (!runtime.sessionTitles.has(sessionID)) return + const original = runtime.sessionTitles.get(sessionID) + runtime.sessionTitles.delete(sessionID) + runtime.appliedTitles.delete(sessionID) + // Empty means there was nothing genuine to restore (no title, or the + // session only carried a status line from a previous process). + if (!original) return + try { + await sessionApi.update(sessionID, { title: original }) + } catch (error) { + await logPluginDebug(client, "Failed to restore session title", error) + } + } + // The restricted agent currently driving this session, or "" when execution // is permitted. Reads the execution context the host reports through // `chat.message`, `chat.params`, and `session.updated`. @@ -4930,6 +5064,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) requireCurrent: false, }) } + // Hand the session title back to the user now that no goal owns it. + if (clearStillCurrent) await restoreSessionTitle(sessionID) replaceCommandOutputText( output, !clearStillCurrent @@ -6563,6 +6699,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // register_command toggle: when disabled, the plugin does not own // a slash command and only the event/transform/compaction hooks remain. + // Session-title indicator: rather than threading a sync call through every + // state-mutating site (a missed one shows the user a stale status), wrap the + // two hooks that gate all state change. The sync no-ops when the rendered + // title is unchanged, and runs in `finally` so the displayed status matches + // the state actually reached even if a hook throws. + if (sessionTitleStatus) { + for (const hookName of ["command.execute.before", "event"]) { + const original = hooks[hookName] + if (typeof original !== "function") continue + hooks[hookName] = async (...args) => { + try { + return await original(...args) + } finally { + let titleSessionID = "" + if (hookName === "event") { + // `message.updated` streams many times per assistant turn. Awaiting + // a title sync on each would put an API round-trip in the streaming + // path for a cosmetic update; idle, compaction, and interruption + // events already cover every state the indicator renders. + if (args[0]?.event?.type !== "message.updated") { + titleSessionID = getSessionID(args[0]?.event) + } + } else { + titleSessionID = args[0]?.sessionID + } + await syncSessionTitle(titleSessionID) + } + } + } + } + if (!registerCommand) { delete hooks["command.execute.before"] } @@ -6700,6 +6867,11 @@ export const testInternals = { isPluginCommandMessage, isPluginContinuationMessage, isPlanAgent, + buildSessionTitle, + formatCompactDuration, + formatCompactTokens, + goalStatusIcon, + looksLikePluginSessionTitle, isRestrictedAgent, normalizeRestrictedAgents, isPluginGeneratedMessage, diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 354efdf..4364515 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -37,6 +37,11 @@ const { isPluginCommandMessage, isPluginContinuationMessage, isPlanAgent, + buildSessionTitle, + formatCompactDuration, + formatCompactTokens, + goalStatusIcon, + looksLikePluginSessionTitle, isRestrictedAgent, normalizeRestrictedAgents, isPluginGeneratedMessage, @@ -9863,3 +9868,221 @@ test("restrictedAgents: [] releases the built-in Plan restriction", async () => }) assert.equal(calls.length, 1, "an empty restricted list is a deliberate opt-out") }) + +// --------------------------------------------------------------------------- +// Session-title status indicator +// --------------------------------------------------------------------------- + +async function createTitleHooks(overrides = {}) { + const updates = [] + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ data: [message("still working")] }), + promptAsync: async () => ({}), + abort: async () => ({}), + get: overrides.get || (async () => ({ data: { title: "my original title" } })), + update: + overrides.update || + (async (input) => { + updates.push(input) + return {} + }), + }, + } + const hooks = await GoalPlugin( + { client }, + { persistState: false, minDelayMs: 1, sessionTitleStatus: true, ...(overrides.options || {}) }, + ) + return { hooks, updates } +} + +test("formatCompactDuration and formatCompactTokens abbreviate for a narrow title", () => { + assert.equal(formatCompactDuration(0), "0s") + assert.equal(formatCompactDuration(45_000), "45s") + assert.equal(formatCompactDuration(60_000), "1m") + // Elapsed time floors rather than rounds: 90s is "1m so far", not "2m". + assert.equal(formatCompactDuration(90_000), "1m") + assert.equal(formatCompactDuration(60 * 60_000), "1h") + assert.equal(formatCompactDuration(95 * 60_000), "1h35m") + // Clock skew must not render as garbage. + assert.equal(formatCompactDuration(-5000), "0s") + + assert.equal(formatCompactTokens(0), "0") + assert.equal(formatCompactTokens(999), "999") + assert.equal(formatCompactTokens(1500), "1.5k") + assert.equal(formatCompactTokens(45_000), "45k") + assert.equal(formatCompactTokens(1_500_000), "1.5m") +}) + +test("goalStatusIcon distinguishes running, paused, and blocked", () => { + assert.equal(goalStatusIcon({ stopped: false, blockedReason: "" }), "▶") + assert.equal(goalStatusIcon({ stopped: true, blockedReason: "" }), "⏸") + // Blocked outranks paused: it needs the user, not just a resume. + assert.equal(goalStatusIcon({ stopped: true, blockedReason: "needs a token" }), "⛔") +}) + +test("buildSessionTitle renders a compact one-line status", () => { + const now = Date.now() + const goal = { + condition: "ship the release", + stopped: false, + blockedReason: "", + turnCount: 3, + totalTokens: 45_000, + startedAt: now - 120_000, + pausedAt: 0, + options: { maxTurns: 10, maxTokens: 200_000 }, + } + assert.equal(buildSessionTitle(goal, now), "▶ ship the release · 3/10 · 2m · 45k/200k") + + // A paused goal freezes its elapsed clock instead of running on. + assert.equal( + buildSessionTitle({ ...goal, stopped: true, pausedAt: now - 60_000 }, now), + "⏸ ship the release · 3/10 · 1m · 45k/200k", + ) + + const title = buildSessionTitle({ ...goal, condition: "x".repeat(200) }, now) + assert.ok(title.includes("…"), "a long objective must be truncated") + assert.ok(title.length < 100, `title should stay compact, got ${title.length}`) +}) + +test("looksLikePluginSessionTitle recognizes titles this plugin wrote", () => { + assert.equal(looksLikePluginSessionTitle("▶ ship it · 3/10 · 2m · 45k/200k"), true) + assert.equal(looksLikePluginSessionTitle("⏸ ship it · 3/10 · 2m · 45k/200k"), true) + assert.equal(looksLikePluginSessionTitle("⛔ ship it · 3/10 · 2m · 45k/200k"), true) + assert.equal(looksLikePluginSessionTitle("my own session title"), false) + assert.equal(looksLikePluginSessionTitle(""), false) + assert.equal(looksLikePluginSessionTitle(undefined), false) + // A user title merely mentioning an icon, not in the leading marker form. + assert.equal(looksLikePluginSessionTitle("play ▶ button work"), false) +}) + +test("session-title status is off by default and never touches the title", async () => { + // The client here DOES expose session.update, so a call would be recorded; + // the assertion is meaningful only because the option is left unset. + const { hooks, updates } = await createTitleHooks({ + options: { sessionTitleStatus: undefined }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "pause" }, + { parts: [] }, + ) + assert.equal(updates.length, 0, "the default must not rewrite the session title") +}) + +test("sessionTitleStatus mirrors goal state into the title and restores it on clear", async () => { + const { hooks, updates } = await createTitleHooks() + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + assert.ok(updates.length >= 1, "setting a goal must publish a title") + assert.match(updates[0].body.title, /^▶ ship it · 0\/\d+ · /) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "pause" }, + { parts: [] }, + ) + assert.match(updates.at(-1).body.title, /^⏸ ship it/, "pausing re-renders the indicator") + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "clear" }, + { parts: [] }, + ) + assert.equal(updates.at(-1).body.title, "my original title", "clear must restore the original") +}) + +test("an unchanged session title is not rewritten", async () => { + const { hooks, updates } = await createTitleHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + const afterSet = updates.length + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "status" }, + { parts: [] }, + ) + assert.equal(updates.length, afterSet, "an unchanged render must skip the API call") +}) + +test("streaming message.updated events do not trigger session-title writes", async () => { + const { hooks, updates } = await createTitleHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + const afterSet = updates.length + + for (let i = 1; i <= 25; i += 1) { + await hooks.event({ + event: { + type: "message.updated", + properties: { + info: { + id: `msg-stream-${i}`, + role: "assistant", + sessionID: "session-1", + tokens: { input: i * 1000, output: i * 100, reasoning: 0 }, + }, + }, + }, + }) + } + + assert.equal( + updates.length, + afterSet, + `streaming must not write titles, got ${updates.length - afterSet} extra writes`, + ) +}) + +test("a status title left by a killed process is not restored as the user's title", async () => { + // After a hard kill the session still carries the plugin's own status line. + // Capturing that as the "original" would make /goal clear promote a stale + // status string to the permanent session title. + const stale = "▶ previous goal · 7/10 · 5m · 90k/200k" + const { hooks, updates } = await createTitleHooks({ + get: async () => ({ data: { title: stale } }), + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "clear" }, + { parts: [] }, + ) + + assert.ok( + !updates.some((u) => u.body.title === stale), + `clear must never write the stale status line back, got ${JSON.stringify(updates.map((u) => u.body.title))}`, + ) + assert.ok( + updates.every((u) => u.body.title.startsWith("▶ ship it") || u.body.title.startsWith("⏸ ship it")), + `only the live goal's own status should be written, got ${JSON.stringify(updates.map((u) => u.body.title))}`, + ) +}) + +test("a failing session-title update never breaks the goal loop", async () => { + const { hooks } = await createTitleHooks({ + update: async () => { + throw new Error("host refused the title update") + }, + }) + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + output, + ) + + assert.equal(currentGoal("session-1").condition, "ship it", "the goal must still be created") + assert.ok(output.parts.length > 0, "the command must still produce output") +})