diff --git a/.agents/notes/implemented/architecture/2026-10-24-codex-wsl-unified.md b/.agents/notes/implemented/architecture/2026-10-24-codex-wsl-unified.md index eae60cc2..b6a4616f 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-codex-wsl-unified.md +++ b/.agents/notes/implemented/architecture/2026-10-24-codex-wsl-unified.md @@ -52,3 +52,5 @@ Claude WSL 复用 RemoteClaudeCli 的历史、控制和配置协议,传输使 同类会话可以统一查看,同时保留配置和执行边界;无原生 App 时 WSL 不再被原生就绪条件阻断。代价是展示聚合状态与实际操作子项必须分开,所有写入门仍以具体 endpoint 为准。停用会等待已接受的写操作返回;操作结果不确定时继续保留待核对状态,关闭接入不会退出原 CLI 或迁移任务。 Supported unmanaged WSL profiles now have [environment-scoped account management](../feature/2026-10-25-wsl-account-management.md). This supersedes only the earlier external-only account UI; target identity, passive reads and original runtime ownership remain unchanged. + +The [account presentation and platform scope correction](../bug-fix/2026-10-26-account-ui-and-platform-scope.md) supersedes the desktop editor placement and skips WSL initialization on unsupported gateways; the account inventory and endpoint identity contracts remain. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-account-ui-and-platform-scope.md b/.agents/notes/implemented/bug-fix/2026-10-26-account-ui-and-platform-scope.md new file mode 100644 index 00000000..dbf39268 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-account-ui-and-platform-scope.md @@ -0,0 +1,37 @@ +# Agent Note: Account presentation and gateway platform scope + +Status: implemented + +## Problem + +The unified account change split the desktop enrollment entry, while the phone editor lacks unsaved-draft model discovery. Desktop overview quota reads saved active rows but omits the native login card used by account management, and its hidden account panel can suppress quota loading. WSL controls and managers are initialized on gateways without WSL capability. + +## Decision + +The desktop restores the Add account or API entry and keep the unified saved inventory. The phone uses the user-approved compact grouped form with existing theme tokens. An explicit authenticated Codex draft-model request validates the original URL/key rules, queries the catalog without saving or switching, and never persists draft credentials. Empty model fields take the first returned ID; manual choices and newer edits win over late responses. + +The desktop flattens only its provider heading rows. The gateway gates WSL-specific manager imports/initialization by the gateway operating system and loads account controls only from the gateway capability. Windows endpoint identity, native routing and write guards stay intact. Desktop overview uses the same native-login identity as account management and its existing visible-page quota cadence. + +## Existing-note audit + +- [Unified local accounts](../../implemented/feature/2026-10-26-unified-codex-accounts.md) partially overlaps: this supersedes its separate desktop editor placement, retaining inventory and switch verification. +- [WSL account management](../../implemented/feature/2026-10-25-wsl-account-management.md) and [WSL unified entry](../../implemented/architecture/2026-10-24-codex-wsl-unified.md) partially overlap: endpoint routing and credentials remain unchanged; unsupported hosts skip initialization. +- Claude manual-quota notes concern a separate provider and remain unchanged. No overlapping proposed/rejected account layout decision was found. + +## Alternatives considered + +Keeping separate desktop forms reuses the CLI editor but contradicts the requested original entry. Merely hiding WSL controls preserves eager loads and environment queries on unsupported systems. Reusing another saved account's quota fills the cell but misidentifies the current login; the native login card is the authoritative alternative. + +## Testing + +Targeted tests verify draft discovery without mutations, stale/manual edits, native unsaved-login quota, flat desktop rows and Windows versus non-Windows initialization. The actual grouped editor is inspected at 320 and 390 CSS pixels in both existing themes with synthetic data; it has no horizontal overflow. Only the root agent edits source and tests; build agents receive a frozen candidate. + +## Consequences + +An upstream catalog does not establish Responses compatibility. Missing quota remains explicitly unavailable rather than inferred. Lazy WSL loading must retain Windows behavior and report load failures without disturbing local accounts. + +Repository-wide note validators still report pre-existing optimization folders and old note format failures; none of the changed notes is reported. Frontend regression: 1200 passed, one skipped. Account/platform/HTTP regression: 206 passed before additional platform lifecycle checks. + +## User follow-up + +The approved follow-up removes the outer compact disclosure style so the phone API entry uses the adjacent reminder disclosure typography. Group containers have no border; each field keeps a visible input boundary, persistent label and translated placeholder. Existing App theme tokens remain unchanged. The complete AccountsPanel nesting, rather than an isolated editor wrapper, is checked at 320/390px in light/dark themes. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-desktop-list-pin-read-token.md b/.agents/notes/implemented/bug-fix/2026-10-26-desktop-list-pin-read-token.md new file mode 100644 index 00000000..41dc8620 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-desktop-list-pin-read-token.md @@ -0,0 +1,27 @@ +# Agent Note: Successful client lists failed during pin synchronization + +Status: implemented + +## Problem + +DSH and Claude successful nonempty list responses threw `ReferenceError: pinRead is not defined` when local pin preferences were enabled. The token was declared in `refresh()` but consumed inside the separate `refreshList()` method. Tests without a pin preference object bypassed argument evaluation through optional chaining, missing this production path. Repeated automatic failures exposed the retry banner even though the connector returned a connected list. + +## Decision + +Capture the pin read token on the shared list task when the request starts, and consume that same token while observing rows. Polling, manual refresh and prefetched reads share the request token. A local pin change after prefetch must not make the earlier response look newer. + +## Existing-note audit + +[Local first pins](../feature/2026-10-26-local-first-session-pins.md) defines the token and stale-read contract; this repairs its cross-method integration. [Shared list refresh](../feature/2026-10-12-shared-client-list-refresh.md) defines request sharing and failure feedback; its genuine disconnect/error behavior is retained. Neither existing note describes this missing variable integration failure. + +## Alternatives considered + +Suppressing the retry banner would conceal genuine network failures. Capturing the token only when a view joins an existing request would misdate prefetched responses. Replacing connector polling does not address the reproduced frontend exception. + +## Testing + +Before the fix, connected list fixtures with pin preferences reproduced the exact ReferenceError for both providers. Four regression cases cover normal and prefetched requests for both providers. Related UI tests: 220 passed. Full frontend: 1204 passed, one skipped. No real session, pin, message or archive mutation was used. + +## Consequences + +Successful reads update the list and clear failure state normally. Genuine connector errors continue to produce retry feedback. The reproduction establishes a concrete frontend cause; it does not rule out separate network failures. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-native-session-archive-feedback.md b/.agents/notes/implemented/bug-fix/2026-10-26-native-session-archive-feedback.md new file mode 100644 index 00000000..ac5f8283 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-native-session-archive-feedback.md @@ -0,0 +1,72 @@ +# Agent Note: Native archive ownership and reversible provider support + +Status: implemented + +## Problem + +An idle Codex App thread still holds its native writer lock. A second short app-server can read its metadata but cannot archive it. The Bridge masks a definite native rejection as an unknown write. Claude Code Desktop and DSH also have reversible native archive interfaces which are absent from the shared management routes. + +## Decision + +Codex keeps original-owner boundaries. Archive RPC error responses become `ArchiveRejected`, distinct from disconnect, EOF and timeout. The gateway reads complete authorized root/descendant archive flags before and after an explicit rejection. Only identical, complete metadata removes that new pending intent and returns a definite 409 `archive_rejected` error with an actionable reason. Partial changes, missing proof and transport uncertainty remain durable unknowns. Existing unknown receipts without rejection proof are never cleared or replayed merely because the root is still unarchived; the existing native-flag success reconciliation remains read-only. + +Loaded local Codex App threads have no demonstrated external owner-native archive route in the installed follower protocol. Their secondary-runtime writer rejection directs the user to the original owning client; it is not reported as archive success. The Bridge never resumes, takes ownership, forges synchronization broadcasts, closes the App, or edits Codex database/rollout files. A writer-free saved App thread can still use its original home/runtime. Codex CLI and WSL use their existing original daemon/native connection; an independently held writer is still a native refusal. + +Claude Code Desktop exposes `mobileArchiveState` and `mobileArchive` only when the native Code surface supplies getAll/getSession/archive/unarchive. Fresh in-App metadata verifies the exact root, attached descendants, idle status, pending approvals/consents, held input, background activity and pending child work. Native archive receives only `{cleanupWorktree:false}`; restore uses native unarchive. No force/discard/stop/resume/delete is substituted. Metadata-only archived-session loading makes restored candidates visible without starting a turn. Cowork and other Claude surfaces do not inherit this Code capability; CLI file-level archive has a separate verification gate described below. + +DSH exposes native `workspaceRegistry.archiveSession(sid,{})` and `unarchiveSession(sid)` only when both methods exist. It never requests stopActivity. The registry's serialized activity-provider guard refuses active child/background work before writing. The Bridge additionally checks current running/approval evidence. A verified native active-session refusal is a known preflight non-send; other native failures remain uncertain unless separately proven. Native archive may clear DSH's own pin as a native lifecycle side effect; no native pin operation is introduced. + +`POST /api/desktop-sessions/{claude|deepseek}/archive` accepts the existing generic body identity `sessionId` plus `{archived,id}`. Provider/environment/native ID map to a private receipt identity; successful native readback returns confirmed. The manager preserves enabled/account gates and refuses queued/unknown write receipts before dispatch. Shared relay adds only the exact archive POST. Rows/details advertise `capabilities.archive` and `capabilities.unarchive` from actual reversible native support. + +Claude connector revision 11 and DSH Bridge revision 6 require updated connector capability evidence. DSH lifecycle compatibility retains revisions 3–5. The exact shipped revision-5 LF/CRLF hashes remain recognized for an explicit, ownership-checked upgrade; existing credentials, source ownership and uncertain request records are preserved. No connector installation or restart is performed by this change's tests. + +## Existing-note audit + +[Bridge session management](../../implemented/feature/2026-10-26-bridge-session-management.md) remains authoritative for Bridge-only pins, pagination and original-environment guards. This note narrows its App archive support claim with the live-writer constraint and adds native provider support. The earlier isolated archive check closed its source runtime, so it did not cover the idle-but-loaded writer case. No prior receipt is retroactively reclassified from that earlier test. + +## Alternatives considered + +- Retry through a second Codex runtime or resume the thread: crosses the original owner boundary and does not safely solve its writer lock. +- Forge thread-archived broadcasts or modify native metadata files: can change display/state without a supported owner operation. +- Hide rows in Bridge or substitute delete: does not implement native archive/restore semantics. +- Force-stop activity during provider archive: native methods can stop tasks, but the requested protection requires refusing active work instead. + +## Evidence and testing + +Exact installed Codex App 26.1021.31345 bundles runtime 0.164.0-alpha.11.1. Two simultaneous isolated runtimes reproduce the user-visible failure: the source reports idle, the secondary returns `already has an active writer`, archived stays zero. A different untouched thread archives after the source closes. An archive through the same source runtime succeeds while it remains open. The corrected production helper returns the precise typed refusal for the concurrent case. These fixtures use empty synthetic threads, no model turns, real accounts or credentials. + +Shipped Claude 3.1623.0 source confirms Code archive/unarchive and worktree options. Cowork archive has additional stop/output-cleanup effects and no matching exposed restore in that version, so it is not treated as reversible Code archive. DSH 0.2.1-rc.3 shipped registry source confirms durable archive sets, default active-work refusal and restore preserving recorded membership; no live native session is changed during inspection. + +Targeted Python tests cover explicit RPC classification on short/local-daemon/WSL channels, full subtree flags, proven-no-effect retry, partial/transport/old-unknown protection, provider archive/restore and HTTP login/CSRF/relay boundaries. Actual connector/host JavaScript fixtures verify Code descendants, consents/background/input guards, cleanupWorktree:false, DSH default activity refusal and no stopActivity. Relevant existing connector/lifecycle, adapter, migration and resource regressions pass. Real user App/Claude/DSH sessions, account files and unknown receipts remain untouched. + +## Consequences + +A known native refusal no longer appears as a lost archive write when complete metadata proves no effect. Reversible provider operations use the original native services and retain uncertainty when proof is absent. Loaded App archive is not silently claimed fixed: the installed external follower protocol lacks that owner action. Native APIs still lack an atomic cross-client conditional archive, so a foreign client can change state between separate read and mutation checks; native default refusal and original owner boundaries remain essential. + + +## Claude CLI reversible file archive + +The user approved a file-level archive after investigating the absence of a verified ordinary local CLI archive command. This is not a Bridge-only hidden flag or a native archive RPC. The original JSONL is moved outside `projects/` into the same profile's `bridge-archives//`, without rewriting its bytes. Its canonical configuration path, UID, directory device/inode, stable host identity, original project name and hash are recorded in a durable manifest; copying the package into another profile does not authorize restore. Native UUID, cwd, account/origin, and history remain those of the original transcript. Exact platform/CLI-version pairs enter `VERIFIED_PICKER_VERSIONS` only after an isolated native picker shows disappearance and reappearance; the verified macOS 2.1.292 native binary SHA-256 `97a01e5bc74a199e67189435d0331ea3a24eac2e07db4b76d9148c5b0386138f` and Linux 2.1.308 native binary SHA-256 `356733f185fa988e6bf3aa9a555269fdd167982a2e2a80d3c8a78037bb92ad58` are enabled. Unverified native distributions, JS replacements and Windows remain disabled pending separate native acceptance. Synthetic storage tests alone do not enable a pair. + +Installed 2.1.292 removes its PID record on clean exit and can conditionally skip registration, so a matching dead record is not required and an empty registry alone is not sufficient. The adapter requires the existing full-profile PID-generation-aware guard plus a fresh same-UID executable census. The census rejects known native Claude CLI identities, native version-directory executable paths and known Anthropic Node/Bun entry scripts; it inspects entry-script identity without logging prompt operands or environments. Unrelated Node/Bun development tools remain allowed. Inaccessible or unclassifiable Claude process evidence refuses the operation. The exact binary fingerprint is rechecked immediately before mutation; a program update during capability hashing disables the capability instead of breaking the list. This is a verified native-distribution boundary, not a claim to identify arbitrary renamed/custom scripts. A live Bridge executor, active/pending work, changed profile, unknown native version, or existing uncertain manager receipt prevents dispatch. No process is stopped or resumed by archive. Native registry checks are not an atomic lock against an unrelated CLI starting between check and mutation; that residual race is not represented as exclusive ownership. + +Only the primary transcript is relocated. The native UUID-private directory and `file-history/` are independently copied into the package while originals remain in place. This protects known per-session data if native orphan/retention cleanup later removes originals. Shared task lists and project memory are untouched. Restore verifies backup hashes, fills only missing private files, refuses differing existing files, and never overwrites a new transcript. An existing hard link, symlink, foreign Unix owner, or cross-filesystem link failure refuses the move. Copies do not retain inode sharing with native sidecars. + +Passive list/history state checks file type, ownership and location without hashing whole transcripts; explicit archive-state verification and mutations verify archived hashes afresh. The same-filesystem link/unlink transaction uses an exclusive private transaction directory and fsynced intent/commit records written through random mode-0600 temporary files. A preexisting predictable temporary-name symlink is never opened or overwritten, and failed replacements clean their own temporary files. Destination link creation atomically refuses existing files. An in-process unlink failure rolls back a verified duplicate; an interrupted preparing record, stale transaction directory, conflicting duplicate, or changed hash requires manual examination and is never automatically replayed. Restored native sessions may grow normally; their next explicit archive refreshes the snapshot/hash. Restore does not select a native session or run a turn. Linux durable identity uses machine ID, canonical profile path, UID and directory device/inode; the PID namespace is used only by fresh writer checks, so an ordinary distro restart does not invalidate the saved package. Linux acceptance applies only to the exact independently tested 2.1.308 native binary; Windows does not inherit this proof. + +Synthetic tests cover exact CRLF/NUL bytes and identity, list discovery after archive, restore/collision/tamper refusal, native-sidecar deletion and missing-only recovery, shared memory preservation, rearchive after native growth, active-writer refusal, hard links/symlinks, interrupted journals and unlink rollback. Root completed the actual macOS 2.1.292 picker check in an isolated synthetic profile under OS-enforced network denial: the exact 516-byte synthetic session appeared before archive, disappeared from both current-project and all-project lists after moving the primary transcript, and reappeared after exact-byte restore. Picker processes were canceled without selecting a session or running a turn. The evidence record stays in task `.tmp/claude-archive-research-20261026/manual-native-picker-acceptance.json`. Incomplete/corrupt packages produce a visible “Claude Code 归档待核对” placeholder and incomplete coverage, while valid sessions remain listed; such packages cannot be restored automatically. Real user history was not moved, and no native capability is inferred from the synthetic scan fixture. + + +### Linux native picker and writer identity evidence + +The approved already-running WSL Linux 2.1.308 binary independently passed the real native picker sequence: the synthetic title appeared before archive, disappeared from both current-project and Ctrl+A all-projects views, and reappeared after exact-byte restore. Every phase ran in a fresh user/network namespace with no egress route and IPv4 ENETUNREACH. A child-only Landlock ABI 3 allowlist proved EACCES when opening the real home and an outside synthetic sentinel before native exec. Only the approved empty synthetic project's trust and invalid dummy-key acceptance were seeded in synthetic config. No session was selected or model prompt sent; all owned native children exited after picker cancellation. Evidence is retained under task `.tmp/hands-on-feedback-20261026/claude-wsl-picker-payload/native-evidence/` and `native-linux-acceptance.json`; no real history was relocated. + +The native version-named process appeared as `comm=2.1.308`, so treating Linux `ps comm` as a full executable path missed the writer. A public sleep binary copied only into a synthetic native-version-shaped path reproduced the miss. Linux now selects candidates using known Claude names, the selected binary's 15-character basename, native version-shaped basenames, and Node/Bun names. Known Claude names refuse directly; other candidates require actual `/proc//exe` plus fresh same-UID, same PID namespace and stable start-tick evidence around the read. The ` (deleted)` suffix is removed before known-install-path classification, so a running older unlinked native version remains a writer. Only a vanished process directory is skipped; a live process with missing executable metadata, permission failure, namespace/owner change or generation change refuses. + +Ordinary noncandidate processes are not elevated into new archive prerequisites merely because their `/proc` metadata is protected. This avoids blocking all operations on an unrelated non-dumpable system service and does not add a service-name exception. The boundary remains known native distributions and known Node/Bun entry scripts, not arbitrary renamed/custom programs. The final full census allowed idle, refused the live synthetic native-version process with a specific CLI-writer reason, also refused its deleted-executable state, and allowed again after natural exit. Unit regressions cover noncandidate unreadability, candidate unknowns, selected/other versions, deleted executable identity, PID reuse and UID/namespace changes without native-candidate argument/environment reads. + +The metadata choice follows Linux [proc_pid_comm(5)](https://man7.org/linux/man-pages/man5/proc_pid_comm.5.html): comm is mutable and limited to 16 bytes including NUL. [proc_pid_exe(5)](https://man7.org/linux/man-pages/man5/proc_pid_exe.5.html) documents the actual executable path, deleted suffix, ptrace access checks and missing main-thread metadata. Fresh census and registry reads still cannot atomically prevent a different CLI starting after verification; no cross-client lifecycle lock is claimed. + +## Confirmed stop before archive follow-up + +[Confirmed disconnect and archive](../feature/2026-10-27-confirmed-disconnect-archive.md) partially supersedes the idle-only rule for explicit `disconnect:true` requests. Legacy requests and restore remain idle-only; original-owner, version, writer and uncertain-receipt restrictions remain. Loaded Codex App and unverified provider/platform limits are not removed by that follow-up. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-proxy-http-backlog-framing.md b/.agents/notes/implemented/bug-fix/2026-10-26-proxy-http-backlog-framing.md new file mode 100644 index 00000000..70c060cb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-proxy-http-backlog-framing.md @@ -0,0 +1,37 @@ +# Agent Note: Proxy HTTP startup backlog and GET framing + +Status: implemented + +## Problem + +A reverse proxy can open many upstream HTTP/1.1 sockets while loading a cold page. A small listen backlog rejects connections before handlers run. Separately, an empty chunked GET leaves framing bytes unread; persistent request parsing can interpret those bytes as the next request. These are different transport failures and require separate checks. + +## Decision + +`GatewayServer.request_queue_size` is explicitly 128. This is the pending TCP listen backlog, subject to operating-system limits, and does not increase accepted handler concurrency. All listeners retain the existing shared 48-slot semaphore and 20-second connection timeout. Local CPython 3.12 uses a default backlog of 5; newer CPython defaults can differ, so 128 is an explicit target rather than a universal increase over every Python release. + +GET processing does not consume request bodies. Before dispatch, the handler marks the connection for closure if any `Transfer-Encoding` header exists, even when empty, or any `Content-Length` value after whitespace trimming is not exactly `0`. The normal resource/API response still follows the existing route checks. The handler then closes without parsing residual body/framing bytes as another request. Ordinary bodyless GETs and canonical zero-length GETs keep connection reuse. + +Authentication, exact Host/Origin pairing, CSRF checks, active-connection admission, session mutation routing and JSON/upload framing validation are unchanged. POST still rejects chunked JSON bodies, duplicate lengths and invalid lengths. A GET body containing a syntactically valid, authenticated pin/archive POST cannot dispatch that mutation on the same connection. This is a framing boundary, not a replacement for authorization. + +## Existing-note audit + +No active note covers the listen backlog or unread GET framing. [Bridge session management](../../implemented/feature/2026-10-26-bridge-session-management.md) partially overlaps only in authenticated pin/archive route boundaries; that decision remains in force. The existing architecture security section documents the transport policy. No prior decision needs rejection or removal. + +## Alternatives considered + +- Increase accepted handler concurrency: may reduce resets under load, but changes the resource and security budget rather than the pending connection queue. +- Drain GET bodies: preserves upstream pooling, but requires bounded framing parsing and read-time limits. Closing the affected connection is the smaller fix for a gateway that does not accept GET bodies. +- Disable keep-alive for all GETs: avoids residual bodies but needlessly loses pooling for ordinary bodyless resources. + +## Testing + +The seven loopback proxy tests validate a 38-connection pending startup burst, the unchanged 48-slot accepted-connection limit, normal GET reuse, empty/chunked/invalid/duplicate-length GET closure, and rejected chunked writes. Added regressions put valid authenticated pin/archive POST bytes directly inside a framed GET body and assert no mutation dispatch and EOF. Separate authenticated POST cases exercise missing login, missing CSRF, wrong Origin, chunked encoding, duplicate/invalid lengths and successful single dispatch. + +Bounded in-process old-behavior overrides reproduce both failures without editing product source: backlog 5 times out within the fixture's two-second connect limit; the old GET handler dispatches three synthetic pin calls and three archive calls from unread GET bodies. The patched seven-test suite passes. These expected pre-change failures are separate from regression results. + +The requested existing auth, login-security, Bridge, session-management and Python resource suites pass: 157 tests, two platform skips. Node asset/relay checks pass: four tests. All sessions and native-operation targets are synthetic; no real proxy deployment, desktop process, account, chat, installation or model call is exercised by these tests. + +## Consequences + +Cold proxy startup can queue the intended burst without raising the active connection budget. Framed GETs cannot leave bytes for a subsequent HTTP request. Affected upstream sockets reconnect instead of being pooled; ordinary resource GETs retain reuse. A larger backlog does not guarantee more than 48 accepted connections or solve saturation by long-lived connections. Real proxy acceptance remains a separate deployment check. Existing binary/304 response helpers can omit an explicit close header but still close the socket; this change does not broaden their response behavior or alter protocol upgrades. diff --git a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md index 6eb23f6a..112f90a0 100644 --- a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md +++ b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md @@ -33,3 +33,5 @@ Node 回归覆盖并发读取合并、等待完成、失败与断开连接重试 ## Consequences 三个客户端共享手势及结果反馈,手动确认不增加写操作或重复轮询。代价是共享组件需要维护可重置的请求修订号,列表读取与轮询编排分开。浏览器验证不代替 Android/iOS 真机 WebView 的回弹体验验收。 + +Follow-up: [Shared list pin token integration fix](../bug-fix/2026-10-26-desktop-list-pin-read-token.md) preserves these contracts while repairing successful list processing with pin preferences enabled. diff --git a/.agents/notes/implemented/feature/2026-10-14-local-cli-account-sync.md b/.agents/notes/implemented/feature/2026-10-14-local-cli-account-sync.md index 867a8119..f23ad681 100644 --- a/.agents/notes/implemented/feature/2026-10-14-local-cli-account-sync.md +++ b/.agents/notes/implemented/feature/2026-10-14-local-cli-account-sync.md @@ -39,3 +39,5 @@ The same fixture passed on Windows 10.0.19045 with Python 3.11 and installed Cla Account configuration JSON and native Claude authentication output are read explicitly as UTF-8, independent of the Windows system code page. The legacy negative-control probe retains the same encoding and requires native exit status 1 together with logged-out JSON. [WSL account management](2026-10-25-wsl-account-management.md) uses a separate registered-environment transaction. It does not extend native-local account synchronization to WSL or automatically mirror Windows credentials. + +The Codex opt-in decision is superseded by [unified local Codex accounts](2026-10-26-unified-codex-accounts.md). Claude synchronization remains optional. diff --git a/.agents/notes/implemented/feature/2026-10-25-wsl-account-management.md b/.agents/notes/implemented/feature/2026-10-25-wsl-account-management.md index deafda65..03ec0fa7 100644 --- a/.agents/notes/implemented/feature/2026-10-25-wsl-account-management.md +++ b/.agents/notes/implemented/feature/2026-10-25-wsl-account-management.md @@ -71,3 +71,5 @@ Current verification distinguishes browser component fixtures, authenticated HTT The native Codex updater is not an inference client, but it can restart the daemon. Its exemption requires the exact internal role, same HOME/UID/executable and a matching native PID birth record. The handoff holds the existing native operation lock across normal exit and account-file commit, so the updater cannot restart between the paired credential writes. Native Codex 0.164.0 was observed draining an in-flight fake request after the first SIGTERM. A second SIGTERM can force exit, so the gateway persists the first termination intent before signaling and never repeats it for the same process generation; timeout leaves credentials unchanged and recovery only observes whether normal exit has completed. Codex draft continuation snapshots catalog identities rather than full conversation contents, including inactive and archived chats. Only an explicit successful account transaction authorizes the new identity lineage. A missing old identity allows first-time account setup without inventing draft ownership. Repeating completion for the exact applied identity is idempotent and retains prior authorized lineage; destination drafts and unconfirmed sends remain untouched. + +The [account presentation and platform scope correction](../bug-fix/2026-10-26-account-ui-and-platform-scope.md) supersedes the desktop editor placement and skips WSL initialization on unsupported gateways; the account inventory and endpoint identity contracts remain. diff --git a/.agents/notes/implemented/feature/2026-10-26-bridge-session-management.md b/.agents/notes/implemented/feature/2026-10-26-bridge-session-management.md new file mode 100644 index 00000000..ea05a35f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-bridge-session-management.md @@ -0,0 +1,50 @@ +# Agent Note: Bridge 会话置顶与原生归档 + +Status: implemented + +## Problem + +手机与网页需要共享会话置顶,并在原执行环境归档和恢复聊天。置顶不能改变 Codex 自身的偏好;原生归档可以关闭原生任务和后代,不能仅依据历史记录判断空闲。 + +## Decision + +Live writer ownership, definite rejection receipts and reversible provider support are refined in [native archive feedback](../../implemented/bug-fix/2026-10-26-native-session-archive-feedback.md). + +`SessionPins` 在 Bridge 私有 `session-pins.json` 按执行环境与线程保存置顶,Web 与手机使用同一 HTTP 写入口。环境键保留 host、配置目录以及 WSL user。它不使用原生 isPinned RPC,也不写 Codex 数据库或 rollout。最近模式在只读索引查询的 LIMIT/OFFSET 前按 Bridge 置顶、最近交互时间排序;网页跨主机合并后仍按同样优先级截取。置顶和非置顶各自按最近交互排列。列表快照键包含 recent/project 模式,异步读取在同一 generation 内捕获该模式,离线或部分主机结果不能复用另一模式的分页集合。项目模式不传置顶优先级,保持现有项目与最近排序;没有置顶时保留原列表结构。 + +归档和取消归档使用 `thread/archive`、`thread/unarchive`。App 路径复用原 home、原本机/SSH 运行时的短期官方连接;本机 CLI 使用同一原 daemon,WSL 使用已有 distribution/user/home 的共享服务,均不 resume、不创建模型轮次,也不修改 provider、auth、cwd。显式归档可以沿现有显式操作路径启动已安装的原 daemon,WSL 使用同一 ensure_daemon 归属核验;启动控制服务不恢复线程。原生方法不支持、控制面不可用或状态无法确认时拒绝操作。 + +只读索引枚举根线程完整后代,不沿用界面 128 条上限。App 原 owner 与后代用即时 IPC discovery/follow 快照确认 idle;timeout、断连、错 owner、错 revision、审批或运行中均阻止。CLI 仅在 Desktop IPC connect 本身的 FileNotFoundError 证明该端点不存在时继续检查原 daemon;元数据错误不能使用该例外。本机 App 关闭时,只有同样的端点缺失证据加上即时核验的原 GUI 可执行文件确实无进程,才允许使用短期原生元数据操作;仍运行、进程可见性不足、路径未知或远端 App 均不使用此例外。其他连接错误不能当作无 owner。原 daemon 的 metadata-only `thread/read` 必须报告 idle/notLoaded,Bridge 保留的请求、队列、Goal 和消息写入 unknown/pending/sent 记录仍阻止归档。WSL 沿用 entry 写门,检查相同子树的原生状态及未确认回执。 + +`ArchiveCommands` 在发送归档前持久化 unknown 意图,成功后读取原生索引确认 archived 值。连接丢失、进程重启或新请求 ID 均不自动重放未知写入;只有目标原生状态已经确认时才消解回执。能够证明原生写入尚未发出的本机预检失败清除该意图,允许修正状态后重新操作。原生拒绝或传输失败发生在已尝试写入后则保守保留未知状态。 + +列表长按、右键和 Shift+F10 打开置顶、复制链接、归档/取消归档菜单。复制按钮的忙碌状态交由共享剪贴板处理,避免菜单预先禁用按钮导致复制被跳过。复制使用 issue74 的 `BridgeSessionLinks` 与共享剪贴板;该 helper 不支持的原生环境不伪造链接。详情复制入口由 issue74 提供。共享中继只扩展这两个准确的 POST 路径,登录与 CSRF 保持不变。 + +## Existing-note audit + +- [列表首屏](../../implemented/bug-fix/2026-10-16-codex-list-first-paint.md) 部分重叠:保留逐主机加载与完整成功分页边界,在原合并点增加最近模式优先级。 +- [WSL 统一接入](../../implemented/architecture/2026-10-24-codex-wsl-unified.md) 与 [WSL 账号隔离](../../implemented/feature/2026-10-25-wsl-account-management.md) 部分重叠:保留原环境、写门与未知结果不重放,本篇只增加会话管理。 +- archive/归档关键词其余命中为文件下载、更新包、历史路径或 Claude 列表,与本决定无关。没有可吸收或废弃的既有置顶提案。 + +## Alternatives considered + +- 原生置顶可跨 Codex 客户端共享,但用户明确要求仅 Bridge 生效。 +- 在网页排序当前页实现简单,但旧置顶聊天可能永远进不了首屏。 +- 直接修改原生数据库可立即显示归档,但绕过原生生命周期及任务保护,不能采用。 +- 对归档使用 resume 能取得更多运行时数据,但会改变原执行归属及控制状态。只读原 owner 与原 daemon 的快照可以核验状态,未知时拒绝。 + +## Testing + +Python 合成数据验证共享环境隔离、150 条旧置顶在分页前排序、项目原顺序、完整后代、运行/审批/未知状态拦截、未知回执跨重启核对且不重放、本机 CLI 原 home/daemon、WSL 原运行时双向归档、HTTP 登录/CSRF 与中继边界。Node 夹具实际执行列表和菜单函数,验证最近/项目排序、原 host、复制 helper、长按取消与点击抑制、取消归档。 + +macOS 安装的 CLI 0.162.1 在工作树 `.tmp/native-home` 的隔离空会话完成官方协议及生产 `archive_thread` 两轮归档/取消归档,索引确认 archived,cwd/provider 保持一致;没有 turn/start 或模型调用。该运行时 schema 含两种归档方法,参数均为 threadId;includeTurns:true 在该安装版本返回 list_turns unsupported,因此安全检查使用当前运行状态的 metadata-only read。真实用户 App 会话、真实 WSL/SSH 环境及移动设备没有在本分支操作。 + +## Consequences + +置顶只影响 Bridge 最近列表,并可覆盖分页之外的旧聊天。归档是真正原生状态变化,恢复仍属于原环境。额外成本是有置顶时的排序与归档前原 owner/子树核验;归档结果未知时,用户需先在原客户端核对。原生协议没有跨 owner 的原子条件归档;即时核验与实际写入之间仍有外部客户端并发启动的窗口,不能宣称具备原子任务锁。Bridge 自身操作继续由现有账号/entry 写门串行化。 + +先等待网关写入再重绘的置顶交互已由 [本地优先会话置顶](2026-10-26-local-first-session-pins.md) 取代。本地待同步覆盖层与期望偏好重试不放宽上述任何原生归档约束。 + +## Confirmed stop before archive follow-up + +[Confirmed disconnect and archive](../feature/2026-10-27-confirmed-disconnect-archive.md) partially supersedes the idle-only rule for explicit `disconnect:true` requests. Legacy requests and restore remain idle-only; original-owner, version, writer and uncertain-receipt restrictions remain. Loaded Codex App and unverified provider/platform limits are not removed by that follow-up. diff --git a/.agents/notes/implemented/feature/2026-10-26-copy-native-session-link.md b/.agents/notes/implemented/feature/2026-10-26-copy-native-session-link.md new file mode 100644 index 00000000..3bda9132 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-copy-native-session-link.md @@ -0,0 +1,21 @@ +# Agent Note: Copy native session identity + +Status: implemented + +## Problem + +Sharing a chat requires its original client identity without activating or cloning it. + +## Decision + +The side-effect-free BridgeSessionLinks.text helper returns a Codex thread URL with the native SSH hostId, or the original Harness Session ID. It returns null for unsupported providers, invalid Codex IDs, and WSL endpoint digests. Detail actions use BridgeClipboard, including its HTTP and manual-copy fallbacks. Codex title details place the copy action beside the execution host, separate from message management and rename. Harness details expose the original native Session ID. + +Known copy controls keep the legacy `message-copy` class: already-published iOS and Android injections accept trusted clicks only on `message-copy` or `code-copy`, then consume the click once. They override `BridgeClipboard.copy`, so changing only a new mobile bundle cannot repair existing apps. The shared `copyWithFeedback` adapter calls that override and notifies only after a browser acknowledgement or the old native hook has requested text, reported no failure and set its acknowledged success label. It never treats a prior success label or a silent rejected click as a new acknowledgement. Browser manual-copy fallback notifies only after its later successful retry. Native rejection still uses the existing visible error event; token, frame, origin, foreground and trusted-click checks are unchanged. + +## Alternatives considered + +Using every Bridge host as Desktop hostId makes WSL links look valid even though the endpoint is an internal registry digest. The helper therefore refuses that identity. Launching the Desktop URL would open a session rather than copy it. + +## Consequences + +Copying does not activate or modify a session. Native WSL Desktop mapping remains unsupported. tests/session-links.test.cjs verifies native identity and unsupported cases; existing clipboard tests cover copy fallbacks. `tests/mobile-copy-compat.test.cjs` executes an immutable v2.1.5 shared-injection fixture (byte-identical to v2.1.4) for both iOS and Android against the actual link, file/folder and Harness actions, including native rejection and stale-label cases. Active notes searched for clipboard, deep links and session identity contain no existing link decision; WSL unified architecture partially overlaps identity boundaries and remains unchanged. diff --git a/.agents/notes/implemented/feature/2026-10-26-file-text-size.md b/.agents/notes/implemented/feature/2026-10-26-file-text-size.md new file mode 100644 index 00000000..b4837955 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-file-text-size.md @@ -0,0 +1,27 @@ +# Agent Note: 文件阅读字号独立于聊天和终端 + +Status: implemented + +## Problem + +文件正文和源码没有统一字号控制,聊天和终端字号不能表达文件阅读偏好。 + +## Decision + +文件预览工具栏沿用终端的减小、数值、增大控制形式,独立保存 bridge-file-font-size,范围 6–24px,默认 14px。所有打开及以后打开的文本文件共享字号;CSS 变量只作用于文件阅读正文、源码和 Markdown 代码块。源码行与行号仍在同一字号和 Grid 行中。 + +标签关闭和工作区清理解除控制注册。HTML 源码显示字号,隔离页面预览将同一偏好显示为相对默认值的缩放百分比,仅缩放外层 iframe,沿用百分比宽高让浏览器计算内部视口,不读取沙箱 DOM、不改变原文或消息契约;PDF 和图片不显示此控制,保留自身真实缩放。窗口 storage 事件同步文件字号,不读写聊天或终端偏好。 + +## Existing-note audit + +现有源码视图笔记保留独立行号与精确复制决定;终端字号实现是交互参考,本次不修改终端行为。检索活跃文件笔记没有全局文件字号决定。 + +## Alternatives considered + +- 共用聊天或终端字号:省一份状态,但不同阅读场景互相干扰。 +- 用 CSS transform 缩放全部文件预览:可覆盖图片和 HTML,但不会正确重排正文,且混淆字体与 PDF、图片缩放。 +- 向 HTML 沙箱新增缩放通信:可调整页面预览,但增加独立协议;外层 iframe 缩放保持既有来源、CSP 和沙箱边界。手动反向除宽高会再次缩小可见框,浏览器实测后采用原有百分比宽高。 + +## Consequences + +字号变化触发文本自然重排,包括换行源码和行号。PDF 和图片保留原来的大小行为;HTML 预览以真实页面缩放呈现同一全局偏好。浏览器拒绝本地存储时,本页内仍能调节,重载恢复默认值。 diff --git a/.agents/notes/implemented/feature/2026-10-26-local-first-session-pins.md b/.agents/notes/implemented/feature/2026-10-26-local-first-session-pins.md new file mode 100644 index 00000000..3a1732aa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-local-first-session-pins.md @@ -0,0 +1,31 @@ +# Agent Note: 本地优先的 Bridge 会话置顶 + +Status: implemented + +## Problem + +置顶等待网关写入和完整列表刷新,在弱网下表现为操作没有生效。Claude 与 DeepSeek 也需要相同的 Bridge 展示偏好。 + +## Decision + +设备先持久化最新的置顶布尔意图,立即重绘,再在后台同步。作用域使用已验证的持久网关/auth draftIdentity 加网关来源;目标键包含 provider、host 和原生会话 ID,不依赖所选模型账号或可轮换的登录令牌。退出登录暂停同步和旧响应的影响;重新验证相同身份后恢复待同步意图。最近列表优先显示置顶,项目列表保持原排序。 + +原 pin 请求仍接受 `{pinned}`,新增可选且必须配对的 `{clientId, revision}`。服务端在原环境数组旁保存设备与目标的最新版本,拒绝延迟的旧版本。Claude、DeepSeek 使用独立的偏好文件,接受不透明的原生 ID。偏好写入不读取原生会话详情,也不执行原生置顶。待同步覆盖层拒绝旧 GET;确认后的覆盖层保留到确认之后发起的列表读取,之后接受其他设备的新状态。 + +只有期望置顶布尔值进入重试队列。单次请求 15 秒后中止,临时错误以 5/15/60 秒退避;恢复联网或页面可见立即尝试。认证、权限与请求错误不自动循环。未确认时显示待同步,不宣称已经全局同步。Claude/DSH 复用原右键、长按、Shift-F10 菜单;归档入口仅接受后端已证明的可逆原生能力,仍是一次显式操作,不进入置顶重试器。 + +## Alternatives considered + +保留网关确认后重绘最简单,但让本机展示偏好依赖网络延迟。仅做乐观重绘而不记录服务端版本,无法阻止超时旧请求覆盖新意图。写原生置顶违背 Bridge 内独立偏好的边界。以所选账号或 CSRF 作为偏好作用域,会在正常账号切换或重新登录时丢失置顶。 + +## Testing + +合成夹具覆盖离线立即重绘、刷新恢复、快速反向操作、乱序写入、旧 GET、延迟认证失败、登录/网关/provider/host 隔离、旧请求兼容、原生无调用,以及 Claude/DSH 最近和项目排序、菜单能力和手势。完整前端回归 1176 通过、1 跳过;相关后端 64 通过;最后的持久化失败恢复补充检查通过(10 项本地偏好检查)。没有操作真实会话、账号或移动设备。 + +## Consequences + +弱网下置顶立即生效,设备保留尚未同步的最终选择;代价是持久设备版本元数据与一个后台偏好队列。同步完成后的新列表可反映其他设备更晚的选择。 + +本决定取代 [Bridge 会话管理](2026-10-26-bridge-session-management.md) 原先先等待网关写入再重绘的交互。旧笔记的原生归档、执行归属、任务与未知回执约束全部保留;安全的期望偏好同步不授权归档或消息未知写入重放。既有列表首屏、剪贴板与 WSL 身份笔记保留各自边界;检索未发现可合并的既有本地优先置顶提案。 + +Follow-up: [Shared list pin token integration fix](../bug-fix/2026-10-26-desktop-list-pin-read-token.md) preserves these contracts while repairing successful list processing with pin preferences enabled. diff --git a/.agents/notes/implemented/feature/2026-10-26-markdown-file-modes.md b/.agents/notes/implemented/feature/2026-10-26-markdown-file-modes.md new file mode 100644 index 00000000..01c48601 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-markdown-file-modes.md @@ -0,0 +1,26 @@ +# Agent Note: Markdown 文件的阅读与源码模式 + +Status: implemented + +## Problem + +Markdown 文件只能阅读渲染结果,无法直接查看完整原文;引用位置仍需要源码行定位。 + +## Decision + +Markdown 采用 HTML 已有的模式按钮外观,阅读使用现有渲染器,源码使用 BridgeSourceView。复制按钮始终复制服务端原文。用户主动切换记为浏览器全局默认,新打开文件采用此默认;引用位置强制源码且不覆盖默认。每个标签分别保存两种模式的纵横滚动位置,模式变化同步工作台的滚动缓存。源码 DOM 在模式间保留,并显式恢复内部滚动容器的纵横位置,保留自动换行选择。工作台标签切换也恢复源码内部滚动容器,避免节点卸载重置横向位置。 + +每次阅读实例拥有独立 Mermaid scope。退出阅读、关闭标签或重置工作区均清理 scope,阻止过期异步渲染和残留全屏图表。工作台自身全屏保持同一内容节点,沿用原有标签滚动恢复。 + +## Existing-note audit + +源码视图笔记部分重叠,继续约束精确原文、独立行号和高亮;本决定只负责 Markdown 模式与生命周期,不替换源码组件。 + +## Alternatives considered + +- 把源码作为独立标签:可同时查看,但消耗文件标签数量,且偏离已有 HTML 模式交互。 +- 所有 Markdown 继续共用工作台 Mermaid scope:缓存复用较多,但单个模式清理会影响其他已打开文件。 + +## Consequences + +切回阅读会重新创建图表 scope;原文不经渲染内容重建。默认设置本地保存失败时仍可切换,但重载后回到阅读。HTML、PDF 和图片预览保持现有路径。 diff --git a/.agents/notes/implemented/feature/2026-10-26-unified-codex-accounts.md b/.agents/notes/implemented/feature/2026-10-26-unified-codex-accounts.md new file mode 100644 index 00000000..21e44116 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-unified-codex-accounts.md @@ -0,0 +1,23 @@ +# Agent Note: Unified local Codex accounts + +Status: implemented + +## Problem + +Desktop and CLI already share saved Codex accounts, but two lists and an optional verification checkbox suggest independent identities. + +## Decision + +The UI shows one saved inventory and a separate API editor before reminders. It always verifies CLI configuration after a Desktop switch. With Desktop stopped, use the existing guarded CLI transaction without launching Desktop. Official login stays computer-only; Claude and remote environments keep their current scopes. + +## Alternatives considered + +Keeping both lists preserves existing controls but misrepresents one shared account home. Hiding the CLI list without retaining its API editor and model probes removes useful capability. + +## Consequences + +Desktop and CLI may report different success outcomes; retain partial-success reporting. The [previous optional-sync decision](../../implemented/feature/2026-10-14-local-cli-account-sync.md) partially overlaps and remains as historical rationale. Active account-sync and WSL notes remain applicable outside this narrow local Codex decision. + +Targeted account UI, CLI editor, WSL scoping and backend account/sync/access fixtures verify this behavior. No personal account switch or model request is performed. + +The [account presentation and platform scope correction](../bug-fix/2026-10-26-account-ui-and-platform-scope.md) supersedes the desktop editor placement and skips WSL initialization on unsupported gateways; the account inventory and endpoint identity contracts remain. diff --git a/.agents/notes/implemented/feature/2026-10-26-workspace-copy-path.md b/.agents/notes/implemented/feature/2026-10-26-workspace-copy-path.md new file mode 100644 index 00000000..43949214 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-workspace-copy-path.md @@ -0,0 +1,24 @@ +# Agent Note: 工作区菜单复制目标绝对路径 + +Status: implemented + +## Problem + +目录列表只携带相对路径,浏览器无法可靠判断 SSH、WSL 和 Windows 目标的绝对路径格式。 + +## Decision + +现有 info 操作在目标执行 Workspace 边界检查后返回 absolutePath;文件和目录沿用同一无链接访问规则。菜单点击复用 BridgeClipboard 的异步用户手势、HTTP 选择复制和手动复制退化。下载链接及其行为保持原有实现。返回的绝对路径仅在展示层去除标准 Windows 扩展前缀:`\\?\D:\...` 显示为 `D:\...`,`\\?\UNC\server\share` 显示为普通 UNC;普通 UNC 和设备路径保持原样。底层验证、打开和链接检查继续使用原生规范路径,不把展示字符串回传给文件访问。已发布移动 App 通过已知复制按钮的旧 `message-copy` 标记复用现有可信点击桥,实际确认后显示成功提示。 + +## Existing-note audit + +检索活跃文件预览和源码笔记,源码显示决定只部分相关,没有路径复制契约;保留其独立用途。 + +## Alternatives considered + +- 浏览器拼接 cwd:无需请求,但网关操作系统不能代表目标系统,且无法重新验证边界。 +- 新增路径 API:职责明确,但现有受验证 info 已提供所需元数据,无需扩大路由。 + +## Consequences + +复制前会读取一次目标元数据;目标文件消失、链接或会话切换时失败,不返回猜测路径。目标执行模块继续独立支持本机、SSH、WSL 和其他客户端工作区。 diff --git a/.agents/notes/implemented/feature/2026-10-27-confirmed-disconnect-archive.md b/.agents/notes/implemented/feature/2026-10-27-confirmed-disconnect-archive.md new file mode 100644 index 00000000..cdc628f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-27-confirmed-disconnect-archive.md @@ -0,0 +1,58 @@ +# Agent Note: Confirmed stop, release and archive + +Status: implemented + +## Problem + +Disconnecting a Bridge reader does not release a native writer. Users explicitly request stopping the selected chat before archive, with a confirmation dialog whose left button is Confirm archive and right button is Cancel. A failed stop must not cause an archive or be replayed as a new stop after reconnect. + +## Decision + +An explicit `disconnect:true` on an archive request opts into stopping the exact original target and its verified descendants. Older requests retain idle-only semantics; restore never stops work. The existing lifecycle gates exclude new Bridge submissions during the operation. Persist the existing unknown receipt before any stop or release, read fresh native state after stopping, and only then archive through the original runtime. Never close a shared application/daemon or kill unrelated processes to release one chat. Uncontrollable native owners remain a clear failure, not a fabricated success. + +The confirmation displays title, execution host and interruption consequences. Confirm archive is on the left, Cancel on the right and initially focused. Both buttons use the same existing primary style and equal grid columns; phone-width visual acceptance prevents a narrow/wrapped Cancel label. Cancel, Escape and dismissal perform no request. Restore retains its separate action. + +## Existing-note audit + +- [Native archive feedback](../../implemented/bug-fix/2026-10-26-native-session-archive-feedback.md): partially superseded only for explicitly confirmed stopping; original-owner, reversible storage and uncertain-write requirements remain. +- [Bridge session management](../../implemented/feature/2026-10-26-bridge-session-management.md): overlapping routing, identities and receipts remain in force. +- Local-first pin notes remain unrelated: stopping and archive never enter their retry queue. +- No other active proposed/rejected archive decision supersedes this request. + +## Alternatives considered + +Merely disconnecting Bridge leaves native writers alive. Exiting the whole desktop app releases writers but affects unrelated chats. Editing Codex storage bypasses native ownership. Direct native archive through the actual owner avoids unnecessary detach/reattach when supported; releasing a per-chat Claude executor is needed before file-level archive. + +## Verification contract + +Dialog order and cancellation are exercised through the real event handlers. New synthetic native sessions demonstrate targeted stop/archive/restore while an unrelated session remains usable. Unknown stop/archive results are never replayed. Unsupported external owners are refused without stopping the whole app. Existing idle-only and restore callers retain their behavior. + +## Consequences + +Native client capabilities differ by version; a connected App follower is not its execution owner. Approval covers dedicated new test sessions only, not stopping existing user work. No whole-app shutdown, credential change or release publishing is part of this change. + +## Provider behavior + +Codex CLI and WSL stop verified current turns through their original runtime, clear only that subtree's background terminals, re-read idle state and the descendant inventory, then call native archive without restarting/resuming the target. A `notLoaded` thread is not queried for background terminals (the installed runtime rejects that call) or loaded just for archival. A changed original control connection or child scope prevents archive. New uncertain receipts retain the complete pre-operation archive scope: a partially archived subtree cannot be reported or reconciled as confirmed merely because its root archived. + +Claude Code Desktop uses its existing native interrupt on the verified running subtree, polls fresh native activity and then archives with `cleanupWorktree:false`. Connector revision 12 prevents an old injected implementation silently accepting the new behavior. Unresolved background work, queued input, unknown descendants and pending native mutations still prevent archive. Cowork does not gain unsupported archive/restore capability. + +Owned Claude CLI/SSH/WSL executors acknowledge native interrupt, wait for result/pending-request settlement, close stdin and wait for process exit before the existing profile, version, registry and executable-census checks. There is no terminate/kill fallback. Installed CLI 2.1.292 emits a final `error_during_execution` result and exit code 1 after acknowledged user interruption; this specific active-stop exit is accepted, while timeout and signal/other abnormal exits prevent archive. File-level archive and restoration remain reversible and preserve the original native ID and bytes. The remote archive-state read may report unsafe without stopping anything; the remote mutation still requires all original writer checks. + +DSH uses the native `workspace/session-stop` providers for the selected session's turn, descendants, jobs and schedules, then verifies `workspace/session-activity`, current running state and pending questions before native `archiveSession(sid,{})`. The installed registry's `stopActivity:true` persists the archive before requesting stops and swallows stop failures; it was rejected for this stop-before-archive contract. Native default admission still guards the final write. Bridge revision 7 distinguishes this contract from the previously loaded idle-only implementation. The verified revision 6 LF and CRLF source hashes remain recognized: discovery reuses the original transport, source location, credentials and receipts without rewriting a live plugin; upgrading the native plugin retains the existing explicit restart and fresh task-state gate. Unknown source hashes remain rejected. Reconnecting or starting the gateway with an older running DSH host only records restart-required; it never exits the host or updates its loaded source. Only the explicit restart action can use the fresh idle gate and replace that source. This boundary corrects the pre-existing reconnect path that otherwise upgraded and restarted a running host during Test startup. + +## Verification and limits + +- Release cross-platform validation separates real POSIX archive storage tests from portable process/dispatch contracts. Windows native Claude archival remains disabled and has an explicit negative test; POSIX fsync/ownership round-trips run on Mac/Linux rather than mocking a successful Windows archive. Synthetic process census fixtures provide getuid explicitly and compare path components independently of the runner. The standalone remote-worker fixture executes its exact generated payload from a temporary file, avoiding the Windows command-line length ceiling while still running without Bridge imports. + +- DSH migration follow-up: 51 targeted Python tests and 6 native-provider Node tests passed. The subsequent no-implicit-restart correction passes all 54 DSH migration/recovery tests. The WSL-specific lifecycle fixture explicitly supplies both Windows registries while the default macOS fixture remains free of WSL initialization; all 151 lifecycle/migration/recovery tests pass. Release preparation runs 2,671 backend source tests with 47 documented platform/native-fixture skips and no failures, plus 1,206 frontend passes and one platform skip. The installed revision 6 connector is recognized read-only with no configuration writes; explicit-upgrade fixtures retain its original credentials, receipts and insertion. +- Frontend: 1,206 passed, one existing skipped test. Confirmation handlers verify left Confirm archive/right Cancel, default Cancel focus, no request on cancel/dismissal, exact host/provider identity, and restore without stopping. Actual connector/host fixture tests verify stop ordering, target-only scope and failure before archival. +- Backend: 185 targeted tests passed before the final original-connection guard; 29 focused archive tests passed after that guard. Coverage includes HTTP auth/CSRF and flag validation, full-subtree receipts, no replay after unknown stop, owned CLI exit semantics, real synthetic file archive/restore, remote identity and WSL routing. Three existing remote archive tests had been accidentally nested below `unittest.main()`; they are now discovered and run. +- Installed Codex 0.164.0-alpha.11.1: isolated home plus fake loopback Responses streaming, no account credentials. Two new native sessions actively streamed. Production stop helper stopped only the target; same-runtime archive and restore retained its ID; the second session remained active until explicit test cleanup. A concurrent second runtime was refused by the original writer lock. A restored/unloaded session passed the new preflight without being resumed. Evidence is under the ignored `.tmp/disconnect-archive-20261027/codex-native/`. +- Installed Claude 2.1.292: a new isolated, sandboxed CLI with fake loopback inference acknowledged interrupt, emitted its final result, consumed EOF and exited with code 1. Real user files, keychain and external networking stayed forbidden. The subsequent production executable census found another existing Claude CLI and correctly prevented file archival; no unrelated process was stopped. Therefore this run proves native release and the writer refusal, not a completed live Claude archive round-trip. Synthetic adapter/file tests cover the remaining ordering and restore path. +- Initial implementation did not run Windows/WSL/Linux native acceptance. Release preparation subsequently passed isolated target-only Codex stop/archive/restore on Windows 0.164.0-alpha.11.1, Ubuntu 0.160.0 and WSL 0.164.0, while another synthetic session remained active and a second writer was refused. Live Claude Code Desktop/DSH archive acceptance remains contract regression coverage rather than new native end-to-end evidence. The real local Claude release-plus-file roundtrip fixture, like the file-storage suite, runs only on POSIX; Windows unsupported-capability coverage remains active. +- Loaded Codex App sessions remain an explicit limitation. Installed App 26.1021.31345 exposes follower interrupt but no verified follower archive/unload operation. Removing Bridge's follower cannot release the App's own writer. The implementation does not stop a desktop turn that it cannot subsequently release, quit a shared App, forge archive broadcasts or edit native storage. Writer-free App history retains the earlier supported native archive path. This change does not justify a claim of universal App/CLI archive support or closing that remaining issue. + +## Branch cleanup + +User-approved remote and local `release/v2.1.4-preview.1` (4c251de061da073c6138780d2c9a3a868ea123c5) and `release/v2.1.0-preview.1` (422a46b6ae90b0c52748894c387741ce346ebb92) were deleted after fresh main ancestry and worktree-occupancy checks. Remote deletion used atomic, exact expected-head leases. Tags and Releases were retained. No v2.1.6 release or tag was published. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d455306c..0534de5b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,55 @@ + + +## v2.1.6 · 会话整理、文件阅读与接入体验 + +本版完善手机 App 与 Web 的会话整理、复制和文件阅读,优化账号管理,并修复跨平台项目分组与列表更新问题。桌面与手机 App 版本为 **2.1.6**,Android/iOS 构建号为 **39**。 + +### 新增与改进 + +- **会话置顶**:Codex、Claude 和 DSH 在“按最近交互”列表中支持置顶与取消置顶。操作先在当前设备生效,再同步到网关,减轻弱网等待;这是 Bridge 手机 App 与 Web 的列表偏好,不修改原生 App 或 CLI 的置顶状态。电脑右键、手机长按可打开会话菜单。 +- **断开连接并归档**:增加二次确认,左侧“确认归档”、右侧“取消”,两个按钮采用相同样式和大小。在支持的接入中停止所选会话及可核验的子任务,确认停止后再归档;聊天记录保留,可取消归档。无法确认停止或归档结果时不会自动重复提交。原生能力限制见下文,不能保证所有 App/CLI 会话均可归档。 +- **复制与会话详情(#74、#75)**:支持复制 Codex 深度链接、DSH Session ID,以及文件和目录的绝对路径;修复旧版 iOS/Android App 中复制未写入剪贴板、缺少结果提示的问题。深度链接入口移至会话详情的“在电脑运行”旁;Windows 普通盘符与 UNC 路径显示不再带扩展路径前缀。 +- **文件阅读(#80、#81)**:Markdown 可切换阅读与源码模式,并保留相应阅读位置。增加独立的文件字号设置,应用于文本、代码、Markdown 和 HTML 预览;图片与 PDF 继续使用各自的缩放方式。 +- **账号与 API 接入(#78)**:统一 Codex 账号清单,保留桌面网关的“添加账号或 API”和具体额度展示。手机 API 表单沿用现有配色,折叠标题与相邻设置一致,去掉多余外框,补齐输入标识;支持“获取上游模型”并自动填入默认模型。官方账号登录仍在电脑完成。 +- **网关与列表**:直接展示全部访问地址(#76),移除“客户端与接入”中多余的 Codex/Claude 分组标题。Mac/Linux 不初始化或展示 WSL 模块。修复 DSH/Claude 列表与置顶状态读取范围导致的更新失败,以及最近会话和项目列表之间的缓存干扰。 +- **连接与项目分组(PR #73、#82)**:修复 Windows SSH/CLI 项目目录分组名称;提高 HTTP 监听队列容量,改善并发连接接入,并验证异常 GET 请求关闭连接时不会污染后续请求。 + +### 归档支持范围与限制 + +- **Codex**:支持兼容原生运行时上的 CLI/WSL 会话,以及没有活跃写入者的 App 历史。**仍由 Codex App 持有的部分会话暂时不能从 Bridge 释放并归档**,请在 Codex App 中归档;Bridge 不会为此退出整个 App、影响其他会话或直接改写原生数据库。 +- **Claude**:Claude Code Desktop 通过支持的原生接口停止并归档。CLI/SSH/WSL 使用可逆文件归档,仅在原生版本、配置身份、会话文件及写入进程检查通过后启用;未验证的版本、Windows 原生 CLI,以及仍检测到其他写入者的环境可能无法归档。**Cowork 暂不支持归档与恢复。** +- **DSH**:需要支持本版归档流程的连接器。停止目标会话及其任务后重新核验,再调用原生归档;无法确认安全状态时不执行。升级运行中的旧连接器需要显式重启接入,启动网关本身不会自动重启 DSH。 +- 归档不会删除聊天记录,也不会在取消归档时自动恢复未完成任务。上述未完成范围继续由 [#77](https://github.com/try2love/codex-mobile-bridge/issues/77) 跟踪。 + +### 升级 + +覆盖安装对应平台原 App,不要先卸载或清除数据。Android 沿用原签名;iOS 仍需使用相同应用标识与签名身份自行覆盖安装。独立 Test 与正式 App 保持独立安装与数据目录。 + +更新网关后刷新 Web 或手机共享页面;旧手机 App 可在原电脑连接的“更多 → 刷新页面”加载新版。若正式版和 Test 共用一个 DSH 原生连接器,应一并升级到兼容版本;两套网关的独立安装并不隔离同一个原生连接器。仅在确认原生任务已停止后,通过明确的重启操作完成连接器升级。 + +### 贡献与反馈 + +感谢 [@lanchengjiu](https://github.com/lanchengjiu) 的 [PR #73](https://github.com/try2love/codex-mobile-bridge/pull/73),以及 [@mc373906408(waken)](https://github.com/mc373906408) 的 [PR #82](https://github.com/try2love/codex-mobile-bridge/pull/82)。两位作者的原始提交通过 merge 保留。感谢 #74、#75、#76、#77、#78、#80、#81 的建议与跨平台测试反馈。 + +### English + +**v2.1.6 improves chat organization, file reading, copying and account management.** Desktop/mobile version: **2.1.6**, Android/iOS build **39**. + +- Pin Codex, Claude and DSH chats in Bridge's recently active list. Apply changes locally before gateway synchronization; native clients' pin states are unchanged. Context menus support desktop right-click and mobile long-press. +- Confirm before stopping and archiving a supported chat. Confirm archive is on the left and Cancel on the right, with equal styling and size. Verify the selected chat and its descendants have stopped before archival; retain history and never replay uncertain operations. +- Copy Codex deep links, DSH session IDs and absolute file/folder paths. Restore clipboard writes and feedback in older iOS/Android apps; normalize Windows display paths. +- Switch Markdown between reading and source views and use a separate file-text size preference. Images and PDFs retain their own zoom controls. +- Unify Codex account inventory while retaining desktop quota details and the existing add-account/API entry. Improve the mobile API form and fetch upstream models to fill a default model. Official sign-in remains on desktop. +- Show all gateway addresses, remove redundant client headings and keep WSL initialization/UI on Windows. Fix DSH/Claude list refresh scope and recent/project cache interference. PR #73 corrects Windows SSH/CLI project names; PR #82 increases the HTTP listener backlog. + +**Archive limitations:** Codex App-owned writers cannot always be released externally; archive those chats in Codex App. Claude Code Desktop needs compatible native support. Claude CLI/SSH/WSL reversible file archival is gated by verified versions, identity, files and writer checks; unsupported versions and Windows native CLI may be unavailable. Cowork archive/restore is unsupported. DSH needs the compatible connector and fresh stop verification. Bridge never quits a shared app or kills unrelated work to archive a chat. Restoring history does not restart unfinished tasks. Issue #77 remains open for these limitations. + +Install over the matching existing app without clearing data; retain Android signing and use matching iOS identifiers/signing. Refresh shared pages after the gateway update. If Test and production share one native DSH connector, upgrade both gateways compatibly and explicitly restart the connector only after its tasks have stopped. + +Thanks to [@lanchengjiu](https://github.com/lanchengjiu) for PR #73 and [@mc373906408 (waken)](https://github.com/mc373906408) for PR #82; original author commits are retained through merges. + +--- + diff --git a/VERIFICATION.md b/VERIFICATION.md index e4172f1e..4750fbc7 100644 --- a/VERIFICATION.md +++ b/VERIFICATION.md @@ -1,3 +1,28 @@ + + +# v2.1.6 发布前验收(2026-10-27) + +范围固定为相对 v2.1.5 的本轮会话整理、复制、文件阅读、账号展示、列表更新和 PR #73/#82;没有追加功能。产品与构建输入固定于 `8d0880b`,后续 `c3c404b` / `3e5f658` 仅修正跨平台测试夹具并合入原作者历史,`bridge/desktop/web/mobile/scripts`、依赖及构建配置逐项无差异。版本为 2.1.6,Android/iOS build 39。正式发布须等待维护者审查 Release 描述;本次不创建标签或 Release。 + +| 验收项 | 结果与证据边界 | +| --- | --- | +| 源码回归 | 本地后端 2671 项:2624 通过、47 平台/原生夹具跳过;前端 1207 项:1206 通过、1 跳过;更新器 33 项:32 通过、1 跳过。后续归档夹具 76 项和 CLI 合约 48 项通过;Windows 原生文件归档禁用的负向用例纳入最终 2672 项 CI | +| CI 与官方桌面包 | [最终产品候选五架构构建](https://github.com/try2love/codex-mobile-bridge/actions/runs/40383008443)覆盖 macOS arm64/x64、Windows x64、Linux x64/arm64。包含完整测试、打包、实际安装/启动、登录/扫码绑定、通知、HTTPS、更新交接及故障回滚;具体结论以任务结果为准。PR 的必需检查在合并前必须全部通过 | +| Windows 测试修订 | 真实 POSIX 文件归档不在 Windows 运行;未假装提供 Windows 原生 Claude 文件归档。Windows 上继续执行进程释放、协议、路由和明确不支持的负向检查。独立远端 worker 用临时脚本运行原始完整载荷,避免 Windows 命令行长度限制。未改产品逻辑或放宽归档安全条件 | +| 三端 Test | 同一 `c3c404b` 产品源分别编译并覆盖安装。Mac 包内 370 个 Web 文件一致,Windows 727、Ubuntu 734 个安装文件与 manifest 一致;三端网关健康与实例匹配,配置、账号、有效绑定及已有原生客户端进程保持。只正常退出已核验的 Test;Windows 的两条历史未确认记录按原字节保留,不清除、不重放,也不声称其原生结果已确认 | +| 界面验收 | Mac Test 窗口实际打开,全部访问地址可见,WSL 与冗余客户端分组不出现;具体额度及“添加账号或 API”保留,原 Claude 禁用状态不变。Windows/Ubuntu 已在原用户图形会话启动并验证渲染/实例与资源;本轮未取得远程窗口截图,不将进程证据称为视觉验收 | +| 原生 Codex 归档 | Mac 0.164.0-alpha.11.1、Windows 0.164.0-alpha.11.1、Ubuntu 0.160.0、WSL 0.164.0 均使用新建隔离会话与本地假模型验证:只停止目标、原运行时归档/恢复且 ID 保持、旁路会话仍活跃、第二写入者被拒绝。没有向真实模型发送请求或停止真实用户会话 | +| Claude 原生边界 | 原生 CLI 2.1.292 隔离验证已证明 interrupt → 结果收束 → EOF → 退出。后续真实写入进程检查正确拦截另一 CLI 存活时的文件归档,因此不声称这次完成真实 Claude 全流程归档。POSIX 文件归档/恢复与远端/WSL 路由有合成回归,原生历史列表隐藏/恢复已有指定版本隔离验证 | +| DSH 升级 | 实际 v2.1.5 旧连接器 LF/CRLF 源码均通过识别、只读发现及显式升级夹具;凭据与其他文件字节/时间戳保持,正常启动网关不重启原生 DSH。原生 stop/archive 顺序和失败拒绝有合约测试;本轮未新增真实 DSH/Claude Code Desktop 活跃会话完整归档验收 | +| Android | 正式标识 APK 2.1.6/build39,签名与 v2.1.5 一致。专用 API35 模拟器执行 v2.1.5 → v2.1.6 → v2.1.5 覆盖安装,首次安装时间、偏好、电脑绑定、原生与 WebView 认证保持;无卸载/数据重置 | +| iOS | [官方 unsigned 构建](https://github.com/try2love/codex-mobile-bridge/actions/runs/40378266701)通过;三个 bundle 的版本/标识及 49 个源码文件、共享 JS 均匹配。专用 iOS18.2 模拟器覆盖升级/回退保持原偏好和原生/WebView 认证。此次为模拟器兼容验证,不能替代 iPhone 真机签名安装证明 | + +**归档限制是公开的功能边界,而非已解决的缺陷。** Codex App 仍持有写入权的部分会话无法由 Bridge 释放;不会退出整个 App 或改写原生数据库。Claude 文件归档仅对已验证的平台、版本、原配置和无写入者条件开放,Windows 原生 CLI 与未验证版本不开放,Cowork 暂不支持。DSH 旧连接器需在任务停止后显式重启升级;共用同一原生连接器的正式版与 Test 需同步升级兼容版本。[Issue #77](https://github.com/try2love/codex-mobile-bridge/issues/77)继续打开。 + +PR #73(原始提交 `2ecf0aab`,@lanchengjiu)和 PR #82(原始提交 `491a6f52`,@mc373906408 / waken)均使用 merge 保留作者提交及 GitHub 账号关联。发布说明见 [v2.1.6](RELEASE_NOTES.md#v216)。本轮详细日志、哈希和仅本地的安装/数据保护记录保存在忽略目录 `.tmp/release-v216-20261027/`,不提交凭据、会话或本地配置。 + +--- + # v2.1.5 发布验证 diff --git a/bridge/api/httpd.py b/bridge/api/httpd.py index 15b38370..a747dfcc 100644 --- a/bridge/api/httpd.py +++ b/bridge/api/httpd.py @@ -38,7 +38,7 @@ from bridge.clients.codex.catalog import CatalogError from bridge.features.sessions.store import StoreUnavailable from bridge.clients.codex.remote import RemoteUnavailable -from bridge.features.sessions.create import CreationError +from bridge.features.sessions.create import ArchiveRejected, CreationError from bridge.features.accounts.account import AccountError from bridge.features.terminals.manager import TerminalSessionMissing from bridge.api.html_preview import SHELL_PATH, SHELL_FILE, SHELL_CSP @@ -59,7 +59,7 @@ def _prepared_static(data, content_type, use_gzip): etag = '"sha256-' + hashlib.sha256(data).hexdigest() + '"' return digest, data, compressed, etag -THREAD_ROUTE = re.compile(r"^/api/sessions/([0-9a-f-]{36})(?:/(events|send|stop|history|respond|reconnect|queue|catalog|settings|permissions|poll|timeline|changes|detail|notifications|uploads|message-action|message-management|rename))?$") +THREAD_ROUTE = re.compile(r"^/api/sessions/([0-9a-f-]{36})(?:/(events|send|stop|history|respond|reconnect|queue|catalog|settings|permissions|poll|timeline|changes|detail|notifications|uploads|message-action|message-management|rename|pin|archive))?$") UPLOAD_PREVIEW_ROUTE = re.compile(r"^/api/sessions/([0-9a-f-]{36})/uploads/([0-9a-f-]{36})/preview$") UPLOAD_THUMB_ROUTE = re.compile(r"^/api/sessions/([0-9a-f-]{36})/uploads/([0-9a-f-]{36})/thumb$") DESKTOP_IMAGE_ROUTE = re.compile(r"^/api/sessions/([0-9a-f-]{36})/desktop-images/([0-9a-f]{64})$") @@ -809,7 +809,8 @@ def handle_method(self, write): host_info = host_infos.get(host, {}) options = dict(query=query.get('q', [''])[0][:200], limit=offset + 101, archived=query.get('archived', ['false'])[0] == 'true', - source=source, include_sources=True) + source=source, include_sources=True, + **({'pin_order': True} if query.get('order') == ['recent'] else {})) if wsl_inventory_only: rows, available, errors = [], [], [] elif host.startswith('wsl:'): @@ -1182,6 +1183,14 @@ def handle_method(self, write): result = bridge.send(thread_id, body.get("text"), body.get("id", ""), body.get("mode", "send"), body.get("skills", []), work_mode=body.get("workMode"), attachments=body.get('attachments'), ui_locale=body.get("uiLocale")) + elif action == "archive": + if set(body) not in ({'archived', 'id'}, {'archived', 'id', 'disconnect'}): + raise ValueError('聊天归档设置无效') + result = bridge.archive(thread_id, body['archived'], body['id'], **({'disconnect': body['disconnect']} if 'disconnect' in body else {})) + elif action == "pin": + if set(body) not in ({'pinned'}, {'pinned', 'clientId', 'revision'}): + raise ValueError('聊天置顶设置无效') + result = bridge.pin(thread_id, body['pinned'], **({'client_id': body['clientId'], 'revision': body['revision']} if 'clientId' in body else {})) elif action == "rename": result = bridge.rename(thread_id, body.get("title")) elif action == "permissions": @@ -1234,6 +1243,9 @@ def handle_method(self, write): except ValueError as exc: self.close_connection = True self.output(400, {"error": str(exc)}) + except ArchiveRejected as exc: + self.close_connection = True + self.output(409, {'error': str(exc), 'code': 'archive_rejected', 'reason': exc.reason}) except (IPCError, GoalError, CatalogError, RemoteUnavailable, CreationError, StoreUnavailable, AccountError) as exc: self.close_connection = True self.output(409, {"error": str(exc), "code": "desktop_unavailable"}) diff --git a/bridge/app/service.py b/bridge/app/service.py index 3dcc722b..b092527c 100644 --- a/bridge/app/service.py +++ b/bridge/app/service.py @@ -8,6 +8,7 @@ import re import sqlite3 import subprocess +import sys import threading import time import uuid @@ -23,6 +24,7 @@ from bridge.clients.codex.ipc import DesktopIPC, IPCError from bridge.clients.codex.app_server import AppServerError, AppServerUnavailable from bridge.clients.codex.cli_session import CliSessions +from bridge.clients.desktop_app import DesktopApp from bridge.clients.codex.transport import ipc_endpoint from bridge.features.sessions.model import apply_patches, attachment_prompt, computer_use_approval, items_array, normalize_state, normalize_request, ordered_turns, pending_requests, async_requests, request_id, user_display_text from bridge.features.sessions.store import SessionStore, StoreUnavailable @@ -32,10 +34,12 @@ from bridge.platforms.codex_daemon_env import DaemonEnvironmentUnavailable from bridge.clients.codex.remote import AppHosts, RemoteStore, RemoteCatalog, RemoteUnavailable, ssh_read, ssh_download, payload, project_path_name from bridge.features.sessions.create import ( - rename_thread, independent_directory, create_empty, fork_copy, open_in_desktop, open_project_in_desktop, + archive_thread, rename_thread, independent_directory, create_empty, fork_copy, open_in_desktop, open_project_in_desktop, CreationError, CreationUnavailable, ForkUnavailable, project_directory, project_directory_listing, ) +from bridge.features.sessions.archive import ArchiveCommands +from bridge.features.sessions.pins import SessionPins from bridge.features.sessions.projects import Projects from bridge.features.sessions.timeline import Timeline from bridge.features.sessions.tool_images import TOOL_IMAGE_CACHE, public_turns @@ -43,7 +47,6 @@ from bridge.features.accounts.accounts import Accounts, operation from bridge.features.accounts.draft_identity import codex_account_identity, draft_identity from bridge.features.accounts.cli_access import CliAccess -from bridge.features.accounts.wsl_accounts import WslAccounts from bridge.features.sessions.goal import GoalRPC, GoalError, GoalUnavailable, GoalUnsupported, goal_activation, goal_identity, normalize_goal_result, normalize_ui_locale, goal_command_fingerprint, goal_state_confirms_command, native_goal_absent, normalize_goal, plan_goal_command, read_native_goal from bridge.features.workspace.uploads import Uploads, image_type from bridge.clients.codex.remote import upload_file @@ -223,9 +226,11 @@ def __init__(self, codex_home, data_dir, host="local", alias=None, ipc_path=None self._draft_remote_cache = {} self._draft_remote_pending = set() self.data_dir = Path(data_dir) + self.session_pins = SessionPins(data_dir, host + ":" + str(self.codex_home)) + self.archive_commands = ArchiveCommands(data_dir) self.hosts = AppHosts(codex_home) self.directory_projects = Projects(data_dir) - if host == 'local': + if host == 'local' and sys.platform == 'win32': from bridge.features.endpoints.registry import Entries self.entries = Entries(data_dir, codex_home) else: @@ -247,7 +252,10 @@ def __init__(self, codex_home, data_dir, host="local", alias=None, ipc_path=None self.account = Account(codex_home, data_dir, codex_bin, executable_getter=executable_getter) if host == 'local' else None self.accounts = Accounts(self) if host == "local" else None self.cli_access = CliAccess(self) if host == "local" else None - self.wsl_accounts = WslAccounts(self) if host == "local" else None + self.wsl_accounts = None + if host == 'local' and sys.platform == 'win32': + from bridge.features.accounts.wsl_accounts import WslAccounts + self.wsl_accounts = WslAccounts(self) self.goal = goal_rpc or (GoalRPC(codex_home, codex_bin, executable_getter=executable_getter) if host == 'local' else None) self.owner_goal = owner_goal self.goal_transport = 'owner' if owner_goal else 'sidecar' @@ -974,7 +982,7 @@ def for_host(self, host): self.remote_bridges[host].accounts = self.accounts return self.remote_bridges[host] - def assert_desktop_idle(self, timeout=20): + def assert_desktop_idle(self, timeout=20, identifiers=None, allow_missing_ipc=False, allow_stopped_app=False): """Passively check every desktop owner before an application shutdown. The caller holds the account operation gate until shutdown completes. @@ -992,7 +1000,11 @@ def remaining(): return value def check_submissions(): - if self.accounts.blockers(): + if identifiers is not None: + blocked = [row for row in self.accounts.blockers() if row['host'] == self.host and row['id'] in identifiers] + else: + blocked = self.accounts.blockers() + if blocked: raise ValueError('Codex 有任务或待确认操作,请先处理后再关闭') check_submissions() @@ -1002,7 +1014,7 @@ def check_submissions(): metadata = self.codex_home / '.codex-global-state.json' if metadata.exists() and not isinstance(json.loads(metadata.read_text(encoding='utf-8')), dict): raise ValueError('Invalid desktop metadata') - hosts = self.hosts.lifecycle_hosts() + hosts = self.hosts.lifecycle_hosts() if identifiers is None else {} sources = {self.host: self, **{key: value for key, value in self.remote_bridges.items() if key in hosts}} for host in hosts: remaining() @@ -1014,19 +1026,19 @@ def check_submissions(): # SSH metadata already has its own finite transport timeout. Waiting on # these read-only jobs is also bounded by this operation's deadline. executor = ThreadPoolExecutor(max_workers=min(4, len(sources))) - reads = {host: executor.submit(source.store.lifecycle_threads) for host, source in sources.items()} + reads = {host: executor.submit(source.store.lifecycle_threads) for host, source in sources.items()} if identifiers is None else {} threads = [] try: for host, source in sources.items(): - rows = reads[host].result(timeout=remaining()) - identifiers = {row['id'] for row in rows} + rows = reads[host].result(timeout=remaining()) if identifiers is None else [{'id': sid} for sid in identifiers] + source_identifiers = {row['id'] for row in rows} with source.lock: - identifiers.update(sid for sid, live in source.live.items() - if getattr(live, "origin", "desktop") == "desktop" or - (getattr(live, "runtime", "desktop") == "desktop" and getattr(live, "connected", False))) - if any(not isinstance(sid, str) or not sid for sid in identifiers): + source_identifiers.update(sid for sid, live in source.live.items() if identifiers is None and ( + getattr(live, "origin", "desktop") == "desktop" or + (getattr(live, "runtime", "desktop") == "desktop" and getattr(live, "connected", False)))) + if any(not isinstance(sid, str) or not sid for sid in source_identifiers): raise ValueError('Invalid desktop session metadata') - threads.extend((host, sid) for sid in sorted(identifiers)) + threads.extend((host, sid) for sid in sorted(source_identifiers)) except Exception as exc: raise ValueError('无法读取 Codex 桌面和 SSH 会话,请检查连接后重试') from exc finally: @@ -1100,7 +1112,25 @@ def check_state(record): ipc = DesktopIPC(self.ipc.path, on_event=event, on_disconnect=disconnect) try: remaining() - ipc.connect() + try: + ipc.connect() + except IPCError as exc: + if isinstance(exc.__cause__, FileNotFoundError): + if allow_missing_ipc: + return + if allow_stopped_app and self.host == 'local': + try: + executable = self.accounts.index.get('desktopExecutable') or DesktopApp.discover(self.catalog_reader.executable) + if not executable: + raise ValueError(unknown) + app = DesktopApp(executable, self.codex_home) + app.validate(self.catalog_reader.executable) + if app.processes(): + raise ValueError(unknown) + except (OSError, ValueError, subprocess.SubprocessError) as error: + raise ValueError(unknown) from error + return + raise for host, sid in threads: try: response = ipc.request('thread-owner-discovery', {'hostId': host, 'conversationId': sid}, @@ -1214,7 +1244,7 @@ def inspect(source): 'remoteHostCount': len(sources) - 1, 'hosts': reports} def list(self, query="", limit=100, offset=0, archived=False, source='all', include_sources=False, - local_only=False, host_label=None, entry=None): + local_only=False, host_label=None, entry=None, pin_order=False): if source not in ('all', 'desktop', 'cli', 'wsl'): raise ValueError('不支持的会话来源') if entry and entry not in ('desktop', 'all'): @@ -1222,7 +1252,7 @@ def list(self, query="", limit=100, offset=0, archived=False, source='all', incl target = self.for_host(entry) try: return target.list(query=query, limit=limit, offset=offset, archived=archived, - source=source, include_sources=include_sources) + source=source, include_sources=include_sources, pin_order=pin_order) except (RemoteUnavailable, StoreUnavailable) as exc: self.host_errors = [{'host': entry, 'label': host_label or 'WSL', 'error': str(exc)}] return ([], []) if include_sources else [] @@ -1238,18 +1268,21 @@ def read(host_source): # WSL is a display category; its native source remains CLI. wsl_source = 'wsl' if source in ('all', 'wsl') else 'desktop' value = bridge.list(query=query, limit=limit + offset, archived=archived, - source=wsl_source, include_sources=include_sources) + source=wsl_source, include_sources=include_sources, pin_order=pin_order) rows, available = value if include_sources else (value, []) return rows, None, available available = bridge.store.available_sources() if include_sources else [] + pins = set(bridge.session_pins.identifiers()) rows = ([] if source == 'wsl' else bridge.store.list( - limit=limit + offset, archived=archived, query=query, source=source)) + limit=limit + offset, archived=archived, query=query, source=source, + **({"pinned_ids": sorted(pins)} if pin_order else {}))) rows = self.hosts.decorate(rows, bridge.host, label) with bridge.lock: bridge.listed.update(row['id'] for row in rows) for row in rows: session = bridge.live.get(row['id']) bridge.origins[row['id']] = row.get('origin', 'desktop') + row['bridgePinned'] = row['id'] in pins row['connected'] = bool(session and session.connected) row['access'] = bridge._access(row.get('origin', 'desktop'), session) row['title'] = row.get('name') or row.get('title') or '未命名聊天' @@ -1261,7 +1294,7 @@ def read(host_source): self.host_errors = [error for _, error, _ in results if error] rows = [row for group, _, _ in results for row in group] available_sources = {source for _, _, available in results for source in available} - rows.sort(key=lambda row: (row['recency'], row['id'], row['host']), reverse=True) + rows.sort(key=lambda row: (bool(pin_order and row.get('bridgePinned')), row['recency'], row['id'], row['host']), reverse=True) page = rows[offset:offset + limit] return (page, sorted(available_sources)) if include_sources else page @@ -2477,6 +2510,51 @@ def _resolve_skills(self, session, skills): selected.append({"id": key, "name": skill["name"], "path": skill["path"]}) return selected + @operation + def archive(self, thread_id, archived, request_id, disconnect=False): + if type(disconnect) is not bool or (disconnect and archived is not True): + raise ValueError('聊天归档设置无效') + def read(): + if isinstance(self.store, RemoteStore): + with self.store.lock: + self.store.cache.clear() + metadata = self.store.get(thread_id) + metadata['_archiveFlags'] = self.store.archive_flags(thread_id) + return metadata + def prepare(metadata): + identifiers = [row['id'] for row in self.store.lifecycle_threads(root_id=thread_id)] + if not (disconnect and metadata.get('origin') == 'cli'): + self.assert_desktop_idle(identifiers=identifiers, allow_missing_ipc=metadata.get('origin') == 'cli', + allow_stopped_app=metadata.get('origin') == 'desktop') + with self.submit_lock: + ledgers = (self.submissions, self.goal_commands, self.message_actions) + if any((key.split(':')[0] in identifiers or row.get('source') in identifiers or row.get('id') in identifiers) and row.get('status', row.get('state')) in + ('queued', 'unknown', 'pending', 'sent') for ledger in ledgers for key, row in ledger.items()): + raise ValueError('聊天有排队或待核对操作,请先在原客户端处理') + if metadata.get('origin') == 'cli': + def check_scope(): + if set(identifiers) != {row['id'] for row in self.store.lifecycle_threads(root_id=thread_id)}: + raise ValueError('子会话范围已变化,尚未发送归档请求') + return self.cli_sessions.archive_call(thread_id, archived, identifiers, + **({'disconnect': True, 'check_scope': check_scope} if disconnect else {})) + def call(): + if isinstance(self.store, RemoteStore): + source = source_text('features/sessions/create.py') + source += '\nimport shutil\nhome=Path(os.environ.get("CODEX_HOME", str(Path.home()/".codex")))\n' + source += 'runtime=shutil.which("codex") or str(Path.home()/".local/bin/codex")\n' + source += 'archive_thread(runtime, home, **' + payload({'thread_id': thread_id, 'archived': archived, 'identifiers': identifiers}) + ')\nprint(json.dumps({"ok":True}))\n' + ssh_read(self.store.alias, source, timeout=90) + else: + archive_thread(self.catalog_reader.executable, self.codex_home, thread_id, archived, identifiers) + return call + result = self.archive_commands.perform(thread_id, archived, request_id, read, prepare) + return {**result, 'host': self.host} + + @operation + def pin(self, thread_id, pinned, client_id=None, revision=None): + uuid.UUID(thread_id) + return {**self.session_pins.set(thread_id, pinned, client_id, revision), 'host': self.host} + @operation def rename(self, thread_id, title): uuid.UUID(thread_id) diff --git a/bridge/clients/claude/adapter.py b/bridge/clients/claude/adapter.py index 91df20d2..c1fc9604 100644 --- a/bridge/clients/claude/adapter.py +++ b/bridge/clients/claude/adapter.py @@ -13,10 +13,10 @@ from bridge.clients.claude.context import context_fields from bridge.clients.claude.setup import inspect_installation, console_source, developer_mode_enabled, native_action, running_app, claude_data_home, background_running_app from bridge.clients.errors import BridgeUnavailable +from bridge.features.sessions.create import ArchiveNotSent from bridge.clients.claude.mailbox import FileDesktop, MAX_REQUEST, CONNECTOR_REVISION, workspace_trust_rejection from bridge.clients.claude.cli_session import ClaudeCli, project_account_identity from bridge.clients.claude.remote import RemoteClaudeCli -from bridge.clients.claude.wsl import ClaudeCliRemotes from bridge.app.lifecycle import private_json from bridge.platforms.windows import session as windows_session from bridge.platforms.windows.session import DesktopUnavailable @@ -29,7 +29,11 @@ def __init__(self, directory, *, auto_connect=True, hosts=None, wsl_entries=None self.directory = Path(directory) self.hosts = hosts self.cli = ClaudeCli(self.directory.parent/'uploads') - self.remote_cli = ClaudeCliRemotes(hosts, wsl_entries) if wsl_entries is not None else RemoteClaudeCli(hosts) + if wsl_entries is not None: + from bridge.clients.claude.wsl import ClaudeCliRemotes + self.remote_cli = ClaudeCliRemotes(hosts, wsl_entries) + else: + self.remote_cli = RemoteClaudeCli(hosts) self.desktop = None self.index = {} self.loop = None @@ -589,7 +593,7 @@ async def listing(self): for kind in ('code', 'cowork'): if 'getAll' in self.desktop.capabilities.get(kind, []): raw = batch[kind] if batch is not None else await self.desktop.call(kind, 'getAll') - rows.extend(model.session(kind, row) for row in model.rows(raw)) + rows.extend({**model.session(kind, row), 'capabilities': self.session_capabilities(kind)} for row in model.rows(raw)) coverage[kind] = True self.index = {row['id']: row for row in rows} return {'connected': True, 'sessions': rows, 'coverage': coverage, 'complete': all(coverage.values())} @@ -612,6 +616,8 @@ def session_capabilities(self, kind): 'permissions': 'setPermissionMode' in methods, 'attachments': 'sendMessage' in methods, 'skills': 'getSupportedCommands' in methods, 'queue': kind == 'code', 'steer': kind == 'code', 'fork': kind == 'code' and 'mobileFork' in methods, + 'archive': kind == 'code' and 'mobileArchive' in methods, + 'unarchive': kind == 'code' and 'mobileArchive' in methods, 'contextUsage': any(method in methods for method in ('getContextUsageSummary', 'getContextUsage'))} def cli_projects(self, include_remote=True): @@ -701,10 +707,10 @@ async def dispatch(self, action, sid, body): desktop = await self.listing() return {'connected': True, 'sessions': desktop['sessions'] + cli['sessions'] + remote['sessions'], 'coverage': {**desktop['coverage'], 'cli': cli['connected']}, - 'complete': desktop['complete'] and remote['complete']} + 'complete': desktop['complete'] and cli.get('complete', True) and remote['complete']} return {'connected': cli['connected'] or remote['connected'], 'sessions': cli['sessions'] + remote['sessions'], 'coverage': {'code': False, 'cowork': False, 'cli': cli['connected']}, - 'complete': (cli['connected'] or remote['connected']) and remote['complete']} + 'complete': (cli['connected'] or remote['connected']) and cli.get('complete', True) and remote['complete']} if action == 'resume': if await asyncio.to_thread(self.remote_cli.contains, sid): return await asyncio.to_thread(self.remote_cli.call, action, sid, body) @@ -763,6 +769,26 @@ async def dispatch(self, action, sid, body): if not saved: raise ValueError('找不到 Claude 会话') kind, native = saved['backend'], saved['nativeId'] + if action in ('archive-state', 'archive'): + if kind != 'code' or not self.session_capabilities(kind)['archive']: + raise ValueError('当前接入尚未确认原生归档和恢复能力') + state = await self.desktop.call(kind, 'mobileArchiveState', native) + session = model.session(kind, state['session']) + if session['id'] != sid: + raise BridgeUnavailable('桌面返回的归档目标标识不一致') + result = {**session, '_archiveFlags': state['archiveFlags'], 'archiveSafe': state.get('archiveSafe') is True, + 'capabilities': self.session_capabilities(kind)} + if action == 'archive-state': + return result + if getattr(self.desktop, 'unconfirmed_mutations', None): + raise ArchiveNotSent('Claude 有待核对的原生写入,请先在原客户端处理') + options = {'archived': body['archived']} + if body.get('disconnect') is True: + options['disconnect'] = True + reply = await self.desktop.call(kind, 'mobileArchive', native, options) + if reply.get('archiveNotSent') is True: + raise ArchiveNotSent(reply['message']) + return {'status': 'accepted'} history_limit = body.get('historyLimit', 40) if action == 'detail' and (type(history_limit) is not int or not 1 <= history_limit <= 100): raise ValueError('历史页大小无效') @@ -925,7 +951,7 @@ def call(self, action, sid=None, body=None, *, timeout=100): return {'connected': cli['connected'] or remote['connected'], 'sessions': cli['sessions'] + remote['sessions'], 'coverage': {'code': False, 'cowork': False, 'cli': cli['connected']}, - 'complete': (cli['connected'] or remote['connected']) and remote['complete']} + 'complete': (cli['connected'] or remote['connected']) and cli.get('complete', True) and remote['complete']} if action == 'resume': if self.remote_cli.contains(sid): return self.remote_cli.call(action, sid, body or {}) diff --git a/bridge/clients/claude/cli_session.py b/bridge/clients/claude/cli_session.py index 2a8f34ff..6cbe4081 100644 --- a/bridge/clients/claude/cli_session.py +++ b/bridge/clients/claude/cli_session.py @@ -13,7 +13,7 @@ from collections import deque from pathlib import Path -from bridge.clients.claude import model +from bridge.clients.claude import model, file_archive from bridge.clients.claude.history import file_stamp, page as history_page from bridge.clients.claude.context import context_fields from bridge.clients.claude.session_registry import assert_session_closed @@ -486,6 +486,32 @@ def control(self, request, timeout=20): raise BridgeUnavailable('Claude Code 控制请求未确认,请检查原会话') return response.get('response') or {} + def release_for_archive(self, timeout=20): + """Finish this owned CLI only; never terminate a foreign or shared process.""" + deadline = time.monotonic() + timeout + with self.condition: + if self.error: + raise BridgeUnavailable(self.error) + active = self.active or bool(self.pending) + if self.process.poll() is not None: + return + if active: + self.control({'subtype': 'interrupt'}, timeout=max(.1, deadline-time.monotonic())) + with self.condition: + while (self.active or self.pending) and not self.finished and time.monotonic() < deadline: + self.condition.wait(max(0, deadline-time.monotonic())) + if self.error or self.active or self.pending: + raise BridgeUnavailable('停止结果待核对,尚未发送归档请求') + # EOF lets the original executor flush and exit; no terminate/kill fallback. + self.process.stdin.close() + try: + code = self.process.wait(timeout=max(.1, deadline-time.monotonic())) + # Native print mode exits 1 after an acknowledged user interruption. + if code != 0 and not (active and code == 1): + raise BridgeUnavailable('原会话未正常退出,尚未发送归档请求') + except subprocess.TimeoutExpired: + raise BridgeUnavailable('原会话连接尚未释放,尚未发送归档请求') from None + def close(self): try: if self.native_process is not None: @@ -574,10 +600,30 @@ def listing(self): row = self._saved_cached(path, profile['origin']) if row: rows[row['id']] = row + archive_errors = [] + for path in file_archive.archive_paths(profile['directory'], archive_errors): + row = self._saved_cached(path, profile['origin']) + if row: + rows[row['id']] = row + for error in archive_errors: + native = error['nativeId'] + sid = model.gateway_id('cli:' + profile['origin'], native) + rows[sid] = {'id': sid, 'nativeId': native, 'backend': 'cli', 'mode': 'cli', + 'host': 'local', 'title': error['notice'], 'cwd': '', 'model': '', + 'permissionMode': 'default', 'permissionKnown': False, 'updatedAt': 0, + 'archived': True, 'archiveIncomplete': True, 'origin': profile['origin'], + 'path': profile['directory']/'bridge-archives'/native/(native+'.jsonl'), + 'stamp': None, 'events': [], 'contextEvents': []} + env = managed_api_env() if profile.get('managedApi') else os.environ.copy() + env['CLAUDE_CONFIG_DIR'] = str(profile['directory']) + enabled = file_archive.supported(profile['binary'], env) + for row in rows.values(): + allowed = enabled and not row.get('archiveIncomplete') + row['capabilities'] = {'archive': allowed, 'unarchive': allowed} with self.lock: self.index = rows public = [self._public(row) for row in rows.values()] - return {'connected': True, 'sessions': public} + return {'connected': True, 'sessions': public, 'complete': not archive_errors} def _public(self, row): value = {key: item for key, item in row.items() if key not in ('events', 'contextEvents', 'stamp', 'path', 'origin', 'permissionKnown')} @@ -600,6 +646,8 @@ def _public(self, row): if live: value['model'] = owned.model or value['model'] value['permissionMode'] = owned.permission or value['permissionMode'] + if row.get('archiveIncomplete'): + value.update(status='unknown', runtimeKnown=False, connectionState='unavailable') return value def _get(self, sid): @@ -630,7 +678,13 @@ def _saved_cached(self, path, origin): cached = self.index.get(sid) if cached and cached['origin'] == origin and cached.get('stamp') == stamp and cached['path'] == path: return cached - return _saved(path, origin) + row = _saved(path, origin) + profile = self.profile_resolver() + if row and profile and profile['origin'] == origin: + if path.parent.parent == profile['directory']/'bridge-archives': + row.update(file_archive.state(profile['directory'], path, row['nativeId'])) + row['capabilities'] = cached.get('capabilities', {}) if cached else {} + return row def contains(self, sid): profile = self.profile_resolver() @@ -727,6 +781,38 @@ def call(self, action, sid, body): owned = None public = self._public(row) live = owned is not None + if action in ('archive-state', 'archive'): + profile = self.profile_resolver() + if not profile or profile['origin'] != row['origin']: + raise BridgeUnavailable('原 Claude Code 配置已变化') + env = managed_api_env() if profile.get('managedApi') else os.environ.copy() + env['CLAUDE_CONFIG_DIR'] = str(profile['directory']) + enabled = file_archive.supported(profile['binary'], env) + safe = False + if enabled and live and action == 'archive' and body.get('disconnect') is True and body.get('archived') is True: + owned.release_for_archive() + live = False + public = self._public(row) + if enabled and not live: + assert_session_closed(profile['directory']) + file_archive.assert_no_cli(profile['binary']) + safe = True + result = {**public, **file_archive.state(profile['directory'], row['path'], native, verify=True), + 'archiveSafe': safe, 'runtimeKnown': True, + 'capabilities': {'archive': enabled, 'unarchive': enabled}} + if action == 'archive-state': + return result + if not safe: + raise BridgeUnavailable('无法确认原 Claude Code 会话已停止或原生归档能力未验证') + if self.profile_resolver() != profile: + raise BridgeUnavailable('原 Claude Code 配置已变化') + assert_session_closed(profile['directory']) + file_archive.assert_no_cli(profile['binary']) + if not file_archive.supported(profile['binary'], env): + raise BridgeUnavailable('Claude 原生程序在归档前变化或无法核验') + value = file_archive.change(profile['directory'], row['path'], native, body.get('archived')) + self.index.pop(sid, None) + return value if action == 'detail': before = body.get('historyBefore') if before is not None and (not isinstance(before, str) or len(before) > 1024): @@ -743,7 +829,7 @@ def call(self, action, sid, body): public.update(context_fields(row['contextEvents'] + live_events)) messages = model.transcript(events) unique = {item['id']: item for item in messages} - can_resume = (not live and row['permissionKnown'] and self._ready(self.profile_resolver()) + can_resume = (not row['archived'] and not live and row['permissionKnown'] and self._ready(self.profile_resolver()) and Path(row['cwd']).is_dir()) return {'connected': True, 'session': public, 'messages': list(unique.values()), 'turns': model.turns(events), @@ -753,7 +839,8 @@ def call(self, action, sid, body): 'resume': can_resume, 'send': live and public['connectionState'] == 'ready', 'models': live, 'permissions': live, 'stop': live, - 'attachments': True, 'queue': False, 'steer': False, 'skills': live}} + 'attachments': True, 'queue': False, 'steer': False, 'skills': live, + **row.get('capabilities', {})}} if action == 'catalog': if not live: return {'models': [], 'capabilities': {'models': False}, 'notice': '请先继续这条 Claude Code 会话'} @@ -764,6 +851,8 @@ def call(self, action, sid, body): return {'status': 'accepted', 'mode': public['permissionMode'], 'options': options if live else []} if action == 'resume': + if row['archived']: + raise ResumePreflightBlocked('请先恢复这条已归档的 Claude Code 会话') if live: return {'status': 'ready'} profile = self.profile_resolver() diff --git a/bridge/clients/claude/connector.js b/bridge/clients/claude/connector.js index 91f24f5e..195d88f6 100644 --- a/bridge/clients/claude/connector.js +++ b/bridge/clients/claude/connector.js @@ -95,7 +95,7 @@ } const surfaces = {code: 'LocalSessions', cowork: 'LocalAgentModeSessions'}; const allowed = new Set(['getAll', 'getSession', 'getTranscript', 'sendMessage', - 'interrupt', 'stop', 'respondToToolPermission', 'start', 'getPermissionMode', 'archive', 'delete', 'updateSession', + 'interrupt', 'stop', 'respondToToolPermission', 'start', 'getPermissionMode', 'archive', 'unarchive', 'loadArchivedSessions', 'delete', 'updateSession', 'setPermissionMode', 'setModel', 'setEffort', 'getDefaultEffort', 'getSupportedCommands', 'setThinkingSummariesWanted', 'getContextUsageSummary', 'getContextUsage', 'listSessionDirectory', 'readSessionFile', 'readSessionImageAsDataUrl', 'readSessionPanelDocumentAsDataUrl']); const capabilities = Object.fromEntries(Object.entries(surfaces).map(([kind, name]) => @@ -112,6 +112,7 @@ for (const [kind, surface] of Object.entries(surfaces)) { capabilities[kind].push('mobileCatalog', 'mobileProgress', 'mobileAccount'); if(kind==='code')capabilities[kind].push('mobileList'); + if(kind==='code'&&['getAll','getSession','archive','unarchive'].every(method=>typeof api[surface]?.[method]==='function'))capabilities[kind].push('mobileArchiveState','mobileArchive'); if(typeof api[surface]?.getSession==='function'&&typeof api[surface]?.getTranscript==='function')capabilities[kind].push('mobileDetail'); if(kind==='code'&&typeof api[surface]?.getTranscriptTail==='function'&&typeof api[surface]?.getTranscriptPage==='function')capabilities[kind].push('mobileHistory'); if(kind==='code'&&typeof api[surface]?.forkSession==='function'&&typeof api[surface]?.getTranscript==='function')capabilities[kind].push('mobileFork'); @@ -288,6 +289,7 @@ return target.forkSession(id,'local_'+options.id,options.messageId); } async function mobileList(){ + if(typeof api.LocalSessions?.loadArchivedSessions==='function')await api.LocalSessions.loadArchivedSessions(); const rows=await Promise.all(Object.entries(surfaces).filter(([,name])=>typeof api[name]?.getAll==='function').map(async([kind,name])=>{ const result=await api[name].getAll(); if(Array.isArray(result))knownSessions.set(api[name],result); @@ -295,6 +297,41 @@ })); return Object.fromEntries(rows); } + async function mobileArchiveState(target,id){ + if(target!==api.LocalSessions||!capabilities.code.includes('mobileArchive'))throw Error('当前接入尚未确认原生归档和恢复能力'); + const [root,all]=await Promise.all([target.getSession(id),target.getAll()]); + if(root?.sessionId!==id||!Array.isArray(all))throw Error('归档目标或子会话状态无法确认'); + const scope=new Set([id]);let expanded=true; + while(expanded){expanded=false;for(const row of all){if(scope.has(row.spawnedFrom?.sessionId)&&!scope.has(row.sessionId)){scope.add(row.sessionId);expanded=true;}}} + const rows=[root,...all.filter(row=>row.sessionId!==id&&scope.has(row.sessionId))]; + const nonempty=value=>Array.isArray(value)?value.length>0:value&&typeof value==='object'?true:!!value; + const idle=row=>row&&row.isRunning===false&&typeof row.isArchived==='boolean'&& + !row.turnRunning&&!row.starting&&!row.cliBootPending&&!row.hasBackgroundActivity&&!row.hasBackgroundWork&&!row.hasLiveWorkflows&& + ['pendingToolPermissions','pendingRefusalFallbackPrompt','pendingAutoModeServerFallbackPrompt','pendingLanyardConsent','pendingViolinBowPrompt','pendingCwdTrustPrompt','heldInput','queuedSideSessionNotes','spawningTaskIds'].every(key=>!nonempty(row[key])); + return {session:publicSession(root),archiveSafe:rows.every(idle),archiveFlags:Object.fromEntries(rows.map(row=>[row.sessionId,row.isArchived])), + runtimeKnown:rows.every(row=>typeof row.isRunning==='boolean'&&typeof row.isArchived==='boolean'), + runningIds:rows.filter(row=>row.isRunning||row.turnRunning).map(row=>row.sessionId)}; + } + async function mobileArchive(target,id,options={}){ + if(typeof options.archived!=='boolean'||(options.disconnect!=null&&typeof options.disconnect!=='boolean')||options.disconnect&&!options.archived)throw Error('归档设置无效'); + let state=await mobileArchiveState(target,id); + if(options.disconnect){ + if(!state.runtimeKnown||state.runningIds.length&&typeof target.interrupt!=='function')return {archiveNotSent:true,message:'无法确认可停止的原生任务,未停止或归档'}; + const scope=Object.keys(state.archiveFlags).sort().join(','); + for(const child of [...state.runningIds].reverse())await target.interrupt(child); + const deadline=Date.now()+15000; + do{ + state=await mobileArchiveState(target,id); + if(Object.keys(state.archiveFlags).sort().join(',')!==scope)throw Error('子会话范围已变化,尚未发送归档请求'); + if(state.archiveSafe)break; + if(Date.now()>=deadline)throw Error('停止结果待核对,尚未发送归档请求'); + await new Promise(resolve=>setTimeout(resolve,100)); + }while(true); + } + if(!state.archiveSafe)return {archiveNotSent:true,message:'聊天或子会话有运行、排队、审批或未知状态,请先在原客户端处理'}; + if(options.archived)await target.archive(id,{cleanupWorktree:false});else await target.unarchive(id); + return {status:'accepted'}; + } async function mobileDetail(target,id,options={}){ const paged=target===api.LocalSessions&&capabilities.code.includes('mobileHistory'); const limit=Number.isInteger(options.historyLimit)&&options.historyLimit>=1&&options.historyLimit<=100?options.historyLimit:40; @@ -343,6 +380,8 @@ if (!target || !Array.isArray(msg.args)) throw new Error('不支持的桌面操作'); if (msg.method === 'mobileCatalog') return {result:await mobileCatalog(target,msg.surface,msg.args[0])}; if (msg.method === 'mobileAccount') return {result:await mobileAccount()}; + if (msg.method === 'mobileArchiveState' && capabilities[msg.surface].includes('mobileArchiveState')) return {result:await mobileArchiveState(target,msg.args[0])}; + if (msg.method === 'mobileArchive' && capabilities[msg.surface].includes('mobileArchive')) return {result:await mobileArchive(target,msg.args[0],msg.args[1])}; if (msg.method === 'mobileList') return {result:await bounded(mobileList(),30000,'读取会话超时')}; if (msg.method === 'mobileDetail' && capabilities[msg.surface].includes('mobileDetail')) return {result:await bounded(mobileDetail(target,msg.args[0],msg.args[1]),30000,'读取会话超时')}; if (msg.method === 'mobileFork' && capabilities[msg.surface].includes('mobileFork')) return {result:await mobileFork(target,msg.args[0],msg.args[1])}; @@ -484,6 +523,8 @@ const target = api[surfaces[msg.surface]]; if (target && msg.method === 'mobileCatalog') return mobileCatalog(target,msg.surface,msg.args[0]); if (target && msg.method === 'mobileAccount') return mobileAccount(); + if (target && msg.method === 'mobileArchiveState' && capabilities[msg.surface].includes('mobileArchiveState')) return mobileArchiveState(target,msg.args[0]); + if (target && msg.method === 'mobileArchive' && capabilities[msg.surface].includes('mobileArchive')) return mobileArchive(target,msg.args[0],msg.args[1]); if (target && msg.method === 'mobileList') return mobileList(); if (target && msg.method === 'mobileDetail' && capabilities[msg.surface].includes('mobileDetail')) return mobileDetail(target,msg.args[0],msg.args[1]); if (target && msg.method === 'mobileFork' && capabilities[msg.surface].includes('mobileFork')) return mobileFork(target,msg.args[0],msg.args[1]); diff --git a/bridge/clients/claude/file_archive.py b/bridge/clients/claude/file_archive.py new file mode 100644 index 00000000..e0c9d0be --- /dev/null +++ b/bridge/clients/claude/file_archive.py @@ -0,0 +1,420 @@ +"""Byte-preserving local transcript archive; standalone for selected remote workers.""" +import hashlib +import json +import os +import subprocess +import sys +import shutil +import re +import shlex +from functools import lru_cache +import uuid +import tempfile +from pathlib import Path + +# Populated only after native picker hide/restore acceptance for an exact version. +VERIFIED_PICKER_VERSIONS = { + ('darwin', '2.1.292'): '97a01e5bc74a199e67189435d0331ea3a24eac2e07db4b76d9148c5b0386138f', + ('linux', '2.1.308'): '356733f185fa988e6bf3aa9a555269fdd167982a2e2a80d3c8a78037bb92ad58', +} + + +def supported(binary, environment): + if not any(platform == sys.platform for platform, version in VERIFIED_PICKER_VERSIONS): + return False + try: + path = Path(binary).resolve(strict=True) + stamp = path.stat() + digest = _binary_hash(str(path), stamp.st_dev, stamp.st_ino, stamp.st_size, stamp.st_mtime_ns) + if not any(platform == sys.platform and expected == digest + for (platform, version), expected in VERIFIED_PICKER_VERSIONS.items()): + return False + result = subprocess.run([binary, '--version'], env=environment, capture_output=True, + text=True, timeout=5) + expected = VERIFIED_PICKER_VERSIONS.get((sys.platform, result.stdout.split()[0])) + return result.returncode == 0 and digest == expected + except (OSError, subprocess.SubprocessError, IndexError, ValueError): + return False + + +@lru_cache(maxsize=8) +def _binary_hash(path, device, inode, size, modified): + digest = hashlib.sha256() + with open(path, 'rb') as source: + for block in iter(lambda: source.read(1024*1024), b''): + digest.update(block) + stamp = Path(path).stat() + if (stamp.st_dev, stamp.st_ino, stamp.st_size, stamp.st_mtime_ns) != (device, inode, size, modified): + raise ValueError('Claude 原生程序在核验期间变化') + return digest.hexdigest() + + +def _linux_executable(pid, uid, namespace): + """Read the actual executable with same-UID/namespace/generation evidence.""" + process = Path('/proc')/str(pid) + def proof(): + if process.stat().st_uid != uid or os.readlink(process/'ns/pid') != namespace: + raise ValueError('Claude 进程归属或 PID 命名空间已变化') + value = (process/'stat').read_text() + fields = value.rpartition(')')[2].split() + if not value.startswith(str(pid)+' (') or len(fields) < 20 or not fields[19].isdigit(): + raise ValueError('Claude 进程代际不可验证') + return fields[19] + try: + before = proof() + executable = os.readlink(process/'exe') + if proof() != before: + raise ValueError('Claude 进程代际已变化') + return executable.removesuffix(' (deleted)') + except FileNotFoundError: + # A missing exe can also mean an exited main thread with live workers. + # Only a vanished process directory proves there is no current writer. + try: + process.stat() + except FileNotFoundError: + return None + except OSError as error: + raise ValueError('无法核验 Claude 进程可执行文件') from error + raise ValueError('无法核验 Claude 进程可执行文件') from None + except OSError as error: + raise ValueError('无法核验 Claude 进程可执行文件') from error + + +def assert_no_cli(binary): + """Fresh process identity census; no environment or conversation reads.""" + if sys.platform not in ('darwin', 'linux'): + raise ValueError('当前系统的 Claude 原生进程清单未验证') + namespace = None + if sys.platform == 'linux': + try: namespace = os.readlink('/proc/self/ns/pid') + except OSError as error: raise ValueError('无法核验 Claude PID 命名空间') from error + if not re.fullmatch(r'pid:\[\d+\]', namespace): + raise ValueError('无法核验 Claude PID 命名空间') + result = subprocess.run(['ps', '-axo', 'uid=,pid=,comm='], capture_output=True, + text=True, timeout=5) + if result.returncode: + raise ValueError('无法核验 Claude 原生进程清单') + for line in result.stdout.splitlines(): + parts = line.strip().split(None, 2) + if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit(): + raise ValueError('Claude 原生进程清单不完整') + if int(parts[0]) != os.getuid():continue + pid, executable = int(parts[1]), parts[2] + if sys.platform == 'linux': + comm = Path(executable).name + if comm in ('claude', 'claude.exe', 'claude-code'): + raise ValueError('仍有 Claude CLI 进程运行,请先在原终端结束') + candidate = (comm == Path(binary).name[:15] or comm in ('node', 'nodejs', 'bun') + or re.fullmatch(r'\d+\.\d+\.\d+(?:[-+].*)?', comm)) + if not candidate: continue + executable = _linux_executable(pid, os.getuid(), namespace) + if executable is None: continue + name = Path(executable).name + if (name in ('claude', 'claude.exe', 'claude-code') + or '@anthropic-ai/claude-code/' in executable + or re.search(r'/(?:\.local/share/claude/versions/[^/]+|Caskroom/claude-code/[^/]+(?:/[^/]+)?)$', executable) + or str(Path(executable).resolve()) == str(Path(binary).resolve())): + raise ValueError('仍有 Claude CLI 进程运行,请先在原终端结束') + if name not in ('node', 'nodejs', 'bun'):continue + # Inspect the entry-script operand only; never log the argument vector, + # parse a prompt operand, or inspect process environments. + command = subprocess.run(['ps', '-p', str(pid), '-o', 'args='], + capture_output=True, text=True, timeout=3) + if command.returncode or not command.stdout.strip(): + raise ValueError('无法核验 Claude 脚本进程身份') + lexer = shlex.shlex(command.stdout, posix=True) + lexer.whitespace_split = True;lexer.commenters = '' + next(lexer, None) + for token in lexer: + if token in ('-e', '--eval', '-p', '--print'):break + if token in ('-r', '--require', '--import', '--loader'): + next(lexer, None);continue + if token.startswith('-'):continue + normalized = token.replace('\\', '/') + if ('@anthropic-ai/claude-code/' in normalized + or '/claude-code/' in normalized + or Path(normalized).name in ('claude', 'claude.js', 'claude.cjs', 'claude.mjs')): + raise ValueError('仍有 Claude CLI 脚本进程运行,请先在原终端结束') + break + + +@lru_cache(maxsize=1) +def _domain(): + if sys.platform == 'linux': + machine = Path('/etc/machine-id').read_text().strip() + if not re.fullmatch(r'[0-9a-fA-F]{32}', machine): + raise ValueError('Claude 归档主机身份不可验证') + return 'linux:'+machine + if sys.platform == 'darwin': + result = subprocess.run(['/usr/sbin/ioreg', '-rd1', '-c', 'IOPlatformExpertDevice'], + capture_output=True, text=True, timeout=5) + match = re.search(r'"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"', result.stdout) + if result.returncode or not match: + raise ValueError('Claude 归档主机身份不可验证') + return 'darwin:'+hashlib.sha256(match[1].encode()).hexdigest() + raise ValueError('当前系统的 Claude 文件归档未验证') + + +def _identity(directory): + directory = Path(directory) + stat = directory.stat() + return {'configDir': str(directory.resolve()), 'uid': os.getuid() if hasattr(os, 'getuid') else None, + 'device': stat.st_dev, 'inode': stat.st_ino, 'domain': _domain()} + + +def _root(directory): + directory = Path(directory) + root = directory/'bridge-archives' + _owned(directory) + if root.exists(): + _owned(root) + if root.stat().st_dev != directory.stat().st_dev: + raise ValueError('Claude 归档必须位于原配置文件系统') + if directory.is_symlink() or root.is_symlink(): + raise ValueError('Claude 归档路径不可验证') + return root + + +def _owned(path): + if path.is_symlink() or (hasattr(os, 'getuid') and path.stat().st_uid != os.getuid()): + raise ValueError('Claude 归档文件归属不可验证') + + +def _regular_file(path): + if path.is_symlink() or not path.is_file(): + raise ValueError('Claude 归档文件不可验证') + _owned(path) + + +def _digest(path): + _regular_file(path) + value = hashlib.sha256() + with path.open('rb') as source: + for block in iter(lambda: source.read(1024*1024), b''): + value.update(block) + return value.hexdigest() + + +def _sync_directory(path): + if os.name != 'nt': + handle = os.open(path, os.O_RDONLY) + try:os.fsync(handle) + finally:os.close(handle) + + +def _write(path, value): + descriptor, temporary = tempfile.mkstemp(prefix='.manifest-', suffix='.tmp', dir=path.parent) + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as target: + json.dump(value, target, ensure_ascii=False) + target.flush() + os.fsync(target.fileno()) + os.replace(temporary, path) + _sync_directory(path.parent) + finally: + try:os.unlink(temporary) + except FileNotFoundError:pass + + +def _manifest(directory, native): + native = str(uuid.UUID(native)) + bundle = _root(directory)/native + if bundle.is_symlink(): + raise ValueError('Claude 归档目录不可验证') + if bundle.exists():_owned(bundle) + path = bundle/'manifest.json' + if not path.exists(): + return bundle, None + if path.exists():_owned(path) + if path.is_symlink(): + raise ValueError('Claude 归档记录不可验证') + value = json.loads(path.read_text(encoding='utf-8')) + if (value.get('schema') != 1 or value.get('nativeId') != native + or value.get('identity') != _identity(directory) + or not isinstance(value.get('project'), str) + or value['project'] in ('', '.', '..') + or '/' in value['project'] or '\\' in value['project']): + raise ValueError('Claude 归档记录不可验证') + return bundle, value + + +def _original(directory, project, native): + projects = Path(directory)/'projects' + target = projects/project + if projects.is_symlink() or target.is_symlink() or not target.is_dir(): + raise ValueError('Claude 原项目路径不可验证') + _owned(projects);_owned(target) + if target.stat().st_dev != Path(directory).stat().st_dev: + raise ValueError('Claude 会话与归档必须位于同一文件系统') + return target/(native+'.jsonl') + + +def archive_paths(directory, errors=None): + if not Path(directory).exists() and not Path(directory).is_symlink(): + return [] + root = _root(directory) + if not root.exists(): + return [] + result = [] + for bundle in root.iterdir(): + if not bundle.is_dir() or bundle.name.startswith('.'): + continue + try: + native = str(uuid.UUID(bundle.name)) + except ValueError: + continue + try: + bundle, manifest = _manifest(directory, native) + if manifest and manifest.get('phase') != 'restored': + state(directory, bundle/(native+'.jsonl'), native) + result.append(bundle/(native+'.jsonl')) + except (OSError, ValueError, TypeError, KeyError): + if errors is None:raise + errors.append({'nativeId': native, 'notice': 'Claude Code 归档待核对'}) + return result + + +def state(directory, path, native, *, verify=False): + bundle, manifest = _manifest(directory, native) + if not manifest: + original = _original(directory, Path(path).parent.name, native) + if Path(path) != original: + raise ValueError('Claude 原会话路径不可验证') + _regular_file(original) + return {'archived': False} + original = _original(directory, manifest['project'], native) + archived = bundle/(native+'.jsonl') + phase = manifest.get('phase') + if phase not in ('archived', 'restored'): + raise ValueError('Claude 文件归档事务中断,请在电脑端核对;不会自动重放') + expected, absent = (archived, original) if phase == 'archived' else (original, archived) + if (absent.exists() or absent.is_symlink() + or (verify and phase == 'archived' and _digest(expected) != manifest['sha256'])): + raise ValueError('Claude 归档文件冲突或已变化,请在电脑端核对') + _regular_file(expected) + return {'archived': phase == 'archived'} + + +def _private_files(root): + if not root.exists() and not root.is_symlink():return [] + _owned(root) + if not root.is_dir():raise ValueError('Claude 会话附属目录不可验证') + result = [] + for path in root.rglob('*'): + _owned(path) + if path.is_file():result.append(path) + elif not path.is_dir():raise ValueError('Claude 会话附属文件不可验证') + return result + + +def _snapshot(directory, original, bundle, native): + destination = bundle/'sidecars' + if destination.exists(): + _private_files(destination) + shutil.rmtree(destination) + roots = [original.parent/native, Path(directory)/'file-history'/native] + # Validate the complete trees before creating any copy. + for root in roots: + if root.parent.exists() or root.parent.is_symlink():_owned(root.parent) + files = [(root, _private_files(root)) for root in roots] + inventory = {} + for index, (root, items) in enumerate(files): + for source in items: + target = destination/str(index)/source.relative_to(root) + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + shutil.copy2(source, target, follow_symlinks=False) + with target.open('rb') as copied:os.fsync(copied.fileno()) + parent = target.parent + while parent != bundle: + _sync_directory(parent);parent = parent.parent + inventory[str(target.relative_to(destination))] = _digest(target) + if _digest(source) != _digest(target): + raise ValueError('Claude 附属文件在备份期间变化') + return inventory + + +def _restore_snapshots(directory, original, bundle, native, manifest): + snapshot = bundle/'sidecars' + actual = {str(path.relative_to(snapshot)): _digest(path) for path in _private_files(snapshot)} + if actual != manifest.get('sidecars', {}): + raise ValueError('Claude 附属文件备份已变化') + roots = [original.parent/native, Path(directory)/'file-history'/native] + missing = [] + for index, root in enumerate(roots): + backup = bundle/'sidecars'/str(index) + for source in _private_files(backup): + target = root/source.relative_to(backup) + parent = target.parent + while parent != Path(directory): + if parent.exists() or parent.is_symlink():_owned(parent) + parent = parent.parent + if target.exists() or target.is_symlink(): + if _digest(target) != _digest(source): + raise ValueError('Claude 附属文件冲突,恢复不会覆盖') + else:missing.append((source,target)) + for source,target in missing: + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + # Independent copy: later native edits must not mutate the backup inode. + with source.open('rb') as reader, target.open('xb') as writer: + shutil.copyfileobj(reader, writer);writer.flush();os.fsync(writer.fileno()) + shutil.copystat(source,target,follow_symlinks=False) + + +def change(directory, path, native, archived): + """Caller must freshly verify the exact profile and stopped native writer.""" + if type(archived) is not bool: + raise ValueError('Claude 归档设置无效') + native = str(uuid.UUID(native)) + directory = Path(directory) + root = _root(directory) + root.mkdir(mode=0o700, exist_ok=True) + lock = root/'.transaction' + try: + lock.mkdir(mode=0o700) + except FileExistsError as exc: + raise ValueError('Claude 文件归档事务待核对,不会自动重放') from exc + try: + before = state(directory, path, native, verify=True) + if before['archived'] == archived: + return before + bundle, manifest = _manifest(directory, native) + if manifest is None: + original = _original(directory, Path(path).parent.name, native) + bundle.mkdir(mode=0o700) + manifest = {'schema': 1, 'nativeId': native, 'project': original.parent.name, + 'identity': _identity(directory), + 'sha256': _digest(original)} + original = _original(directory, manifest['project'], native) + stored = bundle/(native+'.jsonl') + source, target = (original, stored) if archived else (stored, original) + if archived: + if source.stat().st_nlink != 1: + raise ValueError('Claude 会话已有共享硬链接,不能归档') + manifest['sha256'] = _digest(source) + manifest['sidecars'] = _snapshot(directory, original, bundle, native) + else: + _restore_snapshots(directory, original, bundle, native, manifest) + previous = 'restored' if archived else 'archived' + manifest['phase'] = 'preparing' + _write(bundle/'manifest.json', manifest) + linked = False + try: + # link refuses existing destinations atomically; same filesystem only. + with source.open('rb') as original_file:os.fsync(original_file.fileno()) + os.link(source, target, follow_symlinks=False) + linked = True + if _digest(target) != manifest['sha256'] or _digest(source) != manifest['sha256']: + raise ValueError('Claude 会话在归档期间变化') + source.unlink() + _sync_directory(source.parent);_sync_directory(target.parent) + manifest['phase'] = 'archived' if archived else 'restored' + _write(bundle/'manifest.json', manifest) + except Exception: + if linked and source.exists() and target.exists() and _digest(source) == _digest(target): + target.unlink() + manifest['phase'] = previous + _write(bundle/'manifest.json', manifest) + raise + return state(directory, target, native, verify=True) + finally: + lock.rmdir() diff --git a/bridge/clients/claude/mailbox.py b/bridge/clients/claude/mailbox.py index e20229c2..b050831a 100644 --- a/bridge/clients/claude/mailbox.py +++ b/bridge/clients/claude/mailbox.py @@ -22,8 +22,8 @@ MAX_REQUEST = 10 * 1024 * 1024 # Bump when the injected renderer must reload a changed API contract. A new # owner generation alone only reconnects the existing JavaScript closure. -CONNECTOR_REVISION = 10 -MUTATIONS = {'sendMessage', 'interrupt', 'stop', 'respondToToolPermission', 'start', 'archive', +CONNECTOR_REVISION = 12 +MUTATIONS = {'sendMessage', 'interrupt', 'stop', 'respondToToolPermission', 'start', 'archive', 'unarchive', 'mobileArchive', 'delete', 'updateSession', 'setPermissionMode', 'setModel', 'setEffort', 'setThinkingSummariesWanted', 'mobileFork'} diff --git a/bridge/clients/claude/remote.py b/bridge/clients/claude/remote.py index 11ad00ce..0e353b45 100644 --- a/bridge/clients/claude/remote.py +++ b/bridge/clients/claude/remote.py @@ -22,8 +22,17 @@ from bridge.resources import source_text -def _command(alias): +def worker_source(): source = source_text('clients/claude/remote_worker.py') + marker = 'from bridge.clients.claude.file_archive import archive_paths, state as archive_file_state, change as archive_file_change, supported as archive_supported, assert_no_cli as archive_assert_no_cli' + if source.count(marker) != 1: + raise ValueError('Claude Code 远端归档模块无法加载') + helper = source_text('clients/claude/file_archive.py') + return source.replace(marker, helper + '\narchive_file_state = state\narchive_file_change = change\narchive_supported = supported\narchive_assert_no_cli = assert_no_cli\n') + + +def _command(alias): + source = worker_source() inner = 'exec python3 -u -c ' + shlex.quote(source) return ssh_command(alias)[:-1] + ['exec "$SHELL" -lc ' + shlex.quote(inner)] @@ -85,7 +94,7 @@ def _row(self, host_id, host, raw): sid = model.gateway_id('cli:'+self.transport_kind+':'+host_id+':'+origin, native) return {**raw, 'id': sid, 'backend': 'cli', 'mode': 'cli', 'host': host_id, 'hostLabel': host.get('displayName') or host['alias'], 'alias': host['alias'], - 'archived': False} + 'archived': raw.get('archived') is True} def _fetch(self, item): host_id, host = item @@ -111,6 +120,8 @@ def _refresh(self, hosts): rows.update({sid: row for sid, row in previous.items() if row['host'] == host_id and row['alias'] == host['alias']}) continue + if result.get('complete') is False: + complete = False for item in result.get('sessions') or []: if isinstance(item, dict) and item.get('nativeId') and item.get('origin'): row = self._row(host_id, host, item) @@ -250,6 +261,9 @@ def _public(self, row): if live: value['model'] = owned.model or value['model'] value['permissionMode'] = owned.permission or value['permissionMode'] + if row.get('archiveIncomplete'): + value.update(runtimeKnown=False, status='unknown', connectionState='closed', requests=[], + capabilities={'archive': False, 'unarchive': False}) return value def _upload_files(self, row, files): @@ -363,7 +377,43 @@ def create(self, host_id, alias, origin, cwd, message, directory_identity=None): owned.close() raise + def _archive(self, action, sid, body): + with self.lock: + row = self.index.get(sid) + if row is None or self.hosts is None: + raise ValueError('找不到原 Claude Code 会话') + host = self.hosts.hosts().get(row['host']) + if not host or host['alias'] != row['alias']: + raise ValueError('原 SSH 主机配置已变化') + owned = self.owned.get(sid) + live = owned is not None and owned.process.poll() is None + if live and (owned.origin != row['origin'] or owned.native != row['nativeId'] or owned.error): + raise BridgeUnavailable('原 Claude Code 执行连接身份无法核验') + request = {'operation': action, 'native': row['nativeId'], 'origin': row['origin']} + if action == 'archive': + if (set(body) not in ({'archived'}, {'archived', 'disconnect'}) or type(body['archived']) is not bool or + type(body.get('disconnect', False)) is not bool or (body.get('disconnect') and body['archived'] is not True)): + raise ValueError('Claude 归档设置无效') + if live: + if not body.get('disconnect'): + raise BridgeUnavailable('原 Claude Code 会话仍有网关执行连接,请先在原客户端结束') + owned.release_for_archive() + live = False + request['archived'] = body['archived'] + fresh = self._remote(row['alias'], request) + updated = self._row(row['host'], host, fresh) + if updated['id'] != sid: + raise ValueError('原 SSH Claude Code 会话身份已变化') + current_host = self.hosts.hosts().get(row['host']) + if not current_host or current_host['alias'] != row['alias']: + raise ValueError('原 SSH 主机配置已变化') + self.index[sid] = updated + self.history = {key: value for key, value in self.history.items() if key[0] != sid} + return {**self._public(updated), 'archiveSafe': not live and fresh.get('archiveSafe') is True} + def call(self, action, sid, body): + if action in ('archive-state', 'archive'): + return self._archive(action, sid, body) try: with self.lock: cached = self.index.get(sid) @@ -432,7 +482,7 @@ def call(self, action, sid, body): public.update(context_fields((row.get('contextEvents') or []) + live_events)) messages = model.transcript(history) ready = False - if not live and row['permissionKnown']: + if not live and not row.get('archived') and row['permissionKnown']: try: ready = self._remote(row['alias'], {'operation': 'auth', 'origin': row['origin']}, timeout=12)['ready'] except BridgeUnavailable: @@ -442,9 +492,11 @@ def call(self, action, sid, body): 'turns': model.turns(history), 'history': page['history'], 'notice': '无法确认原会话权限模式,请在远端终端继续' if not live and not row['permissionKnown'] else '', - 'capabilities': {'resume': not live and ready, 'send': live and public['connectionState'] == 'ready', + 'capabilities': {**row.get('capabilities', {}), 'resume': not live and not row.get('archived') and ready, 'send': live and public['connectionState'] == 'ready', 'models': live, 'permissions': live, 'stop': live, 'attachments': True, 'queue': False, 'steer': False, 'skills': live}} + if row.get('archived'): + raise BridgeUnavailable('请先恢复归档的 Claude Code 会话') if action == 'catalog': if not live: return {'models': [], 'capabilities': {'models': False}, 'notice': '请先继续这条 Claude Code 会话'} diff --git a/bridge/clients/claude/remote_worker.py b/bridge/clients/claude/remote_worker.py index 22512adc..d235ef80 100644 --- a/bridge/clients/claude/remote_worker.py +++ b/bridge/clients/claude/remote_worker.py @@ -11,6 +11,8 @@ import uuid from pathlib import Path +from bridge.clients.claude.file_archive import archive_paths, state as archive_file_state, change as archive_file_change, supported as archive_supported, assert_no_cli as archive_assert_no_cli + MODES = {'default', 'acceptEdits', 'plan', 'dontAsk', 'auto', 'bypassPermissions'} @@ -371,38 +373,50 @@ def saved(path, found, *, include_events=False): return row -def sessions(found): +def session_paths(found, errors=None): root = Path(found['directory'])/'projects' - if not root.is_dir() or root.is_symlink(): - return [] result = [] - for directory in root.iterdir(): - if not directory.is_dir() or directory.is_symlink(): - continue - for path in directory.glob('*.jsonl'): - row = saved(path, found) - if row: - result.append(row) + if root.is_dir() and not root.is_symlink(): + for directory in root.iterdir(): + if directory.is_dir() and not directory.is_symlink(): + result.extend(path for path in directory.glob('*.jsonl') if not path.is_symlink()) + return result + archive_paths(found['directory'], errors if errors is not None else []) + + +def sessions(found, errors=None): + errors = errors if errors is not None else [] + result = [] + capable = archive_supported(found['binary'], os.environ.copy()) + for path in session_paths(found, errors): + row = saved(path, found) + if row: + result.append({**row, 'archived': path.parent.parent == Path(found['directory'])/'bridge-archives', + 'capabilities': {'archive': capable, 'unarchive': capable}}) + for error in errors: + result.append({'nativeId': error['nativeId'], 'origin': found['origin'], + 'title': 'Claude Code 归档待核对', 'cwd': '', 'model': '', + 'permissionMode': 'default', 'permissionKnown': False, 'updatedAt': 0, + 'stamp': None, 'contextEvents': [], 'archived': True, 'archiveIncomplete': True, + 'runtimeKnown': False, 'status': 'unknown', + 'capabilities': {'archive': False, 'unarchive': False}}) return result def session_path(found, native): native = str(uuid.UUID(native)) - root = Path(found['directory'])/'projects' - if not root.is_dir() or root.is_symlink(): - raise ValueError('原 Claude Code 会话不可用') - for directory in root.iterdir(): - if directory.is_dir() and not directory.is_symlink(): - path = directory/(native+'.jsonl') - if path.is_file() and not path.is_symlink(): - return path - raise ValueError('原 Claude Code 会话不可用') + matches = [path for path in session_paths(found) if path.stem == native and path.is_file()] + if len(matches) != 1: + raise ValueError('原 Claude Code 会话路径不可用或存在冲突') + return matches[0] def session(found, native): - row = saved(session_path(found, native), found) + path = session_path(found, native) + row = saved(path, found) if row: - return row + capable = archive_supported(found['binary'], os.environ.copy()) + return {**row, **archive_file_state(found['directory'], path, native), + 'capabilities': {'archive': capable, 'unarchive': capable}} raise ValueError('原 Claude Code 会话不可用') @@ -431,8 +445,10 @@ def linux_process_start(pid): return fields[19] -def assert_session_closed(found, native): +def assert_session_closed(found, native=None): directory = Path(found['directory'])/'sessions' + if native is None and not directory.exists() and not directory.is_symlink(): + return if not directory.is_dir() or directory.is_symlink(): raise ValueError('无法核验 Claude Code 原会话是否仍在运行') domain = pid_domain() @@ -444,7 +460,7 @@ def assert_session_closed(found, native): entry = json.loads(path.read_text(encoding='utf-8')) if not isinstance(entry, dict): raise ValueError('无法核验 Claude Code 原会话是否仍在运行') - if entry.get('sessionId') != native: + if native is not None and entry.get('sessionId') != native: continue pid = entry.get('pid') start = entry.get('procStart') @@ -537,12 +553,38 @@ def command(request): raise ValueError('WSL 账号配置在续接核验期间变化') return {'identity': identity, 'profile': found, 'sessions': rows} if operation == 'list': - return {'profile': found, 'sessions': sessions(found) if found else []} + errors = [] + rows = sessions(found, errors) if found else [] + return {'profile': found, 'sessions': rows, 'complete': not errors} if operation == 'projects': return {'profile': found, 'sessions': sessions(found) if found else [], 'authReady': auth_ready(found) if found else False} if not found or found['origin'] != request.get('origin'): raise ValueError('原 Claude Code 配置已变化或不可用') + if operation in ('archive-state', 'archive'): + native = str(uuid.UUID(request['native'])) + row = session(found, native) + row.update(archive_file_state(found['directory'], session_path(found, native), native, verify=True)) + if not row['capabilities']['archive']: + raise ValueError('原 Claude Code 归档能力或停止状态尚未确认') + safe = False + try: + assert_session_closed(found) + archive_assert_no_cli(found['binary']) + safe = True + except ValueError: + if operation == 'archive': + raise + if found != profile(): + raise ValueError('原 Claude Code 配置已变化或不可用') + if operation == 'archive': + assert_session_closed(found) + archive_assert_no_cli(found['binary']) + if not archive_supported(found['binary'], os.environ.copy()): + raise ValueError('原 Claude Code 二进制归档能力已变化,请重新核对') + archive_file_change(found['directory'], session_path(found, native), native, request.get('archived')) + row = session(found, native) + return {**row, 'archiveSafe': safe, 'runtimeKnown': True, 'status': 'idle', 'requests': []} if operation == 'detail': return session(found, request['native']) if operation == 'history': @@ -564,6 +606,8 @@ def resume(request): if not found or found['origin'] != request.get('origin'): raise ValueError('原 Claude Code 配置已变化或不可用') row = session(found, request['native']) + if row.get('archived'): + raise ValueError('请先恢复归档的 Claude Code 会话') if row['cwd'] != request.get('cwd') or row['model'] != request.get('model') or row['permissionMode'] != request.get('permissionMode'): raise ValueError('原 Claude Code 会话设置已变化') if not row['permissionKnown'] or not auth_ready(found): diff --git a/bridge/clients/claude/wsl.py b/bridge/clients/claude/wsl.py index c45af37e..0b8fe8d4 100644 --- a/bridge/clients/claude/wsl.py +++ b/bridge/clients/claude/wsl.py @@ -11,10 +11,9 @@ import zlib from bridge.app.lifecycle import private_json -from bridge.clients.claude.remote import RemoteClaudeCli +from bridge.clients.claude.remote import RemoteClaudeCli, worker_source from bridge.clients.errors import BridgeUnavailable from bridge.platforms.windows import wsl -from bridge.resources import source_text def entry_id(distribution, user, directory): @@ -70,7 +69,7 @@ def scan(self): candidates, errors = {}, [] for distribution in wsl.running_distributions(): try: - info = wsl.read(distribution, '__name__="wsl_claude_probe"\n'+source_text('clients/claude/remote_worker.py')+PROBE) + info = wsl.read(distribution, '__name__="wsl_claude_probe"\n'+worker_source()+PROBE) for profile in info.get('profiles', []): directory = profile['configDir'] identifier = entry_id(distribution, info['user'], directory) @@ -261,7 +260,7 @@ def _row(self, host_id, host, raw): def _command(self, alias): row = self.hosts.selected(alias) source = 'import os\nos.environ["MOBILE_BRIDGE_WSL_NATIVE"]="1"\nos.environ["CLAUDE_CONFIG_DIR"]='+repr(row['configDir'])+'\n' - source += source_text('clients/claude/remote_worker.py') + source += worker_source() # wsl.command rechecks running state before each invocation. The worker # receives native stream-json stdin without another protocol envelope. encoded = base64.b64encode(zlib.compress(source.encode('utf-8'))).decode('ascii') diff --git a/bridge/clients/codex/app_server.py b/bridge/clients/codex/app_server.py index aa30c677..09e9ba72 100644 --- a/bridge/clients/codex/app_server.py +++ b/bridge/clients/codex/app_server.py @@ -226,6 +226,9 @@ def request(self, method, params=None, timeout=15, replay_safe=False): error = response['error'] code = error.get('code') if isinstance(error, dict) else None detail = error.get('message', 'App-server request failed') if isinstance(error, dict) else str(error) + if method in ('thread/archive', 'thread/unarchive'): + from bridge.features.sessions.create import ArchiveRejected + raise ArchiveRejected(error) if code == -32601: raise AppServerUnsupported(detail) raise AppServerError(detail) diff --git a/bridge/clients/codex/cli_session.py b/bridge/clients/codex/cli_session.py index 5d8ba578..08c6a263 100644 --- a/bridge/clients/codex/cli_session.py +++ b/bridge/clients/codex/cli_session.py @@ -208,6 +208,36 @@ def create(self, cwd, title, on_start=None, on_created=None): self.client.request('thread/name/set', {'threadId': thread_id, 'name': title}) return self.refresh(thread_id) + def archive_call(self, thread_id, archived, identifiers, disconnect=False, check_scope=None): + # Explicit archive may start the installed original daemon, never a thread. + info = self._connect_explicit() + home = Path(info.get('codexHome', '')) + expected = self.remote_home if self.alias else self.home + if not home.is_absolute() or (expected is not None and home != expected): + raise AppServerUnavailable('Codex CLI runtime belongs to another home') + from bridge.features.sessions.create import check_archive_runtime, stop_archive_runtime + if not disconnect: + check_archive_runtime(lambda method, params: self.client.request(method, params, replay_safe=True), identifiers) + with self.lock: + if not disconnect and any(identifier in self.requests and self.requests[identifier] for identifier in identifiers): + raise ValueError('聊天有等待确认的请求,请先在原客户端处理') + process = self.client.process + def call(): + if self.client.process is not process: + from bridge.features.sessions.create import ArchiveNotSent + raise ArchiveNotSent('原运行时连接已变化,未停止或归档') + if disconnect: + def request(method, params): + if self.client.process is not process: + raise AppServerUnavailable('原运行时连接已变化,尚未发送归档请求') + return self.client.request(method, params) + stop_archive_runtime(request, identifiers) + if check_scope: + check_scope() + return request('thread/archive', {'threadId': thread_id}) + return self.client.request('thread/archive' if archived else 'thread/unarchive', {'threadId': thread_id}) + return call + def attach(self, thread_id, explicit=False): """Passive inspection never starts or resumes a runtime.""" if not explicit: diff --git a/bridge/clients/codex/remote.py b/bridge/clients/codex/remote.py index d6700f92..5f237489 100644 --- a/bridge/clients/codex/remote.py +++ b/bridge/clients/codex/remote.py @@ -113,8 +113,11 @@ def available_sources(self): def notification_changes(self, since=0): return self.call('notification_changes', {'since': since}) - def lifecycle_threads(self): - return self.call('lifecycle_threads', {}) + def lifecycle_threads(self, root_id=None): + return self.call('lifecycle_threads', {} if root_id is None else {'root_id': root_id}) + + def archive_flags(self, thread_id): + return self.call('archive_flags', {'thread_id': thread_id}) def get(self, thread_id): return self.call('get', {'thread_id': thread_id}) diff --git a/bridge/clients/codex/shared_server.py b/bridge/clients/codex/shared_server.py index 59be9d8c..b3829b7c 100644 --- a/bridge/clients/codex/shared_server.py +++ b/bridge/clients/codex/shared_server.py @@ -21,8 +21,9 @@ class SharedServer: METHODS = frozenset({ 'thread/list', 'thread/read', 'thread/loaded/list', 'thread/turns/list', 'thread/items/list', 'thread/resume', 'thread/start', + 'thread/backgroundTerminals/list', 'thread/backgroundTerminals/clean', 'turn/start', 'turn/steer', 'turn/interrupt', 'thread/settings/update', - 'thread/name/set', 'model/list', 'config/read', + 'thread/name/set', 'thread/archive', 'thread/unarchive', 'model/list', 'config/read', 'skills/list', 'configRequirements/read', 'permissionProfile/list', 'experimentalFeature/list', 'account/read', 'thread/goal/get', 'thread/goal/set', 'thread/goal/clear', }) @@ -111,6 +112,9 @@ def _request(self, method, params, timeout): self.write({'id': identifier, 'method': method, 'params': params}) response = future.result(timeout=timeout) if 'error' in response: + if method in ('thread/archive', 'thread/unarchive'): + from bridge.features.sessions.create import ArchiveRejected + raise ArchiveRejected(response['error']) error = IPCError('WSL Codex 拒绝此操作,请检查原会话状态和版本') error.rpc_error = response['error'] raise error diff --git a/bridge/clients/deepseek/adapter.py b/bridge/clients/deepseek/adapter.py index c9f0a90e..a62f6943 100644 --- a/bridge/clients/deepseek/adapter.py +++ b/bridge/clients/deepseek/adapter.py @@ -10,13 +10,14 @@ from bridge.features.accounts.models import NoRedirect from bridge.app.lifecycle import private_json from bridge.clients.errors import BridgeUnavailable +from bridge.features.sessions.create import ArchiveNotSent from bridge.clients.deepseek.setup import MARKER, insertion, verified_directory, legacy_account_configured PLUGIN_ID = re.compile(r'''["']?id["']?\s*:\s*["']?codex-mobile-desktop(?:["'\s,}]|$)''') -BRIDGE_REVISION = 5 -# Revisions 3–5 share the complete native-agent lifecycle schema; presentation +BRIDGE_REVISION = 7 +# Revisions 3–6 share the complete native-agent lifecycle schema; presentation # and fork capability additions do not invalidate an older idle snapshot. -LIFECYCLE_REVISIONS = (3, 4, BRIDGE_REVISION) +LIFECYCLE_REVISIONS = (3, 4, 5, 6, BRIDGE_REVISION) UPDATE_REASON = 'Harness 接入需要更新,请结束任务后点击“重启并接入”' @@ -197,6 +198,8 @@ def call(self, action, sid=None, body=None): raise BridgeUnavailable(message) from exc except (OSError, KeyError) as exc: raise BridgeUnavailable('DeepSeek 桌面未连接,请安装接入插件并重新打开 Harness') from exc + if action == 'archive' and value.get('archiveNotSent') is True: + raise ArchiveNotSent(value.get('message') or '原生客户端拒绝归档,操作未发送') if action == 'detail': records = value.pop('records', []) value['messages'] = messages(records) diff --git a/bridge/clients/deepseek/host.mjs b/bridge/clients/deepseek/host.mjs index 705c6746..00670585 100644 --- a/bridge/clients/deepseek/host.mjs +++ b/bridge/clients/deepseek/host.mjs @@ -30,14 +30,14 @@ export async function apply(ctx,config){ const list=async()=> (await ctx.sessionController.list({},signal())).items; const find=async id=>{const row=(await list()).find(r=>r.sessionId===id);if(!row)throw Error('DeepSeek 会话不存在');return row;}; const values=row=>row.projections?.values||{}; - const capabilities=()=>({models:typeof ctx.sessionController.selectModel==='function',effort:typeof ctx.sessionController.selectModel==='function',permissions:typeof ctx.permissionPresets?.set==='function',attachments:typeof ctx.sessionController.prompt==='function',skills:typeof ctx.sessionSkillCatalog?.list==='function',queue:typeof ctx.sessionController.prompt==='function',steer:typeof ctx.sessionController.prompt==='function',fork:typeof ctx.sessionController.fork==='function'}); + const capabilities=()=>({models:typeof ctx.sessionController.selectModel==='function',effort:typeof ctx.sessionController.selectModel==='function',permissions:typeof ctx.permissionPresets?.set==='function',attachments:typeof ctx.sessionController.prompt==='function',skills:typeof ctx.sessionSkillCatalog?.list==='function',queue:typeof ctx.sessionController.prompt==='function',steer:typeof ctx.sessionController.prompt==='function',fork:typeof ctx.sessionController.fork==='function',archive:typeof ctx.workspaceRegistry.archiveSession==='function'&&typeof ctx.workspaceRegistry.unarchiveSession==='function',unarchive:typeof ctx.workspaceRegistry.archiveSession==='function'&&typeof ctx.workspaceRegistry.unarchiveSession==='function'}); const contextUsage=row=>{ const pressure=values(row).contextPressure||{},used=pressure.projectedTokens??pressure.pressureTokens,limit=pressure.contextWindow; return Number.isFinite(used)&&used>=0&&Number.isFinite(limit)&&limit>0 ? {usedTokens:used,contextWindow:limit,estimated:Number.isFinite(pressure.projectedTokens)&&pressure.projectedTokens!==pressure.pressureTokens} : null; }; const permissionState=row=>{const catalog=ctx.permissionPresets.catalog(),mode=values(row).permissions?.currentValue||catalog.defaultPreset,option=catalog.options.find(o=>o.value===mode);return {permissionMode:mode,permissionLabel:localized(option?.label||option?.name)||mode};}; - const normalize=row=>{const v=values(row),m=v.modelSelection?.next||v.modelSelection?.lastUsed||ctx.get?.('agentDefaultModel')?.currentSelection()||{};return {id:row.sessionId,title:v.title||'新会话',cwd:row.cwd||'',backend:'deepseek',mode:'desktop',status:typeof row.running==='boolean'?(row.running?'active':'idle'):'unknown',runtimeKnown:typeof row.running==='boolean',updatedAt:row.updatedAt,model:m.provider&&m.model?m.provider+'/'+m.model:'',effort:m.reasoningEffort||'',...permissionState(row),contextUsage:contextUsage(row),requests:[...questions.values()].filter(q=>q.sid===row.sessionId).map(q=>q.public)};}; + const normalize=row=>{const v=values(row),m=v.modelSelection?.next||v.modelSelection?.lastUsed||ctx.get?.('agentDefaultModel')?.currentSelection()||{};return {id:row.sessionId,capabilities:capabilities(),title:v.title||'新会话',cwd:row.cwd||'',backend:'deepseek',mode:'desktop',status:typeof row.running==='boolean'?(row.running?'active':'idle'):'unknown',runtimeKnown:typeof row.running==='boolean',updatedAt:row.updatedAt,model:m.provider&&m.model?m.provider+'/'+m.model:'',effort:m.reasoningEffort||'',...permissionState(row),contextUsage:contextUsage(row),requests:[...questions.values()].filter(q=>q.sid===row.sessionId).map(q=>q.public)};}; const bounded=async promise=>{let timer;try{return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('插件目录读取超时,请重试')),6000);})]);}finally{clearTimeout(timer);}}; const localized=value=>typeof value==='string'?value:value?.['zh-CN']||value?.zh||value?.en||''; const catalog=async(sid,section='all')=>{ @@ -107,12 +107,12 @@ export async function apply(ctx,config){ function lifecycle(){ // The runtime registry includes child agents omitted from the chat list. // This snapshot is requested only before a native quit/restart. - if(typeof ctx.agents.list!=='function')return {connected:true,bridgeRevision:5,complete:false,sessions:[]}; + if(typeof ctx.agents.list!=='function')return {connected:true,bridgeRevision:7,complete:false,sessions:[]}; const agents=ctx.agents.list(); - if(!Array.isArray(agents))return {connected:true,bridgeRevision:5,complete:false,sessions:[]}; + if(!Array.isArray(agents))return {connected:true,bridgeRevision:7,complete:false,sessions:[]}; const sessions=agents.map(agent=>({id:agent.id,status:agent.status==='running'?'active':agent.status==='idle'?'idle':'unknown',runtimeKnown:['idle','running'].includes(agent.status),requests:[...questions.values()].filter(q=>q.sid===agent.id).map(q=>q.public)})); for(const question of questions.values())if(!sessions.some(s=>s.id===question.sid))sessions.push({id:question.sid,status:'waiting',runtimeKnown:true,requests:[question.public]}); - return {connected:true,bridgeRevision:5,complete:sessions.every(s=>typeof s.id==='string'&&s.id.length>0),sessions}; + return {connected:true,bridgeRevision:7,complete:sessions.every(s=>typeof s.id==='string'&&s.id.length>0),sessions}; } const idle=runtime=>runtime.complete&&runtime.sessions.every(s=>s.runtimeKnown&&s.status==='idle'&&!s.requests.length); const quitFailed=error=>{quitting=false;ctx.logger?.error?.('Harness native quit failed: '+String(error.message||error));}; @@ -135,7 +135,7 @@ export async function apply(ctx,config){ quitting=true; return {status:'accepted',pid:process.pid,generation}; } - if(action==='status')return {connected:true,bridgeRevision:5,projectDirectories:true,nativeQuit:typeof ctx.get?.('appExit')==='function',configured:(await account()).status==='configured'}; + if(action==='status')return {connected:true,bridgeRevision:7,projectDirectories:true,nativeQuit:typeof ctx.get?.('appExit')==='function',configured:(await account()).status==='configured'}; if(action==='account')return account(sid); if(action==='list')return {connected:true,sessions:(await list()).filter(r=>r.origin!=='subagent').map(r=>({...normalize(r),archived:(ctx.workspaceRegistry.archivedSessionIds||[]).includes(r.sessionId)})).sort((a,b)=>Number(a.archived)-Number(b.archived))}; if(action==='projects'){const seen=new Map();for(const row of await list())if(row.cwd)seen.set(row.cwd,{key:row.cwd,cwd:row.cwd,name:row.cwd.replaceAll('\\','/').split('/').pop()});for(const w of ctx.workspaceRegistry.list())seen.set(w.path,{key:w.path,cwd:w.path,name:w.title||w.name||w.path,workspaceId:w.id});return {projects:[...seen.values()]};} @@ -170,7 +170,28 @@ export async function apply(ctx,config){ return {status:'ready',result:{id:child.sessionId}}; } if(action==='rename'){const title=String(body.title||'').trim();if(!title||title.length>120)throw Error('标题为 1–120 字');await ctx.sessionController.rename({sessionId:sid,title});return {status:'accepted'};} - if(action==='archive'){if(body.confirmed!==true)throw Error('请确认归档');await ctx.workspaceRegistry.archiveSession(sid,{stopActivity:true});return {status:'accepted'};} + if(action==='archive-state')return {...normalize(row),archived:(ctx.workspaceRegistry.archivedSessionIds||[]).includes(sid),_archiveFlags:{[sid]:(ctx.workspaceRegistry.archivedSessionIds||[]).includes(sid)},archiveSafe:row.running===false&&![...questions.values()].some(q=>q.sid===sid)}; + if(action==='archive'){ + if(typeof body.archived!=='boolean'||!capabilities().archive)throw Error('当前接入尚未确认原生归档和恢复能力'); + if(body.disconnect!=null&&typeof body.disconnect!=='boolean'||body.disconnect&&!body.archived)throw Error('归档设置无效'); + if(body.disconnect){ + if(typeof ctx.parallel!=='function'||typeof ctx.waterfall!=='function'||typeof row.running!=='boolean')return {archiveNotSent:true,message:'无法确认可停止的原生任务,未停止或归档'}; + // Native stop providers cover this session's turn, jobs, schedules and descendants. + // Do not use stopActivity: that native option writes the archive flag BEFORE stopping. + await ctx.parallel('workspace/session-stop',{sessionId:sid}); + const deadline=Date.now()+15000; + while(true){ + const activity=await ctx.waterfall('workspace/session-activity',{sessionId:sid},()=>Promise.resolve([])); + const fresh=await find(sid); + if(Array.isArray(activity)&&!activity.length&&fresh.running===false&&![...questions.values()].some(q=>q.sid===sid))break; + if(Date.now()>=deadline)throw Error('停止结果待核对,尚未发送归档请求'); + await new Promise(resolve=>setTimeout(resolve,100)); + } + }else if(row.running!==false||[...questions.values()].some(q=>q.sid===sid))return {archiveNotSent:true,message:'聊天有运行、审批或未知状态,请先在原客户端处理'}; + try{if(body.archived)await ctx.workspaceRegistry.archiveSession(sid,{});else await ctx.workspaceRegistry.unarchiveSession(sid);} + catch(error){if(error?.name==='WorkspaceActiveSessionError'&&error.sessionId===sid&&Array.isArray(error.activity))return {archiveNotSent:true,message:'聊天或子智能体有运行中的工作,请先在原客户端处理'};throw error;} + return {status:'accepted'}; + } if(action==='open')return {ok:true}; if(action==='detail'){const page=await readHistoryPage(ctx.sessionController,row,body,signal());return {session:normalize(row),capabilities:capabilities(),records:page.rows.filter(r=>['user/message','assistant/message','tool/call','tool/result','todo/write','turn/start','turn/end'].includes(r.event.type)),history:page.history,steps:values(row).todos||[],connected:true};} if(action==='catalog')return catalog(sid,body.section||'all'); diff --git a/bridge/clients/deepseek/setup.py b/bridge/clients/deepseek/setup.py index f2d8c79e..1c29785c 100644 --- a/bridge/clients/deepseek/setup.py +++ b/bridge/clients/deepseek/setup.py @@ -42,6 +42,15 @@ PREVIOUS_WORKSPACE_SOURCES = ('b7f3c05ec967e736897f068b9da2820f7ce98a151a12051691e641540174e640', '0d673a9d13fb7f32da07f36ff34e45d1fd48de555cb20661e4ab8192b1427582') +# Revision 5 before reversible native archive and guarded activity semantics. +PREVIOUS_ARCHIVE_SOURCES = ('0b7d749e831f181c7b0ea9ad9d4bc6fd5858355c8facb52e1a5e3e6a8c0fce8d', + '0f3e7b0962a8f1a9de1a9333afaa74316c83ab6cad18b820a132b8179d3e592e') + + +# Revision 6 before explicitly confirmed stop-before-archive, LF and CRLF. +PREVIOUS_DISCONNECT_SOURCES = ('b8b05cd4d93af60913b505c842dc6b74d2860199980bab079fe2b4e17e153752', + '95451e5ff26ba7e0e5f139a1109ceeaaf3dba1a51b169d3e5d5207d5ef29873b') + def insertion(text): """Read only our JSON flow insertion, including Harness's line wrapping. @@ -160,7 +169,8 @@ def verified_directory(entry, home): if digest not in current_sources and digest not in ( LEGACY_SOURCE, PREVIOUS_SOURCE, PREVIOUS_CLEANUP_SOURCE, PREVIOUS_CATALOG_SOURCE, *PREVIOUS_QUIT_SOURCES, *PREVIOUS_CONTEXT_SOURCES, *PREVIOUS_HISTORY_SOURCES, - *PREVIOUS_PROJECT_SOURCES, *PREVIOUS_WORKSPACE_SOURCES): + *PREVIOUS_PROJECT_SOURCES, *PREVIOUS_WORKSPACE_SOURCES, *PREVIOUS_ARCHIVE_SOURCES, + *PREVIOUS_DISCONNECT_SOURCES): return None return directory, digest not in current_sources except (OSError, ValueError, KeyError, TypeError, IndexError): diff --git a/bridge/clients/manager.py b/bridge/clients/manager.py index ce0b6748..7e81ca4f 100644 --- a/bridge/clients/manager.py +++ b/bridge/clients/manager.py @@ -23,13 +23,14 @@ from bridge.features.accounts.draft_identity import draft_identity from bridge.app.lifecycle import private_json from bridge.platforms.windows import session as windows_session +from bridge.features.sessions.archive import ArchiveCommands from bridge.features.sessions.projects import Projects from bridge.features.sessions.create import project_directory, project_directory_listing from bridge.clients.codex.remote import ssh_read, payload from bridge.resources import source_text READS = {'list', 'detail', 'catalog', 'projects', 'account', 'access'} -WRITES = {'send', 'stop', 'settings', 'respond', 'create', 'access', 'resume', 'fork'} +WRITES = {'send', 'stop', 'settings', 'respond', 'create', 'access', 'resume', 'fork', 'pin', 'archive'} class TaskStateUnavailable(ValueError): @@ -66,6 +67,8 @@ def __init__(self, directory, bridge=None, *, gateway_running=False): self.claude_quit = None self.directory = Path(directory)/'desktop-sessions' self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) + from bridge.features.sessions.pins import SessionPins + self.session_pins = {provider: SessionPins(self.directory/provider, provider) for provider in ('claude', 'deepseek')} self.model_store = ModelStore(self.directory) self.directory_projects = Projects(self.directory/'projects') self.model_locks = {} @@ -75,8 +78,10 @@ def __init__(self, directory, bridge=None, *, gateway_running=False): self.model_lock = threading.Lock() self.config_path = self.directory/'settings.json' self.config = json.loads(self.config_path.read_text()) if self.config_path.exists() else {} - from bridge.clients.claude.wsl import WslClaudeEntries - self.claude_wsl = WslClaudeEntries(self.directory) + self.claude_wsl = None + if sys.platform == 'win32': + from bridge.clients.claude.wsl import WslClaudeEntries + self.claude_wsl = WslClaudeEntries(self.directory) self.adapters = {'deepseek': DeepSeek(self.directory/'deepseek', self.config.get('deepseekHome')), 'claude': Claude(self.directory/'claude', auto_connect=False, hosts=bridge.hosts if bridge is not None and hasattr(bridge, 'hosts') else None, @@ -588,6 +593,8 @@ def list_with_activity(self, provider): with self.read_lock: binding, adapter = self.binding_epochs[provider], self.adapters[provider] value = self.call(provider, 'list') + pins = set(self.session_pins[provider].identifiers()) + value = {**value, 'sessions': [{**row, 'bridgePinned': row['id'] in pins} for row in value.get('sessions', [])]} if value.get('connected') is not False: self._list_activity(provider, adapter, binding, value.get('sessions', [])) with self.read_lock: @@ -707,6 +714,37 @@ def project_directories(self, provider, environment, path=None, hidden=False, na 'value': path, 'hidden': hidden, 'name': name, 'identity': identity}) + ')))\n' return self._project_read(selected, source) + def _archive(self, provider, sid, body): + if (set(body) not in ({'id', 'archived'}, {'id', 'archived', 'disconnect'}) or + not isinstance(body.get('archived'), bool) or not isinstance(body.get('id'), str) or + type(body.get('disconnect', False)) is not bool or (body.get('disconnect') and body['archived'] is not True)): + raise ValueError('归档请求格式无效') + uuid.UUID(body['id']) + with self._changing(provider, rebind=False): + self.require_enabled(provider) + adapter = self.adapters[provider] + directory = adapter.directory + scoped = str(uuid.uuid5(uuid.NAMESPACE_URL, json.dumps([provider, str(directory), str(getattr(adapter, 'home', '')), sid]))) + commands = ArchiveCommands(directory) + def read(): + return self._adapter_call(provider, 'archive-state', sid, {}) + def prepare(state): + if not state.get('capabilities', {}).get('archive') or not state.get('capabilities', {}).get('unarchive'): + raise ValueError('当前接入尚未确认原生归档和恢复能力') + if (state.get('runtimeKnown') is not True or state.get('status') not in ('idle', 'active') or + (not body.get('disconnect') and (not state.get('archiveSafe') or state.get('status') != 'idle' or state.get('requests')))): + raise ValueError('聊天有运行、排队、审批或未知状态,请先在原客户端处理') + with self.db_lock: + db = self._receipts(adapter) + if any(json.loads(row[0]).get('status') in ('unknown', 'queued') for row in db.execute('SELECT result FROM requests')): + raise ValueError('此接入有排队或待核对操作,请先在原客户端处理') + options = {'archived': body['archived']} + if body.get('disconnect'): + options['disconnect'] = True + return lambda: self._adapter_call(provider, 'archive', sid, options) + result = commands.perform(scoped, body['archived'], body['id'], read, prepare) + return {**result, 'id': sid} + def call(self, provider, action, sid=None, body=None): if provider not in self.adapters or action not in READS | WRITES: raise ValueError('不支持的桌面操作') @@ -717,7 +755,14 @@ def call(self, provider, action, sid=None, body=None): body = body or {} if any(key in body for key in ('directoryProject', 'projectDirectory')): raise ValueError('项目目录必须通过目录选择器登记') + if action == 'pin': + self.require_enabled(provider) + if set(body) not in ({'pinned'}, {'pinned', 'clientId', 'revision'}): + raise ValueError('聊天置顶设置无效') + return self.session_pins[provider].set(sid, body['pinned'], body.get('clientId'), body.get('revision')) adapter = self.adapters[provider] + if action == 'archive': + return self._archive(provider, sid, body) if action == 'account': return self._account_models(provider, refresh=body.get('refresh') is True) if action == 'catalog' and body.get('section') == 'models': @@ -849,6 +894,8 @@ def status(self): return result def _wsl_claude_enabled(self): + if self.claude_wsl is None: + return False with self.claude_wsl.lock: return any(row.get('enabled', True) for row in self.claude_wsl.entries) @@ -1167,7 +1214,11 @@ def _prepare_deepseek(self, restart=False): needs_update = bool(installed.get('updateRequired') or previous.get('requiredBridgeRevision') and status.get('bridgeRevision') != BRIDGE_REVISION or status.get('updateRequired')) needs_reload = needs_update or previous.get('setupStatus') == 'restart-required' and not status.get('connected') - if state['running'] and (restart or needs_reload): + if state['running'] and needs_reload and not restart: + self._deepseek_connection('idle', UPDATE_REASON, setupStatus='restart-required', + reason=UPDATE_REASON, requiredBridgeRevision=BRIDGE_REVISION) + return + if state['running'] and restart: self._stop_client('deepseek', row, state) if needs_update or getattr(adapter, 'reused', False) and (restart or not state['running']): adapter.update_existing() @@ -1278,10 +1329,9 @@ def _claude_quit_snapshot(self, descriptor): raise ValueError('Claude 进程已变化,请重新检查后再确认退出') from None def _claude_native_cli_busy(self): - from bridge.clients.claude.wsl import ClaudeCliRemotes adapter = self.adapters['claude'] remote = getattr(adapter, 'remote_cli', None) - if isinstance(remote, ClaudeCliRemotes): + if self.claude_wsl is not None: remote = remote.ssh return adapter.cli.busy() is True or remote is not None and remote.busy() is True @@ -1517,7 +1567,7 @@ def clients(self, refresh=False): codex.update(setupStatus='ready', reason='WSL Codex 接入可用') claude = next(row for row in rows if row['id'] == 'claude') claude['nativeEnabled'] = self.config.get('enabled', {}).get('claude', False) - claude['entries'] = self.claude_wsl.applications() + claude['entries'] = self.claude_wsl.applications() if self.claude_wsl is not None else [] if any(row['enabled'] for row in claude['entries']): claude.update(enabled=True, selectable=True, configured=True) result = {'clients': rows, 'computer': socket.gethostname(), 'gatewayRunning': self.gateway_running, @@ -1644,6 +1694,8 @@ def _deepseek_progress(self, row, native): def toggle_client(self, value): provider, enabled = value.get('provider'), value.get('enabled') if isinstance(provider, str) and provider.startswith('wsl-claude:'): + if self.claude_wsl is None: + raise ValueError('当前电脑不支持 WSL 应用') if type(enabled) is not bool or set(value) - {'action', 'provider', 'enabled', 'quitDesktop'}: raise ValueError('WSL Claude Code 应用开关无效') if value.get('quitDesktop', False) is not False: diff --git a/bridge/features/accounts/accounts.py b/bridge/features/accounts/accounts.py index c5c04650..27626ea5 100644 --- a/bridge/features/accounts/accounts.py +++ b/bridge/features/accounts/accounts.py @@ -859,20 +859,36 @@ def switch(self, value): raise ValueError('切换请求标识无效') with self.gate, self.lock: row = dict(self.row(value.get('id'))) - sync_cli = value.get('syncLocalCli') is True + sync_cli = True ledger = read_json(self.root/'requests.json', {}) if request_id in ledger: if ledger[request_id] != row['id']: raise ValueError('同一请求不能切换到不同账号') return self.public() - # Desktop and local CLI use the same auth/config files even when - # the optional CLI effectiveness check is not selected. + # One local account home always verifies CLI effectiveness. from bridge.features.accounts.cli_sync import LocalCliSync - LocalCliSync(self.bridge).preflight('codex') + sync = LocalCliSync(self.bridge) + preflight = sync.preflight('codex') self.assert_editable() if self.info.current().get('id') == row['id']: return self.public() self.idle() + if not preflight['desktopRunning']: + with self.bridge.account.lock: + before = sync.snapshot('codex') + applied = None + try: + applied = sync.apply('codex', row) + sync.verify('codex', row) + except Exception: + if applied and applied.get('mutated') is True: + sync.restore('codex', before) + raise + ledger[request_id] = row['id'] + private_json(self.root/'requests.json', ledger) + self.state = {'phase': 'complete', 'requestId': request_id, 'targetId': row['id'], + 'error': None, 'cliSync': {'status': 'verified'}} + return self.public() executable = self.index.get('desktopExecutable') or DesktopApp.discover(self.runtime) if not executable: raise ValueError('请先在桌面端指定 Codex 桌面程序路径') diff --git a/bridge/features/accounts/cli_access.py b/bridge/features/accounts/cli_access.py index 74ec155f..a7009a82 100644 --- a/bridge/features/accounts/cli_access.py +++ b/bridge/features/accounts/cli_access.py @@ -185,6 +185,18 @@ def change(self, value): if not isinstance(value, dict) or value.get('provider') not in ('codex', 'claude'): raise ValueError('CLI 接入请求无效') provider, action = value['provider'], value.get('action') + if action == 'modelsDraft': + if provider != 'codex' or set(value) - {'provider', 'action', 'id', 'baseUrl', 'apiKey'}: + raise ValueError('API 模型查询参数无效') + accounts = self.bridge.accounts + with accounts.lock: + accounts.assert_editable() + if value.get('id') and value.get('apiKey', '') == '': + row = accounts.row(value['id']) + if row['kind'] != 'api' or accounts.api_url(value.get('baseUrl', '')) != row.get('baseUrl'): + raise ValueError('修改 API 地址后,请重新填写 API Key') + url, key = accounts.api_credentials(value) + return {'models': model_ids(url, key)} if action in ('models', 'test'): return self._probe_api(provider, value) saved_id = None diff --git a/bridge/features/endpoints/wsl.py b/bridge/features/endpoints/wsl.py index 8080299a..4a30967e 100644 --- a/bridge/features/endpoints/wsl.py +++ b/bridge/features/endpoints/wsl.py @@ -22,6 +22,9 @@ from bridge.features.sessions.model import normalize_state, normalize_request, computer_use_approval from bridge.features.sessions.timeline import Timeline from bridge.features.sessions.store import StoreUnavailable +from bridge.features.sessions.archive import ArchiveCommands +from bridge.features.sessions.create import check_archive_runtime +from bridge.features.sessions.pins import SessionPins from bridge.features.sessions.projects import Projects from bridge.features.endpoints.wsl_features import WslFeatures from bridge.features.endpoints.wsl_models import WslModelCatalog @@ -95,6 +98,8 @@ def __init__(self, row, directory): self.row = dict(row) self.host = row['id'] # Gateway route only; never sent as a native hostId. self.data_dir = Path(directory) + self.session_pins = SessionPins(directory, self.host + ":" + row["user"] + ":" + row["home"]) + self.archive_commands = ArchiveCommands(directory) self.lock = threading.RLock() self.connection_lock = threading.RLock() self.closed = threading.Event() @@ -331,10 +336,12 @@ def _maintain(self): if self.closed.is_set(): return - def list(self, query='', limit=100, offset=0, archived=False, source='all', include_sources=False): + def list(self, query='', limit=100, offset=0, archived=False, source='all', include_sources=False, pin_order=False): available = ['wsl'] if include_sources and self.store.available_sources() else [] + pins = set(self.session_pins.identifiers()) rows = [] if source == 'desktop' else self.store.list(query=query, limit=limit, offset=offset, - archived=archived, source='cli' if source == 'wsl' else source) + archived=archived, source='cli' if source == 'wsl' else source, + **({'pinned_ids': sorted(pins)} if pin_order else {})) for row in rows: cwd = row.get('cwd') or '' row.update(host=self.host, endpointId=self.host, hostLabel=self.row['label'], @@ -344,6 +351,7 @@ def list(self, query='', limit=100, offset=0, archived=False, source='all', incl with self.lock: session = self.live.get(row['id']) self.listed.add(row['id']) + row['bridgePinned'] = row['id'] in pins row['connected'] = bool(session and session.connected) row['access'] = self._access(session) return (rows, available) if include_sources else rows @@ -1232,6 +1240,45 @@ def dispatch(): receipt = self._mutation(identifier, identifier + ':approval:' + self.generation + ':' + str(pending['id']), result, dispatch) return receipt + @_entry_write + def archive(self, identifier, archived, request_id, disconnect=False): + if type(disconnect) is not bool or (disconnect and archived is not True): + raise ValueError('聊天归档设置无效') + def read(): + with self.store.lock: + self.store.cache.clear() + metadata = self.store.get(identifier) + metadata['_archiveFlags'] = self.store.archive_flags(identifier) + return metadata + def prepare(metadata): + identifiers = [row['id'] for row in self.store.lifecycle_threads(root_id=identifier)] + if any(key.split(':')[0] in identifiers and row['result'].get('status') in ('queued', 'unknown') + for key, row in self.receipts.items()): + raise ValueError('聊天有排队或待核对操作,请先在原 WSL 处理') + runtime = self.runtime(allow_start=True) + if not disconnect: + check_archive_runtime(runtime.request, identifiers) + with self.lock: + sessions = [self.live[sid] for sid in identifiers if sid in self.live] + for session in sessions: + with session.condition: + if session.activating or (not disconnect and session.state.get('requests')): + raise ValueError('聊天有运行或等待确认的请求,请先在原 WSL 处理') + def call(): + if disconnect: + from bridge.features.sessions.create import stop_archive_runtime + stop_archive_runtime(runtime.request, identifiers) + if set(identifiers) != {row['id'] for row in self.store.lifecycle_threads(root_id=identifier)}: + raise ValueError('子会话范围已变化,尚未发送归档请求') + return runtime.request('thread/archive' if archived else 'thread/unarchive', {'threadId': identifier}) + return call + return {**self.archive_commands.perform(identifier, archived, request_id, read, prepare), 'host': self.host} + + @_entry_write + def pin(self, identifier, pinned, client_id=None, revision=None): + uuid.UUID(identifier) + return {**self.session_pins.set(identifier, pinned, client_id, revision), 'host': self.host} + @_entry_write def rename(self, identifier, title): if not isinstance(title, str) or not title.strip() or len(title) > 120 or any(ord(c) < 32 for c in title): diff --git a/bridge/features/sessions/archive.py b/bridge/features/sessions/archive.py new file mode 100644 index 00000000..164ae87c --- /dev/null +++ b/bridge/features/sessions/archive.py @@ -0,0 +1,70 @@ +"""Durable archive receipts: uncertain native mutations are never replayed.""" +import json +import threading +import uuid +from pathlib import Path +from bridge.app.lifecycle import private_json +from bridge.features.sessions.create import ArchiveNotSent, ArchiveRejected + + +class ArchiveCommands: + def __init__(self, directory): + self.path = Path(directory) / 'session-archives.json' + self.lock = threading.RLock() + + def perform(self, identifier, archived, request_id, read, dispatch): + uuid.UUID(identifier) + if not isinstance(request_id, str): + raise ValueError('归档请求标识无效') + uuid.UUID(request_id) + if not isinstance(archived, bool): + raise ValueError('聊天归档设置无效') + with self.lock: + entries = json.loads(self.path.read_text(encoding='utf-8')) if self.path.exists() else {} + before = read() + old = entries.get(request_id) + if old and (old['id'] != identifier or old['archived'] != archived): + raise ValueError('相同请求标识不能用于不同操作') + for entry in entries.values(): + if entry['id'] == identifier and entry['status'] == 'unknown': + if (bool(before.get('archived')) != entry['archived'] or + (entry['archived'] and any((before.get('_archiveFlags') or {}).get(sid) is not True for sid in entry.get('scope', [])))): + raise ValueError('此前归档操作仍待核对;旧记录不会自动清除或重发,请先在原客户端核对') + entry['status'] = 'confirmed' + if old: + private_json(self.path, entries) + return dict(old) + result = {'id': identifier, 'archived': archived, 'status': 'confirmed'} + if bool(before.get('archived')) == archived: + return result + # Validation and fresh runtime checks happen before persisting intent. + call = dispatch(before) + scope = list(before.get('_archiveFlags') or {}) if archived else [] + entries[request_id] = {**result, 'status': 'unknown', 'scope': scope} + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + private_json(self.path, entries) + try: + call() + except ArchiveRejected as exc: + try: + after = read() + except Exception: + raise ValueError('归档结果待核对,请检查原聊天;不会重复提交') from exc + flags = before.get('_archiveFlags') + if isinstance(flags, dict) and flags and flags == after.get('_archiveFlags'): + del entries[request_id] + private_json(self.path, entries) + raise + raise ValueError('归档结果待核对,请检查原聊天;不会重复提交') from exc + except ArchiveNotSent: + del entries[request_id] + private_json(self.path, entries) + raise + except Exception as exc: + raise ValueError('归档结果待核对,请检查原聊天;不会重复提交') from exc + after = read() + if bool(after.get('archived')) != archived or any((after.get('_archiveFlags') or {}).get(sid) is not True for sid in scope): + raise ValueError('归档结果待核对,请刷新原聊天;不会重复提交') + entries[request_id] = result + private_json(self.path, entries) + return result diff --git a/bridge/features/sessions/create.py b/bridge/features/sessions/create.py index bc8800ec..b575a4b3 100644 --- a/bridge/features/sessions/create.py +++ b/bridge/features/sessions/create.py @@ -75,6 +75,8 @@ def request(method, params): raise CreationError('创建聊天超时,请先检查桌面聊天列表') continue if 'error' in result: + if method in ('thread/archive', 'thread/unarchive'): + raise ArchiveRejected(result['error']) raise CreationError(result['error'].get('message', '创建聊天失败')) return result['result'] @@ -337,3 +339,100 @@ def rename_thread(executable, codex_home, thread_id, title): uuid.UUID(thread_id) return _runtime_operation(executable, codex_home, str(codex_home), lambda request: request('thread/name/set', {'threadId': thread_id, 'name': title})) + + +class ArchiveRejected(CreationError): + """The native archive RPC replied with an explicit error, not a lost reply.""" + def __init__(self, error): + self.rpc_error = error if isinstance(error, dict) else {'message': str(error)} + message = str(self.rpc_error.get('message', '')) + self.reason = ('active_writer' if 'already has an active writer' in message else + 'unsupported' if self.rpc_error.get('code') == -32601 else 'native_rejected') + public = ('聊天仍由原 Codex 客户端持有;当前原生连接无法归档,请在持有该聊天的原客户端操作' + if self.reason == 'active_writer' else + '原 Codex 运行时不支持此归档操作,请在原客户端操作' if self.reason == 'unsupported' else + '原生客户端已拒绝此次归档操作,请检查原客户端后重试') + super().__init__(public) + + +class ArchiveNotSent(CreationError): + """Archive validation failed before a native mutation was dispatched.""" + + +def check_archive_runtime(request, identifiers): + """Read only: never resume a thread to establish archive safety.""" + for identifier in identifiers: + thread = request('thread/read', {'threadId': identifier, 'includeTurns': False})['thread'] + status = (thread.get('status') or {}).get('type') + if (thread.get('id') != identifier or status not in ('idle', 'notLoaded') or + thread.get('requests') or any(turn.get('status') == 'inProgress' for turn in thread.get('turns', []))): + raise ValueError('聊天或子智能体正在运行、等待确认或状态未知,请先在原客户端处理') + + +def stop_archive_runtime(request, identifiers, timeout=20): + """Stop verified turns and background terminals on their original runtime.""" + turns = [] + def background(identifier): + thread = request('thread/read', {'threadId': identifier, 'includeTurns': False})['thread'] + if thread.get('id') != identifier: + raise ValueError('原会话身份已变化,尚未发送归档请求') + if (thread.get('status') or {}).get('type') == 'notLoaded': + return False + result = request('thread/backgroundTerminals/list', {'threadId': identifier, 'limit': 1}) + if not isinstance(result.get('data'), list): + raise ValueError('无法确认原生后台任务状态') + return bool(result['data'] or result.get('nextCursor')) + # Complete preflight before the first stop; never resume to discover activity. + try: + for identifier in identifiers: + thread = request('thread/read', {'threadId': identifier, 'includeTurns': False})['thread'] + status = (thread.get('status') or {}).get('type') + if thread.get('id') != identifier or status not in ('idle', 'notLoaded', 'active'): + raise ValueError('无法确认原会话状态,未停止或归档') + if status == 'active': + rows = request('thread/turns/list', {'threadId': identifier, 'limit': 1, 'sortDirection': 'desc'})['data'] + if len(rows) != 1 or rows[0].get('status') != 'inProgress' or not isinstance(rows[0].get('id'), str): + raise ValueError('无法确认可停止的原生任务,未停止或归档') + turns.append((identifier, rows[0]['id'])) + elif thread.get('requests'): + raise ValueError('聊天有等待确认的请求,未停止或归档') + background(identifier) + except Exception as exc: + raise ArchiveNotSent(str(exc)) from exc + for identifier, turn_id in turns: + # Stopping a parent can already settle its descendants. + thread = request('thread/read', {'threadId': identifier, 'includeTurns': False})['thread'] + if thread.get('id') != identifier: + raise CreationError('原会话身份已变化,尚未发送归档请求') + if (thread.get('status') or {}).get('type') == 'active': + request('turn/interrupt', {'threadId': identifier, 'turnId': turn_id}) + for identifier in identifiers: + if background(identifier): + request('thread/backgroundTerminals/clean', {'threadId': identifier}) + deadline = time.monotonic() + timeout + while True: + try: + check_archive_runtime(request, identifiers) + if any(background(identifier) for identifier in identifiers): + raise ValueError('后台任务尚未停止') + return + except ValueError: + if time.monotonic() >= deadline: + raise CreationError('停止结果待核对,尚未发送归档请求') from None + time.sleep(.1) + + +def archive_thread(executable, codex_home, thread_id, archived, identifiers): + uuid.UUID(thread_id) + sent = False + def dispatch(request): + nonlocal sent + check_archive_runtime(request, identifiers) + sent = True + return request('thread/archive' if archived else 'thread/unarchive', {'threadId': thread_id}) + try: + return _runtime_operation(executable, codex_home, str(codex_home), dispatch) + except Exception as exc: + if not sent: + raise ArchiveNotSent(str(exc)) from exc + raise diff --git a/bridge/features/sessions/pins.py b/bridge/features/sessions/pins.py new file mode 100644 index 00000000..658d0c66 --- /dev/null +++ b/bridge/features/sessions/pins.py @@ -0,0 +1,59 @@ +"""Bridge display preferences, independent of native Codex pins.""" +import json +import threading +import uuid +from pathlib import Path +from bridge.app.lifecycle import private_json + + +class SessionPins: + def __init__(self, directory, environment): + self.path = Path(directory) / 'session-pins.json' + self.environment = environment + self.lock = threading.RLock() + + def _read(self): + try: + value = json.loads(self.path.read_text(encoding='utf-8')) + except FileNotFoundError: + return {} + if not isinstance(value, dict): + raise ValueError('聊天置顶记录无效') + return value + + def identifiers(self): + with self.lock: + return tuple(self._read().get(self.environment, [])) + + def set(self, identifier, pinned, client_id=None, revision=None): + if not isinstance(identifier, str) or not identifier or len(identifier) > 512: + raise ValueError('会话标识无效') + if (client_id is None) != (revision is None): + raise ValueError('聊天置顶同步标识无效') + if client_id is not None: + if not isinstance(client_id, str): + raise ValueError('聊天置顶同步标识无效') + client_id = str(uuid.UUID(client_id)) + if not isinstance(revision, int) or isinstance(revision, bool) or not 0 < revision <= 9007199254740991: + raise ValueError('聊天置顶同步版本无效') + if not isinstance(pinned, bool): + raise ValueError('聊天置顶开关无效') + with self.lock: + value = self._read() + identifiers = set(value.get(self.environment, [])) + if client_id is not None: + versions = value.setdefault('_clientRevisions', {}).setdefault(self.environment, {}).setdefault(identifier, {}) + previous = versions.get(client_id, 0) + if revision <= previous: + return {'id': identifier, 'bridgePinned': identifier in identifiers, + 'clientId': client_id, 'revision': previous} + versions[client_id] = revision + if pinned: + identifiers.add(identifier) + else: + identifiers.discard(identifier) + value[self.environment] = sorted(identifiers) + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + private_json(self.path, value) + return {'id': identifier, 'bridgePinned': pinned, + **({'clientId': client_id, 'revision': revision} if client_id is not None else {})} diff --git a/bridge/features/sessions/store.py b/bridge/features/sessions/store.py index bc66c25c..482865d6 100644 --- a/bridge/features/sessions/store.py +++ b/bridge/features/sessions/store.py @@ -209,7 +209,7 @@ def fingerprint(): bool(database and Path(str(database) + '-shm').exists()), exc) raise StoreUnavailable("暂时无法读取 Codex 会话数据库,请稍后重试或重新打开电脑 Codex。") from exc - def list(self, query="", limit=100, offset=0, archived=False, source='all'): + def list(self, query="", limit=100, offset=0, archived=False, source='all', pinned_ids=()): if source not in ('all', 'desktop', 'cli'): raise ValueError('不支持的会话来源') labels, matches = self._cli_inputs(query) @@ -236,7 +236,11 @@ def list(self, query="", limit=100, offset=0, archived=False, source='all'): where.append("(" + " OR ".join([name + " LIKE ?" for name in search_fields] + ['bridge_cli_input_match(id)']) + ")") params += ["%" + query + "%"] * len(search_fields) recency = "COALESCE(recency_at_ms, recency_at * 1000, updated_at * 1000)" if "recency_at_ms" in columns else "COALESCE(recency_at, updated_at)" if "recency_at" in columns else "updated_at" - rows = conn.execute("SELECT " + ",".join(fields) + " FROM threads WHERE " + " AND ".join(where) + " ORDER BY " + recency + " DESC, id DESC LIMIT ? OFFSET ?", params + [limit, offset]).fetchall() + pinned = set(pinned_ids) + if pinned: + conn.create_function('bridge_session_pinned', 1, lambda identifier: int(identifier in pinned)) + priority = 'bridge_session_pinned(id) DESC, ' if pinned else '' + rows = conn.execute("SELECT " + ",".join(fields) + " FROM threads WHERE " + " AND ".join(where) + " ORDER BY " + priority + recency + " DESC, id DESC LIMIT ? OFFSET ?", params + [limit, offset]).fetchall() result = [] for row in rows: item = dict(row) @@ -312,13 +316,17 @@ def notification_changes(self, since=0): return [dict(row) for row in conn.execute('SELECT id, ' + clock + ' AS changed_at FROM threads WHERE ' + ' AND '.join(filters), (since,))] - def lifecycle_threads(self): + def lifecycle_threads(self, root_id=None): """Desktop roots and their complete agent subtree, using metadata only.""" with self._connect() as conn: columns = {r[1] for r in conn.execute('PRAGMA table_info(threads)')} fields = [name for name in ('id', 'originator', 'source') if name in columns] rows = [dict(row) for row in conn.execute('SELECT ' + ','.join(fields) + ' FROM threads')] - if self.source == 'all': + if root_id is not None: + if not any(row['id'] == root_id for row in rows): + raise KeyError('找不到这个 Codex 会话') + roots = {root_id} + elif self.source == 'all': roots = {row['id'] for row in rows if ('originator' not in columns and row.get('source') != 'cli') or self._origin(row) == 'desktop'} @@ -337,6 +345,17 @@ def lifecycle_threads(self): pending.append(child) return [{'id': identifier} for identifier in sorted(roots)] + def archive_flags(self, thread_id): + """Complete archive metadata for one authorized root and its descendants.""" + self.get(thread_id) + identifiers = {row['id'] for row in self.lifecycle_threads(root_id=thread_id)} + with self._connect() as conn: + flags = {row['id']: bool(row['archived']) for row in conn.execute('SELECT id, archived FROM threads') + if row['id'] in identifiers} + if flags.keys() != identifiers: + raise StoreUnavailable('无法完整核验聊天和子智能体的归档状态') + return flags + def recencies(self, identifiers): if not identifiers: return {} diff --git a/bridge/features/workspace/workspace.py b/bridge/features/workspace/workspace.py index 745d35ef..a4366698 100644 --- a/bridge/features/workspace/workspace.py +++ b/bridge/features/workspace/workspace.py @@ -116,6 +116,15 @@ def _cached_transfer(key, create, range_header, if_range): entry['lock'].release() +def display_absolute_path(path): + """Presentation only; filesystem access keeps the validated native path.""" + if path[:8].lower() == '\\\\?\\unc\\': + return '\\\\' + path[8:] + if path.startswith('\\\\?\\') and re.match(r'^[A-Za-z]:\\', path[4:]): + return path[4:] + return path + + class Workspace: def __init__(self, root): if not root or not Path(root).is_absolute(): @@ -199,12 +208,14 @@ def info(self, path): if stat.S_ISDIR(info.st_mode): # Re-walk the full directory using the same no-link boundary. with self.directory(parts): - return {'name': parts[-1], 'path': path, 'size': None, 'kind': 'directory'} + return {'name': parts[-1], 'path': path, 'size': None, 'kind': 'directory', + 'absolutePath': display_absolute_path(str(self.root.joinpath(*parts)))} handle = self.open_file(directory, fd, parts[-1], os.O_RDONLY) with os.fdopen(handle, 'rb') as stream: info = os.fstat(stream.fileno()) if not stat.S_ISREG(info.st_mode): raise ValueError('仅支持普通文件') - return {'name': parts[-1], 'path': path, 'size': info.st_size, 'kind': 'file'} + return {'name': parts[-1], 'path': path, 'size': info.st_size, 'kind': 'file', + 'absolutePath': display_absolute_path(str(self.root.joinpath(*parts)))} @contextmanager def archive(self, path, range_header='', if_range='', limit=None): diff --git a/desktop/features/clients/desktop-sessions.js b/desktop/features/clients/desktop-sessions.js index 5505a749..0f859abf 100644 --- a/desktop/features/clients/desktop-sessions.js +++ b/desktop/features/clients/desktop-sessions.js @@ -150,12 +150,10 @@ class DesktopConnectionsPanel { const node=(tag,text,cls='')=>{const n=document.createElement(tag);n.className=cls;if(text!==undefined)n.textContent=t(text);return n;}; const table=node('table',undefined,'connection-table'),head=node('thead'),tr=node('tr'),body=node('tbody'); for(const title of ['客户端','应用开关','连接状态','当前接入 / 剩余额度','管理'])tr.append(node('th',title));head.append(tr);table.append(head,body); - const groups=new Set(); for(const client of this.managementClients()){ - if(client.parentId&&!groups.has(client.parentId)){const group=node('tr'),title=node('th',client.parentId==='codex'?'Codex':'Claude');title.colSpan=5;title.setAttribute('scope','rowgroup');group.append(title);body.append(group);groups.add(client.parentId);} const row=node('tr'),identity=node('td'),name=node('div',undefined,'connection-name'),logo=node('span',undefined,'client-logo');row.dataset.clientId=client.id;if(client.parentId)row.dataset.clientParent=client.parentId; const img=node('img');img.src='../web/client-icons/'+(client.icon||client.id)+'.png';img.alt='';logo.append(img); - const label=node('div');label.append(node('strong',client.parentId==='codex'&&client.id==='codex'?'APP / CLI':client.name),node('small',client.kind==='wsl-claude-cli'?'WSL Claude Code':client.kind==='wsl-cli'?'WSL Codex CLI':({codex:'Codex Desktop',claude:'Claude Desktop',deepseek:'DeepSeek Harness'})[client.id]));name.append(logo,label);identity.append(name); + const label=node('div');label.append(node('strong',client.name),node('small',client.kind==='wsl-claude-cli'?'WSL Claude Code':client.kind==='wsl-cli'?'WSL Codex CLI':({codex:'Codex Desktop',claude:'Claude Desktop',deepseek:'DeepSeek Harness'})[client.id]));name.append(logo,label);identity.append(name); const access=node('td'),toggle=node('input');toggle.type='checkbox';toggle.className='client-toggle';toggle.checked=client.enabled;toggle.disabled=(!client.enabled&&!(client.selectable??client.configured))||this.scanning||this.busy||!!this.choosingClient;toggle.setAttribute('aria-label',t('启用')+' '+client.name);toggle.title=t(client.reason);toggle.onchange=()=>this.toggleClient(client.id,toggle.checked);access.append(toggle,node('small',this.pendingClient?.id===client.id?(this.gatewayRunning===false?'正在保存…':this.pendingClient.enabled?(client.id==='claude'?'正在连接…':'正在打开应用…'):this.pendingClient.forceDesktop?'正在后台强制结束…':this.pendingClient.quitDesktop?'正在退出应用…':'正在停用接入…'):client.setupStatus==='unsupported'?'暂不可用':client.enabled?(this.gatewayRunning===false?'随网关启动':'已启用'):!client.installed&&!client.configured?'待配置':'未启用')); toggle.disabled=toggle.disabled||this.hasUncertain();const state=this.connection(client),failure=this.operationFailures?.get(client.id),status=node('td'),stateLabel=node('span',state.label,state.waiting?'state-pending client-connection-waiting':client.connected?'state-ready':state.error?'state-error':state.action?'state-pending':'');stateLabel.setAttribute('role','status');stateLabel.setAttribute('aria-busy',String(state.waiting));status.append(stateLabel,node('small',failure?ClientLifecycle.failureMessage(failure):state.reason)); const account=node('td');account.dataset.clientAccount=client.id; @@ -183,8 +181,9 @@ class DesktopConnectionsPanel { for(const [provider,panel] of Object.entries(this.accountPanels)){this.renderClientAccount(provider,panel.value);} this.resumeAccounts(); } - wslDetails(client){ - if(typeof WslAccountsPanel==='undefined'){this.feedback(BridgeI18n.t('WSL 账号管理面板尚未加载,请重新打开网关窗口'),true);return;} + async wslDetails(client){ + try{if(typeof WslAccountsPanel==='undefined')await window.loadWslAccounts();} + catch(error){this.feedback(error.message,true);return;} const provider=client.kind==='wsl-claude-cli'?'claude':'codex',entry=client.id; const dialog=document.createElement('dialog'),header=document.createElement('div'),title=document.createElement('h2'),root=document.createElement('div'),close=document.createElement('button'); dialog.id='wsl-account-management-dialog';dialog.className='wsl-accounts-dialog';dialog.dataset.provider=provider;dialog.dataset.entry=entry; @@ -211,7 +210,10 @@ class DesktopConnectionsPanel { renderAccount(value){ if(value)this.accountValue=value;else value=this.accountValue; const cell=document.querySelector('[data-client-account="codex"]');if(!cell)return;cell.replaceChildren();const t=BridgeI18n.t,name=document.createElement('span');name.textContent=value?.current?.name||t('暂未识别');cell.append(name); - const row=value?.accounts?.find(row=>row.id===value.activeId);if(row?.kind!=='chatgpt')return; + const row=value?.loginAccount||value?.accounts?.find(row=>row.id===value.activeId);if(row?.kind!=='chatgpt')return; + if(row.name&&row.name!==value?.current?.name){const login=document.createElement('small');login.textContent=t('已登录')+' · '+row.name;cell.append(login);} + if(!row.details?.usage||row.details.usage.status==='loading'){const loading=document.createElement('small');loading.textContent=t('正在读取额度…');cell.append(loading);} + else if(row.details.usage.status==='error'||!row.details.usage.limits?.length){const notice=document.createElement('small');notice.textContent=t(row.details.usage.error||'暂未提供额度信息');cell.append(notice);} const usage=row.details?.usage;if(usage?.status==='ready'){const credits=document.createElement('small');credits.textContent=t('重置卡')+' · '+(usage.resetCredits?.availableCount??t('暂未提供'));cell.append(credits);}for(const bucket of usage?.limits||[])for(const window of bucket.windows||[]){const note=document.createElement('small');note.textContent=bucket.name+' · '+(window.remainingPercent==null?t('暂未提供'):t('剩余 ')+Math.round(window.remainingPercent)+'%');cell.append(note);if(window.remainingPercent!=null){const bar=document.createElement('progress');bar.max=100;bar.value=window.remainingPercent;cell.append(bar);}} const refresh=document.createElement('button');refresh.type='button';refresh.className='quota-link';refresh.textContent=t('查看剩余额度');refresh.disabled=usage?.status==='loading';refresh.onclick=async()=>{refresh.disabled=true;try{this.renderAccount(await this.api.accounts({action:'details',id:row.id,section:'usage',refresh:true}));}catch(error){this.feedback(error.message,true);refresh.disabled=false;}};cell.append(refresh); } diff --git a/desktop/index.html b/desktop/index.html index 70a5d18e..c800d0a3 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -39,7 +39,7 @@ -
+ - + diff --git a/desktop/preload.cjs b/desktop/preload.cjs index b289600b..b7e77e0c 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -2,6 +2,7 @@ const {contextBridge,ipcRenderer}=require('electron'); contextBridge.exposeInMainWorld('bridgeDesktop',{ + platform:process.platform, checkUpdate:()=>ipcRenderer.invoke('bridge:check-update'), skipUpdate:()=>ipcRenderer.invoke('bridge:skip-update'), installUpdate:()=>ipcRenderer.invoke('bridge:install-update'), diff --git a/desktop/shell/layout.css b/desktop/shell/layout.css index 9ccbfc55..7c041622 100644 --- a/desktop/shell/layout.css +++ b/desktop/shell/layout.css @@ -8,7 +8,7 @@ button{font-size:12px;padding:8px 12px;color:#444;border-color:#dedede;border-ra .gateway-banner{display:flex;align-items:center;gap:14px;background:#edf2fc;border:1px solid #dce5f6;border-radius:9px;padding:17px 19px;margin-bottom:26px;flex-wrap:wrap}.gateway-banner>div:not(.actions){flex:1;min-width:160px}.gateway-banner strong,.gateway-banner h2{font-size:14px;font-weight:550;margin:0}.gateway-banner small,.gateway-banner p{display:block;font-size:11px;color:#888;margin:6px 0 0}.gateway-indicator,.hero-icon{display:grid;place-items:center;font-size:12px;color:#2563eb;background:#e3ebfc;width:33px;height:33px;border-radius:50%;flex:none}.hero .actions{margin-left:auto;gap:7px}.hero{align-items:center}.hero .actions button{font-size:11px}#clients-page>.section-head{margin-bottom:12px}#clients-page>.section-head h2{margin:0} .connection-table{width:100%;border:1px solid #e3e3e3;border-radius:9px;border-spacing:0;overflow:hidden;background:#fff;table-layout:fixed}.connection-table th{font-size:10px;font-weight:500;color:#888;background:#f3f5f9;padding:12px 15px;text-align:left}.connection-table td{border-top:1px solid #ededed;padding:21px 15px;font-size:12px;vertical-align:middle;overflow-wrap:anywhere}.connection-table th:nth-child(1){width:23%}.connection-table th:nth-child(2){width:15%}.connection-table th:nth-child(3){width:19%}.connection-table th:last-child{width:15%}.connection-table td:last-child{padding-left:0;padding-right:12px}.connection-table td small{display:block;font-size:10px;color:#999;margin-top:7px;line-height:1.6}.connection-name{display:flex;align-items:center;gap:10px}.connection-name strong{font-size:13px;font-weight:550}.client-logo{display:inline-flex;align-items:center;justify-content:center;width:29px;height:29px;flex:none;font-size:26px}.client-logo img{width:100%;height:100%;object-fit:contain}.client-logo.claude{color:#ad7963}.client-logo.deepseek{font-size:11px;font-weight:600;border:1px solid #8592a8;border-radius:8px;color:#61718b}.client-toggle{appearance:none;display:block;width:31px;height:18px;border:0;border-radius:12px;background:#ccc;cursor:pointer;position:relative;padding:0;margin:0}.client-toggle:before{content:'';position:absolute;width:14px;height:14px;border-radius:50%;background:white;left:2px;top:2px;box-shadow:0 1px 2px #0002}.client-toggle:checked{background:var(--accent)}.client-toggle:checked:before{left:15px}.client-toggle:disabled{opacity:.4;cursor:not-allowed}.connection-table progress{display:block;width:85px;height:3px;margin-top:6px;accent-color:#197346}.state-ready{color:#197346}.client-notes{margin-top:24px}.cap-row{display:flex;align-items:center;justify-content:space-between;font-size:11px;border-bottom:1px solid #eee;padding:10px 0;gap:10px}.cap-row:last-child{border:0}.cap-row small{color:#888;font-size:10px}.client-config-hidden,.configuration-open{display:none!important}.client-config-back{margin:0 0 20px;border:0;background:transparent;padding-left:0;color:#777}.client-config-back:before{content:'‹';margin-right:8px} .module-setting{border:1px solid #e3e3e3;border-radius:8px;background:white;margin-bottom:12px;overflow:hidden}.module-setting>summary{cursor:pointer;padding:18px 22px;font-size:13px;font-weight:500;list-style:none;display:flex;justify-content:space-between;align-items:center}.module-setting>summary:after{content:'›';color:#999;font-size:18px;font-weight:400}.module-setting[open]>summary:after{transform:rotate(90deg)}.module-setting>summary::-webkit-details-marker{display:none}.module-setting>article{border:0;border-top:1px solid #eee;border-radius:0;margin:0;padding-top:20px}.module-setting>article>h2{display:none}.maintenance-tabs{display:flex;gap:25px;border-bottom:1px solid #ddd;margin-bottom:24px}.maintenance-tabs button{border:0;background:transparent;border-radius:0;padding:10px 2px 13px;font-size:12px;color:#888}.maintenance-tabs .active{color:var(--accent);border-bottom:2px solid var(--accent)} -.entrances{margin-top:0}.entrances>h3{margin:0 0 12px}.addresses{gap:0}.address{padding:12px 0;border:0;border-bottom:1px solid #eee;border-radius:0}.address small{font-size:10px}.address strong{font-size:11px}.address button{padding:6px 9px;font-size:11px}.address-summary:not(.expanded)>.address:nth-child(n+4){display:none}.address-summary.expanded{max-height:340px;overflow:auto}.entrance-footer{display:flex;justify-content:space-between;padding-top:15px}.entrance-footer button{border:0;background:none;padding:4px 0;font-size:11px}.save-bar{left:230px;padding:13px 34px;background:#f6f7f9ee}.toast{left:calc(50% + 115px);background:#fff;color:#333;border-color:#ddd;border-radius:8px;box-shadow:0 8px 30px #0002}.toast-progress{height:2px}.feedback{background:#eaf1ff;color:#234fc4}.account-compact{margin:15px 0}.section-head{gap:15px} +.entrances{margin-top:0}.entrances>h3{margin:0 0 12px}.addresses{gap:0}.address{padding:12px 0;border:0;border-bottom:1px solid #eee;border-radius:0}.address small{font-size:10px}.address strong{font-size:11px}.address button{padding:6px 9px;font-size:11px}.entrance-footer{display:flex;justify-content:space-between;padding-top:15px}.entrance-footer button{border:0;background:none;padding:4px 0;font-size:11px}.save-bar{left:230px;padding:13px 34px;background:#f6f7f9ee}.toast{left:calc(50% + 115px);background:#fff;color:#333;border-color:#ddd;border-radius:8px;box-shadow:0 8px 30px #0002}.toast-progress{height:2px}.feedback{background:#eaf1ff;color:#234fc4}.account-compact{margin:15px 0}.section-head{gap:15px} @media(max-width:950px){body>aside{width:200px;padding:24px 14px}body>main{margin-left:200px;width:calc(100% - 200px);padding:28px 24px 85px}.save-bar{left:200px}.connection-table th,.connection-table td{padding-left:10px;padding-right:10px}.connection-name{gap:6px}.client-logo{width:23px;height:23px}.two{grid-template-columns:1fr 1fr}.toast{left:calc(50% + 100px)}} .module-group{background:#fff;border:1px solid var(--line);border-radius:9px;padding:20px 22px;margin-bottom:18px}.module-group>h2{font-size:13px;margin-bottom:10px}.module-group>.module-setting{border:0;border-radius:0;border-top:1px solid #ededed;margin:0}.module-group>.module-setting>summary{padding:15px 0;font-size:12px;font-weight:400}.module-group>.module-setting>article{padding:15px 0}.module-group>.module-setting:first-of-type{border-top:0} .module-group>.module-setting[open]{overflow:visible} diff --git a/desktop/shell/layout.js b/desktop/shell/layout.js index 0ac7d258..ff01f86f 100644 --- a/desktop/shell/layout.js +++ b/desktop/shell/layout.js @@ -27,7 +27,6 @@ const clients=document.createElement('section');clients.dataset.panel='accounts';clients.id='clients-page';clients.hidden=true; clients.innerHTML='
网页与手机使用相同的应用启用设置

本机客户端

自动发现本机应用并沿用已有登录或 API;安装新应用后可重新扫描。

工作区

各客户端独立管理项目与会话。新建会话使用当前客户端的项目配置。

接入设置

Codex账号 / API · 额度 · IPC
Claude连接方式 · 会话范围
DSH连接 · 工作目录 · 官方 Web
'; $('settings').before(clients); - const codexCli=$('cli-access-article');codexCli.dataset.clientConfig='codex';accounts.append(codexCli); const claudeArticle=native.querySelectorAll(':scope > article')[1],claudeCli=document.createElement('details');claudeCli.className='account-compact'; claudeCli.append(text('summary','Claude Code CLI 接入'));const claudeCliRoot=document.createElement('div');claudeCliRoot.id='cli-access-claude-content';claudeCli.append(claudeCliRoot);claudeArticle.append(claudeCli); let client=null; @@ -45,7 +44,7 @@ const open=text('button','打开工作台 ↗','primary');open.type='button';open.dataset.openWorkbench='';hero.querySelector('.actions').prepend(open);$('start').classList.remove('primary'); const overview=document.querySelector('[data-panel="overview"]');overview.querySelector('[data-jump="accounts"]').remove(); const addresses=$('addresses');addresses.classList.add('address-summary');const entrances=document.createElement('article');entrances.className='entrances';addresses.previousElementSibling.before(entrances);entrances.append(addresses.previousElementSibling,addresses,$('quick-tunnel-state'),$('quick-setup')); - const footer=document.createElement('div');footer.className='entrance-footer';const more=text('button','展开全部入口');more.id='more-addresses';more.type='button';more.onclick=()=>{const expanded=addresses.classList.toggle('expanded');more.dataset.i18n=expanded?'收起入口':'展开全部入口';more.textContent=t(more.dataset.i18n);};const network=text('button','管理网络访问 →');network.type='button';network.dataset.jump='network';footer.append(more,network);entrances.append(footer);new MutationObserver(()=>more.hidden=addresses.children.length<=3).observe(addresses,{childList:true}); + const footer=document.createElement('div');footer.className='entrance-footer';const network=text('button','管理网络访问 →');network.type='button';network.dataset.jump='network';footer.append(network);entrances.append(footer); let state; document.querySelectorAll('[data-open-workbench]').forEach(b=>b.onclick=()=>{const url=state?.urls?.find(url=>url.startsWith('https:'))||state?.urls?.[0];if(url)window.bridgeDesktop.open(url);}); window.GatewayLayout={selectClient,tab(name){description.dataset.i18n=descriptions[name];description.textContent=t(descriptions[name]);back.hidden=name!=='accounts'||!client;maintenanceTabs.hidden=name!=='advanced';if(name==='advanced')maintenanceTab('general');},update(value,{starting=false,stopping=false}={}){state=value;document.querySelectorAll('[data-gateway-status]').forEach(n=>n.textContent=t(stopping?'正在停止网关':starting?'正在启动网关':value.runtime.running?'网关运行中':'网关未启动'));document.querySelectorAll('[data-open-workbench]').forEach(b=>b.disabled=!value.runtime.running||!value.urls?.length);}}; diff --git a/desktop/shell/renderer.js b/desktop/shell/renderer.js index d5f5b126..d84a4faf 100644 --- a/desktop/shell/renderer.js +++ b/desktop/shell/renderer.js @@ -115,7 +115,7 @@ function showError(error){ function moduleName(name){return ({account:'accounts','desktop-sessions':'accounts',harness:'accounts',updates:'advanced',logs:'advanced'})[name]||name;} function tab(name){const requested=name;name=moduleName(name);activeTab=name; if(name!=='notifications')hideWebhookUrl(); - if(name!=='accounts'&&typeof codexCliAccessPanel!=='undefined'){codexCliAccessPanel?.clear();claudeCliAccessPanel?.clear();} + if(name!=='accounts'&&typeof claudeCliAccessPanel!=='undefined')claudeCliAccessPanel?.clear(); if(name==='accounts'){desktopConnections?.refresh();desktopConnections?.refreshClients();harnessPanel?.refresh();if(typeof wslAccountScopes!=='undefined')for(const scope of wslAccountScopes)scope.load();} document.querySelectorAll('[data-panel]').forEach(node=>node.hidden=moduleName(node.dataset.panel)!==name); if(name==='accounts'&&!$('accounts-content').hidden)accountsPanel?.refresh();else accountsPanel?.resume(); @@ -501,15 +501,28 @@ $('refresh-devices').onclick=loadDevices; $('device-policy').oninput=$('device-policy').onchange=()=>{devicesDirty=true;$('device-feedback').textContent=t('IP 规则有未保存的修改。');}; $('device-policy').onsubmit=event=>{event.preventDefault();return changeDevices({action:'save',policy:devicePolicy()});}; -const codexCliAccessPanel=typeof CliAccessPanel==='undefined'?null:new CliAccessPanel({root:$('cli-access-content'),provider:'codex',read:()=>api.cliAccess({action:'list'}),request:value=>api.cliAccess(value)}); const claudeCliAccessPanel=typeof CliAccessPanel==='undefined'?null:new CliAccessPanel({root:$('cli-access-claude-content'),provider:'claude',read:()=>api.cliAccess({action:'list'}),request:value=>api.cliAccess(value)}); -document.addEventListener('bridge-client-config',event=>{codexCliAccessPanel?.clear();claudeCliAccessPanel?.clear();if(event.detail==='codex'&&!$('cli-access-content').hidden)codexCliAccessPanel?.refresh();else if(event.detail==='claude'&&!$('cli-access-claude-content').hidden)claudeCliAccessPanel?.refresh();}); +document.addEventListener('bridge-client-config',event=>{claudeCliAccessPanel?.clear();if(event.detail==='claude'&&!$('cli-access-claude-content').hidden)claudeCliAccessPanel?.refresh();}); const accountsPanel=typeof AccountsPanel==='undefined'?null:new AccountsPanel({root:$('accounts-content'),desktop:true,read:()=>api.accounts({action:'list'}),request:value=>api.accounts(value),onUpdate:value=>desktopConnections?.renderAccount(value)}); +if(accountsPanel){ + const visible=accountsPanel.visible.bind(accountsPanel); + accountsPanel.visible=()=>visible()||(!document.hidden&&!$('clients-page').hidden&&!$('clients-page').classList.contains('configuration-open')); +} + +let wslAccountsLoading; +window.loadWslAccounts=()=>{ + if(api.platform!=='win32')return Promise.reject(Error(t('当前电脑不支持 WSL 应用'))); + if(!wslAccountsLoading)wslAccountsLoading=new Promise((resolve,reject)=>{ + const script=document.createElement('script');script.src='../web/features/accounts/wsl-accounts.js'; + script.onload=()=>resolve();script.onerror=()=>{script.remove();wslAccountsLoading=null;reject(Error(t('WSL 账号管理面板尚未加载,请重新打开网关窗口')));};document.head.append(script); + }); + return wslAccountsLoading; +}; // WSL access has its own scope; local panels keep their existing data and callbacks. const wslAccountScopes=[]; +function createWslAccountScopes(){ for(const provider of ['codex','claude']){ - if(typeof WslAccountsPanel==='undefined')continue; const localRoot=provider==='codex'?$('accounts-content'):desktopConnections?.accountPanels?.claude?.root; if(!localRoot)continue; const wrapper=document.createElement('div'),label=document.createElement('label'),select=document.createElement('select'),caption=document.createElement('span'),root=document.createElement('div'); @@ -518,11 +531,13 @@ for(const provider of ['codex','claude']){ const panel=new WslAccountsPanel({root,provider,desktop:true,read:entry=>api.cliAccess({action:'wslAccounts',provider,entry,operation:'list'}),request}); const localPanel=provider==='codex'?accountsPanel:desktopConnections?.accountPanels?.claude; if(localPanel){const visible=localPanel.visible.bind(localPanel);localPanel.visible=()=>!localRoot.hidden&&visible();} - const localCli=provider==='codex'?$('cli-access-content'):$('cli-access-claude-content'); + const localCli=provider==='codex'?null:$('cli-access-claude-content'); let selectedWsl=false; - const choose=()=>{const local=select.value==='local',returning=local&&selectedWsl;selectedWsl=!local;localRoot.hidden=!local;if(localCli)localCli.hidden=!local;if(local){panel.clear();if(returning){localPanel?.refresh();if(localCli&&!localCli.hidden)(provider==='codex'?codexCliAccessPanel:claudeCliAccessPanel)?.refresh();}}else {(provider==='codex'?codexCliAccessPanel:claudeCliAccessPanel)?.clear();if(localPanel){if(localPanel.clear)localPanel.clear();else{localPanel.generation++;localPanel.modelsRevision++;localPanel.loading=false;localPanel.busy=false;localPanel.closeEditor?.();clearTimeout(localPanel.timer);}}panel.select(select.value);}}; + const choose=()=>{const local=select.value==='local',returning=local&&selectedWsl;selectedWsl=!local;localRoot.hidden=!local;if(localCli)localCli.hidden=!local;if(local){panel.clear();if(returning){localPanel?.refresh();if(localCli&&!localCli.hidden)(provider==='claude'?claudeCliAccessPanel:null)?.refresh();}}else {(provider==='claude'?claudeCliAccessPanel:null)?.clear();if(localPanel){if(localPanel.clear)localPanel.clear();else{localPanel.generation++;localPanel.modelsRevision++;localPanel.loading=false;localPanel.busy=false;localPanel.closeEditor?.();clearTimeout(localPanel.timer);}}panel.select(select.value);}}; let revision=0; const load=async()=>{const current=++revision;try{const value=await api.cliAccess({action:'wslAccounts',provider,operation:'environments'});if(current!==revision)return;const previous=select.value||'local';select.replaceChildren(new Option(t('此电脑'),'local'),...(value.environments||[]).map(row=>new Option([row.label||row.distribution,row.user,row.configDir].filter(Boolean).join(' · '),row.id)));select.value=[...select.options].some(row=>row.value===previous)?previous:'local';label.hidden=!(value.environments||[]).length;choose();}catch(error){if(current===revision){label.hidden=true;select.replaceChildren(new Option(t('此电脑'),'local'));choose();}}}; select.onchange=choose;select.append(new Option(t('此电脑'),'local'));root.hidden=true;label.hidden=true;wslAccountScopes.push({panel,load}); } +} +if(api.platform==='win32')window.loadWslAccounts().then(()=>{createWslAccountScopes();if(activeTab==='accounts')for(const scope of wslAccountScopes)scope.load();}).catch(error=>feedback(error.message,true)); document.addEventListener('bridge-client-config',event=>{for(const scope of wslAccountScopes){scope.panel.clear();scope.load();}}); diff --git a/mobile/android-push/build.gradle b/mobile/android-push/build.gradle index 72ca2120..c099f136 100644 --- a/mobile/android-push/build.gradle +++ b/mobile/android-push/build.gradle @@ -3,7 +3,7 @@ android { namespace 'io.github.try2love.codexbridge.preview' compileSdk 35 buildToolsVersion "35.0.0" - defaultConfig { applicationId 'io.github.try2love.codexbridge.preview'; minSdk 26; targetSdk 35; versionCode 38; versionName '2.1.5' } + defaultConfig { applicationId 'io.github.try2love.codexbridge.preview'; minSdk 26; targetSdk 35; versionCode 39; versionName '2.1.6' } sourceSets { main { manifest.srcFile 'AndroidManifest.xml'; java.srcDirs = ['../android/src', 'src']; res.srcDirs = ['../android/res']; assets.srcDirs = ['../shared/web', '../android/assets', '../../web/client-icons'] } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8; targetCompatibility JavaVersion.VERSION_1_8 } signingConfigs { preview { storeFile file('../../.tmp/mobile-tools/preview-debug.p12'); storePassword 'android'; keyAlias 'preview'; keyPassword 'android' } } diff --git a/mobile/android/AndroidManifest.xml b/mobile/android/AndroidManifest.xml index 2916b451..45102003 100644 --- a/mobile/android/AndroidManifest.xml +++ b/mobile/android/AndroidManifest.xml @@ -1,4 +1,4 @@ - + diff --git a/mobile/ios/BridgeActivity/Info.plist b/mobile/ios/BridgeActivity/Info.plist index f85120f3..4b15373d 100644 --- a/mobile/ios/BridgeActivity/Info.plist +++ b/mobile/ios/BridgeActivity/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2.1.5 + 2.1.6 CFBundleVersion - 38 + 39 NSExtension NSExtensionPointIdentifier diff --git a/mobile/ios/BridgeNotifications/Info.plist b/mobile/ios/BridgeNotifications/Info.plist index 4cf9a426..660548e7 100644 --- a/mobile/ios/BridgeNotifications/Info.plist +++ b/mobile/ios/BridgeNotifications/Info.plist @@ -6,8 +6,8 @@ CFBundleName$(PRODUCT_NAME) CFBundleDisplayNameBridge Notifications CFBundlePackageTypeXPC! -CFBundleShortVersionString2.1.5 -CFBundleVersion38 +CFBundleShortVersionString2.1.6 +CFBundleVersion39 NSExtension NSExtensionPointIdentifiercom.apple.usernotifications.service NSExtensionPrincipalClass$(PRODUCT_MODULE_NAME).NotificationService diff --git a/mobile/ios/BridgePreview/Info.plist b/mobile/ios/BridgePreview/Info.plist index cd98bac7..e96195c6 100644 --- a/mobile/ios/BridgePreview/Info.plist +++ b/mobile/ios/BridgePreview/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.1.5 + 2.1.6 CFBundleVersion - 38 + 39 LSRequiresIPhoneOS NSSupportsLiveActivities @@ -73,6 +73,6 @@ BridgeReleaseVersion - 2.1.5 + 2.1.6 diff --git a/package-lock.json b/package-lock.json index 955ef09b..3c4efa95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-bridge-desktop", - "version": "2.1.5", + "version": "2.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-bridge-desktop", - "version": "2.1.5", + "version": "2.1.6", "license": "MIT", "dependencies": { "qrcode": "1.5.4" diff --git a/package.json b/package.json index 466db417..a2564733 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-mobile-bridge-desktop", "desktopName": "codex-mobile-bridge.desktop", - "version": "2.1.5", + "version": "2.1.6", "description": "Codex App 手机网关桌面启动器", "author": "try2love", "license": "MIT", diff --git a/relay/protocol.py b/relay/protocol.py index fda88259..8fc08fe3 100644 --- a/relay/protocol.py +++ b/relay/protocol.py @@ -15,11 +15,11 @@ 'workspace/git-history', 'workspace/git-commit', 'workspace/git-history-diff', 'workspace/git-branches'} WRITE = {'send', 'stop', 'history', 'respond', 'reconnect', 'queue', 'settings', - 'permissions', 'uploads', 'message-action', 'rename', 'notifications', + 'permissions', 'uploads', 'message-action', 'rename', 'pin', 'archive', 'notifications', 'goal/cancel', 'goal/edit', 'goal/status', 'side-chat', 'terminal', 'workspace/upload', 'workspace/git-action'} DESKTOP_READ = {'list', 'detail', 'catalog', 'projects', 'account', 'access', 'terminal', 'notifications'} | {action for action in READ if action.startswith('workspace')} -DESKTOP_WRITE = {'send', 'stop', 'settings', 'respond', 'create', 'access', 'uploads', 'terminal', 'notifications', 'workspace/upload', 'workspace/git-action'} +DESKTOP_WRITE = {'pin', 'send', 'stop', 'settings', 'respond', 'create', 'access', 'uploads', 'terminal', 'notifications', 'workspace/upload', 'workspace/git-action', 'archive'} def origin(value, test_http=False): diff --git a/scripts/gateway-resources.json b/scripts/gateway-resources.json index c431daff..7f013cc7 100644 --- a/scripts/gateway-resources.json +++ b/scripts/gateway-resources.json @@ -29,6 +29,7 @@ "bridge/platforms/windows/codex-quit-helper.cs", "bridge/clients/claude/connector.js", "bridge/clients/claude/remote_worker.py", + "bridge/clients/claude/file_archive.py", "bridge/clients/deepseek/host.mjs", "bridge/clients/LICENSE.coding-mobile", "web/features/workspace/source-view.js", diff --git a/tests/account-startup.test.cjs b/tests/account-startup.test.cjs index 7230780b..fd7f9ac6 100644 --- a/tests/account-startup.test.cjs +++ b/tests/account-startup.test.cjs @@ -50,7 +50,7 @@ async function fixture(read,kind='chatgpt',catalog=null,gatewayLanguage,accountS test('WSL account URLs follow the selected environment within a single Codex page',async()=>{ const ui=await fixture(r=>r(official)); - ui.run("clientNavigation.clients=[{id:'codex',entries:[{id:'desktop',enabled:true},{id:'wsl:original',kind:'wsl-cli',enabled:true},{id:'wsl:selected',kind:'wsl-cli',enabled:true}]}];currentId='fixture';currentHost='wsl:original';refreshEntryAccounts();"); + ui.run("clientNavigation.wslSupported=true;clientNavigation.clients=[{id:'codex',entries:[{id:'desktop',enabled:true},{id:'wsl:original',kind:'wsl-cli',enabled:true},{id:'wsl:selected',kind:'wsl-cli',enabled:true}]}];currentId='fixture';currentHost='wsl:original';refreshEntryAccounts();renderAccountEntries();"); assert.equal(ui.run("accountUrl('/api/accounts')"),'/api/accounts?entry=wsl%3Aoriginal'); assert.equal(ui.run("accountUrl('/api/account?cached=1')"),'/api/account?cached=1&entry=wsl%3Aoriginal'); ui.run("accountEntrySelect.value='wsl:selected';accountEntrySelect.onchange();");assert.equal(ui.run("accountUrl('/api/accounts')"),'/api/accounts?entry=wsl%3Aselected'); diff --git a/tests/accounts-ui.test.cjs b/tests/accounts-ui.test.cjs index 8c5cada3..6cc6d6bf 100644 --- a/tests/accounts-ui.test.cjs +++ b/tests/accounts-ui.test.cjs @@ -32,17 +32,17 @@ test('native login card survives an empty saved list without enrollment or switc test('web exposes saved account selection without enrollment or credential inputs',async()=>{ const ui=fixture();await ui.panel.refresh();assert.equal(ui.all().some(n=>n.tag==='input'&&n.type!=='checkbox'),false);assert.match(ui.text(),/API +

SSH 连接诊断

导出文件
diff --git a/web/shared/i18n.js b/web/shared/i18n.js index 492a24da..dbd04eeb 100644 --- a/web/shared/i18n.js +++ b/web/shared/i18n.js @@ -2,6 +2,59 @@ // UI messages only. Never translate chat content, commands, model IDs or user input. const BridgeI18n=(()=>{ const en={ +"原会话未正常退出,尚未发送归档请求":"The original session did not exit cleanly. No archive request has been sent.", +"无法确认原生后台任务状态":"The native background task state could not be verified.", +"原会话身份已变化,尚未发送归档请求":"The original session identity changed. No archive request has been sent.", +"原运行时连接已变化,尚未发送归档请求":"The original runtime connection changed. No archive request has been sent.", +"子会话范围已变化,尚未发送归档请求":"The child session scope changed. No archive request has been sent.", +"原会话连接尚未释放,尚未发送归档请求":"The original session connection has not been released. No archive request has been sent.", +"原 Claude Code 执行连接身份无法核验":"The original Claude Code execution connection could not be verified.", +"后台任务尚未停止":"Background tasks have not stopped yet.", +"断开连接并归档":"Disconnect and archive", +"确认归档":"Confirm archive", +"将停止此聊天及其子任务,并在确认停止后归档。聊天记录会保留,未完成任务不会自动续跑。":"Stop this chat and its subtasks, then archive after confirming they have stopped. Chat history is retained; unfinished work will not resume automatically.", +"无法确认原会话状态,未停止或归档":"The original session state could not be verified. Nothing was stopped or archived.", +"无法确认可停止的原生任务,未停止或归档":"The native task to stop could not be verified. Nothing was stopped or archived.", +"聊天有等待确认的请求,未停止或归档":"The chat has a pending request. Nothing was stopped or archived.", +"原运行时连接已变化,未停止或归档":"The original runtime connection changed. Nothing was stopped or archived.", +"停止结果待核对,尚未发送归档请求":"The stop result is unconfirmed. No archive request has been sent.", +"置顶设置待同步":"Pin preference pending sync", +"已在此设备保存,等待同步":"Saved on this device; waiting to sync", +"无法保存此设备的置顶设置":"Unable to save pin preferences on this device", + +"此前归档操作仍待核对;旧记录不会自动清除或重发,请先在原客户端核对":"An earlier archive operation is still unconfirmed. Its record will not be cleared or replayed automatically; check the original client first.", +"聊天仍由原 Codex 客户端持有;当前原生连接无法归档,请在持有该聊天的原客户端操作":"The original Codex client still owns this chat. Archive it there; this connection cannot archive it.", +"原 Codex 运行时不支持此归档操作,请在原客户端操作":"The original Codex runtime does not support this archive action. Use the original client.", +"原生客户端已拒绝此次归档操作,请检查原客户端后重试":"The native client rejected this archive action. Check the original client before retrying.", +"当前接入尚未确认原生归档和恢复能力":"Native archive and restore support has not been verified for this connection.", +"聊天或子会话有运行、排队、审批或未知状态,请先在原客户端处理":"This chat or a child has active, queued, pending or unknown work. Check the original client first.", +"聊天或子智能体有运行中的工作,请先在原客户端处理":"This chat or a subagent has active work. Check the original client first.", +"此接入有排队或待核对操作,请先在原客户端处理":"This connection has queued or unconfirmed operations. Check the original client first.", +"Claude 有待核对的原生写入,请先在原客户端处理":"Claude has an unconfirmed native operation. Check the original client first.", +"原生客户端拒绝归档,操作未发送":"The native client refused to archive. No archive operation was sent.", +"归档目标或子会话状态无法确认":"The archive target or child state could not be verified.", +"无法完整核验聊天和子智能体的归档状态":"The archive state of this chat and its subagents could not be fully verified.", +"复制深度链接":"Copy deep link", +"复制 Session ID":"Copy Session ID", +"此接入暂无可用的桌面会话链接":"No Desktop session link is available for this access.", +"新建 Codex API 接入":"New Codex API access", +"官方账号登录与配置导入":"Official account login and configuration import", +"官方账号登录请在电脑端完成。":"Sign in to official accounts on the computer.", +"此电脑的 Codex Desktop 与 CLI 共用接入;桌面运行时切换会重启桌面应用。":"Codex Desktop and CLI share access on this computer. Switching restarts Desktop when it is running.", +"确认所有任务(包括未在网页显示的任务)已结束,并切换此电脑的 Codex 接入?桌面运行时会重启。":"Confirm all tasks, including tasks not shown here, have ended and switch Codex access on this computer? Desktop restarts if running.", +"置顶聊天":"Pinned chats", +"置顶":"Pin chat", +"取消置顶":"Unpin chat", +"最近聊天":"Recent chats", +"归档聊天":"Archive chat", +"取消归档":"Unarchive chat", +"归档结果待核对,请检查原聊天;不会重复提交":"Archive result is unconfirmed. Check the original chat; this operation will not be repeated.", +"归档结果待核对,请刷新原聊天;不会重复提交":"Archive result is unconfirmed. Refresh the original chat; this operation will not be repeated.", +"聊天或子智能体正在运行、等待确认或状态未知,请先在原客户端处理":"This chat or a subagent is running, awaiting approval, or has an unknown state. Check the original client first.", +"聊天有排队或待核对操作,请先在原客户端处理":"This chat has queued or unconfirmed operations. Check the original client first.", +"聊天有排队或待核对操作,请先在原 WSL 处理":"This chat has queued or unconfirmed operations. Check the original WSL session first.", +"聊天有运行或等待确认的请求,请先在原 WSL 处理":"This chat has an active or pending request. Check the original WSL session first.", +"聊天有等待确认的请求,请先在原客户端处理":"This chat has a pending request. Check the original client first.", "组件加载时间较长,请重试":"Component is taking too long to load. Please retry.", "组件暂不可用,请重试":"Component unavailable. Please retry.", "终端加载失败,请重试":"Terminal failed to load. Please retry.", @@ -258,6 +311,9 @@ const en={ "API 地址":"API URL", "模型 ID":"Model ID", "API Key":"API key", +"例如:我的 API":"For example: My API", +"请输入 API Key":"Enter your API key", +"留空以保留现有 Key":"Leave blank to keep the existing key", "保存 API 接入":"Save API access", "保存名称":"Save name", "重命名":"Rename", @@ -664,6 +720,7 @@ const en={ "超过阈值仅显示定位;文件标签页中长按或右键下载不限此大小。":"Larger files offer Locate only. Long-press or right-click downloads in the Files tab are exempt.", "已保存,立即生效":"Saved. Applies immediately.", "下载到本地":"Download to this device", +"复制绝对路径":"Copy absolute path", "定位并打开所在文件夹":"Locate in Files", "文件夹将打包为 ZIP,准备完成后显示下载总量。":"The folder will be prepared as a ZIP. The total size appears when ready.", "文件超过点击下载阈值,请在文件标签页中定位后长按下载。":"This file exceeds the click-to-download limit. Locate it in Files, then long-press to download.", @@ -1775,6 +1832,13 @@ const en={ "扫描配置": "Scan configuration", "上游模型": "Provider models", "获取上游模型": "Fetch provider models", + "API 模型查询参数无效": "Invalid model lookup parameters", + "修改 API 地址后,请重新填写 API Key": "Enter the API key again after changing the API URL", + "获取后自动填入,或手动输入": "Fetch models or enter an ID", + "手动填写": "Enter manually", + "从列表选择": "Choose from list", + "正在获取…": "Fetching…", + "已获取模型,可修改默认选择。": "Models loaded. Choose your default.", "请选择模型": "Choose a model", "模型列表不保证支持 Responses API,请选择可用于对话的模型。": "The list may include models without Responses API support. Choose a compatible chat model.", "没有发现可导入的账号或 API 配置。": "No saved account or API configuration was found.", @@ -1924,6 +1988,7 @@ const en={ "HTML 预览": "HTML preview", "HTML 预览无法加载,请查看源代码或下载文件": "HTML preview could not load. View the source or download the file.", "预览": "Preview", + "阅读": "Reading", "源代码": "Source code", "上一页": "Previous page", "下一页": "Next page", @@ -2942,6 +3007,10 @@ const en={ "输入命令或回复,换行键可换行": "Enter a command or reply. Return inserts a new line.", "终端输入": "Terminal input", "发送到终端": "Send to terminal", + "HTML 预览缩放": "HTML preview zoom", + "所有文件字号": "Text size for all files", + "减小所有文件字号": "Decrease text size for all files", + "增大所有文件字号": "Increase text size for all files", "所有终端字号": "All terminals · font size", "字号": "Font size", "减小所有终端字号": "Decrease font size in all terminals", diff --git a/web/shared/presentation-content.css b/web/shared/presentation-content.css index e8b9186d..2f9e22ee 100644 --- a/web/shared/presentation-content.css +++ b/web/shared/presentation-content.css @@ -72,3 +72,5 @@ .appearance-note{color:var(--muted);margin:16px 0 0;font-size:11px!important} .appearance-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:18px} .appearance-actions .primary{width:auto;min-width:92px} + +.chat-detail-execution{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.chat-detail-execution #copy-session-link{flex:none} diff --git a/web/shell/app.js b/web/shell/app.js index 54289cc6..c029e42b 100644 --- a/web/shell/app.js +++ b/web/shell/app.js @@ -69,9 +69,15 @@ function clearListSnapshot(){ listIdentity='';listFilterKey='';listHostIds=[];listRows=[];listOffset=0;listHasMore=false;listComplete=false;listReadFinished=false;availableSources=[];listRenderStamp='';listGeneration++; } function authLost(){hideWebhookUrl();clearListSnapshot();if(!$('app').hidden)document.dispatchEvent(new Event('bridge-auth-lost'));showLogin();} -async function api(path,body,signal){const options={signal,credentials:'same-origin',cache:'no-store',headers:{}};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-CSRF-Token':csrf};options.body=JSON.stringify(body);}const response=await fetch(hostUrl(path),options);let data;try{data=await response.json();}catch(error){throw Object.assign(error,{status:response.status});}if(!response.ok){if(response.status===401)authLost();throw Object.assign(Error(data.error||t('请求失败')),{loginStatus:data.loginStatus,status:response.status,code:data.code});}return data;} +async function api(path,body,signal,authCurrent){const options={signal,credentials:'same-origin',cache:'no-store',headers:{}};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-CSRF-Token':csrf};options.body=JSON.stringify(body);}const response=await fetch(hostUrl(path),options);let data;try{data=await response.json();}catch(error){throw Object.assign(error,{status:response.status});}if(!response.ok){if(response.status===401&&(!authCurrent||authCurrent()))authLost();throw Object.assign(Error(data.error||t('请求失败')),{loginStatus:data.loginStatus,status:response.status,code:data.code});}return data;} const workbench=window.BridgeWorkbench=new Workbench({chat:$('chat'),request:api,csrf:()=>csrf,notify:toast,onUnauthorized:authLost,openChat}); -const desktopSessions=new DesktopSessionsView({root:$('app'),select:$('desktop-backend'),request:api,notify:toast,csrf:()=>csrf,onUnauthorized:authLost,onSelect:provider=>{ +async function sendSessionPin(identity,body,token){ + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000); + try{return await api(identity.provider==='codex'?sessionUrl(identity.id,'pin',identity.host):'/api/desktop-sessions/'+identity.provider+'/pin',identity.provider==='codex'?body:{...body,sessionId:identity.id},controller.signal,()=>sessionPins.current(token));}finally{clearTimeout(timer);} +} +const sessionPins=new LocalSessionPins({newId:uuid,send:sendSessionPin,onChange:()=>{listRenderStamp='';renderList();desktopSessions.listStamp='';desktopSessions.renderList();if(listFilterKey)saveListSnapshot(listFilterKey);desktopSessions.rememberView();}}); +function pinRow(row,provider='codex'){const identity={provider,id:row.id,host:row.host||'local'};return {...row,bridgePinned:sessionPins.value(identity,row.bridgePinned),pinPending:sessionPins.pending(identity)};} +const desktopSessions=new DesktopSessionsView({root:$('app'),pinPreferences:sessionPins,onSessionMenu:(button,row,provider)=>bindSessionMenu(button,row,provider),select:$('desktop-backend'),request:api,notify:toast,csrf:()=>csrf,onUnauthorized:authLost,onSelect:provider=>{ if(provider!=='codex'){saveDraft();chatTimeline?.dispose();chatTimeline=null;currentId=null;state=null;workbench.reset();$('chat').hidden=true;$('welcome').hidden=false;$('app').classList.remove('chat-open');if(!browserNavigation.enabled())history.replaceState(null,'',location.pathname);} }}); const clientNavigation=new ClientNavigation({view:desktopSessions,request:api,notify:toast}); @@ -101,7 +107,7 @@ desktopSessions.onHistoryInvalidated=()=>browserNavigation.invalidate(); let codexAccountEntry='desktop',preferredWslEntry=clientNavigation.initialHost||''; function codexEntries(){return clientNavigation.clients.find(row=>row.id==='codex')?.entries||[];} -function wslEntries(){return codexEntries().filter(row=>row.kind==='wsl-cli'&&row.enabled);} +function wslEntries(){return clientNavigation.wslSupported===true?codexEntries().filter(row=>row.kind==='wsl-cli'&&row.enabled):[];} function accountUrl(path){return codexAccountEntry==='desktop'?path:path+(path.includes('?')?'&':'?')+'entry='+encodeURIComponent(codexAccountEntry);} function refreshEntryAccounts(){ const entries=wslEntries(); @@ -116,6 +122,8 @@ clientNavigation.onApplicationChange=(previous,provider,{host}={})=>{ if(provider==='codex'&&!$('app').hidden)return loadList(true).catch(e=>toast(e.message)); }; clientNavigation.onClientStates=clients=>{ + const previousSource=listSource;renderSourceFilter(); + if(previousSource!==listSource&&clientNavigation.provider==='codex'&&!$('app').hidden)loadList(true).catch(error=>toast(error.message)); if(!currentId||clientNavigation.provider!=='codex')return; const codex=clients.find(row=>row.id==='codex'),entry=currentHost.startsWith('wsl:')?codex?.entries?.find(row=>row.id===currentHost):codex?.entries?.find(row=>row.id==='desktop')||codex; if(!entry?.enabled)clientNavigation.onApplicationChange('codex','codex'); @@ -151,7 +159,7 @@ function renderLoginStatus(status=loginStatus){ } function clearCodexProvisionalDrafts(){codexProvisionalDrafts.clear();for(let i=sessionStorage.length-1;i>=0;i--){const key=sessionStorage.key(i);if(key?.startsWith('draft:')||key?.startsWith('skills:'))sessionStorage.removeItem(key);}} function clearQuoteComposer(){if(typeof codexQuotes!=='undefined')codexQuotes?.reset();} -function showLogin(passwordless=false,status){clearQuoteComposer();if(typeof ChatTimeline!=='undefined')ChatTimeline.clearCache?.();draftGateway='';codexDraftScope=null;clearCodexProvisionalDrafts();codexActiveAccountId=undefined;window.BridgeDownloads?.clear();$('mobile-binding-status').hidden=true;if(typeof codexCliAccessPanel!=='undefined')codexCliAccessPanel?.clear();claudeCliAccessPanel?.clear();if(window.BridgeSharedRelay){location.replace((window.BridgeRelayBase||'')+'/relay/');return;}desktopSessions.clear();clientNavigation.clear?.();modelCatalogGeneration++;modelCatalogs.clear();catalogData.models=null;catalogResults.models=null;catalogTasks.models=null;workbench.reset();if(typeof accountsPanel!=='undefined')accountsPanel?.clear();chatTimeline?.dispose();chatTimeline=null;document.querySelectorAll('dialog[open]').forEach(d=>d.close());$('app').hidden=true;$('login').hidden=false;$('credentials').hidden=passwordless;$('noauth').hidden=!passwordless;$('username').required=!passwordless;$('password').value='';$('remember-login').parentElement.hidden=passwordless||BridgeHost.isNativeApp();$('login-attempts').hidden=passwordless;renderLoginStatus(status);if(window.BridgeBootstrap)window.BridgeBootstrap.ready();else $('bootstrap-status').hidden=true;} +function showLogin(passwordless=false,status){sessionPins.suspend();clearQuoteComposer();if(typeof ChatTimeline!=='undefined')ChatTimeline.clearCache?.();draftGateway='';codexDraftScope=null;clearCodexProvisionalDrafts();codexActiveAccountId=undefined;window.BridgeDownloads?.clear();$('mobile-binding-status').hidden=true;if(typeof codexCliAccessPanel!=='undefined')codexCliAccessPanel?.clear();claudeCliAccessPanel?.clear();if(window.BridgeSharedRelay){location.replace((window.BridgeRelayBase||'')+'/relay/');return;}desktopSessions.clear();clientNavigation.clear?.();modelCatalogGeneration++;modelCatalogs.clear();catalogData.models=null;catalogResults.models=null;catalogTasks.models=null;workbench.reset();if(typeof accountsPanel!=='undefined')accountsPanel?.clear();chatTimeline?.dispose();chatTimeline=null;document.querySelectorAll('dialog[open]').forEach(d=>d.close());$('app').hidden=true;$('login').hidden=false;$('credentials').hidden=passwordless;$('noauth').hidden=!passwordless;$('username').required=!passwordless;$('password').value='';$('remember-login').parentElement.hidden=passwordless||BridgeHost.isNativeApp();$('login-attempts').hidden=passwordless;renderLoginStatus(status);if(window.BridgeBootstrap)window.BridgeBootstrap.ready();else $('bootstrap-status').hidden=true;} async function start(pairingToken=null){ window.BridgeBootstrap?.waitingForAuth(); const auth=window.BridgeBootstrap?.readAuth?await window.BridgeBootstrap.readAuth(signal=>api('/api/auth',undefined,signal)):await api('/api/auth'); @@ -188,6 +196,7 @@ async function enter(authInfo){ if(!verified.authenticated)throw Error(t('登录状态暂不可用')); if(draftGateway&&draftGateway!==verified.draftIdentity){clearCodexProvisionalDrafts();if(typeof ChatTimeline!=='undefined')ChatTimeline.clearCache?.();} draftGateway=verified.draftIdentity||''; + sessionPins.bind(draftGateway?JSON.stringify([window.BridgeRelayBase||location.origin,draftGateway]):''); desktopSessions.bindDraftGateway?.(draftGateway); listIdentity=await listAuthIdentity(verified); desktopSessions.bindListCache(listIdentity,window.BridgeRelayBase||location.host); @@ -207,7 +216,7 @@ $('logout').onclick=async()=>{await api('/api/logout',{});hideWebhookUrl();docum function dateText(value){if(!value)return '';return new Date(value*1000).toLocaleDateString(BridgeI18n.locale(),{month:'numeric',day:'numeric'});} function listAuthIdentity(auth){return auth?.authenticated&&/^[0-9a-f]{64}$/.test(auth.listCacheIdentity||'')?auth.listCacheIdentity:'';} function listSnapshotKey(filter){return listIdentity?'codex-list-v1:'+listIdentity+':'+filter:'';} -function currentListFilter(){return JSON.stringify([$('search').value,$('archived').checked,listSource]);} +function currentListFilter(){return JSON.stringify([$('search').value,$('archived').checked,listSource,$('list-mode').value]);} function restoreListSnapshot(filter=currentListFilter()){ const key=listSnapshotKey(filter);if(!key)return false; let saved,legacy=false; @@ -228,7 +237,7 @@ function restoreListSnapshot(filter=currentListFilter()){ } function saveListSnapshot(filter){ const key=listSnapshotKey(filter);if(!key)return; - const rows=listRows.slice(0,200),snapshot=JSON.stringify({rows,sources:availableSources,hosts:listHostIds,offset:rows.length,hasMore:listHasMore||listRows.length>rows.length}); + const rows=(JSON.parse(filter)[3]==='recent'?[...listRows].sort((a,b)=>Number(pinRow(b).bridgePinned)-Number(pinRow(a).bridgePinned)||(b.recency-a.recency)||b.id.localeCompare(a.id)):listRows).slice(0,200),snapshot=JSON.stringify({rows,sources:availableSources,hosts:listHostIds,offset:rows.length,hasMore:listHasMore||listRows.length>rows.length}); if(snapshot.length>512*1024)return; try{ localStorage.setItem(key,snapshot);sessionStorage.removeItem(key); @@ -237,15 +246,16 @@ function saveListSnapshot(filter){ }catch{try{sessionStorage.setItem(key,snapshot);}catch{}} } $('list-mode').value=localStorage.getItem('list-mode')||'recent'; -$('list-mode').onchange=()=>{localStorage.setItem('list-mode',$('list-mode').value);renderList();}; +$('list-mode').onchange=()=>{localStorage.setItem('list-mode',$('list-mode').value);loadList(true).catch(e=>toast(e.message));}; listSource=clientNavigation.initialHost?'wsl':localStorage.getItem('codex-source-filter')||'all'; if(!['all','desktop','cli','wsl'].includes(listSource))listSource='all'; $('source-filter').onchange=()=>{listSource=$('source-filter').value;localStorage.setItem('codex-source-filter',listSource);return loadList(true).catch(e=>toast(e.message));}; function renderSourceFilter(){ $('source-filter-label').hidden=false; - const stamp=BridgeI18n.locale(); + const wsl=clientNavigation.wslSupported===true,stamp=BridgeI18n.locale()+':'+wsl; + if(clientNavigation.wslSupported===false&&listSource==='wsl'){listSource='all';localStorage.setItem('codex-source-filter',listSource);} if(stamp!==sourceFilterStamp){ - const options=[['all',t('全部会话')],['cli','CLI'],['desktop','APP'],['wsl','WSL']]; + const options=[['all',t('全部会话')],['cli','CLI'],['desktop','APP'],...(wsl?[['wsl','WSL']]:[])]; $('source-filter').replaceChildren(...options.map(([value,label])=>{const option=el('option','',label);option.value=value;return option;})); sourceFilterStamp=stamp; } @@ -263,14 +273,19 @@ function sessionAccess(access){ function currentAccess(){return sessionAccess(state?.access||listRows.find(row=>row.id===currentId&&row.host===currentHost)?.access);} function sourceLabel(access){return access?.runtime==='wsl'?'WSL':access?.source==='cli'?'CLI':access?.source==='ide'?'IDE':access?.source==='unknown'?t('其他来源'):'APP';} function renderList(){ - const stamp=JSON.stringify([$('list-mode').value,currentId,currentHost,listHasMore,BridgeI18n.locale(),availableSources,listRows.length?null:[listComplete,listReadFinished],listRows.map(row=>[row.id,row.host,row.title,row.projectKey,row.projectName,row.hostLabel,row.recency,row.access])]); + const displayedRows=listRows.map(row=>pinRow(row)); + const stamp=JSON.stringify([$('list-mode').value,currentId,currentHost,listHasMore,BridgeI18n.locale(),availableSources,listRows.length?null:[listComplete,listReadFinished],displayedRows.map(row=>[row.id,row.host,row.title,row.projectKey,row.projectName,row.hostLabel,row.recency,row.bridgePinned,row.pinPending,row.archived,row.access])]); if(stamp===listRenderStamp&&$('sessions').children.length){renderActivity();return;} listRenderStamp=stamp; const scrollTop=$('sessions').scrollTop; $('sessions').replaceChildren(); const grouped=$('list-mode').value==='project', groups=new Map(); - for(const chat of listRows){ + let pinSection=null; + const ordered=grouped?[...displayedRows].sort((a,b)=>(b.recency-a.recency)||b.id.localeCompare(a.id)):[...displayedRows].sort((a,b)=>Number(!!b.bridgePinned)-Number(!!a.bridgePinned)||(b.recency-a.recency)||b.id.localeCompare(a.id)); + const hasPins=!grouped&&ordered.some(chat=>chat.bridgePinned); + for(const chat of ordered){ let parent=$('sessions'); + if(hasPins&&pinSection!==!!chat.bridgePinned){pinSection=!!chat.bridgePinned;const label=el('h3','muted',t(pinSection?'置顶聊天':'最近聊天'));$('sessions').append(label);} if(grouped){ if(!groups.has(chat.projectKey)){ const details=el('details','project-group'); @@ -286,16 +301,56 @@ function renderList(){ button.append(el('strong','',chat.title)); button.append(el('span','session-source',sourceLabel(sessionAccess(chat.access))||t('其他来源'))); const meta=el('small');meta.append(el('span','',chat.projectName+' · '+(chat.host==='local'?t('此电脑'):chat.hostLabel)),el('time','',dateText(chat.recency/1000))); - button.append(meta);button.onclick=()=>openChat(chat.id,chat.host).catch(e=>toast(e.message));parent.append(button); + if(chat.pinPending)button.append(el('span','session-source',t('置顶设置待同步'))); + button.append(meta);button.onclick=()=>openChat(chat.id,chat.host).catch(e=>toast(e.message));bindSessionMenu(button,chat);parent.append(button); } if(listHasMore){const more=el('button','plain',t('加载更多聊天'));more.id='sessions-more';more.type='button';more.onclick=()=>loadList().catch(error=>toast(error.message));$('sessions').append(more);} if(!listRows.length)$('sessions').append(el('p','muted',t(listComplete?'没有找到聊天。':listReadFinished?'暂不可用':'正在读取…')));renderActivity();$('sessions').scrollTop=scrollTop; } +function confirmSessionArchive(chat){ + return new Promise(resolve=>{ + const box=el('dialog','picker archive-confirm'),actions=el('div','copy-dialog-actions');let confirmed=false; + box.append(el('h2','',t('断开连接并归档')),el('p','',chat.title),el('p','muted',chat.hostLabel||t('此电脑')), + el('p','',t('将停止此聊天及其子任务,并在确认停止后归档。聊天记录会保留,未完成任务不会自动续跑。'))); + const confirm=el('button','primary',t('确认归档')),cancel=el('button','primary',t('取消')); + confirm.type=cancel.type='button';confirm.onclick=()=>{confirmed=true;box.close();};cancel.onclick=()=>box.close(); + actions.append(confirm,cancel);box.append(actions); + box.addEventListener('close',()=>{box.remove();resolve(confirmed);});document.body.append(box);box.showModal();cancel.focus(); + }); +} +function bindSessionMenu(button,chat,provider='codex'){ + let timer=null,start=null,suppress=false; + const clear=()=>{clearTimeout(timer);timer=null;}; + const show=()=>{ + const box=el('dialog','picker'),head=el('div','picker-head'),close=el('button','icon-button','×'); + close.type='button';close.setAttribute('aria-label',t('关闭'));close.onclick=()=>box.close();head.append(el('h2','',chat.title),close);box.append(head); + const action=(label,run,disable=true)=>{const item=el('button','plain',t(label));item.type='button';item.onclick=async()=>{if(disable)item.disabled=true;try{await run(item);box.close();}catch(error){toast(error.message);item.disabled=false;}};box.append(item);return item;}; + const identity={provider,id:chat.id,host:chat.host||'local',runtime:chat.access?.runtime}; + action(chat.bridgePinned?'取消置顶':'置顶',()=>{sessionPins.set(identity,!chat.bridgePinned);toast(t('已在此设备保存,等待同步'));}); + const copy=action(provider==='deepseek'?'复制 Session ID':'复制深度链接',item=>BridgeClipboard.copyWithFeedback(()=>window.BridgeSessionLinks?.text(identity),item,toast),false); + copy.classList.add('message-copy'); + copy.disabled=!window.BridgeSessionLinks?.text(identity); + if(provider==='codex'||chat.capabilities?.[chat.archived?'unarchive':'archive']===true)action(chat.archived?'取消归档':'断开连接并归档',async()=>{ + if(!chat.archived&&!await confirmSessionArchive(chat))return; + const body={archived:!chat.archived,id:uuid(),...(!chat.archived?{disconnect:true}:{})}; + const result=await api(provider==='codex'?sessionUrl(chat.id,'archive',chat.host):'/api/desktop-sessions/'+provider+'/archive',provider==='codex'?body:{...body,sessionId:chat.id}); + if(result.status!=='confirmed')throw Error(t('归档结果待核对,请检查原聊天;不会重复提交')); + if(provider==='codex')await loadList(true);else await desktopSessions.refresh(); + }); + box.addEventListener('close',()=>box.remove());document.body.append(box);box.showModal(); + }; + button.addEventListener('contextmenu',event=>{event.preventDefault();clear();if(!suppress)show();}); + button.addEventListener('pointerdown',event=>{if(event.pointerType==='mouse'||event.button>0)return;start={x:event.clientX,y:event.clientY};suppress=false;clear();timer=setTimeout(()=>{if(!button.isConnected)return;suppress=true;show();},550);}); + button.addEventListener('pointermove',event=>{if(start&&Math.hypot(event.clientX-start.x,event.clientY-start.y)>10)clear();}); + for(const name of ['pointerup','pointercancel','pointerleave'])button.addEventListener(name,clear); + button.addEventListener('click',event=>{if(suppress){event.preventDefault();event.stopImmediatePropagation();suppress=false;}},true); + button.addEventListener('keydown',event=>{if(event.key==='F10'&&event.shiftKey){event.preventDefault();show();}}); +} async function loadList(reset=false){ if(!reset&&(listLoading||!listHasMore))return; - const generation=++listGeneration,offset=reset?0:listOffset; - const query=$('search').value,archived=$('archived').checked,source=listSource; - const filter=JSON.stringify([query,archived,source]); + const generation=++listGeneration,offset=reset?0:listOffset,pinRead=sessionPins.capture(); + const query=$('search').value,archived=$('archived').checked,source=listSource,mode=$('list-mode').value; + const filter=JSON.stringify([query,archived,source,mode]); if(reset&&filter!==listFilterKey){ if(!restoreListSnapshot(filter)){listRows=[];availableSources=[];listOffset=0;listHasMore=false;listHostIds=[];listFilterKey=filter;} $('sessions').scrollTop=0; @@ -306,12 +361,12 @@ async function loadList(reset=false){ const sources=new Set(availableSources),freshSources=new Map(),errors=[]; const publish=()=>{ if(generation!==listGeneration)return []; - const merged=[...groups.values()].flat().sort((a,b)=>(b.recency-a.recency)||b.id.localeCompare(a.id)||b.host.localeCompare(a.host)); + const merged=[...groups.values()].flat().sort((a,b)=>(mode==='recent'?Number(pinRow(b).bridgePinned)-Number(pinRow(a).bridgePinned):0)||(b.recency-a.recency)||b.id.localeCompare(a.id)||b.host.localeCompare(a.host)); listRows=merged.slice(0,targetCount);availableSources=[...sources].sort();renderList(); return merged; }; const read=async host=>{ - const url='/api/sessions?scope=host&host='+encodeURIComponent(host)+'&q='+encodeURIComponent(query)+'&offset='+(targetCount-100)+'&archived='+archived+'&source='+source; + const url='/api/sessions?scope=host&host='+encodeURIComponent(host)+'&q='+encodeURIComponent(query)+'&offset='+(targetCount-100)+'&archived='+archived+'&source='+source+'&order='+encodeURIComponent(mode); try{ const result=await api(url);if(generation!==listGeneration)return null; if(host==='local'){ @@ -321,7 +376,9 @@ async function loadList(reset=false){ if(result.complete===false||result.unavailableHosts?.length)errors.push(...(result.unavailableHosts||[{host,label:host,error:t('暂不可用')}])); else{ for(const row of result.sessions){const key=chatKey(row.id,row.host);row.recency=Math.max(row.recency,recentInteractions.get(key)||0);} - groups.set(host,result.sessions);freshSources.set(host,result.availableSources||[]); + for(const row of result.sessions)sessionPins.observe({provider:'codex',host:row.host,id:row.id},row.bridgePinned,pinRead); + const retained=mode==='recent'?(groups.get(host)||[]).filter(row=>sessionPins.pending({provider:'codex',host:row.host,id:row.id})&&pinRow(row).bridgePinned&&!result.sessions.some(fresh=>fresh.id===row.id)):[]; + groups.set(host,[...result.sessions,...retained]);freshSources.set(host,result.availableSources||[]); for(const item of result.availableSources||[])sources.add(item); } publish();return result; @@ -399,7 +456,7 @@ $('open-session-diagnostics').onclick=()=>{ loadSessionDiagnostics(); }; $('session-diagnostics-refresh').onclick=loadSessionDiagnostics; -$('session-diagnostics-copy').onclick=()=>window.BridgeClipboard.copy(()=>sessionDiagnosticsText,$('session-diagnostics-copy')); +$('session-diagnostics-copy').onclick=()=>window.BridgeClipboard.copyWithFeedback(()=>sessionDiagnosticsText,$('session-diagnostics-copy'),toast); $('session-diagnostics-export').onclick=event=>{if(BridgeHost.isNativeApp()&&window.BridgeMobileCapabilities?.diagnosticsExport!==true){event.preventDefault();return;}event.currentTarget.href=window.BridgeRelayURL?.(diagnosticsPath)||diagnosticsPath;}; $('archived').onchange=()=>loadList(true).catch(e=>toast(e.message)); const listSync=window.BridgeListSync=new ListSync({root:$('sessions'),status:$('list-sync'),refresh:()=>loadList(true),visible:()=>!$('app').hidden&&$('sidebar').getBoundingClientRect().width>0,ready:()=>!listLoading&&document.activeElement!==$('search')}); @@ -456,6 +513,9 @@ async function loadNewChatProjects(){ } $('new-chat').onclick=async()=>{ const entries=wslEntries(),native=codexEntries().find(row=>row.id==='desktop'),type=$('new-chat-type'); + const wslOption=[...type.options].find(option=>option.value==='wsl'); + if(clientNavigation.wslSupported!==true)wslOption?.remove(); + else if(!wslOption)type.append(new Option('WSL','wsl')); for(const option of type.options)option.disabled=option.value==='wsl'?!entries.length:native?.enabled===false; $('new-chat-entry').replaceChildren(...entries.map(row=>new Option(row.name||row.label,row.id))); const preferred=currentHost.startsWith('wsl:')?currentHost:preferredWslEntry; @@ -1425,24 +1485,39 @@ $('pushplus-form').onsubmit=event=>{event.preventDefault();return notificationCh $('pushplus-test').onclick=()=>notificationChannelAction(true); const codexCliRoot=el('div'); -const codexCliDetails=el('details','account-compact'),codexCliSummary=el('summary','',t('Codex CLI 接入'));codexCliSummary.dataset.i18n='Codex CLI 接入';codexCliDetails.append(codexCliSummary,codexCliRoot);$('accounts-dialog').append(codexCliDetails); -const codexCliAccessPanel=typeof CliAccessPanel==='undefined'?null:new CliAccessPanel({root:codexCliRoot,provider:'codex',read:()=>api('/api/cli-access'),request:value=>api('/api/cli-access',value)}); -const codexWslRoot=el('div');$('accounts-dialog').append(codexWslRoot); -const codexWslAccounts=typeof WslAccountsPanel==='undefined'?null:new WslAccountsPanel({root:codexWslRoot,provider:'codex',read:entry=>api('/api/wsl-accounts?provider=codex&entry='+encodeURIComponent(entry)),request:value=>api('/api/wsl-accounts',value)}); -function refreshSelectedAccounts(){if(codexAccountEntry==='desktop'){codexWslAccounts?.clear();accountsPanel?.refresh();codexCliAccessPanel?.refresh();}else codexWslAccounts?.select(codexAccountEntry);} +const codexCliDetails=el('details'),codexCliSummary=el('summary','',t('新建 Codex API 接入'));codexCliSummary.dataset.i18n='新建 Codex API 接入';codexCliDetails.append(codexCliSummary,codexCliRoot);$('accounts-dialog').append(codexCliDetails); +const codexCliAccessPanel=typeof CliAccessPanel==='undefined'?null:new CliAccessPanel({root:codexCliRoot,provider:'codex',formOnly:true,onChanged:()=>accountsPanel?.refresh(),read:()=>api('/api/cli-access'),request:value=>api('/api/cli-access',value)}); +let codexWslRoot=null,codexWslAccounts=null,accountEntryControls=null,accountEntrySelect=null; +async function loadWslAccounts(){ + if(clientNavigation.wslSupported!==true)return false; + await window.BridgeFeatures.load('wsl'); + return clientNavigation.wslSupported===true; +} +async function refreshSelectedAccounts(){ + if(codexAccountEntry==='desktop'){codexWslAccounts?.clear();accountsPanel?.refresh();codexCliAccessPanel?.refresh();return;} + const entry=codexAccountEntry; + try{ + if(!await loadWslAccounts()||!$('accounts-dialog').open||codexAccountEntry!==entry)return; + if(!codexWslAccounts)codexWslAccounts=new WslAccountsPanel({root:codexWslRoot,provider:'codex',read:entry=>api('/api/wsl-accounts?provider=codex&entry='+encodeURIComponent(entry)),request:value=>api('/api/wsl-accounts',value)}); + codexWslAccounts.select(entry); + }catch(error){if($('accounts-dialog').open&&codexAccountEntry===entry)toast(error.message);} +} $('accounts-dialog').addEventListener('close',()=>{codexCliAccessPanel?.clear();codexWslAccounts?.clear();}); -const accountEntryControls=el('div','accounts-runtime-controls'),accountEntryLabel=el('label','',t('运行环境')),accountEntrySelect=el('select');accountEntrySelect.setAttribute('aria-label',t('运行环境'));accountEntryLabel.append(accountEntrySelect);accountEntryControls.append(accountEntryLabel);$('accounts-heading').closest('.picker-head').after(accountEntryControls); function renderAccountEntries(){ const entries=wslEntries(); - accountEntrySelect.replaceChildren(new Option(t('此电脑'),'desktop'),...entries.map(row=>new Option(row.name||row.label,row.id))); + if(clientNavigation.wslSupported===true&&!accountEntryControls){ + accountEntryControls=el('div','accounts-runtime-controls');const label=el('label','',t('运行环境'));accountEntrySelect=el('select');accountEntrySelect.setAttribute('aria-label',t('运行环境'));label.append(accountEntrySelect);accountEntryControls.append(label);$('accounts-heading').closest('.picker-head').after(accountEntryControls); + codexWslRoot=el('div');$('accounts-dialog').append(codexWslRoot); + accountEntrySelect.onchange=()=>{codexAccountEntry=accountEntrySelect.value;accountsPanel?.clear();renderAccountEntries();refreshSelectedAccounts();}; + } if(codexAccountEntry!=='desktop'&&!entries.some(row=>row.id===codexAccountEntry))codexAccountEntry='desktop'; - accountEntrySelect.value=codexAccountEntry;accountEntryControls.hidden=!entries.length; - codexCliDetails.hidden=codexAccountEntry!=='desktop';$('accounts-content').hidden=codexAccountEntry!=='desktop';codexWslRoot.hidden=codexAccountEntry==='desktop';if(typeof accountsPanel!=='undefined'&&accountsPanel)accountsPanel.refreshButton.hidden=codexAccountEntry!=='desktop'; + if(accountEntrySelect){accountEntrySelect.replaceChildren(new Option(t('此电脑'),'desktop'),...entries.map(row=>new Option(row.name||row.label,row.id)));accountEntrySelect.value=codexAccountEntry;accountEntryControls.hidden=!entries.length;} + codexCliDetails.hidden=codexAccountEntry!=='desktop';$('accounts-content').hidden=codexAccountEntry!=='desktop';if(codexWslRoot)codexWslRoot.hidden=codexAccountEntry==='desktop';if(typeof accountsPanel!=='undefined'&&accountsPanel)accountsPanel.refreshButton.hidden=codexAccountEntry!=='desktop'; if(codexCliDetails.hidden)codexCliAccessPanel?.clear(); } -accountEntrySelect.onchange=()=>{codexAccountEntry=accountEntrySelect.value;accountsPanel?.clear();renderAccountEntries();refreshSelectedAccounts();}; const accountsPanel=window.BridgeSharedRelay||typeof AccountsPanel==='undefined'?null:new AccountsPanel({root:$('accounts-content'),toolbar:$('accounts-heading'),read:()=>api(accountUrl('/api/accounts')),request:({action,...value})=>action==='ignoreSubmission'?ignoreSubmission(value):api(accountUrl(action==='account'?'/api/accounts/account':action==='details'?'/api/accounts/details':action==='reminders'?'/api/accounts/reminders':action==='checkDesktopUpdate'||action==='requestDesktopUpdate'?'/api/accounts/desktop-update':'/api/accounts/switch'),action==='checkDesktopUpdate'||action==='requestDesktopUpdate'?{action,...value}:value),onChanged:value=>{if(codexActiveAccountId!==undefined&&codexActiveAccountId!==value.activeId){browserNavigation.invalidate();if(typeof ChatTimeline!=='undefined')ChatTimeline.clearCache?.();clearCodexProvisionalDrafts();clearQuoteComposer();codexDraftScope=null;codexDraftTouched=false;}resetModelCatalog();},onUpdate:value=>{codexActiveAccountId=value.activeId;const label='账号管理';$('accounts-button').setAttribute('data-i18n',label);$('accounts-button').textContent=t(label);}}); +accountsPanel?.attachApiAccess(codexCliAccessPanel,codexCliDetails); function openCurrentAccounts(){ const activeProvider=clientNavigation.provider,provider=activeProvider?.startsWith('wsl-claude:')?'claude':activeProvider; if(!provider)return false; @@ -1465,13 +1540,17 @@ function openCurrentAccounts(){ if(provider==='claude'&&typeof CliAccessPanel!=='undefined'){ const details=el('details','account-compact'),root=el('div'),summary=el('summary','',t('Claude Code CLI 接入'));summary.dataset.i18n='Claude Code CLI 接入';details.append(summary,root);dialog.append(details); claudeCliAccessPanel=new CliAccessPanel({root,provider:'claude',read:()=>api('/api/cli-access'),request:value=>api('/api/cli-access',value)}); + const initializeWsl=async()=>{ + if(!await loadWslAccounts()||!dialog.open)return; const selectorControls=el('div','accounts-runtime-controls'),selectorLabel=el('label','',t('运行环境')),selector=el('select'),wslRoot=el('div');selector.setAttribute('aria-label',t('运行环境'));selectorLabel.append(selector);selectorControls.append(selectorLabel);head.after(selectorControls);dialog.append(wslRoot); claudeWslAccounts=new WslAccountsPanel({root:wslRoot,provider:'claude',onSwitched:value=>desktopSessions.continueWslAccountDrafts(value),read:entry=>api('/api/wsl-accounts?provider=claude&entry='+encodeURIComponent(entry)),request:value=>api('/api/wsl-accounts',value)}); const entries=clientNavigation.clients.find(row=>row.id==='claude')?.entries||[]; const targets=entries.filter(row=>row.id?.startsWith('wsl-claude:')); selector.append(new Option(t('此电脑'),'local'),...targets.map(row=>new Option(row.name||row.label||row.id,row.id)));selectorControls.hidden=!targets.length; const select=()=>{const local=selector.value==='local';content.hidden=!local;details.hidden=!local;panel.refreshButton.hidden=!local;if(local){claudeWslAccounts.clear();panel.resume();claudeCliAccessPanel.refresh();}else{panel.generation++;panel.modelsRevision++;clearTimeout(panel.timer);panel.loading=false;panel.busy=false;claudeCliAccessPanel.clear();claudeWslAccounts.select(selector.value);}}; - selector.onchange=select;const activeEntry=desktopSessions.isChatOpen&&desktopSessions.state?.runtime==='wsl'?desktopSessions.state.host:activeProvider;if(targets.some(row=>row.id===activeEntry))selector.value=activeEntry;selectClaudeAccounts=select; + selector.onchange=select;const activeEntry=desktopSessions.isChatOpen&&desktopSessions.state?.runtime==='wsl'?desktopSessions.state.host:activeProvider;if(targets.some(row=>row.id===activeEntry))selector.value=activeEntry;select(); + }; + selectClaudeAccounts=()=>{if(clientNavigation.wslSupported===true)initializeWsl().catch(error=>{if(dialog.open)toast(error.message);});else{panel.resume();claudeCliAccessPanel.refresh();}}; } dialog.addEventListener('close',()=>{panel.dispose();claudeWslAccounts?.dispose();claudeCliAccessPanel?.dispose();claudeCliAccessPanel=null;});dialog.showModal();if(selectClaudeAccounts)selectClaudeAccounts();else panel.resume(); return true; @@ -1526,3 +1605,9 @@ $('permissions-button').onclick=()=>{ const fileTransferSettings=window.BridgeFileActions?.mountSettings($('appearance-dialog'),value=>api('/api/file-transfer',value)); if(fileTransferSettings)$('appearance-dialog').querySelector('.settings-action-row')?.after(fileTransferSettings); + +$('copy-session-link').onclick=()=>{ + const text=window.BridgeSessionLinks.text({provider:'codex',id:currentId,host:currentHost}); + if(text===null){toast(t('此接入暂无可用的桌面会话链接'));return;} + return window.BridgeClipboard.copyWithFeedback(()=>text,$('copy-session-link'),toast); +}; diff --git a/web/shell/content.css b/web/shell/content.css index e20af748..54bd2964 100644 --- a/web/shell/content.css +++ b/web/shell/content.css @@ -40,6 +40,7 @@ .message-actions{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.message-actions button{font-size:12px;min-height:32px;padding:5px 8px;color:var(--muted)}.message-actions button:hover{color:var(--accent)}.code-block{position:relative;min-width:0}.code-block>pre{margin-top:0;padding-top:38px}.code-copy{position:absolute;right:5px;top:4px;font-size:12px;padding:3px 8px;min-height:28px}.copy-dialog textarea,#message-action-text{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;max-height:55dvh}.copy-dialog textarea{height:45dvh;font:inherit}.copy-dialog h2{margin-top:0} .clipboard-selection{position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px} .copy-dialog-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px} +.archive-confirm .copy-dialog-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.archive-confirm .copy-dialog-actions button{width:100%;min-width:0;white-space:nowrap} #message-action-form{display:grid;gap:12px}#message-action-error:empty{display:none}#fork-source{max-width:100%;text-align:left;overflow-wrap:anywhere} .attachment-list{display:grid;gap:8px;margin-bottom:8px}