last activity notifications for opencode
TypeScript 100%

README.md

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 — any request/parts/response event). 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 + 7m is 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). condensed shows both bare ages (20m / 27m); expanded spells 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 reads now; 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 / .interrupted remain 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.updated seeds the prompt clock, time.idle the 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 SlotMap is typed locally in src/tui/context.ts so host path renames fail typecheck (SLOT_PATHS is 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