Skip to content

C11-243: record when the operator last looked at each tab - #461

Merged
BenevolentFutures merged 3 commits into
mainfrom
C11-243-tab-last-seen
Sep 30, 2026
Merged

BenevolentFutures merged 3 commits into
mainfrom
C11-243-tab-last-seen

Conversation

@BenevolentFutures

Copy link
Copy Markdown
Contributor

What

Per-tab lastSeenAt: when the operator last looked at each terminal, browser or markdown tab. Feeds the "Seen" clock in the tab sheet (sheet UI is out of scope here).

Semantics (also documented in Sources/SurfaceSeenClock.swift)

A tab is being seen while it is the selected tab of the focused pane, in the selected workspace of the key main window, with c11 frontmost and the screen unlocked or awake. lastSeenAt is the moment it last stopped being seen; while being seen it reports now; never seen is null.

"Being seen" is derived from live focus state and recomputed on: tab/pane selection (applyTabSelection), workspace switch (TabManager.selectedTabId), window key/resign key, app activate/deactivate, and screen lock, sleep and session-resign. Because it is derived from what is on screen, a programmatic or socket focus change while c11 is in the background stamps nothing. A socket command that changes the visible tab while the operator is looking does stamp the tab that left the screen (they did stop seeing it).

Where it lives

  • State: SurfaceSeenClock (pure state machine) + SurfaceSeenTracker.shared in Sources/SurfaceSeenClock.swift. Read with SurfaceSeenTracker.shared.lastSeenAt(panelId:).
  • Host detail model: SurfaceActivityDetailsSnapshot.lastSeenAt, beside createdAt and lastActivityAt.
  • Persistence: last_seen_at on SessionPanelSnapshot; restored in createPanel(from:). Rides the existing snapshot cadence, no extra writes.
  • Socket/CLI: last_seen_at (ISO-8601 or null) and being_seen on every surface in surface.list and system.tree, so c11 tree --json carries them.
  • Skill: skills/c11/SKILL.md and references/api.md updated; installed copy synced.

Performance

Refresh runs only from selection/focus/app events (never keystroke, hit-test or forceRefresh). Steady state is a few property reads and an equality check; a Date is allocated only when the seen-panel changes.

Validation

  • SurfaceSeenClockTests (9 tests, c11LogicTests) pass: transitions, no restamp, seed vs live, snapshot round-trip incl. legacy decode.
  • Tagged Debug build (tab-last-seen, QA-launched, backgrounded): c11 tree --json shows last_seen_at: null, being_seen: false, and creating/focusing surfaces over the socket while c11 is inactive stamps nothing.
  • Not exercised live: the positive path (real focus changes stamping in the frontmost app). It needs the app active and a human-style focus change; covered only by the state-machine tests plus code reading.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the drawbridge:review Drawbridge: routed to maintainer review label Sep 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Drawbridge deep review — PR #461: record when the operator last looked at each tab

Thank you for this — it's a careful, well-shaped contribution and a genuine pleasure to review. The "being seen" definition is written down once (top of SurfaceSeenClock.swift) and the code actually matches it; the state machine is a pure struct tested through its real transitions; persistence rides the existing snapshot cadence rather than inventing a new write path; and the skill (SKILL.md + references/api.md) was updated in the same PR. That's exactly the shape we want.

Summary

Adds a per-tab lastSeenAt clock: a pure SurfaceSeenClock state machine plus a @MainActor SurfaceSeenTracker.shared that recomputes "what the operator is looking at" from live focus/app/screen events, stamps the tab that just left the screen, persists via SessionPanelSnapshot.last_seen_at, and exposes last_seen_at / being_seen on every surface in surface.list and system.tree. My read: correct, well-scoped, and safe to land; everything remaining is optional polish plus a couple of operational checks.

Alignment

Squarely in scope. This is an agent-facing telemetry primitive on addressable surfaces — one of the "clearly wanted" categories in TRIAGE_POLICY.md — and it respects the hot-path and skill-is-contract rules: refresh fires only on selection/focus/app/screen events, never per keystroke, hit-test, or forceRefresh, and the skill was updated alongside the socket surface. No tenant-config writes. It correctly routed to deep review rather than the autonomous lane: it touches GhosttyTabs.xcodeproj/project.pbxproj (deny-listed) and is a feature, not a bug fix. Nothing untoward in the diff, and no prompt-injection attempts in the code or PR text.

I re-verified the load-bearing claims against the trusted checkout:

  • Workspace.focusedPanelId really is "selected tab of the focused pane" (Sources/Workspace.swift:5475), so operatorSeenPanelId() reads the right thing, and contextForMainTerminalWindow(_:reindex:) exists with the reindex: false overload it calls (Sources/AppDelegate.swift:6234).
  • Both socket read sites run on main: v2SurfaceList wraps its builder in v2MainSync (SurfaceHandlers.swift:68), and the tree path (v2TreeWorkspaceNode) is only ever called from inside the v2MainSync block in SystemHandlers.swift:467. So the SurfaceSeenTracker.shared reads in v2SetSeenFields execute on main and are race-free with refresh().
  • The snapshot builder that calls SurfaceSeenTracker.shared.lastSeenAt(panelId:) is @MainActor-isolated (Sources/Workspace.swift, documented in the surrounding comment), so the persistence read is also main-safe.
  • v2SetSeenFields reuses a cached private static let seenTimestampFormatter via Self., not a per-surface ISO8601DateFormatter() — the formatter-allocation lesson is honored, and the default formatter emits UTC second-precision, matching the docs.
  • The detach/close distinction is wired correctly: the didCloseTab non-detach branch (Workspace.swift:12150 else, entered only when isDetaching is false) forgets the id, the pane-close loop gates forget on !isDetachingCloseTransaction, and the workspace-teardown / window-unregister loops forget unconditionally — which is right, since those run when the panels are truly gone.

Findings

No blockers.

1. (minor, accuracy) A transient auxiliary key window stamps the visible tab as "stopped being seen."
operatorSeenPanelId() (Sources/AppDelegate.swift) requires NSApp.keyWindow to resolve to a main terminal window. When the command palette or any NSPanel/dialog takes key, didBecomeKeyNotification fires, contextForMainTerminalWindow returns nil, seen computes to nil, and the tab the operator is still effectively looking at gets stamped. It self-corrects the instant the palette closes (the tab becomes current again and reports "now"), so the only lasting artifact is a snapshot captured while a palette is open recording the palette-open time. Acceptable as-is; if you want the stamp exact, either treat a non-main key window as "keep the current seen tab," or add one sentence to the SurfaceSeenClock.swift doc noting auxiliary key windows count as "not seen."

2. (minor, robustness) appBecameActive() self-heals every stale interrupt reason except .sessionInactive.
Sources/SurfaceSeenClock.swift: appBecameActive() subtracts .screensaver / .displaysAsleep / .systemAsleep and re-reads .locked from the window server, on the sound reasoning that c11 cannot become frontmost while any of those hold. The same reasoning applies to .sessionInactive — c11 cannot be the frontmost app while its login session is inactive. If sessionDidBecomeActiveNotification is ever dropped (the whole self-heal exists precisely to hedge dropped notifications), .sessionInactive sticks and nothing reports as seen even with c11 foregrounded, until the next full session-switch cycle. Consider adding .sessionInactive to the subtract set in appBecameActive() for symmetry. Non-blocking.

3. (nit) SurfaceActivityDetailsSnapshot.lastSeenAt / isBeingSeen are var where siblings are let.
Sources/Sidebar/SidebarActivityProjector.swift:126-127. The siblings (activityHelp, createdAt, lastActivityAt) are all let (confirmed at :123-125). Both new fields can be let … = nil / let … = false — the defaults keep the fallback call site working while the Workspace.swift:6944 site passes both explicitly. Style only.

