Skip to content

feat(desktop): integrate DeepSeek Harness - #889

Draft
tonyfettes wants to merge 68 commits into
mainfrom
codex/dsh-desktop-integration
Draft

feat(desktop): integrate DeepSeek Harness#889
tonyfettes wants to merge 68 commits into
mainfrom
codex/dsh-desktop-integration

Conversation

@tonyfettes

@tonyfettes tonyfettes commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Run a local DeepSeek Harness (dsh web) process beside the existing OpenSeek engine and Codex app-server, and present dsh as one more agent rather than one more place: its conversations share the sidebar's Projects section with OpenSeek's and Codex's, and one is started by picking a dsh model in the composer's unified model chip.

  • transcript, composer, Files, Editor, and Terminal are the existing shared surfaces — dsh supplies history, live assistant deltas, tool calls, approvals, and questions through them
  • queue a prompt, steer a running turn, cancel it, and answer single-select / multi-select / free-text questions
  • Worktree mode: Desktop cuts its normal Git worktree, registers it as a dsh Workspace, starts a conversation inside it, and sends the first prompt there
  • archive a conversation to hide it from every grouping surface
  • configure the DeepSeek API key from the app, in a dsh home this process owns
  • startup failures surface with dsh's own error rather than a generic "not on PATH"

Design

The command catalog is the capability list

The renderer never names a dsh method. Each operation it may perform is its own Proton command with its own typed request — 17 commands, and no generic proxy:

dsh.status · dsh.pending · dsh.workspace.list · dsh.workspace.create · dsh.workspace.archive_session · dsh.session.list · dsh.session.create · dsh.session.history · dsh.session.models · dsh.session.select_model · dsh.session.prompt · dsh.session.cancel · dsh.llm.models · dsh.credentials.describe · dsh.credentials.set · dsh.credentials.unset · dsh.respond

dsh ships more than fifty RPC methods. The rest — settings.*, host.*, command.execute, agentPreset.*, goal.*, subagent.*, session.fork, workspace.delete, workspace.insertSessionBefore, … — are unreachable because nothing names them, and a method a later dsh release adds stays unreachable until a command is written for it. The capability surface belongs to this repository rather than to whichever dsh happens to be installed.

Two request shapes are narrowed here rather than relayed: session.history takes its window size from the host, so the page cannot ask for a whole log, and session.prompt carries one text string that the endpoint expands into dsh's typed content array, so a part the page never authored cannot ride the call.

Two replies carry a native fact beside dsh's envelope, and both are required fields of dedicated reply types rather than optional ones every caller re-checks — dsh.workspace.list carries the authorization fence's verdict, and dsh.session.list carries the actor revision captured when its HTTP call actually started.

Transport

dsh's existing carriers, unchanged: POST /api/<method> with complete ApiProxy client-request / server-response envelopes, /api/events.mux for all-session events, /api/events.host for host lifecycle, and POST /api/respond for approval and question answers. The native process is the only component that knows the loopback port, which keeps dsh's Host/Origin checks intact instead of adding CORS or pointing the renderer at localhost.

Filesystem authority

dsh's Workspace registry decides which directory a conversation works in — never the session's display-only cwd. Selecting an accounted session opens Files, Editor, and Terminal at its Workspace root; an unaccounted one stays usable as a conversation but receives no filesystem authority. Grants are revoked as soon as either WebSocket drops and restored only by a fresh authoritative workspace.list.

Cleanup, and what dsh does not offer

Archiving is the only cleanup verb, because dsh has no session deletion at all. It is also irreversible: dsh's archive set is append-only, and its own API documents unarchive as a future capability. The sidebar therefore confirms first and offers no Restore on dsh rows, unlike OpenSeek's and Codex's.

A Desktop-created dsh worktree records no owner in the worktree registry. dsh's Workspace registry decides where a conversation runs, that mapping changes without Desktop's involvement, and dsh has no operation that durably means "this conversation ended" — so a recorded owner could only go stale, and a stale owner is what makes a checkout impossible to clean up. worktree.remove therefore asks dsh's current workspace.list and refuses while an unarchived conversation lives anywhere inside the checkout. A dsh that has answered before but is unreachable now refuses too, since its registry is durable; only a dsh that has never answered in this process's life is skipped.

Isolated home, and the DeepSeek key

dsh web gets a dedicated DSH_HOME under the per-user runtime directory — <runtime_dir>/dsh, created 0700 on POSIX — the same shape the Codex integration already gives its app-server. dsh keeps settings.yaml, the .credentials.yaml store, session logs, profiles, and storages there. The user's own dsh CLI home is neither read nor written, and clearing the app's runtime state removes every dsh artifact Desktop created. The costs mirror Codex's and are accepted: CLI conversations are absent from the sidebar and provider configuration starts empty.

The isolated home is also what makes dsh's credential seam reachable. dsh resolves a reference from four layers, and the inherited process environment wins and is read-only:

inherited process environment      (read-only, wins)
> $DSH_HOME/.credentials.yaml      (provider-managed, writable)
> <invocation cwd>/.env            (read-only fallback)
> $DSH_HOME/.env                   (read-only fallback)

While the environment supplies the reference, credentials.set refuses rather than writing a value resolution would keep ignoring. The spawn therefore sets DEEPSEEK_API_KEY to the empty string, which that layer reads as absent — extra_env can add entries but not remove them — leaving the store in the isolated home as the writable source. Both overrides exist only when a home was prepared: without one there is no store of ours to reach, so the environment passes through untouched, and the page reports that honestly because describe returns writable: false and names the layer.

The three credential commands are the narrowest exception to the catalog rule rather than a widening of it. dsh's own methods take a reference name; these take none — the reference is a host constant, so the page configures the shipped DeepSeek route and cannot address any other credential dsh holds. describe carries no value in either direction, and set is the only direction a secret travels.

A missing key is not a startup failure: dsh starts, lists models, and opens conversations without one, and only a turn fails with MISSING_CREDENTIAL. The key card therefore appears above the composer — where that is about to be discovered — and can be dismissed with Not now, since reading an old conversation or configuring the key elsewhere are both reasonable things to be doing instead. The dsh topbar control is the way back.

Settings carries the same key under a DeepSeek Harness group beside Codex's, and both surfaces drive one page state: a key typed in either is the same draft, and neither can disagree with the other about what is stored. They differ in when they appear, which is the point — the card catches the user about to send a prompt that cannot run and hides once a key works, while the Settings row is drawn whether or not anything is wrong. Neither can prefill the field: the seam never returns a value, so it always starts empty, and "Clear" is a separate operation rather than saving a blank.

Limits

dsh runs inside this native process, so its rows appear only under the local device's projects and the chip offers dsh only for a conversation that runs there. While a remote device is focused, dsh is absent from both surfaces. The commands are registered on the embedded Desktop extension alone — they are absent from the remote WebSocket catalog, dsh.status_changed / dsh.event are filtered before relay delivery, and the rows and model group are absent from browser-console builds.

Desktop does not bundle DeepSeek Harness: dsh and the Node environment it needs must already be on the login-shell PATH. What it configures is the home it points dsh at and the one credential inside it — everything else in that home stays dsh's.

Validation

  • moon check --target js --deny-warn
  • moon check --target native --deny-warn
  • moon test --target js — 3082 passed
  • moon test --target native — 3464 passed
  • moon fmt / moon info clean

E2E of the full flow is pending.

@tonyfettes
tonyfettes marked this pull request as ready for review August 15, 2026 14:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8eeb2ab7b9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/internal/api/api.mbt Outdated
Comment on lines +932 to +933
if call.workspace_bindings(reply) is Some(bindings) {
state.dsh_workspaces.replace(bindings)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Ignore stale workspace authorization snapshots

When two workspace.list calls overlap—for example, the parallel refreshes triggered by readiness and subsequent workspace/session events—the actor runs their HTTP requests concurrently, so an older snapshot can finish last. The mutex in replace serializes mutations but does not identify freshness, and this unconditional application can therefore reauthorize a session for a removed or previous workspace; the frontend likewise accepts that stale response and may expose Files or Terminal in the wrong directory. Carry a request generation through both consumers or serialize these list requests so older replies cannot replace newer membership.

Useful? React with 👍 / 👎.

Comment thread desktop/frontend/dsh/bridge.mbt Outdated
Comment on lines +87 to +89
fn State::refresh(_self : State, dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd {
DshStatusReply.status(dispatch)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Provide a retry path from the unavailable empty state

After initial dsh startup fails, the actor waits for a queued Call or Respond before attempting another connection, but the only visible empty-state action reaches this method and merely polls dsh.status. Because session creation is also disabled while unavailable and there are no session rows to open, the user cannot trigger that queued request even after dsh becomes available; restarting Desktop is the only recovery. Make Refresh enqueue a reconnect attempt or expose another enabled retry action.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 682ef0a2a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let next = {
..self,
connection: DshReady,
sessions,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject stale session-list snapshots

When session.list calls overlap—for example, the requests dispatched by readiness, host/session-added, and session creation—the actor executes their HTTP work concurrently, so an older snapshot can finish last. This unconditional replacement can reintroduce a session already removed by host/session-removed or omit a newly added session, leaving the sidebar and active selection stale until another refresh. Serialize session.list calls or associate them with generations and ignore older replies.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d35c78ce1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/internal/dsh/actor.mbt Outdated
Comment on lines +173 to +175
for stream in ["mux", "host"] {
group.spawn_bg(allow_failure=true, () => {
connection.pump(stream, receive) catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replay pending interactions when the page reconnects

When the Desktop page loads after these process-lifetime WebSockets have already delivered their subscription baselines, or when the page reloads while dsh remains running, the new frontend state never receives outstanding approval/requested or question/requested messages. Opening or refreshing the dsh page only requests workspace and session snapshots, so a turn waiting for one of those responses remains blocked with no interaction card; reconnect the dsh streams on page attachment or cache and replay the pending-interaction baseline.

Useful? React with 👍 / 👎.

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +300 to +305
DshPromptReply => {
let cmd = match self.active_id {
Some(session_id) => self.load_history(dispatch, session_id)
None => @cmd.none
}
(cmd, { ..self, draft: "", loading: false, notice: "" })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope prompt replies to their originating session

If a prompt is sent in session A and the user opens session B before its HTTP reply arrives, this handler reloads B and unconditionally clears the shared draft. A host/session-status event can clear loading before that reply, allowing the user to start typing for B and then lose that text; carry the originating session (and submitted draft) in DshPromptReply and ignore or narrowly apply stale replies.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 817fc11bf5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/internal/dsh/actor.mbt Outdated
"error": "\{error}",
}
}
(events.try_put(Disconnected) catch { _ => false }) |> ignore

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Ensure event overflow always disconnects the actor

When either WebSocket fills the 2,048-entry event queue, receive returns false and terminates that pump, but this nonblocking insertion of Disconnected targets the same full queue and can fail as well. In that case the actor never leaves Ready, the other stream keeps the task group alive, and subsequent requests do not reconnect the lost stream; a mux overflow can therefore leave approvals, questions, and live output permanently unavailable until Desktop restarts. Queue the disconnect notification reliably or otherwise signal connection teardown outside the saturated event queue.

Useful? React with 👍 / 👎.

Comment thread desktop/internal/api/api.mbt Outdated
test "dsh endpoints stay in the local Desktop catalog" {
let state = ApiState::new(engine="unused")
let desktop_names = local_endpoints(state).map(endpoint => endpoint.name())
assert_eq(desktop_names, ["dsh.status", "dsh.request", "dsh.respond"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include dsh.pending in the endpoint catalog assertion

The same local_endpoints array now contains dsh.pending between dsh.status and dsh.request, so this exact equality is unconditionally false and the desktop/internal/api MoonBit test suite fails. Update the expected catalog to include the newly registered endpoint (and retain the remote-absence check for it).

AGENTS.md reference: AGENTS.md:L44-L46

Useful? React with 👍 / 👎.

@tonyfettes
tonyfettes force-pushed the codex/dsh-desktop-integration branch from 817fc11 to 894bc60 Compare August 15, 2026 17:27
@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40a1de1f84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +306 to +309
for event in self.events {
if event.seq > snapshot_last &&
!events.iter().any(item => item.seq == event.seq) {
events.push(event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Discard stale history replies instead of merging their old window

When two history reads for the same session overlap—such as the reads initiated by the initial session list and session/subscribed—a newer 200-entry snapshot can be applied first and an older snapshot can finish last. This loop then appends every entry from the newer state above the older snapshot's maximum sequence, producing the union of both windows (for example, 1..200 plus 201..300) rather than the newest 200 entries promised by maxMessages; long sessions can therefore accumulate stale transcript rows and an unnecessarily large DOM until another non-stale refresh. Serialize history requests per session or attach a request generation and reject older snapshots.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a42642a75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/frontend/dsh/view.mbt Outdated
}),
)
}
if entries.is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep a reconnect route when only workspaces remain

When dsh becomes unavailable while the snapshot contains one or more workspaces but no sessions, these project entries make this condition false, so the dsh.open placeholder is omitted; meanwhile both the section and project new-session actions are disabled because can_create() requires DshReady, and there is no refresh action on the dsh page. If the user navigates away, no normal UI action queues a request to wake the actor, leaving restart as the only recovery. The earlier empty-state retry is fixed, but the fresh evidence here is that retained nonempty workspace rows suppress that retry route even though they contain no openable session. Render an unavailable/retry entry regardless of workspace grouping, or keep an enabled action that reconnects.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb18a1d592

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/internal/commands/commands.mbt Outdated
pub let dsh_pending : Command[Json, Json] = Command("dsh.pending")

///|
pub let dsh_request : Command[Json, Json] = Command("dsh.request")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add explicit codecs to the dsh command boundaries

All four new Desktop commands use Command[Json, Json], so their required, missing, null, malformed, and unknown-field behavior is absent from the generated protocol interface and schema drift is only discovered by scattered runtime guards in the API and reducer. Define explicit request/reply envelope types with manual FromJson and ToJson implementations; the versioned upstream payload or value can remain a Json field inside those typed envelopes.

AGENTS.md reference: desktop/AGENTS.md:L16-L19

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55e8728356

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +140 to +141
DshStatusChanged(Json)
DshEvent(Json)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Define typed envelopes for dsh notifications

The actor creates Desktop-owned payload wrappers (status, and stream/message/generation), but these variants expose them as unconstrained Json. The generated protocol therefore cannot document or reject missing, null, malformed, or unknown wrapper fields; schema drift instead makes with_status report dsh unavailable or with_event silently discard live approvals and session updates. Define explicit outer notification payloads with manual codecs while retaining the versioned nested dsh documents as Json.

AGENTS.md reference: desktop/AGENTS.md:L16-L19

Useful? React with 👍 / 👎.

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +108 to +112
if kind is DshPromptReply(session_id~, generation~, ..) &&
(
state.active_id != Some(session_id) ||
state.prompt_generation != generation
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore stale history failures after session switches

When the user opens session A and then session B before A's history request settles, the two requests can overlap because they use different per-session lanes. A late failure from A is not covered by this prompt-only stale-reply check, so it clears B's loading state and displays A's error in B's transcript; scope DshHistoryReply(session_id) failures to the currently active session just as successful history replies are scoped.

Useful? React with 👍 / 👎.

focused=@interop.ChannelId::Local,
active_session=session,
root~,
submission_generation=0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Advance the transcript generation when sending dsh prompts

When a user has scrolled up, the transcript component is unpinned and only re-pins for navigation or a changed submission_generation. This value is permanently zero for dsh, so submitting a prompt or steer in the same session leaves the component unpinned and new live output appears below the viewport rather than following the local submission. Pass the dsh prompt/submission generation here instead of a constant.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9eae57778b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +238 to +242
_ => sessions.get(0).map(row => row.id)
}
let next = {
..self,
connection: DshReady,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the transcript when the session baseline becomes empty

When a reconnect or refresh discovers that the previously active session was deleted while the event stream was disconnected, active_id becomes None, but spreading self retains that session's events and streaming; the clearing branch only runs when another session is selected. The page therefore continues showing the deleted conversation's transcript with no active session. Clear the transcript whenever the selected ID changes, including the transition to None.

Useful? React with 👍 / 👎.

Comment thread desktop/frontend/dsh/model.mbt Outdated
DshCreateReply
DshHistoryReply(String)
DshPromptReply(session_id~ : String, draft~ : String, generation~ : Int)
DshCancelReply

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope cancellation replies to their originating session

If the user cancels session A and opens session B before the HTTP request settles, DshCancelReply carries no session identity, so its late success unconditionally clears B's loading state and notice, while its late failure is displayed in B's transcript. Carry the canceled session ID in this reply kind and ignore page-state effects after the active session changes.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d86708c6c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +281 to +284
let next = {
..self,
active_id: Some(session_id),
events: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve newer navigation when session creation completes

When a user starts creating a session and then opens an existing session before session.create settles, the sidebar remains openable and this late reply unconditionally replaces the newer selection with the created session while clearing its transcript. Carry a creation/selection generation or the originating selection and activate the new session only if the user has not navigated elsewhere.

Useful? React with 👍 / 👎.

Comment on lines +517 to +520
loading: if self.active_id == Some(session_id) {
false
} else {
self.loading

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep prompt input busy until its request settles

When dsh emits host/session-status for the active session before the corresponding session.prompt HTTP reply, this clears loading while the submitted draft is still present. The composer becomes enabled again, and pressing Enter can resend the same text—typically as a steer after running becomes true. Track request busy state separately from session lifecycle status, or leave it set until the prompt reply settles.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27a05d0d73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +52 to +55
..state,
active_id: Some(session_id),
selection_generation: state.selection_generation + 1,
events: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset the draft when switching dsh sessions

When a user types in session A and then opens session B, this transition changes active_id but spreads the unchanged shared draft; the composer therefore presents A's text under B, and Send submits it to B's potentially different workspace. This also affects a prompt still awaiting its reply, because the submitted text remains in draft until settlement. Clear the adapter draft on an actual selection change or store drafts by session while preserving it when reopening the already-selected row.

Useful? React with 👍 / 👎.

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +205 to +208
if kind is DshResponseReply(rpc_id) {
let receipt = DshResponseReceipt(reply)
guard receipt.accepted() else {
return (@cmd.none, { ..self, loading: false, notice: receipt.error() })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope interaction reply state to the originating session

When an approval or question response for session A is in flight, session rows remain openable, so the user can navigate to B before the receipt arrives. DshResponseReply carries only the RPC id, and this branch unconditionally applies a rejection notice—or, on acceptance, clears loading—to B's current page state. Carry the originating session in the reply so pending membership can still be updated globally while notice/loading are settled only for the session that submitted the response.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4731b7275d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +614 to +618
loading: if self.active_id == Some(session_id) {
false
} else {
self.loading
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep interaction cards busy until their response settles

When an approval or question response is in flight, a host/session-status frame for the active session can arrive first over the independent host WebSocket and clear loading here. The unresolved card is still present until the HTTP receipt or mux resolution arrives, so its buttons become enabled again and the user can submit the same client-response twice, typically surfacing a rejection after the first response succeeded. Track interaction-response busy state by RPC id instead of clearing it on session lifecycle updates.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd1a99ef6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +664 to +670
loading: if self.active_id == Some(session_id) {
self.pending
.iter()
.any(item => {
item.session_id() == session_id &&
self.response_pending_rpcs.contains(item.rpc_id())
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve busy state while creating sessions

When session.create is in flight for a user who already has an active session, any independent host/session-status event for that active session resets loading based only on pending interaction responses. Because can_create() then sees the page as idle, the sidebar's new-session actions become enabled before the original create request settles, allowing a second click to create duplicate sessions. Track creation requests independently, as is already done for prompts and interaction responses, rather than clearing their busy state here.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75c51b6bac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +309 to +310
loading: false,
notice: envelope.error(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope business-error replies to their originating session

When session A's history, cancellation, or creation request returns a normal server-response with result.ok: false after the user opens session B, the bridge dispatches Reply rather than Failed. The stale-session checks cover only Failed, and this generic error block runs before the kind-specific branches, so it clears B's loading state and displays A's error in B's transcript. The fresh remaining path is the dsh business-error envelope; validate its session or selection generation before applying this state update.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c25c5fbc1a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/frontend/dsh/update.mbt Outdated
Comment on lines +174 to +176
if kind is DshWorkspaceCreateReply(selection_generation) &&
state.selection_generation != selection_generation {
return (@cmd.none, { ..state, creation_pending: false })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Continue workspace creation after newer navigation

When workspace.create succeeds after the user navigates to another session, this early return discards the successful reply before with_reply can dispatch the follow-up session.create. This leaves an empty workspace behind and silently abandons the requested new conversation; continue the creation chain with the original selection generation, then let the final session reply avoid changing the newer selection.

Useful? React with 👍 / 👎.

Comment on lines +702 to +705
loading: if self.active_id == Some(session_id) {
self.pending
.iter()
.any(item => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep cancellation busy until its request settles

When host/session-status reports running: false before the independent session.cancel HTTP reply settles, this branch recomputes loading solely from pending interaction responses. Because cancellation has no separate pending state, the composer becomes enabled and can submit a new prompt while cancellation is still unresolved; track the cancel request independently and clear it only when DshCancelReply settles.

Useful? React with 👍 / 👎.

@tonyfettes

Copy link
Copy Markdown
Contributor Author

@codex review

tonyfettes and others added 28 commits August 20, 2026 17:53
The worktree registry recorded a `dsh_session` owner for every checkout
created for a DeepSeek Harness conversation, and `worktree.remove` refused
any row that had one. Releasing that owner was therefore the only way a dsh
checkout could ever be cleaned up, and the only release trigger was the
`host/session-removed` event.

That event does not mean what the design assumed. dsh emits it from
`session/disposed`, which is in-memory session teardown; dsh has no session
deletion API at all, and `session.list` keeps serving the session from
persistence afterwards. Keying durable state on it released bindings for
live conversations, and every delivery gap in the event path turned into a
checkout that nothing could remove — a durable release queue, a detached
registry index, a startup retry, a process-local tombstone map and a
separate lifecycle event lane all existed to close those gaps.

Record no owner instead. A dsh checkout is an ordinary unowned worktree,
and removal asks dsh's current `workspace.list` whether any unarchived
conversation works there. The answer is always current, where a stored
owner was stale by construction. An offline dsh cannot be running anything,
so it does not block removal; a dsh that is running but cannot answer
refuses it.

The cost is that first-send creation is no longer idempotent: with no owner
to key on, a lost `worktree.create` reply is reported rather than retried,
leaving at most a spare checkout the user can remove. Retrying would create
a second one.

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js desktop/frontend — 450 passed
- moon test --target native over internal/{worktree,dsh,api,engine,protocol,
  work_dir,remote,host,commands,codex} — 276 passed
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choosing Worktree provisioned a checkout, registered it as a dsh Workspace,
and then called workspace.insertSessionBefore to move the blank conversation
into it. dsh rejects that every time:

  workspace-move-invalid: cannot move session '...' in workspace
  '.../.worktrees/wt-1': the session is not accounted

insertSessionBefore only reorders sessions a Workspace already accounts for.
Membership itself is not writable — it is derived from the session's cwd
(`sessionIds` filters on `sessionPath(id) == record.path`), and that cwd is
fixed in the session header when the session is created. attachSession, the
only operation that adds a member, refuses a session whose cwd resolves
elsewhere. There is no operation anywhere in dsh that moves a conversation
to another directory, so the first send could never succeed.

Create the conversation in the generated Workspace instead: session.create
takes a workspaceId, sets the new session's cwd from the Workspace path, and
attaches it in the same call. The prompt, the submission fence, the draft
snapshot, and the failure diagnostics follow the created session; the blank
conversation the user typed in keeps its own directory and its place in the
sidebar. The composer's model choice carries over on its own, because
selectModel also saves the selection as dsh's default.

The move stage and its verification round-trip are gone with it — the
created session is accounted by construction, so there is nothing to verify,
and workspace.insertSessionBefore leaves the allowlist (nine methods now).

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js desktop/frontend — 450 passed
- moon test --target native desktop/internal/{api,dsh} — 28 passed
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e lock

Three follow-ups to the owner-drop change, all found by review.

`worktree.remove` asked dsh while holding the workspace lifecycle lock. That
lock also serializes attach, detach, archive, and every create in the app,
and the question is a cross-process HTTP round trip with a 120s timeout, so
one wedged dsh could freeze all worktree lifecycle work for minutes. Ask
before taking the lock instead, and only when the answer can change the
outcome: an owned row already belongs to OpenSeek or Codex, and a directory
that is already gone has nothing to protect — that second case also restores
the registry-only prune, which a stale dsh Workspace row could otherwise
block forever.

`dsh_occupancy` skipped Workspace rows it could not parse, so a malformed
reply read as "nobody is working here" — the one conclusion the whole path
exists to prevent, and the opposite of what its own docstring promised. The
cause was structural: three native readers each walked the same document by
hand and disagreed about damage (`workspace_bindings` raised, `dsh_occupancy`
skipped, the renderer dropped the row). Give `workspace.list` a type with an
explicit codec, per desktop/AGENTS.md, and route all three through one
decode; absoluteness stays beside it because that rule is Desktop's, not
dsh's schema. A missing `archivedSessionIds` still decodes as empty, which
can only count more sessions as live — the refusing direction.

`create` read the registry through the lossy reader after the strict branch
was removed with the dsh owner. Since the operation ends in a whole-file
write, a momentarily unreadable registry became an empty baseline that the
write made permanent, dropping every other conversation's placement in that
workspace. Read strictly.

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js desktop/frontend — 450 passed
- moon test --target native over internal/{api,protocol,worktree,dsh,engine} —
  180 passed, including new coverage for the codec, the shared decode, and
  which checkouts are worth asking dsh about
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven follow-ups from review, two of them undoing damage this branch did.

The worst: `remove` refused whenever dsh reported the checkout occupied, and
`force` did not override. Since the new first-send flow always creates a
conversation inside the generated Workspace, every dsh worktree was occupied
from birth — and dsh exposes no way to end a conversation (no delete, and
this integration allowlists no archive), so the checkout became permanently
unremovable. That is precisely the failure the owner drop set out to delete,
reintroduced through the front door. `force` now overrides, and the refusal
message says so instead of advising an action nobody can take.

Second: the session-create reply retargets the page — active session,
selection generation, transcript, models, submission fence — with no check
that the submission it belongs to is still running. A `host/session-removed`
for the originating session releases the fence while `session.create` is in
flight; the late reply then seized the visible conversation and re-sent the
draft under a dead generation, whose own reply the guard downstream drops,
leaving the page busy forever. Both setup stages now check ownership through
one `State::owns_submission`, which `WorktreeReady` already did by hand.

Also from review:
- the retarget left Worktree armed with a project, so the conversation's
  second message provisioned another checkout as soon as a session list
  reported it blank again; it now resets placement like any other selection
- the reconciliation baseline stayed at the originating session's sequence,
  which would report an accepted prompt as undelivered once that sequence is
  non-zero; the created conversation starts from its own
- `Actor::is_available` counted `Starting` as answerable, so a removal during
  startup waited out two readiness timeouts to learn dsh is absent; it is
  `is_ready` now, and a not-yet-connected dsh answers offline
- occupancy compared paths by equality, so a dsh Workspace rooted INSIDE the
  checkout did not protect it from deletion
- `archivedSessionIds` is decoded leniently: only occupancy reads it, while
  the same document carries the Files/Terminal grants, so an unrecognized
  shape must not cost the page its path authority — and reading it as empty
  only ever counts more sessions live, the refusing direction

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js desktop/frontend — 450 passed
- moon test --target native over internal/{api,protocol,worktree,dsh,engine} —
  180 passed, with new coverage for force, the stale-stage fences, and the
  lenient archive set
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A worktree has to be its own dsh Workspace — a conversation runs where its
cwd points, and membership is derived from that — but dsh files every
Workspace as a peer, so the sidebar rendered one project row per worktree.
Starting a Worktree conversation in `rapidhash` therefore produced a
top-level `wt-2` project sitting next to it, which is not what a worktree is.

The shared sidebar already has the right shape: `@conversation.Input` carries
a `worktree` name and marks the row with it, which is how OpenSeek presents
its own worktree conversations while leaving them under their project. dsh
passed `None` and grouped by Workspace instead.

Reconstruct the relationship from the path convention that created the
checkout — `<project>/.worktrees/<name>`, compared in the renderer's
slash-rooted spelling — and fold those Workspaces into the project they were
cut from, marking each conversation with its checkout. A worktree whose
project dsh no longer lists keeps its own row, since there is nothing left to
nest it under, and a deeper path inside `.worktrees` is treated as an
unrelated directory rather than a worktree.

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js — 3050 passed
- moon test --target native — 3508 passed, 1 pre-existing failure in
  deepseek/client's live API smoke test (HTTP 402 Insufficient Balance)
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions

The page had no cleanup verb at all. dsh has no session deletion, and this
integration allowlisted neither of the two operations it does have, so a
first-send leftover, an occupied checkout, and an empty generated Workspace
were all permanent: the sidebar accumulated them and nothing in the app —
or reachable through the bridge — could take them away.

Allowlist `workspace.archiveSession` and `workspace.delete`, and give each a
place in the sidebar it already had a slot for. Archiving hides a
conversation from every grouping surface; dsh keeps its log and its slot in
the Workspace account, so it is reversible from dsh itself but not from here.
Removing a Workspace registration keeps the directory and every session log —
the conversations it grouped become ungrouped rather than disappearing — and
is what finally clears a generated Workspace an abandoned first send left
behind.

The archive set is registry-global and rides every `workspace.list`, so the
page takes it whole and filters the sidebar with it. Nothing had read it
before: an archived conversation keeps its `sessionIds` slot, so a session
archived from any other dsh client still showed here.

Archiving the conversation being read releases the page rather than leaving
it rendering a hidden transcript. Both operations refuse while the page is
busy, like every other deliberate dsh command.

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js — 3051 passed
- moon test --target native over internal/{api,worktree,protocol,dsh} —
  77 passed
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit described archiving as "reversible from dsh itself but
not from here". It is not reversible anywhere. dsh's archive set is
append-only — `archiveSession` appends and no operation removes an id, its
RPC map exposes no unarchive, and dsh's own API documents unarchive as "a
future" capability — so an archived conversation is invisible to every dsh
client from then on, dsh's own included.

What archiving still buys over a delete is that the log stays on disk and the
session keeps its slot in the Workspace account, which is exactly what that
future unarchive would restore.

OpenSeek's sidebar does have Restore, wired for OpenSeek and Codex rows; dsh
rows do not offer it because there is no dsh operation behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Archiving is irreversible in dsh — its archive set is append-only and no
client, dsh's own included, can bring a conversation back — so the sidebar
row now stages a question instead of sending the request. The copy names the
conversation and separates what disappears from what survives: the transcript
stays on disk, project files and Git branches remain.

It is its own confirmation rather than a reuse of the two the root already
has. The worktree-discard dialog is about losing uncommitted work and the
archived-delete dialog is about erasing a record; this one erases neither,
which is exactly what the user needs told.

Like every other modal it joins `covering_overlay`, so native views stay
hidden underneath and the palette cannot open behind it.

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js — 3052 passed
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng it

Archiving the conversation on screen released the page, and then the session
list handed it straight back. `session.list` is not filtered by the archive
set — dsh hides an archived session from its Workspace grouping, not from the
listing — so the refresh dispatched by the archive reply still carried it, and
the fallback took `sessions.get(0)`. The result was a transcript for a
conversation the sidebar deliberately no longer showed.

Subtract the archive set wherever a conversation is picked or drawn, not just
where the sidebar draws it: `State::visible_sessions` is now the one
definition, used by the sidebar, the session-list fallback, and the
session-removed fallback.

`State::release_archived_active` replaces the archive reply's inline
clearing and also runs on every `workspace.list`, so a conversation archived
from another dsh client stops being displayed here too.

`archived_session_ids` is a `Set` now, in the page state and in the occupancy
query. Membership is the only question ever asked of it and dsh's archive
order means nothing to us; the wire type stays a list because that is its
JSON shape.

- moon check --target js --deny-warn
- moon check --target native --deny-warn
- moon test --target js — 3052 passed
- moon test --target native over internal/{protocol,api,worktree} — 70 passed
- moon fmt && moon info

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tonyfettes
tonyfettes force-pushed the codex/dsh-desktop-integration branch from 188cf55 to d6f9f7c Compare August 20, 2026 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant