Vectrum
Sign In

Merge branch 'empryo-harness'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 6d6e49f 2a2cb2fcommit89ea5b8

Showing 40 changed files with 2361 additions and 66 deletions.

Merge commit — showing changes against the first parent.

+79−0Cargo.lockView File
@@ -1733,6 +1733,18 @@ dependencies = [
17331733 "zune-inflate",
17341734 ]
17351735
1736+ [[package]]
1737+ name = "fallible-iterator"
1738+ version = "0.3.0"
1739+ source = "registry+https://github.com/rust-lang/crates.io-index"
1740+ checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
1741+
1742+ [[package]]
1743+ name = "fallible-streaming-iterator"
1744+ version = "0.1.9"
1745+ source = "registry+https://github.com/rust-lang/crates.io-index"
1746+ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
1747+
17361748 [[package]]
17371749 name = "fastrand"
17381750 version = "2.5.0"
@@ -2985,6 +2997,18 @@ name = "hashbrown"
29852997 version = "0.17.1"
29862998 source = "registry+https://github.com/rust-lang/crates.io-index"
29872999 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
3000+ dependencies = [
3001+ "foldhash 0.2.0",
3002+ ]
3003+
3004+ [[package]]
3005+ name = "hashlink"
3006+ version = "0.12.2"
3007+ source = "registry+https://github.com/rust-lang/crates.io-index"
3008+ checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
3009+ dependencies = [
3010+ "hashbrown 0.17.1",
3011+ ]
29883012
29893013 [[package]]
29903014 name = "heapless"
@@ -3705,6 +3729,17 @@ dependencies = [
37053729 "libc",
37063730 ]
37073731
3732+ [[package]]
3733+ name = "libsqlite3-sys"
3734+ version = "0.38.2"
3735+ source = "registry+https://github.com/rust-lang/crates.io-index"
3736+ checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
3737+ dependencies = [
3738+ "cc",
3739+ "pkg-config",
3740+ "vcpkg",
3741+ ]
3742+
37083743 [[package]]
37093744 name = "linebender_resource_handle"
37103745 version = "0.1.1"
@@ -5735,6 +5770,31 @@ dependencies = [
57355770 "memchr",
57365771 ]
57375772
5773+ [[package]]
5774+ name = "rsqlite-vfs"
5775+ version = "0.1.1"
5776+ source = "registry+https://github.com/rust-lang/crates.io-index"
5777+ checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
5778+ dependencies = [
5779+ "hashbrown 0.16.1",
5780+ "thiserror 2.0.21",
5781+ ]
5782+
5783+ [[package]]
5784+ name = "rusqlite"
5785+ version = "0.40.2"
5786+ source = "registry+https://github.com/rust-lang/crates.io-index"
5787+ checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
5788+ dependencies = [
5789+ "bitflags 2.13.2",
5790+ "fallible-iterator",
5791+ "fallible-streaming-iterator",
5792+ "hashlink",
5793+ "libsqlite3-sys",
5794+ "smallvec",
5795+ "sqlite-wasm-rs",
5796+ ]
5797+
57385798 [[package]]
57395799 name = "rust-embed"
57405800 version = "8.13.0"
@@ -6452,6 +6512,18 @@ dependencies = [
64526512 "bitflags 2.13.2",
64536513 ]
64546514
6515+ [[package]]
6516+ name = "sqlite-wasm-rs"
6517+ version = "0.5.5"
6518+ source = "registry+https://github.com/rust-lang/crates.io-index"
6519+ checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
6520+ dependencies = [
6521+ "cc",
6522+ "js-sys",
6523+ "rsqlite-vfs",
6524+ "wasm-bindgen",
6525+ ]
6526+
64556527 [[package]]
64566528 name = "stable_deref_trait"
64576529 version = "1.2.1"
@@ -7475,6 +7547,12 @@ dependencies = [
74757547 "sval_serde",
74767548 ]
74777549
7550+ [[package]]
7551+ name = "vcpkg"
7552+ version = "0.2.15"
7553+ source = "registry+https://github.com/rust-lang/crates.io-index"
7554+ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
7555+
74787556 [[package]]
74797557 name = "vectrum-code"
74807558 version = "0.2.5"
@@ -7490,6 +7568,7 @@ dependencies = [
74907568 "openh264",
74917569 "qrcode",
74927570 "ring",
7571+ "rusqlite",
74937572 "serde",
74947573 "serde_json",
74957574 "smol",
+1−0Cargo.tomlView File
@@ -21,6 +21,7 @@ openh264 = "0.9"
2121 mermaid-rs-renderer = { version = "0.3.1", default-features = false }
2222 qrcode = { version = "0.14.1", default-features = false }
2323 ring = "0.17"
24+ rusqlite = { version = "0.40", features = ["bundled"] }
2425
2526 [profile.dev]
2627 debug = "line-tables-only"
+38−12README.mdView File
@@ -3,7 +3,8 @@
33 A native [T3 Code](https://github.com/pingdotgg/t3code)-style desktop client for coding
44 agents, built with [GPUI](https://gpui.rs) and [GPUI Kit](https://github.com/longbridge/gpui-kit).
55 It drives [Claude Code](https://docs.claude.com/en/docs/claude-code),
6- [Pi](https://github.com/earendil-works/pi) and [jcode](https://github.com/1jehuang/jcode).
6+ [Pi](https://github.com/earendil-works/pi), [jcode](https://github.com/1jehuang/jcode) and
7+ [Empryo](https://empryo.com/).
78
89 Every thread runs its own agent process in the background. Claude Code threads talk to the
910 `claude` CLI over the stream-json protocol the Claude Agent SDKs use
@@ -11,6 +12,9 @@ Every thread runs its own agent process in the background. Claude Code threads t
1112 talk to `pi --mode rpc` over its JSONL RPC protocol. jcode threads talk to
1213 `jcode api --stdio`, a bridge to jcode's versioned harness API that starts jcode's shared
1314 daemon if it isn't running, so jcode threads also show up in jcode's own session list.
15+ Empryo threads talk to `empryo --headless --chat --events`, Empryo's headless chat: a message
16+ per line on stdin, its events as JSONL on stdout, and a session the thread names
17+ (`--session-id`) and resumes (`--session`).
1418
1519 The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the
1620 "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the
@@ -64,19 +68,22 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
6468 it there once the thread has started.
6569 - A model picker across providers (Ctrl+Shift+M): a rail with **Favorites** and each provider,
6670 Claude's models (current ones, plus legacy models folded away), every model Pi's configured
67- providers offer, and every model route jcode's logins and provider profiles make available.
71+ providers offer, every model route jcode's logins and provider profiles make available, and
72+ every model of the providers Empryo has a key or sign-in for (`empryo --list-models`).
6873 Star models to keep them under Favorites, search across all of them, pick the first nine
6974 with Ctrl+1–9, and switch providers with Ctrl+Shift+↑/↓. On a new thread, picking another
7075 provider's model starts a thread of that kind in its place; once a thread has started,
7176 other providers' models are greyed out, as in T3 Code. `/model` opens the picker too.
7277 - A reasoning menu (Ctrl+Shift+E): Low to Max (as the model supports, its default marked),
7378 Claude's Ultracode and Ultrathink, and Fast Mode on models that have it. Pi threads list Pi's thinking levels;
74- jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes.
75- All of it switches live.
76- - An access menu (Ctrl+Shift+A): Supervised, Auto-accept edits, Auto or Full access. Pi and
77- jcode have no approval prompts, so their threads always run with full access. With plan
78- mode turned on (Settings → General → Legacy features), a Build/Plan toggle and Shift+Tab
79- switch Claude into plan mode and back.
79+ jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes; Empryo
80+ threads offer Low, Medium and High (`--effort`). All of it switches live; an Empryo thread
81+ restarts Empryo with the new flags once its turn is done, resuming its session.
82+ - An access menu (Ctrl+Shift+A): Supervised, Auto-accept edits, Auto or Full access. Pi,
83+ jcode and Empryo have no approval prompts, so their threads always run with full access
84+ (Empryo's `--mode auto`). With plan mode turned on (Settings → General → Legacy features), a
85+ Build/Plan toggle and Shift+Tab switch Claude, or Empryo (`--mode plan`), into plan mode and
86+ back.
8087 - Finished turns fold their tool calls and interim messages into one **Worked for 12s** row
8188 above the answer. Click it to unfold.
8289 - Answers can carry live diagrams and UI, written as plain fenced code blocks. ` ```mermaid `
@@ -138,6 +145,16 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
138145 before every run, that extension also switches off Pi's other subagent tools.
139146 jcode gets them as session tools over its API, with its `swarm` and `schedule`
140147 tools turned off. Subagents can't delegate tasks of their own.
148+ - The Genome map, for Empryo threads (the DNA button in the sidebar's corner, or G in the
149+ right panel): [Empryo's Genome](https://empryo.com/docs/concepts/genome), the ranked map of
150+ files, symbols and imports Empryo keeps of a checkout in `.empryo/genome.db`. A graph of the
151+ 48 highest ranked files, sized by rank and colored by folder, with a line for each import,
152+ and the files ranked below it with how many files import each (`→12`) and what changed in
153+ the last two days. A file opens to its symbols, exported ones first with their signatures,
154+ and lights up with its imports in the graph. It follows the thread to its worktree and
155+ reloads as Empryo updates the map. For a thread on another device, that device reads its
156+ Genome and sends it over, and again only when Empryo changed it. The Android app has it
157+ too: the DNA button in an Empryo thread's top bar, or Genome map in the right panel.
141158 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
142159 transcript says so and Claude picks the work back up and reports back on its own. A
143160 long-running command can be sent to the background from its tool card, like Ctrl+B in
@@ -292,6 +309,9 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
292309 (`npm install -g @earendil-works/pi-coding-agent`, then log in to a provider with `pi`)
293310 - Optional: [jcode](https://github.com/1jehuang/jcode) for jcode threads
294311 (`curl -fsSL https://jcode.sh/install | bash`, then `jcode login`)
312+ - Optional: [Empryo](https://empryo.com/) for Empryo threads
313+ (`curl -fsSL https://empryo.com/install.sh | bash`, then `empryo --login claude` or another
314+ account, or `empryo --set-key <provider> <key>`). Add it in Settings → Providers → Add provider.
295315 - Optional: agent-browser for the browser panel
296316 (`npm install -g agent-browser && agent-browser install`)
297317 - Optional, for the Device panel: Node.js 22 or newer with npm, and the Android SDK
@@ -352,6 +372,8 @@ The shortcuts follow T3 Code's default keymap.
352372 `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions.
353373 - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`,
354374 then in `~/.local/bin` and `~/.cargo/bin`.
375+ - `VECTRUM_CODE_EMPRYO_BIN`: path to the `empryo` binary. Otherwise it's looked up on `PATH`,
376+ then in `~/.empryo/bin` and `~/.local/bin`.
355377 - `VECTRUM_UPDATE_URL`: where to look for updates instead of `https://vectrum.cc`, such as a
356378 test server. Releases from there still need the release key's signature.
357379
@@ -392,12 +414,14 @@ after changing them, `cargo test export_android_themes -- --ignored` rewrites
392414
393415 ## Layout
394416
395- - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi` or `jcode api --stdio`
417+ - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi`, `jcode api --stdio` or
418+ `empryo --headless --chat`
396419 (stdin writer and stdout/stderr reader threads).
397- - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC and jcode's
398- harness API events into chat items, handles permission requests, control requests and
420+ - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC, jcode's
421+ harness API and Empryo's headless events into chat items, handles permission requests, control requests and
399422 extension dialogs, and snapshots each turn's changes.
400423 - `src/models.rs`: the model catalog across providers, and favorites.
424+ - `src/genome.rs`: reads Empryo's Genome (`.empryo/genome.db`) and lays out its graph.
401425 - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs.
402426 - `src/settings.rs`: the saved settings file, cached in memory.
403427 - `src/update.rs`: the app's updater: release checks, signature and checksum checks, the
@@ -437,4 +461,6 @@ highlighting, and T3 Code's project
437461 scripts aren't here yet. The terminal drawer doesn't report mouse clicks to full-screen
438462 programs (only the wheel), open links, or show T3's popup after a selection. jcode models can't write thread titles (every
439463 jcode run is saved as a session), and jcode's side panel, applets and background-task
440- progress aren't shown.
464+ progress aren't shown. Empryo threads don't get the app's subagent and device tools (Empryo
465+ loads MCP servers from its config files only), take images by path rather than inline, and
466+ can't be steered mid-turn: a message sent while Empryo works becomes its next turn.
+28−0android/app/src/main/java/cc/vectrum/code/data/AppState.ktView File
@@ -16,6 +16,7 @@ import cc.vectrum.code.net.ClientMessage
1616 import cc.vectrum.code.net.Connection
1717 import cc.vectrum.code.net.ConnectionStatus
1818 import cc.vectrum.code.net.Folder
19+ import cc.vectrum.code.net.GenomeAnswer
1920 import cc.vectrum.code.net.ProjectInfo
2021 import cc.vectrum.code.net.RemoteFileDiff
2122 import cc.vectrum.code.net.RemoteInstance
@@ -33,6 +34,7 @@ import cc.vectrum.code.net.ThreadSummary
3334 import cc.vectrum.code.net.WireJson
3435 import cc.vectrum.code.net.withPort
3536 import kotlinx.coroutines.CompletableDeferred
37+ import kotlinx.coroutines.withTimeoutOrNull
3638 import kotlinx.serialization.Serializable
3739 import kotlinx.serialization.builtins.ListSerializer
3840 import kotlinx.serialization.json.JsonElement
@@ -213,6 +215,7 @@ class HostState(saved: SavedHost, private val app: AppState) {
213215 private val createdCallbacks = mutableMapOf<Long, (String) -> Unit>()
214216 private val folderRequests = mutableMapOf<String, CompletableDeferred<ServerMessage.Folders>>()
215217 private val diffRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DiffReply>>()
218+ private val genomeRequests = mutableMapOf<Long, CompletableDeferred<GenomeAnswer>>()
216219 private val deviceRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DeviceReply>>()
217220 private val terminals = mutableMapOf<Long, TerminalListener>()
218221
@@ -286,6 +289,8 @@ class HostState(saved: SavedHost, private val app: AppState) {
286289 folderRequests.clear()
287290 diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) }
288291 diffRequests.clear()
292+ genomeRequests.values.forEach { it.complete(GenomeAnswer(error = "The host is offline.")) }
293+ genomeRequests.clear()
289294 deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) }
290295 deviceRequests.clear()
291296 terminals.values.forEach { it.onExit(reason) }
@@ -324,6 +329,7 @@ class HostState(saved: SavedHost, private val app: AppState) {
324329 favorites = message.favorites
325330 }
326331 is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message)
332+ is ServerMessage.GenomeReply -> genomeRequests.remove(message.request)?.complete(message.answer)
327333 is ServerMessage.Welcome -> learnRoutes(message.routes)
328334 is ServerMessage.Routes -> learnRoutes(message.routes)
329335 is ServerMessage.Denied -> {}
@@ -398,6 +404,28 @@ class HostState(saved: SavedHost, private val app: AppState) {
398404 return reply.await()
399405 }
400406
407+ /**
408+ * Empryo's Genome of a thread's checkout, read on the host; `since` is when
409+ * the one this app has was written. Older hosts don't answer, so it gives
410+ * up after a while and says why.
411+ */
412+ suspend fun genome(thread: String?, project: String, since: Long?): GenomeAnswer {
413+ val request = nextRequest++
414+ val reply = CompletableDeferred<GenomeAnswer>()
415+ genomeRequests[request] = reply
416+ if (!send(ClientMessage.Genome(request, thread, project, since))) {
417+ genomeRequests.remove(request)
418+ return GenomeAnswer(written = since, error = "$name is offline.")
419+ }
420+ return withTimeoutOrNull(20_000) { reply.await() } ?: run {
421+ genomeRequests.remove(request)
422+ GenomeAnswer(
423+ written = since,
424+ error = "$name didn't send its Genome. Its Vectrum Code may be too old to; update it from its sidebar's corner.",
425+ )
426+ }
427+ }
428+
401429 /** Sends a `DeviceCall` (an object tagged by `call`) and waits for its reply. */
402430 suspend fun device(call: JsonObject): ServerMessage.DeviceReply {
403431 val request = nextRequest++
+63−0android/app/src/main/java/cc/vectrum/code/net/Protocol.ktView File
@@ -416,6 +416,14 @@ sealed class ClientMessage {
416416 val path: String?,
417417 val ignoreWhitespace: Boolean,
418418 ) : ClientMessage()
419+
420+ /**
421+ * Empryo's Genome of a thread's checkout (the project's, for a thread not
422+ * created yet). `since` is when the one this app has was written, so an
423+ * unchanged one isn't sent again. Older hosts don't answer.
424+ */
425+ @Serializable @SerialName("genome")
426+ data class Genome(val request: Long, val thread: String?, val project: String, val since: Long?) : ClientMessage()
419427 }
420428
421429 @Serializable
@@ -548,8 +556,63 @@ sealed class ServerMessage {
548556
549557 @Serializable @SerialName("diff_reply")
550558 data class DiffReply(val request: Long = 0, val files: List<RemoteFileDiff> = emptyList(), val error: String? = null) : ServerMessage()
559+
560+ @Serializable @SerialName("genome_reply")
561+ data class GenomeReply(val request: Long = 0, val answer: GenomeAnswer = GenomeAnswer()) : ServerMessage()
551562 }
552563
564+ /** What the host found of a checkout's Genome (src/remote/mod.rs). */
565+ @Serializable
566+ data class GenomeAnswer(
567+ /** When Empryo last wrote it, in milliseconds since the epoch; `null` when there's none yet. */
568+ val written: Long? = null,
569+ /** The map, unless it's unchanged since the one asked about, or missing. */
570+ val genome: Genome? = null,
571+ val unchanged: Boolean = false,
572+ val error: String? = null,
573+ )
574+
575+ /** Empryo's ranked map of a checkout (src/genome.rs): its top files, their imports and their layout. */
576+ @Serializable
577+ data class Genome(
578+ val totalFiles: Long = 0,
579+ val totalSymbols: Long = 0,
580+ val totalEdges: Long = 0,
581+ /** The highest ranked files, highest first. */
582+ val files: List<GenomeFile> = emptyList(),
583+ /** Imports between those files: `from` depends on `to`. */
584+ val edges: List<GenomeEdge> = emptyList(),
585+ /** Where each file sits in the map, from 0 to 1 on both axes: `[x, y]`. */
586+ val layout: List<List<Float>> = emptyList(),
587+ )
588+
589+ @Serializable
590+ data class GenomeFile(
591+ val path: String = "",
592+ val language: String = "",
593+ val lines: Long = 0,
594+ val symbolCount: Long = 0,
595+ val rank: Double = 0.0,
596+ /** How many files import it. */
597+ val dependents: Long = 0,
598+ val dependencies: Long = 0,
599+ /** Changed in the last 48 hours. */
600+ val recent: Boolean = false,
601+ val symbols: List<GenomeSymbol> = emptyList(),
602+ )
603+
604+ @Serializable
605+ data class GenomeSymbol(
606+ val name: String = "",
607+ val kind: String = "",
608+ val line: Long = 0,
609+ val exported: Boolean = false,
610+ val signature: String? = null,
611+ )
612+
613+ @Serializable
614+ data class GenomeEdge(val from: Int = 0, val to: Int = 0, val weight: Double = 0.0)
615+
553616 /** A message from the host, or `null` for one this app doesn't know. */
554617 fun parseServerMessage(text: String): ServerMessage? = try {
555618 WireJson.decodeFromString(ServerMessage.serializer(), text)
+1−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -59,6 +59,7 @@ enum class Lucide(val res: Int) {
5959 Ellipsis(R.drawable.lucide_ellipsis),
6060 EllipsisVertical(R.drawable.lucide_ellipsis_vertical),
6161 ExternalLink(R.drawable.lucide_external_link),
62+ Dna(R.drawable.lucide_dna),
6263 Eye(R.drawable.lucide_eye),
6364 EyeOff(R.drawable.lucide_eye_off),
6465 Feather(R.drawable.lucide_feather),
+1−1android/app/src/main/java/cc/vectrum/code/ui/Nav.ktView File
@@ -27,7 +27,7 @@ sealed interface Screen {
2727 }
2828
2929 /** The surfaces the right panel can show next to a thread, as the desktop's "Open a surface". */
30- enum class Surface { Subagents, Terminal, Device }
30+ enum class Surface { Subagents, Terminal, Device, Genome }
3131
3232 /** Where the app is: the sheet's screen, a back stack, the sidebar drawer and the right panel. */
3333 class Nav {
+8−1android/app/src/main/java/cc/vectrum/code/ui/chat/Header.ktView File
@@ -158,7 +158,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
158158 /**
159159 * The chat header as a phone's bar: the way back to the threads, the title
160160 * over its project (or the thread that delegated it), which opens the
161- * thread menu, and the right panel's toggle.
161+ * thread menu, the Genome map for Empryo threads, and the right panel's toggle.
162162 */
163163 @Composable
164164 fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) {
@@ -209,6 +209,13 @@ fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) {
209209 runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(pr.url))) }
210210 }
211211 }
212+ // Empryo's map of the thread's code, as the sidebar's foot offers it on bigger screens.
213+ if (summary.provider == "empryo") {
214+ BarAction(Lucide.Dna, selected = nav.surface == Surface.Genome) {
215+ nav.surfacePicker = false
216+ nav.surface = if (nav.surface == Surface.Genome) null else Surface.Genome
217+ }
218+ }
212219 Box {
213220 BarAction(Lucide.PanelRightOpen, selected = nav.surface != null) { nav.surfacePicker = true }
214221 if (subagentsWorking > 0 && nav.surface != Surface.Subagents) {
+1−1android/app/src/main/java/cc/vectrum/code/ui/composer/Composer.ktView File
@@ -270,7 +270,7 @@ fun ComposerFrame(
270270 val p = palette
271271 var focused by remember { mutableStateOf(false) }
272272 var sheet by remember { mutableStateOf<PickerSheet?>(null) }
273- val planToggle = app.prefs.bool("plan_mode") && asksPermission(target.provider)
273+ val planToggle = app.prefs.bool("plan_mode") && hasPlanMode(target.provider)
274274 val attach = rememberAttach(app, state)
275275 LaunchedEffect(state.modelPickerRequested) {
276276 if (state.modelPickerRequested) {
+10−1android/app/src/main/java/cc/vectrum/code/ui/composer/Model.ktView File
@@ -32,12 +32,14 @@ val ClaudeOrange = Color(0xffd97757)
3232 fun providerIcon(provider: String): Lucide = when (provider) {
3333 "pi" -> Lucide.Pi
3434 "jcode" -> Lucide.Feather
35+ "empryo" -> Lucide.Dna
3536 else -> Lucide.Asterisk
3637 }
3738
3839 fun providerLabel(provider: String): String = when (provider) {
3940 "pi" -> "Pi"
4041 "jcode" -> "jcode"
42+ "empryo" -> "Empryo"
4143 else -> "Claude"
4244 }
4345
@@ -45,12 +47,16 @@ fun providerLabel(provider: String): String = when (provider) {
4547 fun agentName(provider: String): String = when (provider) {
4648 "pi" -> "Pi"
4749 "jcode" -> "jcode"
50+ "empryo" -> "Empryo"
4851 else -> "Claude Code"
4952 }
5053
51- /** Whether the agent asks before running tools. Pi and jcode just run them. */
54+ /** Whether the agent asks before running tools. Pi, jcode and Empryo just run them. */
5255 fun asksPermission(provider: String) = provider == "claude"
5356
57+ /** Whether the agent has a plan mode the composer can switch to. */
58+ fun hasPlanMode(provider: String) = provider == "claude" || provider == "empryo"
59+
5460 // ---- reasoning -----------------------------------------------------------------
5561
5662 /** Claude's `--effort` levels, offered when the model doesn't list its own. */
@@ -82,6 +88,9 @@ fun reasoningLevels(provider: String, model: RemoteModel?): List<String> = when
8288 else -> model?.efforts?.takeIf { it.isNotEmpty() } ?: DefaultEfforts
8389 }
8490
91+ /** Whether the reasoning menu offers `Default`: the model's own level. */
92+ fun offersDefaultLevel(provider: String) = provider == "claude" || provider == "empryo"
93+
8594 /** The level `Default` stands for on this model, when known. */
8695 fun defaultLevel(provider: String, model: RemoteModel?): String? =
8796 if (provider == "claude") model?.defaultEffort else null
+2−1android/app/src/main/java/cc/vectrum/code/ui/composer/Pickers.ktView File
@@ -234,7 +234,7 @@ fun ColumnScope.TraitsItems(target: PickTarget, close: () -> Unit) {
234234 if (levels.isNotEmpty() || provider == "claude") {
235235 any = true
236236 MenuLabel(if (provider == "pi") "Thinking" else "Reasoning")
237- if (default == null && provider == "claude") item("default", "Default", null, true)
237+ if (default == null && offersDefaultLevel(provider)) item("default", "Default", null, true)
238238 for (level in levels) item(level, effortLabel(level), null, default == level)
239239 if (provider == "claude") {
240240 if (model == null || model.ultracode) {
@@ -531,6 +531,7 @@ private fun setupText(instance: RemoteInstance?): String? {
531531 "missing" -> when (instance.provider) {
532532 "pi" -> "Open provider setup to install Pi on this environment: npm install -g @earendil-works/pi-coding-agent"
533533 "jcode" -> "Open provider setup to install jcode on this environment."
534+ "empryo" -> "Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash"
534535 else -> "Open provider setup to install Claude on this environment."
535536 }
536537 else -> "$label provider is unavailable."
+1−1android/app/src/main/java/cc/vectrum/code/ui/composer/Strip.ktView File
@@ -155,7 +155,7 @@ fun newThreadDefaults(host: HostState, project: String, git: Boolean, from: Thre
155155 // A host that doesn't list its agents: its instance ids start with the provider.
156156 instances.isEmpty() -> {
157157 instance = id
158- provider = listOf("pi", "jcode").firstOrNull { id.startsWith(it) } ?: "claude"
158+ provider = listOf("pi", "jcode", "empryo").firstOrNull { id.startsWith(it) } ?: "claude"
159159 model = key.substring(split + 1)
160160 }
161161 }
+338−0android/app/src/main/java/cc/vectrum/code/ui/panels/Genome.ktaddedView File
@@ -0,0 +1,338 @@
1+ package cc.vectrum.code.ui.panels
2+
3+ import androidx.compose.foundation.Canvas
4+ import androidx.compose.foundation.gestures.detectTapGestures
5+ import androidx.compose.foundation.layout.Arrangement
6+ import androidx.compose.foundation.layout.Box
7+ import androidx.compose.foundation.layout.BoxWithConstraints
8+ import androidx.compose.foundation.layout.Column
9+ import androidx.compose.foundation.layout.Row
10+ import androidx.compose.foundation.layout.fillMaxSize
11+ import androidx.compose.foundation.layout.fillMaxWidth
12+ import androidx.compose.foundation.layout.height
13+ import androidx.compose.foundation.layout.offset
14+ import androidx.compose.foundation.layout.padding
15+ import androidx.compose.foundation.layout.size
16+ import androidx.compose.foundation.layout.width
17+ import androidx.compose.foundation.rememberScrollState
18+ import androidx.compose.foundation.shape.CircleShape
19+ import androidx.compose.foundation.shape.RoundedCornerShape
20+ import androidx.compose.foundation.verticalScroll
21+ import androidx.compose.foundation.background
22+ import androidx.compose.runtime.Composable
23+ import androidx.compose.runtime.LaunchedEffect
24+ import androidx.compose.runtime.getValue
25+ import androidx.compose.runtime.mutableStateOf
26+ import androidx.compose.runtime.remember
27+ import androidx.compose.runtime.setValue
28+ import androidx.compose.ui.Alignment
29+ import androidx.compose.ui.Modifier
30+ import androidx.compose.ui.draw.clip
31+ import androidx.compose.ui.geometry.Offset
32+ import androidx.compose.ui.graphics.Color
33+ import androidx.compose.ui.graphics.Path
34+ import androidx.compose.ui.graphics.drawscope.Stroke
35+ import androidx.compose.ui.input.pointer.pointerInput
36+ import androidx.compose.ui.platform.LocalDensity
37+ import androidx.compose.ui.text.font.FontWeight
38+ import androidx.compose.ui.unit.Dp
39+ import androidx.compose.ui.unit.dp
40+ import androidx.compose.ui.unit.sp
41+ import cc.vectrum.code.data.AppState
42+ import cc.vectrum.code.data.ThreadRef
43+ import cc.vectrum.code.net.Genome
44+ import cc.vectrum.code.net.GenomeFile
45+ import cc.vectrum.code.theme.Mono
46+ import cc.vectrum.code.theme.Palette
47+ import cc.vectrum.code.theme.Rule
48+ import cc.vectrum.code.theme.Tag
49+ import cc.vectrum.code.theme.Txt
50+ import cc.vectrum.code.theme.palette
51+ import cc.vectrum.code.theme.well
52+ import cc.vectrum.code.ui.Lucide
53+ import cc.vectrum.code.ui.Nav
54+ import cc.vectrum.code.ui.sidebar.pressTint
55+ import kotlinx.coroutines.delay
56+ import kotlin.math.max
57+ import kotlin.math.sqrt
58+
59+ // The Genome map (src/ui/workspace/genome.rs): Empryo's ranked map of the
60+ // thread's checkout, which the host reads and sends over. The graph shows
61+ // the highest ranked files, sized by rank and colored by folder, with a
62+ // line for each import; the list below ranks them, and a file opens to its
63+ // symbols. While it shows, it asks the host every few seconds whether
64+ // Empryo changed the map.
65+
66+ /** How often an open map asks for Empryo's updates. */
67+ private const val WATCH_EVERY = 3_000L
68+
69+ /** How many files the map labels by name. */
70+ private const val LABELED = 6
71+
72+ private val MapHeight = 260.dp
73+
74+ /** What the panel shows. */
75+ private sealed interface GenomeLoad {
76+ data object Loading : GenomeLoad
77+
78+ /** Empryo hasn't mapped this checkout yet. */
79+ data object Missing : GenomeLoad
80+
81+ data class Failed(val error: String) : GenomeLoad
82+
83+ data class Ready(val genome: Genome) : GenomeLoad
84+ }
85+
86+ /** Whether a thread runs on Empryo, which keeps a Genome. */
87+ fun hasGenome(app: AppState, ref: ThreadRef) = app.thread(ref)?.provider == "empryo"
88+
89+ @Composable
90+ fun GenomePanel(app: AppState, nav: Nav, ref: ThreadRef) {
91+ val p = palette
92+ val host = app.host(ref.host)
93+ val project = app.thread(ref)?.project.orEmpty()
94+ var load by remember(ref) { mutableStateOf<GenomeLoad>(GenomeLoad.Loading) }
95+ var written by remember(ref) { mutableStateOf<Long?>(null) }
96+ var selected by remember(ref) { mutableStateOf<String?>(null) }
97+
98+ LaunchedEffect(ref, host) {
99+ if (host == null) return@LaunchedEffect
100+ while (true) {
101+ val answer = host.genome(ref.thread, project, written)
102+ written = answer.written
103+ if (!answer.unchanged) {
104+ load = when {
105+ answer.error != null -> GenomeLoad.Failed(answer.error)
106+ answer.genome != null -> GenomeLoad.Ready(answer.genome)
107+ else -> GenomeLoad.Missing
108+ }
109+ }
110+ delay(WATCH_EVERY)
111+ }
112+ }
113+
114+ val genome = (load as? GenomeLoad.Ready)?.genome
115+ Column(Modifier.fillMaxSize()) {
116+ PanelHeader(
117+ Lucide.Dna,
118+ "Genome",
119+ tag = genome?.let { "${it.totalFiles} files · ${it.totalSymbols} symbols" },
120+ ) { nav.surface = null }
121+ Box(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { Rule() }
122+ when (val state = load) {
123+ GenomeLoad.Loading -> Message("Reading the Genome…", null)
124+ GenomeLoad.Missing -> Message(
125+ "No Genome here yet",
126+ "Empryo maps a checkout when a thread starts in it. Send this thread a message and the map appears here.",
127+ )
128+ is GenomeLoad.Failed -> Message("Couldn't read the Genome", state.error)
129+ is GenomeLoad.Ready -> {
130+ val selectedIx = state.genome.files.indexOfFirst { it.path == selected }.takeIf { it >= 0 }
131+ Column(
132+ Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 6.dp, vertical = 4.dp),
133+ verticalArrangement = Arrangement.spacedBy(2.dp),
134+ ) {
135+ GenomeMap(state.genome, selectedIx) { ix ->
136+ val path = state.genome.files[ix].path
137+ selected = if (selected == path) null else path
138+ }
139+ Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
140+ Txt(
141+ "Top ${state.genome.files.size} of ${state.genome.totalFiles} files by rank · ${state.genome.totalEdges} imports",
142+ Modifier.weight(1f),
143+ color = p.ink3,
144+ size = 11.5.sp,
145+ )
146+ written?.let { Txt("updated ${timeOfDay(it)}", color = p.ink3, size = 11.5.sp) }
147+ }
148+ Box(Modifier.padding(horizontal = 8.dp)) { Rule() }
149+ state.genome.files.forEachIndexed { ix, file ->
150+ FileRow(ix, file, open = selectedIx == ix) {
151+ selected = if (selected == file.path) null else file.path
152+ }
153+ }
154+ }
155+ }
156+ }
157+ }
158+ }
159+
160+ @Composable
161+ private fun Message(title: String, detail: String?) {
162+ val p = palette
163+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
164+ Txt(title, color = p.ink2, size = 13.sp)
165+ if (!detail.isNullOrBlank()) Txt(detail, color = p.ink3, size = 12.sp, lineHeight = 17.sp)
166+ }
167+ }
168+
169+ /**
170+ * The import graph: a dot per file, sized by rank and colored by folder,
171+ * with a line for each import. The file opened in the list lights up with
172+ * its neighbors; tapping a dot opens its file.
173+ */
174+ @Composable
175+ private fun GenomeMap(genome: Genome, focus: Int?, onTap: (Int) -> Unit) {
176+ val p = palette
177+ val density = LocalDensity.current
178+ val maxRank = max(genome.files.maxOfOrNull { it.rank } ?: 0.0, 1e-9)
179+ fun radius(file: GenomeFile): Dp = (3.5 + 8.5 * sqrt(file.rank / maxRank)).dp
180+ val neighbors = focus?.let { f -> genome.edges.mapNotNull { if (it.from == f) it.to else if (it.to == f) it.from else null }.toSet() }.orEmpty()
181+ BoxWithConstraints(
182+ Modifier
183+ .fillMaxWidth()
184+ .height(MapHeight)
185+ .padding(horizontal = 4.dp)
186+ .well(p, RoundedCornerShape(12.dp))
187+ .clip(RoundedCornerShape(12.dp)),
188+ ) {
189+ val inset = 16.dp
190+ val width = maxWidth
191+ fun spot(ix: Int): Pair<Dp, Dp> {
192+ val xy = genome.layout.getOrNull(ix) ?: listOf(0.5f, 0.5f)
193+ return Pair(inset + (width - inset * 2) * xy[0], inset + (MapHeight - inset * 2) * xy.getOrElse(1) { 0.5f })
194+ }
195+ val dim = p.ink3.copy(alpha = 0.3f)
196+ val lit = p.tints[2 % p.tints.size].copy(alpha = 0.9f)
197+ Canvas(
198+ Modifier.fillMaxSize().pointerInput(genome) {
199+ detectTapGestures { tap ->
200+ val hit = genome.files.indices.minByOrNull { ix ->
201+ val (x, y) = spot(ix)
202+ val dx = x.toPx() - tap.x
203+ val dy = y.toPx() - tap.y
204+ dx * dx + dy * dy
205+ } ?: return@detectTapGestures
206+ val (x, y) = spot(hit)
207+ val reach = radius(genome.files[hit]).toPx() + 14.dp.toPx()
208+ val dx = x.toPx() - tap.x
209+ val dy = y.toPx() - tap.y
210+ if (dx * dx + dy * dy <= reach * reach) onTap(hit)
211+ }
212+ },
213+ ) {
214+ for (edge in genome.edges) {
215+ if (edge.from !in genome.files.indices || edge.to !in genome.files.indices) continue
216+ val on = focus != null && (edge.from == focus || edge.to == focus)
217+ val (x1, y1) = spot(edge.from)
218+ val (x2, y2) = spot(edge.to)
219+ val from = Offset(x1.toPx(), y1.toPx())
220+ val to = Offset(x2.toPx(), y2.toPx())
221+ // A slight bend tells crossing lines apart.
222+ val mid = Offset((from.x + to.x) / 2 + (to.y - from.y) * 0.12f, (from.y + to.y) / 2 - (to.x - from.x) * 0.12f)
223+ val path = Path().apply {
224+ moveTo(from.x, from.y)
225+ quadraticTo(mid.x, mid.y, to.x, to.y)
226+ }
227+ drawPath(path, if (on) lit else dim, style = Stroke(width = (if (on) 1.6f else 1f) * density.density))
228+ }
229+ genome.files.forEachIndexed { ix, file ->
230+ val (x, y) = spot(ix)
231+ val center = Offset(x.toPx(), y.toPx())
232+ val r = radius(file).toPx()
233+ val faded = focus != null && ix != focus && ix !in neighbors
234+ val color = folderColor(file.path, p)
235+ drawCircle(if (faded) color.copy(alpha = 0.3f) else color, r, center)
236+ if (ix == focus) drawCircle(p.ink, r, center, style = Stroke(width = 2 * density.density))
237+ else if (ix in neighbors) drawCircle(lit, r, center, style = Stroke(width = density.density))
238+ }
239+ }
240+ // Names for the top files, and for the one in focus.
241+ val labeled = ((0 until minOf(LABELED, genome.files.size)) + listOfNotNull(focus)).distinct()
242+ for (ix in labeled) {
243+ val file = genome.files[ix]
244+ val (x, y) = spot(ix)
245+ val name = file.path.substringAfterLast('/')
246+ val labelWidth = minOf((name.length * 6.2 + 10).dp, 150.dp)
247+ val left = (x - labelWidth / 2).coerceIn(2.dp, width - labelWidth - 2.dp)
248+ val r = radius(file)
249+ val below = y + r + 18.dp < MapHeight
250+ Box(
251+ Modifier
252+ .offset(left, if (below) y + r + 2.dp else y - r - 17.dp)
253+ .width(labelWidth),
254+ contentAlignment = Alignment.Center,
255+ ) {
256+ Txt(
257+ name,
258+ Modifier.background(p.page.copy(alpha = 0.75f), RoundedCornerShape(4.dp)).padding(horizontal = 4.dp),
259+ color = if (ix == focus) p.ink else p.ink2,
260+ size = 10.5.sp,
261+ family = Mono,
262+ maxLines = 1,
263+ )
264+ }
265+ }
266+ }
267+ }
268+
269+ /** A ranked file: its rank, folder color, path, whether it's new and how many files import it; open, its symbols. */
270+ @Composable
271+ private fun FileRow(ix: Int, file: GenomeFile, open: Boolean, onClick: () -> Unit) {
272+ val p = palette
273+ val dir = file.path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" }
274+ val name = file.path.substringAfterLast('/')
275+ Column {
276+ Row(
277+ Modifier
278+ .fillMaxWidth()
279+ .height(40.dp)
280+ .then(if (open) Modifier.background(p.rowHover, RoundedCornerShape(8.dp)) else Modifier)
281+ .pressTint(8.dp, onClick)
282+ .padding(horizontal = 10.dp),
283+ horizontalArrangement = Arrangement.spacedBy(8.dp),
284+ verticalAlignment = Alignment.CenterVertically,
285+ ) {
286+ Txt("${ix + 1}", Modifier.width(20.dp), color = p.ink3, size = 10.5.sp, family = Mono)
287+ Box(Modifier.size(8.dp).background(folderColor(file.path, p), CircleShape))
288+ Row(Modifier.weight(1f)) {
289+ Txt(dir, Modifier.weight(1f, fill = false), color = p.ink3, size = 13.sp, maxLines = 1)
290+ Txt(name, color = p.ink, size = 13.sp, maxLines = 1)
291+ }
292+ if (file.recent) Tag("new")
293+ if (file.dependents > 0) Txt("→${file.dependents}", color = p.ink2, size = 11.sp, family = Mono)
294+ }
295+ if (open) {
296+ Column(
297+ Modifier.fillMaxWidth().padding(start = 46.dp, end = 10.dp, bottom = 8.dp),
298+ verticalArrangement = Arrangement.spacedBy(3.dp),
299+ ) {
300+ Txt(
301+ "${file.language} · ${file.lines} lines · imported by ${file.dependents} · imports ${file.dependencies}",
302+ color = p.ink3,
303+ size = 11.5.sp,
304+ )
305+ if (file.symbols.isEmpty()) Txt("No symbols", color = p.ink3, size = 12.sp)
306+ for (symbol in file.symbols) {
307+ val signature = symbol.signature?.lineSequence()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() }
308+ ?: "${symbol.kind} ${symbol.name}"
309+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
310+ Txt(
311+ if (symbol.exported) "+" else " ",
312+ Modifier.width(10.dp),
313+ color = if (symbol.exported) p.greenInk else p.ink3,
314+ size = 11.5.sp,
315+ family = Mono,
316+ )
317+ Txt(signature, Modifier.weight(1f), color = p.ink2, size = 11.5.sp, family = Mono, maxLines = 1)
318+ Txt(":${symbol.line}", color = p.ink3, size = 11.5.sp, family = Mono)
319+ }
320+ }
321+ val more = file.symbolCount - file.symbols.size
322+ if (more > 0) Txt("… $more more", color = p.ink3, size = 11.5.sp)
323+ }
324+ }
325+ }
326+ }
327+
328+ /** A file's color in the map: one of the palette's tints, the same for every file in a folder. */
329+ private fun folderColor(path: String, p: Palette): Color {
330+ val folder = path.substringBeforeLast('/', "")
331+ // The desktop's hash, so a folder has the same color on both.
332+ var hash = 7u
333+ for (byte in folder.encodeToByteArray()) hash = hash * 31u + byte.toUByte().toUInt()
334+ return p.tints[(hash % p.tints.size.toUInt()).toInt()]
335+ }
336+
337+ private fun timeOfDay(millis: Long): String =
338+ java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(millis))
+6−3android/app/src/main/java/cc/vectrum/code/ui/panels/RightPanel.ktView File
@@ -60,6 +60,7 @@ private val SURFACES = listOf(
6060 Triple(Surface.Subagents, Lucide.Bot, "Subagents"),
6161 Triple(Surface.Device, Lucide.Smartphone, "Device"),
6262 Triple(Surface.Terminal, Lucide.SquareTerminal, "Terminal"),
63+ Triple(Surface.Genome, Lucide.Dna, "Genome map"),
6364 )
6465
6566 /** The right panel over a thread: "Open a surface", or the surface picked. */
@@ -84,8 +85,9 @@ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef, shape: Shape = RoundedCo
8485 ) {
8586 val surface = nav.surface
8687 when {
87- nav.surfacePicker || surface == null -> SurfacePicker(nav)
88+ nav.surfacePicker || surface == null -> SurfacePicker(nav, genome = hasGenome(app, ref))
8889 surface == Surface.Subagents -> SubagentsPanel(app, nav, ref)
90+ surface == Surface.Genome -> GenomePanel(app, nav, ref)
8991 // The terminal and device panels draw their own tab bar with a close button.
9092 surface == Surface.Terminal -> TerminalPanel(app, nav, ref)
9193 else -> DevicePanel(app, nav, ref)
@@ -112,7 +114,7 @@ fun PanelHeader(icon: Lucide, title: String, tag: String? = null, onClose: () ->
112114
113115 /** "Open a surface": the surfaces to pick from, on a raised card. */
114116 @Composable
115- private fun SurfacePicker(nav: Nav) {
117+ private fun SurfacePicker(nav: Nav, genome: Boolean) {
116118 val p = palette
117119 Column(Modifier.fillMaxSize()) {
118120 Row(Modifier.fillMaxWidth().height(48.dp).padding(end = 10.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -135,7 +137,8 @@ private fun SurfacePicker(nav: Nav) {
135137 style = TextStyle(textAlign = TextAlign.Center),
136138 )
137139 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
138- for ((surface, icon, name) in SURFACES) {
140+ // The Genome map is Empryo's; other agents' threads don't list it.
141+ for ((surface, icon, name) in SURFACES.filter { it.first != Surface.Genome || genome }) {
139142 Row(
140143 Modifier
141144 .fillMaxWidth()
+3−2android/app/src/main/java/cc/vectrum/code/ui/settings/Widgets.ktView File
@@ -589,13 +589,14 @@ fun Code(text: String, modifier: Modifier = Modifier) {
589589 /** Claude's asterisk orange, as the desktop's pickers draw it. */
590590 val ClaudeOrange = Color(0xffd97757)
591591
592- /** An agent's mark: Claude's asterisk, Pi's π or jcode's feather. */
592+ /** An agent's mark: Claude's asterisk, Pi's π, jcode's feather or Empryo's DNA strand. */
593593 @Composable
594594 fun ProviderIcon(provider: String, size: Dp = 13.dp, modifier: Modifier = Modifier) {
595595 val p = palette
596596 when (provider) {
597597 "pi" -> LucideIcon(Lucide.Pi, modifier, size = size, tint = p.ink)
598598 "jcode" -> LucideIcon(Lucide.Feather, modifier, size = size, tint = p.ink)
599+ "empryo" -> LucideIcon(Lucide.Dna, modifier, size = size, tint = p.ink)
599600 else -> LucideIcon(Lucide.Asterisk, modifier, size = size, tint = ClaudeOrange)
600601 }
601602 }
@@ -630,7 +631,7 @@ fun ModelControl(
630631 model != null -> model.name
631632 else -> modelId ?: current
632633 }
633- val provider = instance?.provider ?: instanceId?.takeIf { it in setOf("claude", "pi", "jcode") }
634+ val provider = instance?.provider ?: instanceId?.takeIf { it in setOf("claude", "pi", "jcode", "empryo") }
634635 val leading: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit)? =
635636 if (provider != null) { { ProviderIcon(provider) } } else null
636637 Cap(
+12−1android/app/src/main/java/cc/vectrum/code/ui/sidebar/Sidebar.ktView File
@@ -67,6 +67,8 @@ import cc.vectrum.code.ui.LucideIcon
6767 import cc.vectrum.code.ui.MenuItem
6868 import cc.vectrum.code.ui.Nav
6969 import cc.vectrum.code.ui.Screen
70+ import cc.vectrum.code.ui.Surface
71+ import cc.vectrum.code.ui.panels.hasGenome
7072 import cc.vectrum.code.ui.pair.AddProjectSheet
7173 import cc.vectrum.code.util.initials
7274 import cc.vectrum.code.util.tintFor
@@ -419,7 +421,7 @@ internal fun InboxEmpty(app: AppState, nav: Nav, scope: ProjectKey?, hasProjects
419421 }
420422 }
421423
422- /** The foot: Settings, and each host with its connection light. */
424+ /** The foot: Settings, the Genome map for an Empryo thread, and each host with its connection light. */
423425 @Composable
424426 private fun Footer(app: AppState, nav: Nav) {
425427 val p = palette
@@ -429,6 +431,15 @@ private fun Footer(app: AppState, nav: Nav) {
429431 verticalAlignment = Alignment.CenterVertically,
430432 ) {
431433 Cap(icon = Lucide.Settings, kind = CapKind.Ghost) { nav.go(Screen.Settings()) }
434+ // Empryo's map of the open thread's code, while the thread runs on Empryo.
435+ val thread = (nav.screen as? Screen.Thread)?.ref
436+ if (thread != null && hasGenome(app, thread)) {
437+ Cap(icon = Lucide.Dna, kind = CapKind.Ghost) {
438+ nav.sidebarOpen = false
439+ nav.surfacePicker = false
440+ nav.surface = Surface.Genome
441+ }
442+ }
432443 Row(
433444 Modifier.weight(1f).horizontalScroll(rememberScrollState()),
434445 horizontalArrangement = Arrangement.spacedBy(2.dp),
+2−1android/app/src/main/java/cc/vectrum/code/ui/sidebar/ThreadRows.ktView File
@@ -300,13 +300,14 @@ fun Modifier.pressTint(radius: Dp, onClick: () -> Unit): Modifier {
300300 .clickable(interactionSource = interaction, indication = null, onClick = onClick)
301301 }
302302
303- /** The provider's mark: Claude's orange asterisk, Pi, jcode's feather. */
303+ /** The provider's mark: Claude's orange asterisk, Pi, jcode's feather, Empryo's DNA strand. */
304304 @Composable
305305 fun ProviderIcon(provider: String, size: Dp) {
306306 val p = palette
307307 when (provider) {
308308 "pi" -> LucideIcon(Lucide.Pi, size = size, tint = p.ink)
309309 "jcode" -> LucideIcon(Lucide.Feather, size = size, tint = p.ink)
310+ "empryo" -> LucideIcon(Lucide.Dna, size = size, tint = p.ink)
310311 else -> LucideIcon(Lucide.Asterisk, size = size, tint = ClaudeOrange)
311312 }
312313 }
+16−0android/app/src/main/res/drawable/lucide_dna.xmladdedView File
@@ -0,0 +1,16 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m10 16 1.5 1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m14 8-1.5-1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M15 2c-1.798 1.998-2.518 3.995-2.807 5.993" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="m16.5 10.5 1 1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="m17 6-2.891-2.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M2 15c6.667-6 13.333 0 20-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ <path android:pathData="m20 9 .891.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
12+ <path android:pathData="M3.109 14.109 4 15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
13+ <path android:pathData="m6.5 12.5 1 1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
14+ <path android:pathData="m7 18 2.891 2.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
15+ <path android:pathData="M9 22c1.798-1.998 2.518-3.995 2.807-5.993" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
16+ </vector>
+67−11src/claude.rsView File
@@ -1,7 +1,8 @@
11 //! Spawns the long-running agent process behind a thread: the `claude` CLI
22 //! speaking stream-json (the transport the Claude Agent SDKs use), Pi in its
3- //! JSONL RPC mode, or jcode's harness API bridge, which connects to jcode's
4- //! shared daemon. All read commands on stdin and stream events on stdout.
3+ //! JSONL RPC mode, jcode's harness API bridge, which connects to jcode's
4+ //! shared daemon, or Empryo's headless chat, which takes a message per line.
5+ //! All read commands on stdin and stream events on stdout.
56
67 use std::{
78 io::{BufRead, BufReader, Write},
@@ -142,15 +143,18 @@ pub struct LaunchConfig {
142143 /// Which agent, with its binary, home folder, arguments and environment.
143144 pub instance: ProviderInstance,
144145 pub cwd: PathBuf,
145- /// A Claude model alias, `provider/model` for Pi, or a jcode model spec.
146- /// `None` is the default.
146+ /// A Claude model alias, `provider/model` for Pi and Empryo, or a jcode
147+ /// model spec. `None` is the default.
147148 pub model: Option<String>,
148149 pub permission_mode: PermissionMode,
149150 /// `None` leaves effort at the model's default.
150151 pub effort: Option<Effort>,
151- /// Session to resume (a Claude or jcode session id, or a Pi session
152- /// file), used to restart a thread whose process stopped.
152+ /// Session to resume (a Claude, jcode or Empryo session id, or a Pi
153+ /// session file), used to restart a thread whose process stopped.
153154 pub resume: Option<String>,
155+ /// The id a new Empryo session starts under, so the thread knows what
156+ /// to resume before Empryo has said anything.
157+ pub new_session: Option<String>,
154158 /// The thread's agent-browser session, shared with the browser panel.
155159 pub browser_session: Option<String>,
156160 /// The thread the agent runs for.
@@ -181,6 +185,7 @@ impl AgentProcess {
181185 // jcode's tools run in its daemon, out of reach of this process's
182186 // environment; the thread tells it about the browser in a message.
183187 Provider::Jcode => jcode_command(config)?,
188+ Provider::Empryo => empryo_command(config)?,
184189 };
185190 // The app's tools; jcode's are registered over its API.
186191 orchestrator::set_caps(&config.thread, config.tools);
@@ -192,20 +197,22 @@ impl AgentProcess {
192197 }
193198 }
194199 Provider::Pi => orchestrator::prepare_pi(&mut command, &config.thread, config.tools),
195- Provider::Jcode => {}
200+ // Empryo takes MCP servers from its config files only.
201+ Provider::Jcode | Provider::Empryo => {}
196202 }
197203 // The agent-device launcher, on PATH for agents that may drive devices.
198204 if config.tools.devices {
199205 crate::device::agent::prepare_command(&mut command);
200206 }
201- // Claude and Pi take extra system prompt with this flag, once: the
202- // browser's, if the thread has one, and how to write inline UI.
207+ // Claude, Pi and Empryo take extra system prompt with a flag, once:
208+ // the browser's, if the thread has one, and how to write inline UI.
203209 if config.instance.kind != Provider::Jcode {
204210 let browser = config.browser_session.as_ref().and_then(|session| browser::prepare_agent(&mut command, session));
205211 let inline = crate::settings::get(|settings| settings.inline_ui).then(|| crate::inline_ui::INSTRUCTIONS.to_string());
206212 let prompts: Vec<String> = browser.into_iter().chain(inline).collect();
207213 if !prompts.is_empty() {
208- command.args(["--append-system-prompt", &prompts.join("\n\n")]);
214+ let flag = if config.instance.kind == Provider::Empryo { "--system" } else { "--append-system-prompt" };
215+ command.args([flag, &prompts.join("\n\n")]);
209216 }
210217 }
211218 Self::spawn_command(&binary, command, events)
@@ -292,6 +299,11 @@ impl AgentProcess {
292299 let _ = self.stdin_tx.send(message.to_string());
293300 }
294301
302+ /// Sends a line of text as it is, for agents that read plain lines.
303+ pub fn send_line(&self, line: String) {
304+ let _ = self.stdin_tx.send(line);
305+ }
306+
295307 /// Sends a Claude user turn made of API content blocks (text, images).
296308 pub fn send_user_content(&self, content: Vec<Value>) {
297309 self.send(json!({
@@ -312,6 +324,16 @@ impl AgentProcess {
312324 }));
313325 }
314326
327+ /// Sends the agent Ctrl+C. Empryo stops its turn, saves the session and
328+ /// exits on it.
329+ pub fn interrupt(&self) {
330+ if let Ok(child) = self.child.lock() {
331+ unsafe {
332+ libc::kill(child.id() as libc::pid_t, libc::SIGINT);
333+ }
334+ }
335+ }
336+
315337 pub fn kill(&self) {
316338 if let Ok(mut child) = self.child.lock() {
317339 let _ = child.kill();
@@ -393,12 +415,43 @@ fn jcode_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
393415 Ok((binary, command))
394416 }
395417
396- /// The environment variable that moves an agent's home folder.
418+ /// Empryo's headless chat: a message per line on stdin, its events as
419+ /// JSONL on stdout. A thread's session is new under the id the thread picked,
420+ /// or resumed. It runs tools without asking (`auto`), or plans (`plan`).
421+ fn empryo_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
422+ let (binary, mut command) = base_command(&config.instance)?;
423+ command
424+ .args(["--headless", "--chat", "--events", "--cwd"])
425+ .arg(&config.cwd)
426+ .current_dir(&config.cwd)
427+ // Its first run would otherwise wait on a question about add-ons.
428+ .env("EMPRYO_NO_PROMPT", "1");
429+ let mode = if config.permission_mode == PermissionMode::Plan { "plan" } else { "auto" };
430+ command.args(["--mode", mode]);
431+ if let Some(model) = &config.model {
432+ command.args(["--model", model]);
433+ }
434+ if let Some(effort) = config.effort {
435+ command.args(["--effort", effort.as_cli()]);
436+ }
437+ if let Some(session) = &config.resume {
438+ command.args(["--session", session]);
439+ } else if let Some(session) = &config.new_session {
440+ command.args(["--session-id", session]);
441+ }
442+ command.args(split_args(&config.instance.args));
443+ Ok((binary, command))
444+ }
445+
446+ /// The environment variable that moves an agent's home folder. Empryo
447+ /// keeps its keys, settings and sessions list in `~/.empryo`, so its home is
448+ /// the folder that stands in for `~`.
397449 pub fn home_var(kind: Provider) -> &'static str {
398450 match kind {
399451 Provider::Claude => "CLAUDE_CONFIG_DIR",
400452 Provider::Pi => "PI_CODING_AGENT_DIR",
401453 Provider::Jcode => "JCODE_HOME",
454+ Provider::Empryo => "HOME",
402455 }
403456 }
404457
@@ -408,6 +461,7 @@ pub fn binary_var(kind: Provider) -> &'static str {
408461 Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN",
409462 Provider::Pi => "VECTRUM_CODE_PI_BIN",
410463 Provider::Jcode => "VECTRUM_CODE_JCODE_BIN",
464+ Provider::Empryo => "VECTRUM_CODE_EMPRYO_BIN",
411465 }
412466 }
413467
@@ -482,6 +536,8 @@ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> {
482536 home.join(".volta/bin/pi"),
483537 ],
484538 Provider::Jcode => vec![home.join(".local/bin/jcode"), home.join(".cargo/bin/jcode")],
539+ // Its installer puts it here.
540+ Provider::Empryo => vec![home.join(".empryo/bin/empryo"), home.join(".local/bin/empryo")],
485541 };
486542 fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]);
487543 // nvm keeps one bin directory per Node version.
+363−0src/genome.rsaddedView File
@@ -0,0 +1,363 @@
1+ //! Empryo's Genome: the ranked map of a repository Empryo keeps in
2+ //! `.empryo/genome.db`, a SQLite file it updates as code changes. The Genome
3+ //! map panel reads its most important files, what they export and which of
4+ //! them import which, and lays them out as a graph. A host reads it the same
5+ //! way for a connected device's Empryo threads and sends it over.
6+
7+ use std::{
8+ collections::HashMap,
9+ path::{Path, PathBuf},
10+ time::{Duration, SystemTime},
11+ };
12+
13+ use anyhow::{Context as _, Result};
14+ use rusqlite::{Connection, OpenFlags, params_from_iter};
15+ use serde::{Deserialize, Serialize};
16+
17+ /// How many files the map shows, by rank.
18+ pub const MAP_FILES: usize = 48;
19+ /// How many symbols each file lists.
20+ const FILE_SYMBOLS: usize = 12;
21+ /// A file changed this recently is marked new, as Empryo does in its map.
22+ const RECENT: Duration = Duration::from_secs(48 * 60 * 60);
23+
24+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25+ pub struct Genome {
26+ pub total_files: u64,
27+ pub total_symbols: u64,
28+ pub total_edges: u64,
29+ /// The highest ranked files, highest first.
30+ pub files: Vec<GenomeFile>,
31+ /// Imports between those files: `from` depends on `to`.
32+ pub edges: Vec<GenomeEdge>,
33+ /// Where each file sits in the map, from 0 to 1 on both axes.
34+ pub layout: Vec<(f32, f32)>,
35+ }
36+
37+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38+ pub struct GenomeFile {
39+ pub path: String,
40+ pub language: String,
41+ pub lines: u64,
42+ pub symbol_count: u64,
43+ /// Its PageRank in the import graph; the files' ranks add up to 1.
44+ pub rank: f64,
45+ /// How many files import it, from anywhere in the repository.
46+ pub dependents: u64,
47+ /// How many files it imports.
48+ pub dependencies: u64,
49+ /// Changed in the last 48 hours.
50+ pub recent: bool,
51+ /// Its symbols, exported ones first.
52+ pub symbols: Vec<GenomeSymbol>,
53+ }
54+
55+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56+ pub struct GenomeSymbol {
57+ pub name: String,
58+ pub kind: String,
59+ pub line: u64,
60+ pub exported: bool,
61+ pub signature: Option<String>,
62+ }
63+
64+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65+ pub struct GenomeEdge {
66+ pub from: usize,
67+ pub to: usize,
68+ pub weight: f64,
69+ }
70+
71+ /// The Genome database of the checkout `cwd` is in: Empryo keeps it at the
72+ /// checkout's root, which may be a folder or two up.
73+ pub fn db_path(cwd: &Path) -> Option<PathBuf> {
74+ cwd.ancestors().map(|dir| dir.join(".empryo").join("genome.db")).find(|path| path.is_file())
75+ }
76+
77+ /// When Empryo last wrote the Genome of the checkout `cwd` is in, in
78+ /// milliseconds since the epoch: its write-ahead log or the database itself.
79+ pub fn written_at(cwd: &Path) -> Option<u64> {
80+ let path = db_path(cwd)?;
81+ [path.with_extension("db-wal"), path]
82+ .iter()
83+ .filter_map(|path| std::fs::metadata(path).and_then(|meta| meta.modified()).ok())
84+ .max()
85+ .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
86+ .map(|since| since.as_millis() as u64)
87+ }
88+
89+ /// Reads the map of the checkout `cwd` is in.
90+ pub fn load(cwd: &Path) -> Result<Option<Genome>> {
91+ let Some(path) = db_path(cwd) else { return Ok(None) };
92+ // Opened for writing so SQLite can share Empryo's write-ahead log, but
93+ // only ever queried.
94+ let db = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX)
95+ .with_context(|| format!("couldn't open {}", path.display()))?;
96+ db.busy_timeout(Duration::from_secs(5))?;
97+ db.pragma_update(None, "query_only", true)?;
98+
99+ let count = |table: &str| -> Result<u64> {
100+ Ok(db.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| row.get::<_, i64>(0))? as u64)
101+ };
102+ let (total_files, total_symbols, total_edges) = (count("files")?, count("symbols")?, count("edges")?);
103+
104+ let now_ms = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis() as f64;
105+ let mut ids = Vec::new();
106+ let mut files = Vec::new();
107+ let mut statement = db.prepare(
108+ "SELECT id, path, language, line_count, symbol_count, pagerank, mtime_ms FROM files
109+ ORDER BY pagerank DESC, symbol_count DESC LIMIT ?1",
110+ )?;
111+ let rows = statement.query_map([MAP_FILES as i64], |row| {
112+ Ok((
113+ row.get::<_, i64>(0)?,
114+ GenomeFile {
115+ path: row.get(1)?,
116+ language: row.get(2)?,
117+ lines: row.get::<_, i64>(3)?.max(0) as u64,
118+ symbol_count: row.get::<_, i64>(4)?.max(0) as u64,
119+ rank: row.get(5)?,
120+ dependents: 0,
121+ dependencies: 0,
122+ recent: now_ms - row.get::<_, f64>(6)? < RECENT.as_millis() as f64,
123+ symbols: Vec::new(),
124+ },
125+ ))
126+ })?;
127+ for row in rows {
128+ let (id, file) = row?;
129+ ids.push(id);
130+ files.push(file);
131+ }
132+ drop(statement);
133+ let index: HashMap<i64, usize> = ids.iter().enumerate().map(|(ix, id)| (*id, ix)).collect();
134+ let placeholders = vec!["?"; ids.len()].join(",");
135+
136+ let mut edges = Vec::new();
137+ if !ids.is_empty() {
138+ let mut statement = db.prepare(&format!(
139+ "SELECT target_file_id, count(*) FROM edges WHERE target_file_id IN ({placeholders}) GROUP BY target_file_id"
140+ ))?;
141+ let rows = statement.query_map(params_from_iter(&ids), |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?;
142+ for row in rows {
143+ let (id, count) = row?;
144+ if let Some(&ix) = index.get(&id) {
145+ files[ix].dependents = count as u64;
146+ }
147+ }
148+ drop(statement);
149+ let mut statement = db.prepare(&format!(
150+ "SELECT source_file_id, count(*) FROM edges WHERE source_file_id IN ({placeholders}) GROUP BY source_file_id"
151+ ))?;
152+ let rows = statement.query_map(params_from_iter(&ids), |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?;
153+ for row in rows {
154+ let (id, count) = row?;
155+ if let Some(&ix) = index.get(&id) {
156+ files[ix].dependencies = count as u64;
157+ }
158+ }
159+ drop(statement);
160+
161+ let mut statement = db.prepare(&format!(
162+ "SELECT source_file_id, target_file_id, weight FROM edges
163+ WHERE source_file_id IN ({placeholders}) AND target_file_id IN ({placeholders})"
164+ ))?;
165+ let both: Vec<i64> = ids.iter().chain(ids.iter()).copied().collect();
166+ let rows = statement.query_map(params_from_iter(&both), |row| {
167+ Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, f64>(2)?))
168+ })?;
169+ for row in rows {
170+ let (from, to, weight) = row?;
171+ if let (Some(&from), Some(&to)) = (index.get(&from), index.get(&to))
172+ && from != to
173+ {
174+ edges.push(GenomeEdge { from, to, weight });
175+ }
176+ }
177+ drop(statement);
178+
179+ let mut statement = db.prepare(&format!(
180+ "SELECT file_id, name, kind, line, is_exported, signature FROM symbols
181+ WHERE file_id IN ({placeholders}) ORDER BY is_exported DESC, line"
182+ ))?;
183+ let rows = statement.query_map(params_from_iter(&ids), |row| {
184+ Ok((
185+ row.get::<_, i64>(0)?,
186+ GenomeSymbol {
187+ name: row.get(1)?,
188+ kind: row.get(2)?,
189+ line: row.get::<_, i64>(3)?.max(0) as u64,
190+ exported: row.get::<_, i64>(4)? != 0,
191+ signature: row.get(5)?,
192+ },
193+ ))
194+ })?;
195+ for row in rows {
196+ let (id, symbol) = row?;
197+ if let Some(&ix) = index.get(&id)
198+ && files[ix].symbols.len() < FILE_SYMBOLS
199+ {
200+ files[ix].symbols.push(symbol);
201+ }
202+ }
203+ }
204+
205+ let layout = layout(&files, &edges);
206+ Ok(Some(Genome { total_files, total_symbols, total_edges, files, edges, layout }))
207+ }
208+
209+ /// Places the files with a force-directed layout: imports pull files
210+ /// together, every pair pushes apart, and higher ranked files start nearer
211+ /// the middle. Deterministic, so the map holds still between reloads.
212+ pub fn layout(files: &[GenomeFile], edges: &[GenomeEdge]) -> Vec<(f32, f32)> {
213+ let n = files.len();
214+ if n == 0 {
215+ return Vec::new();
216+ }
217+ if n == 1 {
218+ return vec![(0.5, 0.5)];
219+ }
220+ // A spiral by rank: the most important file in the middle.
221+ let golden = std::f32::consts::PI * (3. - 5f32.sqrt());
222+ let mut pos: Vec<(f32, f32)> = (0..n)
223+ .map(|ix| {
224+ let radius = ((ix as f32 + 0.5) / n as f32).sqrt() * 0.45;
225+ let angle = ix as f32 * golden;
226+ (radius * angle.cos(), radius * angle.sin())
227+ })
228+ .collect();
229+ let mut edges_of = vec![0usize; n];
230+ for edge in edges {
231+ edges_of[edge.from] += 1;
232+ edges_of[edge.to] += 1;
233+ }
234+ let k = (1. / n as f32).sqrt() * 1.1;
235+ let mut temperature = 0.1;
236+ for _ in 0..400 {
237+ let mut shift = vec![(0f32, 0f32); n];
238+ for a in 0..n {
239+ for b in a + 1..n {
240+ let (dx, dy) = (pos[a].0 - pos[b].0, pos[a].1 - pos[b].1);
241+ let distance = (dx * dx + dy * dy).sqrt().max(0.001);
242+ let force = k * k / distance;
243+ let (fx, fy) = (dx / distance * force, dy / distance * force);
244+ shift[a].0 += fx;
245+ shift[a].1 += fy;
246+ shift[b].0 -= fx;
247+ shift[b].1 -= fy;
248+ }
249+ }
250+ for edge in edges {
251+ let (a, b) = (edge.from, edge.to);
252+ let (dx, dy) = (pos[a].0 - pos[b].0, pos[a].1 - pos[b].1);
253+ let distance = (dx * dx + dy * dy).sqrt().max(0.001);
254+ // Hubs have many lines; each pulls less, so they don't swallow the map.
255+ let degree = (edges_of[a] + edges_of[b]) as f32;
256+ let force = distance * distance / k * (1. + (edge.weight as f32).ln_1p() * 0.2) / degree.sqrt().max(1.);
257+ let (fx, fy) = (dx / distance * force, dy / distance * force);
258+ shift[a].0 -= fx;
259+ shift[a].1 -= fy;
260+ shift[b].0 += fx;
261+ shift[b].1 += fy;
262+ }
263+ for (ix, (dx, dy)) in shift.into_iter().enumerate() {
264+ // A pull to the middle keeps files nothing imports from drifting off.
265+ let gravity = 2.5 * k;
266+ let (dx, dy) = (dx - pos[ix].0 * gravity, dy - pos[ix].1 * gravity);
267+ let length = (dx * dx + dy * dy).sqrt().max(0.0001);
268+ let step = length.min(temperature);
269+ pos[ix].0 += dx / length * step;
270+ pos[ix].1 += dy / length * step;
271+ }
272+ temperature *= 0.988;
273+ }
274+ // A few files far out would shrink everything else: bring the farthest
275+ // in to just outside the rest.
276+ let mut distances: Vec<f32> = pos.iter().map(|(x, y)| (x * x + y * y).sqrt()).collect();
277+ distances.sort_by(f32::total_cmp);
278+ let edge = distances[(n * 9 / 10).min(n - 1)] * 1.15;
279+ for (x, y) in &mut pos {
280+ let distance = (*x * *x + *y * *y).sqrt();
281+ if distance > edge {
282+ let squeeze = (edge + (distance - edge) * 0.2) / distance;
283+ *x *= squeeze;
284+ *y *= squeeze;
285+ }
286+ }
287+ // Fit into the unit square.
288+ let (min_x, max_x) = pos.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (x, _)| (lo.min(*x), hi.max(*x)));
289+ let (min_y, max_y) = pos.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (_, y)| (lo.min(*y), hi.max(*y)));
290+ let span = (max_x - min_x).max(max_y - min_y).max(0.0001);
291+ let (off_x, off_y) = ((1. - (max_x - min_x) / span) / 2., (1. - (max_y - min_y) / span) / 2.);
292+ pos.into_iter().map(|(x, y)| ((x - min_x) / span + off_x, (y - min_y) / span + off_y)).collect()
293+ }
294+
295+ #[cfg(test)]
296+ mod tests {
297+ use super::{GenomeEdge, GenomeFile, layout, load};
298+
299+ fn file(path: &str, rank: f64) -> GenomeFile {
300+ GenomeFile {
301+ path: path.into(),
302+ language: "rust".into(),
303+ lines: 10,
304+ symbol_count: 1,
305+ rank,
306+ dependents: 0,
307+ dependencies: 0,
308+ recent: false,
309+ symbols: Vec::new(),
310+ }
311+ }
312+
313+ #[test]
314+ fn layout_fits_the_square() {
315+ let files: Vec<GenomeFile> = (0..20).map(|ix| file(&format!("f{ix}.rs"), 1. / (ix as f64 + 1.))).collect();
316+ let edges: Vec<GenomeEdge> = (1..20).map(|ix| GenomeEdge { from: ix, to: 0, weight: 1. }).collect();
317+ let pos = layout(&files, &edges);
318+ assert_eq!(pos.len(), 20);
319+ assert!(pos.iter().all(|(x, y)| (0. ..=1.).contains(x) && (0. ..=1.).contains(y)));
320+ // No two files on top of each other.
321+ for a in 0..pos.len() {
322+ for b in a + 1..pos.len() {
323+ let distance = ((pos[a].0 - pos[b].0).powi(2) + (pos[a].1 - pos[b].1).powi(2)).sqrt();
324+ assert!(distance > 0.01, "{a} and {b} overlap");
325+ }
326+ }
327+ assert_eq!(layout(&files[..1], &[]), vec![(0.5, 0.5)]);
328+ }
329+
330+ #[test]
331+ fn reads_a_genome() {
332+ let dir = std::env::temp_dir().join(format!("vectrum-genome-test-{}", std::process::id()));
333+ let sub = dir.join("src");
334+ std::fs::create_dir_all(dir.join(".empryo")).unwrap();
335+ std::fs::create_dir_all(&sub).unwrap();
336+ let db = rusqlite::Connection::open(dir.join(".empryo/genome.db")).unwrap();
337+ db.execute_batch(
338+ "PRAGMA journal_mode = WAL;
339+ CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT, mtime_ms REAL, language TEXT, line_count INTEGER,
340+ symbol_count INTEGER, pagerank REAL);
341+ CREATE TABLE symbols (id INTEGER PRIMARY KEY, file_id INTEGER, name TEXT, kind TEXT, line INTEGER,
342+ end_line INTEGER, is_exported INTEGER, signature TEXT);
343+ CREATE TABLE edges (source_file_id INTEGER, target_file_id INTEGER, weight REAL);
344+ INSERT INTO files VALUES (1, 'src/main.ts', 0, 'typescript', 4, 2, 0.35), (2, 'src/math.ts', 0, 'typescript', 3, 2, 0.65);
345+ INSERT INTO symbols VALUES (1, 1, 'main', 'function', 3, 3, 1, NULL), (2, 2, 'get', 'method', 2, 2, 0, NULL),
346+ (3, 2, 'add', 'function', 1, 1, 1, 'export function add(a, b)');
347+ INSERT INTO edges VALUES (1, 2, 4.5);",
348+ )
349+ .unwrap();
350+ let genome = load(&sub).unwrap().unwrap();
351+ assert_eq!((genome.total_files, genome.total_symbols, genome.total_edges), (2, 3, 1));
352+ assert_eq!(genome.files[0].path, "src/math.ts");
353+ assert_eq!(genome.files[0].dependents, 1);
354+ assert_eq!(genome.files[1].dependencies, 1);
355+ assert_eq!(genome.files[0].symbols[0].name, "add");
356+ assert!(genome.files[0].symbols[0].exported);
357+ assert_eq!(genome.edges.len(), 1);
358+ assert_eq!((genome.edges[0].from, genome.edges[0].to), (1, 0));
359+ drop(db);
360+ let _ = std::fs::remove_dir_all(&dir);
361+ assert!(load(&std::env::temp_dir().join("vectrum-genome-none")).unwrap().is_none());
362+ }
363+ }
+1−0src/main.rsView File
@@ -7,6 +7,7 @@ mod browser;
77 mod claude;
88 mod clock;
99 mod device;
10+ mod genome;
1011 mod git;
1112 mod http;
1213 mod inline_ui;
+160−4src/models.rsView File
@@ -29,16 +29,18 @@ pub enum Provider {
2929 Claude,
3030 Pi,
3131 Jcode,
32+ Empryo,
3233 }
3334
3435 impl Provider {
35- pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode];
36+ pub const ALL: [Provider; 4] = [Provider::Claude, Provider::Pi, Provider::Jcode, Provider::Empryo];
3637
3738 pub fn label(self) -> &'static str {
3839 match self {
3940 Provider::Claude => "Claude",
4041 Provider::Pi => "Pi",
4142 Provider::Jcode => "jcode",
43+ Provider::Empryo => "Empryo",
4244 }
4345 }
4446
@@ -48,6 +50,7 @@ impl Provider {
4850 Provider::Claude => "Claude Code",
4951 Provider::Pi => "Pi",
5052 Provider::Jcode => "jcode",
53+ Provider::Empryo => "Empryo",
5154 }
5255 }
5356
@@ -56,6 +59,7 @@ impl Provider {
5659 Provider::Claude => "claude",
5760 Provider::Pi => "pi",
5861 Provider::Jcode => "jcode",
62+ Provider::Empryo => "empryo",
5963 }
6064 }
6165
@@ -65,11 +69,17 @@ impl Provider {
6569 self != Provider::Jcode
6670 }
6771
68- /// Whether the agent asks before running tools. Pi and jcode just run them.
72+ /// Whether the agent asks before running tools. Pi, jcode and Empryo
73+ /// just run them.
6974 pub fn asks_permission(self) -> bool {
7075 self == Provider::Claude
7176 }
7277
78+ /// Whether the agent has a plan mode the composer can switch to.
79+ pub fn has_plan_mode(self) -> bool {
80+ matches!(self, Provider::Claude | Provider::Empryo)
81+ }
82+
7383 pub fn from_key(key: &str) -> Option<Self> {
7484 Self::ALL.into_iter().find(|provider| provider.key() == key)
7585 }
@@ -365,6 +375,7 @@ impl Catalog {
365375 Provider::Claude => probe_claude(&config),
366376 Provider::Pi => probe_pi(&config),
367377 Provider::Jcode => probe_jcode(&config),
378+ Provider::Empryo => probe_empryo(&config),
368379 };
369380 let latest = if update_checks { latest_version(config.kind) } else { None };
370381 (probe, latest)
@@ -884,8 +895,125 @@ fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String {
884895 }
885896 }
886897
898+ /// Asks `empryo --list-models` for the models of the providers Empryo has
899+ /// a key or a sign-in for, and reads the model new threads start on from
900+ /// its config.
901+ fn probe_empryo(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
902+ let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
903+ let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
904+ command
905+ .arg("--list-models")
906+ .env("EMPRYO_NO_PROMPT", "1")
907+ .current_dir(std::env::temp_dir())
908+ .stdin(Stdio::null())
909+ .stdout(Stdio::piped())
910+ .stderr(Stdio::null());
911+ let mut child = command.spawn().map_err(|err| failed(&err))?;
912+ let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
913+ let (tx, rx) = mpsc::channel();
914+ std::thread::spawn(move || {
915+ let mut listing = String::new();
916+ let _ = std::io::Read::read_to_string(&mut BufReader::new(stdout), &mut listing);
917+ let _ = tx.send(listing);
918+ });
919+ // Each provider's list may come from its API.
920+ let listing = rx.recv_timeout(Duration::from_secs(60));
921+ let _ = child.kill();
922+ let _ = child.wait();
923+ let listing = listing.map_err(|_| failed(&"empryo didn't list its models"))?;
924+ let models = empryo_models_from(&config.id, &listing);
925+ let version = base_command(config).ok().and_then(|(_, mut command)| {
926+ let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
927+ // `empryo 3.9.4-beta`
928+ let text = String::from_utf8_lossy(&output.stdout).to_string();
929+ text.split_whitespace().find(|word| word.starts_with(|c: char| c.is_ascii_digit())).map(str::to_string)
930+ });
931+ let plan = empryo_default_model(config).map(|model| format!("Default {model}"));
932+ Ok(Probe { models, plan, version, ..Probe::default() })
933+ }
934+
935+ /// The models in `empryo --list-models`' listing: a line per provider,
936+ /// `Anthropic (anthropic)`, then one per model, ` anthropic/claude-opus-5 1000k ctx …`.
937+ pub fn empryo_models_from(instance: &str, listing: &str) -> Vec<ModelEntry> {
938+ let mut models: Vec<ModelEntry> = Vec::new();
939+ let mut provider = String::new();
940+ for line in listing.lines().map(strip_ansi) {
941+ if line.trim().is_empty() {
942+ continue;
943+ }
944+ if !line.starts_with(' ') {
945+ // `Fake (fake) [custom]`
946+ provider = line.split(" (").next().unwrap_or_default().trim().to_string();
947+ continue;
948+ }
949+ let mut words = line.split_whitespace();
950+ let Some(id) = words.next().filter(|id| id.contains('/')) else { continue };
951+ if models.iter().any(|known| known.id == id) {
952+ continue;
953+ }
954+ let words: Vec<&str> = words.collect();
955+ let context_window = words.windows(2).find(|pair| pair[1] == "ctx").and_then(|pair| {
956+ let size = pair[0];
957+ let (number, scale) = match size.chars().last()? {
958+ 'k' | 'K' => (&size[..size.len() - 1], 1_000.),
959+ 'M' | 'm' => (&size[..size.len() - 1], 1_000_000.),
960+ _ => (size, 1.),
961+ };
962+ number.parse::<f64>().ok().map(|number| (number * scale) as u64)
963+ });
964+ let name = id.split_once('/').map_or(id, |(_, model)| model);
965+ models.push(ModelEntry {
966+ provider: Provider::Empryo,
967+ instance: instance.to_string(),
968+ id: id.to_string(),
969+ resolved: name.to_string(),
970+ name: name.to_string(),
971+ detail: provider.clone(),
972+ legacy: false,
973+ // `--effort` takes these; a model without reasoning ignores it.
974+ efforts: vec![Effort::Low, Effort::Medium, Effort::High],
975+ fast_mode: false,
976+ auto_mode: false,
977+ context_window,
978+ });
979+ }
980+ models
981+ }
982+
983+ /// The model Empryo starts on when none is picked, from its global config.
984+ fn empryo_default_model(config: &ProviderInstance) -> Option<String> {
985+ let home = match config.home.trim() {
986+ "" => std::env::var_os("HOME").map(std::path::PathBuf::from)?,
987+ home => settings::expand_home(home),
988+ };
989+ let text = std::fs::read_to_string(home.join(".empryo/config.json")).ok()?;
990+ let config: Value = serde_json::from_str(&text).ok()?;
991+ config.get("defaultModel").and_then(Value::as_str).filter(|model| !model.is_empty()).map(str::to_string)
992+ }
993+
994+ /// `text` without its terminal color codes.
995+ fn strip_ansi(text: &str) -> String {
996+ let mut out = String::with_capacity(text.len());
997+ let mut chars = text.chars();
998+ while let Some(c) = chars.next() {
999+ if c == '\u{1b}' {
1000+ // CSI sequences end with a letter.
1001+ if chars.next() == Some('[') {
1002+ for c in chars.by_ref() {
1003+ if c.is_ascii_alphabetic() {
1004+ break;
1005+ }
1006+ }
1007+ }
1008+ continue;
1009+ }
1010+ out.push(c);
1011+ }
1012+ out
1013+ }
1014+
8871015 /// The newest published version of an agent, from the npm registry (or
888- /// GitHub, for jcode).
1016+ /// GitHub, for jcode, and Empryo's download site).
8891017 fn latest_version(kind: Provider) -> Option<String> {
8901018 let package = match kind {
8911019 Provider::Claude => "@anthropic-ai/claude-code",
@@ -896,6 +1024,10 @@ fn latest_version(kind: Provider) -> Option<String> {
8961024 let tag = serde_json::from_str::<Value>(&body).ok()?.get("tag_name")?.as_str()?.to_string();
8971025 return Some(tag.trim_start_matches('v').to_string());
8981026 }
1027+ Provider::Empryo => {
1028+ let body = crate::http::get("https://dl.empryo.com/latest.txt", &[], Duration::from_secs(10)).ok()?;
1029+ return Some(body.trim().trim_start_matches('v').to_string()).filter(|version| !version.is_empty());
1030+ }
8991031 };
9001032 let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?;
9011033 serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string)
@@ -920,6 +1052,15 @@ pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Comman
9201052 command.args(["update"]);
9211053 Some((command, "jcode update"))
9221054 }
1055+ Provider::Empryo => {
1056+ // Empryo updates by running its installer again.
1057+ let mut command = std::process::Command::new("sh");
1058+ command
1059+ .args(["-c", "curl -fsSL https://empryo.com/install.sh | bash"])
1060+ .env("EMPRYO_QUIET", "1")
1061+ .env("EMPRYO_NO_PROMPT", "1");
1062+ Some((command, "curl -fsSL https://empryo.com/install.sh | bash"))
1063+ }
9231064 }
9241065 }
9251066
@@ -959,7 +1100,7 @@ fn provider_label(provider: &str) -> String {
9591100
9601101 #[cfg(test)]
9611102 mod tests {
962- use super::{claude_probe_from, first_version, jcode_model_from, provider_label, version_newer};
1103+ use super::{claude_probe_from, empryo_models_from, first_version, jcode_model_from, provider_label, version_newer};
9631104 use serde_json::json;
9641105
9651106 #[::core::prelude::v1::test]
@@ -1028,4 +1169,19 @@ mod tests {
10281169 assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto");
10291170 assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none());
10301171 }
1172+
1173+ #[::core::prelude::v1::test]
1174+ fn empryo_listing() {
1175+ let listing = "\u{1b}[1m\u{1b}[35mAnthropic\u{1b}[0m \u{1b}[2m(anthropic)\u{1b}[0m\n anthropic/claude-opus-5 1000k ctx $5/$25/M\n anthropic/claude-haiku-5\n\nFake (fake) [custom]\n fake/fake-1 128k ctx $3/$15/M (cache r$0.3/w$3.75) ~est\n (model fetch failed: offline)\n";
1176+ let models = empryo_models_from("empryo", listing);
1177+ assert_eq!(models.len(), 3);
1178+ assert_eq!(models[0].id, "anthropic/claude-opus-5");
1179+ assert_eq!(models[0].name, "claude-opus-5");
1180+ assert_eq!(models[0].detail, "Anthropic");
1181+ assert_eq!(models[0].context_window, Some(1_000_000));
1182+ assert_eq!(models[1].context_window, None);
1183+ assert_eq!(models[2].detail, "Fake");
1184+ assert_eq!(models[2].key(), "empryo:fake/fake-1");
1185+ assert_eq!(models[2].context_window, Some(128_000));
1186+ }
10311187 }
+2−0src/orchestrator.rsView File
@@ -587,6 +587,8 @@ pub fn wait_limit(provider: crate::models::Provider) -> Duration {
587587 crate::models::Provider::Claude => Duration::from_secs(30 * 60),
588588 crate::models::Provider::Pi => Duration::from_secs(270),
589589 crate::models::Provider::Jcode => Duration::from_secs(100),
590+ // Empryo threads don't get the app's tools.
591+ crate::models::Provider::Empryo => Duration::from_secs(270),
590592 }
591593 }
592594
+33−2src/remote/client.rsView File
@@ -22,7 +22,7 @@ use std::{
2222 use tungstenite::{Message, WebSocket};
2323
2424 use super::{
25- ClientMessage, DEFAULT_PORT, PROTOCOL, RemoteFileDiff, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes,
25+ ClientMessage, DEFAULT_PORT, GenomeAnswer, PROTOCOL, RemoteFileDiff, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes,
2626 };
2727
2828 /// How long to wait for a route that goes over the internet or a VPN.
@@ -70,6 +70,8 @@ pub struct RemoteClient {
7070 pub events: smol::channel::Receiver<ClientEvent>,
7171 /// Diffs asked for and not answered yet, by request.
7272 diffs: Mutex<HashMap<u64, smol::channel::Sender<Result<Vec<RemoteFileDiff>, String>>>>,
73+ /// Genomes asked for and not answered yet, by request.
74+ genomes: Mutex<HashMap<u64, smol::channel::Sender<GenomeAnswer>>>,
7375 next_request: AtomicU64,
7476 }
7577
@@ -144,7 +146,7 @@ impl RemoteClient {
144146 }
145147 });
146148 }
147- Self { outbox, stop, routes, events, diffs: Mutex::default(), next_request: AtomicU64::new(1) }
149+ Self { outbox, stop, routes, events, diffs: Mutex::default(), genomes: Mutex::default(), next_request: AtomicU64::new(1) }
148150 }
149151
150152 /// Asks the host for the diff between two of a thread's snapshots. The
@@ -178,6 +180,35 @@ impl RemoteClient {
178180 self.diffs.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request);
179181 }
180182
183+ /// Asks the host for Empryo's Genome of a thread's checkout. The answer
184+ /// arrives on the returned channel once [`Self::resolve_genome`] gets it;
185+ /// older hosts never answer.
186+ pub fn request_genome(
187+ &self,
188+ thread: Option<String>,
189+ project: std::path::PathBuf,
190+ since: Option<u64>,
191+ ) -> (u64, smol::channel::Receiver<GenomeAnswer>) {
192+ let request = self.next_request.fetch_add(1, Ordering::Relaxed);
193+ let (tx, rx) = smol::channel::bounded(1);
194+ self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(request, tx);
195+ self.send(&ClientMessage::Genome { request, thread, project, since });
196+ (request, rx)
197+ }
198+
199+ /// Hands a Genome reply to the request waiting for it.
200+ pub fn resolve_genome(&self, request: u64, answer: GenomeAnswer) {
201+ let waiting = self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request);
202+ if let Some(waiting) = waiting {
203+ let _ = waiting.try_send(answer);
204+ }
205+ }
206+
207+ /// Forgets a Genome request that stopped waiting.
208+ pub fn forget_genome(&self, request: u64) {
209+ self.genomes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&request);
210+ }
211+
181212 /// Changes the routes without dropping the connection; the next try,
182213 /// and the next look for a faster route, use them.
183214 pub fn set_routes(&self, routes: Vec<Route>) {
+66−1src/remote/mod.rsView File
@@ -22,7 +22,8 @@
2222 //! the host's [`device_proxy`] on the same port. Clients without a catalog
2323 //! of their own, like the Android app, ask for the host's agents and models
2424 //! with [`ClientMessage::Catalog`] and for a turn's diff with
25- //! [`ClientMessage::Diff`].
25+ //! [`ClientMessage::Diff`]. A client's Genome map shows the host's Empryo
26+ //! Genome, read there on [`ClientMessage::Genome`].
2627 //!
2728 //! A host can be reached more than one way, like T3 Code's routes: over the
2829 //! LAN at home and over Tailscale or another VPN away from it. The host
@@ -247,6 +248,11 @@ pub enum ClientMessage {
247248 /// `base` and `head`), for one file or all of them. Older hosts don't
248249 /// answer.
249250 Diff { request: u64, thread: String, base: String, head: String, path: Option<String>, ignore_whitespace: bool },
251+ /// Asks for Empryo's Genome of a thread's checkout (the project's, for a
252+ /// thread not created yet), answered with [`ServerMessage::GenomeReply`].
253+ /// `since` is when the Genome the client has was written, so an
254+ /// unchanged one isn't sent again. Older hosts don't answer.
255+ Genome { request: u64, thread: Option<String>, project: PathBuf, since: Option<u64> },
250256 /// Updates the host's app to the newest release and restarts it, all in
251257 /// one; it answers with [`ServerMessage::UpdateStatus`] until it goes
252258 /// down. Only hosts that say they're `updatable` take it.
@@ -304,12 +310,28 @@ pub enum ServerMessage {
304310 /// The answer to a [`ClientMessage::Diff`]: the changed files, or what
305311 /// went wrong.
306312 DiffReply { request: u64, files: Vec<RemoteFileDiff>, error: Option<String> },
313+ /// The answer to a [`ClientMessage::Genome`].
314+ GenomeReply { request: u64, answer: GenomeAnswer },
307315 /// How an update a client asked for goes: `checking`, `downloading`
308316 /// (with `progress`, 0 to 1), `restarting`, or `failed` with `error`.
309317 /// The connection drops when the host restarts.
310318 UpdateStatus { state: String, progress: Option<f32>, error: Option<String> },
311319 }
312320
321+ /// What a host found of a checkout's Genome.
322+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
323+ #[serde(default)]
324+ pub struct GenomeAnswer {
325+ /// When Empryo last wrote it, in milliseconds since the epoch; `None`
326+ /// when there's no Genome yet.
327+ pub written: Option<u64>,
328+ /// The map, unless it's unchanged since the client's or missing.
329+ pub genome: Option<Box<crate::genome::Genome>>,
330+ /// It's the one the client has.
331+ pub unchanged: bool,
332+ pub error: Option<String>,
333+ }
334+
313335 /// A provider instance on the host and what its last health check found.
314336 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
315337 #[serde(default)]
@@ -1083,6 +1105,49 @@ mod tests {
10831105 assert_eq!((back.lines[1].text.as_str(), back.lines[1].new_line), ("new", Some(1)));
10841106 }
10851107
1108+ #[::core::prelude::v1::test]
1109+ fn genome_messages_round_trip() {
1110+ let message = ClientMessage::Genome { request: 3, thread: Some("t1".into()), project: "/repo".into(), since: Some(42) };
1111+ let text = serde_json::to_string(&message).unwrap();
1112+ assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
1113+ let genome = crate::genome::Genome {
1114+ total_files: 1,
1115+ total_symbols: 1,
1116+ total_edges: 0,
1117+ files: vec![crate::genome::GenomeFile {
1118+ path: "src/a.rs".into(),
1119+ language: "rust".into(),
1120+ lines: 3,
1121+ symbol_count: 1,
1122+ rank: 1.,
1123+ dependents: 0,
1124+ dependencies: 0,
1125+ recent: true,
1126+ symbols: vec![crate::genome::GenomeSymbol {
1127+ name: "a".into(),
1128+ kind: "function".into(),
1129+ line: 1,
1130+ exported: true,
1131+ signature: Some("pub fn a()".into()),
1132+ }],
1133+ }],
1134+ edges: Vec::new(),
1135+ layout: vec![(0.5, 0.5)],
1136+ };
1137+ let answer = GenomeAnswer { written: Some(42), genome: Some(Box::new(genome)), ..GenomeAnswer::default() };
1138+ let text = serde_json::to_string(&ServerMessage::GenomeReply { request: 3, answer: answer.clone() }).unwrap();
1139+ match serde_json::from_str::<ServerMessage>(&text).unwrap() {
1140+ ServerMessage::GenomeReply { request: 3, answer: received } => assert_eq!(received, answer),
1141+ _ => panic!("not a Genome reply"),
1142+ }
1143+ // An unchanged Genome comes back as just that.
1144+ let text = r#"{"type": "genome_reply", "request": 4, "answer": {"written": 42, "unchanged": true}}"#;
1145+ match serde_json::from_str::<ServerMessage>(text).unwrap() {
1146+ ServerMessage::GenomeReply { answer, .. } => assert!(answer.unchanged && answer.genome.is_none()),
1147+ _ => panic!("not a Genome reply"),
1148+ }
1149+ }
1150+
10861151 #[::core::prelude::v1::test]
10871152 fn client_and_server_talk() {
10881153 use std::time::Duration;
+308−5src/session.rsView File
@@ -827,6 +827,24 @@ struct JcodeLink {
827827 instructions_sent: bool,
828828 }
829829
830+ /// Where a thread's Empryo chat stands. Empryo takes a message per line and
831+ /// streams each turn's events; it has no requests to keep track of.
832+ #[derive(Default)]
833+ struct EmpryoLink {
834+ /// The session id a new chat was started under, until Empryo is up and
835+ /// it can be resumed.
836+ new_session: Option<String>,
837+ /// Empryo said it's ready for a message since the process started.
838+ ready: bool,
839+ /// The answer the text streaming now goes into.
840+ text: Option<usize>,
841+ /// The model or effort changed mid-turn: the next turn starts a
842+ /// process with the new flags.
843+ relaunch: bool,
844+ /// The user stopped the turn, so the process is on its way out.
845+ stopping: bool,
846+ }
847+
830848 pub struct Session {
831849 /// Names the thread's file on disk.
832850 pub id: String,
@@ -854,8 +872,8 @@ pub struct Session {
854872 pub items: Vec<ChatItem>,
855873 pub status: SessionStatus,
856874 pub thinking: bool,
857- /// What a restarted process resumes: Claude's or jcode's session id, or
858- /// Pi's session file.
875+ /// What a restarted process resumes: Claude's, jcode's or Empryo's
876+ /// session id, or Pi's session file.
859877 resume_id: Option<String>,
860878 /// The model picked for the thread; `None` means the agent's default.
861879 pub model: Option<String>,
@@ -942,6 +960,7 @@ pub struct Session {
942960 pending_controls: HashMap<String, PendingControl>,
943961 next_request_id: u64,
944962 jcode: JcodeLink,
963+ empryo: EmpryoLink,
945964 _events_task: Task<()>,
946965 }
947966
@@ -1100,6 +1119,7 @@ impl Session {
11001119 pending_controls: HashMap::new(),
11011120 next_request_id: 0,
11021121 jcode: JcodeLink::default(),
1122+ empryo: EmpryoLink::default(),
11031123 _events_task: Task::ready(()),
11041124 }
11051125 }
@@ -1124,6 +1144,11 @@ impl Session {
11241144
11251145 fn start(&mut self, resume: Option<String>, cx: &mut Context<Self>) {
11261146 let (tx, rx) = smol::channel::unbounded();
1147+ // Empryo starts a new session under an id of the thread's choosing.
1148+ let new_session = (self.provider == Provider::Empryo && resume.is_none()).then(|| {
1149+ let millis = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis();
1150+ format!("vectrum-{}-{millis:x}", self.id)
1151+ });
11271152 let config = LaunchConfig {
11281153 instance: settings::instance(&self.instance, self.provider),
11291154 cwd: self.cwd.clone(),
@@ -1131,6 +1156,7 @@ impl Session {
11311156 permission_mode: self.permission_mode,
11321157 effort: self.reasoning.effort(),
11331158 resume: resume.clone(),
1159+ new_session: new_session.clone(),
11341160 browser_session: settings::get_for(&self.project, |settings| settings.agent_browser)
11351161 .then(|| browser::session_name(&self.id)),
11361162 thread: self.id.clone(),
@@ -1194,6 +1220,8 @@ impl Session {
11941220 );
11951221 }
11961222 }
1223+ // Model, effort and session went in as flags.
1224+ Provider::Empryo => self.empryo = EmpryoLink { new_session, ..EmpryoLink::default() },
11971225 }
11981226
11991227 self._events_task = cx.spawn(async move |this, cx| {
@@ -1546,6 +1574,11 @@ impl Session {
15461574 let mut images = Vec::new();
15471575 let mut file_paths = Vec::new();
15481576 for path in &attachments {
1577+ // Empryo's chat takes text only; it reads images from their path.
1578+ if self.provider == Provider::Empryo {
1579+ file_paths.push(path.display().to_string());
1580+ continue;
1581+ }
15491582 match image_media_type(path) {
15501583 Some(media_type) => match read_image(path) {
15511584 Ok(data) => images.push((media_type, data)),
@@ -1639,6 +1672,8 @@ impl Session {
16391672 self.jcode_request("send the message", request);
16401673 }
16411674 }
1675+ // Mid-turn, Empryo holds the message for its next turn.
1676+ Provider::Empryo => process.send_line(empryo_line(&prompt)),
16421677 }
16431678 self.status = SessionStatus::Working;
16441679 }
@@ -2134,6 +2169,14 @@ impl Session {
21342169 Provider::Claude => self.control(PendingControl::Interrupt, json!({"subtype": "interrupt"})),
21352170 Provider::Pi => self.pi(json!({"type": "abort"})),
21362171 Provider::Jcode => self.jcode_request("stop the turn", json!({"req": "cancel"})),
2172+ // Empryo stops the turn, saves the session and exits; the next
2173+ // message resumes it.
2174+ Provider::Empryo => {
2175+ if let Some(process) = &self.process {
2176+ self.empryo.stopping = true;
2177+ process.interrupt();
2178+ }
2179+ }
21372180 }
21382181 cx.notify();
21392182 }
@@ -2163,16 +2206,43 @@ impl Session {
21632206 }
21642207 }
21652208 Provider::Jcode => {}
2209+ Provider::Empryo => self.relaunch_empryo(),
21662210 }
21672211 cx.notify();
21682212 }
21692213
2214+ /// Empryo takes its model and effort as flags: a process that's idle
2215+ /// stops, so the next message starts one with the new ones, and a busy
2216+ /// one stops after its turn.
2217+ fn relaunch_empryo(&mut self) {
2218+ if self.process.is_none() {
2219+ return;
2220+ }
2221+ if self.is_working() || self.resume_id.is_none() {
2222+ self.empryo.relaunch = true;
2223+ return;
2224+ }
2225+ self.process.take();
2226+ self._events_task = Task::ready(());
2227+ if self.status != SessionStatus::Exited {
2228+ self.status = SessionStatus::Idle;
2229+ }
2230+ }
2231+
21702232 pub fn set_permission_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) {
21712233 if self.forward(ThreadAction::SetPermissionMode { mode: mode.as_cli().into() }) {
21722234 self.permission_mode = mode;
21732235 cx.notify();
21742236 return;
21752237 }
2238+ // Empryo plans in its plan mode, a launch flag, and otherwise runs
2239+ // its tools without asking.
2240+ if self.provider == Provider::Empryo {
2241+ self.permission_mode = mode;
2242+ self.relaunch_empryo();
2243+ cx.notify();
2244+ return;
2245+ }
21762246 // Pi and jcode have no permission prompts to configure.
21772247 if !self.provider.asks_permission() {
21782248 return;
@@ -2255,6 +2325,7 @@ impl Session {
22552325 }
22562326 }
22572327 Provider::Jcode => {}
2328+ Provider::Empryo => self.relaunch_empryo(),
22582329 }
22592330 cx.notify();
22602331 }
@@ -2604,6 +2675,7 @@ impl Session {
26042675 Provider::Claude => self.handle_message(message, cx),
26052676 Provider::Pi => self.handle_pi_message(message, cx),
26062677 Provider::Jcode => self.handle_jcode_message(message, cx),
2678+ Provider::Empryo => self.handle_empryo_message(message, cx),
26072679 },
26082680 ProcessEvent::Stderr(line) => {
26092681 if self.stderr_tail.len() == STDERR_TAIL {
@@ -2616,6 +2688,9 @@ impl Session {
26162688 }
26172689
26182690 fn handle_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) {
2691+ if self.provider == Provider::Empryo && self.empryo_exit(code, cx) {
2692+ return;
2693+ }
26192694 self.flush_text(cx);
26202695 self.process = None;
26212696 self.drop_background_tasks();
@@ -3678,6 +3753,184 @@ impl Session {
36783753 }
36793754 }
36803755
3756+ // ---- Empryo (headless chat events) --------------------------------
3757+
3758+ fn handle_empryo_message(&mut self, message: Value, cx: &mut Context<Self>) {
3759+ let text = |key: &str| message.get(key).and_then(Value::as_str).unwrap_or_default();
3760+ let number = |key: &str| message.get(key).and_then(Value::as_u64);
3761+ match text("type") {
3762+ "start" => {
3763+ if let Some(model) = message.get("model").and_then(Value::as_str) {
3764+ self.active_model = Some(model.to_string());
3765+ }
3766+ }
3767+ // Before each message it takes, the first time once the session
3768+ // is loaded and there's something to resume.
3769+ "ready" => {
3770+ self.empryo.ready = true;
3771+ if let Some(session) = self.empryo.new_session.take() {
3772+ self.resume_id = Some(session);
3773+ }
3774+ if self.status == SessionStatus::Starting {
3775+ self.status = SessionStatus::Idle;
3776+ }
3777+ }
3778+ "turn-start" => self.begin_turn(cx),
3779+ "text-start" => self.empryo.text = None,
3780+ "text" => {
3781+ self.begin_turn(cx);
3782+ self.thinking = false;
3783+ let ix = match self.empryo.text {
3784+ Some(ix) => ix,
3785+ None => {
3786+ let ix = self.push_assistant_text("");
3787+ self.empryo.text = Some(ix);
3788+ ix
3789+ }
3790+ };
3791+ self.append_text(ix, text("content"), cx);
3792+ }
3793+ "text-end" => {
3794+ if let Some(ix) = self.empryo.text.take() {
3795+ self.flush_item(ix, cx);
3796+ }
3797+ }
3798+ "reasoning" => {
3799+ self.begin_turn(cx);
3800+ self.thinking = true;
3801+ }
3802+ "tool-call" => {
3803+ self.begin_turn(cx);
3804+ self.thinking = false;
3805+ if let Some(ix) = self.empryo.text.take() {
3806+ self.flush_item(ix, cx);
3807+ }
3808+ let input = message.get("input").cloned().unwrap_or(Value::Null);
3809+ match self.find_tool_mut(text("toolCallId")) {
3810+ Some(tool) => tool.input = input,
3811+ None => {
3812+ self.push_tool(text("toolCallId"), text("tool"), input);
3813+ }
3814+ }
3815+ }
3816+ "tool-result" => {
3817+ let failed = message.get("ok").and_then(Value::as_bool) == Some(false);
3818+ let summary = message
3819+ .get("summary")
3820+ .and_then(Value::as_str)
3821+ .or_else(|| message.get("error").and_then(Value::as_str))
3822+ .unwrap_or_default()
3823+ .to_string();
3824+ let tool = match self.find_tool_mut(text("toolCallId")) {
3825+ Some(tool) => tool,
3826+ None => {
3827+ let ix = self.push_tool(text("toolCallId"), text("tool"), Value::Null);
3828+ let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return };
3829+ tool
3830+ }
3831+ };
3832+ tool.state = if failed { ToolState::Error } else { ToolState::Done };
3833+ tool.result = Some(summary);
3834+ }
3835+ "context-status" => {
3836+ if let Some(tokens) = number("tokens").filter(|tokens| *tokens > 0) {
3837+ self.context_tokens = Some(tokens);
3838+ }
3839+ if let Some(window) = number("contextWindow").filter(|window| *window > 0) {
3840+ self.context_window = Some(window);
3841+ }
3842+ }
3843+ // Every model call, the agent's own and its helpers'.
3844+ "model-call" if text("lane") == "main" && text("state") == "ok" => {
3845+ if let Some(model) = message.get("modelId").and_then(Value::as_str) {
3846+ self.active_model = Some(model.to_string());
3847+ }
3848+ let output = number("output").unwrap_or(0);
3849+ let secs = number("durationMs").unwrap_or(0) as f64 / 1000.;
3850+ // Too short a response says little about the model's speed.
3851+ if output >= 16 && secs >= 0.25 {
3852+ self.tokens_per_sec = Some(output as f64 / secs);
3853+ }
3854+ }
3855+ "warning" => self.push_notice(NoticeKind::Info, text("message").to_string()),
3856+ // Stopping a turn reports it as aborted.
3857+ "error" if self.interrupt_requested => {}
3858+ "error" => {
3859+ let error = text("error");
3860+ self.push_notice(NoticeKind::Error, if error.is_empty() { "Empryo ran into an error." } else { error }.to_string());
3861+ }
3862+ "turn-done" => {
3863+ self.thinking = false;
3864+ self.status = SessionStatus::Idle;
3865+ if let Some(ix) = self.empryo.text.take() {
3866+ self.flush_item(ix, cx);
3867+ }
3868+ if std::mem::take(&mut self.interrupt_requested) {
3869+ self.push_notice(NoticeKind::Info, "Interrupted.");
3870+ }
3871+ self.finish_open_tools("The turn ended before the tool finished.");
3872+ let cost = message.get("cost").and_then(Value::as_f64).filter(|cost| *cost > 0.);
3873+ self.total_cost += cost.unwrap_or(0.);
3874+ // Empryo's own count leaves out waiting for the Genome.
3875+ let duration_ms = self
3876+ .turn_started
3877+ .map(|start| start.elapsed().as_millis() as u64)
3878+ .or_else(|| number("duration"))
3879+ .unwrap_or(0);
3880+ self.finish_turn(duration_ms, cost, cx);
3881+ if std::mem::take(&mut self.empryo.relaunch) {
3882+ self.relaunch_empryo();
3883+ }
3884+ self.send_next_queued(cx);
3885+ }
3886+ _ => {}
3887+ }
3888+ }
3889+
3890+ /// Handles Empryo's process ending where that's part of how it works:
3891+ /// stopping a turn ends the chat (Ctrl+C takes it down), and so does an
3892+ /// idle chat. Returns whether it did; other exits are reported as
3893+ /// failures.
3894+ fn empryo_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) -> bool {
3895+ // The turn may have ended, clearing the request, before the exit.
3896+ let interrupted = std::mem::take(&mut self.interrupt_requested) | std::mem::take(&mut self.empryo.stopping);
3897+ let ready = std::mem::take(&mut self.empryo.ready);
3898+ self.empryo.text = None;
3899+ // A session it couldn't open: carry on in a new one.
3900+ if !ready && !interrupted && self.resume_id.is_some() && code == Some(1) {
3901+ self.resume_id = None;
3902+ self.process = None;
3903+ self.thinking = false;
3904+ self.push_notice(
3905+ NoticeKind::Info,
3906+ "Empryo couldn't reopen this thread's session; the next message starts a new one.",
3907+ );
3908+ if self.is_working() {
3909+ self.status = SessionStatus::Idle;
3910+ self.turn_started = None;
3911+ self.base_snapshot = None;
3912+ }
3913+ return true;
3914+ }
3915+ if !(interrupted || code == Some(0)) {
3916+ return false;
3917+ }
3918+ self.flush_text(cx);
3919+ self.process = None;
3920+ self.thinking = false;
3921+ self.finish_open_tools("The turn was stopped before the tool finished.");
3922+ if self.is_working() {
3923+ // It went before saying the turn was done.
3924+ self.push_notice(NoticeKind::Info, "Interrupted.");
3925+ let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64);
3926+ self.status = SessionStatus::Idle;
3927+ self.finish_turn(duration_ms, None, cx);
3928+ }
3929+ self.status = SessionStatus::Idle;
3930+ self.send_next_queued(cx);
3931+ true
3932+ }
3933+
36813934 /// Registers the app's tools with the thread's jcode session (a
36823935 /// subagent's thread gets none) and switches off jcode's own ways of
36833936 /// spawning agents. jcode keeps the setup in memory, so it's sent on
@@ -3689,6 +3942,10 @@ impl Session {
36893942
36903943 /// Which of the app's tools this thread's agent gets.
36913944 pub fn tool_caps(&self) -> orchestrator::Caps {
3945+ // Empryo only loads MCP servers from its own config files.
3946+ if self.provider == Provider::Empryo {
3947+ return orchestrator::Caps::default();
3948+ }
36923949 orchestrator::Caps { delegate: self.delegation.is_none(), devices: crate::device::agent_access_for(&self.project) }
36933950 }
36943951
@@ -4915,6 +5172,25 @@ pub fn tool_field(input: &Value, keys: &[&str]) -> Option<String> {
49155172 }
49165173
49175174 /// A one-line description of what a tool call is doing.
5175+ /// A message as one line of Empryo's chat: each line but the last ends in a
5176+ /// backslash, which continues the message on the next. A line of the message
5177+ /// that ends in a backslash itself gets a space after it.
5178+ fn empryo_line(text: &str) -> String {
5179+ let lines: Vec<&str> = text.trim().lines().map(|line| line.trim_end_matches('\r')).collect();
5180+ let last = lines.len().saturating_sub(1);
5181+ let mut out = String::with_capacity(text.len() + lines.len());
5182+ for (ix, line) in lines.iter().enumerate() {
5183+ out.push_str(line);
5184+ if line.ends_with('\\') {
5185+ out.push(' ');
5186+ }
5187+ if ix < last {
5188+ out.push_str("\\\n");
5189+ }
5190+ }
5191+ out
5192+ }
5193+
49185194 pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String {
49195195 let field = |keys: &[&str]| tool_field(input, keys);
49205196 let path = |keys: &[&str]| {
@@ -4926,8 +5202,16 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
49265202 })
49275203 };
49285204 let summary = match name.to_ascii_lowercase().as_str() {
4929- "bash" => field(&["command"]),
4930- "read" | "write" | "edit" | "multiedit" => path(&["file_path", "path"]),
5205+ "bash" | "shell" => field(&["command"]),
5206+ "read" | "write" | "edit" | "multiedit" | "edit_file" | "multi_edit" | "ast_edit" => {
5207+ path(&["file_path", "path"]).or_else(|| {
5208+ // Empryo reads several files at once.
5209+ let files = input.get("files")?.as_array()?;
5210+ let paths: Vec<String> = files.iter().filter_map(|file| tool_field(file, &["path"])).collect();
5211+ (!paths.is_empty()).then(|| paths.join(", "))
5212+ })
5213+ }
5214+ "list_dir" => path(&["path"]),
49315215 "notebookedit" => path(&["notebook_path"]),
49325216 "glob" | "grep" | "find" => field(&["pattern", "query"]),
49335217 "ls" => path(&["path"]),
@@ -4956,7 +5240,10 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
49565240
49575241 #[cfg(test)]
49585242 mod tests {
4959- use super::{AssistantText, TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results};
5243+ use super::{
5244+ AssistantText, TaskStatus, ToolCall, ToolState, empryo_line, finished_prefix, jcode_agent_status, pi_subagent_results,
5245+ tool_summary,
5246+ };
49605247 use serde_json::{Value, json};
49615248
49625249 fn tool(name: &str, input: Value) -> ToolCall {
@@ -5039,4 +5326,20 @@ mod tests {
50395326 assert!(answer.view.is_none());
50405327 assert_eq!(answer.text, "First.\n\nSecond");
50415328 }
5329+
5330+ #[test]
5331+ fn empryo_lines() {
5332+ assert_eq!(empryo_line("hi"), "hi");
5333+ assert_eq!(empryo_line("one\r\n\nthree\n"), "one\\\n\\\nthree");
5334+ // A line ending in a backslash would otherwise run on.
5335+ assert_eq!(empryo_line("C:\\\nnext"), "C:\\ \\\nnext");
5336+ }
5337+
5338+ #[test]
5339+ fn empryo_tool_summaries() {
5340+ let cwd = std::path::Path::new("/repo");
5341+ assert_eq!(tool_summary("read", &json!({"files": [{"path": "a.rs"}, {"path": "b.rs"}]}), cwd), "a.rs, b.rs");
5342+ assert_eq!(tool_summary("shell", &json!({"command": "cargo test"}), cwd), "cargo test");
5343+ assert_eq!(tool_summary("edit_file", &json!({"path": "/repo/src/x.rs"}), cwd), "src/x.rs");
5344+ }
50425345 }
+11−0src/textgen.rsView File
@@ -208,6 +208,17 @@ fn generate(project: &Path, prompt: &str) -> Option<String> {
208208 }
209209 }
210210 Provider::Jcode => return None,
211+ Provider::Empryo => {
212+ // Without the project, and in a folder of its own: Empryo keeps
213+ // a `.empryo` folder wherever it runs.
214+ let dir = std::env::temp_dir().join("vectrum-code-empryo");
215+ let _ = std::fs::create_dir_all(&dir);
216+ command.args(["--headless", "--bare", "--quiet", "--no-render", "--max-steps", "3", "--model", model]);
217+ command.arg("--cwd").arg(dir).env("EMPRYO_NO_PROMPT", "1");
218+ if let Some(effort) = effort.filter(|effort| *effort >= Effort::Low) {
219+ command.args(["--effort", effort.as_cli()]);
220+ }
221+ }
211222 }
212223 let output = command
213224 .arg(prompt)
+1−1src/ui/chat/footer.rsView File
@@ -94,7 +94,7 @@ impl ChatView {
9494 let traits_label = pickers::traits_label(session, model.as_ref());
9595 let access = session.access_mode();
9696 let planning = session.is_planning();
97- let plan_toggle = settings::get(|settings| settings.plan_mode) && provider.asks_permission();
97+ let plan_toggle = settings::get(|settings| settings.plan_mode) && provider.has_plan_mode();
9898
9999 let label_width = |label: &str| label.chars().count() as f32 * CHAR_WIDTH;
100100 let model_width = 52. + label_width(&model_label).min(200.);
+18−3src/ui/chat/timeline.rsView File
@@ -145,13 +145,19 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
145145 return Presentation { icon: Icon::new(Lucide::Smartphone), label, action: Action::Other };
146146 }
147147 let (action, label) = match name.to_ascii_lowercase().as_str() {
148- "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
148+ "bash" | "shell" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
149149 "bashoutput" | "killshell" => (Action::Command, name.to_string()),
150150 "read" | "notebookread" => (
151151 Action::Read,
152- field(&["file_path", "path", "notebook_path"]).map(|path| format!("Read {}", relative_path(&path, cwd))).unwrap_or_else(|| "Read file".into()),
152+ field(&["file_path", "path", "notebook_path"])
153+ .map(|path| relative_path(&path, cwd))
154+ // Empryo's read takes a list of files.
155+ .or_else(|| Some(crate::session::tool_summary(name, input, cwd)).filter(|paths| !paths.is_empty()))
156+ .map(|path| format!("Read {path}"))
157+ .unwrap_or_else(|| "Read file".into()),
153158 ),
154- "write" | "edit" | "multiedit" | "notebookedit" => (
159+ // Empryo's edits, by path or by symbol.
160+ "edit_file" | "multi_edit" | "ast_edit" | "write" | "edit" | "multiedit" | "notebookedit" => (
155161 Action::Edit,
156162 field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()),
157163 ),
@@ -159,6 +165,15 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
159165 Action::CodeSearch,
160166 field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()),
161167 ),
168+ // Empryo's queries of its Genome and language servers.
169+ "genome_query" | "genome_analyze" | "genome_impact" | "navigate" | "analyze" => {
170+ let summary = crate::session::tool_summary(name, input, cwd);
171+ let what = if name.starts_with("genome") { "Genome" } else { "Code intelligence" };
172+ (Action::CodeSearch, if summary.is_empty() { what.to_string() } else { format!("{what}: {summary}") })
173+ }
174+ "list_dir" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())),
175+ "web_search" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
176+ "fetch_page" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
162177 "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())),
163178 "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
164179 "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
+1−0src/ui/icons.rsView File
@@ -21,6 +21,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
2121 Code,
2222 Feather,
2323 CornerDownRight,
24+ Dna,
2425 FileCode,
2526 FileDiff,
2627 FolderGit2,
+7−2src/ui/pickers.rsView File
@@ -28,6 +28,7 @@ pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon {
2828 Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)),
2929 Provider::Pi => Icon::new(Lucide::Pi).size(size).text_color(p.ink),
3030 Provider::Jcode => Icon::new(Lucide::Feather).size(size).text_color(p.ink),
31+ Provider::Empryo => Icon::new(Lucide::Dna).size(size).text_color(p.ink),
3132 }
3233 }
3334
@@ -139,7 +140,8 @@ pub fn traits_items(
139140 let mut items = Vec::new();
140141 if !levels.is_empty() || provider == Provider::Claude {
141142 items.push(composer::menu_label(if provider == Provider::Pi { "Thinking" } else { "Reasoning" }, p).into_any_element());
142- if default.is_none() && provider == Provider::Claude {
143+ // Empryo leaves effort to the model unless it's told one.
144+ if default.is_none() && matches!(provider, Provider::Claude | Provider::Empryo) {
143145 items.push(item("traits-default".into(), Reasoning::Default, "Default", None, true));
144146 }
145147 for (ix, level) in levels.into_iter().enumerate() {
@@ -493,7 +495,7 @@ impl ModelPicker {
493495 let locked = !state.items.is_empty() && model.instance != state.instance;
494496 let detail = match model.provider {
495497 Provider::Claude => instance,
496- Provider::Pi | Provider::Jcode => format!("{instance} · {}", model.detail),
498+ Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{instance} · {}", model.detail),
497499 };
498500 h_flex()
499501 .id(SharedString::from(format!("model-{}", model.key())))
@@ -593,6 +595,9 @@ impl ModelPicker {
593595 }
594596 (ProbeStatus::Missing, Provider::Claude) => "Open provider setup to install Claude on this environment.".into(),
595597 (ProbeStatus::Missing, Provider::Jcode) => "Open provider setup to install jcode on this environment.".into(),
598+ (ProbeStatus::Missing, Provider::Empryo) => {
599+ "Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash".into()
600+ }
596601 (ProbeStatus::Failed(error), _) => format!("{label} provider is unavailable. {error}"),
597602 (ProbeStatus::Ready, _) => "No models are available for this provider.".into(),
598603 };
+1−1src/ui/settings/general.rsView File
@@ -134,7 +134,7 @@ impl SettingsView {
134134 .row(
135135 row("Permissions")
136136 .id("default-permissions")
137- .description("Default permissions for new threads. Pi and jcode threads always run with full access.")
137+ .description("Default permissions for new threads. Pi, jcode and Empryo threads always run with full access.")
138138 .reset(reset(&view, cx, |s| s.default_access.clone(), |s, access| s.default_access = access))
139139 .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| {
140140 settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string())
+16−2src/ui/settings/providers.rsView File
@@ -92,8 +92,10 @@ fn status_headline(state: &InstanceState) -> (Led, String) {
9292 match state.kind() {
9393 Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()),
9494 Provider::Claude => parts.push("Not authenticated".to_string()),
95- Provider::Pi | Provider::Jcode if state.models.is_empty() => parts.push("Needs attention · No models configured".into()),
96- Provider::Pi | Provider::Jcode => parts.push("Available".to_string()),
95+ Provider::Pi | Provider::Jcode | Provider::Empryo if state.models.is_empty() => {
96+ parts.push("Needs attention · No models configured".into())
97+ }
98+ Provider::Pi | Provider::Jcode | Provider::Empryo => parts.push("Available".to_string()),
9799 }
98100 parts.extend(state.plan.clone());
99101 let led = if parts[0].starts_with("Not") || parts[0].starts_with("Needs") { Led::Waiting } else { Led::Live };
@@ -147,6 +149,7 @@ fn home_title(kind: Provider) -> &'static str {
147149 Provider::Claude => "CLAUDE_CONFIG_DIR path",
148150 Provider::Pi => "PI_CODING_AGENT_DIR path",
149151 Provider::Jcode => "JCODE_HOME path",
152+ Provider::Empryo => "HOME path",
150153 }
151154 }
152155
@@ -155,6 +158,7 @@ fn home_placeholder(kind: Provider) -> &'static str {
155158 Provider::Claude => "~/.claude",
156159 Provider::Pi => "~/.pi/agent",
157160 Provider::Jcode => "~/.jcode",
161+ Provider::Empryo => "~/empryo-home",
158162 }
159163 }
160164
@@ -163,6 +167,7 @@ fn custom_placeholder(kind: Provider) -> &'static str {
163167 Provider::Claude => "claude-sonnet-5",
164168 Provider::Pi => "anthropic/claude-sonnet-5",
165169 Provider::Jcode => "claude-oauth:claude-opus-5",
170+ Provider::Empryo => "anthropic/claude-sonnet-5",
166171 }
167172 }
168173
@@ -824,6 +829,10 @@ impl SettingsView {
824829 (Provider::Claude, None, _) => "Not signed in. Run claude in a terminal and log in, then refresh.".into(),
825830 (Provider::Pi, _, _) => "Pi signs in to model providers itself: run pi in a terminal and use /login.".into(),
826831 (Provider::Jcode, _, _) => "jcode signs in itself: run jcode login in a terminal.".into(),
832+ (Provider::Empryo, _, _) => {
833+ "Empryo signs in itself: run empryo --login claude (or codex, copilot…), or save a key with empryo --set-key <provider> <key>."
834+ .into()
835+ }
827836 };
828837 let setup = Section::new("Setup").row(row("Account").description(account)).render(cx);
829838
@@ -836,6 +845,7 @@ impl SettingsView {
836845 Provider::Claude => "Path to the Claude binary used by this instance.",
837846 Provider::Pi => "Path to the Pi coding agent binary.",
838847 Provider::Jcode => "Path to the jcode binary used by this instance.",
848+ Provider::Empryo => "Path to the empryo binary used by this instance.",
839849 })
840850 .status(binary_status)
841851 .control(text_field(&form.binary, 224., window, cx)),
@@ -846,6 +856,9 @@ impl SettingsView {
846856 Provider::Claude => "Custom Claude home and config directory. Keeps .claude.json and .claude separate.",
847857 Provider::Pi => "Custom Pi agent directory, so this instance keeps its own logins, settings and sessions.",
848858 Provider::Jcode => "Custom jcode home, so this instance keeps its own logins and sessions.",
859+ Provider::Empryo => {
860+ "A folder Empryo runs with as its HOME, so this instance keeps its own ~/.empryo: keys, settings and sign-ins. Its commands see it too."
861+ }
849862 })
850863 .control(text_field(&form.home, 224., window, cx)),
851864 );
@@ -863,6 +876,7 @@ impl SettingsView {
863876 Provider::Claude => "Additional CLI arguments passed on session start.",
864877 Provider::Pi => "Additional CLI arguments passed to pi --mode rpc on session start.",
865878 Provider::Jcode => "Additional CLI arguments passed to jcode ahead of api --stdio on session start.",
879+ Provider::Empryo => "Additional CLI arguments passed to empryo --headless --chat on session start.",
866880 })
867881 .control(text_field(&form.args, 224., window, cx)),
868882 );
+1−1src/ui/settings/widgets.rsView File
@@ -816,7 +816,7 @@ pub(crate) fn model_control(
816816 let name = match model.provider {
817817 Provider::Claude if model.legacy => format!("{} (legacy)", model.name),
818818 Provider::Claude => model.name.clone(),
819- Provider::Pi | Provider::Jcode => format!("{} · {}", model.name, model.detail),
819+ Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{} · {}", model.name, model.detail),
820820 };
821821 (model.key(), name)
822822 })
+1−1src/ui/usage.rsView File
@@ -136,7 +136,7 @@ impl UsageView {
136136 Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
137137 Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
138138 // jcode keeps no usage logs this page reads.
139- Provider::Jcode => {}
139+ Provider::Jcode | Provider::Empryo => {}
140140 }
141141 if let Some(usage) = &state.usage {
142142 limits.push((SharedString::from(label), usage.clone()));
+19−1src/ui/workspace.rsView File
@@ -52,6 +52,7 @@ use crate::{
5252 mod actions;
5353 mod delegation;
5454 mod devices;
55+ mod genome;
5556 mod git_actions;
5657 mod header;
5758 mod palette;
@@ -261,6 +262,8 @@ pub struct Workspace {
261262 right_panels: HashMap<EntityId, surfaces::Surface>,
262263 /// The right panel while no thread is open.
263264 idle_right_panel: Option<surfaces::Surface>,
265+ /// The Genome map, while it shows: Empryo's map of a checkout.
266+ genome: Option<genome::GenomePanel>,
264267 /// The surface picker's focus, for its keys.
265268 surface_focus: FocusHandle,
266269 settled_open: bool,
@@ -435,6 +438,7 @@ impl Workspace {
435438 panel_open: true,
436439 right_panels: HashMap::new(),
437440 idle_right_panel: None,
441+ genome: None,
438442 surface_focus: cx.focus_handle(),
439443 settled_open: false,
440444 snoozed_open: false,
@@ -2255,6 +2259,15 @@ impl Render for Workspace {
22552259 let floating_device = self.render_floating_device(cx);
22562260 let p = metal::palette(cx);
22572261 let subagents_visible = self.surface_open(surfaces::Surface::Subagents);
2262+ // The map follows the open thread to its checkout, and is only for Empryo's.
2263+ if self.surface_open(surfaces::Surface::Genome) && !self.genome_available(cx) {
2264+ self.set_right_panel(None);
2265+ }
2266+ self.sync_genome(cx);
2267+ let genome_visible = self.surface_open(surfaces::Surface::Genome) && !self.settings_open;
2268+ if !self.surface_open(surfaces::Surface::Genome) {
2269+ self.genome = None;
2270+ }
22582271 let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open;
22592272 let docked_terminal =
22602273 if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None };
@@ -2263,7 +2276,11 @@ impl Render for Workspace {
22632276 && self.active_project().is_some()
22642277 && window.viewport_size().width >= px(PANEL_MIN_WINDOW);
22652278 // The browser gives way before the conversation gets too narrow.
2266- let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) };
2279+ let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() || genome_visible {
2280+ px(360.)
2281+ } else {
2282+ px(0.)
2283+ };
22672284 let mut drawer = self.render_terminal_drawer(false, window, cx);
22682285 let drawer_full_width = panel_visible;
22692286 let wallpaper = self.render_start_wallpaper(chat.is_some(), cx);
@@ -2430,6 +2447,7 @@ impl Render for Workspace {
24302447 )
24312448 .when(panel_visible, |this| this.child(self.render_panel(cx)))
24322449 .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx)))
2450+ .when(genome_visible, |this| this.child(self.render_genome_panel(cx)))
24332451 .when(picker_visible, |this| this.child(self.render_surface_picker(cx)))
24342452 .children(docked_terminal)
24352453 .when_some(browser, |this, browser| this.child(browser))
+603−0src/ui/workspace/genome.rsaddedView File

Large diffs are not rendered by default.

+46−1src/ui/workspace/remote.rsView File

Large diffs are not rendered by default.

+11−0src/ui/workspace/sidebar.rsView File

Large diffs are not rendered by default.

+15−5src/ui/workspace/surfaces.rsView File

Large diffs are not rendered by default.