Skip to content

Stop host Portals on archive and expire idle previews - #336

Merged
jfrolich merged 6 commits into
mainfrom
fix-host-portal-lifecycle
Sep 11, 2026
Merged

jfrolich merged 6 commits into
mainfrom
fix-host-portal-lifecycle

Conversation

@jfrolich

@jfrolich jfrolich commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Host Portals outlived their sessions: archiving a session left its dev servers running, and an unused preview held host memory indefinitely. This makes a host Portal's lifetime match how it is used. No native app, Chrome extension, or protocol changes; Sandbox Portals are untouched.

Behavior

  • Archived cleanup. Archiving a session (manually or through idle auto-archive) stops the host Portals that session owns, including Portals in attached repositories. A sibling session's Portal in a shared checkout is left alone. The five-minute reaper now also treats archived owners like missing ones, so a cleanup interrupted by a crash or restart is retried; legacy ownerless records are reaped only once every owner of their worktree is archived or gone.
  • Host Linux idle tracking. A host Portal expires after 30 minutes without authenticated Portal traffic (the Caddy portal-auth check, after authorization succeeds) or an established connection to its service port, read from /proc/net/tcp and /proc/net/tcp6, so an open WebSocket counts as use. Session-list and readiness polling do not. Timestamps are process-local: a gateway restart grants a fresh idle window rather than killing a browser's Portal on a pre-restart timestamp, and a replacement process gets its own window. When connection telemetry is unavailable (non-Linux, no /proc tables), idle cleanup is skipped; orphan and archive cleanup still run.
  • Memory admission guards. A new host Portal process is refused when MemAvailable falls below 5% of RAM or 2 GiB, memory full-stall pressure (/proc/pressure/memory, full avg10) reaches 10%, or the shared opensession.slice reaches 90% of memory.high. A matching awake Portal is still reused, and the refusal is recorded as the Portal's failed state with the reason. These are admission guards only; nothing kills another session's active work.
  • Safe cleanup. Stops carry an expected generation (owner, name, start time, pid, scope unit), so a stale sweep cannot stop a Portal that was restarted in the meantime.

Implementation

  • New src/server/portal-lifecycle.ts: HostPortalActivity (per-port last-use keyed by generation), /proc tcp parsing, and the capacity checks. Pure functions take file contents so they are testable without the host.
  • portal-supervisor.ts: host registry IO is fully async (node:fs/promises), writes are atomic (temp file + rename) and serialized per canonical worktree, and each operation applies only its own changed records to the freshly re-read file so concurrent starts in one checkout no longer clobber each other. reapOrphanedPortalServices gains archived and idle reasons and audits portal_reaped with the reason. stopArchivedSessionPortals is the targeted archive hook.
  • startPortalReaper takes the async catalog snapshot (getSessionListSnapshotAsync) instead of the sync getAllSessions; no directory scans of session databases and nothing synchronous on the gateway thread.
  • archive.ts calls the cleanup after the archive is committed and logs, rather than fails, if it errors; the reaper retries.
  • routes/preview.ts touches activity only on the authorized 204 path.
  • Docs: docs/portals-and-agent-communication.md describes the lifetime rules.

Tests

  • portal-lifecycle.test.ts: admission thresholds (healthy, starvation, malformed meminfo, full-stall vs old swap, 90% soft-limit headroom), idle window from discovery not process start, HTTP touch vs repeated observation, per-generation windows, and IPv4/IPv6 established-port parsing that ignores listeners and peers.
  • portal-supervisor.test.ts: archived owner reaped even with live connections, sibling owner preserved in a shared worktree, legacy ownerless records, idle expiry preserved by traffic and by established connections and skipped without telemetry, a starved host refusing a start and recording why, an obsolete generation unable to stop a replacement, and concurrent registry updates preserving unrelated services.
  • Supervisor tests install a no-op capacity probe (_setHostPortalCapacityProbeForTests) so the CI or dev host's own memory pressure cannot decide the outcome. Without it the two tests that spawn real Portals failed on this host, which was at full avg10=14.6 while the gate ran.

OPENSESSION_TEST_JOBS=4 bun run check and bun scripts/check-module-side-effects.ts pass on 12f6906c.

Started by Jaap Frolich in this OS session

Started by Jaap Frolich in this OS session

Host Portals outlived their sessions: archiving left dev servers running,
and an unused preview held memory indefinitely. This makes their lifetime
match how they are used.

- Archiving a session stops the host Portals it owns, including those in
  attached repositories, without touching a sibling's Portal in a shared
  checkout. The five-minute reaper also treats archived owners like missing
  ones, so an interrupted cleanup is retried.
- On Linux a host Portal expires after 30 minutes without authenticated
  Portal traffic or an established connection to its port (from /proc/net
  tcp tables, so WebSockets count). Timestamps are process-local: a gateway
  restart grants a fresh window instead of killing a browser's Portal on an
  old timestamp. Without connection telemetry, idle cleanup is skipped.
- New host Portal processes are refused when available RAM is below 5% or
  2 GiB, memory full-stall pressure is at 10% over ten seconds, or the
  shared workload slice is at 90% of its memory soft limit. Matching awake
  Portals are still reused.
- Registry writes go through an atomic, per-worktree serialized update
  that re-reads the file and applies only the caller's changed records, so
  concurrent starts in one checkout no longer clobber each other. Stops
  carry an expected generation so a stale sweep cannot kill a replacement.
- The reaper takes an async catalog snapshot and all supervisor file IO is
  asynchronous, keeping it off the gateway thread.

Tests cover archived, sibling, legacy ownerless, idle and generation cases,
the admission thresholds, /proc tcp parsing, and concurrent registry
updates. Supervisor tests install a no-op capacity probe so the host's own
memory pressure cannot decide their outcome.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
opensession Ready Ready Preview Sep 11, 2026 3:39pm UTC

@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor
🕙 Outdated review — superseded by a newer review below

🤖 OS review · request changes · quality 2/5 · risk medium

Safe once the P1 below is fixed. The generation fence is racy, and alias-based archives miss immediate cleanup.

🟠 Risk medium · recovery in hours · wide blast radius, large diff
Across a 661-line diff, shared portal-supervisor paths terminate process groups and persist stopped records; reverting cannot restart them. Gate lifecycle reaping behind a rapidly reversible feature flag.

