diff --git a/AGENTS.md b/AGENTS.md index be7651f..3023676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,26 @@ reconciliation, startup, or session switching. Preserve global delivery for newly appended live errors and active-scope delivery for live agent-exit warnings. See `docs/notifications.md`. +Git and GitHub notification-bar events are live and user-triggered only. Publish +observed successful commit, push, pull, fetch, sync, merge, repository-publish, +and PR mutation results; never notify from polling, catalogue refresh, startup, +or restored state. The Git Workspace PR launcher badge counts only observed open +and draft PRs, excluding closed and merged PRs. See `docs/notifications.md` and +`docs/github-pull-requests.md`. + +Active Git surfaces silently refresh the bounded GitHub pull-request catalogue +once per minute so PRs created or changed by other users update the Browser and +open-count badge. Keep that poll single-flight, preserve selection when possible, +and never turn polled transitions into notification-bar events. + +Git tabs retain meaningful unfinished UI state across inactive-tab unmounts in +bounded process-session memory keyed by exact outer tab/worktree identity. Keep +commit/amend drafts, PR creation fields/step, PR Browser location, review summary, +and queued inline comments; clear the applicable draft on explicit cancel or +successful submission. Never retain credentials, mutation locks, destructive +confirmations, or infer repository outcomes from UI memory. See +`docs/git-workspace.md` and `docs/github-pull-requests.md`. + Inactive standalone terminal tabs stay mounted to preserve their PTYs, but must pass `active={false}` through `TermColumn` to `XTermPane`. Buffer their output without `term.write()`, then refit and replay it with bounded frame work and @@ -272,6 +292,16 @@ while Electron Background Brain owns it. Remote-access credentials are authority boundaries. Pairing tokens must remain short-lived, memory-only, and single-use. Persist only device-session digests in owner-only atomic stores; enforce expiry and revocation. Browser HTTP/WebSocket origins must match exactly or be explicitly configured—never reflect arbitrary `Origin`/forwarded headers. Keep authentication limiters bounded, and do not hardcode CJ's `crewcode.logixhub.icu` deployment as a default Hub URL. +Hub-connected web Settings lists every machine enrolled to the authenticated owner and +keeps reversible disablement distinct from permanent revocation. Disable must persist +the authority suspension, reject heartbeats/tickets/new Brain relays, close existing +Brain and browser relay sessions, and preserve the machine credential for explicit +re-enable. Enable clears the suspension but reports offline until a fresh Brain relay +and heartbeat are observed. Keep these same-origin, session/CSRF-protected Hub control +operations behind the typed CrewCode client; never route them through the selected +Brain, expose Hub credentials, show the surface in direct-server/Electron-only +Settings, or infer machine availability from the mutation response. + Browser delegation keeps its agent-facing endpoint Brain-loopback and bearer-scoped; delegate requests, editor watches, and LSP handles remain bound to the authenticated browser session that owns them. Browser plugin iframes load only approved plugin @@ -340,6 +370,8 @@ YuHeard PTY integration must remain bundle-safe. `PtyService` receives the activ CrewCoder agent profiles are separate from CrewCode execution modes. Show the desktop model-row profile picker only when the installed CrewCoder provider is active; disable it during a running turn, persist the optional session-scoped `crewcoderMode`, omit `--mode` for Configured default, and pass only `general | crewcoder | plugin | extension` to `crewcoder acp --mode`. A concrete profile locks the underlying CrewCode permission policy to Build and disables Ask/Plan/Build/Full on desktop and phone; Configured default re-enables those controls. Never retain a hidden prior Ask, Plan, or Full Access policy under a concrete profile. When the concrete `crewcoder` profile is active, show the separate desktop approval picker and persist `crewcoderApprovalMode`; expose only CrewCoder's `review`, `always`, `never`, `full-access`, and `sandboxed` values, with `review` as the fail-closed default. Treat approval changes as immutable launch authority: disable them during a running turn, drop only the idle bridge, include the value in custody, and native-resume on the next prompt. Never suppresses prompts but continues to block dangerous calls; Sandboxed applies the native sandbox policy where supported; Full access bypasses CrewCoder approval requests and dangerous-command blocking, so label that risk truthfully and never imply CrewCode Build still interposes. Never route Ask/Plan/Build/Full into CrewCoder's `--mode`. The `crewcoder` profile's plan gate is CrewCoder-owned: project `crewcoder_clarify` / `crewcoder_propose_plan` into the activity overlay and send `/approve-plan` as a prompt, never as a tool-permission Allow/Deny. See `docs/crewcoder-provider.md`. +CrewCoder manual compaction is capability-gated. Expose the bridge `compact()` path only after observing exact `initialize._meta["crewcoder/sessionCompact"].method === "session/compact"`; older CrewCoder versions retain summary-reset. Call the advertised method only while idle and keep CrewCoder's durable session id and ACP child after success. Use the returned summary to replace CrewCode's replay shard and add the visible compact-summary card while retaining the full rich display transcript. Treat `_crewcoder/compaction_update` as authoritative, never infer a duplicate from usage, never fabricate a turn for idle progress, and do not clear usage/replay when the provider reports a skipped compact. See `docs/crewcoder-provider.md` and `docs/conversation-storage.md`. + Provider context handoff is initiated from the Solo Chat header or `/handoff`. Its Used chats tab mirrors the current workspace's live Sessions catalogue across chat tabs; starting either a new or used destination closes the card immediately and moves progress/failure feedback to the destination meter. Preserve each selected destination's owner tab/worktree, existing provider/model/effort locking, and disposable destination-provider summary flow documented in `docs/provider-context-handoff.md`. ## Plugin platform notes diff --git a/docs/conversation-storage.md b/docs/conversation-storage.md index 23029b7..ea226d5 100644 --- a/docs/conversation-storage.md +++ b/docs/conversation-storage.md @@ -68,11 +68,24 @@ Both maps are cleared together when a session is explicitly reset (`bridge:reset | **opencode** | `native` | `POST /session/:id/summarize` (server-side) | | **claude** | `native` | `/compact` slash command via the Agent SDK → `compact_boundary` event | | **plugins** | `native` | plugin bridge `compact()` (local summary prompt) | +| **CrewCoder (advertised extension)** | `native` | ACP `session/compact`; durable session is rewritten in place and its returned summary replaces only CrewCode's replay shard | | **ollama / openrouter** (HTTP_ONLY) | `local-summary` | `LOCAL_COMPACT_PROMPT` + `compactLocalConversation` replaces our owned replay history | -| **pi / hermes / CrewCoder** | `summary-reset` | no native RPC → the agent summarizes its own live context, then we seed a fresh session with it (below) | +| **pi / hermes / older CrewCoder** | `summary-reset` | no advertised native RPC → CrewCode summarizes the bounded transcript, then seeds a fresh session (below) | | _any provider with no thread/conversation key_ | `unsupported` | nowhere to persist a summary; reported instead of faked | -### `summary-reset` flow (pi / hermes / CrewCoder) +### CrewCoder native compact flow + +CrewCode exposes `compact()` on the CrewCoder bridge only after observing the +exact `initialize._meta["crewcoder/sessionCompact"]` method advertisement. An +idle `/compact` calls `session/compact` with the current durable session id. +CrewCoder owns summary generation and the durable transcript rewrite, returns +the installed summary, and emits authoritative `_crewcoder/compaction_update` +progress. CrewCode replaces its local replay shard with `[continue, summary]` +and appends a visible compact-summary card, but keeps the full rich display +transcript, provider session id, and live bridge. If CrewCoder reports a skipped +small-session compact, CrewCode leaves replay and usage state unchanged. + +### `summary-reset` flow (pi / hermes / older CrewCoder) Native-session providers keep their context server-side and expose no compaction RPC, so we cannot shrink it directly. Instead: @@ -81,7 +94,7 @@ Native-session providers keep their context server-side and expose no compaction 3. The bridge is torn down via the **idle-stop path** (`idle_stopped` → renderer drops its keys silently, no "agent exited" noise). Teardown + the `completed` event are deferred to a `queueMicrotask` so ordering stays `turn_end → completed → idle_stopped` and the bridge is never stopped re-entrantly from inside its own event. 4. The **next prompt** calls `bridge:start` with no resume id but local history present → `injectHistoryOnNextPrompt` re-arms → the summary is injected as `` into a fresh, small upstream session. -> History note: previously every provider without a native `compact()` fell through to `prompt('/compact')`. Codex/HTTP-only/plugins worked, but pi/hermes/opencode/claude received the literal string `/compact`, which the agent simply answered. opencode and claude now compact natively; pi/hermes/CrewCoder use summary-reset. +> History note: previously every provider without a native `compact()` fell through to `prompt('/compact')`. Codex/HTTP-only/plugins worked, but pi/hermes/opencode/claude received the literal string `/compact`, which the agent simply answered. OpenCode and Claude now compact natively; pi, Hermes, and CrewCoder versions without the advertised extension use summary-reset. ## Visible chat transcripts (separate from replay history) diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index c8f9f2e..b5bda88 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -76,7 +76,9 @@ plan is a normal composer message; CrewCoder treats that as a new The bridge performs this handshake before reporting ready: -1. `initialize` with protocol version 1 and text-file capabilities. +1. `initialize` with protocol version 1 and text-file capabilities. The bridge + recognizes the exact `_meta["crewcoder/sessionCompact"].method === + "session/compact"` advertisement; it does not assume the extension exists. 2. `session/load` when CrewCode has a saved native session id; unknown ids fall back to `session/new`. 3. `session/set_external_directories` with CrewCode's complete session grant list, including `[]` @@ -84,11 +86,15 @@ The bridge performs this handshake before reporting ready: 4. `session/set_model` when a model is selected. 5. `session/set_reasoning_effort` applies CrewCode's selected effort to CrewCoder's provider client. 6. `session/prompt` runs each turn; `session/follow_up` queues an instruction into an active CrewCoder turn, and `session/cancel` is an ACP notification. +7. An idle `/compact` calls advertised `session/compact` with the durable + `sessionId`. Older CrewCoder versions without the advertisement keep the + existing CrewCode summary-reset fallback. CrewCoder transcript replay contains user/assistant text only. When CrewCode's richer local transcript exists, provider replay is suppressed. CrewCoder remains -a native-resume provider and uses summary-reset for manual compaction because it -has no native compact RPC. +a native-resume provider. Its advertised compact RPC rewrites that durable +session in place, so CrewCode must not clear the native session id, stop the +bridge, or seed a replacement session after a successful native compact. ## Event mapping @@ -105,13 +111,17 @@ has no native compact RPC. CrewCoder's compaction update is an additive namespaced ACP extension carrying started/completed/failed status, automatic intent, progress, and a human-readable message. The bridge treats it as authoritative and does not also infer -compaction from the later context-token drop. The compacted summary remains in -CrewCoder's durable session and is not copied into CrewCode's transcript. -CrewCode deliberately retains the full visible transcript as history; it is not -the provider context. On authoritative completion, CrewCode clears the stale -live context occupancy from memory, disk, and the latest visible usage strip -without fabricating `0` tokens. The next CrewCoder usage report repopulates the -meter with the compacted context's measured size. +compaction from the later context-token drop. Automatic updates omit the summary +body; the compacted summary remains only in CrewCoder's durable session. +Host-requested `session/compact` returns the authoritative summary and includes +it on the completed update. CrewCode replaces only its provider replay shard +with that summary and appends the visible compact-summary card; it deliberately +retains the full rich chat transcript as display history. A skipped update does +not reset context usage. After an applied completion, CrewCode clears stale live +context occupancy from memory, disk, and the latest visible usage strip without +fabricating `0` tokens. The next CrewCoder usage report repopulates the meter +with the compacted context's measured size. Idle compact progress must not +fabricate a model `turn_start`. CrewCoder emits genuine reasoning, so the Hermes cosmetic-thinking filter must not be applied. Gated tools are announced as pending before the permission diff --git a/docs/current-state.md b/docs/current-state.md index 7d07fcb..5fe6e5b 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -41,7 +41,7 @@ PR authorship now loads the author's public GitHub profile image through the tru When the active installed provider is CrewCoder, the desktop model-row reveal offers session-scoped Configured default/general/crewcoder/plugin/extension profiles. Configured default omits the launch flag; a concrete choice adds CrewCoder's distinct `--mode`, restarts only that bridge before native resume, locks CrewCode's underlying permission policy to Build, and disables Ask/Plan/Build/Full on desktop and phone until Configured default is restored. The concrete `crewcoder` profile also reveals a session-scoped approval picker for `review`, `always`, `never`, `full-access`, and `sandboxed`. Review is the fail-closed default; Always prompts for every non-safe call, Never suppresses prompts but blocks dangerous calls, Full access accepts every call without prompting, and Sandboxed suppresses prompts while applying CrewCoder's sandbox policy to non-dangerous calls where supported. Missing or invalid values fail closed to review, changes restart only an idle bridge, and execution custody includes the native approval value. Full access explicitly bypasses CrewCoder approval requests and dangerous-command blocking, so CrewCode's permission overlay/tripwire cannot interpose. Never pass CrewCode's execution-mode value as the CrewCoder profile, and never let a concrete profile inherit a hidden prior Ask, Plan, or Full Access policy. The `crewcoder` profile's inspect/clarify/propose/approve-plan gate is CrewCoder-owned: CrewCode renders `crewcoder_clarify` and `crewcoder_propose_plan` on the activity overlay and sends `/approve-plan` as a prompt from the overlay button or slash command. Do not reuse tool-permission Allow/Deny for that card, and do not treat a clarification answer as plan approval. -CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp` with the session's normalized native approval value. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a CrewCode-owned activity record whose pending/running/tool-phase/terminal state comes only from observed bridge events; runtime loss becomes interrupted. Optional CrewCoder `crew-tasks` snapshots on ACP `rawOutput` enrich that row while active, and newer Task* mutations fold over the current turn's last snapshot. Project-wide `TaskList` results remain excluded because they mix unrelated CrewCoder sessions. CrewCode does not prompt for or fabricate native activity and does not enable disabled CrewCoder tools. See `docs/agent-activity-overlay.md`. See `docs/crewcoder-provider.md`. +CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp` with the session's normalized native approval value. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, and reports authoritative compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference). When `initialize._meta["crewcoder/sessionCompact"]` advertises the exact `session/compact` method, manual `/compact` must call it while idle, replace only CrewCode's replay shard with the returned authoritative summary, retain the full display transcript and durable session/bridge, and clear stale context occupancy only when compaction was applied; older agents keep the summary-reset fallback. Idle compaction events must not fabricate a model turn. CrewCoder uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a CrewCode-owned activity record whose pending/running/tool-phase/terminal state comes only from observed bridge events; runtime loss becomes interrupted. Optional CrewCoder `crew-tasks` snapshots on ACP `rawOutput` enrich that row while active, and newer Task* mutations fold over the current turn's last snapshot. Project-wide `TaskList` results remain excluded because they mix unrelated CrewCoder sessions. CrewCode does not prompt for or fabricate native activity and does not enable disabled CrewCoder tools. See `docs/agent-activity-overlay.md`. See `docs/crewcoder-provider.md`. ## ACP Grok Build @@ -140,7 +140,7 @@ Agent conversation replay history is stored per session under `userData/conversa Provider switching mid-chat is context handoff, not true provider state migration. Keep provider-native resume IDs keyed by the provider-specific bridge key, but keep CrewCode's local replay transcript keyed by the chat session scope so a new provider can continue from the existing visible thread. A provider switch with existing messages should show a `handoff` meter and inject a handoff packet on the next prompt, including workspace metadata plus an AI summary generated by a disposable incoming-provider session even when the target provider has an old native resume id. Bound the transcript before disposable summarization so smaller-context providers do not fail on huge sessions. If disposable summarization fails, fall back to bounded transcript context. -Manual `/compact` uses the same disposable-summary pattern: generate an AI summary from a bounded transcript in a temporary session, show the summary in chat, replace CrewCode's local replay history with that summary, clear the provider-native resume id, and start fresh on the next prompt. +Manual `/compact` generally uses the disposable-summary pattern: generate an AI summary from a bounded transcript in a temporary session, show the summary in chat, replace CrewCode's local replay history with that summary, clear the provider-native resume id, and start fresh on the next prompt. CrewCoder is the exception when it advertises ACP `session/compact`: CrewCoder rewrites its durable session in place, CrewCode uses the returned summary for local replay, and the native session id and bridge remain intact. Manual context handoff is available from the Solo Chat header and `/handoff`. Its card targets either a new chat (with provider/model/effort selection) or any other live chat shown in the current workspace's Sessions list, across that workspace's chat tabs. Used destinations retain their existing provider/model/effort, owning tab, transcript, and selected worktree. Starting either destination type closes the card immediately, reveals the destination through normal session navigation, and leaves progress or failure visible on its handoff meter; a disposable destination-provider session summarizes the source, the packet is appended to destination local replay history, and the destination native resume id is cleared before continuation. Remote browser handoff now follows the same flow through Brain-local `web:` conversation shards: source history stays on the Brain, the authenticated owner must own the destination bridge, a running destination refuses mutation, and the updated destination history is replayed exactly once on the next native-provider prompt. See `docs/provider-context-handoff.md`. diff --git a/docs/desktop-web-continuity.md b/docs/desktop-web-continuity.md index 83ea0ca..c6fe777 100644 --- a/docs/desktop-web-continuity.md +++ b/docs/desktop-web-continuity.md @@ -18,6 +18,15 @@ control and shared runtime state, not cloud file synchronization. `agent` as needed, then **Save and renew tunnel**. Hub identity alone never grants those. +The Hub-connected web application also exposes **Settings → Hub Machines**. It lists +all machines enrolled to the signed-in Hub owner, not only the currently selected +machine. Disabling a row immediately suspends that machine's Hub credential, closes +its relay/browser sessions, and blocks new tickets and heartbeats while preserving +the enrollment for a later Enable. If the current machine is disabled, the Brain +tunnel disconnects but the same-origin Hub control plane remains available so the +owner can enable it again. The machine stays offline until its Brain reconnects and +the Hub observes a fresh heartbeat. + **Settings → Desktop & Web** probes the enrolled Hub and displays its observed canonical browser/passkey origin with **Open Hub**; it never treats the enrollment address as proof of browser origin or reachability. Background Brain and Hub are separate processes: @@ -169,6 +178,11 @@ Hub. The control endpoint only supports status, stop, and the small trusted-desk credential allowlist. Hub relay frames remain end-to-end encrypted and every browser operation still passes the Brain-local scope and registered-workspace checks. Revoked machine authority stops the Brain instead of silently reconnecting. +Reversible Hub disablement is different: it withdraws remote authority and closes +Hub tunnels, but a running local/background Brain retains local custody and retries +the outbound relay. Re-enabling does not assert that the Brain is online; only its +subsequent observed connection and heartbeat do. Permanent revocation still stops +the Brain and requires re-enrollment. If a relay closes during initial browser hydration, the connection screen preserves the first observed close reason instead of replacing it with a later generic @@ -212,8 +226,10 @@ reported as a successful revocation. - `src/main/brain-desktop-rendezvous.ts` — owner-only connection/preferences files. - `src/main/hub-machine-enrollment.ts` and `src/main/hub-brain-relay.ts` — detached Brain startup, local backend lifetime, Hub relay, revocation handling. +- `src/main/hub-store.ts` and `src/main/hub-server.ts` — owner-scoped machine + enable/disable state, CSRF-protected mutations, and relay containment. - `src/renderer/src/runtime/web-rpc-client.ts` — composite Brain-attached Electron - client. + client plus authenticated Hub control-plane adapter for Hub web Settings. - `src/main/continuity-state-service.ts` and `src/renderer/src/runtime/continuity-state.ts` — bounded catalogue continuity. - `src/main/agents/bridge-service.ts` — stable-start coalescing and per-conversation diff --git a/docs/git-workspace.md b/docs/git-workspace.md index 0085fbb..27703ce 100644 --- a/docs/git-workspace.md +++ b/docs/git-workspace.md @@ -50,6 +50,21 @@ available only for real working-tree changes. sections render on the right (with the commit/changes sections hidden, since the page has its own). +## Switching tabs without losing work + +Inactive Git tabs may unmount so CrewCode does not keep duplicate repository +pollers and Git controllers running. Meaningful unfinished UI state is retained +in bounded, process-session memory keyed by the exact outer tab and worktree. +Returning to a tab restores its commit message and amend choice, PR creation +step and fields, whether the PR creator or Browser was open, PR Browser +navigation, review summary, and queued inline review comments. + +Explicitly cancelling a PR creator or successfully submitting a commit/PR clears +that workflow's draft. Credentials, loading indicators, mutation locks, +conflict-editor working buffers, and destructive confirmation dialogs are not +retained. Repository state is always reloaded from Git/GitHub rather than cached +as a successful outcome. + ## Authentication prompts - Pushes that need credentials open the one-shot diff --git a/docs/github-pull-requests.md b/docs/github-pull-requests.md index 0356df6..39f5a5c 100644 --- a/docs/github-pull-requests.md +++ b/docs/github-pull-requests.md @@ -40,6 +40,24 @@ button as proof that the state changed. ## Browse repository pull requests Select **Pull requests** in the Git Workspace header to open the repository PR +Browser. The launcher includes a badge when the latest bounded GitHub status +contains open pull requests; draft pull requests count as open, while closed and +merged pull requests do not. Background catalogue refreshes update evidence but +never create notification-bar history. + +While a Git surface remains active, CrewCode silently refreshes the bounded PR +catalogue once per minute. Pull requests created or changed directly on GitHub by +other users therefore appear in the list and open-count badge without reopening +the workspace. The refresh loop is single-flight, preserves the current selected +PR when it still exists, and does not announce historical or externally observed +state through the notification bar. + +Switching outer tabs retains the PR Browser's repository-scoped navigation, +filters, selected file/tab, review summary, and queued inline comments. The PR +creation flow also restores its current step, branch/commit selection, title, +structured body fields, and draft choice. Explicit cancel or successful submit +clears the creation draft; mutation locks and confirmation dialogs never carry +across an unmount. browser. CrewCode loads up to 100 open, closed, and merged pull requests in one catalogue request, then filters that observed result locally by **All**, **Open**, **Closed**, or **Assigned to you**. Closed includes merged pull diff --git a/docs/notifications.md b/docs/notifications.md index a16164d..e93ad5c 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -19,6 +19,16 @@ notification-worthy chat records only when they are newly appended through the live per-scope message path. Live errors remain global; an `agent exited` warning is shown only when its scope is currently active. +Git and GitHub notifications are also live-event-only. A card is published +after CrewCode observes a successful user-triggered commit, push, pull, fetch, +sync, local merge, merge continuation/abort, repository publish, or pull-request +mutation. Background Git polling, GitHub catalogue refreshes, startup hydration, +and restored PR state stay silent. Failures remain in the contextual Git banner; +they are not announced as successful operations. The shared Git controller +publishes these events for Git Workspace and embedded Git sidebars, while the PR +Browser publishes its direct conflict-workspace continue, abort, and resolved-head +push results. + Each notification card features: - Color-coded left border and icon indicating type - Semi-transparent glassmorphism background with backdrop blur @@ -113,6 +123,7 @@ NotificationBar (UI Component) | **Integration** | `src/renderer/src/App.tsx` | Wires notification triggers to app events | | **Chat event gate** | `src/renderer/src/stores/chat-messages-store.ts` | Publishes only newly appended live chat errors/exits; suppresses restored history | | **Chat host** | `src/renderer/src/components/thread/ChatNotifications.tsx` | Maps gated live chat events onto notification cards | +| **Git event gate** | `src/renderer/src/hooks/useGitSidebar.ts` | Publishes successful user-triggered Git and pull-request mutations only | | **Native toast** | `src/main/notify.ts` | Shows the cross-platform Electron notification and handles click focus | | **Sounds** | `src/renderer/src/notifications/notification-sounds.ts` | Validates presets and synthesizes custom tones | | **Styles** | `src/renderer/src/styles/styles.css` | CSS classes for layout, colors, and animations | diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index 739d5c2..f528a51 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -284,7 +284,8 @@ user ids so multi-user access can be added without changing machine identity. 3. The brain exchanges the approved device code for a revocable machine credential. Only a digest/encrypted form is persisted, with owner-only filesystem permissions. 4. The Hub stores the machine public key, owner, display name, created time, - last-seen time, and revocation state. It never receives provider keys, workspace + last-seen time, reversible disabled state, and revocation state. It never receives + provider keys, workspace credentials, source files, transcripts, or terminal output as control-plane data. 5. Re-enrollment and ownership transfer require explicit confirmation. A revoked machine credential cannot be refreshed. @@ -299,7 +300,7 @@ proves possession of its machine key with a Hub nonce, and sends a bounded capability/presence record. The machine list exposes only metadata such as: - stable opaque machine id and user-selected name; -- online, offline, connecting, or revoked status; +- online, offline, disabled, connecting, or revoked status; - platform, CrewCode version, protocol version, and coarse capabilities; - last seen time and an optional user-selected location label. @@ -313,9 +314,10 @@ never evidence that a command or agent turn completed. 2. The Hub issues a very short-lived, single-use connection ticket bound to the local user, browser session, machine id, requested protocol, and random nonce. 3. The browser presents the opaque ticket once to the Hub relay. The Hub consumes - it, revalidates machine ownership/revocation, and sends immutable user/session/ + it, revalidates machine ownership/disabled/revocation state, and sends immutable + user/session/ scope claims over the machine-authenticated outbound channel. Expired, replayed, - wrong-machine, offline, or revoked tickets are rejected. Tickets are memory-only, + wrong-machine, offline, disabled, or revoked tickets are rejected. Tickets are memory-only, not self-contained bearer claims or durable signed tokens. 4. The browser and brain perform an authenticated end-to-end handshake using the enrolled machine public key and a browser ephemeral key before privileged RPC is @@ -328,6 +330,17 @@ never evidence that a command or agent turn completed. The Hub adapter belongs behind `crewcode-client.ts`. Components and hooks must not know whether frames use direct HTTP/WebSocket or the Hub relay. +When the shared renderer is opened through an authenticated Hub, **Settings → Hub +Machines** lists every machine enrolled to that owner. **Disable** is a reversible +authority suspension, not a visual preference: the Hub records `disabled_at`, closes +the machine relay and its browser tunnels, refuses heartbeats and new connection +tickets, and preserves the enrollment credential for a later explicit **Enable**. +Enable clears the suspension and reports the machine offline until its Brain +reconnects and a new heartbeat is observed. The Settings adapter uses the Hub's +same-origin HttpOnly session plus a freshly observed CSRF value; it never routes this +owner control through the selected Brain or exposes a Hub credential to renderer +storage. Direct-server and Electron-only Settings do not show this Hub-owner surface. + ### Network deployment The Hub binds to loopback by default and requires explicit network configuration. @@ -366,10 +379,15 @@ or become a general-purpose TCP proxy. ### Revocation and custody -- Owners can inspect and revoke browser sessions, users, and enrolled machines from - the Hub. +- Owners can inspect enrolled machines and reversibly enable/disable them from Hub + web Settings. Permanent revocation remains a separate Hub administration action. +- Disabling a machine immediately refuses new authority and closes its active relay + sessions. The local Brain may retain local execution custody and retry its outbound + connection; disabling remote access is not evidence that local work completed or + stopped. - A brain periodically revalidates machine status and immediately closes new and - active tunnels when revocation is observed. + active tunnels when disablement or revocation is observed. Revocation remains + terminal: its credential cannot be enabled again. - If identity, scope, relay continuity, or session authority becomes unknown, the brain refuses new privileged actions and applies the execution-custody rules in `docs/execution-custody.md`. @@ -398,7 +416,7 @@ Minimum Hub data model: ```text LocalUser(id, credential, role, created_at, revoked_at) -Machine(id, owner_user_id, public_key, name, status, created_at, last_seen_at, revoked_at) +Machine(id, owner_user_id, public_key, name, status, created_at, last_seen_at, disabled_at, revoked_at) BrowserSession(id, user_id, created_at, expires_at, revoked_at) ConnectionTicket(id, user_id, machine_id, browser_session_id, expires_at, used_at) AuditEvent(id, user_id?, machine_id?, browser_session_id?, type, created_at, metadata) @@ -422,7 +440,8 @@ AuditEvent(id, user_id?, machine_id?, browser_session_id?, type, created_at, met tickets remain.** 8. Implement `crewcode enroll`, persistent machine identity, outbound presence, and explicit machine revocation. **Enrollment, owner-only machine credentials, - outbound heartbeat presence, dashboard status, and revocation are complete. + outbound heartbeat presence, dashboard status, reversible Settings disable/enable, + and revocation are complete. Machine logout/credential rotation remain.** 9. Implement the bounded Hub relay and a transport-neutral multiplexed tunnel with authenticated end-to-end browser-to-brain encryption. **Preview complete:** @@ -546,8 +565,11 @@ Enrollment creates an Ed25519 machine identity plus a random bearer credential i stores the public key and only a SHA-256 digest of the bearer secret. `crewcode brain` then maintains an authenticated outbound WebSocket relay and sends HTTPS heartbeats every 30 seconds; the dashboard marks a machine offline after 90 seconds without a -successful heartbeat. Revoking it closes active relay sessions and rejects later -heartbeats. Enrollment tokens are never written +successful heartbeat. Disabling it from **Settings → Hub Machines** closes active +relay sessions and rejects heartbeats/tickets until it is explicitly enabled again; +the preserved Brain process can then reconnect with the same credential. Revoking it +closes active relay sessions and permanently rejects later heartbeats. Enrollment +tokens are never written to the Hub database and are invalidated by Hub restart, expiry, first successful use, or a failed guess against their id. @@ -589,7 +611,8 @@ The initial CLI implementation is available from a source checkout through It builds/serves the shared renderer, defaults to loopback, prints a single-use pairing URL, resolves installed provider CLIs without Electron, and shuts down cleanly on SIGINT/SIGTERM. The direct-auth CLI and remaining machine-management -commands above remain planned. Enrollment, dashboard revocation, machine selection, +commands above remain planned. Enrollment, dashboard revocation, Settings machine +enable/disable, machine selection, and shared CrewCode workspace-client launch through the encrypted Hub relay are implemented. diff --git a/src/main/agents/bridge-service.test.ts b/src/main/agents/bridge-service.test.ts index 1210eb9..8413b5b 100644 --- a/src/main/agents/bridge-service.test.ts +++ b/src/main/agents/bridge-service.test.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { describe, expect, it, vi } from 'vitest' import { AgentBridgeService, bridgeSessionStorageKey, webConversationKey } from './bridge-service' import type { AgentBridgeFactory } from './bridge-service' +import type { BridgeEvent } from './bridge-types' import { loadConversation, saveConversation } from './conversation-store' describe('AgentBridgeService', () => { @@ -133,6 +134,47 @@ describe('AgentBridgeService', () => { await expect(service.stop('missing')).resolves.toEqual({ ok: true }) }) + it('stores a native compact summary without replacing the durable provider session', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'crewcode-native-compact-')) + const previousDataDir = process.env.CREWCODE_DATA_DIR + process.env.CREWCODE_DATA_DIR = dataDir + const conversationKey = `web:native-compact-${Date.now().toString(36)}` + const stop = vi.fn(async () => undefined) + const factory: AgentBridgeFactory = async (_path, opts) => ({ + bridgeId: opts.bridgeId, + pid: null, + prompt: async () => ({ ok: true }), + compact: async () => ({ ok: true, compacted: true, summary: 'Durable CrewCoder summary.' }), + abort: async () => undefined, + stop, + }) + const service = new AgentBridgeService(() => '/bin/fake-agent', factory) + const events: BridgeEvent[] = [] + service.subscribe(event => events.push(event)) + try { + saveConversation(conversationKey, [{ role: 'user', content: 'Original history.' }]) + await service.start({ bridgeId: 'crewcoder', provider: 'crewcoder', cwd: '/tmp', conversationKey }) + + await expect(service.compact('crewcoder')).resolves.toEqual({ ok: true, error: undefined, unsupported: undefined }) + expect(loadConversation(conversationKey)).toEqual([ + { role: 'user', content: 'Continue from this compacted conversation summary.' }, + { role: 'assistant', content: 'Durable CrewCoder summary.' }, + ]) + expect(events).toContainEqual(expect.objectContaining({ + type: 'handoff_summary', + bridgeId: 'crewcoder', + summary: 'Durable CrewCoder summary.', + reason: 'compact', + })) + expect(stop).not.toHaveBeenCalled() + } finally { + await service.stopAll() + if (previousDataDir === undefined) delete process.env.CREWCODE_DATA_DIR + else process.env.CREWCODE_DATA_DIR = previousDataDir + rmSync(dataDir, { recursive: true, force: true }) + } + }) + it('treats a duplicate stable start as an attach without stopping the provider', async () => { const service = new AgentBridgeService(() => null) const stop = vi.spyOn(service, 'stop') diff --git a/src/main/agents/bridge-service.ts b/src/main/agents/bridge-service.ts index cf352a3..d4578a3 100644 --- a/src/main/agents/bridge-service.ts +++ b/src/main/agents/bridge-service.ts @@ -263,10 +263,32 @@ export class AgentBridgeService { return this.sendConversationPrompt(entry, text, options) } - compact(bridgeId: string): Promise<{ ok: boolean; error?: string; unsupported?: boolean }> { + async compact(bridgeId: string): Promise<{ ok: boolean; error?: string; unsupported?: boolean }> { const entry = this.bridges.get(bridgeId) - if (!entry) return Promise.resolve({ ok: false, error: 'bridge not found' }) - return entry.bridge.compact?.() ?? Promise.resolve({ ok: false, unsupported: true, error: 'provider does not support compaction' }) + if (!entry) return { ok: false, error: 'bridge not found' } + if (!entry.bridge.compact) return { ok: false, unsupported: true, error: 'provider does not support compaction' } + if (entry.running) return { ok: false, error: 'cannot compact while the conversation is running' } + entry.running = true + try { + const result = await entry.bridge.compact() + if (result.ok && result.compacted !== false && result.summary && entry.opts.conversationKey) { + saveConversation(entry.opts.conversationKey, [ + { role: 'user', content: 'Continue from this compacted conversation summary.' }, + { role: 'assistant', content: result.summary }, + ]) + this.emit({ + type: 'handoff_summary', + bridgeId, + summary: result.summary, + fromProvider: entry.opts.provider, + toProvider: entry.opts.provider, + reason: 'compact', + }) + } + return { ok: result.ok, error: result.error, unsupported: result.unsupported } + } finally { + entry.running = false + } } resetSession(conversationKey: string, sessionKey?: string): { ok: true } | { error: string } { diff --git a/src/main/agents/bridge-types.ts b/src/main/agents/bridge-types.ts index f37f3c0..c1bf890 100644 --- a/src/main/agents/bridge-types.ts +++ b/src/main/agents/bridge-types.ts @@ -195,7 +195,16 @@ export interface AgentBridge { // can sample the bridge's per-process CPU/memory. null if the spawn has no pid. readonly pid: number | null prompt(text: string, options?: PromptOptions): Promise<{ ok: boolean; error?: string }> - compact?(): Promise<{ ok: boolean; error?: string; unsupported?: boolean }> + compact?(): Promise<{ + ok: boolean + error?: string + unsupported?: boolean + // Native compactors may return the authoritative replacement context. + // This stays behind the main/Brain boundary; callers project it into the + // local replay shard and visible handoff-summary event. + summary?: string + compacted?: boolean + }> // Cancel a locally queued follow-up before it is sent. Only bridges that // queue follow-ups themselves (claude) implement this; providers that queue // upstream (pi) cannot un-send and leave it undefined. diff --git a/src/main/agents/compaction-meter.test.ts b/src/main/agents/compaction-meter.test.ts index acc29bd..db00997 100644 --- a/src/main/agents/compaction-meter.test.ts +++ b/src/main/agents/compaction-meter.test.ts @@ -17,8 +17,8 @@ describe('compactionStrategy', () => { }) it('uses summary-reset for native-session providers with no compact rpc', () => { - // pi/hermes/CrewCoder keep server-side sessions and expose no compaction rpc, - // so the agent summarizes its own context and we seed a fresh session with it. + // Pi/Hermes and older CrewCoder versions keep server-side sessions without + // an advertised compact RPC, so we seed a fresh session with a summary. expect(compactionStrategy({ ...base, provider: 'pi', hasNativeCompact: false, nativeResume: true })).toBe('summary-reset') expect(compactionStrategy({ ...base, provider: 'hermes', hasNativeCompact: false, nativeResume: true })).toBe('summary-reset') expect(compactionStrategy({ ...base, provider: 'crewcoder', hasNativeCompact: false, nativeResume: true })).toBe('summary-reset') diff --git a/src/main/agents/compaction-meter.ts b/src/main/agents/compaction-meter.ts index 91c3335..00bddb2 100644 --- a/src/main/agents/compaction-meter.ts +++ b/src/main/agents/compaction-meter.ts @@ -53,7 +53,8 @@ export type CompactionStrategy = 'native' | 'local-summary' | 'summary-reset' | // codex thread/compact, opencode /summarize, plugins). // - local-summary → CrewCode owns the replay history (HTTP_ONLY) so it asks for a // summary and replaces the stored messages with it. -// - summary-reset → native-session providers with no compaction RPC (pi, hermes, CrewCoder): +// - summary-reset → native-session providers with no compact RPC (pi, hermes, +// and CrewCoder versions without the advertised extension): // have the agent summarize its own live context, then start a // fresh upstream session seeded with that summary. // - unsupported → nothing safe to do; report it instead of sending the literal diff --git a/src/main/agents/crewcoder-bridge.test.ts b/src/main/agents/crewcoder-bridge.test.ts index 68c18d4..f99a94b 100644 --- a/src/main/agents/crewcoder-bridge.test.ts +++ b/src/main/agents/crewcoder-bridge.test.ts @@ -9,6 +9,7 @@ vi.mock('./agent-spawn', () => ({ spawnAgentProcess })) import { CREWCODER_PROMPT_INACTIVITY_TIMEOUT_MS, createCrewCoderBridge, + crewCoderCompactMethod, crewCoderInitializeParams, crewCoderAcpErrorMessage, createCrewCoderToolProjectionState, @@ -18,6 +19,18 @@ import { crewCoderUsageFromPromptResult, } from './crewcoder-bridge' +describe('CrewCoder compact capability', () => { + it('accepts only the advertised CrewCoder session compact method', () => { + expect(crewCoderCompactMethod({ + _meta: { 'crewcoder/sessionCompact': { method: 'session/compact', preview: true, editedSummary: true } }, + })).toBe('session/compact') + expect(crewCoderCompactMethod({ + _meta: { 'crewcoder/sessionCompact': { method: 'session/delete' } }, + })).toBeUndefined() + expect(crewCoderCompactMethod({})).toBeUndefined() + }) +}) + describe('CrewCoder filesystem custody handshake', () => { it('keeps local ACP file capabilities separate from virtual custody', () => { expect(crewCoderInitializeParams(false)).toEqual(expect.objectContaining({ @@ -51,7 +64,7 @@ class FakeAcpProcess extends EventEmitter { } } -function crewCoderAcpHarness(options: { directoryError?: string } = {}): { proc: FakeAcpProcess; sent: Array> } { +function crewCoderAcpHarness(options: { directoryError?: string; compact?: boolean } = {}): { proc: FakeAcpProcess; sent: Array> } { const proc = new FakeAcpProcess() const sent: Array> = [] let input = '' @@ -66,7 +79,13 @@ function crewCoderAcpHarness(options: { directoryError?: string } = {}): { proc: const message = JSON.parse(line) as Record sent.push(message) if (message.method === 'initialize') { - proc.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`) + proc.stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: options.compact + ? { _meta: { 'crewcoder/sessionCompact': { method: 'session/compact', preview: true, editedSummary: true } } } + : {}, + })}\n`) } else if (message.method === 'session/new') { proc.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { sessionId: 'session-1' } })}\n`) } else if (message.method === 'session/set_external_directories') { @@ -77,12 +96,73 @@ function crewCoderAcpHarness(options: { directoryError?: string } = {}): { proc: proc.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { queued: true } })}\n`) } else if (message.method === 'session/set_reasoning_effort') { proc.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`) + } else if (message.method === 'session/compact') { + proc.stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { + sessionUpdate: '_crewcoder/compaction_update', + status: 'completed', + automatic: false, + percent: 100, + message: 'Context compacted.', + summary: 'Authoritative compact summary.', + }, + }, + })}\n`) + proc.stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { compacted: true, summary: 'Authoritative compact summary.' }, + })}\n`) } } }) return { proc, sent } } +describe('CrewCoder native compaction', () => { + it('exposes compact only when advertised and calls session/compact without fabricating a turn', async () => { + const harness = crewCoderAcpHarness({ compact: true }) + const events: BridgeEvent[] = [] + spawnAgentProcess.mockResolvedValue({ proc: harness.proc, dir: '/repo', remote: false }) + + const bridge = await createCrewCoderBridge('crewcoder', { + bridgeId: 'bridge', + provider: 'crewcoder', + cwd: '/repo', + mode: 'build', + }, event => events.push(event)) + + await expect(bridge.compact?.()).resolves.toEqual({ + ok: true, + compacted: true, + summary: 'Authoritative compact summary.', + }) + expect(harness.sent).toContainEqual(expect.objectContaining({ + method: 'session/compact', + params: { sessionId: 'session-1' }, + })) + expect(events).toContainEqual(expect.objectContaining({ + type: 'compaction_event', + automatic: false, + resetContext: true, + })) + expect(events.some(event => event.type === 'turn_start')).toBe(false) + }) + + it('keeps compact absent for older CrewCoder ACP agents', async () => { + const harness = crewCoderAcpHarness() + spawnAgentProcess.mockResolvedValue({ proc: harness.proc, dir: '/repo', remote: false }) + const bridge = await createCrewCoderBridge('crewcoder', { + bridgeId: 'bridge-old', provider: 'crewcoder', cwd: '/repo', mode: 'build', + }, () => {}) + expect(bridge.compact).toBeUndefined() + }) +}) + describe('CrewCoder ACP errors', () => { it('prefers actionable RequestError data over the generic JSON-RPC message', () => { expect(crewCoderAcpErrorMessage({ @@ -511,6 +591,17 @@ describe('CrewCoder ACP update projection', () => { provider: 'crewcoder', resetContext: true, }]) + + expect(crewCoderEventsFromUpdate({ + sessionUpdate: '_crewcoder/compaction_update', + status: 'completed', + automatic: false, + phase: 'skipped', + percent: 100, + message: 'Nothing to compact yet.', + }, 'bridge', '', createCrewCoderToolProjectionState())).toEqual([expect.not.objectContaining({ + resetContext: true, + })]) }) it('ignores malformed compaction and unknown session update kinds', () => { diff --git a/src/main/agents/crewcoder-bridge.ts b/src/main/agents/crewcoder-bridge.ts index 9c22b9e..24663f3 100644 --- a/src/main/agents/crewcoder-bridge.ts +++ b/src/main/agents/crewcoder-bridge.ts @@ -161,6 +161,12 @@ function record(value: unknown): Record | undefined { : undefined } +export function crewCoderCompactMethod(initializeResult: unknown): string | undefined { + const meta = record(record(initializeResult)?._meta) + const capability = record(meta?.['crewcoder/sessionCompact']) + return capability?.method === 'session/compact' ? 'session/compact' : undefined +} + function finiteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } @@ -226,13 +232,13 @@ export function crewCoderEventsFromUpdate( return [{ type: 'compaction_event', bridgeId, - turnId, + ...(turnId ? { turnId } : {}), status, automatic, message, percent, provider: 'crewcoder', - ...(status === 'completed' ? { resetContext: true } : {}), + ...(status === 'completed' && update.phase !== 'skipped' ? { resetContext: true } : {}), }] } if (kind === 'agent_message_chunk' || kind === 'agent_thought_chunk') { @@ -446,6 +452,7 @@ export async function createCrewCoderBridge( let replayTurnId: string | null = null let tools = createCrewCoderToolProjectionState() let promptWatchdog: InactivityWatchdog | null = null + let compactMethod: string | undefined let unusable = false let unusableReason = 'bridge must restart after an unresponsive cancellation' let stopping = false @@ -601,7 +608,12 @@ export async function createCrewCoderBridge( if (!update || handleReplayUpdate(update)) return if (sessionId && params.sessionId !== sessionId) return promptWatchdog?.activity() - const turnId = startTurn() + // Host-requested session/compact runs while the session is idle. Its + // namespaced progress notifications are not model turns and must not leave + // a fabricated turn_start waiting forever for a turn_end. + const turnId = update.sessionUpdate === '_crewcoder/compaction_update' + ? currentTurnId ?? '' + : startTurn() for (const event of crewCoderEventsFromUpdate(update, opts.bridgeId, turnId, tools)) emit(event) if (!['user_message_chunk', 'agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update'].includes(update.sessionUpdate)) { dbg('<<', `unhandled sessionUpdate:${update.sessionUpdate}`, JSON.stringify(update).slice(0, 240)) @@ -805,7 +817,8 @@ export async function createCrewCoderBridge( }) try { - await request('initialize', crewCoderInitializeParams(remote)) + const initializeResult = await request('initialize', crewCoderInitializeParams(remote)) + compactMethod = crewCoderCompactMethod(initializeResult) let resumed = false if (opts.resumeSessionId) { @@ -871,7 +884,7 @@ export async function createCrewCoderBridge( emit({ type: 'error', bridgeId: opts.bridgeId, message: (error as Error).message }) } - return { + const bridge: AgentBridge = { bridgeId: opts.bridgeId, pid: proc.pid ?? null, async prompt(text: string, options) { @@ -929,4 +942,25 @@ export async function createCrewCoderBridge( }, 500) }, } + if (compactMethod) { + bridge.compact = async () => { + if (!sessionId) return { ok: false, error: 'crewcoder acp: session not established' } + if (unusable) return { ok: false, error: `crewcoder acp: ${unusableReason}` } + if (currentTurnId) return { ok: false, error: 'crewcoder acp: cannot compact while a turn is running' } + try { + const result = record(await request(compactMethod!, { sessionId }, 120_000)) + const summary = typeof result?.summary === 'string' && result.summary.trim() + ? result.summary.trim() + : undefined + return { + ok: true, + compacted: typeof result?.compacted === 'boolean' ? result.compacted : undefined, + summary, + } + } catch (error) { + return { ok: false, error: (error as Error).message } + } + } + } + return bridge } diff --git a/src/main/agents/index.ts b/src/main/agents/index.ts index 89e1b9f..baebd83 100644 --- a/src/main/agents/index.ts +++ b/src/main/agents/index.ts @@ -1515,6 +1515,52 @@ export function registerAgentBridgeIpc(resolveAgentPath: AgentPathResolver): voi provider: entry.provider, } satisfies BridgeEvent) } + + // Newer CrewCoder ACP agents advertise an authoritative durable-session + // compact method. Keep the same session/bridge alive, install the returned + // summary only into CrewCode's replay shard, and let CrewCoder's + // _crewcoder/compaction_update notifications own progress/completion. + if (entry.provider === 'crewcoder' && strategy === 'native' && entry.bridge.compact) { + const compactionTurnId = `${bridgeId}:compaction:${Date.now().toString(36)}` + entry.userInitiatedStop = false + entry.running = true + entry.lastActivityAt = Date.now() + custodyJournal().patch(bridgeId, { + status: 'running', turnId: compactionTurnId, turnStartedAt: Date.now(), + activePrompt: 'Compact the current CrewCoder session', authority: authorityOf(entry.opts), + }) + try { + const result = await entry.bridge.compact() + if (!result.ok) { + emitCompaction('failed', result.error ?? 'CrewCoder compaction failed.') + return { ok: false, error: result.error ?? 'CrewCoder compaction failed' } + } + if (result.compacted !== false && result.summary?.trim()) { + saveCompactedSummary(entry.conversationKey, result.summary) + webContents.fromId(entry.webContentsId)?.send('bridge:event', { + type: 'handoff_summary', + bridgeId, + summary: result.summary, + fromProvider: entry.provider, + toProvider: entry.provider, + reason: 'compact', + } satisfies BridgeEvent) + } + if (result.compacted !== false) { + entry.lastUsage = undefined + if (entry.sessionKey) clearUsageSnapshot(entry.sessionKey) + } + return { ok: true } + } finally { + entry.running = false + entry.lastActivityAt = Date.now() + const record = custodyJournal().get(bridgeId) + if (record?.status === 'running' && record.turnId === compactionTurnId) { + custodyJournal().patch(bridgeId, { status: 'idle', turnId: undefined, turnStartedAt: undefined, activePrompt: undefined }) + } + } + } + emitCompaction('started', `${entry.provider} compaction requested`) const history = entry.conversationKey ? loadConversation(entry.conversationKey) : [] diff --git a/src/main/hub-server.test.ts b/src/main/hub-server.test.ts index 73e1d9d..f67c4d0 100644 --- a/src/main/hub-server.test.ts +++ b/src/main/hub-server.test.ts @@ -77,6 +77,7 @@ describe('Hub store', () => { const migrated = new DatabaseSync(path) const columns = migrated.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> expect(columns.some(column => column.name === 'credential_digest')).toBe(true) + expect(columns.some(column => column.name === 'disabled_at')).toBe(true) migrated.close() }) @@ -257,12 +258,38 @@ describe('Hub HTTP security boundary', () => { machines = await (await fetch(`${running.url}/api/v1/hub/machines`, { headers: { cookie } })).json() as { machines: Array<{ status: string }> } expect(machines.machines[0]?.status).toBe('online') + const disabledResponse = await fetch(`${running.url}/api/v1/hub/machines/${enrolled.machineId}/enabled`, { + method: 'POST', headers: browserHeaders, body: JSON.stringify({ enabled: false }), + }) + expect(disabledResponse.status).toBe(200) + expect(await disabledResponse.json()).toMatchObject({ ok: true, machine: { status: 'disabled', disabledAt: time } }) + const whileDisabled = await fetch(`${running.url}/api/v1/hub/machines/heartbeat`, { + method: 'POST', headers: { authorization: `Bearer ${enrolled.token}`, 'content-type': 'application/json' }, body: '{}', + }) + expect(whileDisabled.status).toBe(423) + machines = await (await fetch(`${running.url}/api/v1/hub/machines`, { headers: { cookie } })).json() as { machines: Array<{ status: string }> } + expect(machines.machines[0]?.status).toBe('disabled') + + const enabledResponse = await fetch(`${running.url}/api/v1/hub/machines/${enrolled.machineId}/enabled`, { + method: 'POST', headers: browserHeaders, body: JSON.stringify({ enabled: true }), + }) + expect(enabledResponse.status).toBe(200) + expect(await enabledResponse.json()).toMatchObject({ ok: true, machine: { status: 'offline', disabledAt: null } }) + const afterEnable = await fetch(`${running.url}/api/v1/hub/machines/heartbeat`, { + method: 'POST', headers: { authorization: `Bearer ${enrolled.token}`, 'content-type': 'application/json' }, body: '{}', + }) + expect(afterEnable.status).toBe(200) + const revoked = await fetch(`${running.url}/api/v1/hub/machines/${enrolled.machineId}/revoke`, { method: 'POST', headers: browserHeaders, body: '{}' }) expect(revoked.status).toBe(200) const afterRevoke = await fetch(`${running.url}/api/v1/hub/machines/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${enrolled.token}`, 'content-type': 'application/json' }, body: '{}', }) expect(afterRevoke.status).toBe(401) + const cannotEnableRevoked = await fetch(`${running.url}/api/v1/hub/machines/${enrolled.machineId}/enabled`, { + method: 'POST', headers: browserHeaders, body: JSON.stringify({ enabled: true }), + }) + expect(cannotEnableRevoked.status).toBe(404) }) it('serves the standalone setup screen with a restrictive CSP', async () => { diff --git a/src/main/hub-server.ts b/src/main/hub-server.ts index c02eafc..11b18d7 100644 --- a/src/main/hub-server.ts +++ b/src/main/hub-server.ts @@ -187,7 +187,7 @@ const creation=o=>({...o,challenge:bytes(o.challenge),user:{...o.user,id:bytes(o const request=o=>({...o,challenge:bytes(o.challenge),allowCredentials:(o.allowCredentials||[]).map(c=>({...c,id:bytes(c.id)}))}); const authError=e=>{const message=e&&e.message?e.message:String(e);if(!window.isSecureContext)return'Passkeys require a secure browser context. Open the exact localhost URL printed by CrewCode, or use the configured HTTPS Hub origin.';if(message.includes('InsecureLocalhostNotAllowed'))return'This browser or passkey provider refuses passkeys over HTTP localhost. For local testing, try current Chrome or Chromium. Otherwise run the Hub at its final HTTPS origin and create the passkey there.';return message}; function view(name){for(const id of ['setup','signin','dashboard'])$(id).hidden=id!==name} -async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;if(window.matchMedia('(max-width: 768px)').matches&&!new URLSearchParams(location.search).has('hub-admin')){location.replace('/app?hub=mobile');return}view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const mobile=$('mobile');mobile.hidden=location.protocol!=='https:';if(!mobile.hidden){$('mobile-url').textContent=location.origin;$('mobile-qr').src='/api/v1/hub/mobile-qr.svg'}const pending=await json('/api/v1/hub/device-enrollments'),pendingWrap=$('pending-wrap'),pendingList=$('pending-machines');pendingList.textContent='';pendingWrap.hidden=!pending.requests.length;for(const x of pending.requests){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · code '+x.userCode+' · fingerprint '+x.publicKeyFingerprint+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');const approve=document.createElement('button');approve.textContent='Approve';approve.onclick=async()=>{try{await json('/api/v1/hub/device-enrollments/'+encodeURIComponent(x.id)+'/approve',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};const reject=document.createElement('button');reject.className='quiet';reject.textContent='Reject';reject.onclick=async()=>{try{await json('/api/v1/hub/device-enrollments/'+encodeURIComponent(x.id)+'/reject',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(approve,reject);row.append(actions);pendingList.append(row)}const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');if(x.status==='online'){const open=document.createElement('button');open.textContent='Open';open.onclick=()=>{location.href='/app?machine='+encodeURIComponent(x.id)};actions.append(open)}if(x.status!=='revoked'){const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(revoke)}row.append(actions);list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} +async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;if(window.matchMedia('(max-width: 768px)').matches&&!new URLSearchParams(location.search).has('hub-admin')){location.replace('/app?hub=mobile');return}view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const mobile=$('mobile');mobile.hidden=location.protocol!=='https:';if(!mobile.hidden){$('mobile-url').textContent=location.origin;$('mobile-qr').src='/api/v1/hub/mobile-qr.svg'}const pending=await json('/api/v1/hub/device-enrollments'),pendingWrap=$('pending-wrap'),pendingList=$('pending-machines');pendingList.textContent='';pendingWrap.hidden=!pending.requests.length;for(const x of pending.requests){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · code '+x.userCode+' · fingerprint '+x.publicKeyFingerprint+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');const approve=document.createElement('button');approve.textContent='Approve';approve.onclick=async()=>{try{await json('/api/v1/hub/device-enrollments/'+encodeURIComponent(x.id)+'/approve',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};const reject=document.createElement('button');reject.className='quiet';reject.textContent='Reject';reject.onclick=async()=>{try{await json('/api/v1/hub/device-enrollments/'+encodeURIComponent(x.id)+'/reject',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(approve,reject);row.append(actions);pendingList.append(row)}const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');if(x.status==='online'){const open=document.createElement('button');open.textContent='Open';open.onclick=()=>{location.href='/app?machine='+encodeURIComponent(x.id)};actions.append(open)}if(x.status!=='revoked'){const toggle=document.createElement('button');toggle.className='quiet';toggle.textContent=x.status==='disabled'?'Enable':'Disable';toggle.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/enabled',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:JSON.stringify({enabled:x.status==='disabled'})});await refresh()}catch(e){error.textContent=e.message}};actions.append(toggle);const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(revoke)}row.append(actions);list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} $('setup-button').onclick=async()=>{try{error.textContent='';const token=new URLSearchParams(location.hash.slice(1)).get('bootstrap')||'';const username=$('owner').value;const start=await json('/api/v1/hub/bootstrap/options',{method:'POST',body:JSON.stringify({token,username})});const credential=await navigator.credentials.create({publicKey:creation(start.options)});const done=await json('/api/v1/hub/bootstrap/verify',{method:'POST',body:JSON.stringify({token,username,flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;history.replaceState(null,'',location.pathname);await refresh()}catch(e){error.textContent=authError(e)}}; $('signin-button').onclick=async()=>{try{error.textContent='';const start=await json('/api/v1/hub/auth/options',{method:'POST',body:'{}'});const credential=await navigator.credentials.get({publicKey:request(start.options)});const done=await json('/api/v1/hub/auth/verify',{method:'POST',body:JSON.stringify({flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;await refresh()}catch(e){error.textContent=authError(e)}}; $('enrollment-button').onclick=async()=>{try{error.textContent='';const issued=await json('/api/v1/hub/enrollments',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'}),out=$('enrollment');out.hidden=false;out.textContent='Enrollment token (single use; do not share):\\n'+issued.token+'\\n\\nRun on the machine within 10 minutes, then paste the token when prompted:\\ncrewcode enroll --hub '+location.origin}catch(e){error.textContent=e.message}}; @@ -471,6 +471,7 @@ export async function startHubServer(options: HubServerOptions): Promise column.name === 'credential_digest')) { this.db.exec('ALTER TABLE machines ADD COLUMN credential_digest TEXT') } + if (!machineColumns.some(column => column.name === 'disabled_at')) { + this.db.exec('ALTER TABLE machines ADD COLUMN disabled_at INTEGER') + } this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS machines_credential_digest ON machines(credential_digest)') } @@ -250,7 +246,7 @@ export class HubStore { .run(id, input.userId, input.publicKey, digest(secret), input.name, input.platform, input.version, input.now, input.now) this.audit('hub.machine.enrolled', input.userId, id, { name: input.name, platform: input.platform }, input.now) return { - machine: { id, name: input.name, status: 'online', platform: input.platform, version: input.version, createdAt: input.now, lastSeenAt: input.now, revokedAt: null }, + machine: { id, name: input.name, status: 'online', platform: input.platform, version: input.version, createdAt: input.now, lastSeenAt: input.now, disabledAt: null, revokedAt: null }, token: `${id}.${secret}`, } } @@ -260,19 +256,19 @@ export class HubStore { if (separator < 1) return null const id = token.slice(0, separator) const secret = token.slice(separator + 1) - const row = this.db.prepare('SELECT id, owner_user_id, revoked_at FROM machines WHERE id = ? AND credential_digest = ? AND revoked_at IS NULL') - .get(id, digest(secret)) as { id: string; owner_user_id: string; revoked_at: number | null } | undefined - return row ? { id: row.id, ownerUserId: row.owner_user_id, revokedAt: row.revoked_at } : null + const row = this.db.prepare('SELECT id, owner_user_id, disabled_at, revoked_at FROM machines WHERE id = ? AND credential_digest = ? AND revoked_at IS NULL') + .get(id, digest(secret)) as { id: string; owner_user_id: string; disabled_at: number | null; revoked_at: number | null } | undefined + return row ? { id: row.id, ownerUserId: row.owner_user_id, disabledAt: row.disabled_at, revokedAt: row.revoked_at } : null } machineAuthorityForUser(userId: string, machineId: string): HubMachineAuthority | null { - const row = this.db.prepare('SELECT id, owner_user_id, public_key, revoked_at FROM machines WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL') - .get(machineId, userId) as { id: string; owner_user_id: string; public_key: string; revoked_at: number | null } | undefined - return row ? { id: row.id, ownerUserId: row.owner_user_id, publicKey: row.public_key, revokedAt: row.revoked_at } : null + const row = this.db.prepare('SELECT id, owner_user_id, public_key, disabled_at, revoked_at FROM machines WHERE id = ? AND owner_user_id = ? AND disabled_at IS NULL AND revoked_at IS NULL') + .get(machineId, userId) as { id: string; owner_user_id: string; public_key: string; disabled_at: number | null; revoked_at: number | null } | undefined + return row ? { id: row.id, ownerUserId: row.owner_user_id, publicKey: row.public_key, disabledAt: row.disabled_at, revokedAt: row.revoked_at } : null } heartbeatMachine(machineId: string, platform: string | null, version: string | null, now: number): boolean { - const result = this.db.prepare("UPDATE machines SET status = 'online', platform = ?, version = ?, last_seen_at = ? WHERE id = ? AND revoked_at IS NULL") + const result = this.db.prepare("UPDATE machines SET status = 'online', platform = ?, version = ?, last_seen_at = ? WHERE id = ? AND disabled_at IS NULL AND revoked_at IS NULL") .run(platform, version, now, machineId) return Number(result.changes) === 1 } @@ -285,16 +281,29 @@ export class HubStore { return true } + setMachineEnabled(userId: string, machineId: string, enabled: boolean, now: number): HubMachineSummary | null { + const current = this.machinesForUser(userId, now).find(machine => machine.id === machineId && machine.revokedAt === null) + if (!current) return null + if ((current.disabledAt === null) === enabled) return current + const result = enabled + ? this.db.prepare("UPDATE machines SET disabled_at = NULL, status = 'offline', last_seen_at = NULL WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL AND disabled_at IS NOT NULL").run(machineId, userId) + : this.db.prepare("UPDATE machines SET disabled_at = ?, status = 'offline' WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL AND disabled_at IS NULL").run(now, machineId, userId) + if (Number(result.changes) !== 1) return null + this.audit(enabled ? 'hub.machine.enabled' : 'hub.machine.disabled', userId, machineId, {}, now) + return this.machinesForUser(userId, now).find(machine => machine.id === machineId) ?? null + } + machinesForUser(userId: string, now = Date.now(), onlineWindowMs = 90_000): HubMachineSummary[] { - const rows = this.db.prepare('SELECT id, name, status, platform, version, created_at, last_seen_at, revoked_at FROM machines WHERE owner_user_id = ? ORDER BY name COLLATE NOCASE').all(userId) as unknown as MachineRow[] + const rows = this.db.prepare('SELECT id, name, status, platform, version, created_at, last_seen_at, disabled_at, revoked_at FROM machines WHERE owner_user_id = ? ORDER BY name COLLATE NOCASE').all(userId) as unknown as MachineRow[] return rows.map(row => ({ id: row.id, name: row.name, - status: row.revoked_at ? 'revoked' : row.last_seen_at !== null && row.last_seen_at > now - onlineWindowMs ? 'online' : 'offline', + status: row.revoked_at !== null ? 'revoked' : row.disabled_at !== null ? 'disabled' : row.last_seen_at !== null && row.last_seen_at > now - onlineWindowMs ? 'online' : 'offline', platform: row.platform, version: row.version, createdAt: row.created_at, lastSeenAt: row.last_seen_at, + disabledAt: row.disabled_at, revokedAt: row.revoked_at, })) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 727c9e5..be8e467 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2803,6 +2803,7 @@ export default function App() { if (hasWs && tabKind === 'git') { return ( )} (pageMemoryKey) const staged = (state.changes || []).filter(change => change.staged).length const unstaged = Math.max(0, (state.changes || []).length - staged) const conflicts = (state.conflicts || []).length - const prs = (state.prs || []).length + const openPrs = (state.prs || []).filter(pr => pr.status === 'open' || pr.status === 'draft').length const history = (state.history || []).length const webUrl = state.remoteUrl ? remoteWebUrl(state.remoteUrl) : null const [branchOpen, setBranchOpen] = useState(false) const [branchQuery, setBranchQuery] = useState('') const [createBranchOpen, setCreateBranchOpen] = useState(false) - const [createPrOpen, setCreatePrOpen] = useState(false) - const [prBrowserOpen, setPrBrowserOpen] = useState(false) + const [createPrOpen, setCreatePrOpenState] = useState(rememberedPage?.createPullRequestOpen ?? false) + const [prBrowserOpen, setPrBrowserOpenState] = useState(rememberedPage?.pullRequestBrowserOpen ?? false) const [menu, setMenu] = useState<{ top: number; left: number } | null>(null) const moreRef = useRef(null) + const setCreatePrOpen = (open: boolean) => { + setCreatePrOpenState(open) + writeGitTabMemory(pageMemoryKey, { createPullRequestOpen: open, pullRequestBrowserOpen: prBrowserOpen }) + } + const setPrBrowserOpen = (open: boolean) => { + setPrBrowserOpenState(open) + writeGitTabMemory(pageMemoryKey, { createPullRequestOpen: createPrOpen, pullRequestBrowserOpen: open }) + } + const openMenu = () => { const rect = moreRef.current?.getBoundingClientRect() if (rect) setMenu({ top: rect.bottom + 4, left: Math.max(8, rect.right - 210) }) @@ -73,7 +86,11 @@ export function GitPage(props: GitSidebarProps) {

{workspace.path}

- +
{state.ahead || 0} {state.behind || 0} @@ -109,6 +126,7 @@ export function GitPage(props: GitSidebarProps) { onClose={() => setCreateBranchOpen(false)} />
PRs
-
{prs}
+
{openPrs}
Commits
diff --git a/src/renderer/src/components/git/GitPageCommit.tsx b/src/renderer/src/components/git/GitPageCommit.tsx index 7476f65..88d4de7 100644 --- a/src/renderer/src/components/git/GitPageCommit.tsx +++ b/src/renderer/src/components/git/GitPageCommit.tsx @@ -1,31 +1,41 @@ import { useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Icon } from '../ui/Icon' +import { clearGitTabMemory, readGitTabMemory, writeGitTabMemory, type GitCommitDraft } from './git-tab-memory' type CommitMode = 'plain' | 'push' | 'sync' interface GitPageCommitProps { + memoryKey: string branch: string stagedCount: number - onCommit?: (opts: { message: string; amend: boolean; push: boolean; sync?: boolean }) => void + onCommit?: (opts: { message: string; amend: boolean; push: boolean; sync?: boolean }) => Promise | void onPush?: () => void onPull?: () => void onFetch?: () => void onSync?: () => void } -export function GitPageCommit({ branch, stagedCount, onCommit, onPush, onPull, onFetch, onSync }: GitPageCommitProps) { - const [message, setMessage] = useState('') - const [amend, setAmend] = useState(false) +export function GitPageCommit({ memoryKey, branch, stagedCount, onCommit, onPush, onPull, onFetch, onSync }: GitPageCommitProps) { + const remembered = readGitTabMemory(memoryKey) + const [message, setMessageState] = useState(remembered?.message ?? '') + const [amend, setAmendState] = useState(remembered?.amend ?? false) const [menu, setMenu] = useState<{ top: number; left: number } | null>(null) const caretRef = useRef(null) const canCommit = message.trim().length > 0 && (stagedCount > 0 || amend) - const commit = (mode: CommitMode) => { - if (!canCommit) return - onCommit?.({ message, amend, push: mode === 'push', sync: mode === 'sync' }) - setMessage('') - setAmend(false) + const updateDraft = (nextMessage: string, nextAmend: boolean) => { + setMessageState(nextMessage) + setAmendState(nextAmend) + writeGitTabMemory(memoryKey, { message: nextMessage, amend: nextAmend }) + } + + const commit = async (mode: CommitMode) => { + if (!canCommit || !onCommit) return + const completed = await onCommit({ message, amend, push: mode === 'push', sync: mode === 'sync' }) + if (completed === false) return + updateDraft('', false) + clearGitTabMemory(memoryKey) } const openMenu = () => { @@ -35,8 +45,8 @@ export function GitPageCommit({ branch, stagedCount, onCommit, onPush, onPull, o const items = [ { label: 'push', icon: 'arrowUp' as const, run: () => onPush?.() }, - { label: 'commit & push', icon: 'gitCommit' as const, run: () => commit('push'), disabled: !canCommit }, - { label: 'commit & sync', icon: 'refresh' as const, run: () => commit('sync'), disabled: !canCommit }, + { label: 'commit & push', icon: 'gitCommit' as const, run: () => { void commit('push') }, disabled: !canCommit }, + { label: 'commit & sync', icon: 'refresh' as const, run: () => { void commit('sync') }, disabled: !canCommit }, { label: 'pull', icon: 'arrowDown' as const, run: () => onPull?.() }, { label: 'sync', icon: 'refresh' as const, run: () => onSync?.() }, { label: 'fetch', icon: 'refresh' as const, run: () => onFetch?.() }, @@ -53,17 +63,17 @@ export function GitPageCommit({ branch, stagedCount, onCommit, onPush, onPull, o