Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/freshell-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ async fn main() -> ExitCode {
// so the unload beacon and the socket path retire against ONE cross-device view.
//
// Tabs registry now persists rolling snapshot generations under
// `<home>/.freshell/tabs-snapshots/<deviceId>/` (last 5 per device) so a
// `<home>/.freshell/tabs-snapshots/<deviceId>/` (last 5 per (device,
// client) -- MAX_SNAPSHOT_GENERATIONS -- capped at 40 files per device
// across all clients -- MAX_SNAPSHOT_FILES_PER_DEVICE) so a
// device's tabs can be rebuilt after client-state loss (continuity trio,
// docs/plans/2026-07-22-continuity-safety-trio.md).
let tabs = match &home {
Expand Down
5 changes: 4 additions & 1 deletion crates/freshell-server/src/tabs_snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,10 @@ async fn confirm_delivery(
return false;
}
match tokio::time::timeout(state.restore_ack_timeout, rx).await {
Ok(_) => true, // ANY resolve (ok OR error) == received
Ok(Ok(_)) => true, // ANY resolved result (ok OR error) == received
// A dropped sender (pending entry cancelled/purged before the client
// answered) is NOT an ack; its removal already dropped the entry.
Ok(Err(_)) => false,
Err(_) => {
state.screenshots.cancel(&request_id);
false
Expand Down
57 changes: 57 additions & 0 deletions crates/freshell-server/src/tabs_snapshots_restore_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,63 @@ async fn write_ahead_marker_is_durable_before_tab_create_is_sent() {
assert!(saw_write_ahead.load(Ordering::SeqCst));
}

#[tokio::test]
async fn dropped_ack_sender_is_not_delivery_confirmation() {
// A dropped oneshot sender (the pending ack entry was cancelled/purged
// before the client answered) resolves the receiver with `Err(RecvError)`.
// That is NOT a delivery ack: the pane must be reported failed
// (`delivery-unconfirmed`) and stay `in-progress` for a safe retry.
let dir = tempfile::tempdir().unwrap();
seed_records(
dir.path(),
"dev-1",
"c1",
1,
vec![rec(
"t1",
"browser",
json!({ "url": "https://example.com", "devToolsOpen": false }),
)],
);
let r = rig(dir.path());
let broker = r.state.screenshots.clone();
let client_id = 9_102;
r.state.screenshots.add_capable_client(
client_id,
std::sync::Arc::new(move |message| {
let value = serde_json::to_value(message).unwrap();
if value["type"] == "ui.command" && value["command"] == "screenshot.capture" {
// Simulate the pending ack being purged (sender dropped)
// instead of answered by the client.
broker.cancel(value["payload"]["requestId"].as_str().unwrap());
}
true
}),
);

let (status, body) = post(
router(r.state),
"/api/tabs-sync/restore",
json!({ "deviceId": "dev-1" }),
true,
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["deliveryConfirmed"], false,
"a dropped ack sender must not read as confirmation: {body}"
);
let failed = body["failed"].as_array().unwrap();
assert_eq!(failed.len(), 1, "{body}");
assert_eq!(failed[0]["reason"], "delivery-unconfirmed", "{body}");
assert_eq!(body["restored"].as_array().unwrap().len(), 0, "{body}");
assert_eq!(
marker_panes(dir.path(), "dev-1")["dev-1:t1#p-t1"]["state"],
"in-progress",
"unconfirmed delivery must keep the write-ahead entry for retry"
);
}

#[tokio::test]
async fn restore_rejects_malformed_body_selectors_with_400_never_broader_union() {
// FAIL-CLOSED BODY SELECTORS (`:459`): every malformed shape is a 400 —
Expand Down
5 changes: 3 additions & 2 deletions docs/plans/2026-07-22-continuity-safety-trio.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

**Goal:** Give freshell's Rust port a continuity safety net: durable tabs-sync snapshot generations with a one-command restore, a real-CLI continuity smoke test (single wall-clock budget: **≤5 minutes**, matching the 300 s Playwright timeout) as a pre-deploy gate, and a read-only deploy tab-diff ritual script.

**Architecture:** All server work is additive in `crates/` (the client `src/`, `shared/`, and legacy `server/` are FROZEN). Deliverable 1 persists the tabs-sync registry's per-device snapshots to `~/.freshell/tabs-snapshots/` (last 5 generations per device) and adds read + restore REST endpoints that rebuild tabs by driving the existing, proven `POST /api/tabs` create pipeline. Deliverable 2 is one Playwright scenario against the real `freshell-server` binary and the REAL `codex`/`amplifier`/`claude` CLIs, registered outside the default test matrix. Deliverable 3 is a read-only bash script (`capture`/`verify`) over the new snapshot GETs plus the existing `GET /api/terminals`, with an e2e proof that it fails loudly on identity loss.
**Architecture:** All server work is additive in `crates/` (the client `src/`, `shared/`, and legacy `server/` are FROZEN). Deliverable 1 persists the tabs-sync registry's per-device snapshots to `~/.freshell/tabs-snapshots/` (last 5 generations per (device, client), capped at 40 files per device) and adds read + restore REST endpoints that rebuild tabs by driving the existing, proven `POST /api/tabs` create pipeline. Deliverable 2 is one Playwright scenario against the real `freshell-server` binary and the REAL `codex`/`amplifier`/`claude` CLIs, registered outside the default test matrix. Deliverable 3 is a read-only bash script (`capture`/`verify`) over the new snapshot GETs plus the existing `GET /api/terminals`, with an e2e proof that it fails loudly on identity loss.

**Tech Stack:** Rust (axum, serde_json, tokio) in `crates/freshell-ws` + `crates/freshell-server` + `crates/freshell-freshagent`; Playwright (`test/e2e-browser/`, `RustServer` harness); bash + curl + jq operator scripts.

Expand Down Expand Up @@ -1173,7 +1173,8 @@ reuse that binding; a `None` home keeps the in-memory-only registry):

```rust
// Tabs registry now persists rolling snapshot generations under
// `<home>/.freshell/tabs-snapshots/<deviceId>/` (last 5 per device) so a
// `<home>/.freshell/tabs-snapshots/<deviceId>/` (last 5 per (device,
// client), 40 files per device) so a
// device's tabs can be rebuilt after client-state loss (continuity trio,
// docs/plans/2026-07-22-continuity-safety-trio.md).
let tabs = match &home {
Expand Down
186 changes: 186 additions & 0 deletions docs/plans/2026-07-23-opencode-terminal-signals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# OpenCode terminal-mode signals: busy/idle, turn boundaries, queued prompts

**Date:** 2026-07-23
**Status:** Research complete (read-only investigation; no product code)
**Question:** For a plain `opencode` CLI running in a terminal pane (NOT the
freshopencode sidecar), what reliable signals exist for (a) busy vs idle,
(b) turn boundaries, (c) queued user prompts?

**Method:** Ran the real `opencode` CLI (v1.18.3 from PATH) in a throwaway
`HOME`/`XDG_*` root and throwaway project (`/tmp/oc-research/`), both
`opencode run` and the interactive TUI (the latter under `script` to capture
raw output bytes). Inspected everything it wrote to disk, its SQLite schema
and row contents, its process sockets, and its lock files. Sampled the user's
real 4.4 GB `opencode.db` with read-only, `LIMIT`-bounded queries only (never
ran the CLI against real state). Compared with how the freshopencode sidecar
gets its status (`crates/freshell-freshagent/src/opencode_ws.rs`,
`crates/freshell-opencode`).

---

## 1. What opencode writes to disk

opencode 1.18.3 persists **everything in one SQLite database**,
`<XDG_DATA_HOME>/opencode/opencode.db` (WAL mode; the user's real DB is
4.4 GB + 234 MB WAL). The old one-JSON-file-per-session `storage/` layout is
gone (`data_migration` table records the migration). Other artifacts:

| Path | What | Signal value |
|---|---|---|
| `<data>/opencode/opencode.db{,-wal,-shm}` | Sessions, messages, parts, event log, prompt queue | **Primary substrate** (below) |
| `<data>/opencode/log/opencode.log` | Structured text log (`message=loop step=0`, `message=stream`, errors) | Real but format-unstable; per-install not per-session; last resort |
| `<data>/opencode/snapshot/<project>/<sha>/` | Bare git repo for file snapshots | None for status |
| `<state>/opencode/locks/<sha1>.lock/{meta.json,heartbeat}` | `{token, pid, hostname, createdAt}` + empty heartbeat file | **Not usable**: created by `opencode run` one-shots, removed on exit, NOT created by the interactive TUI; contains no port |
| `<config>/opencode/opencode.jsonc` | Config | None |

Relevant tables (schema captured from the throwaway DB, identical in the real
one): `session`, `message`, `part`, `event`, `event_sequence`,
`session_input`, `permission`, `todo`, `project`.

Key columns:

```sql
session ( id, project_id, parent_id, directory, title, agent, model, ...,
time_created, time_updated, time_compacting, time_archived )
message ( id, session_id, time_created, time_updated, data /* JSON */ )
part ( id, message_id, session_id, time_created, time_updated, data )
event ( id, aggregate_id /* = session id */, seq, type, data )
event_sequence ( aggregate_id, seq, owner_id )
session_input ( id, session_id, prompt, delivery,
admitted_seq, promoted_seq, time_created )
```

Observed `message.data` for a completed (errored) assistant turn:

```json
{ "role": "assistant",
"time": { "created": 1784875581948, "completed": 1784875583641 },
"error": { "name": "APIError", ... },
"cost": ..., "tokens": ..., "modelID": ..., "providerID": ..., ... }
```

The `event` table is a persisted per-session event-sourcing log. Recent-400
sample of the real DB shows exactly three persisted types:
`message.part.updated.1` (254), `message.updated.1` (114),
`session.updated.1` (32). **`session.idle` / status events are NOT persisted**
— they exist only on the in-process bus / serve SSE stream.

## 2. Per-question findings

### (a) Busy vs idle

- **No process-level signal.** The standalone TUI opens **no listening
socket** (verified via `/proc/<pid>/fd` + `ss` while the TUI ran) — there is
no API to ask. It holds `opencode.db` open directly and writes through it.
- **No lock/heartbeat signal.** The `locks/` heartbeat exists only for
`opencode run` one-shots and holds no port; the TUI never created one.
- **No bare-BEL signal.** Captured raw TUI output bytes across a full
prompt→answer turn: 7 BEL bytes, **all** OSC-terminators (title updates),
0 bare BELs. The existing `shared/turn-complete-signal.ts` pipeline
(`supportsTurnSignal: mode === 'claude' || 'codex'`) **cannot** be extended
to opencode — it emits no in-band completion byte.
- **DB is the real signal.** During a turn, `event` rows append at streaming
cadence (`message.part.updated.1` dominates), `event_sequence.seq` for the
session increments, and `session.time_updated` bumps. Busy is derivable as:
latest assistant `message` row in the bound session has
`json_extract(data,'$.time.completed') IS NULL`; idle when set.

### (b) Turn boundaries

- **Positive edge exists on disk:** the assistant message's
`data.time.completed` transitions null → epoch-ms when the turn ends.
Verified for both `opencode run` and TUI turns (including error turns —
the `error` key is set alongside, so a *positive* completion can be
distinguished from an errored one by `error` being absent, matching the
"chime only on positive completion" policy).
- **Subagent/tool activity is separable.** Subagent sessions are separate
`session` rows with `parent_id` set; messages/parts/events are keyed by
session id. Filtering to the pane's bound root session means tool calls
(`part` rows) and subagent chatter cannot fire a false top-level edge —
the edge is only the root session's assistant `message.time.completed`.
- **Pane→session binding already exists.** `OpencodeLocator`
(`crates/freshell-sessions/src/opencode_locator.rs`) binds a fresh opencode
PTY to its new `session` row by bounded row-diff (cwd + correlation
window), designed explicitly for the multi-GB DB (indexed,
`time_created >= floor LIMIT n` reads only). Resumed panes
(`opencode -s ses_*`) carry the id directly.

### (c) Queued prompts

- The `session_input` table (`prompt`, `delivery`, `admitted_seq`,
`promoted_seq`) is structurally a prompt queue — a row with
`promoted_seq IS NULL` would be a queued-but-not-yet-running prompt.
- **Honesty caveat: unverified.** It held 0 rows in the throwaway DB (turns
fail in ~1.7 s without provider auth — no queue window to observe) and 0
rows at rest in the user's heavily-used real DB. Rows are evidently
transient (deleted/promoted on consumption); whether they are visible
on disk *while* queued was not observable in this investigation. Treat
queued-prompt detection as **plausible but unproven** until verified
against an authed, long-running turn.

## 3. Comparison with the freshopencode sidecar

The sidecar path (`freshell-opencode`'s `OpencodeServeManager`, one shared
`opencode serve` child) gets **server-pushed** `session.idle` /
`session.status` over the serve HTTP+SSE surface — ephemeral bus events that
never reach the DB. That source is reachable for terminal mode **only** if
the terminal TUI runs against the same server: `opencode attach <url>`
(supports `--dir`, `--session`, basic auth) attaches a TUI to a running
`opencode serve`. A standalone TUI cannot be attached to after the fact (no
socket). So there are two viable architectures:

1. **DB polling (works with vanilla `opencode` panes):** poll the bound
session's latest assistant message for the `time.completed` edge, using
the same bounded-read discipline as `OpencodeLocator`.
2. **Attach mode (server-authoritative, but changes the product):** spawn
opencode terminal panes as `opencode attach http://127.0.0.1:<sidecar>`;
sessions then live on the server freshell already subscribes to, and
terminal panes get the exact same `session.idle` signal freshopencode
uses. Cost: the pane is no longer a vanilla local opencode (auth
plumbing, sidecar lifecycle coupling — a sidecar restart severs every
attached pane — and divergent user expectations).

## 4. Recommendation

**Full truly-idle support via bounded `opencode.db` polling** (option 1),
gated on the pane having a bound session (locator-bound fresh panes or
`-s`-resumed panes):

- Busy: bound session's latest assistant message has `time.completed` null
(or `event_sequence.seq` advanced within the poll interval).
- Turn-complete (green + chime): `time.completed` null→set **with no
`error`** — a discrete, positive, server-side edge consistent with the
`freshAgent.turn.complete` regime. Errored/interrupted turns clear busy
without a chime.
- Unbound panes stay **status-inert** (like Gemini/Kimi). No grace-window
heuristics: the signal either comes from the DB edge or not at all — a
lying bell is worse than no bell.
- Queued-prompt indication: defer; revisit `session_input` with an authed
long-turn verification before building anything on it.

The attach-mode architecture is worth a separate spike only if
DB-polling latency or DB-size cost proves unacceptable in practice.

**Estimated effort:** 2–4 days. A small poller in the existing sessions/ws
layer (arm on opencode-pane bind, disarm on kill/exit, idle short-circuit
like the locator), two bounded SQL reads per tick, edge dedupe reusing the
`terminal.turn.complete` plumbing, plus unit tests against a synthetic
`opencode.db` (the locator's test harness already builds one) and a real-CLI
contract test in `test/integration/real/` (opt-in, like the existing provider
contracts).

## Appendix: evidence log

- Throwaway run: `HOME=/tmp/oc-research/home ... opencode run "say hi"` →
created `opencode.db` (+wal/shm), `log/opencode.log`, snapshot repo,
`locks/<sha1>.lock/{meta.json,heartbeat}`; lock removed on exit.
- TUI run: no lock created; `/proc/<pid>/fd` shows the DB open + sockets,
`ss -tlnp` shows no LISTEN for the pid.
- Bell capture: `script -q -e -c opencode` over a full turn →
`total_BEL=7, OSC-terminating=7, bare=0`.
- DB rows: session/message/part/event/event_sequence contents as quoted in
§1–§2 (throwaway DB); real-DB sample: recent-400 `event.type` counter and
`session_input` count 0, via read-only `LIMIT` queries.
- Sidecar source: `crates/freshell-freshagent/src/opencode_ws.rs` module doc
(serve SSE bridge → `freshAgent.event` / status-guarded turn-complete);
`opencode attach --help` (url, `--dir`, `--session`, basic auth).
Loading