Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7454348
feat(agent-host): install the DeepSeek Harness runtime on demand
deymosh Oct 4, 2026
2d8de40
feat(agent-host): the DeepSeek Harness driver over ACP
deymosh Oct 4, 2026
427afa7
feat(agent-host): run the harness on a gateway's own models
deymosh Oct 4, 2026
e982ce3
feat(agent-host): the harness's slash commands, on the phone
deymosh Oct 4, 2026
f28a3b2
feat(bridge): the DeepSeek Harness environment
deymosh Oct 4, 2026
536f22d
build(docker): bundle the harness runtime with BUNDLE_AGENTS=1
deymosh Oct 4, 2026
54295ee
docs: the DeepSeek Harness agent
deymosh Oct 4, 2026
34a4bf9
chore: store every text file as LF
deymosh Oct 4, 2026
aee7dff
fix(agent-host): read a shipped bundle's version where it actually is
deymosh Oct 4, 2026
cb931cc
feat(agent-host): the harness's questions, on the phone
deymosh Oct 4, 2026
5d14275
feat(agent-host): the harness's plan review, on the phone
deymosh Oct 4, 2026
1ca28b4
feat(agent-host): mount the harness's question tool
deymosh Oct 4, 2026
75b9c11
fix(agent-host): give every harness process its own plugin socket
deymosh Oct 7, 2026
3c1856a
fix(agent-host): ask again for a session's commands that could not be…
deymosh Oct 7, 2026
5fb4024
fix(agent-host): keep the harness's endpoint and key out of other agents
deymosh Oct 7, 2026
e6a84f9
fix(agent-host): never pin the gateway's endpoint in the shared profile
deymosh Oct 7, 2026
add5a53
fix(agent-host): run the harness's plugin CLI in the sessions' home
deymosh Oct 7, 2026
b68ed8b
perf(agent-host): hold a package tree's tarballs gzipped until extrac…
deymosh Oct 7, 2026
e74b272
build(docker): keep the agents' runtimes in a volume of their own
deymosh Oct 7, 2026
d0db304
feat: send feedback with a plan the user sends back
deymosh Oct 7, 2026
422c355
feat(agent-host): steer the DeepSeek Harness's running turn with a ne…
deymosh Oct 7, 2026
072628d
test(agent-host): reach the DeepSeek plugin over a named pipe on Windows
deymosh Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Guidance for Claude Code (claude.ai/code) when working in this repository.
## Project identity

CodeDeck+ is a community-maintained continuation of CodeDeck Next: run coding
agents (Claude Code, OpenCode, …) on a laptop/VPS ("the bridge") and drive them
agents (Claude Code, OpenCode, DeepSeek Harness) on a laptop/VPS ("the bridge") and drive them
from an Android phone over end-to-end-encrypted Nostr. It started as a merge of
the two upstream projects (`codedeck-next-bridge`, `codedeck-next-mobile`) and
has since been rebuilt: the protocol, the bridge and the phone's core are Rust;
Expand Down Expand Up @@ -218,8 +218,8 @@ crates/protocol the phone wire: messages, total codec, kinds, ranges,
there by default — keep them consistent. Neither ships the agents' own
binaries: the agent host installs them on demand at the version and sha512
pnpm-lock.yaml pins (the image into the `/data` volume); only an image built
with `BUNDLE_AGENTS=1` bakes them in. Never add a global Claude Code or
OpenCode install — the version must follow the lockfile.
with `BUNDLE_AGENTS=1` bakes them in. Never add a global Claude Code,
OpenCode or DeepSeek Harness install — the version must follow the lockfile.

## History note

Expand Down
99 changes: 60 additions & 39 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,58 +1,79 @@
# Docker Compose reads this file: the container's secrets and Git setup, and
# optionally the bridge's most common settings. Every bridge setting can also
# live in ./data/config.json (created on first start; config.example.json
# shows every key). A variable set here wins over config.json; leave it
# unset to use the file's value.
# CodeDeck+ container settings (docker compose reads this file).
#
# Almost nothing here is required: the bridge writes its own configuration on
# first start (./data/config.json, and config.example.json in the repository
# shows every key it knows). What you set here wins over that file, so this is
# the place for the few things you want in the environment — the secrets, your
# git identity, and whichever agent you configure beyond its defaults.

# Compose exposes these two to the container as secrets.
# --------------------------------- secrets ----------------------------------
# Compose mounts these into the container as files under /run/secrets, so they
# never show up in `docker inspect`; the entrypoint reads them into the
# variables the agents expect. Every one of them is optional — leave a line
# empty and set that credential on the phone instead.

# Claude Code: a long-lived token from `claude setup-token`.
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat...

# GitHub: for git and the `gh` CLI inside sessions.
GITHUB_TOKEN=ghp_...