4. (nit) Test file lives in c11Tests/ on disk but is a member of the c11LogicTests target.
c11Tests/SurfaceSeenClockTests.swift, added to the c11LogicTests Sources phase. This matches the existing convention (AttentionModelTests.swift does the same) and is not a C11-105 socket-unlink risk — the test drives the pure struct, the tracker with an injected provider, and a Codable round-trip only; it never calls TerminalController.shared.stop(). Flagging only because that directory/target drift is exactly what the C11-105 note warns about. Non-blocking.

On the tests generally: the pure-state-machine cases are exactly right, and testLastSeenAtRoundTripsThroughPanelSnapshot verifies real encode output + decode round-trip + legacy (nil) decode. The contains("\"last_seen_at\"") assertion is a serialization-contract check (the wire/persistence key name), not a source-text grep, so it conforms to the test policy. The interruption-set tests (testWakeWhileStillLockedStaysInterrupted, testScreensaverWithoutStopIsHealedByAppActivation) nicely pin the "independent reasons" semantics that finding #2 is about.

Recommendation

Merge after fixes — where "fixes" is really just optional polish (findings 1–4 are all minor/nit and could land here or in a follow-up) plus two operational checks. Before merge, please confirm: CI is green on the final head (the PR notes the positive stamping path wasn't exercised live, which is a reasonable limitation given it needs a foregrounded app and a human-style focus change — the state-machine tests plus code reading cover it); and, since the skill source was edited, that scripts/sync-installed-skills.sh c11 was run on the maintainer's machine, since the committed source and the installed copy are separate files. The core state machine, event wiring, threading, and persistence are correct.

Nice work — thank you for the contribution.


🌉 Drawbridge deep review (lane 3) — an automated pass; a maintainer has been notified and makes the final call.


🌉 Drawbridge deep review (lane 3) — an automated pass; a maintainer has been notified and makes the final call.

Atin and others added 3 commits September 30, 2026 14:55
Per-tab last-seen clock. A tab is being seen while it is the selected tab of
the focused pane, in the selected workspace of the key main window, with c11
frontmost and the screen unlocked; lastSeenAt is the moment it last stopped
being seen. Derived from live focus state and refreshed on tab/pane/workspace
selection, key window, app activation and screen lock/sleep, so background
programmatic focus changes stamp nothing. Persisted as last_seen_at on the
session panel snapshot, exposed as last_seen_at/being_seen in surface.list and
system.tree (c11 tree --json), and carried on SurfaceActivityDetailsSnapshot
for the tab sheet. Skill and api reference updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n close, injectable tracker)

- Interrupts are a Set of independent reasons (locked, screensaver, displays
  asleep, system asleep, session inactive); one clearing never lifts another.
- Seen also requires the key window on the active Space and not occluded.
- Panel stamps are dropped on tab close, pane close and workspace teardown
  (not on detach/move, where the id survives).
- One static ISO8601 formatter for socket output; set fields directly.
- Refresh when a main window registers or becomes the active window.
- Restore last_seen_at inside applySessionPanelMetadata on the created id.
- Tracker takes an injectable seen provider and clock; tests cover refresh,
  lock-then-wake staying interrupted, close-then-forget.
- SurfaceActivityDetailsSnapshot gains isBeingSeen; docs updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…amp for the sheet

- App activation clears screensaver and sleep reasons (c11 cannot be frontmost
  while they hold) and re-derives .locked from the window server.
- Distributed lock/screensaver observers use .deliverImmediately; screensaver
  willstop also clears.
- Closing a window forgets its panels' stamps (workspace moves re-home first).
- SurfaceActivityDetailsSnapshot.lastSeenAt is the raw stored stamp; pair with
  isBeingSeen to render "now".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
BenevolentFutures pushed a commit that referenced this pull request Sep 30, 2026
…e default

