opencode-lastact #
Two temporal cues for OpenCode v2 sessions:
- a subdued TUI status line reading
5.4m ago— or, with both clocks known,20m + 7m: how long since the LLM last did anything, and how big the gap is behind it; - durable transcript notices for significant time shifts, so both the human and the next model can see that work resumed after a long gap.
This is the minimal core of openlastcode
— its current-activity tracker, v2 event mapping, and time formatting — cut
down to a single purpose. openlastcode itself keeps the full feature set
(thread-history sidebar, /lastcode, toasts, activity modes). The rendering
follows opencode-quota-finer's
compact-line conventions, and the authoring discipline comes from the
[plugin authoring guide](file:///home/rektide/archive/doc/opencode/plugin.md).
What it shows #
The line answers: what is this session doing, and how long has it been
doing nothing? — under the activity ledger model
(.design/settled-clock/),
matched to the current host's durable clocks and event vocabulary:
- Two clocks per session — the last prompt (a user prompt landing,
session.inbox.enqueued) and the last part of any kind from the LLM (new thought, new tool, streamed text — anyrequest/parts/responseevent). The gap between them is the stall signal: a session whose part clock has gone quiet while running is blocked, and the line says so in plain arithmetic. - Diff by default —
20m + 7mis the fresher clock's age plus the span to the other clock (here: last LLM part 20m ago, 7m after the prompt that preceded it, silent since).condensedshows both bare ages (20m / 27m);expandedspells them out (part 20m ago · prompt 27m ago). With only one clock known, every mode degrades to the settled-clock line. - Working holds
now— until there is something fresher to say. While the session runs (data.session.status, the host's own busy derivation) and no LLM part has been seen, the line readsnow; once part evidence flows, the real ages speak for themselves — streaming keeps the part age at seconds, a wedged turn lets it grow. - Settle arms the markers —
session.execution.succeeded/.failed/.interruptedremain the turn terminals; non-success outcomes earn a marker:5.4m ago ✗,20m + 7m ⋯. - Durable seed — each clock reconciles monotonically with the host
store (
time.updatedseeds the prompt clock,time.idlethe part clock), so a freshly opened session already shows a truthful age and a restart agrees with the live clock by construction. - Quiet degradation — while nothing is known, the line simply does not paint. No fake clock, no error.
(The message.* events of the earlier v2 rewrite exist only in the v1 legacy
schema and never fire on the current host — see
.design/settled-clock/.)
Transcript time shifts #
The server companion follows the prompt-hook pattern used by
[openthinkfo](file:///home/rektide/src/openvariant): immediately before a
prompt resumes a session after four hours or more, it admits a durable
synthetic message:
[time-shift from="2026-08-31T22:15:00.000Z" to="2026-09-01T04:47:30.000Z" duration="6h 32m"]
Continue the implementation.
The exact UTC endpoints make the transition unambiguous; the compact duration
makes it easy to scan. The marker inherits the prompt's steer / queue
delivery and uses resume: false, so it is durably ordered before the prompt
without waking execution by itself. Synthetic admission bypasses prompt hooks,
so the marker does not recurse.
The from point is the later of the last settled turn (time.idle) and the
last prompt this plugin observed. A selection, rename, or another plugin's
synthetic notice cannot erase a real conversational gap by advancing
time.updated. Empty new sessions stay quiet. Prompt hooks are serialized per
session, and a deterministic synthetic message id plus the stored prompt clock
prevents overlapping prompts from duplicating a marker.
The server retains session-prompt/<sessionID> in plugin storage and attaches
{ source: "opencode-lastact", kind: "time-shift", from, to, durationMs } to
each synthetic message. Storage entries inherit the session plugin's current
retention behavior and are not automatically pruned.
How it stays at rest #
Subscriptions never do clock math. Every subscribed event — routed through
one flat table, src/tui/events.ts — does nothing but touch one cell of a
per-session ledger row (src/activity/tracker.ts), O(1), monotone. The
bucket is the view's fixed-cadence ticker: it alone re-derives the
clocks from ledger + store, renders, and notices edge transitions — at most
one refreshMs late, by design. No timer is created, reset, or cleared by
activity; the update signal refreshes per session, the bucket stays be.
Surfaces #
Where the line paints is config — separately for regular sessions and subagent child panes. Both options accept one surface name or an array (order preserved, duplicates dropped, unknown names ignored — if none remain, that option's default applies):
| Name | Claimed path (additive append) |
Clock | Mounts | Default for |
|---|---|---|---|---|
prompt |
prompt.footer.status |
the current session | non-child panes | slots |
sidebar |
sidebar.footer |
the focused session | non-child panes | |
composer |
session.composer.top |
the focused session | all session panes | childSlots |
All three surfaces publish a session id input, so every line is
session-bound. The child pane is the catch: the host suppresses the
sidebar and the prompt entirely when a subagent child is focused — which
is exactly where stalls hurt most — so composer is the only surface that
renders there. It mounts in regular panes too, so each claim is gated by
session kind (Boolean(session.parentID), the host's own child test): a
composer line paints only where no prompt/sidebar line already does.
There is deliberately no home surface: the sessionless home screen has no
session context for a last-used line (unlike a process-global readout such as
opencode-quota's, where home.footer is meaningful).
Plugins cannot publish new slot paths (claims into unpublished paths
silently suppress), so the vocabulary grows only as the host publishes new
paths — the drift-catcher in src/tui/context.ts
makes any host change fail typecheck here first.
Usage #
Register the package once in opencode.jsonc. The server export advertises
its TUI companion, so no duplicate CLI-plugin entry is needed:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
"package": "opencode-lastact",
"options": {}
}
]
}
To run directly from this checkout, use
"package": "file:///home/me/src/opencode-lastact".
Options #
| Option | Values | Default |
|---|---|---|
enabled |
boolean | true |
gapThresholdMs |
minimum transcript gap in milliseconds | 14400000 (4h) |
slots |
"prompt" | "sidebar" | "composer" or array |
["prompt"] |
childSlots |
surfaces for subagent child panes | ["composer"] |
view |
diff | condensed | expanded |
"diff" |
refreshOn |
object of family → boolean (below) | all on but updated |
formatStyle |
precise | compact | verbose | clock |
"precise" |
decimals |
integer 0–4 (precise style) |
1 |
refreshMs |
tick rate for the line, ms (≥100) | 1000 |
enabled: false disables both the transcript recorder and TUI surfaces.
gapThresholdMs: 0 records every gap after the first observed activity.
refreshOn — one option per timer type: whether that event family
refreshes a clock.
| Family | Events | Feeds |
|---|---|---|
message |
session.inbox.enqueued (a user prompt lands) |
prompt |
request |
session.execution.started (a turn begins) |
part |
parts |
step/text/reasoning/compaction/shell started, tool.called, skill.activated |
part |
response |
*.delta, step.streamed, tool.progress, retry.scheduled, synthetic |
part |
updated |
durable ordering bumps (renames, selections, reverts, forks) | prompt |
The session.execution.* terminals are always observed (they settle the
turn and carry the outcome markers), regardless of refreshOn.
Examples:
{ "plugins": [{ "package": "opencode-lastact", "options": { "slots": ["prompt", "sidebar"] } }] }
{ "plugins": [{ "package": "opencode-lastact", "options": { "formatStyle": "compact", "slots": "sidebar" } }] }
{ "plugins": [{ "package": "opencode-lastact", "options": { "view": "condensed", "refreshOn": { "updated": true } } }] }
{ "plugins": [{ "package": "opencode-lastact", "options": { "childSlots": ["composer", "sidebar"] } }] }
Invalid values are omitted, not defaulted-over, so a typo falls back to the field's default rather than clobbering it.
Architecture #
Domain-grouped, mirroring openlastcode's layout at one-tenth the size:
src/
config/ surface + view vocabulary, refreshOn families, options decoding (pure)
time/ elapsed-duration formatting, four styles (pure)
render/ the short line: "20m + 7m" / "5.4m ago" / "now" / "14:32" (pure)
activity/ process-local ActivityTracker (the per-session activity ledger)
server/ prompt hook that records significant time shifts in the transcript
tui/ v2 host seam: drift-catcher context typing, the event→ledger
route table, the reconciling bucket view, and the
slot-claiming entrypoint
Host-contract discipline (each line earned by a past bug, see the [plugin authoring guide](file:///home/rektide/archive/doc/opencode/plugin.md)):
- the full published
SlotMapis typed locally insrc/tui/context.tsso host path renames fail typecheck (SLOT_PATHSis compile-time checked against it); - every claim is additive
append; nothing replaces host UI; - setup returns a cleanup that disposes every claim and subscription;
- events are hints, not a journal — the view re-derives from the ledger + store on every tick, so a missed event self-heals (the SSE stream is volatile by contract).
Develop #
pnpm install
pnpm run check:type
pnpm test
The package runs its TypeScript source directly under OpenCode's Bun runtime
(the ./tui export points at src/tui/plugin.tsx); there is no build step.
Prior design material #
doc/ui-conf.md is the earlier OpenCode UI-configurability
research that this repo grew out of; the v1 command-tracking plugin it
accompanied was retired with this v2 rewrite.
.design/settled-clock/settled-clock0.glm53.md
is the settled-clock redesign: the live-host verification of the
session.execution.* vocabulary and durable clock family, and the two
decisions (status()-derived working hold, outcome markers) that replaced the
original message.* mapping. Its parent wave lives in openlastcode as
host-clocks0.
The activity-ledger round — the two-clock prompt/part model, the
touch-only event path, the bucket-cadence view, and the refreshOn /
view options — is captured in
activity-ledger0.
.design/settled-clock/subagent.glm53.md
researches rendering in subagent panes — where stalls hurt most — and
finds the current slot claims are suppressed there; its follow-up,
.design/settled-clock/subagent2.glm53.md,
verifies the parentID child test and specifies the composer surface +
childSlots option this repo implements.
License #
MIT