diff --git a/.claude/commands/release-notes.md b/.claude/commands/release-notes.md index cd20429..26fca4e 100644 --- a/.claude/commands/release-notes.md +++ b/.claude/commands/release-notes.md @@ -1,7 +1,7 @@ --- -description: Generate a short, curated CHANGELOG.md entry from the commits since the last release +description: Curate a CHANGELOG.md entry from commits since the last release; with a bump arg, also version, commit, push, and publish to npm argument-hint: "[patch|minor|major]" -allowed-tools: Bash(git describe:*), Bash(git log:*), Bash(git tag:*), Bash(git rev-list:*), Bash(node:*), Read, Edit +allowed-tools: Bash(git describe:*), Bash(git log:*), Bash(git tag:*), Bash(git rev-list:*), Bash(git rev-parse:*), Bash(git status:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(node:*), Bash(npm version:*), Bash(npm publish:*), Bash(npm view:*), Bash(cp:*), Bash(pnpm:*), Read, Edit --- You are writing the next release-notes entry for `@madarco/agentbox`. The @@ -56,7 +56,56 @@ changelog is at `apps/cli/CHANGELOG.md` (Keep a Changelog format). Produce ``` Use today's real date — get it from the environment context, do not invent one. -- Do **not** bump `package.json` or create a git tag — that happens at publish - time (`pnpm --filter @madarco/agentbox run publish:`). -- Print the entry you wrote and stop, so the user can review and edit before - releasing. +- Print the entry you wrote. + +## 6. Release (only when `$ARGUMENTS` named a bump) + +If `$ARGUMENTS` did **not** name a bump (`patch` / `minor` / `major`), stop here so +the user can review and edit the changelog before releasing — do not bump or push. + +Otherwise continue. **This publishes to a public registry and is irreversible**, so +get the user's explicit go-ahead at step 6.4 before publishing. + +1. **Bump `package.json` (no commit, no tag yet).** Section 5 just edited the + changelog, so the tree is dirty and a plain `npm version` would abort with + `EGITDIRTYWORKINGDIR`. Bump the version field only, from the package dir: + `cd apps/cli && npm version --no-git-tag-version` + (this is the version you already wrote into the changelog heading). + +2. **Commit the changelog + bump together, and tag.** One commit: + ``` + git add apps/cli/CHANGELOG.md apps/cli/package.json + git commit -m "release: v" + git tag v + ``` + (Stage whatever actually changed — add the root `CHANGELOG.md` too if you edited it.) + +3. **Push the commit and tag.** Check the current branch first (`git rev-parse + --abbrev-ref HEAD`). If it is not `main`, tell the user and confirm they want to + release from this branch. Then: `git push --follow-tags`. + +4. **Confirm before publishing.** Restate package (`@madarco/agentbox`), the new + version, and the branch. Verify the version is not already on the registry + (`npm view @madarco/agentbox@ version` should print nothing). Get + an explicit go-ahead. + +5. **Publish and surface the MFA link.** From the package dir (`prepublishOnly` + rebuilds the whole workspace first, so this also runs the full build): + `cd apps/cli && npm publish --auth-type=web` + - With 2FA enabled, npm prints a web-auth URL: + ``` + npm notice Authenticate your account at: + npm notice https://www.npmjs.com/auth/cli/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + ``` + **Show that full URL to the user immediately and prominently** ("Open this to + authorize the publish: "). Keep the command running in the **foreground** — + npm completes the publish automatically once the browser approval lands. Do not + cancel or background it. + - **Classic TOTP fallback:** if npm instead asks for a one-time password + (`This operation requires a one-time password`), ask the user for the 6-digit + code and re-run with `--otp=`. + +6. **Confirm success.** `npm view @madarco/agentbox version` should now show + . Report the published version, the pushed tag, and the commit. If + `npm publish` fails (already-published version, auth, build), report the exact + error and stop — do not retry blindly. diff --git a/.gitignore b/.gitignore index e070eca..07d4fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,8 @@ coverage/ .vscode/ .idea/ +# Claude Code session-specific runtime state (lock files, etc.) — keep +# .claude/commands and other intentional config tracked. +.claude/scheduled_tasks.lock + TODO.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 210e195..f06ac32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,4 @@ Each topic has a dedicated file under [`docs/`](./docs). Read the relevant one b - [`docs/daytona-backlog.md`](./docs/daytona-backlog.md) — what's done vs still missing on the Daytona path. Quick index of where each cloud feature actually lives. - [`docs/hertzner_backlog.md`](./docs/hertzner_backlog.md) — Hetzner provider build-out status: phase-by-phase progress, the live e2e smoke results, deferred follow-ups (per-project snapshot tier, `--pause` checkpoint flag, `agentbox prune --provider hetzner`, the install-script post-Chromium trace mystery). Filename uses the user-requested spelling. - [`docs/vercel-backlog.md`](./docs/vercel-backlog.md) — Vercel provider build-out status: why Vercel's shape differs (no Dockerfile, no containers, no SSH, persistent snapshots), phase-by-phase progress, and the live-verify checklist (user mapping, attach latency / ttyd upgrade, snapshot-vs-delete cascade, VNC on AL2023, published-CLI asset staging). +- [`docs/linux-host-backlog.md`](./docs/linux-host-backlog.md) — Linux (Ubuntu) **host** support: what's done (`agentbox doctor` is Linux-aware), how to test on a persistent Hetzner Ubuntu VM (`scripts/linux-dev-vm.sh` — `up`/`deploy`/`ssh`/`doctor`/`down`), and the remaining macOS-only host assumptions (browser `open`→`xdg-open`, iTerm2/AppleScript terminal spawning, OrbStack-only fast paths). diff --git a/apps/cli/src/commands/_cloud-agent-create.ts b/apps/cli/src/commands/_cloud-agent-create.ts index a4c9e7e..d61a05f 100644 --- a/apps/cli/src/commands/_cloud-agent-create.ts +++ b/apps/cli/src/commands/_cloud-agent-create.ts @@ -16,10 +16,10 @@ * only runs when the caller pre-resolved a non-docker provider. */ -import { log, outro } from '@clack/prompts'; import type { BoxRecord, CreateBoxRequest, Provider } from '@agentbox/core'; import type { AttachOpenIn } from '@agentbox/config'; import { makeProgressReporter } from '../lib/progress.js'; +import { printLaunchRecap } from '../lib/launch-recap.js'; import { cloudAgentAttach } from './_cloud-attach.js'; export interface CloudAgentCreateArgs { @@ -71,12 +71,7 @@ export async function cloudAgentCreate(args: CloudAgentCreateArgs): Promise b.id === boxId); if (!box) return 'box not found'; const { url } = webTarget(box); - detach('open', [url]); + detach(hostOpenCommand(), [url]); return `Opening ${url.replace(/^https?:\/\//, '')}…`; }; diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 6d9798b..d37730e 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { BoxRecord } from '@agentbox/core'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { openBoxInFinder } from '@agentbox/sandbox-docker'; import { Command } from 'commander'; import { resolveBoxOrExit } from '../box-ref.js'; @@ -156,9 +157,10 @@ async function runCloudOpen( if (mount.exitCode !== 0) { throw new Error(`sshfs mount failed (exit ${String(mount.exitCode)}): ${mount.stderr || mount.stdout}`); } - // `open` on macOS reveals the dir in Finder. On non-macOS this is a no-op - // / error — degrade silently because the mount path is already printed. - await execa('open', [mountRoot], { reject: false }); + // Reveal the mount in the OS file manager (Finder on macOS, the default + // handler via xdg-open on Linux). Best-effort — the mount path is already + // printed, so a missing opener degrades silently. + await execa(hostOpenCommand(), [mountRoot], { reject: false }); process.stdout.write(`opened ${mountRoot}\n`); process.stdout.write(`unmount later with: agentbox open ${box.name} --unmount\n`); } diff --git a/apps/cli/src/commands/opencode.ts b/apps/cli/src/commands/opencode.ts index 3688562..4d0ce54 100644 --- a/apps/cli/src/commands/opencode.ts +++ b/apps/cli/src/commands/opencode.ts @@ -56,6 +56,7 @@ import { providerForCreate } from '../provider/registry.js'; import { prepareTeleport, TeleportError } from '../session-teleport/index.js'; import { clampSpinnerLine } from '../spinner-line.js'; import { makeProgressReporter } from '../lib/progress.js'; +import { printLaunchRecap } from '../lib/launch-recap.js'; import { openCommandLog } from '../lib/log-file.js'; import { resolveLimits } from '../limits.js'; import { maybePromptPortless } from '../portless-prompt.js'; @@ -620,15 +621,21 @@ export const opencodeCommand = new Command('opencode') typeof result.record.projectIndex === 'number' ? ` · n ${String(result.record.projectIndex)}` : ''; - s.stop(`box ${result.record.container} ready${nSuffix}`); + s.stop(`box ready${nSuffix}`); + await printLaunchRecap({ + record: result.record, + mode: 'opencode', + reattach: reattachRef(result.record), + workspacePath: opts.workspace, + fromBranch, + useBranch, + checkpointRef, + attaching: opts.attach !== false, + }); if (opts.attach === false) { - outro( - `session started — attach with: agentbox opencode attach ${reattachRef(result.record)}`, - ); return; } - outro('attaching — Control+a d to detach, leaves opencode running'); await attachOpencodeWrapped( result.record, sessionName, diff --git a/apps/cli/src/commands/screen.ts b/apps/cli/src/commands/screen.ts index c30ffd9..09446a2 100644 --- a/apps/cli/src/commands/screen.ts +++ b/apps/cli/src/commands/screen.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { log } from '@clack/prompts'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { buildVncUrls, detectEngine, @@ -173,7 +174,7 @@ export const screenCommand = new Command('screen') return; } - const opened = spawnSync('open', [url], { stdio: 'inherit' }); + const opened = spawnSync(hostOpenCommand(), [url], { stdio: 'inherit' }); if (opened.status !== 0) { throw new Error(`open ${url} failed (exit ${String(opened.status ?? 'n/a')})`); } diff --git a/apps/cli/src/commands/url.ts b/apps/cli/src/commands/url.ts index db43946..8bd96a1 100644 --- a/apps/cli/src/commands/url.ts +++ b/apps/cli/src/commands/url.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { log } from '@clack/prompts'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { detectEngine, getBoxHostPaths, @@ -119,7 +120,7 @@ export const urlCommand = new Command('url') return; } - const opened = spawnSync('open', [url], { stdio: 'inherit' }); + const opened = spawnSync(hostOpenCommand(), [url], { stdio: 'inherit' }); if (opened.status !== 0) { throw new Error(`open ${url} failed (exit ${String(opened.status ?? 'n/a')})`); } diff --git a/apps/cli/src/lib/doctor-checks.ts b/apps/cli/src/lib/doctor-checks.ts index a57c0b0..1d32f7b 100644 --- a/apps/cli/src/lib/doctor-checks.ts +++ b/apps/cli/src/lib/doctor-checks.ts @@ -71,7 +71,13 @@ function checkNode(): CheckResult { } function checkPlatform(): CheckResult { - return { label: 'platform', status: 'ok', detail: `${process.platform}/${process.arch}` }; + const supported = process.platform === 'darwin' || process.platform === 'linux'; + return { + label: 'platform', + status: supported ? 'ok' : 'warn', + detail: `${process.platform}/${process.arch}`, + hint: supported ? undefined : 'agentbox supports macOS and Linux hosts; this OS is untested', + }; } function checkAgentboxHome(): CheckResult { @@ -121,6 +127,7 @@ export async function runSystemChecks(): Promise { } async function dockerChecks(): Promise { + const linux = process.platform === 'linux'; const cli = await probeVersion('docker'); if (!cli) { return [ @@ -128,23 +135,38 @@ async function dockerChecks(): Promise { label: 'docker cli', status: 'warn', detail: 'not found', - hint: 'install Docker Desktop, OrbStack, or docker engine', + hint: linux + ? 'install docker engine: https://docs.docker.com/engine/install/' + : 'install Docker Desktop, OrbStack, or docker engine', }, ]; } const cliRes: CheckResult = { label: 'docker cli', status: 'ok', detail: cli }; // Daemon reachability via `docker info` (same probe pattern as - // packages/sandbox-docker/src/docker.ts:dockerInfo). + // packages/sandbox-docker/src/docker.ts:dockerInfo). On Linux the most common + // failure is not a stopped daemon but the user missing from the `docker` + // group — `docker info` then exits non-zero with "permission denied" on the + // socket. Distinguish the two so the hint points at the right fix. const info = await execa('docker', ['info'], { reject: false }); if (info.exitCode !== 0) { + const permDenied = `${info.stderr ?? ''}`.toLowerCase().includes('permission denied'); + let hint: string; + if (permDenied && linux) { + hint = + 'add your user to the docker group: `sudo usermod -aG docker $USER`, then log out/in (or run `newgrp docker`)'; + } else if (linux) { + hint = 'start Docker: `sudo systemctl start docker` (install docker engine if missing)'; + } else { + hint = 'start Docker (Desktop / OrbStack)'; + } return [ cliRes, { label: 'docker daemon', status: 'warn', - detail: 'unreachable', - hint: 'start Docker (Desktop / OrbStack / `systemctl start docker`)', + detail: permDenied ? 'permission denied' : 'unreachable', + hint, }, ]; } diff --git a/apps/cli/src/lib/launch-recap.ts b/apps/cli/src/lib/launch-recap.ts new file mode 100644 index 0000000..0849aac --- /dev/null +++ b/apps/cli/src/lib/launch-recap.ts @@ -0,0 +1,86 @@ +/** + * `printLaunchRecap` — the single bordered "card" shown after an agent box is + * provisioned, replacing the old scatter of `●` rows (`id:`/`provider:`/ + * `sandboxId:`) and the split detach/reattach hints. Rendered identically for + * docker and cloud, for `claude` / `codex` / `opencode` / `shell`, so the + * post-create surface stays consistent across every launch path. + * + * The card shows the box name (+ source checkpoint when started from one), the + * project folder (the dir that actually held `agentbox.yaml`, which may be a + * parent of cwd), the `from → to` branch mapping, and the detach/reattach + * instructions on the last line. + */ +import { homedir } from 'node:os'; +import { note } from '@clack/prompts'; +import type { BoxRecord } from '@agentbox/core'; +import { currentHostBranch } from './from-branch.js'; + +export interface LaunchRecapArgs { + record: BoxRecord; + mode: 'claude' | 'codex' | 'opencode' | 'shell'; + /** Reattach ref shown in the hint: the per-project index `n` or the box name. */ + reattach: string; + /** Host repo path — used to resolve the base branch label when none was given. */ + workspacePath: string; + /** Resolved `--from-branch` (base ref), if any. */ + fromBranch?: string; + /** Resolved `--use-branch` (reused existing branch), if any. */ + useBranch?: string; + /** Source checkpoint the box started from, when applicable. */ + checkpointRef?: string; + /** true → attaching now (detach hint); false → background create (attach hint). */ + attaching: boolean; +} + +/** Collapse an absolute path under $HOME to a `~/…` form for display. */ +function homeShorten(p: string): string { + const home = homedir(); + return p === home || p.startsWith(home + '/') ? '~' + p.slice(home.length) : p; +} + +/** + * clack's `note` wraps every body line in `color.dim`, which renders dark gray. + * Prefix each line with reset + bright-white so the recap reads in plain white; + * the leading reset cancels the dim attribute clack opened. `strip-ansi` length + * (clack's padding math) ignores these codes, so box alignment is unaffected. + */ +function whiten(text: string): string { + if (process.env.NO_COLOR) return text; + return text + .split('\n') + .map((line) => `\x1b[0m\x1b[97m${line}\x1b[0m`) + .join('\n'); +} + +export async function printLaunchRecap(args: LaunchRecapArgs): Promise { + const { record } = args; + const rows: Array<[string, string]> = []; + + rows.push([ + 'box', + args.checkpointRef ? `${record.name} (${args.checkpointRef})` : record.name, + ]); + + if (record.projectRoot) { + rows.push(['project', homeShorten(record.projectRoot)]); + } + + const toBranch = record.gitWorktrees?.find((w) => w.kind === 'root')?.branch; + if (toBranch) { + if (args.useBranch) { + rows.push(['branch', `${toBranch} (reused)`]); + } else { + const base = args.fromBranch ?? (await currentHostBranch(args.workspacePath)) ?? 'HEAD'; + rows.push(['branch', `${base} → ${toBranch}`]); + } + } + + const pad = Math.max(...rows.map(([label]) => label.length)) + 2; + const body = rows.map(([label, value]) => `${label.padEnd(pad)}${value}`).join('\n'); + + const instruction = args.attaching + ? `Ctrl+a d to detach. Reattach with: agentbox ${args.mode} attach ${args.reattach}` + : `Attach with: agentbox ${args.mode} attach ${args.reattach}`; + + note(whiten(`${body}\n\n${instruction}`)); +} diff --git a/apps/cli/src/lib/paste-image.ts b/apps/cli/src/lib/paste-image.ts index 4b8fb81..58d54fd 100644 --- a/apps/cli/src/lib/paste-image.ts +++ b/apps/cli/src/lib/paste-image.ts @@ -11,7 +11,7 @@ * "paste image from clipboard" binding reads the now-populated selection. * * All steps go through the provider-neutral `Provider` seam (`uploadPath` + - * `exec`), so it works identically on docker / daytona / hetzner. + * `exec`), so it works identically on docker / daytona / hetzner / vercel. */ import { rm } from 'node:fs/promises'; diff --git a/apps/cli/src/terminal/host.ts b/apps/cli/src/terminal/host.ts index 0494807..c170f6c 100644 --- a/apps/cli/src/terminal/host.ts +++ b/apps/cli/src/terminal/host.ts @@ -8,8 +8,11 @@ export type HostTerminal = 'tmux' | 'iterm2' | 'unknown'; * when nested — when `TMUX` is set, the tmux CLI is the right primitive (it can * split the current pane / open a new window without going through AppleScript). * - * macOS-only by design: the CLI itself is macOS-only (see CLAUDE.md), so we - * don't try to recognize gnome-terminal / alacritty / Windows Terminal. + * tmux is recognized on every host (macOS + Linux) — its CLI is the portable + * primitive. The iTerm2 path is macOS-only (it drives AppleScript). On Linux we + * deliberately don't recognize native emulators (gnome-terminal / alacritty / + * konsole) yet: outside tmux the caller falls back to attaching in the current + * terminal. See docs/linux-host-backlog.md. */ export function detectHostTerminal(env: NodeJS.ProcessEnv = process.env): HostTerminal { const tmux = env['TMUX']; @@ -105,7 +108,7 @@ async function spawnInTmux(args: SpawnInNewTerminalArgs): Promise { + // original keypress so the inner program reads the (now-loaded) box clipboard. + // `reemit` is the exact byte sequence we swallowed — a raw 0x16, or the CSI-u / + // modifyOtherKeys encoding when an enhanced keyboard protocol is active — so + // the inner program (Claude) sees the encoding it negotiated. A press while one + // is in flight is dropped — the Ctrl+V was already swallowed by the caller, so + // there's nothing to forward. + const triggerPaste = (reemit: Buffer): void => { if (pasteInFlight) return; pasteInFlight = true; const done = (): void => { pasteInFlight = false; - if (!disposed) opts.onForward(Buffer.from([KEY_CTRL_V])); + if (!disposed) opts.onForward(reemit); }; void Promise.resolve() .then(() => onPasteImage?.()) @@ -333,11 +337,26 @@ export function createInputRouter(opts: InputRouterOptions): InputRouter { continue; } } + // Ctrl+V re-encoded by an enhanced keyboard protocol (kitty / modifyOtherKeys). + // The inner app (Claude) can flip this on, in which case the host terminal + // sends `ESC [ 118 ; 5 u` instead of a raw 0x16 — mirror the leader handling + // above so the paste hook still fires. + if (pasteEnabled && byte === KEY_ESC) { + const k = parseCsiKey(buf, i); + if (k && k.ctrl && k.code === KEY_V_LOW) { + flushChunk(i); + const seq = Buffer.from(buf.subarray(i, i + k.len)); + i += k.len; + chunkStart = i; // swallow it; triggerPaste re-emits after the load + triggerPaste(seq); + continue; + } + } if (pasteEnabled && byte === KEY_CTRL_V) { flushChunk(i); // forward everything typed before the Ctrl+V i += 1; chunkStart = i; // swallow it; triggerPaste re-emits after the load - triggerPaste(); + triggerPaste(Buffer.from([KEY_CTRL_V])); continue; } i += 1; diff --git a/apps/cli/test/wrapped-pty/input-router.test.ts b/apps/cli/test/wrapped-pty/input-router.test.ts index 55e15b5..6e1695a 100644 --- a/apps/cli/test/wrapped-pty/input-router.test.ts +++ b/apps/cli/test/wrapped-pty/input-router.test.ts @@ -368,6 +368,35 @@ describe('input router (Ctrl+V image paste)', () => { await flushMicrotasks(); expect(Buffer.concat(s.forwarded)).toEqual(Buffer.from('abcd\x16')); }); + + it('intercepts kitty-encoded Ctrl+V (ESC[118;5u): re-emits the same sequence', async () => { + const s = pasteSetup(); + s.router.feed(Buffer.from('\x1b[118;5u', 'latin1')); + await flushMicrotasks(); + expect(s.calls()).toBe(1); + expect(s.forwarded).toHaveLength(0); // nothing forwarded until the load finishes + s.resolveAll(); + await flushMicrotasks(); + expect(Buffer.concat(s.forwarded)).toEqual(Buffer.from('\x1b[118;5u', 'latin1')); + }); + + it('intercepts modifyOtherKeys Ctrl+V (ESC[27;5;118~)', async () => { + const s = pasteSetup(); + s.router.feed(Buffer.from('\x1b[27;5;118~', 'latin1')); + await flushMicrotasks(); + expect(s.calls()).toBe(1); + s.resolveAll(); + await flushMicrotasks(); + expect(Buffer.concat(s.forwarded)).toEqual(Buffer.from('\x1b[27;5;118~', 'latin1')); + }); + + it('does NOT intercept a non-ctrl kitty "v" (ESC[118u) — forwards verbatim', async () => { + const s = pasteSetup(); + s.router.feed(Buffer.from('\x1b[118u', 'latin1')); + await flushMicrotasks(); + expect(s.calls()).toBe(0); + expect(Buffer.concat(s.forwarded)).toEqual(Buffer.from('\x1b[118u', 'latin1')); + }); }); describe('input router (enhanced keyboard: kitty / modifyOtherKeys)', () => { diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000..9e3c7fb --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1,41 @@ +# apps/web — marketing site + +The public marketing/landing site for AgentBox. Deployed on Vercel. + +## What it is + +- A **hand-written static site**, NOT a framework (no Astro/Vite/Next/React). +- The whole site is a single file: **`index.html`** — markup plus one big inline `