2 inline comments below.

Reviewed 5b71a50 · GPT-5.6 Sol · open session · labels: os-auto-fix fix and push · os-adversarial deeper pass · os-simplify cleanup

🔁 2 findings → owning session · fix round 1/6

@open-session-os-tella-dev open-session-os-tella-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OS review · 5b71a50

if (!current) throw new Error(`Portal '${name}' does not exist.`);
if (
expectedGeneration !== undefined &&
portalGeneration(current) !== expectedGeneration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P1 — Generation validation races with stopping the process

The generation is checked before terminatePortalProcess, but neither the check, termination, nor registry write shares a lifecycle lock with start/restart. An idle sweep can validate generation A, then a user restart can replace it with B before termination resumes. Because previewScopeUnit is stable for the worktree/name, the stale stop can terminate B; it can also overwrite B's record with the stopped A record. The replacement then disappears from supervision and may leave its port permanently blocked. Serialize each host Portal's complete start/stop/restart transaction by canonical worktree and name, with restart performed as one transaction, so the generation check remains valid through termination and persistence.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 01f0ca6. Start, stop, and restart of a host Portal now queue on one lock keyed by canonical worktree and name (withHostPortalOperation), and restart runs as a single transaction (read current, stop, start) inside that lock. The generation check therefore stays valid through termination and the registry write: a sweep holding generation A that arrives during a restart waits for the restart to finish, then rejects with "Portal changed" instead of killing B or overwriting its record. Unlike the Sandbox seam, this queues rather than coalesces, so a stop after a start runs after it. Covered by the new test "a stale stop queued behind a restart cannot kill the replacement", which fires a real restart, sends a stale-generation stop 100 ms later, and asserts the stop rejects while the replacement stays awake and supervised with its new pid.

for (const dir of dirs) {
if (!dir) continue;
for (const portal of await readHostPortalRegistry(dir)) {
if (portal.sessionId !== sessionId || portal.state === "stopped")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P2 — Archiving through an alias does not stop the canonical Portal

findSessionAsync(sessionId) supports historical aliases and returns the merged canonical session, but this comparison still uses the requested alias. For example, archiving slack-C998-1719860000.000000 can resolve to canonical session bks-live-canonical-archived-alias, whose Portal record is owned by the canonical ID. The hook skips that record, leaving it running until the periodic reaper. After resolving the session, compare and stop using session.id rather than the input ID.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 01f0ca6. stopArchivedSessionPortals now compares Portal owners against the resolved session.id and stops with that id, so archiving a historical alias stops the canonical owner's Portal. The lookup is injectable (options.findSession, defaulting to findSessionAsync) and the new test "archiving through an alias stops the canonical owner's Portal" resolves a Slack alias to a canonical session and asserts its record is stopped.

Review round 1 on the lifecycle change:

- A stop validated a record's generation, then terminated the process and
  wrote the stopped record without excluding a concurrent start or restart
  of the same Portal. Because the scope unit is stable per worktree and
  name, a stale sweep could kill the replacement and overwrite its record.
  Start, stop, and restart now queue on one lock per canonical worktree and
  name, with restart as a single transaction, so the generation check holds
  through termination and persistence. The lock queues rather than
  coalesces: a stop after a start runs after it.
- Archive cleanup compared Portal owners against the archived id, which may
  be a historical alias; the record is owned by the canonical session that
  the lookup resolves to. Compare and stop with the resolved id.

Tests: a stale stop queued behind a restart rejects and leaves the awake
replacement supervised; archiving through an alias stops the canonical
owner's Portal.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor
🕙 Outdated review — superseded by a newer review below

🤖 OS review · approve · quality 5/5 · risk medium

Safe to merge. The new operation serialization and canonical-owner lookup resolve both previous findings.

🟠 Risk medium · recovery in hours · wide blast radius, large diff
The large shared supervisor rewrite can stop processes and mark registries stopped, requiring affected Portals to be restarted. Canary the reaper and retain a bulk Portal restart path.

Reviewed 01f0ca6 · GPT-5.6 Sol · open session · labels: os-auto-fix fix and push · os-adversarial deeper pass · os-simplify cleanup

Reconcile the host Portal lifecycle fix with f8698f6 (sleep idle Portals,
cap preview capacity). Upstream sleep/wake, default path and ready timeout
persistence, the host Portal count cap, and running-owner protection stay.
The idle sleep sweep now uses the PR's connection-aware activity model, so
there is one idle loop instead of a competing stop loop. Archive and orphan
cleanup remain permanent stops. Sleep and wake run under the per-Portal
operation lock with generation fencing, so a stale sweep cannot kill a
replacement. Registry, catalog snapshot, and capacity IO stay async.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
🕙 Outdated review — superseded by a newer review below

🤖 OS review · request changes · quality 2/5 · risk medium

Safe once the P1 below is fixed. The stop lock is addressed, but stale status writes remain unsafe and alias cleanup still misses canonical owners.

🟠 Risk medium · recovery in hours
A revert cannot restart portals already stopped or recover their in-memory state; affected services require reconciliation. Canary the reaper and retain a bulk portal restart procedure.

  • Stored data semantics: portal-supervisor.ts reinterprets existing .ports.conf records to sleep or stop services.
  • Wide blast radius: portal-supervisor.ts changes shared start, stop, restart, routing, registry, and reaper paths.
  • Large diff: Nine files add 1,037 lines and remove 248, centered on portal supervision.

2 inline comments below.

Reviewed 80e7894 · GPT-5.6 Sol · open session · labels: os-auto-fix fix and push · os-adversarial deeper pass · os-simplify cleanup

🔁 2 findings → owning session · fix round 1/6

@open-session-os-tella-dev open-session-os-tella-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OS review · 80e7894

),
);
await updateHostPortalRegistry(worktreeDir, (latest) => {
for (const record of changed) latest = upsert(latest, record);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P1 — Status reconciliation can overwrite a replacement generation

listPortalServices mutates records without withHostPortalOperation, and this unconditional upsert applies its stale snapshot to the freshly read registry. If a preview-status poll reads generation A while restartPortalService replaces it with B, a delayed liveness probe can subsequently write A as failed over B. The replacement process remains running, but its route disappears and archive/orphan cleanup skips the now-failed record, leaking the process. Put liveness reconciliation under the per-Portal operation lock, or apply each status update only when the latest record still matches the generation that was probed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prepared patch makes registry writes conditional on the generation read before the probe, with a regression test for a replacement generation. It is not committed or pushed: after incorporating current origin/main, bun run check fails in the unrelated app-source-ownership test (AppContent.tsx: 2,017 lines, maximum 1,999). The Portal tests pass. Repository policy prohibits committing while that gate fails; the patch is preserved locally for resumption.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f26f012: registry reconciliation now applies a changed record only when the latest registry still matches the generation originally probed. A stale failed result cannot replace the restarted Portal PID or ownership. Verified and landed the prepared fix with a regression test covering replacement preservation and valid same-generation writes. All 29 Portal tests and the full bun run check pass.

if (!session || session.runner || session.sandbox?.sandboxId) return;
// The lookup resolves historical aliases to the canonical session, and the
// Portal record is owned by that canonical id, not the alias archived.
const ownerId = session.id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P2 — Alias archive cleanup still receives the alias row

The new test injects a resolver that returns the canonical row, but the production findSessionAsync first calls readSlackSession(sessionId) and returns that exact Slack row before consulting the merged list. For the existing slack-C998-1719860000.000000 file merged into bks-live-canonical-archived-alias, session.id therefore remains the Slack alias, so the following ownership comparison still skips Portals recorded under the canonical ID. Resolve the owner through the merged session-list projection by matching id or aliasIds, rather than using the direct detail resolver.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prepared patch resolves archive ownership from the merged session-list projection by id or aliasIds, and stops records owned by either spelling without stopping sibling sessions. It is not committed or pushed: after incorporating current origin/main, bun run check fails in the unrelated app-source-ownership test (AppContent.tsx: 2,017 lines, maximum 1,999). The Portal tests pass. Repository policy prohibits committing while that gate fails; the patch is preserved locally for resumption.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f26f012: archive cleanup now resolves the owner from the merged session-list projection by id or aliasIds before falling back to detail lookup. It stops records owned by the canonical ID or any merged alias, while leaving sibling owners alone. Verified and landed the prepared fix and ownership regression test; all 29 Portal tests and the full bun run check pass. Latest main was merged without rewriting history; its AppContent extraction also clears the inherited CI blocker.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
🕙 Outdated review — superseded by a newer review below

🤖 OS review · request changes · quality 2/5 · risk medium

Not ready to merge. The latest commits merge main without changing the two outstanding findings; the lifecycle operation lock addresses the original stop/restart race.

🟠 Risk medium · recovery in hours
Reverting leaves services stopped during the unnoticed window; recovery requires identifying affected Portals and restarting them. Gate lifecycle enforcement behind a flag; retain affected-service restart records.

  • Stored data semantics: portal-supervisor.ts reinterprets existing archived ownership and activity records, persisting stopped or sleeping states.
  • Wide blast radius: portal-supervisor.ts changes shared registry operations used by starts, stops, restarts, routing, and reapers.
  • Large diff: portal-supervisor.ts extensively rewrites registry concurrency, admission, activity tracking, and lifecycle reconciliation.

2 inline comments below.

Reviewed 460b43d · GPT-6 Astra · open session · labels: os-auto-fix fix and push · os-adversarial deeper pass · os-simplify cleanup

🔁 2 findings → owning session · fix round 2/6

@open-session-os-tella-dev open-session-os-tella-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OS review · 460b43d

for (const record of changed) latest = upsert(latest, record);
return latest;
});
readSnapshot = records;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P1 — Status reconciliation can overwrite a replacement generation

This remains unresolved. listPortalServices runs listPortals without the lifecycle lock, and this unconditional upsert persists its stale results. A status poll can read generation A, observe its port closing during restart, then finish its asynchronous liveness probe after generation B is written. It overwrites B with A marked failed, losing the replacement's PID and ownership record. The reaper skips failed records, leaving B unmanaged. Serialize reconciliation with lifecycle operations, or condition each reconciliation write on the latest record still matching its original snapshot.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f26f012: registry reconciliation now applies a changed record only when the latest registry still matches the generation originally probed. A stale failed result cannot replace the restarted Portal PID or ownership. Verified and landed the prepared fix with a regression test covering replacement preservation and valid same-generation writes. All 29 Portal tests and the full bun run check pass.

if (!session || session.runner || session.sandbox?.sandboxId) return;
// The lookup resolves historical aliases to the canonical session, and the
// Portal record is owned by that canonical id, not the alias archived.
const ownerId = session.id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P2 — Alias archive cleanup still receives the alias row

This remains unresolved. findSessionAsync returns the direct Slack row before consulting merged sessions (session-cache.ts:736–740), and slackSessionRow constructs its ID from the Slack filename (sessions.ts:772). For the merged alias slack-C998-1719860000.000000 covered by sessions.test.ts, cleanup therefore uses the alias instead of bks-live-canonical-archived-alias, so it does not stop the canonical owner's Portal on archive. Resolve the owner through the merged session snapshot before selecting directories and matching Portal ownership. The current injected-resolver test bypasses this production behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f26f012: archive cleanup now resolves the owner from the merged session-list projection by id or aliasIds before falling back to detail lookup. It stops records owned by the canonical ID or any merged alias, while leaving sibling owners alone. Verified and landed the prepared fix and ownership regression test; all 29 Portal tests and the full bun run check pass. Latest main was merged without rewriting history; its AppContent extraction also clears the inherited CI blocker.

Keep status reconciliation from overwriting replacement generations and resolve archived aliases through the merged session projection. Preserve upstream CI fixture setup and inherit the AppContent size repair.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
🕙 Outdated review — superseded by a newer review below

🤖 OS review · request changes · quality 3/5 · risk medium

Safe once the P1 below is fixed. The latest commits resolve the prior generation races and targeted alias cleanup, but periodic reconciliation still mishandles aliases.

🟠 Risk medium · recovery in hours
Until detection, affected Portals accumulate; reverting leaves terminated services and persisted states requiring reconciliation and restarts. Gate lifecycle enforcement behind a flag; retain pre-sweep registries for recovery.

  • Stored data semantics: portal-supervisor.ts reinterprets archived ownership and persisted activity, terminating processes and persisting stopped or sleeping states.
  • Large diff: portal-supervisor.ts broadly rewrites registry IO, lifecycle coordination, admission, and reaping across multiple callers.

1 inline comment below.

Reviewed f26f012 · GPT-5.6 Sol · open session · labels: os-auto-fix fix and push · os-adversarial deeper pass · os-simplify cleanup

🔁 1 finding → owning session · fix round 3/6

@open-session-os-tella-dev open-session-os-tella-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OS review · f26f012

if (portal.state === "stopped" || portal.state === "failed") continue;
const generation = portalGeneration(portal);
const orphaned = portal.sessionId
? !liveOwners.has(portal.sessionId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P1 — The reaper treats live alias-owned Portals as orphaned

stopArchivedSessionPortals now correctly recognizes that a Portal may use either the canonical session ID or an alias, but the periodic reaper records only session.id in liveOwners. For a live canonical session canonical with alias slack-C998-1719860000.000000, a Portal whose sessionId is that alias makes this expression true, so the five-minute reaper stops an actively owned Portal as orphaned. Add aliasIds to PortalOwnerSession and register the canonical ID plus every alias for each owned worktree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8495804: PortalOwnerSession carries aliasIds, and the reaper registers each canonical ID plus its aliases in every primary and attached worktree. Archive, idle and containment ownership use the same ID helper. Regression tests preserve live alias-owned Portals, stop them when the canonical owner archives, and keep idle expiry without sleeping a running owner. All 32 Portal tests, the module-side-effects check and full bun run check pass.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🤖 OS review · approve · quality 5/5 · risk medium

Safe to merge. The current diff resolves the previously reported generation races and alias ownership issues; no remaining blocking findings.

🟠 Risk medium · recovery in hours
Incorrectly stopped previews accumulate until noticed; reverting leaves persisted stopped/failed records requiring reconciliation and service restarts. Gate cleanup behind a flag and retain restartable pre-cleanup registry snapshots.

  • Stored data semantics: portal-supervisor.ts reinterprets existing ownership and activity records, persisting stopped or sleeping states.
  • Large diff: portal-supervisor.ts extensively rewrites registry IO, lifecycle reconciliation, admission, and operation serialization.

Reviewed 8495804 · GPT-6 Astra · open session · labels: os-auto-fix fix and push · os-adversarial deeper pass · os-simplify cleanup

@jfrolich
jfrolich merged commit 94493e4 into main Sep 11, 2026
7 checks passed
samuelbeek pushed a commit to schematik-engineering/opensession that referenced this pull request Sep 14, 2026
* Merge risk: name the evidence behind each factor

The review comment listed bare factor categories ("auth or billing,
delivered output, CI or deploy") next to one sentence of reasoning, so a
reader could not tell which files earned which label or why. On
tella-fusion#6468 "auth or billing" came from a parameter-object refactor
of refreshSessionWithToken and read as unrelated noise.

Each factor now carries its own evidence (which files, what changed) and
the comment renders one bullet per factor with that evidence, ordered by
the factor that sets the recovery time. Reasoning is refocused on what a
revert leaves behind. auth_or_billing splits into auth and billing so the
label matches what was actually touched; the auth path hint also covers
login and sso files.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Move the web Desk voice call to GPT-Live (tellahq#350)

* Keep a video's letterbox black from darkening its border

.md-video paints a black background for letterboxing, and the default
background-clip lets it show through the 25% grey border, which then reads
as near-black (rgb 32,32,32) against a light page. Clip the background to
the padding box so a video's edge matches an image's.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Desk: float the panel with drag and resize instead of a modal

On desktop the Desk is now a floating, non-modal panel: no backdrop or
blur, no focus trap, and the page underneath stays live, so it can sit
open in a corner while you work in other sessions. Its header drags it,
its edges and corners resize it, and the place it was left is kept per
browser, anchored to the nearest corner so it stays put when the window
changes size. It opens over the Desk trigger in the bottom-right corner.

Escape closes it only from its own focus, so an Escape meant for the
session you are working in does not take the Desk with it. Cmd+J now
summons it, jumps back into it when it is open elsewhere, and dismisses
it from inside. The phone sheet is unchanged.

Adds a `floating` variant to ui/modal: full bleed like the palette, no
backdrop, positioned by the caller, for use with `modal={false}`.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Desk voice: link spoken PR and session references in the transcript (tellahq#369)

A GPT-Live Desk call mirrors what was said as plain text, so a PR comes
out as "six four seven four" and a session the Desk just started is only
named by title; neither renders as a chip. A per-call ledger now records
every PR (repo + number) and session (id + title) the call's tool calls
surfaced, and each assistant row is rewritten before it is mirrored: a
spoken number that matches exactly one PR in the ledger becomes
`repo#N`, and the first row after start_session/steer_session (or a row
that says a listed session's title) gets a `Session: <id>` trailer. A
number that matches nothing, or PRs in two repos, is left as said; user
rows are never touched.

list_current_work and inspect_session now include prNumber, and the
backend prompt asks for PR numbers as digits with their repo.

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Desk voice: choose the Live backend and report call usage (tellahq#370)

* Add Desk voice backend choice and call diagnostics

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Add desktop and phone proof for Desk voice settings

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Desk voice: stream the call's transcript as live captions (tellahq#371)

* Desk voice: stream the call's transcript as live captions

The web Desk received session.output_transcript.delta and
input_transcript.delta on the RTC data channel but only used them to
update the call status; the mirrored row appeared only once the server
settled the utterance (2s timeline gap, 2.5s idle, or the other speaker).

The client now hands every fragment to a call-scoped caption store that
renders one streaming row per speaker until the durable row lands.
Mirrored row ids carry the row's timeline span (-end-<endMs>) so the
captions come down by position rather than text, which keeps them
correct when the server links what the Desk said to PR and session
references before mirroring. Legacy ids without the end still match by
content.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Desk voice: normalize sideband caption timestamps

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Native PR panel: hold a merge for five seconds with Undo and a countdown (tellahq#365)

* Native PR panel: hold a merge for five seconds with Undo and a countdown

The web client holds a confirmed merge for five seconds before the request
goes out, keeps the button in place reading "Merging…", and puts an undo glyph
with the seconds left in front of it. The native panel sent the request the
moment the confirmation was tapped.

DeferredMerge is the held merge: one per panel, a clock-driven countdown in
whole seconds, refused while one is already held or in flight, cancellable only
inside the window. The PR panel keeps its merge-method confirmation, then holds
the request; the Merge row stays where it is as "Merging…", an Undo control with
the digit lands in the toolbar in front of the actions menu, and the panel's
busy state and spinner wait for the request itself so nothing reads as merged
before anything was sent. Closing the panel inside the window takes the merge
back; a request already on the wire runs to its end.

Tests drive the window with the manual clock: the countdown, undo, one-shot
execution, a replaced schedule that must not fire, no undo once in flight, and
a failed send freeing the slot.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* PR panel: keep Review closed while a merge is held or on the wire

Review can squash and merge after approving, and that path sent its own
/pr-merge while a held merge was still armed, so one tap sequence could put two
merge requests on the wire and report a failure for a PR the first had merged.
Review now waits until the panel has no merge held or in flight.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* PR panel: disable Close while a merge is held

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Native: move a host session into a Sandbox (tellahq#364)

* Native: move a host session into a Sandbox

The web gained "Move to Daytona/Box" for a code session that runs on this
machine (POST /api/sessions/:id/sandbox/attach, commits ef430bc and
0d07410); the native app had no way in.

OS1API.attachSandbox posts { provider, confirm? } and answers a
SandboxAttachOutcome: the 428 is an answer, not an error, so the server's
own sentence about the uncommitted files or unpushed commits reaches the
person before a repeat with confirm. SandboxMove mirrors the server's
refusals (a materialized Sandbox, a Runner, an automation by flag, name or
id, a non-code or repo-less session) so no surface offers a move the
server would 409, and takes the composer's Ready providers minus the host.

Three surfaces share one SandboxMoveViewModel and the same rows: a Move to
Sandbox submenu in the iOS session overflow menu, a cube toolbar menu on
the Mac, and a Runtime section in the worktree details sheet. The rows are
disabled while the agent runs, with the server's reason. A 428 becomes a
"Move to Daytona anyway?" confirmation carrying the server's sentence and
a Move anyway; a refusal is an alert. On success the session row is
refreshed from the server and the sheet overlays the recorded provider
until the poll agrees, so the Sandbox section says Preparing at once.

DEBUG hooks OS1_SANDBOX_MOVE, OS1_SANDBOX_MOVE_CONFIRM and OS1_SCROLL_TO
let the capture tool drive the real flow on a simulator that takes no taps.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Native: no Move to Sandbox once a provider is recorded

The eligibility rule let a session whose Sandbox was still preparing move
again. The server takes that second attach and provisions twice; when the
first finishes, the provider mismatch means that Sandbox is neither
recorded nor destroyed. The web ends the offer as soon as any non-local
provider is recorded; the native rule now does the same. A failed Sandbox
is retried from the Sandbox section's Recreate, not by moving again.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Native: record the Sandbox the moment the move lands; allow a failed one to retry

Two holes in the Move to Sandbox eligibility rule.

The success path only refreshed the row from the server in a detached task,
so until that GET landed (or for ever, if it failed) the surfaces still
held the host snapshot and offered a second move the server would take.
SandboxMoveViewModel.adopt now writes the attach response onto the open
session synchronously, through updateSessionSnapshot, before the refresh
starts, and the model remembers the moved session so the rows stay
disabled even if a sessions poll hands back the host snapshot late. All
three surfaces go through it.

The previous round's rule refused every recorded provider, which pinned a
session whose provision failed before making anything (needs_attention,
no id): Recreate needs an id, and the server permits the attach retry in
exactly that state. The rule now refuses a materialized Sandbox and a
preparing one, and lets that explicit failure move again.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Native: allow Sandbox move retry after provisioning failure

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Test Sandbox failures with immutable decoded snapshots

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Answer the native ask card's letters from the keyboard (tellahq#366)

* Answer the native ask card's letters from the keyboard

The question card labels its options A, B, C, but the native app only
heard those letters through a tap. A question lands while you are reading
the transcript or sitting in the composer, so in practice the letters
were dead and you reached for the pointer.

AskQuestionCard now wears the letter on each row and listens for it: a
bare letter typed anywhere in the card's key window that is not a text
field picks that option, and on the lone single-select ask it answers,
exactly as a tap would. Text fields keep their letters, a chord or a held
key stays the system's, and only the card in the key window while the app
is frontmost answers, so two windows never answer one keystroke.

SwiftUI's keyboardShortcut cannot carry a bare letter safely on macOS,
where a key equivalent is matched before the field editor sees the key,
so the composer would answer instead of typing. AskKeyBridge scopes an
NSEvent monitor to the card's own window on the Mac (the same way the
composer reads Shift-Return out from under a focused field) and rides the
option rows as key equivalents on iOS, where text input already wins over
an unmodified key command. AskLetterShortcuts holds the pure
keystroke-to-option mapping and the "which card hears this" rule, both
unit-tested without a window.

The composer is the one place the letters cannot reach, so a rebindable
"Answer the Question" command (mod+i, the web's ask-focus) takes the
keyboard back from the composer and rings the card; the command palette
carries it while a question is waiting.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Focus the answer field for native free-text questions

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Re-read sidebar maps when another client writes them (native) (tellahq#363)

* Re-read sidebar maps when another client writes them

The native app re-read lanes, snoozes and hides only at launch, on
foreground, and on a 30-second tick. A Mac window that stays in front
never foregrounds, so a workspace claimed, snoozed or hidden from the
phone or the browser reached it late.

The server already sends a user_map_changed frame to that person's
sockets (5c11d43). Decode it, route it from the active account's
presence socket to the matching store, and refetch the sessions list
once the store has re-read: these maps decide which rows the scoped
list carries. The frame is scoped to the account on screen and to its
user, and each store's hydrate keeps pending local writes over the
response. WorkspaceSnoozeStore gains the same applyHydrated seam the
other two stores have, so the merge is covered by tests.

Verified on the Mac target against the live server: a snooze written
by a second client moved the row out of the Active band within
seconds, and the unsnooze brought it back.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Order sidebar map re-reads so an older GET cannot win

The lane, hide and snooze stores release the main actor while their GET
is in flight, and a user_map_changed frame lands whenever it likes
during the 30-second tick. When both re-read at once, the tick's
response could have been served before the other client's write and
still publish last, dropping that write until the next tick. A write
this client confirmed meanwhile had the same effect: the GET may carry
the pre-write map.

Each store now keeps a HydrationClock: a GET takes a ticket when it
begins and its response is applied only while no newer GET has begun
and no write has been confirmed since. The hide and snooze stores gain
the applySaved seam the lane store already had, which is where the
confirmation is counted. Same rule as the web user-map's
hydrationVersion and confirmedVersions.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Preserve newer sidebar resyncs when a PUT response arrives late

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Forgive typos in the native sidebar search, command palette, and @ people (tellahq#361)

* Forgive typos in the native sidebar search, command palette, and @ people

Port the shared fuzzy matcher (shared/fuzzy-match.ts, commit 5b51f4d) to
Swift as Models/FuzzyMatch.swift, rule for rule: normalization, per-term
edit budgets, adjacent transpositions as one edit, in-word subsequences, and
multi-term queries, with the same parity fixtures ("relase" finds Release,
"wrokspace" finds workspace).

The sidebar filter now scores title, repository, branch, and workspace name
with it. SidebarSearch computes the matched ids in a detached task so a list
of thousands of rows is never scored on the main actor; the row predicate is
a set lookup. The Mac command palette ranks by the same scorer, title
matches a band above subtitle/keyword matches, and the @ palette ranks
people by score with the signed-in person and roster order breaking ties.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Stop a superseded sidebar search scan instead of letting it finish

Task.detached does not inherit the cancellation that .task(id:) issues on
the next keystroke, so every superseded scan of the session list ran to the
end and competed with the live one. The view now keeps the detached handle
and cancels it from a cancellation handler, and SidebarSearch.matches reads
the cancellation flag once per row and per archived session, returning nil
so the caller publishes nothing for a scan that gave up.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Native app: show merge risk beside the review's quality score (tellahq#362)

* Native app: show merge risk beside the review's quality score

The server's review now scores two things apart: quality (the 1-5
confidence) and merge risk, how hard a mistake is to undo. The native app
decoded only the score, so a correct migration and a sloppy CSS tweak read
the same on a phone.

OsReviewSummary gains optional risk, recovery and riskFactors. Decoding
stays tolerant: a level or recovery word this build does not know costs
that field and never the session row it rides in. Risk shows on every
surface that already carried the score, always as its own phrase in its
own ink so the two axes cannot be read as one: the long-press preview
strip, the workspace review row, the report sheet (with recovery time and
factors above the write-up), the review loop's verdict facts, and the PR
status card as separate Quality and Merge risk rows. Colours are the app's
status inks (high red, medium yellow, low dim), not the web's, and a stale
reading goes faint with the rest of the review. VoiceOver names each axis
("quality 5 of 5, merge risk high") instead of reading "5/5 · high risk".

The screenshot fixture gains high-risk and stale-high-risk cases and now
takes the Mac's detail column, since an overlay on a NavigationSplitView
never reaches the AppKit split view.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Native app: name both review axes in the preview strip's VoiceOver text

The strip shows "5/5 · approved" and "high risk" as two phrases in two
inks, which tells the eye which is quality and which is risk. Read aloud
they were one number and one word with no axis named, so this surface
never met the "quality 5 of 5, merge risk high" wording the workspace row
and loop verdict already use.

A fact now carries an optional spoken form; the review and risk facts
fill it in and the strip hands it to VoiceOver. Facts whose text already
says everything keep reading their text.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Let Desk navigate from text chat and voice (tellahq#373)

Use the same fixed-route action in the voice sideband and a dispatch-scoped text MCP. Bind text prompts to their verified browser at intake, preserve the dispatch identity through run RPC, revoke stale or cross-browser authority, and await browser acknowledgment.

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Compare block: 2-up, Swipe and Onion skin views

The before/after block only had the slider. GitHub's image diff offers
three views and this adds the other two under a switch below the stills:
2-up shows both whole, side by side under their names, each opening the
gallery on click; Swipe is the existing divider; Onion skin lays the after
still over the before at an opacity set by a range beside the switch.

The view is a per-user preference (user-pref.ts), 2-up until picked
otherwise, and one switch flips every comparison on the page. The pref is
created on the first build rather than at import so the module stays inert
in the DOM-less unit tests and the copy control that imports the registry.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Link sessions MCP tool rows to the session they name

A get_session, send_to_session, task_status or similar call showed the
target only as an id in its summary. The row now carries an Open chip
that opens that session in place through the transcript's delegated
session-link handler, and is reachable by keyboard. It is not offered
when the call is about the session being read.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Add a personal Agentation toggle under Debug settings, default off

Lands the two commits from os/agentation-debug-setting (58922fe,
ba62518) on main as one change, so the live release no longer has to
run from a branch that diverged from main.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Remove blanket Git publishing restriction from run instructions (tellahq#376)

Keep PR attribution and co-author requirements while removing the blanket instruction against merging, approving, or pushing the default branch. Leave command guards and MCP behavior unchanged.

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Fix MCP relay token sharing across runner processes (tellahq#375)

Publish immutable per-identity grant records atomically and read relay tokens asynchronously without process-local snapshots. Preserve legacy tokens for detached hosts and cover concurrent minting, token validation, and authenticated forwarding.

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Keep MCP relay token issuance compatible with gateway rollback (tellahq#377)

Stage the rollout: retain legacy token issuance while shipping fresh asynchronous legacy reads and v2 reader support. Defer v2 issuance until the rollback target supports it. Add rollback coverage and keep cross-process discovery and forwarding tests.

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Add check_pr_ready: one deterministic PR merge-readiness verdict (tellahq#378)

The Desk had no reliable way to answer "is this PR ready to merge?" for a
session's PR; it pieced the answer together from transcript reads that do
not surface CI, review state, or mergeability. opensession-repos now has
check_pr_ready: pass a PR URL, a repo id and number, or a session id (its
Review-tab PR: primary branch, attached repos, linked PRs), and get a
spoken one-sentence verdict, the blockers in fix order, every check's
latest run by name, the review decision and who gave it, the base branch's
rulesets, and the same verdict as JSON.

The fetch runs gh as the bot for the PR's repository through the existing
resolveGithubCredential path. GitHub computes mergeable lazily after the
base moves, so an open PR answering UNKNOWN is re-queried a few times
before an honest UNKNOWN reaches the verdict.

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Open session pills clicked inside the Desk (tellahq#380)

* Open session pills clicked inside the Desk

Session chips in markdown and the tool row's spawned-session pill carry
only a data-session-id and rely on the transcript's scroll container to
delegate the click. SessionViewer's pane does that (handleMessagesClick);
the Desk's pane never did, so every session reference inside the Desk
overlay was inert. Extract the click reading into one helper shared by
both panes and route the Desk's click to onOpenSubagent, which already
closes the overlay and opens the session in the full viewer.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Format pr-merge-readiness.ts

oxfmt output for the file tellahq#378 landed unformatted, so format:check
and every check gated behind it run again on this branch.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Desk: tab-aware show_in_app, live-call indicator, fuzzy session lookup (tellahq#382)

* Desk show_in_app: fuzzy title matching that prefers a primary over its derivatives

The resolver behind show_in_app matched titles exactly or as a substring, so
"Profile subtitle sidebar opening" was a full miss against "Profile Subtitles
sidebar opening", and a session and its own PR review or auto-fix worker came
back as two equal candidates.

Score titles with the shared fuzzy matcher (every spoken term lands whole or
within a small edit budget), drop filler a voice request carries when other
terms remain, take a clear leader among fuzzy matches and ask back on a near
tie. Drop a derivative (parentSessionId/spawnedBy, or an automation-owned or
agent-started session sharing the primary's workspace) whenever the primary
scores at least as well; phrasing that names the derivative outscores the
primary and reaches it directly.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Desk show_in_app: land on a named tab

show_in_app carried only a session or workspace id, and the browser handoff
mapped that onto the page route. A session's Review tab is not a route: it is
the workspace view tab the sidebar foregrounds. So the Desk had no way to
show "the review tab for this" and said it could not switch tabs.

Add an optional tab (chat, review, conversation, video) to the voice and text
tools, validated in the shared wire schema next to the id; panes that spawn
something stay out of reach. A workspace tab rides the existing route suffix.
For a session, review reuses the sidebar's pending-open pulse, chat clears the
workspace's remembered pane, conversation and video open the session's
workspace on that pane. The voice and Desk instructions name the parameter.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Desk: show a live voice call while Desk is minimised

The call state lived inside the Desk body, so after minimising the overlay
nothing on screen said a call was still running.

DeskOverlay reports the call through onCallActiveChange. While a call is live
and Desk is closed, the Desk trigger carries the app's pulse dot and reads
"Desk call in progress"; pushed phone pages have no trigger, so the mobile top
bar shows a handset control instead. Both reopen Desk. The indicator clears
when Desk is back up or the call ends.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Fix check_pr_ready follow-ups from the tellahq#378 review (tellahq#381)

* Format pr-merge-readiness.ts with oxfmt

CI's formatting step failed on tellahq#378: the file had been run through
prettier, not the repo's oxfmt, and the two disagree on one wrap.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Count skipped checks in the ready sentence

Review on tellahq#378: a ready PR with one passing and one skipped check was
summarized as "all 1 check passing", and one with only skipped checks as
"all 0 checks passing", contradicting the rollup underneath. The sentence
now says "1 check passing and 1 skipped" or "1 check skipped, none run".

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Word neutral checks apart from skipped ones

Review on tellahq#381: NEUTRAL and SKIPPED share the no-result bucket, so a
ready PR whose only check completed neutral was summarized as "1 check
skipped, none run". A neutral check did run. Each PrReadinessCheck now
carries its conclusion, and the sentence, the note, and the rollup line
count skipped and neutral separately: "1 check neutral, none failing",
"1 check passing, 2 skipped, and 1 neutral".

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>
Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Add Databases: named SQLite databases sessions and automations keep

A database is a sibling of a report: a durable thing a run produces that
outlives it, kept by Open Session outside every repo and browsed in its
own view. Where a report is an append-only document, a database is a
named SQLite file a session keeps coming back to.

Store: databases-sqlite.ts owns the files (<id>.sqlite + <id>.json
sidecar under stateDir("databases")) and runs on a dedicated Bun Worker
behind the async facade in databases.ts, so an agent's slow query never
touches the gateway thread. database-sql-guard.ts screens every
statement (no ATTACH, DETACH, VACUUM INTO, load_extension, or non-schema
PRAGMA), reads run on a read-only connection, and results are capped.

MCP: opensession-databases (create, list, describe, query, execute,
insert_rows, update, delete) for interactive runs, and for every
automation run scoped to that automation's own databases, the way
opensession-report only publishes into its own group. Also on the
workflow-script allowlist.

Routes: /api/databases for list, schema, paged rows, read-only SQL,
rename, delete, .sqlite download and CSV per table.

Web: a Databases sidebar tool and view (rows per table, a Query tab,
desktop and phone), a Databases card on the session overview, and a
data-grid primitive shared by the two grids.

Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>

* Restore direct GitHub actions and fix ingress classification (tellahq#345)

* Restore direct GitHub actions and fix ingress classification

Remove the dedicated PR tools and blanket restrictions for connected-person code turns while retaining automated and read-only guards. Wait for bounded HTTP headers before routing stable frontend requests. Stabilize the actor-service verification fixtures without changing production timeouts.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

* Lift the merge guard for projected person tokens in sandboxes

A sandboxed owner turn resolved no login because the guest has no person
store, so the merge guard stayed on even though the launcher had projected
the person's own token. The launcher now writes the resolved login as a
non-secret marker beside the token, and the guard reads that marker in the
guest. App-token and simple-mode projections carry no login and stay
guarded.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

* Resolve the run's owner login from the selected credential

In simple mode the run holds the sole connected account's token, but the
owner login came from githubUserLoginForRun, which is null there, so the
merge guard stayed on for personal code turns. Read the login from the
credential githubCredentialForRun actually selects, on the host and in the
launcher's sandbox projection, so the guard follows the token the run holds.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

* Allowlist the Slack thread identifier in the catalog test

The exact fixture value is a channel/thread identifier, not a credential. Allowlisting it also covers the historical commit scanned by CI.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Render rich transcript blocks in the native Swift client (tellahq#367)

* Render the web's transcript blocks natively in the Swift client

The native app kept every block the web renders as a raw fence: a
```choices list read as code, a ```tree as box drawing, a placed
OPENSESSION_IMAGE as a bare markdown image the library declines, a
```compare as two URLs. Now `TranscriptRichBlocks` lifts each kind out
of a durable message (the fence grammar is `MarkdownFenceSegmenter`,
which `MermaidSegmenter` is a thin claim on) and `MarkdownBody` draws
it with a view of its own under `Views/Blocks/`:

- choices: chips that send the reply on the composer's own path
  (`SessionViewModel.sendQuickReply`, queued while a run is busy) or,
  from the context menu, fill the composer; they go quiet once a later
  user message exists (`QuickReplyRelay`, observable, in the environment)
  and wherever there is no session.
- tree: a collapsible tree, top two levels open; a file row opens the
  Changes panel when the session touched that file (`FileLinks.paths`).
- placed media: a paragraph that is one `/media?path=` image renders
  where it was written with its caption, and the thumbnail strip shows
  only what the body did not place (`PlacedMedia`).
- compare: a before/after control with a draggable divider, adjustable
  for VoiceOver, expanding into the image viewer; both stills load with
  the session's credentials.
- chart: Swift Charts for a unit-view subset of Vega-Lite (bar, line,
  area, point, tick; nominal, quantitative and temporal x; colour and
  xOffset series; count/sum/mean aggregates) with inline data only.
- artifact and svg: a WKWebView with content JavaScript off, a
  `default-src 'none'` policy ahead of the source and every navigation
  after the first refused; Source toggle, drag grip, expand sheet.
- slides: a 16:9 deck rendered through `MarkdownBody` (rich blocks off
  inside), arrows, dots, counter, arrow keys, swipe, expand.
- palette, csv/tsv/table (sort, filter past eight rows, Copy CSV, laid
  out by `MarkdownTableView`), json tree (Tree/Raw toggle), ansi
  (through `TerminalScrollback` and the Terminal panel's palette), diff
  (washed rows), math (a TeX vocabulary set as text, no KaTeX),
  metrics cards and GitHub callouts.

Every parser is a pure model with a test beside it, and every one falls
back to the plain fence when it refuses the source, so a block still
streaming stays readable. Streaming rows are untouched; blocks are
parsed once per distinct text behind a bounded cache. Each view uses
OS1VisualStyle tokens, honours Reduce Motion and carries labels.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Add native rich-block screenshots for the Swift client port

macOS and iPhone captures of the chart, metrics, CSV table, palette,
tree, callout and choices blocks rendering natively.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

* Fix native rich-block review findings: bound JSON depth, exact chart grouping, aligned sort headers

- JsonTreeBlock: thread a depth counter through parseValue/parseArray/
  parseObject and refuse past 64 levels, so a deeply nested fence falls
  back to a code block instead of exhausting the stack.
- VegaLiteChart: make Value hashable and aggregate by the exact x value
  plus series, not the display label, so times within one day and
  closely spaced numbers stay separate groups.
- DataGridView: render sortable headers through MarkdownTableView's own
  column plan and scroll container, reserving room for the indicator,
  so header buttons line up with their body columns and scroll with them.
- Tests for each, README note.

Co-authored-by: Kent de Bruin <hi@kentdebruin.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

* Stop host Portals on archive and expire idle previews (tellahq#336)

* Stop host Portals on archive and expire idle previews

Host Portals outlived their sessions: archiving left dev servers running,
and an unused preview held memory indefinitely. This makes their lifetime
match how they are used.

- Archiving a session stops the host Portals it owns, including those in
  attached repositories, without touching a sibling's Portal in a shared
  checkout. The five-minute reaper also treats archived owners like missing
  ones, so an interrupted cleanup is retried.
- On Linux a host Portal expires after 30 minutes without authenticated
  Portal traffic or an established connection to its port (from /proc/net
  tcp tables, so WebSockets count). Timestamps are process-local: a gateway
  restart grants a fresh window instead of killing a browser's Portal on an
  old timestamp. Without connection telemetry, idle cleanup is skipped.
- New host Portal processes are refused when available RAM is below 5% or
  2 GiB, memory full-stall pressure is at 10% over ten seconds, or the
  shared workload slice is at 90% of its memory soft limit. Matching awake
  Portals are still reused.
- Registry writes go through an atomic, per-worktree serialized update
  that re-reads the file and applies only the caller's changed records, so
  concurrent starts in one checkout no longer clobber each other. Stops
  carry an expected generation so a stale sweep cannot kill a replacement.
- The reaper takes an async catalog snapshot and all supervisor file IO is
  asynchronous, keeping it off the gateway thread.

Tests cover archived, sibling, legacy ownerless, idle and generation cases,
the admission thresholds, /proc tcp parsing, and concurrent registry
updates. Supervisor tests install a no-op capacity probe so the host's own
memory pressure cannot decide their outcome.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

* Serialize host Portal operations and archive by canonical owner

Review round 1 on the lifecycle change:

- A stop validated a record's generation, then terminated the process and
  wrote the stopped record without excluding a concurrent start or restart
  of the same Portal. Because the scope unit is stable per worktree and
  name, a stale sweep could kill the replacement and overwrite its record.
  Start, stop, and restart now queue on one lock per canonical worktree and
  name, with restart as a single transaction, so the generation check holds
  through termination and persistence. The lock queues rather than
  coalesces: a stop after a start runs after it.
- Archive cleanup compared Portal owners against the archived id, which may
  be a historical alias; the record is owned by the canonical session that
  the lookup resolves to. Compare and stop with the resolved id.

Tests: a stale stop queued behind a restart rejects and leaves the awake
replacement supervised; archiving through an alias stops the canonical
owner's Portal.

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

* Recognize merged aliases throughout host Portal ownership

Co-authored-by: Jaap Frolich <jfrolich@gmail.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: open-session-os-tella-dev[bot] <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com>
Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
Co-authored-by: Kent de Bruin <hi@kentdebruin.com>
Co-authored-by: Kent de Bruin <52224550+kentdebruin@users.noreply.github.com>
Co-authored-by: Jaap Frolich <jfrolich@gmail.com>
Co-authored-by: OpenSession <opensession@localhost>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant