Merge #c1156d20: Terminal developer environment, resumable TUI, and Arc…

Terminal developer environment, resumable TUI, and Archipelago skills

nostr:nevent1qqsvz9tdypdv9cmtjmsz6azv8yujk0xezwv0j4wpa57qnk60nfyj28gpz3mhxue69uhhyetvv9ujumn8d96zuer9wcffu24e

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Adds the authenticated resumable terminal session backend, xterm-based terminal UI with bounded scrolling and app-style controls, tmux key translation, Codex/node bootstrap, preloaded Archipelago skills, developer app starter, and UAT runbook.

Validation: npm run build:production; cargo check -p archipelago; scripts/tests/archy-developer-setup-test.sh. Framework UAT health and unauthenticated terminal endpoint checks pass.
This commit is contained in:
archipelago
2026-10-09 15:08:54 -04:00
35 changed files with 2089 additions and 266 deletions
+49
View File
@@ -0,0 +1,49 @@
---
name: archipelago-app
description: Build, package, test, and update a manifest-driven Archipelago app using the real app developer contract, rootless runtime, and lifecycle acceptance flow.
metadata:
short-description: Build a real Archipelago app
---
# Archipelago app development
Use this skill when creating or changing an Archipelago app, manifest, container
build, app integration, credentials, signer flow, or app preview. Always load
`archipelago-design` for newly authored UI.
Read [app-contract.md](references/app-contract.md),
`docs/app-developer-guide.md`, and `docs/app-manifest-spec.md` before coding.
## Required loop
1. Create a dedicated worktree or project directory and choose the smallest
useful app scope. Prefer the maintained starter and its pinned dependencies.
2. Implement the app as a manifest, rootless container, persistent data path,
truthful health/readiness check, declared interface, and tests. Never add a
per-app Rust installer or rootful/Docker-socket shortcut.
3. Use generated secrets or declared secret files; never put credentials in a
manifest, image, logs, URL, or frontend bundle. Keep production wallets and
app databases outside development fixtures.
4. Validate the manifest and build context, then run the app through install,
start, stop, restart, manager restart, uninstall with data preservation, and
reinstall. Verify the real My Apps/Services launch path, not only a direct
port.
5. For UI, exercise standalone and embedded modes at 320, 390, 768, and 1440px
plus phone landscape, keyboard navigation, loading/empty/error/retry/success,
safe areas, and reduced motion.
6. Report source, disposable-node, actual-node, and release-artifact evidence
separately. Catalog publication is a separate request.
Important runtime facts:
- `/opt/archipelago/apps` is rebuilt from the runtime payload at backend start;
staging only there will be lost. Follow the developer guide's payload path.
- Signed catalog entries take precedence over disk manifests for catalog apps.
- Installed does not mean ready or launchable; use health and readiness evidence.
- App interfaces describe the service behind the gate. Do not hard-code a node
IP, scheme, app path, or host frame URL into app code.
## References
- [app contract and acceptance](references/app-contract.md)
- [design routing](../archipelago-design/SKILL.md)
@@ -0,0 +1,7 @@
interface:
display_name: "Archipelago app"
short_description: "Build a real Archipelago app"
brand_color: "#F7931A"
default_prompt: "Use $archipelago-app to build and validate this Archipelago app."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,17 @@
# App contract
Start with `docs/app-developer-guide.md` and `docs/app-manifest-spec.md`; those
files are authoritative for fields and launch behavior. Validate with:
```bash
./scripts/validate-app-manifest.sh apps/<id>/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
Use pinned image versions, read-only root, no-new-privileges, minimal
capabilities, rootless Podman, declared persistent data under
`/var/lib/archipelago/<id>`, health checks, generated/declared secrets, and
truthful interfaces. Test install/start/stop/restart/manager-restart/uninstall
with data preservation/reinstall on a disposable node. Do not replace the
signed catalog to make a local test app appear.
@@ -0,0 +1,41 @@
---
name: archipelago-design
description: Create or change Archipelago UI using the shared semantic tokens, components, responsive patterns, accessibility states, and embedded/standalone host contract.
metadata:
short-description: Keep Archipelago UI consistent
---
# Archipelago design system
Use this skill for any new or changed Archipelago UI, including app screens,
Terminal, setup flows, dialogs, dashboards, and app wrappers. Read
[design-contract.md](references/design-contract.md) before implementation.
## Rules
- Find and reuse the closest shared component and pattern before creating one.
- Use semantic `--archy-*` tokens and the pinned kit version. A bespoke color,
font, radius, shadow, or z-index needs a documented reason.
- Preserve the dark baseline, readable surfaces, 4px spacing rhythm, bottom
action placement, 44px touch targets, visible focus, and safe-area behavior.
- Keep terminal/editor/tmux keys inside the terminal focus boundary; generic
modal Escape/arrow handlers must not consume them.
- Implement loading, empty, offline, denied, validation-error, retry, disabled,
and confirmed-success states. Status color must have text or icon support.
- Test standalone and embedded modes. Embedded apps do not duplicate the host
wallpaper or navigation and must work without a host handshake.
## Workflow
1. Read the reference screen and source; choose the matching component/pattern.
2. Build with the shared kit and local assets, not runtime dashboard CSS or CDN
fonts. Keep host RPC, signer, and credential bridges behind typed adapters.
3. Render the gallery/preview at required viewports and inspect screenshots and
keyboard behavior. Check contrast on the real composited surface.
4. Record deliberate exceptions and update the kit only when a reusable need is
demonstrated. Do not silently accept visual-diff changes.
## References
- [design contract](references/design-contract.md)
- [existing component map](references/component-map.md)
@@ -0,0 +1,7 @@
interface:
display_name: "Archipelago design"
short_description: "Keep Archipelago UI consistent"
brand_color: "#F7931A"
default_prompt: "Use $archipelago-design to implement this UI in Archipelago's design system."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,15 @@
# Existing component map
Reuse these first:
- Structure: `BaseModal.vue`, `BackButton.vue`, `EmptyState.vue`,
`SkeletonCard.vue`.
- Controls: `ToggleSwitch.vue`, `PasswordRevealInput.vue`,
`AppSearchField.vue`, `CopyButton.vue`.
- Feedback: `ToastStack.vue`, `ContainerStatus.vue`, existing upload/progress
components.
- Completion: `PaymentSuccessPane.vue`, `IdentitySuccessPane.vue`.
- App flows: `AppLauncherOverlay.vue`, `AppCredentialInterstitial.vue`.
The terminal must have an explicit keyboard ownership boundary and must not be
wrapped in the generic arrow-key modal behavior without adapting it.
@@ -0,0 +1,18 @@
# Design contract
The current baseline is defined by `neode-ui/src/style.css`,
`neode-ui/tailwind.config.js`, `BaseModal.vue`, `AppSearchField.vue`,
`EmptyState.vue`, `SkeletonCard.vue`, and `PaymentSuccessPane.vue`. Preserve the
dark canvas, glass-card surface, 4px spacing scale, semantic orange accent,
local licensed fonts, bottom card actions, 40px desktop/52px mobile search,
44px touch targets, visible focus, dynamic viewport, safe-area and embedded
canvas rules while the shared kit is extracted.
Use semantic tokens, not raw values. New controls must document keyboard,
focus, disabled, loading, validation, error, retry, success, responsive, and
reduced-motion behavior. No essential action may be hover-only. Use the existing
dialog footer/content split and restore focus after close.
Visual acceptance covers 320/390/768/1440px, phone landscape, enlarged text,
standalone/embedded modes, dark native controls, no-blur fallback, and actual
Companion WebView behavior where applicable.
+53
View File
@@ -0,0 +1,53 @@
---
name: archipelago
description: Configure, troubleshoot, and safely modify an Archipelago node or its developer environment using the repository's typed operations, ownership rules, and recovery workflow.
metadata:
short-description: Work safely on Archipelago systems
---
# Archipelago system work
Use this skill for node configuration, terminal/developer setup, service
diagnosis, network and SSH work, supported app operations, and system changes.
For app implementation load `archipelago-app`; for any new or changed UI also
load `archipelago-design`.
Read [system-map.md](references/system-map.md) before acting. Read `AGENTS.md`
and the relevant project documentation in the current checkout.
## Workflow
1. Identify the node, repository/worktree, owner, and whether the request is
planning, source work, a disposable test, or a live-node operation.
2. Inspect current state before changing it. Prefer `archy`/Archipelago RPC
operations and existing scripts over direct edits or ad-hoc service commands.
3. Preserve wallets, app data, credentials, user uninstall decisions, and
unrelated services. Back up only the scoped state before a mutation.
4. Make the smallest reversible change. Keep generated state separate from
user overrides; never edit generated or packaged files when an owned source
or managed override exists.
5. Verify the actual result and report source tests, disposable integration,
live-node acceptance, and packaged-artifact checks separately.
For repository work, use a dedicated worktree and focused branch. For backend
unit tests use `scripts/test-backend-isolated.sh`; do not run unrestricted
`cargo test` on a node with installed apps. Do not publish OTA, ISO, catalog,
or Git mirrors from this skill unless that publication is explicitly requested
and every release gate is satisfied.
## Terminal and sessions
Treat terminal close as detach. Resume the existing named session; never create
a duplicate shell or replay a lost command. A reboot can restore workspace and
Codex conversation metadata but cannot restore the old process. Distinguish
running, detached, ended, and interrupted states in user-facing output.
Keep credentials, wallet material, terminal output, and command contents out of
diagnostics and support bundles. A terminal is a privileged capability: verify
the authenticated owner, origin, attachment grant, and account boundary before
any PTY bytes flow.
## References
- [system map and safe recipes](references/system-map.md)
- [app and design routing](references/routing.md)
@@ -0,0 +1,7 @@
interface:
display_name: "Archipelago system"
short_description: "Work safely on Archipelago systems"
brand_color: "#F7931A"
default_prompt: "Use $archipelago to inspect and safely change this Archipelago node."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,10 @@
# Skill routing
Load `archipelago-app` for manifests, containers, app previews, lifecycle,
credentials, signer integration, or app packaging. Load `archipelago-design` for
any newly authored or changed UI. For backend-only work, use only the system
skill and the relevant project docs.
Planning stays planning. A source change, node mutation, deployment, or
publication requires that explicit scope from the user. A skill supplies
workflow knowledge; it does not grant additional privileges.
@@ -0,0 +1,19 @@
# System map and safe recipes
The native backend is `core/archipelago`; the Vue dashboard is `neode-ui`; ISO
and first-boot material lives under `image-recipe` and `scripts`; app manifests
are under `apps/<id>/manifest.yml`. Read `CLAUDE.md`, `AGENTS.md`, and the current
release checklist before release work.
Use the existing typed RPC and orchestration layers for app lifecycle, network,
wallet, identity, and service operations. Inspect a matching handler before
adding an endpoint. Preserve the existing session cookie, CSRF, Origin, and
app-gate protections.
For source tests, run frontend checks from `neode-ui`. Backend unit tests run
only through `scripts/test-backend-isolated.sh`; live host checks must be named
and explicit. Do not touch real wallet/payment/channel state for a test fixture.
Developer changes belong in a dedicated worktree. Keep generated catalog,
runtime payload, release artifacts, and local node state separate until the
request explicitly includes packaging or deployment.
+20
View File
@@ -14,6 +14,7 @@ mod remote_input;
mod remote_relay;
mod rental_playback;
mod routstr_proxy;
mod terminal;
mod websocket;
use crate::api::rpc::RpcHandler;
@@ -426,6 +427,16 @@ impl ApiHandler {
.await;
}
// Owner terminal attachment — the browser socket is disposable; the
// authenticated tmux session survives reconnects and browser closes.
if method == Method::GET && path == "/ws/terminal" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_terminal_websocket(req).await;
}
// Remote input WebSocket — companion app sends keyboard/mouse events
if method == Method::GET && path == "/ws/remote-input" {
if !self.is_authenticated(req.headers()).await {
@@ -544,6 +555,15 @@ impl ApiHandler {
.unwrap())
}
(Method::GET, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
terminal::list_response().await
}
(Method::POST, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
terminal::create(&body_bytes).await
}
// Node message — P2P endpoint (authenticated by source validation, not cookie)
(Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await
@@ -0,0 +1,279 @@
//! Owner-authenticated terminal sessions backed by private tmux processes.
//!
//! Browser connections are disposable attachments. The tmux process and its
//! metadata remain on the node so a reconnect resumes the same workspace.
use anyhow::{anyhow, Result};
use futures_util::{SinkExt, StreamExt};
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tokio_tungstenite::tungstenite::Message;
fn tmux_key_for_input(data: &str) -> Option<&'static str> {
match data {
"\u{3}" => Some("C-c"),
"\u{4}" => Some("C-d"),
"\r" | "\n" => Some("Enter"),
"\u{7f}" => Some("BSpace"),
"\t" => Some("Tab"),
"\u{1b}[A" => Some("Up"),
"\u{1b}[B" => Some("Down"),
"\u{1b}[C" => Some("Right"),
"\u{1b}[D" => Some("Left"),
"\u{1b}[H" => Some("Home"),
"\u{1b}[F" => Some("End"),
"\u{1b}[3~" => Some("DC"),
_ => None,
}
}
use uuid::Uuid;
use super::{build_response, ApiHandler};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionRecord {
pub schema: u8,
pub id: String,
pub name: String,
pub workspace: String,
pub state: String,
pub tmux: String,
pub updated_at: i64,
}
#[derive(Debug, Deserialize)]
struct CreateRequest {
name: Option<String>,
workspace: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ClientMessage {
#[serde(rename = "type")]
kind: String,
data: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
}
pub(crate) fn state_dir() -> PathBuf {
std::env::var_os("ARCHY_SESSION_STATE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
PathBuf::from(xdg).join("archipelago/sessions")
} else if let Some(home) = std::env::var_os("HOME") {
let user_dir = PathBuf::from(home).join(".local/state/archipelago/sessions");
if user_dir.exists() {
user_dir
} else {
PathBuf::from("/var/lib/archipelago/sessions")
}
} else {
PathBuf::from("/var/lib/archipelago/sessions")
}
})
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
async fn read_record(dir: &Path, id: &str) -> Result<SessionRecord> {
if !valid_id(id) {
return Err(anyhow!("invalid session id"));
}
let bytes = tokio::fs::read(dir.join(format!("{id}.json"))).await?;
Ok(serde_json::from_slice(&bytes)?)
}
async fn tmux_alive(name: &str) -> bool {
Command::new("tmux")
.args(["has-session", "-t", name])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
}
async fn write_record(dir: &Path, record: &SessionRecord) -> Result<()> {
tokio::fs::create_dir_all(dir).await?;
let tmp = dir.join(format!(".{}.tmp-{}", record.id, Uuid::new_v4()));
let final_path = dir.join(format!("{}.json", record.id));
tokio::fs::write(&tmp, serde_json::to_vec_pretty(record)?).await?;
tokio::fs::rename(tmp, final_path).await?;
Ok(())
}
pub(crate) async fn list(dir: &Path) -> Result<Vec<SessionRecord>> {
let mut out = Vec::new();
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(error) => return Err(error.into()),
};
while let Some(entry) = entries.next_entry().await? {
if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = tokio::fs::read(entry.path()).await else {
continue;
};
let Ok(mut record) = serde_json::from_slice::<SessionRecord>(&bytes) else {
continue;
};
record.state = if tmux_alive(&record.tmux).await {
"detached".into()
} else if record.state == "running" {
"interrupted".into()
} else {
record.state.clone()
};
out.push(record);
}
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(out)
}
pub(crate) async fn list_response() -> Result<Response<hyper::Body>> {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(
&list(&state_dir()).await?,
)?))?)
}
pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
let request: CreateRequest = serde_json::from_slice(body).unwrap_or(CreateRequest {
name: None,
workspace: None,
});
let workspace_was_requested = request.workspace.is_some();
let workspace = request.workspace.unwrap_or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("Work")
.to_string_lossy()
.into_owned()
});
let workspace_path = PathBuf::from(&workspace);
if !workspace_was_requested {
tokio::fs::create_dir_all(&workspace_path).await?;
}
if !workspace_path.is_absolute() || !workspace_path.is_dir() || workspace_path == Path::new("/")
{
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"workspace must be an existing non-root directory"}"#),
));
}
let id = format!("s-{}", Uuid::new_v4().simple());
let tmux_name = format!("archy-{id}");
let output = Command::new("tmux")
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
.output()
.await?;
if !output.status.success() {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"error":"tmux could not start the session"}"#),
));
}
let name = request
.name
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| "Work".into());
let record = SessionRecord {
schema: 1,
id,
name,
workspace,
state: "running".into(),
tmux: tmux_name,
updated_at: chrono::Utc::now().timestamp(),
};
write_record(&state_dir(), &record).await?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(&record)?))?)
}
pub(crate) async fn websocket(req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let id = req
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find_map(|part| part.strip_prefix("session="))
})
.unwrap_or("")
.to_string();
let record = read_record(&state_dir(), &id).await?;
if !tmux_alive(&record.tmux).await {
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(r#"{"error":"session is not running"}"#),
));
}
let (response, ws_fut) =
hyper_ws_listener::create_ws(req).map_err(|e| anyhow!("WebSocket upgrade failed: {e}"))?;
if let Some(ws_fut) = ws_fut {
tokio::spawn(async move {
let Ok(Ok(stream)) = ws_fut.await else { return };
let (mut tx, mut rx) = stream.split();
let mut interval = tokio::time::interval(std::time::Duration::from_millis(150));
let mut last = String::new();
loop {
tokio::select! {
_ = interval.tick() => {
// Capture the visible pane only. Asking tmux for a large
// historical range injects hundreds of blank rows into
// xterm on every reconnect, producing a misleading
// giant initial scrollbar. xterm owns live scrollback.
let output = Command::new("tmux").args(["capture-pane", "-p", "-e", "-t", &record.tmux]).output().await;
if let Ok(output) = output {
let text = String::from_utf8_lossy(&output.stdout).into_owned();
if text != last { last = text.clone(); if tx.send(Message::Text(serde_json::json!({"type":"output", "data":text}).to_string())).await.is_err() { break; } }
} else { break; }
}
message = rx.next() => match message {
Some(Ok(Message::Text(text))) => {
let Ok(message) = serde_json::from_str::<ClientMessage>(&text) else { continue };
match message.kind.as_str() {
"input" => if let Some(data) = message.data { let mut command = Command::new("tmux"); command.args(["send-keys", "-t", &record.tmux]); if let Some(key) = tmux_key_for_input(&data) { command.arg(key); } else { command.args(["-l", "--", &data]); } let _ = command.output().await; },
"resize" => if let (Some(cols), Some(rows)) = (message.cols, message.rows) { let _ = Command::new("tmux").args(["resize-window", "-t", &record.tmux, "-x", &cols.to_string(), "-y", &rows.to_string()]).output().await; },
"ping" => { let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.into())).await; },
_ => {}
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break,
_ => {}
}
}
}
});
}
Ok(response)
}
impl ApiHandler {
pub(super) async fn handle_terminal_websocket(
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
websocket(req).await
}
}
@@ -0,0 +1,268 @@
# Archipelago design system for agent-built apps
Status: planning draft, 2026-10-09. Companion to the
[terminal and developer environment plan](terminal-developer-environment-spec.md).
No tokens, components, templates or installed skills are changed by this draft.
An agent asked to build an Archipelago app should produce an app that belongs in
Archipelago by construction. Give it a maintained component kit, runnable
starters, documented screen patterns and visual references. A skill telling it
to “use glassmorphism and orange” is insufficient.
The same foundation should drive Terminal, setup screens, first-party apps and
newly generated apps. Existing third-party applications keep their own product UI;
their Archipelago entry, launch and integration surfaces follow this contract.
## Evidence and design authority
At repository baseline `2cb1bae5`, the dashboard's design is distributed across
`neode-ui/src/style.css`, `neode-ui/tailwind.config.js`, Vue components, and the
short standards section of `docs/developer-guide.md`. AIUI maintains a separate
token/style definition in `aiui/packages/app/src/styles/main.css`.
| Existing rule | Source observation | Contract to preserve or resolve |
| --- | --- | --- |
| Dark controls | Dashboard sets `color-scheme: dark` and explicit select colors | Dark baseline, including native controls |
| Glass cards | `.glass-card`: black at 0.65 alpha, white border at 0.18, 16px radius | Preserve the established surface, not a generic frosted card |
| Blur | Default card blur 18px; dashboard contexts deliberately suppress backdrop blur to avoid rendering corruption | Context-aware surface variants; never reintroduce blanket blur |
| Buttons | `.glass-button`: 44px minimum, 12px radius; established focus/hover/pressed variants | Reusable buttons with full interaction states |
| Accent | Dashboard focus/action uses `#fb923c`; AIUI accent is `#F7931A` | Named semantic tokens with explicit mapping; do not silently choose one for everything |
| Typography | Dashboard body uses Avenir Next/system fallback, headings have bundled Montserrat; AIUI uses Inter/system fallback | Approved roles, metrics, fallback behavior and licensed assets |
| Spacing | Tailwind extends a 4px grid | Shared spacing scale and layout examples |
| Search | Existing search is 40px on desktop, 52px below the 920px breakpoint; right clear control retains focus | Shared search component and documented responsive behavior |
| Card actions | Existing CSS places full-width actions at the bottom on desktop and mobile | Preserve this layout in generated card screens |
| Modal layout | `BaseModal.vue`: pinned title/footer with independently scrolling content | Shared dialog shell with tested focus and Back behavior |
| Mobile layout | Dynamic viewport, safe-area and audio-player offsets already exist | One documented inset contract; prevent double padding in embedded apps |
| Success | Shared `PaymentSuccessPane.vue` and `IdentitySuccessPane.vue` express branded completion | Reuse the relevant pattern only when its completion condition is actually met |
| Embedded canvas | AIUI embedded mode is transparent; standalone has its own background | Host owns wallpaper; apps choose explicit embedded/standalone mode |
These observations seed a visual audit; they do not make every legacy style a
rule. Capture representative current screens in the running supported UI before
extracting components. Approve Home, Apps, app detail, Settings, forms/dialogs,
list/detail screens, Terminal and mobile examples as reference baselines.
The source of truth becomes versioned tokens plus components and pattern docs.
Screenshots illustrate that contract and detect drift; they do not replace it.
Existing dashboard and AIUI differences need explicit migration decisions.
Preserve the current look first and make intentional design improvements visible
in review rather than introducing them incidentally through an app template.
## Deliverable shape
Proposed layout, to be finalized against repository packaging conventions:
```text
packages/archipelago-design/
tokens/ semantic definitions and generated CSS/JSON
styles/ scoped foundations and non-Vue component classes
assets/ approved fonts, icons and licenses
packages/archipelago-ui/
components/ Vue components with documented states
patterns/ app shells and composed screen patterns
docs/design-system/
index.md decision guide and version compatibility
foundations.md typography, color, surfaces, spacing, motion
components.md usage, states, accessibility, stable imports
patterns.md complete screen composition and behavior
examples/ reference captures and their matching source
examples/archipelago-app/
... working starter with manifest and tests
skills/archipelago-design/
SKILL.md concise trigger, routing and workflow
references/ versioned design index and evaluation rubric
```
Build an offline component gallery with the UI kit. Every example links to its
source, token usage and applicable app pattern. A developer can run the gallery
without a node session or provider API key. It must show loading, empty, error,
success, disabled, focused, hovered and pressed states, not only the ideal screen.
Publish packages only as part of an explicitly authorized later implementation
workflow. Until then these names and paths describe proposed deliverables.
## Token contract
Use framework-neutral CSS custom properties generated from a single definition.
Vue components and any Tailwind adapters consume the same values. Prefix tokens
with `--archy-`; do not expose arbitrary dashboard-global selectors to apps.
| Token family | Required coverage |
| --- | --- |
| Color | Canvas, card, inset, overlay, border, text primary/secondary/muted, interactive accent, focus, selected, success, warning, danger and info |
| Typography | Body, heading and monospace families; type scale, weights, line heights and numeric alignment |
| Space and size | 4px-based scale, page gutters, content widths, control heights, touch targets and icon sizes |
| Shape | Card, dialog, input, button and pill radii |
| Elevation | Card/overlay shadows, border strengths, blur-permitted and no-blur surfaces |
| Motion | Durations/easing, permitted hover/press transitions and reduced-motion equivalents |
| Layering | Header, navigation, menu, popover, modal and toast layers with ownership rules |
| Viewport | Host-provided safe areas, keyboard viewport, navigation/audio offsets and embedded mode |
| Terminal | Canvas, foreground, cursor, selection and a readable 16-color ANSI palette |
Make brand orange and interactive/focus orange distinct roles if preserving both
existing values. Status colors must communicate meaning alongside text/icons.
Name action variants by purpose: the existing orange “warning” class also serves
primary actions, so exporting that name unchanged would teach the wrong semantics.
Typography must have predictable dimensions on Debian, Android and desktop
browsers. Use only redistributable local font assets. Referencing Avenir or Courier
New in a fallback stack does not license bundling those fonts. Freeze a tested
body/heading/mono choice and line-height matrix in the visual review; do not let
each generated app choose its own fonts or fetch a font CDN.
Check contrast on actual composited surfaces and the brightest/darkest supported
backgrounds. Opacity values or CSS comments alone do not establish accessibility.
Small text must remain readable, and focus must remain visible where current
global styles suppress outlines. Low-power/no-backdrop-filter fallbacks retain
the same hierarchy and usable contrast.
## Components and patterns agents can reuse
Extract reusable behavior from existing code; avoid giving agents a second
lookalike library with slightly different interactions. Package components
without dashboard stores, router assumptions, privileged RPC clients or secrets.
Use adapters for integrations owned by the host.
| Foundation | First required components | Existing reference |
| --- | --- | --- |
| Structure | AppShell, PageHeader, Section, Card, InsetPanel, ActionRow | Dashboard CSS, app headers, card action rules |
| Controls | Button, IconButton, TextField, PasswordField, Select, Toggle, SearchField, SegmentedControl | `ToggleSwitch.vue`, `PasswordRevealInput.vue`, `AppSearchField.vue` |
| Navigation | Tabs, Breadcrumbs, Back action, contextual menu | Dashboard navigation, `BackButton.vue`, Cloud menus |
| Feedback | StatusBadge, EmptyState, Skeleton, Progress, InlineError, Toast | `EmptyState.vue`, `SkeletonCard.vue`, `ToastStack.vue`, existing progress patterns |
| Dialogs | Dialog, ConfirmDialog, credential handoff | `BaseModal.vue`, `AppConfirmModal.vue`, `AppCredentialInterstitial.vue` |
| Data | ListRow, responsive table/list, detail key/value row, copy action | Existing app lists, `CopyButton.vue` |
| Terminal | TerminalShell, SessionList, SessionRow, ConnectionStatus, mobile key strip | New consumers of the same foundation |
Each public component documents supported props/events/slots, sizing, keyboard
behavior, accessibility, states and examples. Shared helpers must respect context:
the current modal helper captures Escape and arrow keys, which is unsuitable
for a terminal, editor or other widget that owns those keys. Fix/reuse that
behavior through explicit contracts rather than blindly wrapping Terminal in it.
Provide composed patterns for:
- A searchable collection with filters, list/grid view, empty state and bottom
card actions.
- A detail screen with back navigation, metadata, status and primary action.
- Settings grouped by task, with visible validation, save progress and retry.
- A multi-step setup flow with skip/resume, honest progress and recovery.
- First launch and credential handoff, including apps with their own login.
- A destructive confirmation that names its target and data-preservation effect.
- A session picker and terminal that clearly distinguish Close from End session.
Copywriting is part of the contract: concise task labels, useful error recovery,
explicit pending versus complete states, no raw internal exception as the sole
user message, and appropriate units/time formatting. Technical detail is
available on demand. App-generated progress must reflect actual work.
## App shells and host integration
Supply a preferred Vue/TypeScript starter and a small portable HTML/CSS example.
Existing React or other framework applications consume the tokens/styles and
behavior contracts; do not require a framework rewrite to package an app.
Full additional framework bindings follow actual demand and have their own
acceptance, rather than presenting CSS classes as equivalent accessible widgets.
New starters include local assets, locked dependencies, scripts, manifest,
container definition, health check, persistent-data example, tests and a complete
reference screen. A starter must build and run from its copied location without
monorepo-only aliases. It cannot require fetching current dashboard CSS at runtime.
The host integration contract defines:
- Explicit standalone versus embedded appearance. Embedded apps use a transparent
canvas where appropriate; they do not add a duplicate wallpaper or navigation
shell. Standalone mode still has a complete, readable canvas.
- Versioned, non-secret theme/inset messages if runtime synchronization is needed.
Validate sender origin, source window and payload; no wildcard privileged
message bridge. An app must remain usable with no host handshake.
- Exactly one owner for top/bottom safe-area padding and fixed player/navigation
clearance. Test the actual Companion WebView, not just a mobile screenshot.
- Existing launch interfaces and app-gate behavior from the developer docs.
Asset URLs work under their supported base path and HTTP/HTTPS entry points.
- Nostr signer, media controls and credential handoff only through their supported
APIs. Visual kit installation does not give an app host management access.
Package tokens/components with the app's pinned kit version. Evolve them with
documented compatibility and migrations. Runtime theme values may be forwarded
through the agreed contract; runtime JavaScript/CSS replacement is not the update
mechanism. A platform update must not silently break every installed app.
## Agent workflow
The app skill always routes new UI work through `archipelago-design`. The system
skill does the same when a system modification affects a visible screen. A
backend-only request loads no unnecessary design material.
For relevant tasks the agent:
1. Reads the design index, supported kit version and matching pattern; identifies
existing components before creating new ones.
2. Inspects the reference screen and its source, then selects the closest starter.
3. Implements the requested behavior using shared components and semantic tokens.
4. Exercises normal, loading, empty, invalid, failed and successful states.
5. Renders standalone and embedded previews at the required viewports; inspects
captures, keyboard behavior and actual component geometry.
6. Runs app checks and supplies the working preview, component/token provenance,
results and any deliberate design exceptions.
Make that loop easy: `archy app new`, `archy app preview` and `archy app check`
should supply the starter, preview fixtures and relevant checks. Those are
proposed tools in the terminal plan, not currently implemented commands.
Ordinary changes within the requested task proceed through this loop without
requiring approval for every edit. A new shared design primitive or intentional
departure is identified in the review, with its use case and visual evidence.
The skill should tell agents how to find and use the design system, not make
them recreate it from a long list of adjectives. Keep the entrypoint concise;
conditional references cover forms, layout, terminal interaction, media and
signer flows. Templates and screenshots belong in reusable assets, not prose
that the agent has to transcribe.
## Design conformance and definition of done
Combine targeted static checks, interaction tests and visual review. Static
checks cannot prove a coherent design; screenshots cannot prove an accessible
or correct interaction.
| Gate | Acceptance |
| --- | --- |
| Shared foundation | New UI imports the approved kit/version; bespoke color/font/radius/z-index values need a named exception, with allowances for content such as charts and user imagery |
| Visual hierarchy | Reference-matched page width/gutters, type roles, card surfaces, action placement and density |
| Responsive layout | 320, 390, 768 and 1440px widths plus phone landscape; 200% text zoom; no accidental horizontal page overflow |
| Input | Keyboard-only use, visible focus, sensible tab order, labels, dialog focus containment/restoration and Back behavior; terminal keys remain intact |
| Touch | 44px usable targets for new mobile controls, reachable persistent actions, no essential hover-only interaction |
| States | Loading, empty, offline, denied, validation error, retry and success demonstrated with deterministic fixtures |
| Appearance | Dark native controls, foreground/background contrast, reduced motion, no-blur fallback and locally available fonts |
| Host modes | Embedded/standalone, HTTP/HTTPS where supported, software keyboard, safe areas, audio-player offsets and Companion |
| Runtime truth | Success and readiness only follow actual confirmed results; theme/state changes do not erase form or session work |
| Regression | Shared component changes compared against representative dashboard and app reference captures before release |
Choose per-component visual-diff tolerances after baseline capture; avoid a
single permissive threshold that conceals layout regressions. Stabilize fonts,
viewport, fixture data and animations. Review intended baseline changes rather
than automatically accepting new screenshots when CI fails.
Behavioral skill evaluation should use realistic tasks with no hidden design
brief: build a bookmarks app, add a settings form, create an app with first-run
credentials, adapt a non-Vue app, and improve Terminal's session picker. Inspect
the generated artifacts and previews for component reuse, consistent appearance,
correct manifest integration and usable failure states. Repeat on a fresh
environment to ensure success does not rely on this checkout or an agent's
conversation history.
## Implementation order
1. Audit representative existing screens; resolve accent/typography/blur and
embedded-mode decisions; record the approved reference set.
2. Extract semantic tokens and the first components while preserving current
dashboard rendering. Migrate a small representative slice to prove parity.
3. Build the offline gallery, app shell and working starter. Use Terminal/setup
as real consumers so the foundation is exercised immediately.
4. Package the app/design skills with matching docs and assets; run realistic
generation tasks against the starter and gallery.
5. Add scoped conformance checks to app validation and qualification; migrate
additional first-party surfaces incrementally.
App generation cannot be called complete while agents still invent the visual
foundation. Completion means a new agent, a new workspace and an ordinary app
request reliably produce a working, recognizably Archipelago result.
+539
View File
@@ -0,0 +1,539 @@
# Archipelago terminal and developer environment plan
Status: execution draft, 2026-10-09. The local persistent-session primitive is
implemented in `scripts/archy-session`; dashboard/WebSocket attachment,
developer-account provisioning, and Codex installation remain follow-on slices.
Repository baseline: `2cb1bae5ce244d387679f90951d62fd030ebf228`.
Archipelago should let an owner open a real terminal, resume previous work,
configure their system, and ask a preinstalled coding agent to build an app that
looks and behaves like Archipelago. The experience must work from the dashboard,
local console, and SSH, with the same tools and discoverable commands.
The user requirements are:
- Bring Omarchy's initial setup and development capabilities to Archipelago.
- Ship Codex ready to launch, with guided personal authentication.
- Make closing and resuming terminal sessions easy and reliable.
- Ship local skills that help agents modify the system and build apps using the
actual developer documentation.
- Make visual and interaction consistency an enforced part of app creation.
- Plan in a separate worktree, without conflicting with current release work.
The companion [agent design system plan](archipelago-agent-design-system-spec.md)
defines the shared UI kit, agent workflow, app templates, and visual acceptance.
That work is a dependency of the app-building experience, not finishing polish.
## Product outcome
On a newly installed node, Terminal opens to a usable shell with a small welcome
panel offering Setup, Resume, Build an app, Work on Archipelago, and Help. The
shell is immediately usable; onboarding is dismissible and resumable. Existing
owners get the same environment through an upgrade that preserves their files,
configuration, credentials, and uninstall decisions.
A representative first session:
1. Open Terminal from the dashboard or launch it locally.
2. See the node, workspace, account, and any existing sessions clearly identified.
3. Run the setup guide, select editor/toolchains, and sign in to Codex.
4. Choose Build an app and describe the app. The agent loads Archipelago's app
and design guidance and starts from the maintained starter.
5. Review a working local preview, including mobile and failure states.
6. Close Terminal while a build or agent task runs.
7. Reopen it, select the named session, and continue where it was left.
8. Install the candidate through the existing app lifecycle on a chosen test
node, then contribute through ngit when publication is requested.
## Current implementation and gaps
These are source findings, not live-node acceptance results.
| Surface | Current source | Required change |
| --- | --- | --- |
| Dashboard terminal | `neode-ui/src/components/CLIPopup.vue`: development-only simulated commands; production SSH instructions and a literal default password | Real terminal and session picker; connection information derived from actual setup state |
| Terminal state | `neode-ui/src/stores/cli.ts`: open/close boolean | Server-owned session inventory; client UI state independent of process lifetime |
| Keyboard | `neode-ui/src/App.vue`: global F and other shortcuts; `useModalKeyboard.ts` captures arrows and Escape | A terminal focus boundary so shell, editor and agent keys reach the PTY |
| Console setup | `image-recipe/archipelago-scripts/archipelago-menu.sh`: legacy menu, eager installer-tool installation, direct container setup | Shared command catalog and current orchestration; opening a menu does not install software |
| Shell onboarding | `scripts/welcome-banner.sh`: banner and SSH information, including literal default password; may wait for networking | Short local welcome, accurate access state, fast offline shell startup |
| ISO | `image-recipe/build-debian-iso.sh` invokes a relocated copy of `_archived/build-auto-installer-iso.sh` | Integrate the actual active build path, despite its archived filename |
| Upgrade | `scripts/self-update.sh`, `core/archipelago/src/bootstrap.rs`, runtime assets | One versioned environment payload and repeatable migration shared with ISO |
| Existing development access | Pinned ngit installer and GitWorkshop app already exist | Reuse these; keep ngit the contribution platform |
| App packaging | `docs/app-developer-guide.md`, manifest spec and Rust parser | Turn existing contracts into agent workflows, validated starters and development commands |
| Design | Dashboard CSS/Tailwind, reusable Vue components, separate AIUI CSS | Extract and document a coherent shared contract; see companion plan |
The backend uses Hyper/Tokio and existing WebSocket handlers; the developer guide
currently calls it Axum. Several contributor documents also show unrestricted
`cargo test`, conflicting with `AGENTS.md`. Correct those documentation examples
before packaging them as agent instructions. On a live node, backend execution
must use `scripts/test-backend-isolated.sh`.
## Omarchy reference and capability mapping
Research baseline: the official [Omarchy repository](https://github.com/omacom/omarchy),
formerly reached through `basecamp/omarchy`. Its default branch was `quattro` at
[`fcf9eeb5c454739f3f23cdfd7d57d77b12961025`](https://github.com/omacom/omarchy/tree/fcf9eeb5c454739f3f23cdfd7d57d77b12961025).
The latest published release returned during research was
[v4.0.4](https://github.com/omacom/omarchy/releases/tag/v4.0.4), published September 15,
2026, resolving to `c668141e9c42b13c80c9ca4ea108e11708c5e8a5`.
The inventory below describes the pinned default-branch source; it does not
assert that every item is present in that published release.
“All setup capabilities” means explicit disposition of the setup surface, not
silently omitting desktop-specific features. The proposed core release covers
the terminal, system setup and development rows below. Hardware/desktop options
remain named follow-on work; completion of the core is not full Omarchy parity.
| Omarchy capability | Proposed Archipelago equivalent | Delivery |
| --- | --- | --- |
| Owner, keyboard, hostname, timezone, optional Git name/email | Resume existing node onboarding; configure developer identity separately from appliance identity | Core |
| Deferred provisioning for another owner | Leave personal developer credentials unset; first owner completes setup | Core |
| Separate packaged defaults, user finalization and migrations | Versioned system payload, per-user setup receipts, explicit reset with backup | Core |
| First-login welcome, shortcuts, network/update guidance | Welcome and searchable help shared by terminal and dashboard | Core |
| Bash completion, history, prompt, fuzzy finding and directory navigation | Bash, completion, Starship, fzf and zoxide with readable console fallback | Core |
| File/search/system tools | ripgrep, fd, bat, eza, jq, less, man, tldr, btop and fastfetch | Core |
| Terminal selection: Foot, Alacritty, Ghostty, Kitty | Browser terminal plus console/SSH; local emulator adapters compatible with the actual kiosk display stack | Browser/console core; native choices follow-on |
| tmux sessions, panes, developer layouts | Named persistent sessions and editor/agent/shell layouts; same sessions accessible by SSH | Core |
| Neovim and selectable editors | nano available immediately; maintained Neovim profile and editor preference; GUI editors when desktop support exists | Core terminal editors; GUI follow-on |
| Mise development environments | Versioned Node/npm, Rust, Python/uv profiles first; project-local versions | Core |
| Ruby/Rails, Bun, Deno, Go, PHP/Laravel/Symfony, Elixir/Phoenix, Java, Zig, OCaml, .NET, Clojure, Scala | Optional profiles in the same installer catalog, each with architecture and verification metadata | Parity follow-on |
| Git, lazygit, GitHub CLI | Git/lazygit and ngit/GitWorkshop first-class; gh optional for other upstream projects | Core |
| Lazy agent launchers | Codex actually bundled; optional adapters for other agent CLIs | Core Codex; provider expansion follow-on |
| Default-agent selector and starter prompts | Codex selected initially for a fresh setup; preserve existing preference; system/app/design entry prompts | Core |
| Agent account selection and usage panel | Explicit account context and supported login status; manual account profiles before considering usage automation | Follow-on |
| Local model tools | Integrate the existing Ollama app and compatible provider setup; downloads and model resource needs visible | Follow-on |
| Bundled system and app-building skills | Archipelago system, app and design skills with offline references and templates | Core |
| Development databases: MySQL, PostgreSQL, Redis, MongoDB, MariaDB, MSSQL | Scoped rootless Podman development recipes, isolated ports/data and generated credentials | Core PostgreSQL/Redis-compatible recipe; remaining recipes follow-on |
| Container tooling | Existing rootless Podman; documented Compose compatibility where tested | Core |
| DNS, Wi-Fi, network QR, SSH daemon and SSH agent setup | Existing network/SSH controls exposed through shared setup operations, with accurate connection details | Core |
| Fingerprint, FIDO2 and privilege preferences | Hardware-aware account-security setup; no copied Arch PAM configuration | Follow-on |
| Monitors, keyboard bindings, input, XCompose | Kiosk/console equivalents through existing system configuration; desktop-specific adapters separately | Core console basics; desktop follow-on |
| Browser, terminal, editor and dictation defaults | Editor/agent/terminal choices first; browser/dictation surfaced when relevant to the device | Core subset; follow-on adapters |
| Themes, fonts, background and prompt | Shared Archipelago tokens and terminal palette; preserve owner customization | Core |
| Shell plugins and customization hooks | Versioned extension points and documented user overrides; dashboard extensions require a separate supported contract | User overrides core; plugins follow-on |
| Package, TUI, web-app and development installation menus | Curated tool catalog plus existing Archipelago app catalog; distinguish developer tools from managed apps | Core |
| Commercial services, GUI apps, gaming, Windows VM | Individual optional app/desktop integrations, inventoried as a separate parity backlog | Follow-on, not preinstalled on nodes |
| Update, reset, snapshots, direct boot | Integrate Archipelago update/recovery choices; explicit reset scope and backup | Core existing operations; new boot/snapshot features follow-on |
| Hardware detection and vendor fixes | Existing Archipelago hardware configuration, capability detection and separately qualified device fixes | Core detection; device adapters follow-on |
| Crash diagnosis skill | Sanitized diagnostics with an explicit handoff to the chosen agent; preserve source versus live evidence | Follow-on |
Primary source routes for the inventory:
- [Provisioning and file layout](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/docs/file-layout.md),
[setup form](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/install/provisioning/setup-form.sh),
[setup and installation menu](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/default/omarchy/omarchy-menu.jsonc).
- [Base packages](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/install/omarchy-base.packages),
[shell initialization](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/default/bash/init),
[terminal and tmux](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/manual/15-terminal.md).
- [Development profiles](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/bin/omarchy-install-dev-env),
[agent setup](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/install/user/mise.sh),
[agent launcher implementation](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/bin/omarchy-mise-install),
[development databases](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/bin/omarchy-install-docker-dbs).
- [System skill](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/default/agents/skills/omarchy/SKILL.md)
and [app skill](https://github.com/omacom/omarchy/blob/fcf9eeb5c454739f3f23cdfd7d57d77b12961025/default/agents/skills/omarchy-app/SKILL.md).
Adapt the workflows to Debian and Archipelago's manifest-driven runtime. In
particular, Omarchy's app skill produces Qt desktop apps; Archipelago's starter
must produce an Archipelago app. Omarchy's agent launchers download tools on use;
the user's requirement here is stronger: Codex must already be installed. Its
database recipes and permission-changing aliases are not defaults to transplant.
Any reused upstream files need their original license notices and provenance.
## Terminal experience
Use a real PTY with a bundled terminal renderer, proposed `@xterm/xterm` with
fit/search support. It must run Bash, nano/Neovim, lazygit, tmux and Codex without
special simulated command handling. Exact dependency versions are selected and
locked during implementation qualification.
The dashboard entry opens a large resizable terminal on desktop and a full-height
surface on mobile. It provides session name, workspace/node context, connection
status, session switcher, New, Resume, Search, copy/paste, font size, fullscreen,
and Close. End session belongs in a separate menu with a clear running-work warning.
Mobile adds an Esc/Ctrl/Tab/arrows key strip and works with the software keyboard.
Use the shared Archipelago shell/components around an opaque, readable terminal
canvas. Terminal content uses a packaged monospace font with Unicode support;
the console gets an ASCII-compatible fallback. Apply the design plan's focus,
contrast, reduced-motion and safe-area rules.
While terminal input has focus, F, Escape, arrows, Ctrl+C, Ctrl+D, Ctrl+R, Tab,
editor keys and tmux prefixes belong to the terminal. Closing uses a visible
control or a documented terminal-specific shortcut. Do not reuse the current
modal keyboard handler unchanged. Ctrl+C interrupts the foreground job; Ctrl+D
has normal shell semantics. Focus can move to toolbar controls and back.
## Persistent sessions and one-click resume
### Implemented local primitive
`scripts/archy-session` provides the first execution slice for named sessions.
It stores atomic JSON metadata under the user state directory and uses one
isolated tmux session per workspace. `list` reconciles metadata with the live
tmux process, `attach` resumes an existing process without creating a duplicate,
`rename` preserves the workspace, and `end` is the only operation that stops a
session. Its lifecycle test is `scripts/tests/archy-session-test.sh`.
This helper is intentionally not the browser security boundary: the future
session service must enforce owner/node/boot identity, attachment grants, CSRF,
and revocation before exposing it through the dashboard.
This is a core release criterion, including the first usable terminal milestone.
Session execution lives on the node in a dedicated, supervised user environment.
Use tmux as the initial persistence engine; a browser WebSocket is an attachment,
not the owner of the shell process. Keep the tmux server and worker scope outside
the dashboard/backend service's kill group. Ordinary manager deployment must not
tear down developer sessions.
Prefer one tmux server/socket and supervised process scope per managed session,
so ending one session cannot kill a shared server containing other work. Keep
those sockets private to the developer account. SSH and local-console resume use
the same session registry and attachment helper rather than guessing tmux names.
Cross-device resume refers to browsers connected to the same node; moving live
processes between different nodes is outside this contract.
| Event | Required behavior |
| --- | --- |
| Close terminal panel, navigate away, close tab, kill browser | Detach; shell, build and agent continue |
| Reopen Terminal | Show running and recent sessions; Resume most recent available in one action; do not auto-create duplicates |
| Switch session | Detach the old view and attach the selected session |
| Network drop or mobile sleep | Show Reconnecting; reauthenticate if needed, then attach to the existing session |
| Dashboard reload or backend restart | Session survives; rediscover it from node state |
| Second browser/device | Same authenticated owner sees sessions; explicit transfer of input control |
| Logout/session expiry | Revoke browser attachments immediately; background work remains; a fresh owner login is required to reattach |
| Explicit End session | Confirm when work is active, terminate that session's process scope, record ended state; do not remove its files |
| Shell exits | Show ended status and exit information where available; offer a new shell in that workspace |
| Broker crash | tmux/process scope survives if its supervisor survives; reconcile inventory, without replaying input |
| Node reboot/power failure | Processes stop. Keep workspace/session metadata and offer Reopen workspace and Resume Codex conversation; never label this a live process resume |
The picker shows a user-editable name, workspace, creation/last-attachment time,
Running/Detached/Ended/Interrupted state, and optional pinned status. Scope every
entry to a node and stable owner identity. LocalStorage may remember selection;
it is never the authoritative session registry.
Persist metadata atomically under the workspace account's private state directory.
Record schema version, opaque session ID, owner ID, node ID, boot ID, tmux target,
workspace ID/path, created/attached timestamps and lifecycle state. Treat tmux and
supervised process state as authority for whether a session is still alive.
After a crash, reconcile orphan sessions and interrupted creates before accepting
another create with the same request ID.
Place workspaces and agent state on durable storage with an explicit ownership,
quota and backup policy; do not consume a small system partition accidentally.
Expose a familiar `~/Work` entrypoint while recording the actual workspace root.
Reconnecting never requires a fresh Git checkout. A missing or unmounted workspace
is a visible recovery state, not permission to create an empty replacement at the
same path. Deleting session metadata and deleting project files are distinct actions.
Keep bounded in-memory scrollback and restore the current terminal screen from
the surviving tmux attachment. Do not assume client-side scrollback survived.
Reboot persistence covers metadata and saved files; transcript recording is a
separate opt-in setting because terminal output can include credentials.
Initial proposed limits: eight sessions per owner, one active input controller
per session, a 10,000-line scrollback ceiling plus a byte ceiling, and bounded
per-connection queues. Do not kill detached work because a browser idle timer
expired. Make resource use visible and allow explicit stop/cleanup. Qualify CPU,
memory and disk limits on the smallest supported node before choosing defaults.
Input is never automatically replayed after reconnect: a lost acknowledgement
does not establish whether Enter or a command reached the shell. Resize events
are idempotent. A newly attached terminal redraws from the current PTY state.
Revoke the previous writer before granting a new writer lease; read-only views
can be a later addition. User labels and working directories must never be
interpolated into shell command strings.
## Execution and connection architecture
Proposed components and responsibility boundaries:
```text
Dashboard terminal entry / SSH / local console
|
authenticated attachment
|
Terminal session service -- session metadata and ownership
|
supervised developer user + tmux + PTY
|
Bash / editor / Codex / project toolchains
|
archy CLI -- existing typed management operations
```
Add a small terminal session service with a local Unix-socket control interface.
The existing management backend validates owner authorization and brokers
short-lived attachments; it does not execute arbitrary shell strings in an RPC
handler. Use typed arguments and fixed executable paths for session creation.
An implementation spike must verify PTY allocation, systemd ownership, tmux
reattachment and Codex rendering before committing to the broker library.
Prefer a dedicated developer Unix account, separate from the existing
`archipelago` service account, with its own home, rootless container storage and
no mounts of production wallets/secrets. Keep the current service account and
data ownership intact. The inspected ISO builder grants the service account broad
passwordless sudo; using that account for a browser shell would grant equivalent
host authority. A new developer account alone is not proof of isolation: test
actual filesystem permissions, sockets, groups and sudo policy.
Provide an explicit System administration context for system changes. Routine
supported operations should use the same typed management operations as the UI;
arbitrary host-shell administration needs a distinct authenticated operator
session. These boundaries must still permit authorized agents to configure the
system efficiently. Do not require repeated consent for each step of an already
authorized operation, or treat a skill as an access-control mechanism.
Initial web access is for the node owner. Public app sessions, guests, peer
identities and iframe signers do not imply terminal authorization. Multi-user
workspace sharing is outside the first release; reject unsupported mappings.
Prefer a dedicated terminal browser origin with a minimal self-hosted bundle,
no app iframes or external scripts, and narrow authenticated handoff from the
dashboard. A same-origin subpage reduces bundle complexity but does not isolate
it from same-origin scripts. Resolve origin, certificate and Companion handling
in the connection spike before shipping. The
[xterm.js integration guide](https://xtermjs.org/docs/guides/security/) specifically
requires application-level WebSocket authentication/origin handling and careful
treatment of terminal output.
Remote terminal transport requires verified HTTPS/WSS. Existing plain-HTTP
dashboard users get a working secure-terminal entry and SSH alternative; do not
force a global dashboard HTTPS migration as a side effect. Retain current private
management ingress controls, IPv6 support and explicit proxy trust. Never infer
terminal authorization from a forwarded hostname or a private IP alone.
The protocol contract must include:
- Create/list/rename/end session operations with CSRF checks, stable owner
binding and idempotent create/end behavior.
- A single-use, short-lived attachment grant bound to session, owner and exact
terminal origin. No reusable dashboard cookie or API key in a query string.
- An authenticated WebSocket with bounded pre-auth time and no PTY output before
authorization; validate Origin separately from CORS and revalidate on reconnect.
- Input bytes, output bytes, resize, connection state and exit messages; bounded
frame sizes, queue backpressure and slow-reader behavior.
- Immediate attachment revocation on logout/credential revocation. No automatic
input replay, automatic command rerun or unauthenticated reconnect.
- Process-group cleanup only for explicit termination; metadata-only audit logs,
excluding command contents, keystrokes, credentials and terminal output.
- Terminal titles/links treated as untrusted text; external links require a user
gesture; clipboard escape sequences cannot silently read/write the clipboard.
## Setup and tool distribution
Provide a searchable `archy` CLI and matching setup TUI; the existing
`archipelago` executable remains the backend daemon. All `archy` commands in
this document are proposed interfaces, not commands available today.
| Proposed command | Purpose |
| --- | --- |
| `archy setup` | Resume setup, show installed/available/deferred items |
| `archy commands --json` | Agent-readable command descriptions, inputs, privileges and side effects |
| `archy doctor` | Read-only, bounded health checks with actionable findings |
| `archy session list` / `resume <id>` | Discover and attach to existing work |
| `archy dev setup <profile>` | Install a declared, versioned toolchain profile |
| `archy agent` | Launch selected agent in the selected workspace |
| `archy app new <id>` | Create from a maintained Archipelago starter |
| `archy app check <path>` | Manifest, build, integration and design checks |
| `archy app preview <path>` | Start an isolated local preview and return its URL |
| `archy app install <path> --node <target>` | Explicit candidate deployment through supported orchestration |
| `archy system <operation>` | Discoverable adapters to supported system operations |
| `archy skills status` | Show installed skill/doc/kit versions and local overrides |
Use one command catalog for the CLI, setup menu and skill references. Each entry
declares what it reads/changes, required privilege, supported machines, expected
output, progress and rollback/recovery behavior. Structured status is for agents;
the human menu uses clear task names. Missing capability is a visible explanation.
First-run sequence: verify network/time/access status; create or select a workspace;
set optional Git identity and editor; confirm installed tools; offer toolchain
profiles; sign in to Codex; offer a sample app or system task. Users can skip and
resume individual steps. Authentication is never an ISO build step. Run nothing
interactive in noninteractive shells, SCP/SFTP, remote command execution or CI.
Ship offline: shell essentials, tmux, Git/ngit, Codex binary, local documentation,
skills and design-kit assets. Developer profiles add the complete Node/frontend,
Rust/backend or Python environment and build prerequisites. Offline capability
must be stated precisely: shell/docs/Codex launch can work offline; model calls,
uncached dependencies and external login require connectivity. Plan a full
offline developer bundle as a separate profile if all build caches are required.
Use signed/versioned payloads and per-architecture hashes. Debian packages and
user toolchains have separate ownership. Core tools update through qualified
releases; opening a shell or running `codex` must not silently update binaries.
Optional tool installs show download size, source, version and progress. Respect
package-manager locks and existing mise/rustup/nvm installations. Do not prune a
binary version beneath a running session.
State is versioned per machine and per user with pending/running/done/failed/skipped
steps; write completion only after verification. Concurrent setup is serialized.
Preserve user dotfiles through small managed includes and explicit overrides;
preview changes and back up touched configuration during an explicit reset.
Network interruption, disk exhaustion and reboot leave resumable state.
## Codex integration
Bundle a qualified stable Codex release for each supported architecture, verified
against a pinned artifact. The official
[CLI installation documentation](https://learn.chatgpt.com/docs/codex/cli)
documents standalone and npm installation; the release implementation should
resolve exact packaging and pin it rather than executing an unversioned installer
on every node.
Use normal Codex authentication in the developer user's private environment.
Support browser login and the official device-code option for remote/headless
sessions when available; API-key login is another explicit option. The
[authentication documentation](https://learn.chatgpt.com/docs/auth) describes
these flows. Do not collect credentials in an Archipelago transcript or copy the
node's wallet identity into agent configuration. The UI reports Installed,
Sign-in required, Ready or Error based on actual results.
Honor the user's Codex configuration, permissions and model choice. A launcher
selects a workspace and skill context; it does not inject unrestricted execution
flags. A local skill is local guidance, not a claim that Codex inference is local.
Explain the selected provider and what workspace content may be sent to it during
setup, alongside any self-hosted alternative.
During an ordinary disconnect, resume the same running Codex process in tmux.
After an ended session or reboot, offer the official `codex resume` workflow in
the matching workspace. Do not automatically reissue the last task. Preserve
agent history/configuration separately from disposable build caches, and keep
credentials out of ordinary app export/support bundles.
## Local skills that enable real work
Ship a small coordinated skill family with one obvious entrypoint. Keep the
instructions actionable and references focused; avoid copying the whole manual
into every prompt. The skill-creator guidance informs this structure: precise
triggers, reusable resources, progressive disclosure and behavioral validation.
| Skill | Trigger and outcome | Required resources |
| --- | --- | --- |
| `archipelago` | Configure, troubleshoot or change an Archipelago system; route app/UI work to the companion skills | System map, command catalog, config ownership, task recipes and recovery |
| `archipelago-app` | Build, package, test or update an Archipelago app using the developer contract | Developer docs, manifest schema, starter assets, launch/auth/signer examples, lifecycle checks |
| `archipelago-design` | Create or change an Archipelago UI, including apps, setup and Terminal | Shared tokens/components, pattern gallery, app shells, screenshots and visual checks |
For a system task, the agent should locate the correct config/operation, inspect
current state, make the requested scoped change, validate it and report the result.
Teach recipes for networking, SSH, tool installation, terminal preferences,
service diagnosis and supported app configuration. Prefer maintained management
commands; when direct configuration is necessary, explain owned versus generated
files, the minimal affected service and reversal. Repository changes go into a
separate branch/worktree; installed-node changes target an explicitly identified
node and retain a scoped backup. Planning requests stay planning requests.
For an app task, the agent reads `docs/app-developer-guide.md` and
`docs/app-manifest-spec.md`, follows the design plan, chooses a starter, implements
the requested behavior, validates it and supplies a runnable preview. Package
with pinned images/build contexts, rootless execution, declared storage/secrets,
health checks and truthful interfaces. Include HTTP/HTTPS, iframe/Companion,
first-run credentials and Nostr signer behavior where relevant. Publication and
live installation are distinct requested steps.
The skill must explain non-obvious runtime facts: manifests copied only into
`/opt/archipelago/apps` are replaced at backend start; runtime payload promotion
and signed-catalog precedence matter. Installed status is not launch readiness.
Pre-catalog testing must not replace the signed catalog. Test uninstall/reinstall
with data preservation on a disposable target. Use `AGENTS.md` over stale testing
examples. Payments, wallets and uninstall decisions retain their existing
invariants; a generic repair must not reset them.
Proposed source layout: `skills/archipelago*` plus a versioned reference bundle and
app starter assets. Ship managed copies under a versioned read-only system path
and expose one discoverable link per skill per agent. Current official
[Codex skill guidance](https://learn.chatgpt.com/docs/build-skills) supports
repository `.agents/skills`, user `~/.agents/skills`, administrator
`/etc/codex/skills`, and symlinked skill directories. Qualify discovery with the
pinned CLI, including from outside this repository. Preserve local skills and
overrides; avoid duplicate skill names from multiple discovery paths.
Each bundle records the source revision, supported Archipelago version, manifest
schema and design-kit version. Installed skills use matching offline docs;
repository development uses that checkout's docs. Version mismatch is visible.
Future command names in this plan must not be taught as available until shipped.
## App creation and design integration
The default new app is a Vue/TypeScript app using the shared Archipelago UI kit,
with a pinned container build, manifest, icon, tests and local preview. Supply a
framework-neutral CSS/token starter for existing non-Vue apps; port semantic
behavior deliberately instead of depending on dashboard globals.
Provide a complete example app with a useful list/detail/settings flow, persistent
data, loading/empty/error/success states, a manifest and lifecycle evidence.
Extend it with optional signer/media examples only when those features are used.
The starter must render correctly both embedded and standalone. A third-party
upstream app can retain its own UI; the consistency contract governs the app's
Archipelago wrapper and newly authored Archipelago screens.
Local development data and ports are separate from production apps. Bind previews
to loopback by default and provide an authenticated preview path for remote users.
Development databases get unique project-scoped names, persistent volumes and
credentials; they must not attach to live Bitcoin/LND or production app databases.
## Delivery sequence
| Phase | Deliverable | Exit evidence |
| --- | --- | --- |
| 0 | Finalize command/session contracts, origin/account choice and design baseline | Reviewed wireframes, reference screens, privilege map and pinned Omarchy inventory |
| 1A | Persistent terminal service and browser/SSH attachment | Real shell + Codex TUI; close/reopen, network loss, manager restart and ownership tests |
| 1B | Shared design foundation, gallery and starter | Dashboard-derived tokens/components; consistent app at desktop/mobile sizes |
| 2 | Core setup/tool payload and Codex onboarding | Fresh/offline/upgrade/retry matrix; correct account state and user override preservation |
| 3 | System/app/design skills and app-development commands | Realistic agent tasks produce correct scoped system changes and a consistent packaged app |
| 4 | Integrated candidate qualification | Real node and Companion resume/design acceptance, resource tests and packaged ISO/OTA checks |
| 5 | Remaining Omarchy parity adapters | Each inventory row supported or explicitly retained with reason and acceptance target |
1A and 1B can be independent implementation workstreams once phase 0 contracts
are agreed. This planning session has not launched implementation agents.
App-generation functionality is incomplete until 1B and the skill acceptance pass.
Expected code boundaries: Terminal UI/store/keyboard handling; new terminal
service and typed management routes; shared CLI/setup catalog; versioned tool and
skill packaging; design-kit extraction; app starter/validation; ISO and OTA
integration. Keep each in a focused contribution. Coordinate shared frontend
styles, backend routing and packaging files before implementation starts.
## Acceptance criteria
| ID | Required proof |
| --- | --- |
| TERM-01 | Real PTY supports editors, completion, colors, Unicode, signals and Codex; no fake production commands |
| TERM-02 | Start a long-running fixture and edit a file, close Terminal/tab/browser, reopen and resume the exact session and process |
| TERM-03 | Offline/reconnect, mobile sleep, frontend reload, backend restart and attachment-service restart do not duplicate execution |
| TERM-04 | A second device resumes after owner authentication; writer transfer is atomic; another identity cannot list/attach/terminate |
| TERM-05 | End session stops only its process scope; logout revokes access; neither operation deletes workspace files |
| TERM-06 | Reboot retains workspace metadata, labels interrupted sessions accurately and offers Codex conversation resume without rerunning commands |
| TERM-07 | Terminal keyboard ownership, focus, text selection, paste, resize, mobile keyboard and screen-reader mode work |
| AUTH-01 | Missing/expired/replayed grants, cross-origin sockets, forged proxy headers and app/guest credentials fail before shell I/O |
| AUTH-02 | Developer user cannot read production wallets/secrets or control production container sockets; authorized system workflow works |
| SETUP-01 | Fresh install and existing-node upgrade deliver the same core capabilities; Codex version works before network access |
| SETUP-02 | Failed download, package lock, low disk, reboot and concurrent setup recover without false completion or broken existing tools |
| SETUP-03 | Existing dotfiles, editor/agent choices, credentials, app data and uninstall decisions are preserved |
| AGENT-01 | A system-change task uses the correct operation/config, verifies its result and preserves unrelated services |
| AGENT-02 | An app-building task follows actual developer docs and passes manifest/build/launch/lifecycle checks |
| DESIGN-01 | Agent-built apps satisfy the companion design plan using shared assets/components, including failure and mobile states |
| PKG-01 | Exact candidate OTA and ISO contain matching tools, docs, skills and UI-kit versions; restore previous payload without removing user work |
Backend unit execution uses `scripts/test-backend-isolated.sh`. PTY/process and
account-isolation integration tests run in disposable users/VMs, not a funded
production node. Terminal continuity tests use observable process IDs/output and
file checks, not a mocked “resumed” label. UI tests include 320/390/768/1440 pixel
layouts, landscape, enlarged text and actual Companion input on a device.
Record source tests, disposable integration, actual-node acceptance and packaged
artifact results separately. Preserve unfinished requirements in
`docs/post-1.8.22-regressions-20261001.md` and the current release acceptance ledger;
this feature plan closes none of them. Later publication follows ngit review and
merge, then identical accepted main/tag objects on both ngit and Gitea, with the
required mirror checks. No release version or publication date is reserved here.
## Decisions to resolve during design review
The proposed defaults are a dedicated developer account, tmux persistence,
minimal terminal origin, bundled Codex, Bash, and a Vue starter with portable
design tokens. The implementation review must settle terminal-origin/certificate
handling on every supported ingress, the supported operator-shell model, the
exact first-release tool profile, and the approved visual baseline. Native
terminal emulators and the broader desktop parity backlog need separate device
compatibility decisions. None of these questions prevents reviewing this plan.
+55
View File
@@ -0,0 +1,55 @@
# Terminal UAT deployment runbook
Status: prepared, 2026-10-09. This runbook targets the physical Framework node
(`framework-pt`) and does not authorize an OTA, catalog publication, or wallet
mutation.
## Candidate contents
Build and record the backend binary, dashboard bundle, and source commit from
the isolated terminal worktree. The backend must have `ARCHY_SESSION_STATE_DIR`
set to the developer account's shared state directory (or use the default
resolution in `api/handler/terminal.rs`). Preserve the existing web root and
service binary before any replacement.
## Preconditions
1. Verify the Framework hostname and SSH host key through the operator's
approved connection mechanism. A plain `ssh framework-pt` must not be used
until host-key verification is available.
2. Capture service/container state, boot ID, running binary digest, served UI
digest, and the existing `/var/lib/archipelago/support` layout without
printing credentials, wallet files, or environment contents.
3. Create a timestamped protected rollback directory under
`/var/lib/archipelago/support/terminal-uat-<timestamp>`.
## Acceptance flow
- Open the dashboard as the node owner; unauthenticated requests to
`/api/terminal/sessions` and `/ws/terminal` return 401.
- Create a named session, type `printf 'uat\n'`, close the terminal, reopen it,
and resume the same session without a duplicate tmux process.
- Refresh the browser and reconnect after a temporary network interruption.
- Verify the terminal panel stays bounded while output grows, keeps an inner
scroll position at the newest line, and supports drag, resize, minimize,
refresh, and fullscreen controls without blocking dashboard navigation.
- Open a second owner browser and verify inventory visibility; verify only one
active attachment sends input at a time before enabling transfer controls.
- Confirm explicit End stops the tmux process but preserves the workspace.
- Reboot acceptance is separate: processes may stop, metadata must remain, and
the UI must call this interrupted rather than a live resume.
- Verify the Omarchy-derived agent skill files and app starter are present in
the candidate source/artifact; do not treat a local npm install failure as a
successful app build.
The node's reverse proxy must route `/api/terminal/` to the Archipelago daemon
(`127.0.0.1:5678`) in both HTTP and HTTPS server blocks. `/ws` already carries
the terminal WebSocket upgrade. Without the REST location, the SPA fallback
returns `index.html` instead of the authenticated session response.
## Rollback
Stop exposing the new dashboard before restoring the previous UI/backend pair.
Restore only from the protected receipt, verify the previous hashes and health,
and leave terminal session metadata/workspaces untouched unless the operator
explicitly requests session cleanup.
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.*
!.env.example
@@ -0,0 +1,16 @@
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json vite.config.ts index.html ./
COPY src ./src
RUN npm install --ignore-scripts && npm run build
FROM nginx:1.27-alpine
COPY --from=build /src/dist /usr/share/nginx/html
COPY <<'EOF' /etc/nginx/conf.d/default.conf
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ /index.html; }
}
EOF
@@ -0,0 +1,11 @@
# Archipelago app starter
This is a small standalone Vue/Vite app that demonstrates Archipelago's initial
design contract: semantic `--archy-*` tokens, dark native controls, responsive
cards, bottom actions, visible focus, 44px targets, and loading/empty/error/
success states. It intentionally has no dashboard dependency or host handshake.
Run `npm install && npm run dev` from this directory. Before packaging, copy the
source into a project, replace the demo state with a real API, pin dependencies,
and validate the manifest using the repository app developer guide. The Docker
file is a starter build example; it is not a substitute for lifecycle acceptance.
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<title>Archipelago app</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,48 @@
app:
id: archipelago-app-starter
name: Archipelago App Starter
version: 0.1.0
description: A minimal Archipelago design-system starter with responsive states.
container:
build:
context: .
dockerfile: Dockerfile
tag: localhost/archipelago-app-starter:0.1.0
resources:
cpu_limit: 1
memory_limit: 128Mi
disk_limit: 256Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
- host: 8180
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/archipelago-app-starter
target: /data
options: [rw]
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Archipelago App Starter
description: Responsive design-system starter
type: ui
port: 8180
protocol: http
path: /
metadata:
category: development
tier: optional
@@ -0,0 +1,18 @@
{
"name": "archipelago-app-starter",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.2",
"typescript": "~5.9.3",
"vue": "^3.5.24"
},
"devDependencies": {}
}
@@ -0,0 +1,67 @@
<template>
<main class="archy-shell" aria-labelledby="page-title">
<header class="archy-header">
<div>
<p class="archy-eyebrow">ARCHIPELAGO APP</p>
<h1 id="page-title">Project workspace</h1>
<p class="archy-muted">A small, useful starting point for a node app.</p>
</div>
<button class="archy-button archy-button-secondary" type="button" @click="reload">
Refresh
</button>
</header>
<section class="archy-card" aria-labelledby="items-title">
<div class="archy-section-heading">
<div>
<p class="archy-eyebrow">YOUR DATA</p>
<h2 id="items-title">Items</h2>
</div>
<button class="archy-button" type="button" @click="addItem">Add item</button>
</div>
<div v-if="status === 'loading'" class="archy-state" role="status">Loading items…</div>
<div v-else-if="status === 'error'" class="archy-state archy-state-error" role="alert">
<strong>Items are unavailable.</strong>
<span>Check the app service and try again.</span>
<button class="archy-button archy-button-secondary" type="button" @click="reload">Try again</button>
</div>
<div v-else-if="items.length === 0" class="archy-state">
<strong>No items yet.</strong>
<span>Create one to see the app's normal success state.</span>
<button class="archy-button archy-button-secondary" type="button" @click="addItem">Create first item</button>
</div>
<ul v-else class="archy-list" aria-live="polite">
<li v-for="item in items" :key="item.id" class="archy-list-row">
<div>
<strong>{{ item.name }}</strong>
<span class="archy-muted">{{ item.detail }}</span>
</div>
<span class="archy-status">Ready</span>
</li>
</ul>
</section>
<p class="archy-footnote">This starter is standalone-safe and does not require a host handshake.</p>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
type Item = { id: number; name: string; detail: string }
const status = ref<'ready' | 'loading' | 'error'>('ready')
const items = ref<Item[]>([])
let nextId = 1
function addItem() {
status.value = 'ready'
items.value.push({ id: nextId, name: `Item ${nextId}`, detail: 'Persist this through your app API.' })
nextId += 1
}
function reload() {
status.value = 'loading'
window.setTimeout(() => { status.value = 'ready' }, 250)
}
</script>
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
createApp(App).mount('#app')
@@ -0,0 +1,54 @@
:root {
color-scheme: dark;
--archy-canvas: #0a0a0a;
--archy-surface: rgba(0, 0, 0, 0.65);
--archy-surface-muted: rgba(255, 255, 255, 0.06);
--archy-border: rgba(255, 255, 255, 0.18);
--archy-text: rgba(255, 255, 255, 0.92);
--archy-muted: rgba(255, 255, 255, 0.58);
--archy-accent: #fb923c;
--archy-success: #4ade80;
--archy-danger: #f87171;
--archy-radius-card: 16px;
--archy-radius-control: 12px;
--archy-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
--archy-space-1: 4px;
--archy-space-2: 8px;
--archy-space-3: 12px;
--archy-space-4: 16px;
--archy-space-6: 24px;
--archy-space-8: 32px;
font-family: Avenir Next, system-ui, sans-serif;
background: var(--archy-canvas);
color: var(--archy-text);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--archy-canvas); }
button { font: inherit; }
button:focus-visible { outline: 2px solid var(--archy-accent); outline-offset: 3px; }
.archy-shell { width: min(100% - 32px, 960px); margin: 0 auto; padding: 40px 0 56px; }
.archy-header, .archy-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--archy-space-4); }
.archy-header { margin-bottom: var(--archy-space-8); }
.archy-eyebrow { margin: 0 0 var(--archy-space-2); color: var(--archy-accent); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; }
h1, h2 { margin: 0; line-height: 1.15; }
h1 { font-size: clamp(1.8rem, 5vw, 2.8rem); }
h2 { font-size: 1.25rem; }
.archy-muted, .archy-footnote { color: var(--archy-muted); }
.archy-card { padding: var(--archy-space-6); background: var(--archy-surface); border: 1px solid var(--archy-border); border-radius: var(--archy-radius-card); box-shadow: var(--archy-shadow); }
.archy-section-heading { align-items: center; margin-bottom: var(--archy-space-6); }
.archy-button { min-height: 44px; padding: 10px 18px; border: 1px solid rgba(251, 146, 60, 0.35); border-radius: var(--archy-radius-control); background: rgba(251, 146, 60, 0.2); color: #fed7aa; cursor: pointer; }
.archy-button:hover { background: rgba(251, 146, 60, 0.3); }
.archy-button-secondary { border-color: var(--archy-border); background: var(--archy-surface-muted); color: var(--archy-text); }
.archy-state { display: grid; gap: var(--archy-space-3); padding: var(--archy-space-6); border-radius: var(--archy-radius-control); background: var(--archy-surface-muted); color: var(--archy-muted); }
.archy-state strong { color: var(--archy-text); }
.archy-state-error { border: 1px solid rgba(248, 113, 113, 0.35); }
.archy-state-error strong { color: var(--archy-danger); }
.archy-list { display: grid; gap: var(--archy-space-2); padding: 0; margin: 0; list-style: none; }
.archy-list-row { display: flex; align-items: center; justify-content: space-between; gap: var(--archy-space-4); padding: var(--archy-space-4); border-radius: var(--archy-radius-control); background: var(--archy-surface-muted); }
.archy-list-row div { display: grid; gap: var(--archy-space-1); min-width: 0; }
.archy-status { color: var(--archy-success); font-size: 0.8rem; }
.archy-footnote { margin: var(--archy-space-4) 0 0; font-size: 0.8rem; }
@media (max-width: 560px) { .archy-shell { width: min(100% - 24px, 960px); padding-top: 24px; } .archy-header { flex-direction: column; } .archy-header .archy-button { width: 100%; } .archy-card { padding: var(--archy-space-4); } .archy-section-heading { align-items: stretch; flex-direction: column; } .archy-section-heading .archy-button { width: 100%; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }
@@ -0,0 +1,4 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()] })
+17
View File
@@ -11,6 +11,8 @@
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
@@ -4574,6 +4576,21 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+2
View File
@@ -28,6 +28,8 @@
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
+188 -266
View File
@@ -1,298 +1,220 @@
<template>
<Teleport to="body">
<Transition name="cli-popup">
<div
v-if="cliStore.isOpen"
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
@click.self="cliStore.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="panelRef"
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
:style="panelStyle"
@mousedown="onPanelMouseDown"
>
<!-- Header: terminal icon + title + app switcher -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
<div v-if="cliStore.isOpen" class="fixed inset-0 z-[2500] flex items-center justify-start p-4 pointer-events-none">
<section ref="panelRef" data-archipelago-terminal class="glass-card terminal-panel relative z-10 w-full overflow-hidden pointer-events-auto md:ml-[300px]" :class="{ minimized }" :style="panelStyle" aria-label="Archipelago terminal">
<header class="terminal-header sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none" @mousedown="startDrag">
<div class="terminal-drag-handle" title="Drag to move">
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24"><path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" /></svg>
</div>
<div class="flex items-center gap-3 flex-1 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-white font-medium">CLI Access</span>
<div class="terminal-title"><svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg><span>CLI Access</span><span class="terminal-host">{{ activeSession?.name || 'No session' }}</span></div>
<div class="terminal-actions">
<select v-model="selectedId" class="terminal-select" aria-label="Terminal session" @change="connectSelected">
<option value="">Select session</option>
<option v-for="session in sessions" :key="session.id" :value="session.id">{{ session.name }} · {{ session.state }}</option>
</select>
<button class="terminal-icon-button" type="button" @click="createSession">New</button>
<button class="terminal-icon-button" type="button" aria-label="Refresh" title="Refresh" @click="refreshTerminal"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg></button>
<button class="terminal-icon-button" type="button" :aria-label="minimized ? 'Restore terminal' : 'Minimize terminal'" @click="minimized = !minimized"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="minimized ? 'M5 12h14' : 'M6 12h12'" /></svg></button>
<button class="terminal-icon-button" type="button" :aria-label="fullscreen ? 'Windowed' : 'Fullscreen'" @click="toggleFullscreen"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fullscreen ? 'M8 3v5H3m18 0h-5V3M3 16h5v5m8 0v-5h5' : 'M8 3H3v5m13-5h5v5M3 16v5h5m8 0h5v-5'" /></svg></button>
<button class="terminal-icon-button" type="button" aria-label="Close" @click="closeTerminal"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</header>
<div v-show="!minimized" class="terminal-body">
<div v-if="error" class="terminal-empty terminal-error"><strong>Terminal unavailable</strong><span>{{ error }}</span><button class="terminal-button" type="button" @click="loadSessions">Retry</button></div>
<div v-else-if="!sessions.length" class="terminal-empty"><strong>Create your first developer session</strong><span>Sessions stay running when this window closes and can be resumed later.</span><button class="terminal-button terminal-primary" type="button" @click="createSession">Start session</button></div>
<div v-else-if="!selectedId" class="terminal-empty"><strong>Choose a session</strong><span>Resume an existing workspace or start a new one.</span></div>
<div v-else ref="terminalRef" class="terminal-output" tabindex="0" aria-label="Archipelago terminal session" @pointerdown.stop="focusInput" @click.stop="focusInput" @keydown.stop @keyup.stop @keypress.stop></div>
</div>
<!-- Content: mock CLI in dev, SSH instructions in production -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<!-- Mock CLI interface (dev mode only) -->
<div
v-if="isDev"
class="flex-1 flex flex-col min-h-0 p-4 bg-black/80 rounded-b-lg font-mono text-sm"
>
<div ref="outputRef" class="flex-1 overflow-y-auto text-green-400/90 whitespace-pre-wrap break-words mb-2 min-h-0">{{ mockOutput }}</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-amber-400">archipelago@node</span>
<span class="text-white/60">~</span>
<span class="text-white/40">$</span>
<input
ref="cliInputRef"
v-model="mockCommand"
type="text"
class="flex-1 bg-transparent text-white outline-none border-none"
placeholder=" "
@keydown.enter="runMockCommand"
/>
</div>
</div>
<!-- SSH instructions (production only) -->
<div v-else class="flex-1 overflow-y-auto p-4 space-y-4">
<p class="text-white/80 text-sm">
Connect to this node via SSH to access the command line. Use the same host as this web interface.
</p>
<div class="space-y-3">
<div class="p-3 rounded-lg bg-white/5 font-mono text-sm">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">SSH Command</div>
<div class="flex items-center gap-2 flex-wrap">
<code class="text-green-400 break-all">{{ sshCommand }}</code>
<button
type="button"
class="shrink-0 px-2 py-1 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white text-xs transition-colors"
@click="copyCommand"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<div class="p-3 rounded-lg bg-white/5 text-sm space-y-1">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">Connection Details</div>
<div class="flex flex-col gap-1.5 text-white/80">
<div class="flex justify-between gap-4">
<span class="text-white/50">Host</span>
<span class="font-mono text-green-400">{{ host }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">User</span>
<span class="font-mono">archipelago</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">Password</span>
<span class="font-mono">archipelago</span>
</div>
</div>
</div>
<p class="text-white/50 text-xs">
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
</p>
<p class="text-white/40 text-xs">
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">F</kbd> to open this anytime.
</p>
</div>
</div>
</div>
</div>
<footer v-if="selectedId && !minimized" class="terminal-input-bar">
<span class="terminal-status" :class="{ connected: connected }">{{ connected ? 'Connected' : 'Reconnecting…' }}</span>
<span class="terminal-hint">Click terminal to focus · drag the header to move · resize from the corner</span>
</footer>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { useCLIStore } from '@/stores/cli'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
type Session = { id: string; name: string; workspace: string; state: string; updated_at: number }
const cliStore = useCLIStore()
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const outputRef = ref<HTMLElement | null>(null)
const cliInputRef = ref<HTMLInputElement | null>(null)
const copied = ref(false)
const minimized = ref(false)
const fullscreen = ref(false)
const position = ref({ x: 0, y: 0 })
const dragging = ref<{ x: number; y: number; px: number; py: number } | null>(null)
const sessions = ref<Session[]>([])
const selectedId = ref('')
const error = ref('')
const connected = ref(false)
const terminalRef = ref<HTMLElement | null>(null)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let resizeObserver: ResizeObserver | null = null
let socket: WebSocket | null = null
let renderedSession = ''
let connectionGeneration = 0
const mockCommand = ref('')
const mockOutput = ref(` ╔═══════════════════════════════════════════════════════════╗
║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║
║ Your sovereign Bitcoin infrastructure ║
╚═══════════════════════════════════════════════════════════╝
const activeSession = computed(() => sessions.value.find(session => session.id === selectedId.value))
const panelStyle = computed(() => ({ transform: `translate(${position.value.x}px, ${position.value.y}px)` }))
const wsUrl = (id: string) => `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/terminal?session=${encodeURIComponent(id)}`
function compactSnapshot(data: string) {
const lines = data.split(/\r?\n/)
const visible = (line: string) => line.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').trim().length > 0
while (lines.length && !visible(lines[0] ?? '')) lines.shift()
while (lines.length && !visible(lines[lines.length - 1] ?? '')) lines.pop()
return lines.join('\n')
}
System Status:
─────────────────────────────────────────────────────────────
Mode: 🟢 Installed
Podman: 🟢 Installed
Bitcoin: 🟢 Running (blocks: syncing)
Lightning: 🟡 Stopped
Main Menu:
─────────────────────────────────────────────────────────────
r) Refresh - Update IP/status
w) Open Web UI - Launch graphical interface
1) Install to Disk - Permanently install Archipelago
2) Setup Bitcoin Core - Configure Bitcoin full node
3) Setup Lightning (LND) - Configure Lightning Network
4) Setup BTCPay Server - Bitcoin payment processor
5) View Logs - Monitor running services
6) Network Settings - Configure networking
7) System Info - View system information
q) Quit
`)
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const SAVED_POSITION_KEY = 'archipelago-cli-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
const isDev = import.meta.env.DEV
const host = computed(() => window.location.hostname)
const sshCommand = computed(() => `ssh archipelago@${host.value}`)
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
function loadSavedPosition() {
async function loadSessions() {
error.value = ''
try {
const raw = localStorage.getItem(SAVED_POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw)
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
} else {
savedPosition.value = null
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved CLI position', e)
savedPosition.value = null
}
const response = await fetch('/api/terminal/sessions', { credentials: 'include' })
if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to use the developer terminal.' : `Session service returned ${response.status}.`)
sessions.value = await response.json()
if (!selectedId.value && sessions.value[0]) selectedId.value = sessions.value[0].id
if (selectedId.value) connectSelected()
} catch (cause) { error.value = cause instanceof Error ? cause.message : 'Could not load terminal sessions.' }
}
function savePosition(x: number, y: number) {
savedPosition.value = { x, y }
async function createSession() {
error.value = ''
try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to save CLI position', e)
const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) })
if (!response.ok) throw new Error('Could not create a developer session.')
const session = await response.json()
sessions.value = [session, ...sessions.value]
selectedId.value = session.id
connectSelected()
} catch (cause) { error.value = cause instanceof Error ? cause.message : 'Could not create terminal session.' }
}
function closeTerminal() { disconnect(); cliStore.close() }
function refreshTerminal() { if (selectedId.value) { terminal?.reset(); connectSelected() } else loadSessions() }
function startDrag(event: MouseEvent) {
if ((event.target as HTMLElement).closest('button, select, input')) return
dragging.value = { x: event.clientX, y: event.clientY, px: position.value.x, py: position.value.y }
}
function onDrag(event: MouseEvent) {
if (!dragging.value) return
position.value = { x: dragging.value.px + event.clientX - dragging.value.x, y: dragging.value.py + event.clientY - dragging.value.y }
}
function stopDrag() { dragging.value = null }
async function toggleFullscreen() {
if (!panelRef.value) return
if (document.fullscreenElement === panelRef.value) { await document.exitFullscreen?.(); fullscreen.value = false; return }
try { await panelRef.value.requestFullscreen?.(); fullscreen.value = true } catch { fullscreen.value = false }
}
function disconnect() { socket?.close(); socket = null; connected.value = false }
function connectSelected() {
disconnect()
if (!selectedId.value) return
if (renderedSession !== selectedId.value) {
terminal?.reset()
renderedSession = selectedId.value
}
}
function runMockCommand() {
const cmd = mockCommand.value.trim()
if (!cmd) return
mockOutput.value += `\n archipelago@node ~ $ ${cmd}\n`
const lower = cmd.toLowerCase()
if (lower === 'r' || lower === 'refresh') {
mockOutput.value += ` Status refreshed.\n`
} else if (lower === 'w' || lower.startsWith('web')) {
mockOutput.value += ` Opening Web UI... (press C to return to CLI)\n`
} else if (lower === 'q' || lower === 'quit' || lower === 'exit') {
mockOutput.value += ` Goodbye! 🏝️\n`
cliStore.close()
} else if (lower === 'help' || lower === '?') {
mockOutput.value += ` Type r, w, 1-7, or q. Press C to switch to Web UI.\n`
} else {
mockOutput.value += ` Unknown command. Type 'help' or 'r' for menu.\n`
}
mockCommand.value = ''
nextTick(() => {
outputRef.value?.scrollTo({ top: outputRef.value.scrollHeight, behavior: 'smooth' })
})
}
async function copyCommand() {
try {
await navigator.clipboard.writeText(sshCommand.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = sshCommand.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
useModalKeyboard(panelRef, computed(() => cliStore.isOpen), () => cliStore.close())
watch(
() => cliStore.isOpen,
(open) => {
if (open) {
loadSavedPosition()
if (isDev) {
nextTick(() => cliInputRef.value?.focus())
const generation = ++connectionGeneration
const connection = new WebSocket(wsUrl(selectedId.value))
socket = connection
connection.onopen = () => { if (generation !== connectionGeneration) return; connected.value = true; resizeTerminal() }
connection.onclose = () => { if (generation === connectionGeneration) connected.value = false }
connection.onerror = () => { if (generation === connectionGeneration) connected.value = false }
connection.onmessage = event => {
if (generation !== connectionGeneration) return
try {
const message = JSON.parse(event.data)
if (message.type === 'output') {
// The daemon sends a complete visible-pane snapshot, not a byte
// stream. Replace the pane so every prompt does not duplicate the
// previous screen into xterm scrollback.
terminal?.reset()
terminal?.write(compactSnapshot(message.data), () => terminal?.scrollToBottom())
}
}
} catch { /* ignore malformed frames */ }
}
)
}
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
function resizeTerminal() {
fitAddon?.fit()
if (socket?.readyState === WebSocket.OPEN && terminal) socket.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }))
}
function send(data: string) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })) }
function focusInput() { nextTick(() => terminal?.focus()) }
watch(() => cliStore.isOpen, open => { if (open) loadSessions(); else disconnect() })
watch(terminalRef, element => {
if (!element || !terminal || element.querySelector('.xterm')) return
terminal.open(element)
fitAddon?.fit()
resizeObserver?.observe(element)
})
onMounted(() => {
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
terminal = new Terminal({
convertEol: true,
cursorBlink: true,
cursorInactiveStyle: 'none',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 13,
lineHeight: 1.45,
scrollback: 10000,
theme: { background: 'transparent', foreground: '#f3f4f6', cursor: '#f7931a', selectionBackground: 'rgba(247,147,26,.3)' },
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.onData(data => send(data))
resizeObserver = new ResizeObserver(() => resizeTerminal())
if (terminalRef.value) {
terminal.open(terminalRef.value)
resizeObserver.observe(terminalRef.value)
}
nextTick(resizeTerminal)
})
function blurTerminalWhenOutside(event: PointerEvent) {
if (panelRef.value && !panelRef.value.contains(event.target as Node)) terminal?.blur()
}
onMounted(() => window.addEventListener('pointerdown', blurTerminalWhenOutside, true))
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
window.removeEventListener('pointerdown', blurTerminalWhenOutside, true)
resizeObserver?.disconnect()
terminal?.dispose()
disconnect()
})
</script>
<style scoped>
.cli-popup-enter-active,
.cli-popup-leave-active {
transition: opacity 0.2s ease;
}
.cli-popup-enter-from,
.cli-popup-leave-to {
opacity: 0;
}
.terminal-panel { display: flex; flex-direction: column; width: min(960px, calc(100vw - 2rem)); height: min(680px, calc(100vh - 2rem)); min-width: 420px; min-height: 260px; max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); resize: both; }
.terminal-panel.minimized { width: auto; height: auto; min-width: 320px; resize: none; }
.terminal-panel:fullscreen { width: 100vw; height: 100vh; max-width: none; max-height: none; border-radius: 0; }
.terminal-header, .terminal-input-bar { display: flex; align-items: center; gap: 12px; padding: 13px 16px; }
.terminal-header { cursor: grab; user-select: none; }
.terminal-header:active { cursor: grabbing; }
.terminal-title, .terminal-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
.terminal-title { flex: 1; font-weight: 500; } .terminal-host { color: rgba(255,255,255,.45); font-size: 12px; font-weight: 400; }
.terminal-drag-handle { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; flex-shrink: 0; }
.terminal-drag-handle:hover { background: rgba(255,255,255,.1); }
.terminal-body { display: flex; flex: 1 1 0; min-height: 0; max-height: 100%; height: 0; overflow: hidden; }
.terminal-output { box-sizing: border-box; flex: 1 1 auto; min-height: 0; min-width: 0; height: 100%; max-height: 100%; margin: 0; overflow: hidden; padding: 14px 16px; color: rgba(255,255,255,.9); outline: none; }
.terminal-output :deep(.xterm) { height: 100%; width: 100%; }
.terminal-output :deep(.xterm-viewport) { background: transparent !important; }
.terminal-output:not(:focus-within) :deep(.xterm-cursor) { visibility: hidden !important; }
.terminal-output:focus-within :deep(.xterm-cursor) { visibility: visible !important; }
.terminal-input-bar { border-top: 1px solid rgba(255,255,255,.1); } .terminal-prompt { color: #f7931a; font-size: 20px; } .terminal-input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: white; font: 13px ui-monospace, monospace; }
.terminal-status { color: rgba(255,255,255,.5); font-size: 11px; } .terminal-status.connected { color: #92d6a0; }
.terminal-hint { margin-left: auto; color: rgba(255,255,255,.35); font-size: 11px; }
.terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; }
.terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); }
.terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; }
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
@media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
</style>
@@ -252,6 +252,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// ─── Keyboard Handler ───────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
if ((e.target as HTMLElement | null)?.closest?.('[data-archipelago-terminal]')) return
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
if (!navKeys.includes(e.key)) return
@@ -35,6 +35,7 @@ export function useModalKeyboard(
function handleKeydown(e: KeyboardEvent) {
if (!isOpen.value) return
if ((e.target as HTMLElement | null)?.closest?.('[data-archipelago-terminal]')) return
if (e.key === 'Escape') {
restoreFocusRef?.value?.focus?.()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Idempotent Archipelago developer environment bootstrap.
# Check by default; package installation requires --apply.
set -euo pipefail
APPLY=0
# Codex is part of the standard developer environment. Keep --codex as a
# backwards-compatible no-op for existing setup commands.
CODEX=1
for arg in "$@"; do
case "$arg" in
--apply) APPLY=1 ;;
--codex) CODEX=1 ;;
--help|-h) printf 'Usage: archy-developer-setup [--check] [--apply] [--codex]\n\nCodex is installed by default with --apply; authenticate with: codex login\n'; exit 0 ;;
--check) ;;
*) printf 'archy-developer-setup: unknown option: %s\n' "$arg" >&2; exit 2 ;;
esac
done
tools=(git tmux jq fzf rg fd nvim podman mise direnv)
missing=()
for tool in "${tools[@]}"; do
if command -v "$tool" >/dev/null 2>&1; then
printf 'ok\t%s\t%s\n' "$tool" "$(command -v "$tool")"
else
missing+=("$tool")
printf 'missing\t%s\n' "$tool"
fi
done
if command -v codex >/dev/null 2>&1; then
printf 'ok\tcodex\t%s\n' "$(command -v codex)"
elif (( CODEX )); then
missing+=(codex)
printf 'missing\tcodex\n'
fi
user_home=$(getent passwd "$(id -u)" | cut -d: -f6)
codex_root=${CODEX_HOME:-$user_home/.codex}
skill_source_dir=${ARCHY_SKILLS_DIR:-$PWD/.agents/skills}
skills=(archipelago archipelago-app archipelago-design)
missing_skills=()
for skill in "${skills[@]}"; do
source_skill="$skill_source_dir/$skill/SKILL.md"
target_skill="$codex_root/skills/$skill/SKILL.md"
if [ -f "$target_skill" ]; then
printf 'ok\tskill:%s\t%s\n' "$skill" "$target_skill"
elif [ -f "$source_skill" ]; then
missing_skills+=("skill:$skill")
printf 'missing\tskill:%s\t%s\n' "$skill" "$target_skill"
else
printf 'missing\tskill:%s\tsource unavailable (%s)\n' "$skill" "$source_skill"
fi
done
if (( ! APPLY )); then
if [ "${#missing[@]}" -gt 0 ] || [ "${#missing_skills[@]}" -gt 0 ]; then
printf '\nMissing: %s %s\n' "${missing[*]}" "${missing_skills[*]}"
printf 'Re-run with --apply to install missing packages, including Codex.\n'
exit 1
fi
exit 0
fi
if ! command -v apt-get >/dev/null 2>&1; then
printf 'No apt-get found; install the missing tools using the node distribution package manager.\n' >&2
exit 1
fi
packages=(git tmux jq fzf ripgrep fd-find neovim podman direnv nodejs npm)
if [ "${#missing[@]}" -gt 0 ]; then
sudo -n apt-get update
sudo -n apt-get install -y "${packages[@]}"
fi
if (( CODEX )) && ! command -v codex >/dev/null 2>&1; then
command -v npm >/dev/null 2>&1 || { printf 'npm is required for Codex installation.\n' >&2; exit 1; }
sudo -n npm install --global @openai/codex
fi
if [ "${#missing_skills[@]}" -gt 0 ]; then
mkdir -p "$codex_root/skills"
for skill in "${skills[@]}"; do
source_dir="$skill_source_dir/$skill"
target_dir="$codex_root/skills/$skill"
[ -f "$source_dir/SKILL.md" ] || continue
mkdir -p "$target_dir"
cp -a "$source_dir/." "$target_dir/"
printf 'installed\tskill:%s\t%s\n' "$skill" "$target_dir"
done
fi
if command -v mise >/dev/null 2>&1; then
mise trust --yes "$PWD" >/dev/null 2>&1 || true
fi
printf 'developer setup complete; Archipelago skills preloaded for Codex; authenticate interactively with: codex login\n'
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Persistent Archipelago developer sessions. Terminal attachments are disposable;
# the tmux session owns the process and durable JSON owns its discoverable state.
set -euo pipefail
STATE_DIR="${ARCHY_SESSION_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/archipelago/sessions}"
mkdir -p "$STATE_DIR"
die() { echo "archy-session: $*" >&2; exit 2; }
need_tmux() { command -v tmux >/dev/null 2>&1 || die "tmux is required"; }
need_python() { command -v python3 >/dev/null 2>&1 || die "python3 is required"; }
valid_id() { [[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$ ]]; }
session_file() { printf '%s/%s.json' "$STATE_DIR" "$1"; }
write_record() {
local id=$1 name=$2 workspace=$3 state=$4 tmux_name=$5
need_python
python3 - "$STATE_DIR" "$id" "$name" "$workspace" "$state" "$tmux_name" <<'PY'
import json, os, sys, tempfile, time
state_dir, ident, name, workspace, state, tmux_name = sys.argv[1:]
record = {"schema": 1, "id": ident, "name": name, "workspace": workspace,
"state": state, "tmux": tmux_name, "updated_at": int(time.time())}
fd, tmp = tempfile.mkstemp(prefix=f".{ident}.", suffix=".tmp", dir=state_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(record, handle, sort_keys=True); handle.write("\n"); handle.flush(); os.fsync(handle.fileno())
os.replace(tmp, os.path.join(state_dir, ident + ".json"))
finally:
try: os.unlink(tmp)
except FileNotFoundError: pass
PY
}
new_id() { need_python; python3 - <<'PY'
import secrets
print("s-" + secrets.token_hex(8))
PY
}
read_json() {
python3 - "$1" "$2" <<'PY'
import json, sys
print(json.load(open(sys.argv[1], encoding="utf-8"))[sys.argv[2]])
PY
}
cmd_list() {
need_tmux; need_python
python3 - "$STATE_DIR" <<'PY'
import json, os, subprocess, sys
state_dir = sys.argv[1]
for filename in sorted(os.listdir(state_dir)):
if not filename.endswith(".json"): continue
try: record = json.load(open(os.path.join(state_dir, filename), encoding="utf-8"))
except (OSError, ValueError, KeyError): continue
live = subprocess.run(["tmux", "has-session", "-t", record["tmux"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
effective = "detached" if live else ("interrupted" if record.get("state") == "running" else record.get("state", "ended"))
print(f"{record['id']}\t{record['name']}\t{effective}\t{record['workspace']}")
PY
}
cmd_start() {
need_tmux
local name=${1:-Work} workspace=${2:-$HOME/Work}
[[ -d "$workspace" ]] || die "workspace does not exist: $workspace"
local id tmux_name; id=$(new_id); tmux_name="archy-$id"
tmux new-session -d -s "$tmux_name" -c "$workspace"
write_record "$id" "$name" "$workspace" running "$tmux_name"
printf '%s\n' "$id"
}
record_for() {
local id=$1 file; valid_id "$id" || die "invalid session id"; file=$(session_file "$id")
[[ -f "$file" ]] || die "session not found: $id"; printf '%s\n' "$file"
}
cmd_attach() {
need_tmux; local file=$1 tmux_name; tmux_name=$(read_json "$file" tmux)
tmux has-session -t "$tmux_name" 2>/dev/null || die "session is not running; inspect with list"
exec tmux attach-session -t "$tmux_name"
}
cmd_rename() {
local id=$1 name=$2 file=$3; local workspace tmux_name state=ended
workspace=$(read_json "$file" workspace); tmux_name=$(read_json "$file" tmux); need_tmux
tmux has-session -t "$tmux_name" 2>/dev/null && state=detached
write_record "$id" "$name" "$workspace" "$state" "$tmux_name"
}
cmd_end() {
local id=$1 file=$2; local tmux_name name workspace
tmux_name=$(read_json "$file" tmux); name=$(read_json "$file" name); workspace=$(read_json "$file" workspace); need_tmux
tmux kill-session -t "$tmux_name" 2>/dev/null || true
write_record "$id" "$name" "$workspace" ended "$tmux_name"
}
usage() {
cat >&2 <<'EOF'
Usage:
archy-session list
archy-session start [name] [workspace]
archy-session attach <id>
archy-session rename <id> <name>
archy-session end <id>
Closing an attached terminal detaches it. end is the explicit destructive
operation and never removes the workspace or its files.
EOF
}
command=${1:-}
case "$command" in
list) cmd_list ;;
start) shift; cmd_start "$@" ;;
attach) shift; [[ $# == 1 ]] || die "attach requires an id"; file=$(record_for "$1"); cmd_attach "$file" ;;
rename) shift; [[ $# == 2 ]] || die "rename requires an id and name"; file=$(record_for "$1"); cmd_rename "$1" "$2" "$file" ;;
end) shift; [[ $# == 1 ]] || die "end requires an id"; file=$(record_for "$1"); cmd_end "$1" "$file" ;;
*) usage; exit 2 ;;
esac
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
output=$(PATH=/usr/bin:/bin "$ROOT/scripts/archy-developer-setup" --check 2>&1 || true)
grep -qE '^(ok|missing)[[:space:]]' <<<"$output"
! grep -q 'sudo -n apt-get' <<<"$output"
printf 'archy-developer-setup check is non-mutating\n'
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
export ARCHY_SESSION_STATE_DIR="$TMP/state"; mkdir -p "$TMP/work"
if ! command -v tmux >/dev/null 2>&1; then echo "SKIP: tmux is unavailable"; exit 0; fi
id=$("$ROOT/scripts/archy-session" start demo "$TMP/work")
test -f "$TMP/state/$id.json"
grep -q "$id" <("$ROOT/scripts/archy-session" list)
"$ROOT/scripts/archy-session" rename "$id" renamed
grep -q $'renamed\tdetached' <("$ROOT/scripts/archy-session" list)
"$ROOT/scripts/archy-session" end "$id"
grep -q $'renamed\tended' <("$ROOT/scripts/archy-session" list)
test -d "$TMP/work"; echo "archy-session lifecycle passed"