Update version to 0.1.10
Build / Build & push landing (push) Successful in 14s
Build / Deploy to prod (push) Successful in 7s
Build / Notify Max (push) Successful in 2s

Add deepseek to resume commands

Rename app to spaceshell

Add SurfacePicker component for preset panel configuration

Extract agent selection logic to shared agents.ts

Update landing
This commit is contained in:
2026-06-15 17:25:53 +07:00
parent 333b051e9d
commit 2ee2aaaffb
12 changed files with 151 additions and 46 deletions
Generated
+5 -5
View File
@@ -869,7 +869,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-cli" name = "spacesh-cli"
version = "0.1.7" version = "0.1.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -881,7 +881,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-core" name = "spacesh-core"
version = "0.1.7" version = "0.1.10"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"serde", "serde",
@@ -891,7 +891,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-proto" name = "spacesh-proto"
version = "0.1.7" version = "0.1.10"
dependencies = [ dependencies = [
"bytes", "bytes",
"serde", "serde",
@@ -903,7 +903,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-pty" name = "spacesh-pty"
version = "0.1.7" version = "0.1.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -913,7 +913,7 @@ dependencies = [
[[package]] [[package]]
name = "spaceshd" name = "spaceshd"
version = "0.1.7" version = "0.1.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "0.1.7" version = "0.1.10"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -318,6 +318,7 @@ In `crates/spaceshd/src/config.rs`, add the struct and a default table, and exte
const DEFAULT_RESUME: &[(&str, &[&str])] = &[ const DEFAULT_RESUME: &[(&str, &[&str])] = &[
("claude", &["--continue"]), ("claude", &["--continue"]),
("codex", &["resume"]), ("codex", &["resume"]),
("deepseek", &["resume"]),
]; ];
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
+10 -10
View File
@@ -8,8 +8,8 @@ NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg
NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}') NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}')
SIDECAR_DIR := $(APP_DIR)/src-tauri/bin SIDECAR_DIR := $(APP_DIR)/src-tauri/bin
BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json
APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spacesh.app APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spaceshell.app
NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spacesh.app NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spaceshell.app
APP_VERSION := $(shell node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version" 2>/dev/null || echo 0.0.0) APP_VERSION := $(shell node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version" 2>/dev/null || echo 0.0.0)
LANDING_IMAGE := spacesh-landing LANDING_IMAGE := spacesh-landing
@@ -19,7 +19,7 @@ REPO ?= spacesh
# ---- Gitea generic package registry (versioned .dmg downloads) ---- # ---- Gitea generic package registry (versioned .dmg downloads) ----
GITEA_URL ?= https://git.realmanual.ru GITEA_URL ?= https://git.realmanual.ru
GITEA_OWNER ?= realmanual GITEA_OWNER ?= pub
GITEA_PKG ?= spacesh GITEA_PKG ?= spacesh
GITEA_TOKEN ?= # token with package:write; pass via env/CLI, never commit GITEA_TOKEN ?= # token with package:write; pass via env/CLI, never commit
@@ -99,16 +99,16 @@ kill-daemon: ## stop a running spaceshd so a freshly-built one takes over
.PHONY: install .PHONY: install
install: kill-daemon ## install the native .app to /Applications, restart daemon, clear quarantine install: kill-daemon ## install the native .app to /Applications, restart daemon, clear quarantine
rm -rf /Applications/spacesh.app rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too
cp -R "$(NATIVE_APP_BUNDLE)" /Applications/ cp -R "$(NATIVE_APP_BUNDLE)" /Applications/
xattr -dr com.apple.quarantine /Applications/spacesh.app xattr -dr com.apple.quarantine /Applications/spaceshell.app
@echo "Installed (native). Quit & relaunch spacesh; the bundled daemon restarts." @echo "Installed (native). Quit & relaunch spaceshell; the bundled daemon restarts."
.PHONY: install-universal .PHONY: install-universal
install-universal: kill-daemon ## install the universal .app to /Applications install-universal: kill-daemon ## install the universal .app to /Applications
rm -rf /Applications/spacesh.app rm -rf /Applications/spacesh.app /Applications/spaceshell.app
cp -R "$(APP_BUNDLE)" /Applications/ cp -R "$(APP_BUNDLE)" /Applications/
xattr -dr com.apple.quarantine /Applications/spacesh.app xattr -dr com.apple.quarantine /Applications/spaceshell.app
.PHONY: reinstall .PHONY: reinstall
reinstall: app-bundle install ## fast self-update: build .app (no dmg), reinstall, restart daemon reinstall: app-bundle install ## fast self-update: build .app (no dmg), reinstall, restart daemon
@@ -162,10 +162,10 @@ _publish-dmg:
VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \ VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
DMG=$$(ls -t $(DMG_DIR)/*.dmg 2>/dev/null | head -1); \ DMG=$$(ls -t $(DMG_DIR)/*.dmg 2>/dev/null | head -1); \
if [ -z "$$DMG" ]; then echo "no .dmg in $(DMG_DIR) — run make dmg first"; exit 1; fi; \ if [ -z "$$DMG" ]; then echo "no .dmg in $(DMG_DIR) — run make dmg first"; exit 1; fi; \
URL="$(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic/$(GITEA_PKG)/$$VER/spacesh-$$VER.dmg"; \ URL="$(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic/$(GITEA_PKG)/$$VER/spaceshell-$$VER.dmg"; \
echo "Publishing $$DMG → $$URL"; \ echo "Publishing $$DMG → $$URL"; \
curl --fail-with-body -sS -H "Authorization: token $(GITEA_TOKEN)" --upload-file "$$DMG" "$$URL" && \ curl --fail-with-body -sS -H "Authorization: token $(GITEA_TOKEN)" --upload-file "$$DMG" "$$URL" && \
echo "Published spacesh-$$VER.dmg to Gitea Packages ($(GITEA_OWNER)/$(GITEA_PKG)@$$VER)" echo "Published spaceshell-$$VER.dmg to Gitea Packages ($(GITEA_OWNER)/$(GITEA_PKG)@$$VER)"
.PHONY: deploy-stack .PHONY: deploy-stack
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push) deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
+1 -1
View File
@@ -3440,7 +3440,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-proto" name = "spacesh-proto"
version = "0.1.7" version = "0.1.10"
dependencies = [ dependencies = [
"bytes", "bytes",
"serde", "serde",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh", "productName": "spaceshell",
"version": "0.1.7", "version": "0.1.10",
"identifier": "xyz.spacesh.app", "identifier": "xyz.spacesh.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -12,7 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "spacesh", "title": "spaceshell",
"width": 1100, "width": 1100,
"height": 720 "height": 720
} }
+23 -1
View File
@@ -4,6 +4,8 @@ import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar"; import { CenterToolbar } from "./CenterToolbar";
import { Wizard } from "./Wizard"; import { Wizard } from "./Wizard";
import { SurfacePicker } from "./SurfacePicker";
import { PRESETS } from "./PresetPicker";
import { ConfirmDelete } from "./ConfirmDelete"; import { ConfirmDelete } from "./ConfirmDelete";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { EventCenter } from "./EventCenter"; import { EventCenter } from "./EventCenter";
@@ -31,6 +33,8 @@ export function App() {
const [states, setStates] = useState<Record<string, SurfaceState>>({}); const [states, setStates] = useState<Record<string, SurfaceState>>({});
const [events, setEvents] = useState<EventRecord[]>([]); const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false); const [wizard, setWizard] = useState(false);
// Pending additive preset awaiting the per-panel "what to open" choice.
const [pendingPreset, setPendingPreset] = useState<{ id: string; delta: number; base: number } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null); const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true)); const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
@@ -183,7 +187,13 @@ export function App() {
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} /> <Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && ( {active && (
<CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} /> <CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => {
if (!active) return;
const target = PRESETS.find((x) => x.id === p)?.slots ?? leaves.length;
const delta = target - leaves.length;
if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels
setPendingPreset({ id: p, delta, base: leaves.length });
}} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
)} )}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}> <div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active {active
@@ -202,6 +212,18 @@ export function App() {
</div> </div>
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />} {settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} {wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
{pendingPreset && active && (
<SurfacePicker
count={pendingPreset.delta}
onCancel={() => setPendingPreset(null)}
onConfirm={(specs) => {
const padded = [...Array(pendingPreset.base).fill({}), ...specs]; // align to daemon's slots.get(existing.len()+j)
const wsId = active.id;
setPendingPreset(null);
void applyPreset(wsId, pendingPreset.id, padded).then(() => void refresh());
}}
/>
)}
{deleteTarget && ( {deleteTarget && (
<ConfirmDelete <ConfirmDelete
name={deleteTarget.name} name={deleteTarget.name}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { whichAgents } from "./socketBridge";
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
type SlotSpec = { command?: string; args?: string[] };
/**
* Asks what to open in each new panel before a preset spawns it: Terminal
* (shell), one of the installed CLIs (claude/codex/gemini/deepseek), or a
* custom command. `count` is the number of new panels the preset will add.
*/
export function SurfacePicker({ count, onConfirm, onCancel }: { count: number; onConfirm: (specs: SlotSpec[]) => void; onCancel: () => void }) {
const [installed, setInstalled] = useState<string[]>([]);
const [choices, setChoices] = useState<string[]>([]);
const [customCmds, setCustomCmds] = useState<string[]>([]);
const choiceList = [SHELL, ...installed, CUSTOM];
useEffect(() => { void whichAgents(KNOWN_AGENTS).then(setInstalled).catch(() => {}); }, []);
function confirm() {
const specs = Array.from({ length: count }, (_, i) => specForChoice(choices[i] ?? SHELL, customCmds[i] ?? ""));
onConfirm(specs);
}
function onKeyDown(e: React.KeyboardEvent) {
e.stopPropagation();
if (e.key === "Escape") { e.preventDefault(); onCancel(); }
else if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "SELECT") { e.preventDefault(); confirm(); }
}
return (
<div
onMouseDown={onCancel}
style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
style={{ width: 420, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}
>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>{count > 1 ? `Open ${count} new panels` : "Open new panel"}</div>
<div style={{ fontSize: 12, color: "#8B97A6", marginBottom: 16 }}>Choose what to run in each new panel.</div>
<div style={{ display: "grid", gridTemplateColumns: count > 1 ? "1fr 1fr" : "1fr", gap: 8, marginBottom: 20 }}>
{Array.from({ length: count }, (_, i) => {
const val = choices[i] ?? SHELL;
return (
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<select value={val} onChange={(e) => setChoices((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
{choiceList.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
</select>
{val === CUSTOM && (
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev" autoFocus
onChange={(e) => setCustomCmds((c) => { const n = [...c]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #4C8DFF", borderRadius: 6, fontFamily: "monospace", fontSize: 12 }} />
)}
</div>
);
})}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
<button onClick={confirm} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>
Open
</button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -138,7 +138,7 @@ export function TopBar({
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<FolderGit2 size={15} color={COLORS.textSecondary} /> <FolderGit2 size={15} color={COLORS.textSecondary} />
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> <span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{active?.name ?? "spacesh"} {active?.name ?? "spaceshell"}
</span> </span>
{active && ( {active && (
<> <>
+5 -16
View File
@@ -1,10 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PresetPicker, PRESETS } from "./PresetPicker"; import { PresetPicker, PRESETS } from "./PresetPicker";
import { openWorkspace, applyPreset, whichAgents } from "./socketBridge"; import { openWorkspace, applyPreset, whichAgents } from "./socketBridge";
import { KNOWN_AGENTS, SHELL, CUSTOM, agentLabel, specForChoice } from "./agents";
// Agents we know about; only the installed ones are offered (probed via whichAgents).
const KNOWN_AGENTS = ["claude", "codex", "gemini"];
const CUSTOM = "custom…";
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) { export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
const [path, setPath] = useState("."); const [path, setPath] = useState(".");
@@ -16,7 +13,7 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const pathRef = useRef<HTMLInputElement>(null); const pathRef = useRef<HTMLInputElement>(null);
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1; const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
const agentChoices = ["shell", ...installed, CUSTOM]; const agentChoices = [SHELL, ...installed, CUSTOM];
// Grab focus on open — otherwise keystrokes leak to the xterm panel behind us // Grab focus on open — otherwise keystrokes leak to the xterm panel behind us
// (its helper textarea sits at z-index 1000 and keeps the live focus). // (its helper textarea sits at z-index 1000 and keeps the live focus).
@@ -34,15 +31,7 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
setError(null); setError(null);
try { try {
const ws = await openWorkspace(path); const ws = await openWorkspace(path);
const slotSpecs = Array.from({ length: slots }, (_, i) => { const slotSpecs = Array.from({ length: slots }, (_, i) => specForChoice(agents[i] ?? SHELL, customCmds[i] ?? ""));
const a = agents[i] ?? "shell";
if (a === "shell") return {};
if (a === CUSTOM) {
const parts = (customCmds[i] ?? "").trim().split(/\s+/).filter(Boolean);
return parts.length ? { command: parts[0], args: parts.slice(1) } : {};
}
return { command: a };
});
await applyPreset(ws, preset, slotSpecs); await applyPreset(ws, preset, slotSpecs);
onDone(ws); onDone(ws);
} catch (e) { } catch (e) {
@@ -76,12 +65,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label> <label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
{Array.from({ length: slots }, (_, i) => { {Array.from({ length: slots }, (_, i) => {
const val = agents[i] ?? "shell"; const val = agents[i] ?? SHELL;
return ( return (
<div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}> <div key={i} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<select value={val} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })} <select value={val} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}> style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)} {agentChoices.map((c) => <option key={c} value={c}>{agentLabel(c)}</option>)}
</select> </select>
{val === CUSTOM && ( {val === CUSTOM && (
<input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev" <input value={customCmds[i] ?? ""} placeholder="e.g. npm run dev"
+22
View File
@@ -0,0 +1,22 @@
// Launchable agents/CLIs offered when opening a new panel. Only the installed
// ones are surfaced (probed via whichAgents); "shell" and "custom…" are always
// available. Keep this list as the single source of truth — Wizard and
// SurfacePicker both consume it.
export const KNOWN_AGENTS = ["claude", "codex", "gemini", "deepseek", "opencode"];
export const SHELL = "shell";
export const CUSTOM = "custom…";
/** Human label for an agent choice (the shell is presented as "Terminal"). */
export function agentLabel(choice: string): string {
return choice === SHELL ? "Terminal" : choice;
}
/** Map a picker choice (+ optional custom command line) to an applyPreset slot spec. */
export function specForChoice(choice: string, custom: string): { command?: string; args?: string[] } {
if (choice === SHELL) return {};
if (choice === CUSTOM) {
const parts = (custom ?? "").trim().split(/\s+/).filter(Boolean);
return parts.length ? { command: parts[0], args: parts.slice(1) } : {};
}
return { command: choice };
}
+9 -8
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>spacesh — терминал-воркспейс для AI-агентов на macOS</title> <title>spaceshell — терминал-воркспейс для AI-агентов на macOS</title>
<meta name="description" content="Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS."> <meta name="description" content="Запускай Claude Code, Codex, Gemini и shell параллельно. Фоновый демон держит сессии живыми: закрыл окно — агенты работают. Скачать для macOS.">
<link rel="canonical" href="https://spaceshell.ru"> <link rel="canonical" href="https://spaceshell.ru">
<meta property="og:title" content="spacesh — терминал-воркспейс для AI-агентов"> <meta property="og:title" content="spaceshell — терминал-воркспейс для AI-агентов">
<meta property="og:description" content="Десяток AI-агентов параллельно. Демон держит сессии живыми — закрой окно, агенты работают."> <meta property="og:description" content="Десяток AI-агентов параллельно. Демон держит сессии живыми — закрой окно, агенты работают.">
<meta property="og:url" content="https://spaceshell.ru"> <meta property="og:url" content="https://spaceshell.ru">
<meta property="og:image" content="https://spaceshell.ru/og.png"> <meta property="og:image" content="https://spaceshell.ru/og.png">
@@ -812,7 +812,7 @@
<div class="header-inner"> <div class="header-inner">
<a href="/" class="logo"> <a href="/" class="logo">
<span class="logo-icon">>_</span> <span class="logo-icon">>_</span>
spacesh spaceshell
</a> </a>
<nav class="nav"> <nav class="nav">
<a href="#features">Возможности</a> <a href="#features">Возможности</a>
@@ -836,7 +836,7 @@
Гоняй десяток AI-агентов параллельно. <span class="accent">Не теряй ни одного.</span> Гоняй десяток AI-агентов параллельно. <span class="accent">Не теряй ни одного.</span>
</h1> </h1>
<p class="hero-subtitle"> <p class="hero-subtitle">
spacesh держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне. spaceshell держит живые сессии Claude Code, Codex, Gemini и shell в фоновом демоне.
Закрыл окно, обновил приложение, словил краш — агенты продолжают работать. Закрыл окно, обновил приложение, словил краш — агенты продолжают работать.
</p> </p>
<div class="hero-buttons"> <div class="hero-buttons">
@@ -860,7 +860,7 @@
<span class="terminal-dot"></span> <span class="terminal-dot"></span>
<span class="terminal-dot"></span> <span class="terminal-dot"></span>
</div> </div>
<span class="terminal-title">spacesh — workspace</span> <span class="terminal-title">spaceshell — workspace</span>
</div> </div>
<div class="terminal-grid"> <div class="terminal-grid">
<div class="terminal-pane"> <div class="terminal-pane">
@@ -928,6 +928,7 @@
<span class="agent-tag">Codex</span> <span class="agent-tag">Codex</span>
<span class="agent-tag">Gemini</span> <span class="agent-tag">Gemini</span>
<span class="agent-tag">opencode</span> <span class="agent-tag">opencode</span>
<span class="agent-tag">deepseek</span>
<span class="agent-tag">shell</span> <span class="agent-tag">shell</span>
</div> </div>
</div> </div>
@@ -946,7 +947,7 @@
</div> </div>
<div class="solution-card"> <div class="solution-card">
<p class="card-label">Решение</p> <p class="card-label">Решение</p>
<h3 class="card-title">spacesh разрывает эту связь.</h3> <h3 class="card-title">spaceshell разрывает эту связь.</h3>
<p class="card-text"> <p class="card-text">
Сессиями владеет фоновый демон, а не окно. Интерфейс — всего лишь вид поверх него. Сессиями владеет фоновый демон, а не окно. Интерфейс — всего лишь вид поверх него.
</p> </p>
@@ -999,7 +1000,7 @@
<h3 class="feature-title">CLI как первый класс</h3> <h3 class="feature-title">CLI как первый класс</h3>
<p class="feature-text"> <p class="feature-text">
spacesh status --json, focus, new-surface, notify — те же команды, что и в интерфейсе, плюс shell-completions. spacesh status --json, focus, new-surface, notify — те же команды, что и в интерфейсе, плюс shell-completions.
Встраивай spacesh в свои пайплайны. Встраивай spaceshell в свои пайплайны.
</p> </p>
</div> </div>
<div class="feature-card reveal"> <div class="feature-card reveal">
@@ -1117,7 +1118,7 @@
<div class="footer-inner"> <div class="footer-inner">
<div class="footer-left"> <div class="footer-left">
<span class="footer-logo">spaceshell.ru</span> <span class="footer-logo">spaceshell.ru</span>
<span class="footer-copy">© 2026 spacesh</span> <span class="footer-copy">© 2026 spaceshell</span>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a> <a href="https://git.realmanual.ru/pub/spaceshell" target="_blank" rel="noopener">GitHub</a>