From 3c58ce51f8b6181fd07ffc12b14cabe8365e23fc Mon Sep 17 00:00:00 2001 From: eggplantiny <57978095+eggplantiny@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:17:04 +0900 Subject: [PATCH] fix: preserve collaboration context across tabs for v0.8.4 --- .claude-plugin/marketplace.json | 2 +- .github/workflows/ci.yml | 5 + CHANGELOG.md | 13 ++ Cargo.lock | 10 +- Cargo.toml | 6 +- README.ko.md | 10 +- README.md | 10 +- crates/cli/src/mcp.rs | 11 +- crates/core/examples/screen_probe.rs | 14 ++ crates/core/src/collaboration.rs | 17 ++ crates/core/src/connections.rs | 95 +++++++++++- crates/core/src/ipc.rs | 32 +++- crates/core/src/screen.rs | 6 +- crates/core/src/session.rs | 5 +- crates/core/tests/admission.rs | 45 ++++++ crates/core/tests/lifecycle_audit.rs | 4 +- crates/core/tests/screen.rs | 30 ++++ crates/frontend/src/automation.rs | 2 +- crates/frontend/src/lib.rs | 15 +- crates/frontend/tests/admission_harness.rs | 14 ++ docs/architecture.ko.md | 6 +- docs/architecture.md | 5 +- docs/browser-testing.md | 17 ++ docs/collaboration-context-results.ko.md | 61 ++++++++ docs/collaboration-context-results.md | 64 ++++++++ docs/collaboration-refactor-plan.ko.md | 2 + docs/collaboration-refactor-plan.md | 2 + docs/getting-started.ko.md | 20 +-- docs/getting-started.md | 20 +-- docs/platform-support.ko.md | 2 +- docs/platform-support.md | 2 +- docs/product-backlog.ko.md | 140 +++++++++++++++++ docs/product-backlog.md | 146 ++++++++++++++++++ frontends/tauri/package.json | 7 +- .../tauri/scripts/context-harness-test.mjs | 132 ++++++++++++++++ .../tauri/scripts/screen-parity-test.mjs | 38 +++++ .../tauri/src-tauri/capabilities/default.json | 2 +- frontends/tauri/src-tauri/tauri.conf.json | 2 +- frontends/tauri/src/App.svelte | 89 +++++++---- .../src/components/AdmissionRequest.svelte | 5 +- frontends/tauri/src/components/Center.svelte | 6 + .../components/CollaborationActivity.svelte | 30 ++++ .../src/components/ControlRequest.svelte | 2 +- .../tauri/src/components/DecisionCard.svelte | 4 +- frontends/tauri/src/components/Ghost.svelte | 2 +- frontends/tauri/src/components/Hold.svelte | 2 +- frontends/tauri/src/components/Island.svelte | 7 +- .../tauri/src/components/TabStrip.svelte | 2 +- frontends/tauri/src/components/Term.svelte | 3 + .../src/components/TerminalPreparation.svelte | 32 ++++ .../tauri/src/lib/collaboration/activity.ts | 15 ++ .../tauri/src/lib/collaboration/attention.ts | 17 ++ .../tauri/src/lib/collaboration/session.ts | 12 +- frontends/tauri/src/lib/i18n.svelte.ts | 50 +++++- frontends/tauri/src/lib/store.svelte.ts | 10 +- .../tauri/tests/collaboration/state.test.ts | 16 ++ package-lock.json | 9 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/.codex-plugin/plugin.json | 2 +- vendor/vt100/src/grid.rs | 118 ++++++++++++++ vendor/vt100/src/parser.rs | 7 + vendor/vt100/src/row.rs | 4 + vendor/vt100/src/screen.rs | 34 ++-- 63 files changed, 1352 insertions(+), 142 deletions(-) create mode 100644 crates/core/examples/screen_probe.rs create mode 100644 docs/collaboration-context-results.ko.md create mode 100644 docs/collaboration-context-results.md create mode 100644 docs/product-backlog.ko.md create mode 100644 docs/product-backlog.md create mode 100644 frontends/tauri/scripts/context-harness-test.mjs create mode 100644 frontends/tauri/scripts/screen-parity-test.mjs create mode 100644 frontends/tauri/src/components/CollaborationActivity.svelte create mode 100644 frontends/tauri/src/components/TerminalPreparation.svelte create mode 100644 frontends/tauri/src/lib/collaboration/activity.ts create mode 100644 frontends/tauri/src/lib/collaboration/attention.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fd465a1..ae37ecd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "conn", "source": "./plugin", "description": "Conn: MCP server + working-etiquette skill.", - "version": "0.8.3" + "version": "0.8.4" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62377bb..f361fe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,9 @@ jobs: - run: python -m unittest discover -s tests/release -v - run: npm test working-directory: frontends/tauri + - name: Core and renderer screen parity + run: npm run test:screen + working-directory: frontends/tauri - run: npm run build working-directory: frontends/tauri - name: The website builds, including the pages it generates from docs/ @@ -172,6 +175,8 @@ jobs: working-directory: frontends/tauri - run: npm run test:collaboration:harness working-directory: frontends/tauri + - run: npm run test:context:harness + working-directory: frontends/tauri required: name: Required checks if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f33e3..d0dbe48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +## 0.8.4 — Preview · 2026-09-21 + +- Keep pending requests, active control and recent agent activity visible across tabs. Clicking a notice opens the exact terminal and request; opening or switching an agent's tab preserves the person's current selection. +- Let admitted agents without an available shell request terminal preparation. Choose an existing terminal, create a private one, or defer; sharing remains an explicit decision. Duplicate requests coalesce and dismissed requests have a short cooldown. +- Improve Observe navigation, lease-expiry guidance and reconnect instructions. Reconnect to the existing live shell by its stable ID; a new connection does not inherit sharing or control by name. +- Fix truncated and mismatched agent snapshots after resizing long wrapped output. Normal-screen reflow preserves text and attributes, alternate screens keep their layout, and Unicode width handling matches the tested UI output. + +Validation: 254 Rust tests (one existing ignored test), 48 frontend tests, 12 stages in the production-UI/real-Rust browser harness and 85 core/xterm screen comparisons passed locally. See [implementation and validation](docs/collaboration-context-results.md). Native taskbar/Dock attention, minimized/multiple windows, Mac/Windows interactive collaboration, real SSH reconnect/reauthentication and installed-app upgrades remain unverified for this patch. Release CI, artifact signatures and notarization are checked separately. + +한국어: 여러 탭의 대기 요청·제어 상태·최근 에이전트 작업을 함께 표시하고, 안내에서 정확한 셸과 요청으로 이동합니다. 에이전트가 다른 탭으로 이동해도 사람이 보는 탭은 유지합니다. 셸이 없는 에이전트는 준비를 부탁할 수 있으며, 새 셸도 비공개로 시작해 사람이 공유를 결정합니다. Observe 이동·제어 만료·기존 셸 재접속 안내를 개선하고, 긴 출력의 크기 변경 시 화면과 스냅샷이 달라지던 오류를 수정했습니다. + +한국어: Rust 254개·프런트엔드 48개·실제 UI와 Rust 하네스 12단계·화면 교차 비교 85회를 통과했습니다. [구현·검증 결과](docs/collaboration-context-results.ko.md)에 범위를 기록했습니다. 네이티브 알림과 최소화·다중 창, Mac·Windows 대화형 협업, 실제 SSH 재접속·재인증, 설치된 앱의 업그레이드는 이번 패치에서 아직 검증하지 않았습니다. + ## 0.8.3 — Preview · 2026-09-21 Validation: 246 Rust tests, 46 frontend tests, 78 release tests, production-App browser fixtures, the real Rust Harness collaboration flow and a real OpenSSH authentication/sharing regression passed locally. Interactive testing of this patch on macOS was not performed because the test Mac is offline; Linux/Windows native GUI acceptance and installed-app upgrade coverage also remain open. Release CI and signing checks are reported separately. diff --git a/Cargo.lock b/Cargo.lock index ef5465a..5d91a03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "conn" -version = "0.8.3" +version = "0.8.4" dependencies = [ "clap", "conn-core", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "conn-browser-harness" -version = "0.8.3" +version = "0.8.4" dependencies = [ "axum", "conn-core", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "conn-core" -version = "0.8.3" +version = "0.8.4" dependencies = [ "chrono", "dirs 5.0.1", @@ -742,7 +742,7 @@ dependencies = [ [[package]] name = "conn-desktop" -version = "0.8.3" +version = "0.8.4" dependencies = [ "base64 0.22.1", "cc", @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "conn-frontend" -version = "0.8.3" +version = "0.8.4" dependencies = [ "base64 0.22.1", "conn-core", diff --git a/Cargo.toml b/Cargo.toml index 9d250ce..33c8695 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,14 @@ members = ["crates/core", "crates/cli", "crates/frontend", "crates/browser-harne default-members = ["crates/core", "crates/cli", "crates/frontend", "crates/browser-harness"] [workspace.package] -version = "0.8.3" +version = "0.8.4" edition = "2021" license = "MIT" repository = "https://github.com/eggp-dev/conn" [workspace.dependencies] -conn-core = { path = "crates/core", version = "0.8.3" } -conn-frontend = { path = "crates/frontend", version = "0.8.3" } +conn-core = { path = "crates/core", version = "0.8.4" } +conn-frontend = { path = "crates/frontend", version = "0.8.4" } tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "time", "macros", "signal"] } portable-pty = "0.8" vt100 = { path = "vendor/vt100", version = "0.15.2" } diff --git a/README.ko.md b/README.ko.md index 401126b..4fdc5ea 100644 --- a/README.ko.md +++ b/README.ko.md @@ -29,13 +29,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh Rust나 Node.js는 필요 없습니다. 에이전트 연결 도구가 함께 들어 있습니다. 두 방법 모두 아래 다운로드와 같은 서명된 릴리스 파일을 설치하며, 이후에는 앱 안에서 업데이트됩니다. Windows는 설치 파일을 사용하세요. -| 운영체제 | v0.8.3 프리뷰 | +| 운영체제 | v0.8.4 프리뷰 | |---|---| -| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.3) +Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.4) ## 처음 5분 diff --git a/README.md b/README.md index 7567938..30bf664 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh No Rust or Node.js required; the agent connector is included. Both install the same signed release files as the downloads below, and the app updates itself afterwards. Windows: use the installer. -| Platform | v0.8.3 preview | +| Platform | v0.8.4 preview | |---|---| -| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.3) +Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.4) ## First five minutes diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index e07615c..d5effc6 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -90,14 +90,14 @@ const TOOLS: &[ToolDef] = &[ name: "terminal_request_attention", affordance: "request_attention", method: "request_attention", - description: "Asks the human to look at this shared session, for example to review a pending decision. Background access does not depend on attention.", - schema: || json!({ "type": "object", "properties": { "reason": { "type": "string", "description": "Why they should look, one line" } }, "additionalProperties": false }), + description: "Asks the human to review this session. After connection admission, if no live shell is accessible, asks them to choose or prepare a terminal without granting access or control. Repeated preparation requests coalesce. Background access does not depend on attention. Use cancel to withdraw a preparation request.", + schema: || json!({ "type": "object", "properties": { "reason": { "type": "string", "maxLength":512, "description": "Why they should look, one line; omit credentials" }, "cancel": {"type":"boolean"} }, "additionalProperties": false }), }, ToolDef { name: "terminal_list_tabs", affordance: "*", method: "list_tabs", - description: "Lists the tabs (sessions): number, mode, effectiveMode, whether the human is looking at it (attended), who has the conn, who opened it, and which one your connection is bound to (current). No screen contents.", + description: "Lists permitted existing shells: stable id, tab number, profileName, processAlive, mode, effectiveMode, attended, controller and current binding. After reconnecting, rediscover a surviving shell here and select its id instead of opening a replacement. Names do not establish remote host/account identity. Exited shells are not restored sessions. No screen contents.", schema: || json!({ "type": "object", "properties": {}, "additionalProperties": false }), }, ToolDef { @@ -111,7 +111,7 @@ const TOOLS: &[ToolDef] = &[ name: "terminal_switch_tab", affordance: "switch_tab", method: "switch_tab", - description: "Moves your connection to another tab. It never moves the human's view. In a tab the human is not looking at you still cannot see or write. `tab` is a number or id from terminal_list_tabs.", + description: "Moves your connection to a permitted existing tab, including an Observe tab. It never moves the human's view or grants write control. Access follows sharing, mode and control independently of focus. Prefer the stable session id from terminal_list_tabs when rediscovering an existing shell.", schema: || json!({ "type": "object", "properties": { "tab": { "type": ["integer", "string"], "description": "Tab number (from 1) or session id" } }, "required": ["tab"], "additionalProperties": false }), }, ToolDef { @@ -251,7 +251,7 @@ impl Bridge { "exec_pending" => " — a previous ENTER is still in its grace window", "busy" => " — another agent holds control; use terminal_snapshot only", "approval_pending" => " — wait with terminal_check_approval or call terminal_interrupt", - "control_denied" => " — the human declined to hand over control; observe with terminal_snapshot and ask again later with a clearer reason", + "control_denied" => " — the human declined control; observe only and tell them. Do not repeat the request unless asked to retry", "wrong_mode" => " — the frontend put agents in observe mode", "proposal_pending" => " — your proposal is waiting for the human to commit or reject", "intent_required" => " — resend ENTER with an `intent` (one line: what this command does and changes)", @@ -263,6 +263,7 @@ impl Bridge { }; tool_error(format!("{code}: {message}{hint}")) } + Err(ClientError::Disconnected) => tool_error("connection_lost: Conn disconnected. The last operation may be incomplete; do not replay it automatically. Reconnect, complete any new connection approval, then use terminal_list_tabs and terminal_switch_tab with the existing session ID and read a fresh snapshot. A new connection inherits no selected sharing or control.".into()), Err(e) => tool_error(e.to_string()), } } diff --git a/crates/core/examples/screen_probe.rs b/crates/core/examples/screen_probe.rs new file mode 100644 index 0000000..a93d413 --- /dev/null +++ b/crates/core/examples/screen_probe.rs @@ -0,0 +1,14 @@ +//! Test-only JSON-lines bridge for comparing the core grid with the UI parser. +use std::io::{self, BufRead}; +use conn_core::screen::ScreenModel; +use serde_json::{json, Value}; +fn main() { + let mut screen=ScreenModel::new(24,80); + for line in io::stdin().lock().lines() { + let v:Value=serde_json::from_str(&line.unwrap()).unwrap(); + if v["reset"]==true { screen=ScreenModel::new(v["rows"].as_u64().unwrap() as u16,v["cols"].as_u64().unwrap() as u16); } + else if let Some(text)=v["text"].as_str() {screen.process(text.as_bytes());} + else {screen.resize(v["rows"].as_u64().unwrap() as u16,v["cols"].as_u64().unwrap() as u16);} + println!("{}",json!({"screen":screen.rows(),"cursor":screen.cursor()})); + } +} diff --git a/crates/core/src/collaboration.rs b/crates/core/src/collaboration.rs index cfb03d4..9e9ceff 100644 --- a/crates/core/src/collaboration.rs +++ b/crates/core/src/collaboration.rs @@ -40,6 +40,23 @@ impl Hub { pub fn admission_snapshot(&self) -> crate::connections::AdmissionSnapshot { self.connections.snapshot() } + pub fn activity_snapshot(&self) -> crate::connections::ActivitySnapshot { + // A newly shared live shell resolves preparation, without choosing it or granting control. + let snapshot = self.connections.activity_snapshot(); + for a in &snapshot.connections { + if let Some(request) = &a.preparation { + if self.has_available_session(a.conn_id) { self.connections.dismiss_preparation(a.conn_id, request.id); } + } + } + self.connections.activity_snapshot() + } + pub fn has_available_session(&self, conn: ConnId) -> bool { + self.public_ids().iter().any(|id| self.get_public(id).is_some_and(|s| { + let s = s.lock(); s.participant_allowed(conn) && s.process_alive() + })) + } + pub fn set_activity_listener(&self, listener: crate::connections::ActivityListener) { self.connections.set_activity_listener(listener); } + pub fn dismiss_preparation(&self, conn: ConnId, request_id: u64) -> bool { self.connections.dismiss_preparation(conn, request_id) } pub fn admission_policy(&self) -> AdmissionPolicy { self.connections.policy() } diff --git a/crates/core/src/connections.rs b/crates/core/src/connections.rs index 3d8ffd0..dd4118f 100644 --- a/crates/core/src/connections.rs +++ b/crates/core/src/connections.rs @@ -1,4 +1,4 @@ -//! App-wide admission and live connection identity; no sessions or terminal access. +//! App-wide admission, live identity and navigation metadata; never grants terminal access. use crate::{authority::ConnId, session::AgentConnection}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, sync::Arc}; @@ -7,8 +7,26 @@ struct AgentRegistration { last_seen: std::time::Instant, catalog_changed: tokio::sync::mpsc::UnboundedSender<()>, admission: tokio::sync::watch::Sender, + session: Option, + preparation: Option, + last_preparation: Option, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparationRequest { pub id: u64, pub reason: Option } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionActivity { + pub conn_id: ConnId, + pub agent_id: String, + pub session: Option, + pub preparation: Option, +} +#[derive(Serialize)] +pub struct ActivitySnapshot { pub revision: u64, pub connections: Vec } +pub type ActivityListener = Arc; + /// Whether a new agent connection participates at once or waits for the owner. /// Ordinary tabs are open to every admitted connection, so admission is where /// the owner's explicit consent lives. Embedders default to `Allow`. @@ -53,6 +71,7 @@ struct State { pub struct ConnectionRegistry { state: parking_lot::Mutex, listener: parking_lot::Mutex>, + activity_listener: parking_lot::Mutex>, } impl Default for ConnectionRegistry { fn default() -> Self { @@ -63,10 +82,58 @@ impl Default for ConnectionRegistry { policy: AdmissionPolicy::Allow, }), listener: Default::default(), + activity_listener: Default::default(), } } } impl ConnectionRegistry { + pub fn set_activity_listener(&self, listener: ActivityListener) { *self.activity_listener.lock() = Some(listener); } + fn activity_changed(&self) { + let listener = self.activity_listener.lock().clone(); + if let Some(listener) = listener { listener(); } + } + pub fn activity_snapshot(&self) -> ActivitySnapshot { + let state = self.state.lock(); + let mut connections: Vec<_> = state.agents.iter() + .filter(|(_, a)| *a.admission.borrow() == Admission::Granted) + .map(|(id, a)| ConnectionActivity { conn_id: *id, agent_id: a.name.clone(), session: a.session.clone(), preparation: a.preparation.clone() }).collect(); + connections.sort_by_key(|a| a.conn_id); + ActivitySnapshot { revision: state.revision, connections } + } + pub(crate) fn bind(&self, conn: ConnId, session: &str) { + let changed = { + let mut state = self.state.lock(); + if let Some(a) = state.agents.get_mut(&conn).filter(|a| *a.admission.borrow() == Admission::Granted) { + if a.session.as_deref() == Some(session) && a.preparation.is_none() { false } + else { a.session = Some(session.into()); a.preparation = None; state.revision += 1; true } + } else { false } + }; + if changed { self.activity_changed(); } + } + /// One live request per admitted connection. Dismissed requests have a short cooldown. + pub(crate) fn request_preparation(&self, conn: ConnId, reason: Option) -> Option { + let request = { + let mut state = self.state.lock(); + let revision = state.revision + 1; + let a = state.agents.get_mut(&conn).filter(|a| *a.admission.borrow() == Admission::Granted)?; + if let Some(request) = &a.preparation { return Some(request.clone()); } + if a.last_preparation.is_some_and(|at| at.elapsed().as_secs() < 10) { return None; } + let request = PreparationRequest { id: revision, reason }; + a.preparation = Some(request.clone()); a.last_preparation = Some(std::time::Instant::now()); + state.revision = revision; + request + }; + self.activity_changed(); Some(request) + } + pub fn dismiss_preparation(&self, conn: ConnId, id: u64) -> bool { + let changed = { + let mut state = self.state.lock(); + if let Some(a) = state.agents.get_mut(&conn).filter(|a| a.preparation.as_ref().is_some_and(|r| r.id == id)) { + a.preparation = None; state.revision += 1; true + } else { false } + }; + if changed { self.activity_changed(); } changed + } fn connections(state: &State, admission: Admission) -> Vec { let mut out: Vec<_> = state .agents @@ -102,6 +169,7 @@ impl ConnectionRegistry { if let Some(listener) = listener { listener(change); } + self.activity_changed(); } pub fn set_policy(&self, policy: AdmissionPolicy) { let events = { @@ -170,6 +238,9 @@ impl ConnectionRegistry { last_seen: std::time::Instant::now(), catalog_changed, admission, + session: None, + preparation: None, + last_preparation: None, }, ); state.revision += 1; @@ -225,6 +296,28 @@ impl ConnectionRegistry { mod tests { use super::*; #[test] + fn preparation_requires_admission_coalesces_and_does_not_survive_reconnect() { + let r=Arc::new(ConnectionRegistry::default());r.set_policy(AdmissionPolicy::Ask); + let weak=Arc::downgrade(&r); + r.set_activity_listener(Arc::new(move || { let _=weak.upgrade().unwrap().activity_snapshot(); })); + let (tx,_)=tokio::sync::mpsc::unbounded_channel();r.register(1,"same",tx); + assert!(r.request_preparation(1,None).is_none());assert!(r.activity_snapshot().connections.is_empty()); + r.decide(1,true); + let first=r.request_preparation(1,Some("fixture".into())).unwrap(); + assert_eq!(r.request_preparation(1,None).unwrap().id,first.id); + assert!(!r.dismiss_preparation(1,first.id+1)); + assert!(r.dismiss_preparation(1,first.id)); + assert!(r.request_preparation(1,None).is_none(),"dismissal cooldown"); + r.state.lock().agents.get_mut(&1).unwrap().last_preparation=None; + let next=r.request_preparation(1,None).unwrap();assert!(next.id>first.id); + assert!(!r.dismiss_preparation(1,first.id),"old UI cannot dismiss a replacement"); + r.bind(1,"tab");let snapshot=r.activity_snapshot(); + assert_eq!(snapshot.connections[0].session.as_deref(),Some("tab"));assert!(snapshot.connections[0].preparation.is_none()); + r.forget(1);assert!(r.activity_snapshot().connections.is_empty()); + let (tx,_)=tokio::sync::mpsc::unbounded_channel();r.register(2,"same",tx);r.decide(2,true); + let snapshot=r.activity_snapshot();assert!(snapshot.connections[0].session.is_none());assert!(snapshot.connections[0].preparation.is_none()); + } + #[test] fn snapshots_and_events_share_a_revision_and_callbacks_can_read_state() { let registry = Arc::new(ConnectionRegistry::default()); registry.set_policy(AdmissionPolicy::Ask); diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 80f347d..12005d5 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -296,9 +296,25 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared let g = s.lock(); if !g.participant_allowed(conn) { return None; } let st = g.status(); - Some(json!({ "tab": i + 1, "id": sid, "current": bound.as_deref() == Some(sid.as_str()), "attended": st.attended, "controller": st.controller, "pending": st.pending.len(), "processAlive": st.process_alive, "mode": st.mode, "effectiveMode": st.effective_mode })) + Some(json!({ "tab": i + 1, "id": sid, "profileName": st.profile_name, "externalOrigin": st.external_origin, "current": bound.as_deref() == Some(sid.as_str()), "attended": st.attended, "controller": st.controller, "pending": st.pending.len(), "processAlive": st.process_alive, "mode": st.mode, "effectiveMode": st.effective_mode })) }).enumerate().map(|(i, mut row)| { row["tab"] = json!(i + 1); row }).collect(); Ok(json!({ "sessions": list, "tabs": list.len(), "attended": hub.public_attended_for(conn), "current": bound })) + } else if req.method == "request_attention" && (!hub.has_available_session(conn) || req.params["cancel"] == true) { + disclosure = None; + if req.params["cancel"] == true { + if let Some(a) = hub.connections.activity_snapshot().connections.iter().find(|a| a.conn_id == conn) { + if let Some(r) = &a.preparation { hub.connections.dismiss_preparation(conn, r.id); } + } + Ok(json!({"requested":false,"status":"cancelled"})) + } else { + let reason = str_param(&req.params, "reason"); + if reason.as_ref().is_some_and(|s| s.chars().count() > 512) { + Err(RpcError { code:"invalid_input".into(), message:"Use a preparation reason of at most 512 characters.".into() }) + } else { + let request = hub.connections.request_preparation(conn, reason); + Ok(json!({"requested":request.is_some(),"scope":"connection","requestId":request.map(|r|r.id),"status":"waiting_for_terminal"})) + } + } } else if req.method == "open_tab" { // Agent: open a tab. It starts unattended; the human sees it knock. let reason = str_param(&req.params, "reason"); @@ -317,7 +333,7 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared hub.leave_other_tabs(conn, &sid); ns.lock().agent_opened_tab(conn, reason); bound = Some(sid.clone()); - Ok(json!({ "session": sid, "tab": hub.public_index_of(&sid, conn), "attended": false, "note": "the human is not looking at this tab yet; request_attention is queued — wait for the human to show this tab" })) + Ok(json!({ "session": sid, "tab": hub.public_index_of(&sid, conn), "attended": false, "note": "Your connection moved to this tab; the human view did not move. Sharing, mode and control apply independently of attention. Request attention when a human decision is needed." })) } }, } @@ -331,7 +347,9 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared let pinned = bound.as_ref().and_then(|b| hub.get_public(b)).is_some_and(|s| s.lock().blocks_navigation_from(conn)); let can_switch = !pinned && hub.public_ids().iter().any(|id| hub.get_public(id) .is_some_and(|s| s.lock().agent_can_navigate(conn).is_ok())); - Ok(json!(if can_switch { vec!["switch_tab"] } else { vec![] })) + let mut available = if can_switch { vec!["switch_tab"] } else { vec![] }; + if !hub.has_available_session(conn) { available.push("request_attention"); } + Ok(json!(available)) } else if req.method == "switch_tab" { let want = req.params.get("tab").cloned().or_else(|| req.params.get("session").cloned()).unwrap_or(Value::Null); match hub.find_public_tab(&want, conn) { @@ -383,6 +401,14 @@ async fn handle_conn(stream: crate::transport::Stream, conn: ConnId, hub: Shared } } } => result, }; + // Discovery polling must not overwrite the last addressed terminal. + // Explicit session calls count too; they need not change the IPC binding. + if result.is_ok() && !matches!(req.method.as_str(), "list_tabs" | "sessions" | "navigation_affordances" | "affordances" | "status") { + let activity_target = str_param(&req.params, "session").or_else(|| bound.clone()); + if let Some(sid) = activity_target { + if hub.get_public(&sid).is_some_and(|s| { let s=s.lock(); s.participant_allowed(conn) && s.process_alive() }) { hub.connections.bind(conn, &sid); } + } + } let resp = match result { Ok(v) => Response { id: req.id, result: Some(v), error: None }, Err(e) => Response { id: req.id, result: None, error: Some(e) }, diff --git a/crates/core/src/screen.rs b/crates/core/src/screen.rs index 7c208a8..0a4f966 100644 --- a/crates/core/src/screen.rs +++ b/crates/core/src/screen.rs @@ -63,7 +63,9 @@ pub struct ScreenModel { impl ScreenModel { pub fn new(rows: u16, cols: u16) -> Self { - let parser = vt100::Parser::new(rows.max(1), cols.max(1), 0); + // Same bounded normal-buffer history as the owner terminal, solely for + // resize reflow. Observations still project only the current grid. + let parser = vt100::Parser::new(rows.max(1), cols.max(1), 5000); let last_contents = vec![""; usize::from(rows.max(1))].join("\n"); Self { parser, revision: 0, last_contents } } @@ -89,7 +91,7 @@ impl ScreenModel { } pub fn resize(&mut self, rows: u16, cols: u16) { - self.parser.set_size(rows.max(1), cols.max(1)); + self.parser.set_size_reflow(rows.max(1), cols.max(1)); self.last_contents = self.rows().join("\n"); self.revision += 1; } diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 2bf0636..1867e86 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -1044,6 +1044,7 @@ impl Session { /// Agent: ask the human to look at this session. pub fn agent_request_attention(&mut self, conn: ConnId, reason: Option) -> Result<(), SessionError> { self.require_participant(conn)?; + self.check_mask(Affordance::RequestAttention)?; let agent_id = self.agent_name(conn); let req = AttentionRequest { agent_id: agent_id.clone(), reason: reason.clone(), at: chrono::Local::now().format("%H:%M:%S").to_string() }; self.attention_request = Some(req); @@ -1081,7 +1082,7 @@ impl Session { return Err(SessionError::NotAvailable("participation not granted".into())); } self.check_mask(Affordance::SwitchTab)?; - if !self.tabs_supported || self.mode == AgentMode::Observe { + if !self.tabs_supported { return Err(SessionError::NotAvailable("switch_tab".into())); } Ok(()) @@ -1212,7 +1213,7 @@ impl Session { out.retain(|a| mask.contains(a)); } if matches!(actor, Actor::Agent { .. }) && self.mode == AgentMode::Observe { - out.retain(|a| *a == Affordance::Snapshot); + out.retain(|a| matches!(a, Affordance::Snapshot | Affordance::RequestAttention | Affordance::SwitchTab | Affordance::CheckApproval)); } out } diff --git a/crates/core/tests/admission.rs b/crates/core/tests/admission.rs index ed25468..bf74f31 100644 --- a/crates/core/tests/admission.rs +++ b/crates/core/tests/admission.rs @@ -32,6 +32,51 @@ fn wait_until(what: &str, ready: impl Fn() -> bool) { panic!("timed out: {what}"); } +#[test] +fn preparation_works_without_sessions_and_never_grants_access() { + let hub=Arc::new(Hub::new());hub.set_admission_policy(AdmissionPolicy::Ask); + let (_dir,path,_guard)=serve(&hub); + let agent=Arc::new(Client::connect(&path).unwrap());let conn=agent.hello("agent","preparation").unwrap()["conn"].as_u64().unwrap(); + let waiting={let a=agent.clone();std::thread::spawn(move||a.call("request_attention",json!({"reason":"Choose a fixture shell"})))}; + std::thread::sleep(Duration::from_millis(60));assert!(hub.activity_snapshot().connections.is_empty());assert!(!waiting.is_finished()); + hub.decide_admission(conn,true);let request=waiting.join().unwrap().unwrap();assert_eq!(request["scope"],"connection"); + assert_eq!(agent.call("navigation_affordances",json!({})).unwrap(),json!(["request_attention"])); + assert_eq!(agent.call("request_attention",json!({})).unwrap()["requestId"],request["requestId"]); + let tab=session();tab.lock().set_shared(false).unwrap();hub.add("existing",tab.clone()); + assert_eq!(agent.call("list_tabs",json!({})).unwrap()["tabs"],0); + assert!(agent.call("snapshot",json!({"session":"existing"})).is_err()); + assert!(hub.activity_snapshot().connections[0].preparation.is_some()); + tab.lock().set_shared_with_agents(true,vec![conn]).unwrap(); + assert!(hub.activity_snapshot().connections[0].preparation.is_none()); + assert!(matches!(tab.lock().status().controller,ControllerInfo::Human)); + assert!(agent.call("activity_snapshot",json!({})).is_err()); + assert!(agent.call("prepare_terminal",json!({})).is_err()); +} + +#[test] +fn observe_can_navigate_and_request_attention_but_cannot_acquire_or_write() { + use conn_core::session::AgentMode; + let first=session();let second=session(); + first.lock().set_mode(AgentMode::Observe).unwrap();second.lock().set_mode(AgentMode::Observe).unwrap(); + let hub=Hub::single("first",first);hub.add("second",second.clone());hub.set_opener(Arc::new(|_,_|Err("unused".into()))); + let (_dir,path,_guard)=serve(&hub);let agent=Client::connect(&path).unwrap();agent.hello("agent","observer").unwrap(); + agent.call("switch_tab",json!({"tab":"second"})).unwrap();agent.call("snapshot",json!({})).unwrap();agent.call("request_attention",json!({"reason":"Please inspect"})).unwrap(); + let affordances=agent.call("affordances",json!({})).unwrap();assert!(affordances.as_array().unwrap().contains(&json!("request_attention"))); + assert_eq!(code(agent.call("request_control",json!({}))),"wrong_mode");assert!(agent.call("type",json!({"text":"x"})).is_err()); + second.lock().set_affordance_mask(Some([Affordance::Snapshot].into_iter().collect())); + assert_eq!(code(agent.call("request_attention",json!({}))),"masked"); +} + +#[test] +fn activity_tracks_explicit_targets_and_discovery_cannot_move_it() { + let hub=Hub::single("first",session());hub.add("second",session()); + let (_dir,path,_guard)=serve(&hub);let a=Client::connect(&path).unwrap();a.hello("agent","reader").unwrap(); + a.call("snapshot",json!({"session":"second"})).unwrap(); + a.call("list_tabs",json!({})).unwrap();a.call("status",json!({})).unwrap(); + assert_eq!(hub.activity_snapshot().connections[0].session.as_deref(),Some("second")); + drop(a);wait_until("activity removed",||hub.activity_snapshot().connections.is_empty()); +} + #[test] fn pending_connection_learns_nothing_until_the_owner_allows_it() { let hub = Hub::single("tab", session()); diff --git a/crates/core/tests/lifecycle_audit.rs b/crates/core/tests/lifecycle_audit.rs index 573b8bc..85582ba 100644 --- a/crates/core/tests/lifecycle_audit.rs +++ b/crates/core/tests/lifecycle_audit.rs @@ -255,13 +255,13 @@ fn navigation_catalog_recovers_without_leaking_or_granting_access() { .unwrap(); assert_eq!( c.call("navigation_affordances", json!({})).unwrap(), - json!([]) + json!(["request_attention"]) ); assert!(c.call("switch_tab", json!({"tab":"target"})).is_err()); target.lock().set_shared(false).unwrap(); assert_eq!( c.call("navigation_affordances", json!({})).unwrap(), - json!([]) + json!(["request_attention"]) ); } #[test] diff --git a/crates/core/tests/screen.rs b/crates/core/tests/screen.rs index a4fb9b1..f17dd2c 100644 --- a/crates/core/tests/screen.rs +++ b/crates/core/tests/screen.rs @@ -56,6 +56,36 @@ fn resize_changes_size() { assert_eq!((sz.rows, sz.cols), (30, 100)); } +#[test] +fn completed_lines_reflow_without_losing_unicode_or_conceal_attributes() { + let mut s=ScreenModel::new(8,12); + s.process("/long/가나다/끝\r\n\x1b[8mHIDDEN\x1b[0m visible\r\n$ ".as_bytes()); + let original=s.rows(); + s.resize(8,7); + assert_eq!(&s.rows()[..3],["/long/","가나다/","끝"]); + assert!(!s.rows().join("\n").contains("HIDDEN")); + assert_eq!(s.cursor().row,5); + s.resize(8,12);assert_eq!(s.rows(),original); +} + +#[test] +fn height_resizes_keep_the_cursor_and_only_restore_visible_history() { + let mut s=ScreenModel::new(4,20);s.process(b"one\r\ntwo\r\nthree\r\nfour\r\n$ "); + assert_eq!(s.rows(),["two","three","four","$"]); + s.resize(2,20);assert_eq!(s.rows(),["four","$"]);assert_eq!(s.cursor().row,1); + s.resize(4,20);assert_eq!(s.rows(),["two","three","four","$"]); + s.process(b"\x1b[3J");s.resize(6,20); + assert_eq!(s.rows(),["two","three","four","$","",""]); +} + +#[test] +fn alternate_screen_is_not_reflowed_and_normal_buffer_survives() { + let mut s=ScreenModel::new(5,12);s.process(b"abcdefghijklmnop\r\n$ "); + s.process(b"\x1b[?1049h\x1b[HALTERNATE123\r\n$ ");s.resize(5,8); + assert_eq!(s.rows()[0],"ALTERNAT");s.process(b"\x1b[?1049l"); + assert_eq!(&s.rows()[..2],["abcdefgh","ijklmnop"]); +} + #[test] fn concealed_split_sequences_wide_cells_and_equal_colors_are_not_observed() { let mut s=ScreenModel::new(5,80); diff --git a/crates/frontend/src/automation.rs b/crates/frontend/src/automation.rs index d261286..576bfb9 100644 --- a/crates/frontend/src/automation.rs +++ b/crates/frontend/src/automation.rs @@ -269,7 +269,7 @@ pub(crate) fn dispatch(app: &AppHandle, state: &Arc, window: &str, cal } crate::ensure_runtime(app, state).map_err(|_| "Cannot initialize terminal")?; if !(caller.still_alive)() || !state.windows.lock().ready(window) { return Err("External session unavailable".into()); } - let id = crate::spawn_tab_with(app, state, window, 24, 80, Some(profile), true, launch, Some(caller.still_alive.as_ref())) + let id = crate::spawn_tab_with(app, state, window, 24, 80, Some(profile), true, false, launch, Some(caller.still_alive.as_ref())) .map_err(|_| "Cannot start external session")?; let session = state.hub.get(&id).ok_or("Session unavailable")?; let handle = uuid::Uuid::new_v4().to_string(); diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 3930d2a..69962e1 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -80,6 +80,8 @@ impl Harness { let notify = app.clone(); // Connection-level, so every owner window hears it; any of them may answer. state.hub.set_admission_listener(Arc::new(move |change| { let _ = notify.emit("ss:admission", serde_json::to_value(change).unwrap_or(Value::Null)); })); + let notify = app.clone(); + state.hub.set_activity_listener(Arc::new(move || { let _ = notify.emit("ss:activity_changed", Value::Null); })); Self { state, app } } pub fn shutdown(&self) { @@ -105,6 +107,12 @@ impl Harness { match name { "start" => start(&self.app, &self.state, window, arg(&args, "rows")?, arg(&args, "cols")?), "open_tab" => open_tab(&self.app, &self.state, window, arg(&args, "rows")?, arg(&args, "cols")?, arg(&args, "profileId")?).map(|id| json!(id)), + "prepare_terminal" => { + let _startup = self.state.startup.lock(); + if self.state.windows.lock().pending(window) { return Err("Window is preparing an external session".into()); } + let id = spawn_tab_with(&self.app, &self.state, window, arg(&args,"rows")?, arg(&args,"cols")?, None, false, true, LaunchSpec::ProfileDefault, None)?; + self.state.windows.lock().select(window, &id); self.state.hub.set_attended(&id); Ok(json!(id)) + }, "ui_ready" => { self.state.windows.lock().mark_ready(window); Ok(Value::Null) }, "attend" => { let session: String = arg(&args, "session")?; @@ -213,10 +221,10 @@ fn terminal_output(app: AppHandle, session: String) -> conn_core::session::Outpu } fn spawn_tab(app: &AppHandle, state: &AppState, window: &str, rows: u16, cols: u16, profile_id: Option<&str>) -> Result { - spawn_tab_with(app, state, window, rows, cols, profile_id, false, LaunchSpec::ProfileDefault, None) + spawn_tab_with(app, state, window, rows, cols, profile_id, false, false, LaunchSpec::ProfileDefault, None) } -fn spawn_tab_with(app: &AppHandle, state: &AppState, window: &str, mut rows: u16, mut cols: u16, profile_id: Option<&str>, external_private: bool, launch: LaunchSpec, caller_alive: Option<&(dyn Fn() -> bool + Send + Sync)>) -> Result { +fn spawn_tab_with(app: &AppHandle, state: &AppState, window: &str, mut rows: u16, mut cols: u16, profile_id: Option<&str>, external_private: bool, start_private: bool, launch: LaunchSpec, caller_alive: Option<&(dyn Fn() -> bool + Send + Sync)>) -> Result { let profiles = conn_core::profiles::Profiles::load(&state.config_dir.join("profiles.json"))?; let profile = profiles.profiles.iter().find(|p| p.id == profile_id.unwrap_or(&profiles.default_profile)).ok_or("Unknown profile")?.clone(); let availability = profile.availability(); @@ -300,6 +308,7 @@ fn spawn_tab_with(app: &AppHandle, state: &AppState, window: &str, mut rows: u16 }), ); if !external_private { apply_defaults(&state.defaults.lock(), engine.as_ref()); } + if start_private { engine.session().lock().set_shared(false).map_err(|e|e.to_string())?; } state.hub.add(&id, engine.session()); state.engines.lock().insert(id.clone(), engine); if let Some(pending) = pending.as_mut() { pending.remove(&id); } @@ -613,6 +622,8 @@ fn dispatch(app: &AppHandle, state: &AppState, name: &str, args: Value) -> Resul }, "open_release" => updates::open(&arg::(&args, "url")?).map(|_| Value::Null), "admission_snapshot" => Ok(json!(state.hub.admission_snapshot())), + "activity_snapshot" => Ok(json!(state.hub.activity_snapshot())), + "dismiss_preparation" => Ok(json!({"dismissed":state.hub.dismiss_preparation(arg(&args,"connId")?, arg(&args,"requestId")?)})), "pending_admissions" => Ok(json!(state.hub.pending_admissions().into_iter().map(|a| json!({"connId":a.conn_id,"agentId":a.agent_id})).collect::>())), "admission_policy" => Ok(json!({"ask": state.hub.admission_policy() == AdmissionPolicy::Ask})), "set_admission" => set_admission(state, arg::(&args, "ask")?), diff --git a/crates/frontend/tests/admission_harness.rs b/crates/frontend/tests/admission_harness.rs index 79bd4cd..87dd280 100644 --- a/crates/frontend/tests/admission_harness.rs +++ b/crates/frontend/tests/admission_harness.rs @@ -21,6 +21,20 @@ fn wait_for(what: &str, ready: impl Fn() -> bool) { while !ready() { assert!(Instant::now() < deadline, "timed out: {what}"); std::thread::sleep(Duration::from_millis(10)); } } +#[test] +fn preparation_creates_a_private_live_shell_with_no_agent_participation() { + let dir=tempfile::tempdir().unwrap();let h=harness(dir.path(),Default::default()); + let agent=Client::connect(&dir.path().join("conn.sock")).unwrap();let hello=agent.hello("agent","candidate").unwrap(); + h.invoke("decide_admission",json!({"connId":hello["conn"],"allow":true})).unwrap(); + let before=agent.call("list_tabs",json!({})).unwrap()["tabs"].clone(); + let id=h.invoke("prepare_terminal",json!({"rows":24,"cols":80})).unwrap(); + let status=h.invoke("status",json!({"session":id})).unwrap(); + assert_eq!(status["shared"],false);assert_eq!(status["processAlive"],true);assert_eq!(status["controller"]["type"],"human"); + assert_eq!(agent.call("list_tabs",json!({})).unwrap()["tabs"],before); + assert!(agent.call("snapshot",json!({"session":id})).is_err()); + h.shutdown(); +} + #[test] fn desktop_asks_by_default_and_the_owner_command_admits() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index 03a779a..3f8b216 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -46,8 +46,10 @@ UI의 앱 연결 계약과 세션 이벤트 계약은 분리됩니다. 공통 ## 화면의 식별과 최신성 그리드 revision은 `surfaceId`, `generation`, `revision`, `outputSeq`, 크기, 보이는 텍스트, -없을 수도 있는 커서, 대체 화면 상태를 식별합니다. 코어는 크기가 제한되고 스크롤백이 없는 -터미널 모델로 PTY 출력을 해석합니다. 스냅샷은 텍스트만 담습니다. +없을 수도 있는 커서, 대체 화면 상태를 식별합니다. 코어는 크기가 제한된 터미널 모델로 PTY 출력을 +해석합니다. 일반 화면은 소프트 랩과 화면 위치를 유지하기 위해 소유자 터미널과 같은 최대 5,000행의 +내부 기록을 크기 변경에 사용합니다. 스냅샷은 현재 그리드의 텍스트만 담으며 기록 조회 기능은 없습니다. +대체 화면은 줄을 재배치하지 않고 크기만 바꾸며, 커서가 있는 논리적 줄은 실행 중인 프로그램이 다시 그립니다. 공유 변경은 generation을 올리고, 출력·커서·크기 변화는 화면 revision을 갱신합니다. 창의 전경 여부, 하트비트, 소유자 렌더러의 화면 게시는 권한 판단에 관여하지 않습니다. diff --git a/docs/architecture.md b/docs/architecture.md index 00e6490..89fe1a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,10 @@ palette commands. See [ownership, lock order and validation](collaboration-refac A grid revision identifies `surfaceId`, `generation`, `revision`, `outputSeq`, size, visible text, nullable cursor and alternate-screen state. The core parses PTY -output with a bounded, zero-scrollback terminal model. Snapshots are text-only. +output with a bounded terminal model. Normal-buffer resize uses up to 5,000 internal +history rows, matching the owner's terminal, to preserve soft wraps and viewport position. +Snapshots remain text-only projections of the current grid; there is no history query. +Alternate screens resize without reflow, and the application redraws the cursor's logical line. Sharing changes advance the generation; output, cursor and resize changes update the screen revision. No foreground, diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 9d91170..9f5666b 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -76,3 +76,20 @@ the test backend. CLI staging and backups remain under the test state directory. Run `npm test` in `frontends/tauri` for the timeline, icon-state, connection-status and terminal-input checks. They need neither a browser nor the native backend. + +## Collaboration context and screen parity + +Build `cargo build -p conn-browser-harness --locked` at the repository root, then run +`npm run test:context:harness` from `frontends/tauri`. This uses the same UI, Rust backend, +real local PTYs and persistent agent sockets. It covers background tab requests and notification +targets, human takeover, fresh connection admission, retained shell state, Observe navigation, +sessionless preparation, explicit sharing, EN/KO and a narrow viewport. It uses ports 1461/1463; +override `CONN_HARNESS_UI_PORT` and `CONN_HARNESS_PORT` if needed. +Screenshots, video, state and grid comparisons are written under the printed temporary directory. +`CONN_CONTEXT_OUTPUT` overrides the evidence location. Native attention delivery is not simulated. + +`npm run test:screen` compares the installed xterm parser with a JSON-lines probe of the real Rust +`ScreenModel`. It checks rows and cursor positions after ASCII, Korean, combining marks, emoji, +soft wraps, scroll, height/width changes and alternate-screen transitions. The browser test separately +compares actual rendered rows against agent snapshots. Neither test proves every terminal program, +Unicode sequence, SSH connection or native window-manager behavior. diff --git a/docs/collaboration-context-results.ko.md b/docs/collaboration-context-results.ko.md new file mode 100644 index 0000000..77fd35e --- /dev/null +++ b/docs/collaboration-context-results.ko.md @@ -0,0 +1,61 @@ +# 협업 맥락 개선 — 구현과 검증 + +[English](collaboration-context-results.md) · [제품 백로그](product-backlog.ko.md) + +2026-09-21. v0.8.3 소스 이후의 **v0.8.4 구현 검증 기록**이다. 제품 기준은 사람이 같은 실제 셸에서 +에이전트의 작업을 따라가고 개입할 수 있는 경험이다. 빌드·자동 검사·공통 브라우저 하네스와 +네이티브 앱 실기 검증을 구분한다. + +## 백로그 분류와 이번 범위 + +| 분류 | 판단 | +|---|---| +| 이미 존재 | Observe, 공유된 백그라운드 탭 관찰, 탭 목록·전환, 탭별 제어권, 사람 입력 시 회수, 세션 attention, 실제 실행된 명령 기록. 중복 기능보다 발견성과 연결된 흐름을 개선한다. | +| 필수 개선 | UX-08 다중 탭 맥락, UX-02/03 대상·상태 안내, UX-04 세션 없는 준비 요청, UX-05 기존 셸 재발견, UX-01 출력 정확성. Observe의 이동 제한·도구 안내도 함께 정리한다. | +| 제외할 방향 | 포커스·선택 탭으로 접근을 제한하기, 숨겨진 별도 실행 셸, 에이전트 이동마다 사람 탭 강제 전환, 알림 클릭으로 자동 승인·공유·제어 획득, 같은 이름에 이전 권한 상속, 숨김 입력 수집. | +| 별도 설계·검증 | UX-07 검토 가능한 명령 묶음, SEC-01 사람과 에이전트의 공통 마스킹, TTL/자동 갱신 정책, PROD-01 외부 사용자의 첫 협업·재사용. 기능 자체를 부정하는 것이 아니라 이번 구현에 넣지 않는다. | + +## 구현한 동작 + +- **다른 탭의 작업을 놓치지 않기:** 다른 탭의 대기 요청과 살아 있는 연결의 최근 사용 터미널을 + 하단에 표시한다. 탭을 열거나 옮겨도 사람의 선택은 유지한다. 안내를 클릭하면 세션 ID로 + 현재 상태를 읽고 해당 탭으로 이동한다. 요청 ID가 이미 해결되었으면 그 사실을 안내한다. + 새 탭 등록과 이벤트 도착 순서가 달라도 상태 조회로 대기를 복구한다. +- **터미널 준비 부탁하기:** 연결 승인 후 접근 가능한 살아 있는 셸이 없으면 기존 attention + 도구로 준비를 요청한다. 사람은 `터미널 선택 / 새 터미널 / 나중에`를 선택한다. + 새 터미널은 비공개로 만들고 기존 공유 패널에서 참여자를 명시적으로 고른다. + 준비 요청은 연결별 하나이며 반복 요청은 합쳐지고, 나중에 처리한 뒤 10초간 재요청을 억제한다. + 요청 취소·연결 종료·공유 가능한 셸 준비 시 정리한다. 연결 승인 전에는 기존 admission 흐름이다. +- **상태와 안내:** Observe에서도 허용된 공유 탭으로 이동하고 사람의 확인을 요청할 수 있다. + 제어·입력은 계속 금지된다. 제어 만료 시간은 패널 안에서 서버 상태에 맞춰 표시한다. + 연결 단절과 셸 종료의 안내를 분리하고, 사람 입력의 제어 회수 범위가 해당 터미널임을 설명한다. + 알림은 제목의 요청 수와 네이티브 작업 표시줄/Dock attention을 사용하며 명령·출력은 담지 않는다. + 포커스 여부는 알림 전달에만 사용한다. 시스템 알림에서 개별 요청으로 이동하는 동작은 추가하지 않았다. +- **재접속:** 목록은 안정적인 세션 ID, 프로필 표시명, 프로세스 생존 여부를 제공한다. + 프로필명으로 원격 호스트·로그인 계정을 추측하지 않는다. 기존 셸을 명시적으로 다시 선택하고 + 화면을 읽도록 도구 안내를 수정했다. 연결이 끊긴 마지막 명령은 자동 재실행하지 않는다. + 새 연결은 새 승인 대상이고, 이전에 선택된 공유·제어권·대기 작업을 이름으로 상속하지 않는다. +- **출력:** 창을 좁힐 때 UI는 줄을 재배치하지만 코어는 오른쪽 문자를 잘라내던 오류를 재현했다. + 코어는 셀 속성과 전각 문자 쌍을 보존하며 일반 화면을 재배치한다. 내부 기록은 UI와 같은 최대 + 5,000행으로 제한하고 스냅샷은 현재 그리드만 반환한다. 대체 화면은 줄 재배치 없이 크기를 바꾼다. + 줄 끝 전각 문자의 연결 관계를 보존하고, UI에 공식 Unicode 11 애드온을 적용해 시험한 이모지 폭 차이를 수정했다. + +연결 메타데이터와 준비 요청은 `ConnectionRegistry`, 사용 가능한 세션 판단은 `Hub`, 참여·제어· +명령 결정은 기존 `Session`이 소유한다. UI는 기존 공유 패널과 결정 카드를 재사용한다. + +## 검증 결과와 한계 + +| 검증 | 결과 | +|---|---| +| Rust core/frontend/CLI/browser-harness | 254개 통과, 기존 1개 무시. 실제 소켓·PTY, 승인 경계, 연결별 준비·중복·취소, Observe·mask, 사적인 새 셸, 기존 협업 회귀 검사 포함 | +| 프런트엔드 상태 검사 | 48개 통과. 세션/요청 ID, 늦은 응답, 새 요청 교체, 만료 표시, 기존 입력·타임라인 검사 포함 | +| 공통 UI + 실제 Rust + 로컬 PTY | 12단계 통과. 새 탭 알림, 백그라운드 요청 이동, 별도 탭 사람 입력, 오래된 요청, 재접속 후 동일 셸 변수, Observe, 준비 요청·선택·비공개 생성·공유, 같은 이름 권한 비상속, EN/KO·좁은 창 | +| 출력 교차 비교 | 실제 렌더링 행과 스냅샷이 긴 경로·URL·한글 출력의 크기 변경 전/축소/복원 후 일치. 코어와 실제 xterm 파서의 화면·커서 85회 비교 통과 | +| 프런트엔드 빌드 | 통과. 기존 Svelte 경고와 번들 크기 경고 있음 | +| 네이티브 앱 빌드·실기 | 이 환경에 `gdk-3.0` 개발 라이브러리가 없어 Tauri 검사 중단. 작업 표시줄/Dock attention, 최소화·비활성·다중 창, Mac/Windows 실기는 미검증 | +| SSH·제품 검증 | 실제 SSH 재접속/재인증과 다양한 편집기·유니코드 조합의 전수 검증, 외부 사용자의 첫 협업·반복 사용은 남음 | +| 배포 | 공개 상태와 서명된 파일은 [v0.8.4 릴리스 페이지](https://github.com/eggp-dev/conn/releases/tag/v0.8.4)에 기록하며, 위 로컬 검증과 구분함 | + +백로그의 SSH·네이티브 인수 기준까지 완료했다고 해석하지 않는다. +[하네스 실행 방법](browser-testing.md)에 재현 명령이 있다. 합성 출력의 스크린샷·동영상·상태·그리드 +비교 자료는 각 실행이 출력한 임시 경로에 저장하며 저장소에는 넣지 않는다. diff --git a/docs/collaboration-context-results.md b/docs/collaboration-context-results.md new file mode 100644 index 0000000..eb0dfb6 --- /dev/null +++ b/docs/collaboration-context-results.md @@ -0,0 +1,64 @@ +# Collaboration context — implementation and validation + +[한국어](collaboration-context-results.ko.md) · [Product backlog](product-backlog.md) + +2026-09-21. **v0.8.4 implementation evidence** after the v0.8.3 source. The product criterion is that a person +can follow and intervene in an agent's work in the same real shell. Build results, automated checks, +the shared browser harness and native app acceptance are separate evidence levels. + +## Backlog classification and scope + +| Category | Decision | +|---|---| +| Already present | Observe, shared background observation, tab discovery/navigation, per-session leases, human takeover, session attention and executed-command history. Improve discoverability and the connected flow. | +| Essential improvements | UX-08 multi-tab context, UX-02/03 target/state guidance, UX-04 sessionless preparation, UX-05 rediscovery and UX-01 display fidelity. Include Observe navigation/tool guidance. | +| Incompatible directions | Focus/selected-tab access gates, hidden parallel execution shells, forcibly following every agent move, approving/sharing/granting on notification click, inheriting permissions by display name, or collecting hidden input. | +| Separate design/validation | UX-07 reviewable batches, SEC-01 masking shared by people and agents, TTL/renewal policy and PROD-01 first-use/reuse studies. These need separate work, rather than rejection as product ideas. | + +## Implemented behavior + +- **Keep other tabs visible:** pending requests elsewhere and each live connection's recently used + terminal remain available in the bottom dock. Agent tab creation/navigation preserves the person's + selection. Notices re-read state and navigate by session ID; resolved request IDs get a status + message. State reconciliation recovers requests racing new-tab registration. +- **Ask for a terminal:** after admission, an agent with no available live shell can use the existing + attention tool for preparation. The person chooses **Choose terminal / New terminal / Later**. + New terminals start private and reuse the existing participant-selection dialog. One request per + connection coalesces duplicates, with a ten-second cooldown after dismissal. Cancellation, disconnect + or an available shared shell resolves it. Before admission, the normal connection decision applies. +- **Consistent guidance:** Observe permits navigation to authorized shared tabs and attention, while + continuing to deny control/input. The control panel displays approximate lease expiry from server + state and distinguishes connection loss from shell exit. Human takeover applies to the receiving + terminal. Pending requests update the title count and native taskbar/Dock attention without command + or output payloads. Focus affects notification delivery only. System-notification actions selecting + individual requests were not added. +- **Rediscovery:** tab listings expose stable session ID, profile label and process liveness. Profile + labels are not inferred remote host/account identities. Tool guidance directs reconnecting clients + to choose the existing session and read a fresh screen, without replaying the last operation. + A fresh connection needs fresh admission and inherits no selected sharing, control or pending work + by name. +- **Output fidelity:** reproduced UI soft-wrap reflow while the core truncated columns on narrowing. + Core normal-buffer resize now preserves cells, attributes and wide-character pairs, using at most + 5,000 internal history rows like the UI; snapshots expose only the current grid. Alternate screens + resize without reflow. Wide characters retain their soft-wrap relationship, and the official + Unicode 11 addon corrects the tested emoji width mismatch in the UI. + +`ConnectionRegistry` owns live metadata and preparation. `Hub` checks available sessions. Existing +`Session` owns participation, leases and command decisions. The UI reuses sharing and decision components. + +## Validation and limits + +| Check | Result | +|---|---| +| Rust core/frontend/CLI/browser-harness | 254 passed, 1 existing ignored test. Real sockets/PTYS, admission, preparation identity/coalescing/cancellation, Observe/masks, private new shells and existing regressions | +| Frontend state checks | 48 passed, including request identity/replacement, stale replies, lease display and existing input/timeline checks | +| Shared UI + real Rust + local PTYs | 12 stages passed: new/background tabs, targeted notices, human input in another tab, stale requests, retained shell variable after reconnect, Observe, sessionless preparation/private creation/explicit sharing, same-name noninheritance, EN/KO and narrow viewport | +| Screen comparisons | Rendered rows match snapshots before narrowing, after narrowing and after restoring long paths/URLs/Korean output. 85 core/xterm grid-and-cursor comparisons passed | +| Frontend build | Passed, with existing Svelte warnings and bundle-size warning | +| Native build/acceptance | Tauri check blocked by missing `gdk-3.0` development library. Taskbar/Dock delivery, minimized/inactive/multiple windows and Mac/Windows acceptance remain unverified | +| SSH/product validation | Real SSH reconnect/reauthentication, exhaustive editor/Unicode behavior, and external first-use/reuse validation remain | +| Delivery | The [v0.8.4 release page](https://github.com/eggp-dev/conn/releases/tag/v0.8.4) records publication and signed artifacts; those checks are separate from the local evidence above | + +This does not complete the backlog's native/SSH acceptance criteria. See [reproduction commands](browser-testing.md). +Synthetic screenshots, video, states and grid comparisons remain in the temporary directory printed by each run, +outside the repository. diff --git a/docs/collaboration-refactor-plan.ko.md b/docs/collaboration-refactor-plan.ko.md index c9fe048..7320172 100644 --- a/docs/collaboration-refactor-plan.ko.md +++ b/docs/collaboration-refactor-plan.ko.md @@ -310,6 +310,8 @@ AppShell ### 추적할 후속 항목 +- 2026-09-21 실사용 피드백의 신규 항목·우선순위·완료 기준은 [제품 백로그](product-backlog.ko.md)에서 관리한다. + - TUI에서 `input_pending`이 실제 입력 상태와 어긋나는 사례의 별도 재현·설계. - 자동화 실패를 비밀값 없이 단계별로 설명하는 native 진단 개선. - 협업 범위를 벗어난 UI 경고와 기타 설정 UX 개선. diff --git a/docs/collaboration-refactor-plan.md b/docs/collaboration-refactor-plan.md index a0bacb8..896d9ad 100644 --- a/docs/collaboration-refactor-plan.md +++ b/docs/collaboration-refactor-plan.md @@ -312,6 +312,8 @@ every possible combination indiscriminately. - Address regressions with fixes or reverting the affected sprint change, never by bypassing admission or weakening private-session boundaries. +The [product backlog](product-backlog.md) tracks the 2026-09-21 field feedback, priorities and acceptance criteria. + Follow-up backlog: a separate TUI `input_pending` investigation; native staged diagnostics without payload disclosure; unrelated UI warnings and settings improvements. diff --git a/docs/getting-started.ko.md b/docs/getting-started.ko.md index 079010a..04db46f 100644 --- a/docs/getting-started.ko.md +++ b/docs/getting-started.ko.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -cask는 서명·공증된 DMG를 설치하며 릴리스와 함께 공개된 SHA-256으로 확인합니다. Linux 스크립트는 AppImage를 내려받아 릴리스의 `SHA256SUMS`와 대조한 뒤 내 계정에만 설치합니다(`~/.local/bin`, 앱 메뉴 항목, sudo 불필요). 체크섬이 맞지 않으면 설치하지 않습니다. `CONN_VERSION=v0.8.3`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). +cask는 서명·공증된 DMG를 설치하며 릴리스와 함께 공개된 SHA-256으로 확인합니다. Linux 스크립트는 AppImage를 내려받아 릴리스의 `SHA256SUMS`와 대조한 뒤 내 계정에만 설치합니다(`~/.local/bin`, 앱 메뉴 항목, sudo 불필요). 체크섬이 맞지 않으면 설치하지 않습니다. `CONN_VERSION=v0.8.4`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). 직접 파일을 받으려면: -OS와 CPU에 맞는 [v0.8.3 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.3)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. +OS와 CPU에 맞는 [v0.8.4 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.4)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. | 플랫폼 | 다운로드 | 설치 | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | -| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | +| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | 새 릴리스의 Intel Mac 배포는 잠시 중단합니다. 기존 Intel 파일은 [이전 릴리스](https://github.com/eggp-dev/conn/releases)에 유지하며 Intel Mac에 Apple Silicon 파일을 설치하지 마세요. Ubuntu에서는 다운로드한 폴더에서 실행합니다. ```sh -sudo apt install ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb ``` AppImage를 선택했다면 다음과 같이 실행합니다. ```sh -chmod +x ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage ``` Linux 빌드 대상은 Ubuntu 24.04·26.04 x64이며 AppImage에도 시스템 라이브러리가 필요합니다. Windows 프리뷰는 의도적으로 무서명 배포하므로 SmartScreen·알 수 없는 배포자 경고가 나올 수 있고 관리되는 PC에서는 설치가 차단될 수 있습니다. Mac 공개 파일은 릴리스의 Developer ID 서명·공증 검사를 통과해야 합니다. 각 대상의 범위는 [플랫폼 지원](platform-support.ko.md)을 참고하세요. -원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.3/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. +원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.4/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. ## 2. 터미널 설정 diff --git a/docs/getting-started.md b/docs/getting-started.md index f2e83f3..bcbe87a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.3` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). +The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.4` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). Or download a file yourself: -Download the [v0.8.3 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.3) for your OS and CPU. See the release notes for the exact signing and runtime verification results. +Download the [v0.8.4 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.4) for your OS and CPU. See the release notes for the exact signing and runtime verification results. | Platform | Download | Install | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | -| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.3/conn-v0.8.3-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | +| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.4/conn-v0.8.4-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | Intel Mac distribution is paused for future releases. Previously published Intel packages remain in [past releases](https://github.com/eggp-dev/conn/releases); do not install the Apple Silicon package on an Intel Mac. For Ubuntu, run the matching command in your download directory: ```sh -sudo apt install ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.deb ``` Or, for AppImage: ```sh -chmod +x ./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.3-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.4-x86_64-unknown-linux-gnu-desktop.AppImage ``` The Linux build targets Ubuntu 24.04 and 26.04 x64; AppImage still depends on system libraries. Windows previews are intentionally unsigned, so SmartScreen or an unknown-publisher prompt may appear; managed PCs may block installation. Public Mac assets must pass the release's Developer ID signing and notarization checks. See [platform support](platform-support.md) for the limits of each target. -Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.3/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. +Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.4/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. ## 2. Set up your terminal diff --git a/docs/platform-support.ko.md b/docs/platform-support.ko.md index 7f68cc9..ee21be6 100644 --- a/docs/platform-support.ko.md +++ b/docs/platform-support.ko.md @@ -2,7 +2,7 @@ [English](platform-support.md) · 한국어 · [설치](getting-started.ko.md) · [릴리스](releasing.ko.md) -Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.3)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. +Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.4)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. ## 다음 프리뷰 릴리스 대상 diff --git a/docs/platform-support.md b/docs/platform-support.md index 8592af9..8f4ac93 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -2,7 +2,7 @@ English · [한국어](platform-support.ko.md) · [Install](getting-started.md) · [Release](releasing.md) -Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.3). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. +Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.4). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. ## Targets for upcoming preview releases diff --git a/docs/product-backlog.ko.md b/docs/product-backlog.ko.md new file mode 100644 index 0000000..b42e305 --- /dev/null +++ b/docs/product-backlog.ko.md @@ -0,0 +1,140 @@ +# Conn 제품 백로그 + +[English](product-backlog.md) · 한국어 · [기존 리팩터링 계획](collaboration-refactor-plan.ko.md) + +## 2026-09-21 — 실제 원격 셸 협업 피드백 + +상태: v0.8.4 필수 협업 범위 구현 및 공통 하네스 검증 완료. [구현 범위와 남은 검증](collaboration-context-results.ko.md)을 참고한다. +아래 우선순위는 출시 일정이나 모든 완료 기준 충족을 뜻하지 않는다. +제품 목표는 **500+ GitHub stars**로 상향되었다. 기능 수보다 첫 협업 성공, 작업 맥락의 명확성, +반복 사용과 신뢰를 개선하는 데 집중한다. 이 문서는 게시·홍보 실행을 승인하는 문서가 아니다. + +### 핵심 가치와 근거 + +> 사용자가 실제 터미널에서 에이전트의 작업 위치와 흐름을 눈으로 따라가며, 필요할 때 같은 자리에서 개입할 수 있다. + +| 근거 | 보고된 내용 | 증거의 한계 | +|---|---|---| +| F01 · Copilot 작업 후기 | 실제 SSH 셸에서 화면 조회 → 제어 요청 → 명령 승인 → 읽기 전용 명령 실행 → 결과 확인 → 제어 반환 성공 | 사용자에게 전달받은 에이전트의 보고이며 이번 문서 작업에서 독립 재검증하지 않음 | +| F02 · 비교·사용성 평가 | 반복 배포·운영에서 요청·승인·결과가 이어져 편리하고, 절차가 복잡하지 않다는 평가. iTerm2 MCP보다 해당 작업에 적합하다는 의견 | 통제된 경쟁 제품 비교나 독립 사용자 연구가 아닌 에이전트의 평가 | +| F03 · 사용자 직접 관찰 | 서로 다른 계정으로 로그인된 셸 간 이동과 이동 안내를 확인. 실제 원격 셸에서 어디를 작업하는지 시각적으로 추적하는 경험을 긍정적으로 평가 | 셸 이동 관찰이며 계정·권한 경계 전체의 검증은 아님 | +| F04 · 후속 작업 후기 | 명령 의도 확인, 사람 입력 시 제어 회수, 승인 흐름에서 신뢰를 느낌. 재접속·lease·반복 실행·긴 출력에는 불편을 보고 | 주관적 신뢰를 운영 안전성 보장이나 우회 불가능성으로 확대하지 않음 | + +기존의 사람이 경로를 바로잡고 에이전트가 이어가는 사례는 [협업 이야기](collaboration-story.ko.md)에 있다. +실제 주소·계정·명령 원문·인증 응답은 이 백로그에 보관하지 않는다. 재현에는 합성 데이터를 사용한다. + +## 우선순위와 의존성 + +| ID | 우선순위 | 작업 | 출처 | 선행 확인 | +|---|---|---|---|---| +| UX-01 | P1 | 긴 경로·줄바꿈·긴 응답 렌더링 | F04 | 표시 오류와 원본 데이터 오류 구분 | +| UX-02 | P1 | 탭·호스트·계정과 이동 대상 식별 | F03, F04 | 신뢰할 수 있는 메타데이터 출처 | +| UX-03 | P1 | 연결·승인·lease 상태 안내 | F01, F04 | 각 상태와 기다리는 주체 분리 | +| UX-04 | P1 | 세션 없는 attention 및 상황별 알림 | F01, F03 | UX-02, UX-03 | +| UX-05 | P1 | 재접속 시 기존 셸 재발견 | F04 | UX-02, 세션 생존 여부 식별 | +| UX-08 | P1 | 사람과 에이전트의 다중 탭 작업 맥락 유지 | 사용자 추가 보고·실제 하네스 재현 | UX-02/03/04 | +| UX-06 | P2 | Observe 발견성과 관찰 흐름 | F04 | 기존 모드·참여 권한 동작 확인 | +| UX-07 | P2 · 설계 | 검토 가능한 명령 묶음 | F04 | 실패·중단·승인 단위 설계 | +| SEC-01 | 설계 조사 | Cookie·토큰 마스킹 | F04 | 공통 화면·기록·입력 경계 정의 | +| PROD-01 | P2 · 검증 | 외부 사용자의 첫 협업·재사용 검증과 데모 | F01–F04 | 주요 P1 흐름 정리 | + +### UX-01 · 긴 출력의 표시 정확성 + +- 문제: 긴 경로와 OAuth 응답이 줄바꿈 때문에 깨져 보인다는 보고. +- 범위: 실제 출력, 터미널 그리드, 렌더링, 에이전트 스냅샷을 비교해 원인을 분리한다. +- 완료 기준: 합성 긴 경로·URL·토큰 형태 문자열, 한글/유니코드, 붙여넣기, 창 크기 변경, + 로컬/SSH 출력에서 문자 유실·중복·순서 오류를 검증한다. 의도적인 소프트 랩은 오류와 구분한다. + 숨김 입력을 수집하거나 가시성 규칙을 완화해 해결하지 않는다. + +### UX-02 · 어느 셸인지 명확하게 + +- 문제: 셸 간 이동은 자연스럽지만 서로 다른 계정·호스트의 작업 대상을 더 분명히 보여줄 필요가 있다. +- 범위: 탭 이름, 이동 안내, 요청 카드의 대상 표현을 공통화한다. +- 완료 기준: 서로 다른 두 계정/호스트의 셸을 오갈 때 요청과 결과의 대상을 구분할 수 있다. + 호스트·계정은 확인 가능한 정보만 표시하며 알 수 없을 때 추측하지 않는다. + 탭 이름 변경·종료·재연결에도 안정적인 세션 식별자를 따른다. 공유 화면을 불필요하게 복잡하게 만들지 않는다. + +### UX-03 · 대기 상태와 lease 안내 + +- 문제: 연결 승인, 제어 승인, 명령 검토, lease 만료가 사용자와 에이전트에게 번거롭거나 불명확하다. +- 범위: 각 상태의 이유, 기다리는 주체, 다음 행동과 제어권 만료 정보를 일관되게 표현한다. +- 완료 기준: 만료 전후·갱신·거절·취소·사람 입력으로 회수되는 경우를 검증한다. + 표시 시간은 서버 상태와 일치하고 오래된 승인 UI가 되살아나지 않는다. + 대기 중인 사용자에게 필요한 행동을 안내하며 상시 카운트다운 등 시각적 소음은 피한다. + 기본 TTL 변경이나 관찰만으로 자동 갱신하는 정책은 별도 결정한다. + +### UX-04 · 준비 요청과 알림 + +- 문제: 접근 가능한 탭이 없으면 에이전트가 터미널 준비를 부탁하기 어렵다. 다른 셸에서의 요청도 놓치기 쉽다. +- 범위: 기존 attention의 세션 없는 요청 가능 여부를 확인하고 확장한다. + 연결 승인 전에는 기존 admission 카드, 승인 후 접근 가능한 셸이 없으면 터미널 선택 요청을 사용한다. +- 완료 기준: 탭 없음·모두 비공개·다른 탭·앱 비활성 상태에서 요청의 대상을 찾아갈 수 있다. + `터미널 선택 / 새 터미널 / 나중에` 또는 이에 준하는 간결한 행동을 제공한다. + 클릭은 해당 셸·요청으로 이동할 뿐 자동 공유·승인·제어 획득을 하지 않는다. + 이동은 조용한 화면 내 안내, 사용자 행동이 필요한 대기는 더 명확한 알림으로 구분한다. + 완료·막힘 알림은 확인 가능한 상태 또는 에이전트 보고로 구분하며 입력 전달을 성공으로 오인하지 않는다. + 중복·폭주 요청을 합치고 해결·취소된 요청을 정리한다. 시스템 알림에 명령·출력·인증정보를 기본 노출하지 않는다. + 창을 강제로 활성화하거나 사용자의 입력 포커스를 빼앗지 않는다. + +### UX-05 · 기존 셸 재발견과 복구 + +- 문제: 연결이 끊긴 뒤 기존 원격 셸을 다시 찾는 과정이 번거롭다. +- 범위: 에이전트 연결 단절, SSH 단절, 셸 종료를 구분하고 살아 있는 세션을 재발견하게 한다. +- 완료 기준: 같은 세션이 살아 있으면 계정·작업 상태를 유지한 채 다시 선택할 수 있다. + 종료된 셸을 새로 열었을 때 복구된 셸처럼 표현하지 않는다. + 새 연결 ID에 이전의 공유 선택·제어권·미해결 승인을 자동 상속하지 않는다. + 재인증은 사용자에게 맡기며 자격 증명을 저장하거나 재주입하지 않는다. + +### UX-06 · Observe 사용 흐름 + +- 문제: 읽기 전용 관찰 모드를 요청받았으나 기존 Observe가 있다. +- 범위: 기능 추가 전, 발견성 문제인지 실제 접근 제한 문제인지 사용 시나리오로 확인한다. +- 완료 기준: 공유된 셸을 읽고 Observe의 제한을 이해할 수 있다. 관찰만으로 비공개 셸 접근이나 + 제어·입력 권한을 얻지 않는다. 필요한 경우 기존 설정·안내를 개선하고 모드를 중복 추가하지 않는다. + +### UX-08 · 다중 탭 협업 맥락 + +- 재현: 사람이 1번 탭을 보는 동안 에이전트가 2·3번 탭을 열고 작업하면 요청 카드는 해당 탭에만 + 나타나고, 알림 클릭은 사람이 보고 있던 탭의 패널을 열었다. 백그라운드 탭 사이 이동도 놓치기 쉬웠다. +- 범위: 다른 탭의 대기 요청과 살아 있는 연결의 최근 작업 대상을 계속 표시하고, 알림을 안정적인 + 세션·요청 ID에 연결한다. 새 탭 등록 중 발생한 요청도 상태 조회로 복구한다. +- 완료 기준: 사용자가 선택한 탭은 유지되고, 안내를 클릭하면 정확한 셸과 요청으로 이동한다. + 닫힌 탭·해결된 요청·같은 이름의 새 연결을 구분한다. 이동 자체는 승인·공유·제어 획득을 하지 않는다. + 사람 입력에 의한 회수가 현재 터미널에만 적용된다는 점을 이해할 수 있다. + +### UX-07 · 명령 묶음 실행 + +- 문제: 한 줄씩 요청·입력·실행하는 과정이 반복 작업을 방해한다. +- 범위: 사용자가 전체 실행 내용을 검토할 수 있는 제한된 묶음 실행을 먼저 설계한다. +- 완료 기준: 명령별 정책 평가, 승인 범위, 오류 시 정지/계속, 부분 완료 표시를 정의한다. + 사람 입력·제어 회수·공유 중단·연결 단절 시 남은 실행이 취소되는지 검증한다. + 인증/TUI 입력을 일반 명령 묶음으로 취급하지 않는다. 읽기 전용 또는 안전한 명령이라는 + 에이전트의 주장만으로 정책을 생략하지 않는다. 기존 복합 명령 지원과 중복되는지도 확인한다. + +### SEC-01 · 민감 정보 마스킹 조사 + +- 문제: curl의 Cookie·토큰 등이 보이는 출력이나 기록에 남을 수 있다는 우려와 자동 마스킹 요청. +- 범위: 공통 화면의 선택적 마스킹, 알림 최소화, 기록 경계를 설계한다. 구현 방식은 미정이다. +- 완료 기준: 사람과 에이전트의 공통 화면 원칙을 유지하며 스냅샷·타임라인·알림·진단·복사 경로를 검토한다. + 합성 데이터로 줄바꿈·분할 출력·ANSI·중복 값·오탐/미탐을 시험한다. + 화면 마스킹이 셸 history·프로세스 인자·원격 로그의 제거를 보장하지 않음을 명시한다. + 숨김/별표 입력을 수집하지 않고, 정규식만으로 비밀 탐지가 완전하다고 주장하지 않는다. + +### PROD-01 · 가치 재현과 외부 사용자 검증 + +- 범위: 사람이 인증하고, 에이전트가 확인하고, 사람이 작업할 셸이나 방향을 보정한 뒤 + 승인·실행·제어 반환으로 이어지는 데모/온보딩 시나리오를 정리한다. +- 완료 기준: EN/KO 자료에서 실제 동작과 재현을 구분한다. 처음 쓰는 사용자의 첫 협업 성공, + 개입 성공, 막힌 지점, 다음 작업에서 재사용 여부를 동의하에 확인한다. + 수집하는 것은 사용성 결과이며 명령·출력·인증정보를 자동 수집하는 분석 기능을 뜻하지 않는다. + 경쟁 우위·안전성·쉬운 사용을 단일 에이전트 후기만으로 보장하지 않는다. + +## 작업 순서와 관리 + +1. UX-01을 재현하고 UX-02/03의 공통 상태·표현을 정리한다. +2. UX-04/05로 첫 연결과 재접속의 막힘을 줄인다. +3. UX-06을 점검하고 UX-07/SEC-01을 별도 설계한다. PROD-01로 반복 사용 근거를 모은다. + +각 항목은 구현 시 담당 변경/이슈와 재현 환경, 관찰 결과를 연결하고 `미착수 → 설계 → 구현 → 검증 → 완료`로 갱신한다. +실기 검증이 남아 있으면 완료와 구분한다. 이전 TUI 입력 문제와 네이티브 인수 검증은 +[기존 리팩터링 계획](collaboration-refactor-plan.ko.md)의 별도 후속 과제로 유지한다. diff --git a/docs/product-backlog.md b/docs/product-backlog.md new file mode 100644 index 0000000..69b4662 --- /dev/null +++ b/docs/product-backlog.md @@ -0,0 +1,146 @@ +# Conn product backlog + +English · [한국어](product-backlog.ko.md) · [Existing refactor plan](collaboration-refactor-plan.md) + +## 2026-09-21 — Feedback from real remote-shell collaboration + +Status: essential collaboration scope implemented and verified in the shared harness for v0.8.4. +See [scope and remaining validation](collaboration-context-results.md). Priorities do not imply release +commitments or completion of every acceptance criterion. +The product goal is now **500+ GitHub stars**. Prioritize first collaboration success, clear work +context, repeat use and trust over feature count. This document does not authorize publication or promotion. + +### Core value and evidence + +> A person can follow where an agent works in a real terminal and intervene in that same work context. + +| Source | Report | Evidence boundary | +|---|---|---| +| F01 · Copilot session report | Screen read → control request → command approval → read-only command → result verification → release worked in a real SSH shell | Agent report relayed by the user, not independently rerun while writing this backlog | +| F02 · Comparative usability feedback | Connected requests, approvals and results felt convenient for repeated deployment/operations; the agent preferred it to iTerm2 MCP for that work and described the steps as simple | Not a controlled comparison or independent user study | +| F03 · Direct user observation | Movement between shells logged into different accounts, with a movement notice; visible tracking of work in real remote shells was valuable | Evidence of shell navigation, not exhaustive account/permission isolation testing | +| F04 · Follow-up report | Intent review, human takeover and approval flow inspired confidence; reconnecting, leases, repeated execution and long output caused friction | Perceived trust is not a safety guarantee or proof that bypass is impossible | + +The earlier human correction of a working directory is documented in the [collaboration story](collaboration-story.md). +Do not retain real addresses, accounts, command payloads or authentication responses here. Reproduce with synthetic data. + +## Priorities and dependencies + +| ID | Priority | Work | Source | First dependency | +|---|---|---|---|---| +| UX-01 | P1 | Long paths, wrapping and response rendering | F04 | Separate display defects from data corruption | +| UX-02 | P1 | Tab, host, account and navigation identity | F03, F04 | Reliable metadata sources | +| UX-03 | P1 | Connection, approval and lease state guidance | F01, F04 | Separate states and who is waiting | +| UX-04 | P1 | Sessionless attention and contextual notifications | F01, F03 | UX-02, UX-03 | +| UX-05 | P1 | Rediscover an existing shell after reconnecting | F04 | UX-02, session-liveness identity | +| UX-08 | P1 | Preserve context across human and agent tabs | Additional user report and real harness reproduction | UX-02/03/04 | +| UX-06 | P2 | Discoverability and flow of Observe | F04 | Audit existing mode/participation behavior | +| UX-07 | P2 · design | Reviewable command batches | F04 | Failure, cancellation and approval boundaries | +| SEC-01 | design investigation | Cookie/token masking | F04 | Shared screen, recording and input boundaries | +| PROD-01 | P2 · validation | New-user collaboration, repeat use and demo | F01–F04 | Main P1 flows | + +### UX-01 · Display fidelity for long output + +- Problem: long paths and OAuth responses reportedly look broken when wrapped. +- Scope: compare actual output, terminal grid, rendering and agent snapshots to locate the defect. +- Acceptance: synthetic long paths, URLs, token-shaped strings, Korean/Unicode, paste, resize and + local/SSH output exercise character loss, duplication and ordering. Distinguish intentional soft + wrapping from corruption. Do not collect hidden input or weaken visibility rules as a fix. + +### UX-02 · Identify the target shell + +- Problem: navigation feels natural, but different accounts/hosts need clearer target identity. +- Scope: share target presentation across tab titles, movement notices and request cards. +- Acceptance: requests and results remain distinguishable across two accounts/hosts. Display only + established host/account metadata; represent unknown values without guessing. Follow stable session + identity across renames, exit and reconnection. Keep the shared UI visually simple. + +### UX-03 · Explain waiting and lease state + +- Problem: admission, control approval, command review and lease expiry create friction or ambiguity. +- Scope: consistently explain the reason, waiting actor, next action and lease expiration. +- Acceptance: cover renewal, expiry, denial, cancellation and human takeover. Remaining time agrees + with server state; stale approvals never reappear. Show the needed action without a noisy permanent + countdown. Default TTL changes and read-triggered renewal require separate policy decisions. + +### UX-04 · Preparation requests and notifications + +- Problem: without an accessible tab, the agent cannot easily ask the human to prepare one; requests + in other shells can be missed. +- Scope: inspect and extend existing attention support for a sessionless request. Use admission before + connection approval, and a terminal-selection request after admission when no shell is accessible. +- Acceptance: cover no tabs, all-private tabs, another selected tab and an inactive app. Offer concise + actions equivalent to `Choose terminal / New terminal / Later`. Clicking navigates to the target; + it never automatically shares, approves or grants control. Keep movement notices quiet; make required + human action more prominent. Distinguish established completion/blockage from agent-reported status; + delivering input does not establish command success. Coalesce repeated requests and clear resolved + or cancelled ones. System notifications omit command/output/authentication payloads by default. + Do not forcibly activate a window or steal keyboard focus. + +### UX-05 · Rediscover and recover existing shells + +- Problem: finding tabs/sessions again after a disconnection is cumbersome. +- Scope: distinguish agent transport loss, SSH disconnection and shell exit; rediscover surviving sessions. +- Acceptance: a surviving session retains account/process state and can be selected again. A newly + created shell is not presented as the old session restored. New connection IDs do not automatically + inherit sharing selection, control or pending approval. The human handles reauthentication; + credentials are not stored or reinjected. + +### UX-06 · Observe flow + +- Problem: read-only observation was requested, although Observe already exists. +- Scope: determine whether the issue is discoverability or an access restriction before adding a feature. +- Acceptance: users can read an explicitly shared shell and understand Observe's limits. Observation + does not grant access to private shells or input/control authority. Improve existing settings/guidance + where needed rather than duplicating the mode. + +### UX-08 · Context across multiple tabs + +- Reproduced: while the person stayed in tab 1 and the agent opened/worked in tabs 2 and 3, + request cards appeared only in the target tab. Clicking a notice opened the person's current + tab panel instead. Background-to-background moves were easy to miss. +- Scope: persistently show pending requests elsewhere and live connections' most recently used + terminal; address notices by stable session/request IDs. Recover requests during tab startup. +- Acceptance: preserve the person's selected tab until they choose to navigate. Notices lead to + the correct shell/request; closed tabs, resolved requests and same-name new connections remain + distinct. Navigation grants no sharing, decision or control. Explain that typing reclaims only + the terminal receiving that input. + +### UX-07 · Command batches + +- Problem: repeated single-line typing and execution interrupt routine work. +- Scope: first design a bounded batch whose complete execution content the human can review. +- Acceptance: define per-command policy checks, approval scope, stop/continue on error and partial + completion. Verify cancellation of remaining commands on human input, takeover, stop-sharing or + disconnection. Authentication/TUI interaction is not a command batch. An agent calling a command + safe or read-only does not bypass policy. Check overlap with existing compound-command support. + +### SEC-01 · Investigate sensitive-content masking + +- Problem: concern about visible or recorded curl Cookies/tokens, with a request for automatic masking. +- Scope: design optional shared-screen masking, minimal notifications and recording boundaries; + implementation mechanism remains undecided. +- Acceptance: preserve the common human/agent screen model and review snapshot, timeline, notification, + diagnostic and clipboard paths. Use synthetic wrapped/chunked/ANSI output, repeated values and + false-positive/negative cases. Explain that display masking does not remove shell history, process + arguments or remote logs. Never collect hidden/masked input or claim regex detection is complete. + +### PROD-01 · Reproduce the value and validate with new users + +- Scope: build a demo/onboarding scenario where the human authenticates, the agent observes, the human + corrects the target shell or direction, then approval, execution and control return follow. +- Acceptance: EN/KO materials distinguish observed behavior from reenactment. With participants' + consent, assess first collaboration success, intervention success, friction and reuse on a later task. + These are usability outcomes, not permission to automatically collect commands, output or credentials. + Do not establish superiority, safety or ease of use from a single agent's testimonial. + +## Sequence and tracking + +1. Reproduce UX-01; align shared state/presentation for UX-02/03. +2. Use UX-04/05 to remove first-connection and reconnection friction. +3. Audit UX-06; design UX-07/SEC-01 separately. Use PROD-01 to gather repeat-use evidence. + +When work starts, link changes/issues, reproduction environments and observations to each ID. +Track `not started → design → implementation → validation → done`; keep pending native acceptance +explicit. Earlier TUI input issues and native acceptance remain separate follow-ups in the +[existing refactor plan](collaboration-refactor-plan.md). diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index 7efa2c7..118e408 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -1,7 +1,7 @@ { "name": "conn-desktop", "private": true, - "version": "0.8.3", + "version": "0.8.4", "type": "module", "scripts": { "dev": "vite", @@ -10,13 +10,16 @@ "test:browser": "node scripts/browser-test.mjs", "test": "node tests/timeline/events.test.ts && node tests/icon/state.test.ts && node tests/connections/status.test.ts && node tests/terminal/input.test.ts && node tests/collaboration/state.test.ts", "test:collaboration": "node scripts/collaboration-test.mjs", - "test:collaboration:harness": "node scripts/collaboration-harness-test.mjs" + "test:collaboration:harness": "node scripts/collaboration-harness-test.mjs", + "test:context:harness": "node scripts/context-harness-test.mjs", + "test:screen": "node scripts/screen-parity-test.mjs" }, "dependencies": { "@conn/brand": "*", "@conn/themes": "*", "@tauri-apps/api": "^2", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "0.9.0", "@xterm/xterm": "^6.0.0" }, "devDependencies": { diff --git a/frontends/tauri/scripts/context-harness-test.mjs b/frontends/tauri/scripts/context-harness-test.mjs new file mode 100644 index 0000000..c568181 --- /dev/null +++ b/frontends/tauri/scripts/context-harness-test.mjs @@ -0,0 +1,132 @@ +// Production UI, Rust owner boundary, real PTYs and persistent agent sockets. +// Synthetic fixtures only. Evidence lives outside the checkout by default. +import {chromium} from 'playwright'; +import {spawn} from 'node:child_process'; +import {mkdtempSync,writeFileSync,existsSync,mkdirSync,openSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import net from 'node:net'; +import {strict as assert} from 'node:assert'; +const dir=mkdtempSync(join(tmpdir(),'conn-context-')); +const output=process.env.CONN_CONTEXT_OUTPUT ?? join(dir,'evidence');mkdirSync(output,{recursive:true}); +const port=process.env.CONN_HARNESS_UI_PORT??'1461',backend=process.env.CONN_HARNESS_PORT??'1463'; +const env={...process.env,CONN_TEST_PORT:backend,CONN_TEST_ORIGIN:`http://127.0.0.1:${port}`,CONN_TEST_STATE:dir}; +writeFileSync(join(dir,'app.json'),JSON.stringify({mode:'autopilot',gate:true,pacing:{enterGraceMs:0,minWriteIntervalMs:0,leaseTtlSecs:120,approvalTtlSecs:120}})); +writeFileSync(join(dir,'profiles.json'),JSON.stringify({version:1,revision:0,defaultProfile:'fixture',profiles:[{id:'fixture',name:'Fixture',program:'/bin/bash',args:['--noprofile','--norc'],cwd:dir}]})); +const log=openSync(join(dir,'harness.log'),'w'); +const server=spawn(resolve('../../target/debug/conn-browser-harness'),[dir],{env,stdio:['ignore',log,log]}); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +let vite,browser,context,page;const clients=[],errors=[],stages=[]; +async function connect(name){ + const socket=net.createConnection(join(dir,'conn.sock'));let seq=0,buffer='';const pending=new Map(); + await new Promise((r,j)=>{socket.once('connect',r);socket.once('error',j)}); + socket.on('data',chunk=>{buffer+=chunk;let at;while((at=buffer.indexOf('\n'))>=0){const m=JSON.parse(buffer.slice(0,at));buffer=buffer.slice(at+1);const p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Object.assign(Error(m.error.message),m.error)):p.resolve(m.result)}}}); + const client={call:(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(Error(`RPC timeout: ${method}`))},18000);pending.set(id,{resolve,reject,timer});socket.write(JSON.stringify({id,method,params})+'\n')}),close(){for(const p of pending.values())clearTimeout(p.timer);socket.destroy();}}; + clients.push(client);client.hello=await client.call('hello',{kind:'agent',agentId:name});return client; +} +const state=()=>page.evaluate(async()=>{const {st}=await import('/src/lib/store.svelte.ts');return {active:st.active,order:st.order,tabs:st.tabs,activity:st.activity,notice:st.announcement};}); +async function capture(name,extra={}){await delay(500);stages.push({name,...await state(),...extra});writeFileSync(join(output,'states.json'),JSON.stringify(stages,null,2));await page.screenshot({path:join(output,`${name}.png`)});console.log(`PASS ${name}`);} +async function until(test,what){for(let i=0;i<100;i++){if(await test())return;await delay(100)}throw Error(`Timed out: ${what}`);} +async function allow(agent){await page.locator('.decision-card .primary').click();await until(async()=>(await state()).activity.connections.some(a=>a.connId===agent.hello.conn),'admission resolved');} +// fill() cannot deliver terminal input; keyboard insertion emits the real onData path. +async function typeHuman(text){const input=page.locator('.xterm-helper-textarea:visible');await input.click();await page.keyboard.insertText(text);await input.press('Enter');await delay(300);} +async function select(id){const s=await state();await page.getByRole('tab').nth(s.order.indexOf(id)).click();await delay(250);} +async function shareDialog(){await page.locator('button.dot:not(.hidden), .sharing-trigger').first().click();await page.locator('.sharing-row button').click();await page.locator('.sharing').waitFor();} +async function stopSharing(){await page.locator('.pill').waitFor({state:'detached'});await shareDialog();await page.locator('.sharing footer .ghost').click();await page.locator('.sharing').waitFor({state:'detached'});await until(async()=>{const s=await state();return !s.tabs[s.active].shared},'sharing stopped');} +async function shareSelected(){await page.locator('.sharing input[type=checkbox]').first().check();await page.locator('.sharing footer .btn:not(.ghost)').click();await page.locator('.sharing').waitFor({state:'detached'});} +async function grantBackground(agent,session){ + const pending=agent.call('request_control',{reason:'Print a synthetic fixture marker.'});pending.catch(()=>{}); + await until(async()=>!!(await state()).tabs[session]?.ctlReq,'background request'); + assert.notEqual((await state()).active,session); + await page.locator('.pill').click();await until(async()=>(await state()).active===session,'notice navigation'); + await until(async()=>page.evaluate(()=>document.activeElement?.getAttribute('data-request-key')?.includes(':control:')),'focus exact request card'); + assert.equal((await state()).tabs[session].controller.type,'human','navigation never grants'); + await page.locator('.decision-card .primary').click();await pending; +} +async function marker(agent,text){ + await agent.call('type',{text:`printf '%s\\n' ${text}`}); + const result=await agent.call('send_key',{key:'ENTER',intent:'Print synthetic test output.'});assert.equal(result.status,'executed'); + await until(async()=>(await agent.call('snapshot')).screen.some(r=>r===text),'standalone output'); +} +async function grid(agent,label){ + await delay(700); + const snapshot=await agent.call('snapshot'); + const visible=await page.locator('.xterm-rows:visible > div').allTextContents(); + const rows=visible.map(row=>row.replace(/\u00a0/g,' ').trimEnd()); + const result={label,size:snapshot.size,core:snapshot.screen,visible:rows,equal:JSON.stringify(snapshot.screen)===JSON.stringify(rows)}; + writeFileSync(join(output,`grid-${label}.json`),JSON.stringify(result,null,2));return result; +} +try{ + for(let i=0;!existsSync(join(dir,'connection.json'));i++){if(i>100||server.exitCode!==null)throw Error(`Rust startup failed: ${readFileSync(join(dir,'harness.log'),'utf8')}`);await delay(100)} + vite=spawn(process.execPath,['node_modules/vite/bin/vite.js','--mode','browser-test','--host','127.0.0.1','--port',port,'--strictPort'],{env,stdio:['ignore',log,log]}); + await until(async()=>{try{return (await fetch(`http://127.0.0.1:${port}/`)).ok}catch{return false}},'Vite'); + browser=await chromium.launch();context=await browser.newContext({viewport:{width:1440,height:960},recordVideo:{dir:output,size:{width:1440,height:960}}}); + await context.addInitScript(()=>localStorage.setItem('ss:lang','ko'));page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message)); + await page.goto(`http://127.0.0.1:${port}/`);await page.locator('.xterm-helper-textarea').waitFor();await delay(500); + await typeHuman('export CONN_CONTEXT_MARKER=retained_shell_42');const first=(await state()).active; + const agent=await connect('context-agent');assert.equal(agent.hello.admission,'pending');await allow(agent); + const second=(await agent.call('open_tab',{reason:'Synthetic second terminal.'})).session; + await until(async()=>(await state()).order.includes(second),'new tab');assert.equal((await state()).active,first); + await capture('01-new-tab-notice');assert.equal((await state()).notice.target.session,second); + await page.locator('.pill').waitFor({state:'detached'});await grantBackground(agent,second);await capture('02-notice-target-and-explicit-grant'); + await select(first);await marker(agent,'BACKGROUND_TWO');await page.locator('.collaboration-activity button').waitFor(); + await typeHuman("printf '%s\\n' HUMAN_FIRST");assert.equal((await agent.call('status')).controller.type,'agent');await capture('03-visible-background-activity'); + await agent.call('snapshot',{session:first});await until(async()=>(await state()).activity.connections.some(a=>a.session===first),'latest read target'); + assert.equal(await page.locator('.collaboration-activity button').count(),1,'control elsewhere remains visible after reading this tab'); + await page.locator('.collaboration-activity button').first().click();await until(async()=>(await state()).active===second,'activity navigation');await typeHuman("printf '%s\\n' HUMAN_SECOND");assert.equal((await agent.call('status')).controller.type,'human'); + await select(first);const third=(await agent.call('open_tab')).session;await until(async()=>(await state()).order.includes(third),'third tab'); + await page.locator('.pill').waitFor({state:'detached'});await agent.call('switch_tab',{tab:second}); + await until(async()=>(await state()).notice?.target?.session===second,'background movement notice');assert.equal((await state()).active,first);await capture('04-background-to-background-move'); + // Explicitly addressed operations update activity without polling reverting it. + await agent.call('snapshot',{session:third});await agent.call('list_tabs'); + await until(async()=>(await state()).activity.connections.some(a=>a.session===third),'explicit session activity'); + // Clear an old request while its notice remains; clicking may navigate but never grants. + await agent.call('switch_tab',{tab:second});const cancelled=agent.call('request_control',{reason:'Cancel this request by disconnecting.'});cancelled.catch(()=>{}); + await until(async()=>!!(await state()).tabs[second].ctlReq,'request before disconnect');agent.close(); + await until(async()=>!(await state()).tabs[second].ctlReq,'disconnect clears request'); + if(await page.locator('.pill').count())await page.locator('.pill').click();assert.equal((await state()).tabs[second].controller.type,'human'); + // Same name reconnect is a fresh admission; select and inspect the existing shell. + const again=await connect('context-agent');assert.equal(again.hello.admission,'pending');await allow(again); + assert.notEqual(again.hello.conn,agent.hello.conn);await again.call('switch_tab',{tab:first});await select(first); + await typeHuman('printf "%s\\n" "$CONN_CONTEXT_MARKER"'); + await until(async()=>(await again.call('snapshot')).screen.includes('retained_shell_42'),'same PTY after reconnect');await capture('05-reconnect-retains-shell'); + // Long paths, URLs, Korean and line wrapping are compared with actual DOM rows. + const path='/synthetic/'+('deep-path/'.repeat(24))+'끝'; + const url='https://example.invalid/?synthetic='+('abcdef0123456789'.repeat(16)); + await typeHuman(`printf '\\033[2J\\033[H%s\\n%s\\n%s\\n' '${path}' '${url}' '한글과 English: 가나라마바사 아자차카타파하'`); + const before=await grid(again,'before');assert.ok(before.equal,'initial current grid matches rendered rows'); + await page.setViewportSize({width:860,height:720});const narrow=await grid(again,'narrow'); + await page.setViewportSize({width:1440,height:960});const restored=await grid(again,'restored'); + if(process.env.CONN_GRID_INVESTIGATE!=='1'){assert.ok(narrow.equal,'narrow current grid matches rendered rows');assert.ok(restored.equal,'restored current grid matches rendered rows')} + await capture('06-output-resize',{grid:{before:before.equal,narrow:narrow.equal,restored:restored.equal}}); + await page.locator('.pill').waitFor({state:'detached'});await page.locator('button.dot:not(.hidden)').click(); + await page.locator('.center .seg button').filter({hasText:'Observe'}).click(); + await until(async()=>(await again.call('status')).mode==='observe','Observe mode');await page.locator('.center').press('Escape'); + await again.call('snapshot');await assert.rejects(()=>again.call('request_control'),e=>e.code==='wrong_mode'); + await again.call('switch_tab',{tab:third});await again.call('switch_tab',{tab:first});await select(third);await again.call('request_attention',{reason:'Read-only inspection completed.'}); + await until(async()=>(await state()).notice?.target?.session===first,'Observe attention');await page.locator('.pill').click();await until(async()=>(await state()).active===first,'Observe request target');await capture('06b-observe-navigation'); + // Remove all sharing. Preparation stays connection-scoped and reveals no shell. + for(const id of [first,second,third]){await select(id);await stopSharing();} + assert.equal((await again.call('list_tabs')).tabs,0); + const request=await again.call('request_attention',{reason:'Choose a terminal for the synthetic fixture.'});assert.equal(request.scope,'connection'); + assert.equal((await again.call('request_attention',{reason:'duplicate'})).requestId,request.requestId); + await page.locator('.terminal-preparation').waitFor();await capture('07-sessionless-request'); + await page.setViewportSize({width:780,height:640});await page.emulateMedia({reducedMotion:'reduce'}); + await page.evaluate(async()=>{const {setLang}=await import('/src/lib/i18n.svelte.ts');setLang('en')});await capture('07b-preparation-english-narrow'); + assert.equal(await page.locator('.terminal-preparation .primary').innerText(),'Choose terminal'); + const card=await page.locator('.terminal-preparation').boundingBox();assert.ok(card.x>=0 && card.x+card.width<=780 && card.y+card.height<=640); + await page.evaluate(async()=>{const {setLang}=await import('/src/lib/i18n.svelte.ts');setLang('ko')});await page.setViewportSize({width:1440,height:960}); + await page.locator('.terminal-preparation .primary').click();await page.locator('.sharing').waitFor();assert.equal((await again.call('list_tabs')).tabs,0); + await page.locator('.sharing header button').click();await page.locator('.sharing').waitFor({state:'detached'}); + await page.locator('.terminal-preparation .btn:not(.ghost):not(.primary)').click();await page.locator('.sharing').waitFor(); + await until(async()=>![first,second,third].includes((await state()).active),'new private tab selected');const prepared=(await state()).active;assert.equal((await state()).tabs[prepared].shared,false);assert.equal((await again.call('list_tabs')).tabs,0);await capture('08-new-private-terminal'); + await shareSelected();await until(async()=>!(await page.locator('.terminal-preparation').count()),'resolved preparation'); + await again.call('switch_tab',{tab:prepared});assert.equal((await again.call('status')).controller.type,'human');await capture('09-explicit-sharing-without-control'); + await stopSharing();again.close();await until(async()=>!(await state()).activity.connections.length,'closed connection activity'); + const fresh=await connect('context-agent');await allow(fresh);assert.equal((await fresh.call('list_tabs')).tabs,0,'same name inherits no selected sharing'); + await fresh.call('request_attention');await page.locator('.terminal-preparation').waitFor();await page.locator('.terminal-preparation .ghost').click();await until(async()=>!(await page.locator('.terminal-preparation').count()),'later dismisses'); + assert.equal((await fresh.call('request_attention')).requested,false,'dismiss cooldown');await fresh.call('request_attention',{cancel:true}); + await capture('10-no-inherited-sharing');assert.deepEqual(errors,[]); + writeFileSync(join(output,'result.json'),JSON.stringify({status:'passed',stages:stages.length,errors},null,2));console.log(`Evidence: ${output}`); +}catch(error){if(page){await page.screenshot({path:join(output,'failure.png')}).catch(()=>{});console.error(await page.locator('body').innerText().catch(()=>''))}console.error(`Evidence: ${output}`);throw error; +}finally{for(const client of clients)client.close();await context?.close();if(page)await page.video()?.saveAs(join(output,'context.webm'));await browser?.close();vite?.kill();server.kill();} diff --git a/frontends/tauri/scripts/screen-parity-test.mjs b/frontends/tauri/scripts/screen-parity-test.mjs new file mode 100644 index 0000000..6738c11 --- /dev/null +++ b/frontends/tauri/scripts/screen-parity-test.mjs @@ -0,0 +1,38 @@ +// Compare the installed production xterm parser against conn-core, without mocks. +import {createRequire} from 'node:module'; +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {strict as assert} from 'node:assert'; +globalThis.self=globalThis; +const {Terminal}=createRequire(import.meta.url)('@xterm/xterm'); +const {Unicode11Addon}=createRequire(import.meta.url)('@xterm/addon-unicode11'); +const core=spawn('cargo',['run','--quiet','--locked','-p','conn-core','--example','screen_probe'],{cwd:'../..',stdio:['pipe','pipe','inherit']}); +const lines=createInterface({input:core.stdout})[Symbol.asyncIterator](); +const call=async op=>{core.stdin.write(JSON.stringify(op)+'\n');const row=await lines.next();assert.ok(!row.done,'core probe exited');return JSON.parse(row.value);}; +let assertions=0; +async function compare(name,rows,cols,ops){ + const term=new Terminal({rows,cols,scrollback:5000,allowProposedApi:true}); + term.loadAddon(new Unicode11Addon());term.unicode.activeVersion='11'; + try{ + await call({reset:true,rows,cols}); + for(const [index,op] of ops.entries()){ + if(op.text!==undefined)await new Promise(resolve=>term.write(op.text,resolve));else term.resize(op.cols,op.rows); + const result=await call(op),b=term.buffer.active; + const expected=Array.from({length:term.rows},(_,i)=>(b.getLine(b.baseY+i)?.translateToString(true)??'').trimEnd()); + assert.deepEqual(result.screen,expected,`${name} step ${index}: ${JSON.stringify(op)}`); + assert.deepEqual(result.cursor,{row:b.cursorY,col:b.cursorX},`${name} cursor step ${index}`);assertions++; + } + }finally{term.dispose()} +} +try{ + for(const cols of [8,13,40,80]){ + const narrow=Math.max(3,Math.floor(cols/2)); + for(const text of ['abcdefghij'.repeat(13),'한글/가나다/경로/'.repeat(9),'wide:界🙂e\u0301/'.repeat(11)]){ + await compare(`wrap-${cols}-${text.slice(0,4)}`,12,cols,[{text:text+'\r\n$ '},{rows:12,cols:narrow},{rows:12,cols},{rows:6,cols:narrow},{rows:16,cols},{text:'done\r\n$ '}]); + } + } + await compare('scrollback',5,20,[{text:Array.from({length:30},(_,i)=>`row-${i}-`+'abcd'.repeat(9)).join('\r\n')+'\r\n$ '},{rows:3,cols:11},{rows:10,cols:30},{rows:15,cols:80},{text:'\x1b[3J'},{rows:20,cols:80}]); + await compare('cursor-line',8,20,[{text:'completed line before input\r\n$ '+('unsubmitted-'.repeat(4))},{rows:8,cols:12},{rows:10,cols:30}]); + await compare('alternate',6,20,[{text:'normal long line to retain\r\n$ \x1b[?1049h\x1b[Heditor\r\nlast line\x1b[6;1Hbottom'},{rows:4,cols:14},{rows:8,cols:30},{text:'\x1b[?1049l'}]); + console.log(`PASS ${assertions} core/xterm grid and cursor comparisons`); +}finally{core.stdin.end();core.kill();} diff --git a/frontends/tauri/src-tauri/capabilities/default.json b/frontends/tauri/src-tauri/capabilities/default.json index f6b38da..ddb3b02 100644 --- a/frontends/tauri/src-tauri/capabilities/default.json +++ b/frontends/tauri/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Conn windows: scoped core commands and events", "windows": ["main", "automation-*"], - "permissions": ["core:default", "core:event:default", "core:window:default"] + "permissions": ["core:default", "core:event:default", "core:window:default", "core:window:allow-request-user-attention", "core:window:allow-is-focused"] } diff --git a/frontends/tauri/src-tauri/tauri.conf.json b/frontends/tauri/src-tauri/tauri.conf.json index 34881df..d8daec7 100644 --- a/frontends/tauri/src-tauri/tauri.conf.json +++ b/frontends/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Conn", - "version": "0.8.3", + "version": "0.8.4", "identifier": "dev.eggp.conn", "build": { "beforeDevCommand": "npm run dev", diff --git a/frontends/tauri/src/App.svelte b/frontends/tauri/src/App.svelte index 1448118..d8c20a4 100644 --- a/frontends/tauri/src/App.svelte +++ b/frontends/tauri/src/App.svelte @@ -1,4 +1,8 @@ @@ -368,15 +403,15 @@ {/if} openSettings(st.settingsTab)} onpalette={() => { st.settingsOpen = false; st.centerOpen = false; st.paletteOpen = true; }} ontimeline={() => { st.timelineOpen = !st.timelineOpen; }} /> - {#if st.active} { st.paletteOpen = false; st.centerOpen = !st.centerOpen; }} />{/if} -
{#if !st.settingsOpen}{/if}{#if cur().shared}{/if}
+ {#if st.active} { st.paletteOpen = false; st.centerOpen = !st.centerOpen; }} />{/if} +
void navigateNotice({session})} />{#if !st.settingsOpen}{/if}{#if cur().shared}{/if}
{#if !!cur().shared} {/if} {#if st.centerOpen}
{ st.centerOpen = false; focusTerm(); }} />{/if} {#if st.paletteOpen} { st.paletteOpen = false; focusTerm(); }} />{/if} - {#if st.sharingOpen} { st.sharingOpen = false; focusTerm(); }} />{/if} + {#if st.sharingOpen}{#key st.active} { st.sharingOpen = false; focusTerm(); }} />{/key}{/if} {#if st.settingsOpen} { st.settingsOpen = false; queueMicrotask(focusTerm); }} />{/if} diff --git a/frontends/tauri/src/components/AdmissionRequest.svelte b/frontends/tauri/src/components/AdmissionRequest.svelte index b476ed6..a580d94 100644 --- a/frontends/tauri/src/components/AdmissionRequest.svelte +++ b/frontends/tauri/src/components/AdmissionRequest.svelte @@ -4,7 +4,8 @@ import { decideAdmission } from '../lib/collaboration/api'; import { requestAction } from '../lib/collaboration/decision.svelte'; import DecisionCard from './DecisionCard.svelte'; - const r = $derived(st.admissions[0]); + let { focused }: {focused?:number}=$props(); + const r = $derived(st.admissions.find(a=>a.connId===focused) ?? st.admissions[0]); const action = $derived(requestAction(`admission:${r?.connId ?? "none"}`)); async function decide(allow: boolean) { const request = r; @@ -16,7 +17,7 @@ {#if r} {#key r.connId} - +

{t('admission.desc')}

{#if st.admissions.length > 1}{t('admission.more', { n: st.admissions.length - 1 })}{/if} {#snippet actions()} diff --git a/frontends/tauri/src/components/Center.svelte b/frontends/tauri/src/components/Center.svelte index 6c80fed..89e8ccd 100644 --- a/frontends/tauri/src/components/Center.svelte +++ b/frontends/tauri/src/components/Center.svelte @@ -15,6 +15,9 @@ const live = $derived(liveAgents.list); $effect(() => { void st.active; return watchLiveAgents(); }); + let now=$state(Date.now()); + $effect(()=>{const timer=setInterval(()=>now=Date.now(),1000);return ()=>clearInterval(timer)}); + const leaseLeft=$derived(tb.leaseExpiresAt===undefined?null:Math.max(0,Math.ceil((tb.leaseExpiresAt-now)/1000))); const MODES = ["observe", "copilot", "autopilot"] as const; function key(e: KeyboardEvent) { if (e.key === "Escape") { e.stopPropagation(); onclose(); } } @@ -28,6 +31,9 @@ {#if isAgent}{/if} +

{t('activity.scope')}

+ {#if isAgent && leaseLeft!==null}

{t('activity.lease',{n:leaseLeft})}

{/if} + {#if !tb.processAlive}

{t('activity.exited')}

{:else if !isAgent && tb.controlReason==='expired'}

{t('activity.expired')}

{:else if !isAgent && tb.controlReason==='disconnected'}

{t('activity.disconnected')}

{/if} {#if tb.inputPending}

{t("sharing.pendingInput")}

{/if} {#if tb.shared} diff --git a/frontends/tauri/src/components/CollaborationActivity.svelte b/frontends/tauri/src/components/CollaborationActivity.svelte new file mode 100644 index 0000000..e7ce430 --- /dev/null +++ b/frontends/tauri/src/components/CollaborationActivity.svelte @@ -0,0 +1,30 @@ + +{#if waiting.length || controlled.length || elsewhere.length} + +{/if} + diff --git a/frontends/tauri/src/components/ControlRequest.svelte b/frontends/tauri/src/components/ControlRequest.svelte index 131698e..b653c60 100644 --- a/frontends/tauri/src/components/ControlRequest.svelte +++ b/frontends/tauri/src/components/ControlRequest.svelte @@ -14,7 +14,7 @@ {#if r} {#key `${st.active}:${r.id}`} - + {#if r.reason}

{r.reason}

{/if} {#snippet actions()} diff --git a/frontends/tauri/src/components/DecisionCard.svelte b/frontends/tauri/src/components/DecisionCard.svelte index bb518d4..ebe45e8 100644 --- a/frontends/tauri/src/components/DecisionCard.svelte +++ b/frontends/tauri/src/components/DecisionCard.svelte @@ -4,9 +4,9 @@ import { agentColor } from '../lib/themes'; import { t } from '../lib/i18n.svelte'; import { failureKey } from '../lib/collaboration/api'; - let { agent, label, busy = false, error = null, children, actions }: { agent: string; label: string; busy?: boolean; error?: unknown; children?: Snippet; actions: Snippet } = $props(); + let { agent, label, busy = false, error = null, requestKey, children, actions }: { agent: string; label: string; busy?: boolean; error?: unknown; requestKey?:string; children?: Snippet; actions: Snippet } = $props(); -
+
{agent}{label}
{@render children?.()} {#if error}{/if} diff --git a/frontends/tauri/src/components/Ghost.svelte b/frontends/tauri/src/components/Ghost.svelte index 688958d..53b4ee5 100644 --- a/frontends/tauri/src/components/Ghost.svelte +++ b/frontends/tauri/src/components/Ghost.svelte @@ -5,7 +5,7 @@ const proposal=$derived(cur().proposal); {#if proposal} -
+
{proposal.agentId}{t('ext.proposal')}
{#if proposal.intent}

{proposal.intent}

{/if} {proposal.text} diff --git a/frontends/tauri/src/components/Hold.svelte b/frontends/tauri/src/components/Hold.svelte index cea3782..6bcf436 100644 --- a/frontends/tauri/src/components/Hold.svelte +++ b/frontends/tauri/src/components/Hold.svelte @@ -12,7 +12,7 @@ {#if cur().approval} - +

{a.intent || t("hold.no_intent")}

{#if targets.length}
    diff --git a/frontends/tauri/src/components/Island.svelte b/frontends/tauri/src/components/Island.svelte index 508db1b..de45267 100644 --- a/frontends/tauri/src/components/Island.svelte +++ b/frontends/tauri/src/components/Island.svelte @@ -7,7 +7,7 @@ import { cmd } from "../lib/bridge"; import { st, cur, toast } from "../lib/store.svelte"; import { agentColor } from "../lib/themes"; - let { onopen }: { onopen: () => void } = $props(); + let { onopen, onnotice }: { onopen: () => void; onnotice: (target?: import("../lib/collaboration/activity").NoticeTarget) => void } = $props(); const t = $derived(cur()); let stopping = $state(false); async function stopExternal() { @@ -52,11 +52,12 @@ {label} +{/if} {#if st.announcement} {#key st.announcement.id} -