clocks[seen] is the stored last-seen stamp from the tracker; while the operator is
looking at the tab it reads "now" (host text) instead of an age. Default clock
order is active,seen,launched again. Self-contained so it can be cherry-picked
onto #463 once #461 lands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@BenevolentFutures
BenevolentFutures merged commit cd567b9 into main Sep 30, 2026
11 checks passed
BenevolentFutures added a commit that referenced this pull request Sep 30, 2026
* Live model detection for agent tabs

Read the model each harness is actually using from the session file c11
already locates for resume (Claude Code, Codex, pi, omp: incremental JSONL
tail; Grok: summary.json; opencode: session.model in its SQLite), off-main on
the AgentDetector 10 s sweep, and publish the raw id as model_detected at the
derived tier. Declared/explicit model and launch stamps still win; detection
fills in. Kimi and Copilot report unsupported instead of guessing. Friendly
names (Opus 5.5, gpt-5.5) are shared by the sidebar chip and the tab sheet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Tab sheet signals: agent last-event Active, turn/tools/tokens/touched clocks, per-type Active

- Agent tabs: Active is the last assistant message or tool result, read by the
  same transcript tail as model detection (Claude Code, Codex, pi, omp; opencode
  and Grok from their rows). Also turn duration, tool calls and tokens this turn.
- Plain terminals: Active is the later of scrollback growth while visible
  (GHOSTTY_ACTION_SCROLLBAR, re-baselined on resize and visibility changes,
  decreases ignored) and the last command start/finish. Markdown: last content
  change (mtime at load). Browser: last load, skipping the first after restore
  or hibernate. Operator input is a separate 'touched' clock (plain Date stamp in
  keyDown and the web view). turn/tools/tokens/touched are opt-in via the clock
  order setting; the default is active,seen,launched.
- Model precedence: agent-declared > detected > launch stamp. Launch stamps
  (launch-agent, A button, blueprints) are now recorded at the heuristic tier.
- Bonsplit: additive clockTexts for non-date clocks (submodule branch
  tab-agent-signals).
- debug.tab_sheet.detail socket method returns a surface's sheet detail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Synthesized keys (socket send) do not stamp the touched clock

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Address review: wrapper no longer declares the launch model, Codex tokens dedupe, per-type Active fall-through, touched from text box and clicks

- Resources/bin/claude stops forwarding CMUX_AGENT_MODEL as a declaration; the
  launch stamp (heuristic tier) already records it, so a later /model shows.
  Snapshots from before the tiering restore launch-stamped models at the launch
  tier (marker c11.model_tiering distinguishes newer snapshots).
- Codex repeated token_count lines collapse by total_token_usage.
- Active falls through to scrollback growth/command edges when an agent has no
  transcript event; legacy activity (which included keystrokes) is gone. Default
  clock order is active,launched again.
- touched also stamps from the text box (keys and typing) and terminal clicks.
- Scrollback tracker: settle window instead of skipping the first event after a
  reveal/resize; alt-screen exit no longer stamps the restored history.
- Parsing: timestamps and roles come from parsed fields; a very long Codex turn
  finds its turn_context by backward substring search; a placeholder ref resets
  the previous session's state; Grok summary is a plain read.
- Clock text follows the app language; panelShellEdgeAt is pruned and moved with
  surfaces; chip model source matches what display_label shows; privacy contract
  lists exactly what is retained; localization terms revised.
- Bonsplit: tolerant decode of clocks/clockTexts (c52fc58).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Tab sheet: wire the Seen clock and restore active,seen,launched as the default

clocks[seen] is the stored last-seen stamp from the tracker; while the operator is
looking at the tab it reads "now" (host text) instead of an age. Default clock
order is active,seen,launched again. Self-contained so it can be cherry-picked
onto #463 once #461 lands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Atin <benevolent.futures@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
@BenevolentFutures
BenevolentFutures deleted the C11-243-tab-last-seen branch September 30, 2026 23:17
@BenevolentFutures BenevolentFutures mentioned this pull request Oct 1, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drawbridge:review Drawbridge: routed to maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant