feat(desktop): integrate DeepSeek Harness - #889
Conversation
There was a problem hiding this comment.
💡 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".
| if call.workspace_bindings(reply) is Some(bindings) { | ||
| state.dsh_workspaces.replace(bindings) |
There was a problem hiding this comment.
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 👍 / 👎.
| fn State::refresh(_self : State, dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { | ||
| DshStatusReply.status(dispatch) | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| for stream in ["mux", "host"] { | ||
| group.spawn_bg(allow_failure=true, () => { | ||
| connection.pump(stream, receive) catch { |
There was a problem hiding this comment.
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 👍 / 👎.
| 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: "" }) |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| "error": "\{error}", | ||
| } | ||
| } | ||
| (events.try_put(Disconnected) catch { _ => false }) |> ignore |
There was a problem hiding this comment.
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 👍 / 👎.
| 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"]) |
There was a problem hiding this comment.
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 👍 / 👎.
817fc11 to
894bc60
Compare
|
@codex review |
There was a problem hiding this comment.
💡 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".
| for event in self.events { | ||
| if event.seq > snapshot_last && | ||
| !events.iter().any(item => item.seq == event.seq) { | ||
| events.push(event) |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| }), | ||
| ) | ||
| } | ||
| if entries.is_empty() { |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| pub let dsh_pending : Command[Json, Json] = Command("dsh.pending") | ||
|
|
||
| ///| | ||
| pub let dsh_request : Command[Json, Json] = Command("dsh.request") |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| DshStatusChanged(Json) | ||
| DshEvent(Json) |
There was a problem hiding this comment.
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 👍 / 👎.
| if kind is DshPromptReply(session_id~, generation~, ..) && | ||
| ( | ||
| state.active_id != Some(session_id) || | ||
| state.prompt_generation != generation | ||
| ) { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| _ => sessions.get(0).map(row => row.id) | ||
| } | ||
| let next = { | ||
| ..self, | ||
| connection: DshReady, |
There was a problem hiding this comment.
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 👍 / 👎.
| DshCreateReply | ||
| DshHistoryReply(String) | ||
| DshPromptReply(session_id~ : String, draft~ : String, generation~ : Int) | ||
| DshCancelReply |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| let next = { | ||
| ..self, | ||
| active_id: Some(session_id), | ||
| events: [], |
There was a problem hiding this comment.
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 👍 / 👎.
| loading: if self.active_id == Some(session_id) { | ||
| false | ||
| } else { | ||
| self.loading |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| ..state, | ||
| active_id: Some(session_id), | ||
| selection_generation: state.selection_generation + 1, | ||
| events: [], |
There was a problem hiding this comment.
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 👍 / 👎.
| if kind is DshResponseReply(rpc_id) { | ||
| let receipt = DshResponseReceipt(reply) | ||
| guard receipt.accepted() else { | ||
| return (@cmd.none, { ..self, loading: false, notice: receipt.error() }) |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| loading: if self.active_id == Some(session_id) { | ||
| false | ||
| } else { | ||
| self.loading | ||
| }, |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| 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()) | ||
| }) |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| loading: false, | ||
| notice: envelope.error(), |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| if kind is DshWorkspaceCreateReply(selection_generation) && | ||
| state.selection_generation != selection_generation { | ||
| return (@cmd.none, { ..state, creation_pending: false }) |
There was a problem hiding this comment.
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 👍 / 👎.
| loading: if self.active_id == Some(session_id) { | ||
| self.pending | ||
| .iter() | ||
| .any(item => { |
There was a problem hiding this comment.
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 👍 / 👎.
|
@codex review |
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>
188cf55 to
d6f9f7c
Compare
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.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.responddsh 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.historytakes its window size from the host, so the page cannot ask for a whole log, andsession.promptcarries 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.listcarries the authorization fence's verdict, anddsh.session.listcarries the actor revision captured when its HTTP call actually started.Transport
dsh's existing carriers, unchanged:
POST /api/<method>with complete ApiProxyclient-request/server-responseenvelopes,/api/events.muxfor all-session events,/api/events.hostfor host lifecycle, andPOST /api/respondfor 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 authoritativeworkspace.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.removetherefore asks dsh's currentworkspace.listand 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 webgets a dedicatedDSH_HOMEunder the per-user runtime directory —<runtime_dir>/dsh, created0700on POSIX — the same shape the Codex integration already gives its app-server. dsh keepssettings.yaml, the.credentials.yamlstore, session logs, profiles, and storages there. The user's owndshCLI 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:
While the environment supplies the reference,
credentials.setrefuses rather than writing a value resolution would keep ignoring. The spawn therefore setsDEEPSEEK_API_KEYto the empty string, which that layer reads as absent —extra_envcan 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 becausedescribereturnswritable: falseand 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.
describecarries no value in either direction, andsetis 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.eventare filtered before relay delivery, and the rows and model group are absent from browser-console builds.Desktop does not bundle DeepSeek Harness:
dshand the Node environment it needs must already be on the login-shellPATH. 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-warnmoon check --target native --deny-warnmoon test --target js— 3082 passedmoon test --target native— 3464 passedmoon fmt/moon infocleanE2E of the full flow is pending.