opencode-goal-plugin 0.8.1 → 0.9.0
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/CHANGELOG.md +31 -0
- package/README.md +59 -1
- package/docs/compatibility.md +50 -0
- package/index.d.ts +28 -0
- package/package.json +1 -1
- package/src/goal-plugin.js +500 -31
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.0 — 2026-08-29
|
|
6
|
+
|
|
7
|
+
- Add plan-mode safety: a goal set while a planning-only agent is active is recorded but held instead of starting, with stop reason `plan agent active`, its budget preserved, and a read-only control turn that tells the model not to begin work. Auto-continue already paused when the session switched to Plan; this closes the creation path, where the goal previously started and ran the loop before any idle occurred. The active agent is read from the host's execution context with a fallback to the session record, which matters because `command.execute.before` runs before `chat.message`/`chat.params` — without the fallback the restriction failed open on the first command in a session. Configurable via `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`); the default-on behavior is pinned by the mutation contract. Note the restriction stops the goal loop, not the single routed command turn, which OpenCode does not fully intercept.
|
|
8
|
+
- 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.
|
|
9
|
+
- Verify both features against a live OpenCode 1.18.25 TUI, not only unit and contract tests: a goal set under Plan records `stopped: true` with zero auto-continues, and the status indicator renders in the session title.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
## 0.8.2 — 2026-08-21
|
|
13
|
+
|
|
14
|
+
- Harden the post-compaction goal continuation guard introduced in
|
|
15
|
+
[#58](https://github.com/willytop8/OpenCode-goal-plugin/pull/58). Building on
|
|
16
|
+
the epoch-guard groundwork contributed by
|
|
17
|
+
[@harryzhou2000](https://github.com/harryzhou2000), the stalled-compaction
|
|
18
|
+
circuit breaker no longer misfires on two conditions that OpenCode produces in
|
|
19
|
+
practice:
|
|
20
|
+
- A re-delivered `session.compacted` event no longer counts as a second
|
|
21
|
+
compaction. Real OpenCode compaction events carry only a `sessionID` (no
|
|
22
|
+
identity field), so identity-based deduplication could never fire against a
|
|
23
|
+
live host; a duplicate host delivery could therefore trip the two-strike
|
|
24
|
+
breaker and abort the session. Re-deliveries are now recognized by the
|
|
25
|
+
absence of any message activity since the previous compaction.
|
|
26
|
+
- A `[goal:complete]` or `[goal:blocked]` reported on the assistant turn that
|
|
27
|
+
a compaction retains as its continuation source is now honored instead of
|
|
28
|
+
being suppressed, so a finished goal is archived rather than driven for one
|
|
29
|
+
more redundant continuation.
|
|
30
|
+
- Remove a productive-turn detection branch that inspected message parts on the
|
|
31
|
+
`message.updated` event. That event never carries parts (they arrive on
|
|
32
|
+
`message.part.updated`, which the plugin does not observe), so the check was
|
|
33
|
+
inert; the stalled-compaction breaker resets on assistant output-token
|
|
34
|
+
progress, which a tool-using turn also produces.
|
|
35
|
+
|
|
5
36
|
## 0.8.1 — 2026-08-07
|
|
6
37
|
|
|
7
38
|
- Fix goal auto-continue stalling after session compaction: a continuation
|
package/README.md
CHANGED
|
@@ -31,13 +31,14 @@ This project is independently implemented for OpenCode. Product names used elsew
|
|
|
31
31
|
| Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows |
|
|
32
32
|
| Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools |
|
|
33
33
|
| Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
|
|
34
|
+
| 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) |
|
|
34
35
|
|
|
35
36
|
See the [compatibility policy](docs/compatibility.md) for the supported public
|
|
36
37
|
surface and versioning expectations.
|
|
37
38
|
|
|
38
39
|
### OpenCode version compatibility
|
|
39
40
|
|
|
40
|
-
Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
|
|
41
|
+
Tested against real OpenCode 1.17.15 and 1.18.25 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
|
|
41
42
|
|
|
42
43
|
| OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Historical custom-command presentation (v0.6.6) |
|
|
43
44
|
|---|---|---|---|---|---|
|
|
@@ -45,6 +46,7 @@ Tested against real OpenCode 1.17.15 processes with live provider credentials an
|
|
|
45
46
|
| 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
|
|
46
47
|
| 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
|
|
47
48
|
| 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed |
|
|
49
|
+
| 1.18.25 | opencode (`nemotron-3.5-lightning-free`) | ✅ | ✅ Held correctly under the Plan agent (`stopped: true`, zero auto-continues) | ✅ Clean `[goal:evidence]` + `[goal:complete]` | ⚠️ Not displayed; command text routed to model |
|
|
48
50
|
|
|
49
51
|
`/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence.
|
|
50
52
|
|
|
@@ -401,6 +403,62 @@ await GoalPlugin(
|
|
|
401
403
|
|
|
402
404
|
`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.
|
|
403
405
|
|
|
406
|
+
## Status indicator
|
|
407
|
+
|
|
408
|
+
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:
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
▶ ship the release · 3/10 · 2m · 45k/200k
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
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.
|
|
415
|
+
|
|
416
|
+
```json
|
|
417
|
+
{
|
|
418
|
+
"plugin": [
|
|
419
|
+
["opencode-goal-plugin", { "sessionTitleStatus": true }]
|
|
420
|
+
]
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
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.
|
|
425
|
+
|
|
426
|
+
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.
|
|
427
|
+
|
|
428
|
+
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.
|
|
429
|
+
|
|
430
|
+
This needs no TUI plugin entrypoint, no `@opentui` dependencies, and no build step.
|
|
431
|
+
|
|
432
|
+
## Plan-mode safety
|
|
433
|
+
|
|
434
|
+
A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default:
|
|
435
|
+
|
|
436
|
+
- A goal set while `plan` is active is **recorded but held**, with stop reason `plan agent active`. The objective and its budget survive, so nothing is lost — the goal simply does not start.
|
|
437
|
+
- The routed confirmation text for a held goal **omits the "start working" instruction** and is sent as a read-only control turn. This matters because command text reaches the model as a normal turn on current OpenCode builds (see [Limitations](#limitations)).
|
|
438
|
+
- Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop.
|
|
439
|
+
- Continuations retain the agent that started the goal, so the loop cannot drift into a different agent.
|
|
440
|
+
|
|
441
|
+
The active agent is read from the execution context the host reports, falling back to the session record. That fallback matters: OpenCode runs `command.execute.before` before any `chat.message`/`chat.params` for the turn, so the context is empty for the first command in a session — the exact case a freshly opened Plan-mode session hits.
|
|
442
|
+
|
|
443
|
+
**What this does and does not prevent.** The restriction stops the *goal loop*: a held goal sends zero auto-continues, so no unattended work happens. It cannot stop a model from acting on the single routed command turn, because OpenCode's `command.execute.before` does not fully intercept command text (see [Limitations](#limitations)). A held goal's routed text explicitly tells the model not to begin work and is sent as a read-only control turn, but a non-compliant model may still act on that one turn. Verified against OpenCode 1.18.25: a goal set under Plan records `stopped: true`, `stopReason: plan agent active`, and `turnCount: 0`.
|
|
444
|
+
|
|
445
|
+
Run `/goal resume` after switching back to an executing agent to start the work.
|
|
446
|
+
|
|
447
|
+
| Option | Default | Controls |
|
|
448
|
+
|---|---|---|
|
|
449
|
+
| `restrictedAgents` | `["plan"]` | Agent names treated as planning-only (case-insensitive). Pass `[]` to release the restriction. |
|
|
450
|
+
| `allowGoalExecutionFromPlan` | `false` | Set `true` to allow goal creation and auto-continue while a restricted agent is active. |
|
|
451
|
+
|
|
452
|
+
```json
|
|
453
|
+
{
|
|
454
|
+
"plugin": [
|
|
455
|
+
["opencode-goal-plugin", { "restrictedAgents": ["plan", "review"] }]
|
|
456
|
+
]
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The restriction being on by default is pinned by the mutation contract: hardcoding `allowGoalExecutionFromPlan` to `true` fails the suite.
|
|
461
|
+
|
|
404
462
|
## Prompt safety
|
|
405
463
|
|
|
406
464
|
The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
|
package/docs/compatibility.md
CHANGED
|
@@ -81,6 +81,56 @@ comes from the rewritten turn's escaped reporting frame, fail-closed tool
|
|
|
81
81
|
blocking, and parent-correlated lifecycle suppression. The system transform
|
|
82
82
|
remains registered as additional protection for hosts that support it.
|
|
83
83
|
|
|
84
|
+
## OpenCode 2
|
|
85
|
+
|
|
86
|
+
**Status: not supported, and not yet tested.**
|
|
87
|
+
|
|
88
|
+
The package declares `engines.opencode` and the `@opencode-ai/plugin` peer as
|
|
89
|
+
`>=1.17.15 <2`. That bound is deliberate: no claim in this repository is made
|
|
90
|
+
without a verified run behind it, and the project has not yet exercised the
|
|
91
|
+
plugin against an OpenCode 2 build. Treat OpenCode 2 as unverified rather than
|
|
92
|
+
as known-broken.
|
|
93
|
+
|
|
94
|
+
### What already exists in this direction
|
|
95
|
+
|
|
96
|
+
- `createOpenCodeSessionApi` speaks both the legacy generated-client shape
|
|
97
|
+
(`{ path, body, query }`) and the flattened shape (`{ sessionID, ... }`),
|
|
98
|
+
selected per operation and remembered after the first success. The
|
|
99
|
+
`sdkShape: "flat"` option pins the flattened shape for embedded clients.
|
|
100
|
+
- Only read-only operations are ever replayed against the alternate shape, so a
|
|
101
|
+
shape probe can never duplicate a mutating call. This invariant is pinned by
|
|
102
|
+
the mutation contract.
|
|
103
|
+
|
|
104
|
+
### What a supported v2 claim would require
|
|
105
|
+
|
|
106
|
+
Before the pin is widened, all of the following need to pass against a real
|
|
107
|
+
OpenCode 2 build, not a mock:
|
|
108
|
+
|
|
109
|
+
1. Plugin load and hook registration through the v2 plugin entrypoint.
|
|
110
|
+
2. `command.execute.before`, `event`, `experimental.chat.system.transform`,
|
|
111
|
+
`experimental.session.compacting`, and `experimental.compaction.autocontinue`
|
|
112
|
+
firing with the shapes the plugin expects.
|
|
113
|
+
3. The execution-context signals (`chat.message`, `chat.params`,
|
|
114
|
+
`session.updated`) still reporting the active agent, which the planning-only
|
|
115
|
+
restriction depends on.
|
|
116
|
+
4. Session-API calls (`messages`, `promptAsync`, `create`, `get`, `update`,
|
|
117
|
+
`abort`) under whichever argument shape v2 ships.
|
|
118
|
+
5. Goal-specific compaction context and recovery of running child sessions after
|
|
119
|
+
a plugin restart, which are the areas most likely to differ.
|
|
120
|
+
|
|
121
|
+
### Configuration
|
|
122
|
+
|
|
123
|
+
This plugin is **server-only**: `package.json` exports the root and
|
|
124
|
+
`opencode-goal-plugin/server`, and there is no TUI plugin entrypoint. Its
|
|
125
|
+
configuration therefore lives entirely in `opencode.json` (the `plugin` and
|
|
126
|
+
`command` keys) on any OpenCode line.
|
|
127
|
+
|
|
128
|
+
Plugins that *do* ship a TUI component are registered in a second file whose
|
|
129
|
+
location differs between OpenCode lines, and those formats must not be mixed.
|
|
130
|
+
That distinction does not apply here — including for the
|
|
131
|
+
[status indicator](../README.md#status-indicator), which reaches the TUI through
|
|
132
|
+
the session title rather than through a TUI plugin.
|
|
133
|
+
|
|
84
134
|
## Versioning
|
|
85
135
|
|
|
86
136
|
Semantic-versioning intent is:
|
package/index.d.ts
CHANGED
|
@@ -311,6 +311,34 @@ export interface GoalPluginOptions {
|
|
|
311
311
|
/** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
|
|
312
312
|
registerAgents?: boolean
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Mirror live goal status into the OpenCode session title, which the TUI
|
|
316
|
+
* renders persistently (e.g. `▶ ship the release · 3/10 · 2m · 45k/200k`),
|
|
317
|
+
* giving unattended runs a continuous heartbeat without a TUI plugin.
|
|
318
|
+
*
|
|
319
|
+
* The session's original title is captured before the first overwrite and
|
|
320
|
+
* restored by `/goal clear`. Title updates are cosmetic: a failure is logged
|
|
321
|
+
* at debug level and never interrupts the goal loop.
|
|
322
|
+
* @default false
|
|
323
|
+
*/
|
|
324
|
+
sessionTitleStatus?: boolean
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Agent names treated as planning-only. A goal created while one of these
|
|
328
|
+
* agents is active is recorded but held paused instead of starting, and
|
|
329
|
+
* auto-continue stays suppressed while one is active. Matching is
|
|
330
|
+
* case-insensitive. Pass `[]` to release the restriction entirely.
|
|
331
|
+
* @default ["plan"]
|
|
332
|
+
*/
|
|
333
|
+
restrictedAgents?: string[]
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Opt out of the planning-only restriction, allowing goals to be created and
|
|
337
|
+
* auto-continued while a {@link restrictedAgents} agent is active.
|
|
338
|
+
* @default false
|
|
339
|
+
*/
|
|
340
|
+
allowGoalExecutionFromPlan?: boolean
|
|
341
|
+
|
|
314
342
|
/** Name of the native primary goal agent. @default "goal" */
|
|
315
343
|
goalAgentName?: string
|
|
316
344
|
|
package/package.json
CHANGED
package/src/goal-plugin.js
CHANGED
|
@@ -40,6 +40,7 @@ function legacyHomeStateFilePath(env = process.env) {
|
|
|
40
40
|
return join(homeBase(env), ".opencode-goal-plugin", "state.json")
|
|
41
41
|
}
|
|
42
42
|
const MAX_HISTORY_ENTRIES = 20
|
|
43
|
+
const MAX_STALLED_COMPACTIONS = 2
|
|
43
44
|
// Marks a plugin-synthesized parent wake so the receiving pass knows it is
|
|
44
45
|
// re-examining an assistant turn that has already been scored.
|
|
45
46
|
const CHILD_WAKE_EVENT_FLAG = Symbol.for("opencode-goal-plugin.childWake")
|
|
@@ -110,6 +111,11 @@ function createRuntimeState() {
|
|
|
110
111
|
seenIdleEventIDs: new Set(),
|
|
111
112
|
sessionStatuses: new Map(),
|
|
112
113
|
sessionExecutionContexts: new Map(),
|
|
114
|
+
// Session-title indicator: the user's own title, captured before the plugin
|
|
115
|
+
// first overwrites it, and the last title the plugin wrote (so an unchanged
|
|
116
|
+
// render skips the API call).
|
|
117
|
+
sessionTitles: new Map(),
|
|
118
|
+
appliedTitles: new Map(),
|
|
113
119
|
pendingCommandTurns: new Map(),
|
|
114
120
|
activeCommandTurns: new Map(),
|
|
115
121
|
commandOutputs: new WeakMap(),
|
|
@@ -395,8 +401,95 @@ function continuationContextInput(goal) {
|
|
|
395
401
|
return context ? { ...context } : {}
|
|
396
402
|
}
|
|
397
403
|
|
|
404
|
+
// Planning-only agents must never be driven into execution by the goal loop.
|
|
405
|
+
// `plan` is OpenCode's built-in read-only agent; `restrictedAgents` lets a
|
|
406
|
+
// deployment name others (for example a review-only agent).
|
|
407
|
+
const DEFAULT_RESTRICTED_AGENTS = ["plan"]
|
|
408
|
+
|
|
409
|
+
function normalizeRestrictedAgents(value) {
|
|
410
|
+
// Anything that is not an array (including undefined) keeps the safe default;
|
|
411
|
+
// an explicit empty array is a deliberate opt-out.
|
|
412
|
+
if (!Array.isArray(value)) return [...DEFAULT_RESTRICTED_AGENTS]
|
|
413
|
+
const names = value
|
|
414
|
+
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
|
|
415
|
+
.filter(Boolean)
|
|
416
|
+
return [...new Set(names)]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isRestrictedAgent(agent, restrictedAgents = DEFAULT_RESTRICTED_AGENTS) {
|
|
420
|
+
if (typeof agent !== "string") return false
|
|
421
|
+
const name = agent.trim().toLowerCase()
|
|
422
|
+
if (!name) return false
|
|
423
|
+
return restrictedAgents.includes(name)
|
|
424
|
+
}
|
|
425
|
+
|
|
398
426
|
function isPlanAgent(agent) {
|
|
399
|
-
return
|
|
427
|
+
return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Session-title status indicator. OpenCode renders the session title
|
|
431
|
+
// persistently, so mirroring goal progress into it gives unattended runs a
|
|
432
|
+
// continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it
|
|
433
|
+
// overwrites a user-visible field.
|
|
434
|
+
const SESSION_TITLE_OBJECTIVE_LIMIT = 48
|
|
435
|
+
const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"]
|
|
436
|
+
|
|
437
|
+
// The title sits in a narrow column, so every field is abbreviated hard.
|
|
438
|
+
function formatCompactDuration(ms) {
|
|
439
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000))
|
|
440
|
+
if (totalSeconds < 60) return `${totalSeconds}s`
|
|
441
|
+
const totalMinutes = Math.floor(totalSeconds / 60)
|
|
442
|
+
if (totalMinutes < 60) return `${totalMinutes}m`
|
|
443
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
444
|
+
const minutes = totalMinutes % 60
|
|
445
|
+
return minutes ? `${hours}h${minutes}m` : `${hours}h`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function formatCompactTokens(tokens) {
|
|
449
|
+
const value = toNonNegativeInteger(tokens)
|
|
450
|
+
if (value < 1000) return String(value)
|
|
451
|
+
if (value < 1_000_000) {
|
|
452
|
+
const thousands = value / 1000
|
|
453
|
+
return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`
|
|
454
|
+
}
|
|
455
|
+
const millions = value / 1_000_000
|
|
456
|
+
return `${millions < 10 ? millions.toFixed(1) : Math.round(millions)}m`
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Blocked and paused are distinct to a watching human: one needs input, the
|
|
460
|
+
// other just needs a resume.
|
|
461
|
+
function goalStatusIcon(goal) {
|
|
462
|
+
if (goal.blockedReason) return "⛔"
|
|
463
|
+
if (goal.stopped) return "⏸"
|
|
464
|
+
return "▶"
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// One-line goal status for the session title, e.g.
|
|
468
|
+
// "▶ ship the release · 3/10 · 2m · 45k/200k".
|
|
469
|
+
function buildSessionTitle(goal, now = Date.now()) {
|
|
470
|
+
const elapsedMs = Math.max(0, (goal.pausedAt || now) - goal.startedAt)
|
|
471
|
+
return [
|
|
472
|
+
`${goalStatusIcon(goal)} ${summarizeText(goal.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`,
|
|
473
|
+
`${goal.turnCount}/${goal.options.maxTurns}`,
|
|
474
|
+
formatCompactDuration(elapsedMs),
|
|
475
|
+
`${formatCompactTokens(goal.totalTokens)}/${formatCompactTokens(goal.options.maxTokens)}`,
|
|
476
|
+
].join(" · ")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Recognize a title this plugin wrote. The captured "original" is what
|
|
480
|
+
// `/goal clear` restores, so capturing one of our own status lines would make
|
|
481
|
+
// clear promote a stale status string to the permanent session title. That is
|
|
482
|
+
// exactly the state a hard process kill leaves behind.
|
|
483
|
+
function looksLikePluginSessionTitle(title) {
|
|
484
|
+
const text = typeof title === "string" ? title.trimStart() : ""
|
|
485
|
+
return SESSION_TITLE_ICONS.some((icon) => text.startsWith(`${icon} `))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Stop reason for a goal held because a planning-only agent is active. The
|
|
489
|
+
// built-in `plan` case keeps its established wording so persisted state and
|
|
490
|
+
// existing consumers stay stable.
|
|
491
|
+
function restrictedAgentStopReason(agent) {
|
|
492
|
+
return isPlanAgent(agent) ? "plan agent active" : `${String(agent).trim().toLowerCase()} agent active`
|
|
400
493
|
}
|
|
401
494
|
|
|
402
495
|
function terminalEvent(event) {
|
|
@@ -919,6 +1012,8 @@ function clearRuntimeState() {
|
|
|
919
1012
|
runtime.seenIdleEventIDs.clear()
|
|
920
1013
|
runtime.sessionStatuses.clear()
|
|
921
1014
|
runtime.sessionExecutionContexts.clear()
|
|
1015
|
+
runtime.sessionTitles.clear()
|
|
1016
|
+
runtime.appliedTitles.clear()
|
|
922
1017
|
runtime.pendingCommandTurns.clear()
|
|
923
1018
|
runtime.activeCommandTurns.clear()
|
|
924
1019
|
runtime.ownedPluginMessages.clear()
|
|
@@ -1104,6 +1199,11 @@ function resetGoalBudget(goal) {
|
|
|
1104
1199
|
goal.formatFailures = 0
|
|
1105
1200
|
goal.lastAssistantMessageID = ""
|
|
1106
1201
|
goal.continuationClaim = null
|
|
1202
|
+
goal.compactionEpoch = 0
|
|
1203
|
+
goal.stalledCompactions = 0
|
|
1204
|
+
goal.lastCompactionEventID = ""
|
|
1205
|
+
goal.messageSeenSinceCompaction = true
|
|
1206
|
+
goal.compactionSourceAssistantMessageID = ""
|
|
1107
1207
|
goal.skipNextTerminalCheck = false
|
|
1108
1208
|
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
1109
1209
|
}
|
|
@@ -1433,15 +1533,31 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
1433
1533
|
stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
|
|
1434
1534
|
promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
|
|
1435
1535
|
formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
|
|
1536
|
+
compactionEpoch: toNonNegativeInteger(rawGoal.compactionEpoch),
|
|
1537
|
+
stalledCompactions: toNonNegativeInteger(rawGoal.stalledCompactions),
|
|
1538
|
+
lastCompactionEventID:
|
|
1539
|
+
typeof rawGoal.lastCompactionEventID === "string" &&
|
|
1540
|
+
rawGoal.lastCompactionEventID.length <= MAX_GOAL_META_LENGTH
|
|
1541
|
+
? rawGoal.lastCompactionEventID
|
|
1542
|
+
: "",
|
|
1543
|
+
messageSeenSinceCompaction: rawGoal.messageSeenSinceCompaction !== false,
|
|
1544
|
+
compactionSourceAssistantMessageID:
|
|
1545
|
+
typeof rawGoal.compactionSourceAssistantMessageID === "string" &&
|
|
1546
|
+
rawGoal.compactionSourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
|
|
1547
|
+
? rawGoal.compactionSourceAssistantMessageID
|
|
1548
|
+
: "",
|
|
1436
1549
|
executionContext: normalizeExecutionContext(rawGoal.executionContext),
|
|
1437
1550
|
continuationClaim:
|
|
1438
1551
|
isPlainObject(rawGoal.continuationClaim) &&
|
|
1439
1552
|
typeof rawGoal.continuationClaim.runId === "string" &&
|
|
1440
1553
|
rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH &&
|
|
1554
|
+
Number.isSafeInteger(rawGoal.continuationClaim.compactionEpoch) &&
|
|
1555
|
+
rawGoal.continuationClaim.compactionEpoch >= 0 &&
|
|
1441
1556
|
typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" &&
|
|
1442
1557
|
rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
|
|
1443
1558
|
? {
|
|
1444
1559
|
runId: rawGoal.continuationClaim.runId,
|
|
1560
|
+
compactionEpoch: rawGoal.continuationClaim.compactionEpoch,
|
|
1445
1561
|
sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID,
|
|
1446
1562
|
}
|
|
1447
1563
|
: null,
|
|
@@ -2111,6 +2227,26 @@ async function logPluginWarning(client, message) {
|
|
|
2111
2227
|
return logPluginMessage(client, "warn", message)
|
|
2112
2228
|
}
|
|
2113
2229
|
|
|
2230
|
+
// Cosmetic failures (session-title updates) log at debug and never fall back to
|
|
2231
|
+
// the console: a title that failed to render must not look like a goal fault.
|
|
2232
|
+
async function logPluginDebug(client, message, error) {
|
|
2233
|
+
if (!client?.app?.log) return
|
|
2234
|
+
try {
|
|
2235
|
+
await client.app.log({
|
|
2236
|
+
body: {
|
|
2237
|
+
service: "opencode-goal-plugin",
|
|
2238
|
+
level: "debug",
|
|
2239
|
+
message,
|
|
2240
|
+
...(error === undefined
|
|
2241
|
+
? {}
|
|
2242
|
+
: { extra: { error: error?.message || error?.name || String(error) } }),
|
|
2243
|
+
},
|
|
2244
|
+
})
|
|
2245
|
+
} catch {
|
|
2246
|
+
// Diagnostics must never affect the goal loop.
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2114
2250
|
function parseGoalArguments(args, defaults) {
|
|
2115
2251
|
const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
|
|
2116
2252
|
const condition = []
|
|
@@ -2635,7 +2771,42 @@ function systemBlockContainsGoal(block, goalId) {
|
|
|
2635
2771
|
}
|
|
2636
2772
|
|
|
2637
2773
|
function findLatestAssistantMessage(messages) {
|
|
2638
|
-
return [...(messages || [])]
|
|
2774
|
+
return [...(messages || [])]
|
|
2775
|
+
.reverse()
|
|
2776
|
+
.find(
|
|
2777
|
+
(message) =>
|
|
2778
|
+
messageRole(message) === "assistant" && !isCompactionAssistantMessage(message),
|
|
2779
|
+
) || null
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function isCompactionAssistantMessage(message) {
|
|
2783
|
+
if (messageRole(message) !== "assistant") return false
|
|
2784
|
+
const info = isPlainObject(message?.info) ? message.info : message
|
|
2785
|
+
return (
|
|
2786
|
+
info?.summary === true ||
|
|
2787
|
+
info?.agent === "compaction" ||
|
|
2788
|
+
info?.mode === "compaction" ||
|
|
2789
|
+
message?.agent === "compaction" ||
|
|
2790
|
+
message?.mode === "compaction"
|
|
2791
|
+
)
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function compactionEventIdentity(event) {
|
|
2795
|
+
const candidates = [
|
|
2796
|
+
event?.id,
|
|
2797
|
+
event?.properties?.compactionID,
|
|
2798
|
+
event?.properties?.summaryID,
|
|
2799
|
+
event?.properties?.messageID,
|
|
2800
|
+
event?.properties?.id,
|
|
2801
|
+
event?.data?.compactionID,
|
|
2802
|
+
event?.data?.summaryID,
|
|
2803
|
+
event?.data?.messageID,
|
|
2804
|
+
event?.data?.id,
|
|
2805
|
+
]
|
|
2806
|
+
const identity = candidates.find(
|
|
2807
|
+
(candidate) => typeof candidate === "string" && candidate.length > 0,
|
|
2808
|
+
)
|
|
2809
|
+
return identity && identity.length <= MAX_GOAL_META_LENGTH ? identity : ""
|
|
2639
2810
|
}
|
|
2640
2811
|
|
|
2641
2812
|
function messageParentID(message) {
|
|
@@ -2771,6 +2942,7 @@ function continuationSnapshot(messages, ownedMessages = currentRuntime().ownedPl
|
|
|
2771
2942
|
.reverse()
|
|
2772
2943
|
.find((message) =>
|
|
2773
2944
|
(messageRole(message) === "assistant" || messageRole(message) === "user") &&
|
|
2945
|
+
!isCompactionAssistantMessage(message) &&
|
|
2774
2946
|
!isPluginGeneratedMessage(message, ownedMessages),
|
|
2775
2947
|
)
|
|
2776
2948
|
return {
|
|
@@ -2939,6 +3111,11 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2939
3111
|
stopReason: "",
|
|
2940
3112
|
promptFailures: 0,
|
|
2941
3113
|
formatFailures: 0,
|
|
3114
|
+
compactionEpoch: 0,
|
|
3115
|
+
stalledCompactions: 0,
|
|
3116
|
+
lastCompactionEventID: "",
|
|
3117
|
+
messageSeenSinceCompaction: true,
|
|
3118
|
+
compactionSourceAssistantMessageID: "",
|
|
2942
3119
|
executionContext: normalizeExecutionContext(
|
|
2943
3120
|
meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
|
|
2944
3121
|
),
|
|
@@ -3893,6 +4070,110 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3893
4070
|
cwd: pluginOptions.cwd || directory,
|
|
3894
4071
|
})
|
|
3895
4072
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
4073
|
+
const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents)
|
|
4074
|
+
// Opt-out for deployments that deliberately drive execution from a planning
|
|
4075
|
+
// agent. Defaults to false: unattended work must not escape Plan mode.
|
|
4076
|
+
const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true
|
|
4077
|
+
|
|
4078
|
+
// Opt-in: mirrors live goal status into the OpenCode session title, which the
|
|
4079
|
+
// TUI renders persistently. Off by default because it overwrites a
|
|
4080
|
+
// user-visible field.
|
|
4081
|
+
const sessionTitleStatus = pluginOptions.sessionTitleStatus === true
|
|
4082
|
+
|
|
4083
|
+
// Title updates are cosmetic: every path swallows errors after logging at
|
|
4084
|
+
// debug level so a failure can never interrupt the goal loop.
|
|
4085
|
+
const syncSessionTitle = async (sessionID) => {
|
|
4086
|
+
if (!sessionTitleStatus || !sessionID) return
|
|
4087
|
+
const goal = goalStates.get(sessionID)
|
|
4088
|
+
if (!goal) return
|
|
4089
|
+
const title = buildSessionTitle(goal)
|
|
4090
|
+
if (currentRuntime().appliedTitles.get(sessionID) === title) return
|
|
4091
|
+
try {
|
|
4092
|
+
if (!currentRuntime().sessionTitles.has(sessionID)) {
|
|
4093
|
+
const session = await sessionApi.get(sessionID)
|
|
4094
|
+
const existing = typeof session?.title === "string" ? session.title : ""
|
|
4095
|
+
// A status line left behind by a previous process is not the user's
|
|
4096
|
+
// title; capture empty so clear leaves the host's title alone rather
|
|
4097
|
+
// than restoring stale goal status.
|
|
4098
|
+
currentRuntime().sessionTitles.set(
|
|
4099
|
+
sessionID,
|
|
4100
|
+
looksLikePluginSessionTitle(existing) ? "" : existing,
|
|
4101
|
+
)
|
|
4102
|
+
}
|
|
4103
|
+
await sessionApi.update(sessionID, { title })
|
|
4104
|
+
currentRuntime().appliedTitles.set(sessionID, title)
|
|
4105
|
+
} catch (error) {
|
|
4106
|
+
await logPluginDebug(client, "Failed to update session title", error)
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
const restoreSessionTitle = async (sessionID) => {
|
|
4111
|
+
if (!sessionTitleStatus || !sessionID) return
|
|
4112
|
+
const runtime = currentRuntime()
|
|
4113
|
+
if (!runtime.sessionTitles.has(sessionID)) return
|
|
4114
|
+
const original = runtime.sessionTitles.get(sessionID)
|
|
4115
|
+
runtime.sessionTitles.delete(sessionID)
|
|
4116
|
+
runtime.appliedTitles.delete(sessionID)
|
|
4117
|
+
// Empty means there was nothing genuine to restore (no title, or the
|
|
4118
|
+
// session only carried a status line from a previous process).
|
|
4119
|
+
if (!original) return
|
|
4120
|
+
try {
|
|
4121
|
+
await sessionApi.update(sessionID, { title: original })
|
|
4122
|
+
} catch (error) {
|
|
4123
|
+
await logPluginDebug(client, "Failed to restore session title", error)
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
// The restricted agent currently driving this session, or "" when execution
|
|
4128
|
+
// is permitted. Reads the execution context the host reports through
|
|
4129
|
+
// `chat.message`, `chat.params`, and `session.updated`.
|
|
4130
|
+
// Resolve the agent driving a session, preferring the execution context the
|
|
4131
|
+
// host reports through `chat.message` / `chat.params` / `session.updated`.
|
|
4132
|
+
//
|
|
4133
|
+
// That context is empty for the first command in a session: OpenCode runs
|
|
4134
|
+
// `command.execute.before` before any of those signals fire. Relying on it
|
|
4135
|
+
// alone made the restriction fail open exactly where it matters most — a
|
|
4136
|
+
// freshly opened session in Plan mode — so fall back to the session record,
|
|
4137
|
+
// which carries the selected agent from the moment the user picks it.
|
|
4138
|
+
const resolveSessionAgent = async (sessionID) => {
|
|
4139
|
+
if (!sessionID) return ""
|
|
4140
|
+
const cached = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent
|
|
4141
|
+
if (typeof cached === "string" && cached.trim()) return cached.trim()
|
|
4142
|
+
try {
|
|
4143
|
+
const session = await sessionApi.get(sessionID)
|
|
4144
|
+
const agent = typeof session?.agent === "string" ? session.agent.trim() : ""
|
|
4145
|
+
// Remember it so later hooks in the same turn do not re-fetch. `replace`
|
|
4146
|
+
// is intentionally false: this must not clobber a richer context (model,
|
|
4147
|
+
// variant) that a host signal may already have recorded.
|
|
4148
|
+
if (agent) rememberSessionExecutionContext(sessionID, { agent })
|
|
4149
|
+
return agent
|
|
4150
|
+
} catch (error) {
|
|
4151
|
+
// Hosts that do not expose the agent fail open, matching the behavior
|
|
4152
|
+
// before the restriction existed.
|
|
4153
|
+
await logPluginDebug(client, "Failed to resolve the session agent", error)
|
|
4154
|
+
return ""
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
const restrictedAgentFor = async (sessionID) => {
|
|
4159
|
+
if (allowGoalExecutionFromPlan) return ""
|
|
4160
|
+
const agent = await resolveSessionAgent(sessionID)
|
|
4161
|
+
return isRestrictedAgent(agent, restrictedAgents) ? agent : ""
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
// Record a newly created goal as held rather than active. Mirrors the idle
|
|
4165
|
+
// guard's stop reason so `/goal status` reads the same either way.
|
|
4166
|
+
const holdGoalForRestrictedAgent = (goal, agent) => {
|
|
4167
|
+
const label = isPlanAgent(agent) ? "Plan" : agent
|
|
4168
|
+
goal.stopped = true
|
|
4169
|
+
goal.stopReason = restrictedAgentStopReason(agent)
|
|
4170
|
+
goal.lastStatus =
|
|
4171
|
+
`Goal recorded but held: the ${label} agent is planning-only. ` +
|
|
4172
|
+
`Switch to an executing agent, then run /${commandName} resume to start work.`
|
|
4173
|
+
pauseGoalClock(goal)
|
|
4174
|
+
pushHistory(goal, "paused", `Created while the ${label} agent was active; held until an executing agent resumes it.`)
|
|
4175
|
+
return label
|
|
4176
|
+
}
|
|
3896
4177
|
|
|
3897
4178
|
// Each session owns an independent snapshot, ledger, write chain, and
|
|
3898
4179
|
// lifetime lease. A project can therefore host any number of unrelated goal
|
|
@@ -4397,18 +4678,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4397
4678
|
sessionID,
|
|
4398
4679
|
goalID,
|
|
4399
4680
|
runID,
|
|
4681
|
+
compactionEpoch,
|
|
4400
4682
|
baselineMessages,
|
|
4401
4683
|
{ refreshMessages = false } = {},
|
|
4402
4684
|
) => {
|
|
4403
4685
|
const goalBeforeRefresh = activeGoal(sessionID, goalID, runID)
|
|
4404
|
-
if (!goalBeforeRefresh) return null
|
|
4686
|
+
if (!goalBeforeRefresh || goalBeforeRefresh.compactionEpoch !== compactionEpoch) return null
|
|
4405
4687
|
const hostMessages = refreshMessages
|
|
4406
4688
|
? await sessionApi.messages(sessionID, {
|
|
4407
4689
|
limit: goalBeforeRefresh.options.maxRecentMessages,
|
|
4408
4690
|
})
|
|
4409
4691
|
: baselineMessages
|
|
4410
4692
|
const goal = activeGoal(sessionID, goalID, runID)
|
|
4411
|
-
if (!goal) return null
|
|
4693
|
+
if (!goal || goal.compactionEpoch !== compactionEpoch) return null
|
|
4412
4694
|
const messages = Array.isArray(hostMessages)
|
|
4413
4695
|
? hostMessages.slice(-goal.options.maxRecentMessages)
|
|
4414
4696
|
: []
|
|
@@ -4417,12 +4699,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4417
4699
|
|
|
4418
4700
|
if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
|
|
4419
4701
|
|
|
4420
|
-
const
|
|
4421
|
-
if (
|
|
4702
|
+
const activeRestrictedAgent = await restrictedAgentFor(sessionID)
|
|
4703
|
+
if (activeRestrictedAgent) {
|
|
4704
|
+
const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
|
|
4422
4705
|
await pauseActiveGoal(sessionID, {
|
|
4423
|
-
stopReason:
|
|
4424
|
-
status:
|
|
4425
|
-
history:
|
|
4706
|
+
stopReason: restrictedAgentStopReason(activeRestrictedAgent),
|
|
4707
|
+
status: `Auto-continue paused because the active agent switched to ${label}.`,
|
|
4708
|
+
history: `Paused before auto-continue because the active session agent switched to ${label}.`,
|
|
4426
4709
|
})
|
|
4427
4710
|
return null
|
|
4428
4711
|
}
|
|
@@ -4523,12 +4806,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4523
4806
|
const sourceAssistantMessageID = refreshed.latestAssistantID || "<no-assistant>"
|
|
4524
4807
|
if (
|
|
4525
4808
|
goal.continuationClaim?.runId === runID &&
|
|
4809
|
+
goal.continuationClaim?.compactionEpoch === compactionEpoch &&
|
|
4526
4810
|
goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID
|
|
4527
4811
|
) {
|
|
4528
4812
|
return null
|
|
4529
4813
|
}
|
|
4530
4814
|
|
|
4531
|
-
goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
|
|
4815
|
+
goal.continuationClaim = { runId: runID, compactionEpoch, sourceAssistantMessageID }
|
|
4532
4816
|
const claimPersisted = await persist(sessionID)
|
|
4533
4817
|
if (!claimPersisted && persistenceOptions.persistState) {
|
|
4534
4818
|
goal.continuationClaim = null
|
|
@@ -4545,7 +4829,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4545
4829
|
})
|
|
4546
4830
|
return null
|
|
4547
4831
|
}
|
|
4548
|
-
|
|
4832
|
+
// Let an already-published compaction event invalidate this claim before
|
|
4833
|
+
// the caller enters promptAsync. The final epoch check is the atomic edge:
|
|
4834
|
+
// a claim is valid only while its context epoch is still current.
|
|
4835
|
+
await Promise.resolve()
|
|
4836
|
+
return activeGoal(sessionID, goalID, runID)?.compactionEpoch === compactionEpoch
|
|
4837
|
+
? goal
|
|
4838
|
+
: null
|
|
4549
4839
|
}
|
|
4550
4840
|
|
|
4551
4841
|
const retireCompletedCommandTurnOnIdle = async (sessionID, messageLimit) => {
|
|
@@ -4802,6 +5092,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4802
5092
|
requireCurrent: false,
|
|
4803
5093
|
})
|
|
4804
5094
|
}
|
|
5095
|
+
// Hand the session title back to the user now that no goal owns it.
|
|
5096
|
+
if (clearStillCurrent) await restoreSessionTitle(sessionID)
|
|
4805
5097
|
replaceCommandOutputText(
|
|
4806
5098
|
output,
|
|
4807
5099
|
!clearStillCurrent
|
|
@@ -5169,6 +5461,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5169
5461
|
`Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
|
|
5170
5462
|
)
|
|
5171
5463
|
|
|
5464
|
+
// A goal set while a planning-only agent is active is recorded but held,
|
|
5465
|
+
// so the objective and its budget survive the mode switch. Without this
|
|
5466
|
+
// the goal is created live and the routed command text tells the model to
|
|
5467
|
+
// start working; the idle guard only catches it on the *next* idle.
|
|
5468
|
+
const creationRestrictedAgent = await restrictedAgentFor(sessionID)
|
|
5469
|
+
if (creationRestrictedAgent) {
|
|
5470
|
+
holdGoalForRestrictedAgent(goal, creationRestrictedAgent)
|
|
5471
|
+
}
|
|
5472
|
+
|
|
5172
5473
|
// Replace the focused goal (cleanupGoal discards it); backgrounded goals
|
|
5173
5474
|
// for this session are preserved. Use `/goal add` to keep the current
|
|
5174
5475
|
// goal and add another. Clear any ordered-sequence flag so the new
|
|
@@ -5180,11 +5481,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5180
5481
|
registerSessionGoal(goal)
|
|
5181
5482
|
focusGoal(sessionID, goal)
|
|
5182
5483
|
await persist(sessionID)
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5484
|
+
const heldLabel = creationRestrictedAgent
|
|
5485
|
+
? isPlanAgent(creationRestrictedAgent)
|
|
5486
|
+
? "Plan"
|
|
5487
|
+
: creationRestrictedAgent
|
|
5488
|
+
: ""
|
|
5489
|
+
announceLifecycle(
|
|
5490
|
+
sessionID,
|
|
5491
|
+
heldLabel
|
|
5492
|
+
? `Goal recorded but held while ${heldLabel} is active.`
|
|
5493
|
+
: replacedGoal
|
|
5494
|
+
? "Goal replaced and active."
|
|
5495
|
+
: "Goal active.",
|
|
5496
|
+
{
|
|
5497
|
+
goal,
|
|
5498
|
+
transition: heldLabel ? "paused" : replacedGoal ? "replaced-active" : "active",
|
|
5499
|
+
expectedState: heldLabel ? "paused" : "active",
|
|
5500
|
+
},
|
|
5501
|
+
)
|
|
5188
5502
|
replaceCommandOutputText(
|
|
5189
5503
|
output,
|
|
5190
5504
|
[
|
|
@@ -5195,14 +5509,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5195
5509
|
"",
|
|
5196
5510
|
]
|
|
5197
5511
|
: []),
|
|
5198
|
-
`New active goal: ${goal.condition}`,
|
|
5512
|
+
heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
|
|
5199
5513
|
goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
|
|
5200
5514
|
goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
|
|
5201
5515
|
goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
|
|
5202
5516
|
"",
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5517
|
+
// A held goal must not be told to start working. Command text reaches
|
|
5518
|
+
// the model as a normal turn on current OpenCode builds, so this line
|
|
5519
|
+
// would be the escape the plan guard exists to prevent.
|
|
5520
|
+
...(heldLabel
|
|
5521
|
+
? [
|
|
5522
|
+
`The ${heldLabel} agent is planning-only, so this goal is not running.`,
|
|
5523
|
+
"Do not begin work on it now. Continue planning only.",
|
|
5524
|
+
`Switch to an executing agent, then run \`/${commandName} resume\` to start work.`,
|
|
5525
|
+
]
|
|
5526
|
+
: [
|
|
5527
|
+
"Start working toward this goal now.",
|
|
5528
|
+
"When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.",
|
|
5529
|
+
"If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
|
|
5530
|
+
]),
|
|
5206
5531
|
`Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
|
|
5207
5532
|
"",
|
|
5208
5533
|
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
|
|
@@ -5211,7 +5536,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5211
5536
|
]
|
|
5212
5537
|
.filter((line) => line !== null)
|
|
5213
5538
|
.join("\n"),
|
|
5214
|
-
|
|
5539
|
+
// A held goal is a control turn, not a work turn: `startsWork: false`
|
|
5540
|
+
// routes it through the read-only command framing.
|
|
5541
|
+
{ preserveFiles: true, startsWork: !heldLabel },
|
|
5215
5542
|
)
|
|
5216
5543
|
},
|
|
5217
5544
|
|
|
@@ -5328,15 +5655,59 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5328
5655
|
if (event?.type === "session.compacted") {
|
|
5329
5656
|
const sessionID = getSessionID(event)
|
|
5330
5657
|
const goal = goalStates.get(sessionID)
|
|
5331
|
-
if (!goal) return
|
|
5658
|
+
if (!goal || goal.stopped) return
|
|
5659
|
+
const identity = compactionEventIdentity(event)
|
|
5660
|
+
if (identity) {
|
|
5661
|
+
if (identity === goal.lastCompactionEventID) return
|
|
5662
|
+
goal.lastCompactionEventID = identity
|
|
5663
|
+
} else if (goal.compactionEpoch > 0 && !goal.messageSeenSinceCompaction) {
|
|
5664
|
+
// A real OpenCode `session.compacted` carries only `sessionID` (SDK:
|
|
5665
|
+
// EventSessionCompacted has no id/compactionID/summaryID/messageID and
|
|
5666
|
+
// no sync variant), so compactionEventIdentity() returns "" for every
|
|
5667
|
+
// host-delivered compaction and the identity dedup above never fires
|
|
5668
|
+
// in production. Recognize a re-delivery by the absence of message
|
|
5669
|
+
// activity instead: a genuine new compaction is always preceded by
|
|
5670
|
+
// messages, because the context has to grow again to trigger one.
|
|
5671
|
+
return
|
|
5672
|
+
}
|
|
5673
|
+
goal.messageSeenSinceCompaction = false
|
|
5674
|
+
|
|
5675
|
+
goal.compactionEpoch += 1
|
|
5676
|
+
goal.stalledCompactions += 1
|
|
5677
|
+
goal.compactionSourceAssistantMessageID =
|
|
5678
|
+
goal.continuationClaim?.runId === goal.runId
|
|
5679
|
+
? goal.continuationClaim.sourceAssistantMessageID
|
|
5680
|
+
: ""
|
|
5332
5681
|
goal.messageIDs = new Set()
|
|
5333
5682
|
goal.totalTokens = 0
|
|
5334
|
-
// Compaction rewrites the context
|
|
5335
|
-
//
|
|
5336
|
-
//
|
|
5337
|
-
// message, which would otherwise stall the goal loop until the user
|
|
5338
|
-
// nudges it).
|
|
5683
|
+
// Compaction rewrites the context. The epoch-scoped claim lets the same
|
|
5684
|
+
// retained assistant source continue once in the new epoch without
|
|
5685
|
+
// allowing duplicate idle delivery to continue it twice.
|
|
5339
5686
|
goal.continuationClaim = null
|
|
5687
|
+
|
|
5688
|
+
// An idle handler can already have persisted its source claim when the
|
|
5689
|
+
// compaction lands. Abort its cooldown and release the per-session guard;
|
|
5690
|
+
// the epoch checks around promptAsync prevent that stale handler from
|
|
5691
|
+
// sending while allowing the post-compaction idle to start immediately.
|
|
5692
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
5693
|
+
currentRuntime().continuationControllers.delete(sessionID)
|
|
5694
|
+
activeContinues.delete(sessionID)
|
|
5695
|
+
|
|
5696
|
+
if (goal.stalledCompactions >= MAX_STALLED_COMPACTIONS) {
|
|
5697
|
+
await pauseActiveGoal(sessionID, {
|
|
5698
|
+
stopReason: "stalled compaction",
|
|
5699
|
+
status: `Goal paused after ${goal.stalledCompactions} compactions without a productive assistant or tool turn.`,
|
|
5700
|
+
history: `Paused after ${goal.stalledCompactions} compactions without productive non-compaction work.`,
|
|
5701
|
+
})
|
|
5702
|
+
if (typeof client?.session?.abort === "function") {
|
|
5703
|
+
try {
|
|
5704
|
+
await sessionApi.abort(sessionID)
|
|
5705
|
+
} catch (error) {
|
|
5706
|
+
await logPluginError(client, "Failed to abort a stalled compaction loop", error)
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
return
|
|
5710
|
+
}
|
|
5340
5711
|
await persist(sessionID)
|
|
5341
5712
|
return
|
|
5342
5713
|
}
|
|
@@ -5344,6 +5715,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5344
5715
|
if (event?.type === "message.updated") {
|
|
5345
5716
|
const message = messageInfoFromEvent(event)
|
|
5346
5717
|
if (!message) return
|
|
5718
|
+
const messageEnvelope =
|
|
5719
|
+
event?.properties?.message || event?.data?.message || message
|
|
5347
5720
|
|
|
5348
5721
|
const currentMessageID = messageID(message)
|
|
5349
5722
|
if (!currentMessageID) return
|
|
@@ -5353,6 +5726,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5353
5726
|
const goal = goalStates.get(currentSessionID)
|
|
5354
5727
|
if (!goal) return
|
|
5355
5728
|
|
|
5729
|
+
// Any message traffic for this goal marks the current compaction epoch
|
|
5730
|
+
// as having seen activity, which is what lets an identity-less
|
|
5731
|
+
// `session.compacted` re-delivery be told apart from a real one. Recorded
|
|
5732
|
+
// before the stale-redelivery guard below: a message that is stale for
|
|
5733
|
+
// token accounting still proves the host is delivering message events.
|
|
5734
|
+
goal.messageSeenSinceCompaction = true
|
|
5735
|
+
|
|
5356
5736
|
// Skip stale re-deliveries from a prior budget window or a replaced goal.
|
|
5357
5737
|
// resetGoalBudget and cleanupGoal both leave seenTokens entries in place
|
|
5358
5738
|
// so this guard can fire: if an ID is already recorded in seenTokens but
|
|
@@ -5395,6 +5775,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5395
5775
|
|
|
5396
5776
|
if (
|
|
5397
5777
|
messageRole(message) === "assistant" &&
|
|
5778
|
+
!isCompactionAssistantMessage(messageEnvelope) &&
|
|
5779
|
+
currentMessageID !== goal.compactionSourceAssistantMessageID &&
|
|
5398
5780
|
currentOutputTokens > previousOutputTokens &&
|
|
5399
5781
|
runtime.suppressedCommandAssistants.get(currentMessageID) !== currentSessionID
|
|
5400
5782
|
) {
|
|
@@ -5402,6 +5784,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5402
5784
|
changed = true
|
|
5403
5785
|
}
|
|
5404
5786
|
|
|
5787
|
+
// Productive-turn reset. `message.updated` carries only
|
|
5788
|
+
// `properties.info` (SDK: EventMessageUpdated) and never any parts —
|
|
5789
|
+
// tool parts arrive on the separate `message.part.updated` event, which
|
|
5790
|
+
// this plugin does not observe. A messageHasToolCall() check against the
|
|
5791
|
+
// event envelope is therefore always false and cannot serve as the reset
|
|
5792
|
+
// signal. Growing output tokens is the signal that does work: an
|
|
5793
|
+
// assistant turn that calls a tool still emits output tokens for it.
|
|
5794
|
+
if (
|
|
5795
|
+
messageRole(message) === "assistant" &&
|
|
5796
|
+
!isCompactionAssistantMessage(messageEnvelope) &&
|
|
5797
|
+
currentMessageID !== goal.compactionSourceAssistantMessageID &&
|
|
5798
|
+
currentOutputTokens > previousOutputTokens &&
|
|
5799
|
+
goal.stalledCompactions > 0
|
|
5800
|
+
) {
|
|
5801
|
+
goal.stalledCompactions = 0
|
|
5802
|
+
goal.compactionSourceAssistantMessageID = ""
|
|
5803
|
+
changed = true
|
|
5804
|
+
}
|
|
5805
|
+
|
|
5405
5806
|
if (changed) await persist(messageSessionID(message))
|
|
5406
5807
|
return
|
|
5407
5808
|
}
|
|
@@ -5497,10 +5898,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5497
5898
|
if (!goal || goal.stopped || activeContinues.has(sessionID)) return
|
|
5498
5899
|
const goalID = goal.goalId
|
|
5499
5900
|
const runID = goal.runId
|
|
5901
|
+
const compactionEpoch = goal.compactionEpoch
|
|
5500
5902
|
|
|
5501
5903
|
const continueToken = randomUUID()
|
|
5502
5904
|
const continueController = new AbortController()
|
|
5503
5905
|
let claimedSourceAssistantMessageID = ""
|
|
5906
|
+
let claimedCompactionEpoch = -1
|
|
5504
5907
|
activeContinues.set(sessionID, continueToken)
|
|
5505
5908
|
currentRuntime().continuationControllers.set(sessionID, continueController)
|
|
5506
5909
|
try {
|
|
@@ -5513,7 +5916,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5513
5916
|
? hostMessages.slice(-goal.options.maxRecentMessages)
|
|
5514
5917
|
: []
|
|
5515
5918
|
const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
|
|
5516
|
-
if (
|
|
5919
|
+
if (
|
|
5920
|
+
!activeGoalAfterMessages ||
|
|
5921
|
+
activeGoalAfterMessages.compactionEpoch !== compactionEpoch
|
|
5922
|
+
) return
|
|
5517
5923
|
if (!activeGoalAfterMessages.executionContext) {
|
|
5518
5924
|
activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
|
|
5519
5925
|
}
|
|
@@ -5526,9 +5932,21 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5526
5932
|
const assistantChanged = summarizeText(latestText) !== summarizeText(previousAssistantText)
|
|
5527
5933
|
const assistantRepeated =
|
|
5528
5934
|
latestAssistantID && latestAssistantID === activeGoalAfterMessages.lastAssistantMessageID
|
|
5529
|
-
|
|
5935
|
+
// A retained pre-compaction assistant must not be scored as fresh
|
|
5936
|
+
// progress, so the compaction source gates checkpointing and the stall
|
|
5937
|
+
// heuristics. It must NOT gate the terminal checks: a [goal:complete] or
|
|
5938
|
+
// [goal:blocked] on that retained turn has not been acted on yet — it
|
|
5939
|
+
// survived the compaction unprocessed — and swallowing it discards a
|
|
5940
|
+
// real result and spends another continuation to re-derive it.
|
|
5941
|
+
const terminalBoundary =
|
|
5530
5942
|
currentRuntime().suppressedCommandAssistants.get(latestAssistantID) === sessionID ||
|
|
5531
5943
|
activeGoalAfterMessages.skipNextTerminalCheck === true
|
|
5944
|
+
const activationBoundary =
|
|
5945
|
+
terminalBoundary ||
|
|
5946
|
+
Boolean(
|
|
5947
|
+
activeGoalAfterMessages.compactionSourceAssistantMessageID &&
|
|
5948
|
+
activeGoalAfterMessages.compactionSourceAssistantMessageID === latestAssistantID,
|
|
5949
|
+
)
|
|
5532
5950
|
activeGoalAfterMessages.skipNextTerminalCheck = false
|
|
5533
5951
|
|
|
5534
5952
|
if (!activationBoundary && latestText && (!assistantRepeated || assistantChanged)) {
|
|
@@ -5555,6 +5973,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5555
5973
|
const sourceAssistantMessageID = latestAssistantID || "<no-assistant>"
|
|
5556
5974
|
if (
|
|
5557
5975
|
activeGoalAfterMessages.continuationClaim?.runId === runID &&
|
|
5976
|
+
activeGoalAfterMessages.continuationClaim?.compactionEpoch === compactionEpoch &&
|
|
5558
5977
|
activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID ===
|
|
5559
5978
|
sourceAssistantMessageID
|
|
5560
5979
|
) {
|
|
@@ -5569,7 +5988,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5569
5988
|
let completionUnverified = false
|
|
5570
5989
|
let blockerUnstated = false
|
|
5571
5990
|
|
|
5572
|
-
if (!
|
|
5991
|
+
if (!terminalBoundary && goalIsComplete(latestText)) {
|
|
5573
5992
|
const evidence = extractCompletionEvidence(latestText)
|
|
5574
5993
|
if (evidence) {
|
|
5575
5994
|
await announceAudit(
|
|
@@ -5731,7 +6150,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5731
6150
|
"completion-unverified",
|
|
5732
6151
|
"Assistant output [goal:complete] without a [goal:evidence] line; completion rejected, continuing.",
|
|
5733
6152
|
)
|
|
5734
|
-
} else if (!
|
|
6153
|
+
} else if (!terminalBoundary && goalIsBlocked(latestText)) {
|
|
5735
6154
|
const reason = extractBlockedReason(latestText)
|
|
5736
6155
|
if (reason) {
|
|
5737
6156
|
await announceAudit(
|
|
@@ -5801,6 +6220,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5801
6220
|
sessionID,
|
|
5802
6221
|
goalID,
|
|
5803
6222
|
runID,
|
|
6223
|
+
compactionEpoch,
|
|
5804
6224
|
messages,
|
|
5805
6225
|
)
|
|
5806
6226
|
if (!claimedGoal) return
|
|
@@ -6014,12 +6434,16 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
6014
6434
|
sessionID,
|
|
6015
6435
|
goalID,
|
|
6016
6436
|
runID,
|
|
6437
|
+
compactionEpoch,
|
|
6017
6438
|
messages,
|
|
6018
6439
|
{ refreshMessages: cooldownWaited },
|
|
6019
6440
|
)
|
|
6020
6441
|
if (!activeGoalBeforePrompt) return
|
|
6021
6442
|
claimedSourceAssistantMessageID =
|
|
6022
6443
|
activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
|
|
6444
|
+
claimedCompactionEpoch =
|
|
6445
|
+
activeGoalBeforePrompt.continuationClaim?.compactionEpoch ?? -1
|
|
6446
|
+
if (claimedCompactionEpoch !== activeGoalBeforePrompt.compactionEpoch) return
|
|
6023
6447
|
|
|
6024
6448
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
6025
6449
|
if (budgetWrapup) {
|
|
@@ -6115,6 +6539,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
6115
6539
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
6116
6540
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
6117
6541
|
if (
|
|
6542
|
+
activeGoalAfterPrompt?.continuationClaim?.compactionEpoch ===
|
|
6543
|
+
claimedCompactionEpoch &&
|
|
6118
6544
|
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
6119
6545
|
claimedSourceAssistantMessageID
|
|
6120
6546
|
) {
|
|
@@ -6133,6 +6559,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
6133
6559
|
} else {
|
|
6134
6560
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
6135
6561
|
if (
|
|
6562
|
+
activeGoalAfterPrompt?.continuationClaim?.compactionEpoch ===
|
|
6563
|
+
claimedCompactionEpoch &&
|
|
6136
6564
|
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
6137
6565
|
claimedSourceAssistantMessageID
|
|
6138
6566
|
) {
|
|
@@ -6164,6 +6592,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
6164
6592
|
if (activeGoalAfterError) {
|
|
6165
6593
|
if (
|
|
6166
6594
|
claimedSourceAssistantMessageID &&
|
|
6595
|
+
activeGoalAfterError.continuationClaim?.compactionEpoch ===
|
|
6596
|
+
claimedCompactionEpoch &&
|
|
6167
6597
|
activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
|
|
6168
6598
|
claimedSourceAssistantMessageID
|
|
6169
6599
|
) {
|
|
@@ -6297,6 +6727,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
6297
6727
|
|
|
6298
6728
|
// register_command toggle: when disabled, the plugin does not own
|
|
6299
6729
|
// a slash command and only the event/transform/compaction hooks remain.
|
|
6730
|
+
// Session-title indicator: rather than threading a sync call through every
|
|
6731
|
+
// state-mutating site (a missed one shows the user a stale status), wrap the
|
|
6732
|
+
// two hooks that gate all state change. The sync no-ops when the rendered
|
|
6733
|
+
// title is unchanged, and runs in `finally` so the displayed status matches
|
|
6734
|
+
// the state actually reached even if a hook throws.
|
|
6735
|
+
if (sessionTitleStatus) {
|
|
6736
|
+
for (const hookName of ["command.execute.before", "event"]) {
|
|
6737
|
+
const original = hooks[hookName]
|
|
6738
|
+
if (typeof original !== "function") continue
|
|
6739
|
+
hooks[hookName] = async (...args) => {
|
|
6740
|
+
try {
|
|
6741
|
+
return await original(...args)
|
|
6742
|
+
} finally {
|
|
6743
|
+
let titleSessionID = ""
|
|
6744
|
+
if (hookName === "event") {
|
|
6745
|
+
// `message.updated` streams many times per assistant turn. Awaiting
|
|
6746
|
+
// a title sync on each would put an API round-trip in the streaming
|
|
6747
|
+
// path for a cosmetic update; idle, compaction, and interruption
|
|
6748
|
+
// events already cover every state the indicator renders.
|
|
6749
|
+
if (args[0]?.event?.type !== "message.updated") {
|
|
6750
|
+
titleSessionID = getSessionID(args[0]?.event)
|
|
6751
|
+
}
|
|
6752
|
+
} else {
|
|
6753
|
+
titleSessionID = args[0]?.sessionID
|
|
6754
|
+
}
|
|
6755
|
+
await syncSessionTitle(titleSessionID)
|
|
6756
|
+
}
|
|
6757
|
+
}
|
|
6758
|
+
}
|
|
6759
|
+
}
|
|
6760
|
+
|
|
6300
6761
|
if (!registerCommand) {
|
|
6301
6762
|
delete hooks["command.execute.before"]
|
|
6302
6763
|
}
|
|
@@ -6433,6 +6894,14 @@ export const testInternals = {
|
|
|
6433
6894
|
isIdleEvent,
|
|
6434
6895
|
isPluginCommandMessage,
|
|
6435
6896
|
isPluginContinuationMessage,
|
|
6897
|
+
isPlanAgent,
|
|
6898
|
+
buildSessionTitle,
|
|
6899
|
+
formatCompactDuration,
|
|
6900
|
+
formatCompactTokens,
|
|
6901
|
+
goalStatusIcon,
|
|
6902
|
+
looksLikePluginSessionTitle,
|
|
6903
|
+
isRestrictedAgent,
|
|
6904
|
+
normalizeRestrictedAgents,
|
|
6436
6905
|
isPluginGeneratedMessage,
|
|
6437
6906
|
legacyStateFilePaths,
|
|
6438
6907
|
messageHasToolCall,
|