# Git identity for commits made in sessions.
# The DeepSeek Harness.
DEEPSEEK_API_KEY=

# ----------------------------------- git ------------------------------------
# Identity for the commits sessions make, and repositories cloned at start
# (each into /data/workspaces/<repo-name>).
GIT_USER=your_username
GIT_EMAIL=your_email@example.com
# Comma-separated repos, each cloned under /data/workspaces/<repo-name>.
GIT_REPO=https://github.com/your-username/your-repo.git

# --- Bridge settings (optional; or set them in data/config.json) ---
# ---------------------------------- agents ----------------------------------
# Claude Code, OpenCode and the DeepSeek Harness all run out of the box; these
# configure them further. docs/OPENCODE.md and docs/DEEPSEEK.md have the long
# version.

# Claude Code through a gateway or router (e.g. claude-code-router) instead of
# Anthropic directly:
# ANTHROPIC_BASE_URL=http://your-router-host:port
# For a router that exposes several providers behind one /v1/models:
# CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1

# OpenCode: an external `opencode serve`...
# CODEDECK_OPENCODE_SERVER_URL=http://127.0.0.1:4096
# ...or one the bridge starts and manages, optionally on a fixed port.
# CODEDECK_OPENCODE_AUTO_START=1
# CODEDECK_OPENCODE_PORT=4096

# DeepSeek Harness: a DeepSeek-compatible relay or gateway, for sessions that
# are not bound to a provider profile of their own:
# DEEPSEEK_BASE_URL=http://your-router-host:port
# A harness CLI of your own, instead of the one installed on first use (its
# lib/bin.js, or an executable):
# CODEDECK_DEEPSEEK_PATH=

# Agent runtimes are installed into /data/agents on first use, at the version
# the lockfile pins. Set this to bake them into the image instead, for a host
# without internet access (it applies at the next build).
# CODEDECK_BUNDLE_AGENTS=1

# -------------------------------- the bridge --------------------------------
# The name the phone shows for this machine.
# CODEDECK_MACHINE_NAME=workstation
# Comma-separated relay URLs.
# CODEDECK_RELAYS=wss://relay.primal.net,wss://nostr.oxtr.dev
# SOCKS5 proxy for all relay connections: your own Tor daemon, or the bundled
# `codedeck-tor` service (docker-compose.yml, `tor` profile).
# CODEDECK_TOR_PROXY_URL=socks5h://codedeck-tor:9050
# OpenCode, a second agent (docs/OPENCODE.md): an external `opencode serve`...
# CODEDECK_OPENCODE_SERVER_URL=http://127.0.0.1:4096
# ...or one the bridge starts and manages, optionally on a fixed port.
# CODEDECK_OPENCODE_AUTO_START=1
# CODEDECK_OPENCODE_PORT=4096
# The direct link (on by default in config.json): where it listens, and the
# host's addresses phones dial (inside the container the bridge cannot see
# them; they can also be added on the phone).
# The direct link (on by default): where it listens, and the addresses phones
# dial. Inside the container the bridge cannot see the host's addresses, so
# set them here (or add them on the phone).
# CODEDECK_DIRECT_LISTEN=0.0.0.0:7447
# CODEDECK_DIRECT_ENDPOINTS=wss://192.168.1.20:7447

# --- LLM gateway / router (optional — leave unset to talk to Anthropic directly) ---
# Point the Claude Code CLI at a router (e.g. claude-code-router) instead of Anthropic.
# ANTHROPIC_BASE_URL=http://your-router-host:port

# Needed for a router that exposes multiple providers behind one /v1/models
# (ids like "<provider>/<model>") — works around a confirmed SDK gateway-
# discovery bug on cold bridge start; see ENABLE_GATEWAY_MODEL_DISCOVERY's doc
# comment in packages/agent-host/src/drivers/claude/facade.ts.
# CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1

# --- Agent binaries ---
# Claude Code (and OpenCode, with auto-start) are installed into /data/agents
# on first use, pinned to the lockfile. Set to 1 to bake them into the image
# instead, for a host without internet access (applies at the next build).
# CODEDECK_BUNDLE_AGENTS=1

# --- GSD (github.com/open-gsd/gsd-core, optional planning workflow) ---
# Off by default: installing gsd-core is a plain npm-registry call the
# entrypoint would otherwise make on every container boot, and outside the
# bridge's Tor proxy. Set to 1 to have the container install it globally for
# Claude on startup. A missing GSD install never blocks a session, it just
# leaves the phone's GSD stage strip empty.
# ---------------------------------- extras ----------------------------------
# Install gsd-core for Claude at container start (github.com/open-gsd/gsd-core).
# Off by default: it is an npm-registry call on every boot, outside the Tor
# proxy. A missing GSD never blocks a session — it only leaves the phone's GSD
# strip empty.
# CODEDECK_GSD_AUTO_INSTALL=1
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Line endings. The repository stores LF and every checkout gets LF, whatever
# the platform that made the change: a Windows checkout would otherwise write
# CRLF into a file it edits, and the tree would carry a whole-file diff that
# says nothing. `text=auto` leaves binaries alone (git detects them), and
# Gradle's wrapper script is the one text file that has to keep its CRLF.
* text=auto eol=lf
apps/android/gradlew.bat text eol=crlf
64 changes: 32 additions & 32 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
.env*
!.env.example
# Node / pnpm workspace
node_modules/
**/node_modules/
pnpm-debug.log*
.pnpm-store/
# Build outputs
apps/mobile/dist/
packages/agent-host/dist/
**/target/
**/*.tsbuildinfo
# NOTE: apps/mobile/src-tauri/gen/ is intentionally NOT ignored — upstream
# commits the generated Android project scaffold (Gradle needs it present;
# `**/target/` above already covers Rust's own build output; the Gradle
# `build/` dir under gen/android is already covered by that project's own
# gen/android/.gitignore).
# APKs built via apps/mobile/docker/build-apk.sh land here — never commit a
# built binary.
/dist/
# Cargo workspace build output
/target
/crates/**/target
# scripts/install-toolchain.sh's local Android SDK/NDK (Linux host builds,
# apps/android/scripts/build-apk-local.sh) — project-scoped on purpose, never
# a system path, so it never collides with another project's SDK/NDK version.
/toolchain/
.env*
!.env.example

# Node / pnpm workspace
node_modules/
**/node_modules/
pnpm-debug.log*
.pnpm-store/

# Build outputs
apps/mobile/dist/
packages/agent-host/dist/
**/target/
**/*.tsbuildinfo

# NOTE: apps/mobile/src-tauri/gen/ is intentionally NOT ignored — upstream
# commits the generated Android project scaffold (Gradle needs it present;
# `**/target/` above already covers Rust's own build output; the Gradle
# `build/` dir under gen/android is already covered by that project's own
# gen/android/.gitignore).

# APKs built via apps/mobile/docker/build-apk.sh land here — never commit a
# built binary.
/dist/
# Cargo workspace build output
/target
/crates/**/target

# scripts/install-toolchain.sh's local Android SDK/NDK (Linux host builds,
# apps/android/scripts/build-apk-local.sh) — project-scoped on purpose, never
# a system path, so it never collides with another project's SDK/NDK version.
/toolchain/
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# CodeDeck+

**Control coding agents (Claude Code, OpenCode) running on your laptop or VPS
**Control coding agents (Claude Code, OpenCode, DeepSeek Harness) running on your laptop or VPS
from your Android phone, over end-to-end encrypted Nostr.** No accounts and no
CodeDeck server: the phone and the bridge pair by scanning a QR code and talk
through ordinary Nostr relays — public ones, or your own.
Expand Down Expand Up @@ -38,8 +38,9 @@ Pairing is a one-time QR scan; it survives restarts on both ends.
- Transcripts that survive restarts and offline gaps (ranged sync)
- Per-session mode, model and effort, plus custom AI provider profiles (Kimi
K3, OpenRouter, any Anthropic-compatible endpoint)
- Claude Code and [OpenCode](https://opencode.ai), chosen per session — the
protocol is agent-neutral, so another agent is one driver away
- Claude Code, [OpenCode](https://opencode.ai) and the
[DeepSeek Harness](https://www.deepseek.com/en/harness/), chosen per session
— the protocol is agent-neutral, so another agent is one driver away
([`docs/PROTOCOL.md`](docs/PROTOCOL.md#adding-an-agent))
- File attachments (photos or any file), and project/folder management on every paired bridge
- NIP-42 `AUTH` relays, and Tor on both ends (see below)
Expand Down Expand Up @@ -223,6 +224,7 @@ the full runbook.
- [`docs/CLIENT.md`](docs/CLIENT.md) — the phone: the Rust client core, the Android app, transport rules, building the APK
- [`docs/PROTOCOL.md`](docs/PROTOCOL.md) — the v11 wire contract, the driver protocol, adding an agent
- [`docs/OPENCODE.md`](docs/OPENCODE.md) — the optional OpenCode session backend: external server vs. bridge-managed, config, Docker setup
- [`docs/DEEPSEEK.md`](docs/DEEPSEEK.md) — the DeepSeek Harness backend: API key, models and reasoning, gateways, MCP servers and plugins
- [`.claude/skills/cut-release/SKILL.md`](.claude/skills/cut-release/SKILL.md) — the release runbook

## Upstream
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ sealed class DisplayEntry {
override val seq: Long,
val requestId: String,
val options: List<OptionChoice> = emptyList(),
/** The option the user's feedback goes with, when the agent takes
* feedback on its plan. */
val revise: String? = null,
/** The outcome, once the bridge resolved it. */
val answered: String? = null,
) : DisplayEntry()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,26 @@ package com.codedeck.plus.ui.transcript.rows

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.codedeck.plus.ui.theme.Tokens
import com.codedeck.plus.ui.transcript.DisplayEntry
Expand All @@ -22,6 +33,12 @@ import uniffi.client_ffi.UniffiIntent
* alongside the answer so the card can name it before the bridge resolves
* the request. The first option is the agent's own go-ahead, so it is the
* one shown as the primary choice.
*
* The option that sends the plan back (`revise`, when the agent has one)
* asks what should change first: the feedback goes to the agent with the
* choice, in one answer, rather than as a message after it — by then the
* agent may already be revising without it. Sending it empty keeps planning
* with no feedback.
*/
@Composable
fun PlanApprovalCard(
Expand All @@ -40,19 +57,49 @@ fun PlanApprovalCard(
return
}

var writingFeedback by remember(item.requestId) { mutableStateOf(false) }
var feedback by remember(item.requestId) { mutableStateOf("") }
fun respond(optionId: String, withFeedback: String?) {
actions(UniffiIntent.SetPlanApprovalChoice(cardId = item.requestId, key = optionId))
actions(
UniffiIntent.RespondPlan(
machine = machine,
sessionId = sessionId,
requestId = item.requestId,
optionId = optionId,
feedback = withFeedback?.trim()?.takeIf { it.isNotEmpty() },
),
)
}

Column(Modifier.interactionCard(waiting = true)) {
Text("Approve this plan?", color = Tokens.Text, fontSize = Tokens.TextMd)
val revise = item.revise
if (writingFeedback && revise != null) {
Row(
Modifier.fillMaxWidth().padding(top = Tokens.Space2),
horizontalArrangement = Arrangement.spacedBy(Tokens.Space2),
) {
OutlinedTextField(
value = feedback,
onValueChange = { feedback = it },
placeholder = { Text("What should change? (optional)") },
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { respond(revise, feedback) }),
)
ActionChip("Send", Tokens.Text) { respond(revise, feedback) }
}
// Choosing to revise is not a commitment until it is sent: the
// other choices stay one tap away, and the draft is kept.
TextButton(onClick = { writingFeedback = false }) {
Text("Back to options", color = Tokens.TextMuted, fontSize = Tokens.TextXs)
}
return@Column
}
item.options.forEachIndexed { i, option ->
PlanOption(option.label, option.description, primary = i == 0) {
actions(UniffiIntent.SetPlanApprovalChoice(cardId = item.requestId, key = option.id))
actions(
UniffiIntent.RespondPlan(
machine = machine,
sessionId = sessionId,
requestId = item.requestId,
optionId = option.id,
),
)
if (option.id == revise) writingFeedback = true else respond(option.id, null)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8424,13 +8424,15 @@ sealed class UniffiIntent {
}

/**
* Answer a plan approval with one of its options' ids.
* Answer a plan approval with one of its options' ids; `feedback` is
* what the user wants changed, with the card's `revise` option.
*/
data class RespondPlan(
val `machine`: kotlin.String,
val `sessionId`: kotlin.String,
val `requestId`: kotlin.String,
val `optionId`: kotlin.String) : UniffiIntent()
val `optionId`: kotlin.String,
val `feedback`: kotlin.String?) : UniffiIntent()

{

Expand Down Expand Up @@ -8935,6 +8937,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer<UniffiIntent
FfiConverterString.read(buf),
FfiConverterString.read(buf),
FfiConverterString.read(buf),
FfiConverterOptionalString.read(buf),
)
24 -> UniffiIntent.SetOption(
FfiConverterString.read(buf),
Expand Down Expand Up @@ -9262,6 +9265,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer<UniffiIntent
+ FfiConverterString.allocationSize(value.`sessionId`)
+ FfiConverterString.allocationSize(value.`requestId`)
+ FfiConverterString.allocationSize(value.`optionId`)
+ FfiConverterOptionalString.allocationSize(value.`feedback`)
)
}
is UniffiIntent.SetOption -> {
Expand Down Expand Up @@ -9681,6 +9685,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer<UniffiIntent
FfiConverterString.write(value.`sessionId`, buf)
FfiConverterString.write(value.`requestId`, buf)
FfiConverterString.write(value.`optionId`, buf)
FfiConverterOptionalString.write(value.`feedback`, buf)
Unit
}
is UniffiIntent.SetOption -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ internal object DesignFixtures {
OptionChoice("default", "Yes, and ask before each edit"),
OptionChoice("plan", "No, keep planning", "Stay in plan mode and send feedback"),
),
revise = "plan",
),
DisplayEntry.Question(
seq = 3,
Expand Down
Loading
Loading