▄▄███▄▄ AETHER CODE
- ▄█████████▄ v0.1.0
- ███▄███▄███ [ /model - opus ] [ /effort MAX ]
- ▀████▄████▀ /mcp need to fix aether agent? run /doctor
- ▀ ▀ ▀ ▀
-
- an open-source coding agent for your terminal -- cloud or --local. /help for commands.
- session: refactor src/auth.ts → async/await + tests · /model opus · /effort MAX
-
-·==[ SCAN ]== (๑•̀ㅂ•́)و✧✎
-• - mapping imports of src/auth.ts
-● tool_call: read_file
-◉ - src/auth.ts 142 lines · 4 callbacks
-● - src/session.ts 88 lines
-• * grep_symbol find all callers before the rewrite
-· turn 1
-· status: scan pool 2/9
-• telemetry: 1840 tok
-● [OK] mapped: 2 files, 7 call sites (remaining=3, stage complete)
-◉==[ REASON ]== (๑•̀ㅂ•́)و✧✎
-● - planning the async/await conversion
-• * plan_edit 4 callbacks → async · wrap login() in try/catch
-· - approach agreement 0.93 → accept
-· [OK] plan locked: 4 hunks + 1 test file (remaining=2, plan converged)
-•==[ EDIT ]== (๑•̀ㅂ•́)و✧✎
-● - applying hunks to src/auth.ts
-◉ tool_call: apply_patch
-● write tests/auth.test.ts 6 cases — login · refresh · expiry · bad-token
-· checkpoint a91f4c2
-· [OK] edited 2 files (+118 −74) (remaining=1, edits applied)
-●==[ VERIFY ]== (๑•̀ㅂ•́)و✧✎
-◉ - running test command
-● !! 1 failing: expiry not awaited -- patching ( Ò﹏Ó)✎
-• - re-run after the fix
-· [OK] 6 passed in 0.42s (remaining=0, green)
-
- anchoring context _φ(°-°=) local/cache [ 412.6M / 1.17B tokens ] |███████████░░░░░░░░░░░░░░░░░░░| 35.4%
-
-------------------------------------------------------------------------
-· aether›
-------------------------------------------------------------------------
-
-
\ No newline at end of file
diff --git a/assets/aether_agent_console.png b/assets/aether_agent_console.png
deleted file mode 100644
index 18a127d..0000000
Binary files a/assets/aether_agent_console.png and /dev/null differ
diff --git a/assets/card_commands.png b/assets/card_commands.png
deleted file mode 100644
index bcf3f19..0000000
Binary files a/assets/card_commands.png and /dev/null differ
diff --git a/assets/card_main.png b/assets/card_main.png
deleted file mode 100644
index 417e329..0000000
Binary files a/assets/card_main.png and /dev/null differ
diff --git a/assets/card_models.png b/assets/card_models.png
deleted file mode 100644
index 36e74cb..0000000
Binary files a/assets/card_models.png and /dev/null differ
diff --git a/cleaningnotes.md b/cleaningnotes.md
new file mode 100644
index 0000000..e370b1f
--- /dev/null
+++ b/cleaningnotes.md
@@ -0,0 +1,140 @@
+# cleaningnotes.md — 2026-07-01 cleanup pass
+
+Branch: `chore/repo-cleanup-audit`. Full-repo audit + bounded cleanup of
+`aether-agent`. Cross-references [scource.md](scource.md) — file/line
+citations below point at locations as surveyed before this pass; see
+scource.md §1 for the post-split file map.
+
+## Method
+
+3 parallel read-only surveys covering `src/core/` (48 files), `src/ui/` (33
+files), `src/commands/` + root (28 files + root config). Findings
+synthesized, then a bounded set of high-value / low-risk fixes applied —
+not every finding was acted on; see scource.md §4 for the tracked backlog of
+what was found but deliberately left alone (and why).
+
+Verification after every batch of changes: `npm run build` (tsc, strict
+mode) + `npm test` (node:test, 468 tests). Both clean after all changes
+below — see "Verification" at the end.
+
+## Changes made
+
+### 1. Dead weight removed
+
+- `docs/index.html` — deleted. 186-line HTML file that was actually a
+ 1.4MB unused bundler-loading placeholder page (embedded base64 SVG
+ thumbnail, `__bundler_loading`/`__bundler_thumbnail` DOM ids). Zero
+ references anywhere in the repo, not part of `package.json`'s `files`
+ allowlist, only ever touched by the squash-merged PR #32.
+- `web-cloud.png` (root, ~57KB) — deleted. Zero references; README embeds
+ images via `github.com/user-attachments/assets/...` URLs, not local files.
+- `assets/` (6 files: `aether_agent_card.png`, `aether_agent_console.html`,
+ `aether_agent_console.png`, `card_commands.png`, `card_main.png`,
+ `card_models.png`) — deleted, whole folder. Only reference anywhere is a
+ historical spec doc (`docs/specs/2026-06-08-aether-agent-rename-design.md`)
+ pointing at the *pre-rebrand* filename `aether_code_console.html`, which
+ doesn't even match — confirms these were already orphaned before the
+ rename.
+
+Cross-ref: scource.md §1 repo map, §3.
+
+### 2. Dead code removed
+
+- `notYet(feature)` — deleted from both `src/commands/vault.ts:45` and
+ `src/commands/workflow.ts:44`. Byte-for-byte duplicated helper, zero
+ callers in either file (confirmed via repo-wide grep before deleting).
+- `_lastMediaPrompt` — module-private `let` in old `slash.ts:72`, written 5
+ times (`photogenSlash`, `reframeSlash`, `videogenSlash`, `animateSlash`,
+ `recutSlash`) but never read anywhere. Dropped when the media handlers
+ moved to `slash_media.ts`.
+
+Cross-ref: scource.md §3.
+
+### 3. Dead imports removed
+
+Old `slash.ts` imported 8 symbols that were never referenced in the file
+body (confirmed by grep — each name appeared exactly once, in its own
+import line): `filterMediaModels`, `ASPECT_RATIOS`, `IMAGE_SHORTCUTS`,
+`VIDEO_SHORTCUTS` (from `core/vision.js`), `createTimer`, `HudElementId`
+(from `core/hud.js`), `titledBox` (from `ui/box.js`), `goalHelp` (from
+`./goals.js`). None were carried into the split files.
+
+### 4. `fail()`/`errMsg()` duplication consolidated
+
+5 near-identical error-formatter functions across `media.ts`, `output.ts`,
+`vault.ts`, `workflow.ts` (`fail(err)` — print `✗ ` [+ optional login
+hint], return 1) and `github.ts` (`errMsg(err)` — return just the message
+string, callers compose their own write). Added to `src/core/errors.ts`:
+
+```ts
+export function errorMessage(err: unknown): string
+export function fail(err: unknown, hint?: string): number
+```
+
+Each of the 5 files now imports and calls the shared version — `media.ts`,
+`vault.ts`, `workflow.ts` keep a one-line local `fail()` wrapper that
+supplies their fixed login hint (output byte-identical to before);
+`output.ts` calls `fail(err)` directly (no hint, matches its original
+no-hint behavior); `github.ts` uses `errorMessage()` for its one custom-
+prefixed message and `fail()`/`fail(err, hint)` for the other three.
+
+Cross-ref: scource.md §3. The near-duplicate `hashOf`/`hashShortCustody`
+pair was found in the same sweep but deliberately **not** merged — see
+scource.md §4 for why (subtly different fallback semantics, audit-adjacent
+code, not worth the regression risk for a cosmetic dedup).
+
+### 5. `commands/slash.ts` split (1807 → 522 lines)
+
+The repo's own convention caps files at ~800 lines (per user-global
+coding-style rules already in effect for this session); `slash.ts` was over
+2x that. Full read of all 1808 lines, then split into a thin dispatcher +
+7 concern files — table and dependency map in scource.md §1.
+
+Constraints honored:
+- `handleSlash`'s switch statement stayed physically in `slash.ts`,
+ 4-space case indentation unchanged — `test/slash_registry.test.ts` parses
+ this exact text via regex to verify registry↔switch sync. Verified this
+ test still passes.
+- The 3 symbols `test/slash.test.ts` imports directly
+ (`resolveSelection`, `handleSlash`, `primeCatalog`) all still exported
+ from `slash.ts` with identical signatures/behavior.
+- Every extracted function moved verbatim (no logic changes) — only
+ import lines and file boundaries changed. Cross-checked with `git diff`
+ reasoning during the split, confirmed via full test suite pass after.
+
+New files: `slash_context.ts`, `slash_git_tools.ts`, `slash_codegen.ts`,
+`slash_hud.ts`, `slash_vault_workflow.ts`, `slash_orchestra.ts`,
+`slash_media.ts`. Largest is `slash_media.ts` at 303 lines — well under the
+repo's ~800-line convention.
+
+Note left inline in `slash.ts`, not extracted: `/mcp` (dynamic-imports
+`./mcp.js`), `/logs`/`/logs-view` (calls `ui/logs_viewer.ts`), and the
+`token-budget`/`limit` cases both intentionally route to the same
+`limitSlash` — `token-budget` is a documented alias, not a duplicate case
+left over from a copy-paste.
+
+## Verification
+
+```
+npm run build → clean, 0 errors (tsc --strict)
+npm test → 468/468 pass (1 flaky failure on first run, reran clean —
+ unrelated to this change, see below)
+```
+
+Note on the flaky test: first `npm test` run showed 467/468 pass with one
+unspecified failure that didn't reproduce; immediate rerun was 468/468 clean.
+Not chased further since it didn't reproduce and isn't in any file touched
+by this pass — flagged here for visibility, not silently ignored.
+
+## Not done (see scource.md §4 for the full, prioritized list)
+
+Deliberately out of scope for this pass — found during the survey, logged
+with file:line precision in scource.md, left for a dedicated follow-up PR:
+cross-file duplication in `ui/` (progress bars, width-padding, ANSI escape
+constants, kaomoji maps), a repo-wide magic-number sweep, further god-file
+splits (`chat.ts`, `core/vision.ts`, `core/workflow.ts`, `ui/tui_layout.ts`),
+and several misplaced-logic items (presentation code in `core/`, business
+logic in `commands/`). Reasoning for stopping here: those items either carry
+real regression risk for a mechanical pass (audit/custody code, the REPL
+input engine) or are high-volume/low-risk items better batched as their own
+reviewable PR rather than bundled into this one.
diff --git a/docs/index.html b/docs/index.html
deleted file mode 100644
index 0d75c98..0000000
--- a/docs/index.html
+++ /dev/null
@@ -1,187 +0,0 @@
-
-
-
-
- Aether Agent
-
-
-
-
-
-
-
-
Unpacking...
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/scource.md b/scource.md
new file mode 100644
index 0000000..32f939a
--- /dev/null
+++ b/scource.md
@@ -0,0 +1,204 @@
+# scource.md — aether-agent source of truth
+
+Dev's table of contents for this repo. Generated during the 2026-07-01 cleanup
+pass (branch `chore/repo-cleanup-audit`). Cross-references `cleaningnotes.md`
+for what was actually changed. Update this file when structure, canonical
+terms, or shared-constant ownership shifts — it's meant to stay current, not
+be a one-time snapshot.
+
+## 1. Repo map
+
+```
+src/
+ main.ts CLI entry — flag parsing, AppContext build, dispatch to cmdX handlers
+ index.ts Public library entry (@aether/cli) — re-exports client/stream/UI-embed surface
+ types.ts Shared config/catalog wire types (AetherConfig, CatalogItem, ...)
+ version.ts VERSION constant
+
+ core/ (48 files, ~6.7k lines) — business logic, network clients, protocol, no terminal I/O
+ ui/ (33 files, ~3.6k lines) — terminal rendering: ANSI, layout, input, pickers
+ commands/ (28 files, ~4.7k lines after split) — CLI/REPL command handlers, one file per command surface
+
+test/ (66 files) — node:test, mirrors src/ by concern (not 1:1 by file)
+docs/ — plans/ releases/ reviews/ security/ specs/ (dated design docs, historical)
+assets/ — REMOVED 2026-07-01 (was orphaned, see cleaningnotes.md #1)
+```
+
+### commands/ — slash-command group ownership (post-split)
+
+`slash.ts` was 1807 lines (repo convention caps files at ~800). Split into a
+thin dispatcher + 7 concern files. `handleSlash`'s switch statement stays
+physically in `slash.ts` — `test/slash_registry.test.ts` parses its raw
+source text (4-space-indented `case "x":` lines) to verify registry↔switch
+sync, so that switch must never move.
+
+| File | Owns | Depends on |
+|---|---|---|
+| `slash.ts` | dispatcher switch, catalog cache (`_catalog`/`getCatalog`/`primeCatalog`/`byKind`/`resolveSelection`), session handlers (`doctor`, `showPicker`, `select`, `showTier`, `showAudit`, `printHelp`), **plus 3 handlers left inline (not split out — small, single-use):** `/mcp` (dynamic-imports `./mcp.js`), `/logs`/`/logs-view` (calls `ui/logs_viewer.ts`), and a 12-case passthrough block (`/queue /steer /btw /plan /review /recon` etc. — one-line "handled in the REPL" stubs) | all 7 files below |
+| `slash_context.ts` | `/pin /drop /snapshot /limit /token-budget /audit-receipt /purge` | `core/context_registry.ts`, `core/custody.ts`, `core/audit.ts` |
+| `slash_git_tools.ts` | `/rollback /revert /stage-diff` | `core/stage_diff.ts`, raw `git` via `execSync` |
+| `slash_codegen.ts` | `/scaffold /port /test-drive /bench` | `core/scaffold.ts`, `core/port.ts`, `core/test_drive.ts`, `core/bench.ts` |
+| `slash_hud.ts` | `/add /hud` | `core/hud.ts`, `core/context_registry.ts` |
+| `slash_vault_workflow.ts` | `/vault-* /workflow*` | `core/vault.ts`, `core/workflow.ts` |
+| `slash_orchestra.ts` | `/agents /delegate /tree /broadcast /gather` | `core/orchestrator.ts` |
+| `slash_media.ts` | `/photogen /frame /re-frame /videogen /sequence /animate /re-cut /output /storyboard` | `core/vision.ts` |
+
+Other `commands/` files unchanged: `audit.ts`, `auth.ts`, `chat.ts` (725 lines
+— second largest in repo, candidate for a future split, see §4), `code.ts`,
+`config.ts`, `github.ts`, `goals.ts`, `login.ts`, `mcp.ts`, `media.ts`,
+`models.ts`, `output.ts`, `prompt_modes.ts`, `receipt.ts`, `resume.ts`,
+`run.ts`, `slash_registry.ts`, `vault.ts`, `workflow.ts`.
+
+## 2. Canonical terms (things that mean one thing, use consistently)
+
+- **UVT** — Unified Value Token, the metering unit for the model fleet. Used
+ everywhere (`bench.ts`, `port.ts`, `scaffold.ts`, `context_registry.ts`,
+ `transport.ts`, `render.ts`, `hud.ts`) but never spelled out in any single
+ source file — this doc is now the canonical definition.
+- **custody log** (client-held, `core/custody.ts`,
+ `~/.config/aether/custody.jsonl`) vs **audit trail** (server-side,
+ `core/audit.ts:fetchTrail`) vs **audit receipt** (`/audit-receipt`, merges
+ both). See `commands/audit.ts` header comment for the authoritative
+ explanation — promoted here so it survives file moves.
+- **backend** — overloaded. `core/backend.ts`'s `BackendPref`/`BackendPath`
+ mean "local vs cloud brain routing." Comments elsewhere ("push to the
+ AETHER-CLOUD backend") mean "the server side of the API" generically. Two
+ senses, same word — read context.
+- **stage** vs **phase** — `stage` (ui/animations.ts, ui/phase_verb.ts) means
+ agent *activity* (recon/reasoning/execute/self-review/…). `phase`
+ (ui/goal_chain.ts `GoalPhase`, ui/task_chain.ts, ui/workflow_viewer.ts)
+ means workflow/goal *phase* (numbered, agent-grouping). Not interchangeable.
+- **workspace** vs **worktree** — near-homophones, different meanings.
+ `core/workspace.ts` = the file-edit sandbox abstraction (`WorkspaceContext`,
+ `applyEdit`). `core/worktree.ts` = literal `git worktree` isolation.
+- **orchestrator** vs **agent** (3 senses) — *orchestrator* = a Neo/Kronus
+ catalog entry (`CatalogItem.kind === "orchestrator"`). *agent session* = a
+ live running worker (what `/agents` lists, from `GET /agents`). *`/agent`*
+ = the slash command that picks an orchestrator. `models.ts:cmdAgents` shows
+ the static orchestrator catalog; `slash_orchestra.ts:agentsSlash` shows
+ live running sessions — same English word, unrelated data sources.
+- **~/.aether-agent/** (sessions, history, snapshots, repo mirrors,
+ worktrees) vs **~/.config/aether/** (config, token, mcp.json, custody log,
+ goals) — two separate per-user data roots, never reconciled into one "my
+ Aether CLI data" directory. Know which one a given file lives under before
+ changing path logic.
+- **"neo-lite"** — proper-noun personality name (`ui/host_render.ts`), not
+ self-explanatory from the code alone.
+
+## 3. Fixed in this pass (2026-07-01) — see cleaningnotes.md for detail
+
+- Dead weight removed: `docs/index.html` (1.4MB unused bundler placeholder),
+ `web-cloud.png`, `assets/` (6 files, all orphaned).
+- Dead code removed: `notYet()` (duplicated, zero callers, `vault.ts` +
+ `workflow.ts`), `_lastMediaPrompt` (write-only module state, `slash.ts`).
+- Dead imports removed: `filterMediaModels`, `ASPECT_RATIOS`,
+ `IMAGE_SHORTCUTS`, `VIDEO_SHORTCUTS`, `createTimer`, `titledBox`,
+ `goalHelp`, `HudElementId` (all imported, never used, in old `slash.ts`);
+ plus (found by the adversarial reviewer via `tsc --noUnusedLocals`)
+ `mediaKind`/`listOutput`/`findOutput`/`clearOutput` in `media.ts`,
+ `TemplateInfo`/`WorkflowAssessResponse` in `commands/workflow.ts`, and
+ `VaultSpacesFile` in `core/workflow.ts`.
+- Duplication consolidated: `fail()`/`errMsg()` error-formatter (5 near-
+ identical copies across `media.ts`, `output.ts`, `vault.ts`, `workflow.ts`,
+ `github.ts`) → `core/errors.ts` exports `errorMessage()` + `fail()`.
+- God-file split: `commands/slash.ts` 1807 → 522 lines + 7 new focused files
+ (table in §1). No file in the split exceeds ~300 lines.
+
+## 4. Backlog — found, not fixed this pass (real follow-up work)
+
+Not touched because: lower value-to-risk ratio than the items above, or the
+fix is itself a nontrivial refactor that deserves its own reviewed PR rather
+than being bundled into a mechanical cleanup pass. Grouped by file/area so a
+future pass can grab one and go.
+
+**Duplication (cross-file, medium risk to unify):**
+- `hashOf` (`commands/audit.ts:69`) and `hashShortCustody`
+ (`commands/slash_context.ts`) are near-identical but not byte-identical
+ (different null/empty-string fallback, `hashOf` checks 4 fields,
+ `hashShortCustody` checks 3). Audit/custody-adjacent — unify only with
+ test coverage proving output is unchanged for both callers.
+- Poll-until-deadline loop duplicated 3x: `github.ts:pollUntilConnected`,
+ `mcp.ts:McpClient.pollUntilConnected`, `core/device.ts:pollForToken`.
+ `PollOpts { intervalSec, timeoutSec }` type also duplicated verbatim in
+ `github.ts` + `mcp.ts`.
+- 4 independent progress/fill-bar implementations in `ui/`: `progress.ts`
+ (canonical), `ui/statusbar.ts` inline, `ui/status_renderer.ts:bar()`,
+ `ui/tui_layout.ts:bar()`.
+- "Pad string to visible width" reimplemented in ≥5 `ui/` files despite
+ `ui/text.ts` claiming sole ownership of width math.
+- ANSI alt-screen/cursor escape codes (`\x1b[?1049h` etc.) hand-defined
+ independently in `ui/model_picker.ts`, `ui/tui_layout.ts`,
+ `ui/status_renderer.ts`, `ui/logs_viewer.ts` — `ui/restore.ts` claims to be
+ the "central terminal-restore registry" but doesn't own these constants.
+- Kaomoji/stage-glyph vocabulary duplicated across 4 maps with overlapping
+ keys: `ui/kaomoji.ts`, `ui/phase_verb.ts`, `ui/statusbar.ts`,
+ `ui/host_render.ts`.
+- JSON-file-read-with-fallback pattern (`try { JSON.parse(readFileSync) }
+ catch { default }`) reimplemented independently 6+ times across
+ `core/config.ts`, `core/goals.ts`, `core/mcp_store.ts`,
+ `core/context_registry.ts`, `core/session_resume.ts`, `core/vision.ts`.
+
+**More dead imports (found via `tsc --noUnusedLocals` during the adversarial
+review pass) — fixed since the files were already open in this diff:**
+`commands/media.ts` (`mediaKind`, `listOutput`, `findOutput`, `clearOutput`),
+`commands/workflow.ts` (`TemplateInfo`, `WorkflowAssessResponse` types),
+`core/workflow.ts` (`VaultSpacesFile` type). See cleaningnotes.md §6.
+
+**Still backlog:**
+- Repo-wide: dozens of unused `ctx: AppContext` parameters on handler
+ functions that don't need it (`tsc --noUnusedLocals --noUnusedParameters`
+ surfaces them all) — this is a consistent enough pattern across the
+ codebase that it reads as an intentional "handlers share one signature
+ shape" convention rather than accidental dead params; worth a deliberate
+ decision (enable `noUnusedParameters` + fix, or leave as house style)
+ rather than a silent mass-edit.
+
+**Magic numbers/strings (low risk, high volume — batch as its own PR):**
+- Timeouts: no shared constant across `ollama.ts`, `web.ts`,
+ `stage_diff.ts`, `test_drive.ts`, `tool_executor.ts`, `agent_events.ts`.
+- "Keep last N records" caps: `HISTORY_CAP=1000`, custody `MAX=500`,
+ vision output `>100` — three unrelated magic numbers for the same
+ "capped append log" concept.
+- `~/.aether-agent` root path constructed independently in 5 files instead
+ of one shared helper (contrast `core/config.ts`'s `configDir()`, which
+ does this correctly).
+- Model-key literals (`"vision_gpt_image2"`, `"vision_seedance"`) repeated
+ 7x across the old `slash.ts` (now split across `slash_media.ts`) instead
+ of named constants in `core/vision.ts`.
+
+**Oversized files (candidates for a future split, same pattern as §1):**
+- `commands/chat.ts` (725 lines) — `repl()` alone is ~455 lines mixing raw-
+ mode lifecycle, paste state machine, Ctrl-C handling, slash dispatch, and
+ HUD repaint. Candidate: extract the REPL engine to `ui/repl.ts`.
+- `core/vision.ts` (428 lines) — model routing + prompt building + download
+ engine + output log + storyboard parser in one file.
+- `core/workflow.ts` (418 lines) — ~165 lines are inline literal template
+ *data* (8 hardcoded workflow graphs) that belongs in a data file, not
+ logic code.
+- `ui/tui_layout.ts` (363 lines) — `TuiLayout` class is a god-object
+ (terminal lifecycle + pager state + 4 render methods + resize handling).
+- `commands/code.ts:cmdCode()` (200 lines, lines 106-304) — 6+
+ responsibilities in one function; the animated/plain render branches
+ duplicate structure.
+
+**Misplaced logic (presentation code sitting in core/, or vice versa):**
+- `core/hud.ts` and `core/render.ts` import from `src/ui/` and contain ANSI
+ rendering — arguably belong in `ui/`, not `core/`.
+- `commands/goals.ts:decomposeGoal` (pure heuristic, zero I/O) belongs in
+ `core/goals.ts` alongside the rest of the goal model.
+- `commands/auth.ts:renderAuthBox`/`renderLoggedOut` (~100 lines of ASCII-art
+ box rendering) belongs in `ui/`.
+- `ui/logs_viewer.ts:loadAllSessions`/`exportSession` do filesystem
+ read/write — data-access logic sitting in a `ui/` file.
+
+## 5. Number/constant relations worth knowing before touching them
+
+- `TOKENS_PER_GB = 233_000_000` (`ui/statusbar.ts`) — named, exported, but
+ the number itself is undocumented anywhere. Don't "simplify" it without
+ understanding where 233M comes from.
+- `CTRL_C_WINDOW_MS = 1500` (`commands/chat.ts`) and `heartbeatTimeoutMs:
+ 5000` (`commands/code.ts`) are the *only* named UX-timing constants in
+ `commands/` — everything else in the backlog above is a bare literal.
+- Box widths are inconsistent per-file: `BOX_W = 64` (`auth.ts`), `BOX = 74`
+ (`slash.ts`), inline `{ width: 60 }` (`slash_context.ts`) — three
+ unrelated hardcoded panel widths, no shared layout constant exists yet.
diff --git a/src/commands/github.ts b/src/commands/github.ts
index 15a67a9..919ad5b 100644
--- a/src/commands/github.ts
+++ b/src/commands/github.ts
@@ -8,6 +8,7 @@
import type { AppContext } from "../core/context.js";
import { openBrowser } from "../core/browser.js";
+import { fail as coreFail, errorMessage } from "../core/errors.js";
import {
getGithubStatus,
startGithubConnect,
@@ -63,8 +64,7 @@ async function githubStatus(ctx: AppContext): Promise {
process.stdout.write(renderStatus(s));
return s.connected ? 0 : 1;
} catch (err) {
- process.stderr.write(`✗ ${errMsg(err)}\n (are you logged in? run: aether auth login)\n`);
- return 1;
+ return coreFail(err, "are you logged in? run: aether auth login");
}
}
@@ -73,7 +73,7 @@ async function githubConnect(ctx: AppContext, opts: GithubOpts): Promise
try {
installUrl = await startGithubConnect(ctx.api);
} catch (err) {
- process.stderr.write(`✗ could not start GitHub connect: ${errMsg(err)}\n`);
+ process.stderr.write(`✗ could not start GitHub connect: ${errorMessage(err)}\n`);
return 1;
}
process.stdout.write(`\nTo link GitHub, open:\n ${installUrl}\n\n`);
@@ -85,8 +85,7 @@ async function githubConnect(ctx: AppContext, opts: GithubOpts): Promise
process.stdout.write(`✓ GitHub linked${s.login ? ` (${s.login})` : ""}.\n`);
return 0;
} catch (err) {
- process.stderr.write(`✗ ${errMsg(err)}\n`);
- return 1;
+ return coreFail(err);
}
}
@@ -96,11 +95,6 @@ async function githubDisconnect(ctx: AppContext): Promise {
process.stdout.write("GitHub unlinked.\n");
return 0;
} catch (err) {
- process.stderr.write(`✗ ${errMsg(err)}\n`);
- return 1;
+ return coreFail(err);
}
}
-
-function errMsg(err: unknown): string {
- return err instanceof Error ? err.message : String(err);
-}
diff --git a/src/commands/media.ts b/src/commands/media.ts
index 2ad93c2..b3a8318 100644
--- a/src/commands/media.ts
+++ b/src/commands/media.ts
@@ -7,10 +7,11 @@
import type { AppContext } from "../core/context.js";
import type { CatalogResponse } from "../types.js";
import { MODELS_PATH } from "../core/transport.js";
+import { fail as coreFail } from "../core/errors.js";
import {
- filterMediaModels, resolveModelKey, autoRouteModel, mediaKind,
+ filterMediaModels, resolveModelKey, autoRouteModel,
buildMediaPrompt, dispatchGeneration, downloadMediaFile,
- ensureOutputDir, recordOutput, listOutput, findOutput, openOutput, clearOutput,
+ ensureOutputDir, recordOutput, openOutput,
IMAGE_SHORTCUTS, VIDEO_SHORTCUTS,
type MediaKind, type MediaModel, type GenFlags, type GenResult,
} from "../core/vision.js";
@@ -235,7 +236,5 @@ function printMediaHelp(kind: MediaKind): void {
}
function fail(err: unknown): number {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`✗ ${msg}\n (are you logged in? run: aether auth login)\n`);
- return 1;
+ return coreFail(err, "are you logged in? run: aether auth login");
}
diff --git a/src/commands/output.ts b/src/commands/output.ts
index f2ca212..47321c4 100644
--- a/src/commands/output.ts
+++ b/src/commands/output.ts
@@ -5,6 +5,7 @@
import type { AppContext } from "../core/context.js";
import { listOutput, findOutput, openOutput, clearOutput } from "../core/vision.js";
import { theme } from "../ui/theme.js";
+import { fail } from "../core/errors.js";
export async function cmdOutput(ctx: AppContext, argv: string[]): Promise {
const sub = (argv[0] ?? "").toLowerCase();
@@ -50,8 +51,3 @@ async function outputClean(): Promise {
return 0;
}
-function fail(err: unknown): number {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`✗ ${msg}\n`);
- return 1;
-}
diff --git a/src/commands/slash.ts b/src/commands/slash.ts
index e7766ba..d1ab41d 100644
--- a/src/commands/slash.ts
+++ b/src/commands/slash.ts
@@ -12,49 +12,42 @@
// /audit [n] recent Aether audit trail
// /clear clear the screen
// /exit | /quit leave the REPL
+//
+// Command handlers live in sibling slash_*.ts files, grouped by concern
+// (context, git tools, codegen, HUD, vault/workflow, orchestra, media) to
+// keep each file under the repo's ~800-line convention. This file owns the
+// dispatcher switch, the models/orchestrators catalog cache, and the
+// session-level handlers (picker, doctor, help) that need direct access to
+// that cache.
import type { Writable } from "node:stream";
import type { AppContext } from "../core/context.js";
import type { CatalogItem, CatalogResponse } from "../types.js";
import { MODELS_PATH } from "../core/transport.js";
-import { AGENTS_PATH } from "../core/transport.js";
import { fetchTrail } from "../core/audit.js";
import { isApiToken } from "./auth.js";
-import { getVaultSnapshot, searchNotes, notesByTag, getNotesTree } from "../core/vault.js";
-import { WORKFLOW_TEMPLATES, listWorkflows } from "../core/workflow.js";
import { theme } from "../ui/theme.js";
import { SLASH_COMMANDS, SLASH_SECTIONS, findCommand, suggestCommand } from "./slash_registry.js";
-import { handleGoal, handleGoals, goalHelp } from "./goals.js";
-import { box, titledBox } from "../ui/box.js";
+import { handleGoal, handleGoals } from "./goals.js";
+import { box } from "../ui/box.js";
import { sliceVisible } from "../ui/text.js";
import { pickModel } from "../ui/model_picker.js";
import { runLogsViewer } from "../ui/logs_viewer.js";
-import { getRegistry, resetRegistry, saveSnapshot, loadSnapshot, listSnapshots, ContextRegistry, syncToBackend, loadFromBackend } from "../core/context_registry.js";
-import { readCustodyLog } from "../core/custody.js";
-import type { AuditEntry } from "../core/audit.js";
-import { existsSync } from "node:fs";
-import { join } from "node:path";
-import { delegateWorker, getOrchTree, broadcastToAgents, gatherResults, requireOrchestrator } from "../core/orchestrator.js";
+
+import { pinSlash, dropSlash, snapshotSlash, limitSlash, auditReceiptSlash, purgeSlash } from "./slash_context.js";
+import { rollbackSlash, revertSlash, stageDiffSlash } from "./slash_git_tools.js";
+import { scaffoldSlash, portSlash, testDriveSlash, benchSlash } from "./slash_codegen.js";
+import { addSlash, hudSlash } from "./slash_hud.js";
import {
- filterMediaModels, resolveModelKey, autoRouteModel,
- buildMediaPrompt, dispatchGeneration, downloadMediaFile,
- ensureOutputDir, recordOutput, listOutput, findOutput, openOutput, clearOutput,
- ASPECT_RATIOS, IMAGE_SHORTCUTS, VIDEO_SHORTCUTS,
- parseStoryboard, saveStoryboard, loadStoryboard, listStoryboards,
- type MediaKind, type GenFlags, type GenResult,
-} from "../core/vision.js";
-import { basename } from "node:path";
-import { existsSync as fsExistsSync } from "node:fs";
-import { generateScaffold, isValidScaffoldType, SCAFFOLD_USAGE, type ScaffoldType } from "../core/scaffold.js";
-import { portCode, readSource, writePortedFiles } from "../core/port.js";
-import { generateDiff } from "../core/stage_diff.js";
-import { startTestDrive } from "../core/test_drive.js";
-import { runBenchmark } from "../core/bench.js";
+ vaultStatusSlash, vaultContextSlash, vaultSearchSlash, vaultRecentSlash,
+ vaultProjectSlash, vaultTagSlash, vaultTreeSlash,
+ workflowSlash, workflowTemplatesSlash, workflowTemplateSlash,
+} from "./slash_vault_workflow.js";
+import { agentsSlash, delegateSlash, treeSlash, broadcastSlash, gatherSlash } from "./slash_orchestra.js";
import {
- HUD_ELEMENTS, resolveHudElement,
- renderHud, timerLive, createTimer,
- type HudElementId, type HudRenderState,
-} from "../core/hud.js";
+ photogenSlash, reframeSlash, videogenSlash, animateSlash, recutSlash,
+ outputSlash, storyboardSlash,
+} from "./slash_media.js";
export interface SlashResult {
exit: boolean;
@@ -65,13 +58,6 @@ export interface SlashResult {
type Kind = "model" | "orchestrator";
-// Media pipeline state — persists across turns in the same REPL session.
-// Cleared on REPL restart. Used by /re-frame and /re-cut.
-let _lastMediaUrl: string | null = null;
-let _lastMediaModel: string | null = null;
-let _lastMediaPrompt: string | null = null;
-let _lastMediaKind: MediaKind | null = null;
-
// Catalog is cached per REPL session; a fresh session re-fetches.
let _catalog: CatalogResponse | null = null;
@@ -300,9 +286,7 @@ export async function handleSlash(
await snapshotSlash(ctx, out, arg);
break;
case "limit":
- await limitSlash(ctx, out, arg);
- break;
- case "token-budget":
+ case "token-budget": // alias — same handler, not a leftover duplicate
await limitSlash(ctx, out, arg);
break;
case "audit-receipt":
@@ -352,519 +336,6 @@ export async function handleSlash(
return { exit: false };
}
-// ── /pin ──────────────────────────────────────
-
-async function pinSlash(ctx: AppContext, out: Writable, arg: string, _line: string): Promise {
- if (!arg.trim() || arg.trim() === "list" || arg.trim() === "ls") {
- const pins = getRegistry().pins;
- if (pins.length === 0) {
- out.write("(no pinned files — use /pin [reason] to pin one)\n");
- return;
- }
- out.write(theme.cyan("📌 Pinned Context\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
- for (const p of pins) {
- out.write(` ${theme.bold(p.label)} ${theme.dim(p.path)} ${theme.muted(p.reason)}\n`);
- }
- return;
- }
-
- const parts = arg.trim().split(/\s+/);
- const pth = parts[0]!;
- const reason = parts.slice(1).join(" ") || "pinned";
-
- const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth);
- const label = pth.split("/").pop() || pth;
-
- const entry = getRegistry().pin(resolved, label, reason);
- out.write(`${theme.cyan("📌 pinned")} ${theme.bold(entry.label)} ${theme.dim(entry.path)} (${entry.reason})\n`);
- out.write(theme.dim(" This file will persist in context across /recon and /autonomous-execution loops.\n"));
- syncAfter(ctx);
-}
-
-// ── /drop ─────────────────────────────────────
-
-async function dropSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!arg.trim()) {
- const registry = getRegistry();
- out.write("usage: /drop evict a file from memory context\n");
- out.write(" /drop src/core/old.ts\n");
- if (registry.pins.length > 0) {
- out.write("\n Pinned files (use /pin list for details):\n");
- for (const p of registry.pins) {
- out.write(` ${theme.dim(p.label)}\n`);
- }
- }
- if (registry.drops.length > 0) {
- out.write("\n Recently dropped:\n");
- for (const d of registry.drops.slice(-5)) {
- out.write(` ${theme.dim(d)}\n`);
- }
- }
- return;
- }
-
- const pth = arg.trim();
- const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth);
- const wasPinned = getRegistry().isPinned(resolved);
- getRegistry().drop(resolved);
-
- if (wasPinned) {
- out.write(`${theme.cyan("🗑 dropped")} ${theme.bold(pth)} — removed from pinned context\n`);
- } else {
- out.write(`${theme.cyan("🗑 evicted")} ${theme.dim(pth)}\n`);
- out.write(theme.dim(" (wasn't pinned, but will be excluded from future context loads)\n"));
- }
- syncAfter(ctx);
-}
-
-// ── /snapshot ─────────────────────────────────
-
-async function snapshotSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const registry = getRegistry();
- const sub = arg.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
-
- if (sub === "resume" || sub === "load") {
- const id = arg.trim().split(/\s+/).slice(1).join(" ");
- if (!id) {
- // Try cloud backend first
- const cloudLoaded = await loadFromBackend(ctx.api);
- if (cloudLoaded) {
- const reg = getRegistry();
- out.write(`${theme.cyan("☁ loaded from cloud")} ${theme.bold(reg.sessionLabel)} (${reg.pins.length} pins, cap ${reg.uvtCap ?? "none"})\n`);
- out.write(theme.dim(" Use /pin list to see restored context.\n"));
- return;
- }
- const snaps = listSnapshots();
- if (snaps.length === 0) {
- out.write("(no snapshots — use /snapshot to save one)\n");
- return;
- }
- out.write(theme.cyan("💾 Saved Snapshots\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
- for (const s of snaps) {
- const pinCnt = s.data.pins.length;
- const cap = s.data.uvtCap ? ` cap ${s.data.uvtCap} UVT` : "";
- out.write(` ${theme.bold(s.id)} ${theme.dim(s.data.createdAt)} ${s.data.sessionLabel} (${pinCnt} pins${cap})\n`);
- }
- out.write(theme.dim("\n /snapshot resume to restore a snapshot\n"));
- return;
- }
- const data = loadSnapshot(id);
- if (!data) {
- out.write(`no snapshot: ${id} (use /snapshot to list)\n`);
- return;
- }
- const restored = ContextRegistry.fromSnapshot(data);
- resetRegistry();
- Object.assign(getRegistry(), restored);
- out.write(`${theme.cyan("📂 restored")} ${theme.bold(data.sessionLabel)} (${data.pins.length} pins, cap ${data.uvtCap ?? "none"})\n`);
- out.write(` cwd: ${data.cwd}\n`);
- out.write(theme.dim(" Use /pin list to see restored context.\n"));
- return;
- }
-
- if (sub === "list" || sub === "ls") {
- const snaps = listSnapshots();
- if (snaps.length === 0) {
- out.write("(no snapshots)\n");
- return;
- }
- out.write(theme.cyan("💾 Snapshots\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
- for (const s of snaps) {
- const pinCnt = s.data.pins.length;
- out.write(` ${theme.bold(s.data.sessionLabel)} ${theme.dim(s.data.createdAt)} (${pinCnt} pins)\n`);
- }
- return;
- }
-
- const snapPath = saveSnapshot(registry);
- const basename = snapPath.split("/").pop() || snapPath;
- out.write(`${theme.cyan("💾 snapshot saved")} ${theme.bold(basename)}\n`);
- out.write(` ${theme.dim(snapPath)}\n`);
- out.write(` pins: ${registry.pins.length} UVT cap: ${registry.uvtCap ?? "none"} drops: ${registry.drops.length}\n`);
- out.write(theme.dim(" Resume with: /snapshot resume \n"));
- syncAfter(ctx);
-}
-
-// ── /limit ────────────────────────────────────
-
-/** Fire-and-forget backend sync. Never blocks the REPL. */
-function syncAfter(ctx: AppContext): void {
- void syncToBackend(ctx.api).catch(() => {});
-}
-
-function renderUvtBar(pct: number, width: number): string {
- const filled = Math.round((pct / 100) * width);
- const empty = width - filled;
- const color = pct > 80 ? theme.muted : pct > 50 ? theme.dim : theme.cyan;
- return color("[" + "█".repeat(filled) + "░".repeat(empty) + "]");
-}
-
-async function limitSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const registry = getRegistry();
-
- if (!arg.trim()) {
- const current = registry.uvtCap;
- const spent = registry.uvtSpent;
- if (current == null) {
- out.write("UVT cap: none (uncapped)\n");
- } else {
- const remaining = Math.max(0, current - spent);
- const pct = current > 0 ? Math.round((spent / current) * 100) : 0;
- const bar = renderUvtBar(pct, 20);
- out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`);
- }
- out.write(theme.dim(" /limit set cap (e.g., /limit 50000)\n"));
- out.write(theme.dim(" /limit off remove cap\n"));
- return;
- }
-
- if (arg.trim().toLowerCase() === "off" || arg.trim().toLowerCase() === "none") {
- registry.uvtCap = null;
- out.write(theme.cyan("UVT cap removed — session is uncapped.\n"));
- return;
- }
-
- const n = Number(arg.trim());
- if (!Number.isFinite(n) || n <= 0) {
- out.write(`invalid: ${arg} — use a positive number (e.g., /limit 50000)\n`);
- return;
- }
-
- registry.setUvtCap(Math.floor(n));
- out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`);
- syncAfter(ctx);
-}
-
-// ── /audit-receipt ────────────────────────────
-
-function hashShortCustody(v: unknown): string | null {
- if (v == null) return null;
- if (typeof v === "string") return v.slice(0, 12);
- if (typeof v === "object") {
- const o = v as Record;
- const inner = o["hash"] ?? o["env_hash"] ?? o["commitment_hash"] ?? o["digest"];
- if (inner != null) return String(inner).slice(0, 12);
- }
- return "\u2713";
-}
-
-async function auditReceiptSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const nArg = Number(arg);
- const limit = Number.isInteger(nArg) && nArg > 0 ? Math.min(nArg, 100) : 20;
-
- const custody = readCustodyLog(limit);
- let serverEntries: AuditEntry[] = [];
- try {
- serverEntries = await fetchTrail(ctx.api, { limit });
- } catch {
- /* offline — local custody is enough */
- }
-
- out.write(theme.cyan("🧾 Audit Receipt\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
-
- const H_TIME = 26;
- const H_ORDER = 14;
- const H_EVENT = 14;
- const H_COMMIT = 14;
- const H_PATH = 20;
-
- const header =
- "time".padEnd(H_TIME) +
- "order_id".padEnd(H_ORDER) +
- "event".padEnd(H_EVENT) +
- "commitment".padEnd(H_COMMIT) +
- "path";
- out.write(theme.bold(" " + header) + "\n");
- out.write(theme.dim(" " + "─".repeat(H_TIME + H_ORDER + H_EVENT + H_COMMIT + H_PATH)) + "\n");
-
- for (const c of custody) {
- const ts = c.received_at != null ? new Date(c.received_at).toISOString().padEnd(H_TIME) : "—".padEnd(H_TIME);
- const oid = (String(c.order_id ?? "—")).slice(0, H_ORDER - 1).padEnd(H_ORDER);
- const evt = "chat_turn".padEnd(H_EVENT);
- const comm = (hashShortCustody(c.commitment) ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT);
- const pathCol = String(c.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH);
- out.write(` ${theme.dim(ts)}${oid}${theme.cyan(evt)}${theme.dim(comm)}${pathCol}\n`);
- }
-
- for (const e of serverEntries) {
- const ts = String(e.timestamp).padEnd(H_TIME);
- const oid = e.orderId.slice(0, H_ORDER - 1).padEnd(H_ORDER);
- const evt = e.eventType.padEnd(H_EVENT);
- const comm = (e.commitmentHash ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT);
- const pathCol = (e.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH);
- out.write(` ${theme.dim(ts)}${theme.dim(oid)}${theme.muted(evt)}${theme.dim(comm)}${pathCol}\n`);
- }
-
- const totalEntries = custody.length + serverEntries.length;
- const uvtTotal = getRegistry().uvtSpent;
- const boxContent = [
- "",
- ` Total entries: ${totalEntries}`,
- ` UVT spent: ${uvtTotal}`,
- ` UVT cap: ${getRegistry().uvtCap ?? "none"}`,
- "",
- " Export proof: aether receipt ",
- " Full log: /logs-view",
- "",
- ];
- out.write("\n" + box(boxContent, { width: 60 }) + "\n");
-}
-
-// ── /rollback ─────────────────────────────────
-
-async function rollbackSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const n = parseInt(arg.trim()) || 1;
- if (n < 1 || n > 50) {
- out.write("usage: /rollback [n] revert last n filesystem changes (1-50, default 1)\n");
- return;
- }
-
- const cwd = process.cwd();
- const gitDir = join(cwd, ".git");
- if (!existsSync(gitDir)) {
- out.write(theme.muted("Not in a git repository. /rollback requires git for safe undo.\n"));
- return;
- }
-
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
- try {
- const status = execSync("git diff --name-only", { cwd, encoding: "utf8", timeout: 5000 });
- const dirty = status.trim().split("\n").filter(Boolean);
- if (dirty.length === 0) {
- out.write("(working tree clean — nothing to rollback)\n");
- return;
- }
-
- out.write(`${theme.cyan("\u21A9 Ready to rollback")} ${dirty.length} files changed\n`);
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
- const show = dirty.slice(0, 20);
- for (const f of show) {
- out.write(` ${theme.muted(f)}\n`);
- }
- if (dirty.length > 20) {
- out.write(` ${theme.dim(`... and ${dirty.length - 20} more`)}\n`);
- }
-
- const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert all ${dirty.length} uncommitted changes? [y/N] `));
- if (!ok) {
- out.write("cancelled.\n");
- return;
- }
-
- execSync("git checkout -- .", { cwd, encoding: "utf8", timeout: 10000 });
- out.write(`${theme.cyan("\u21A9 rolled back")} ${dirty.length} files restored to last commit.\n`);
- out.write(theme.dim(" Git reflog untouched — all commits preserved.\n"));
- } catch (err) {
- out.write(`\u2717 ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── /scaffold ─────────────────────────────────
-
-async function scaffoldSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const parts = arg.trim().split(/\s+/);
- const type = parts[0]?.toLowerCase() ?? "";
- const name = parts.slice(1).join(" ");
-
- if (!type || !name) {
- out.write(SCAFFOLD_USAGE + "\n");
- return;
- }
-
- if (!isValidScaffoldType(type)) {
- out.write(`invalid type: ${type} — use component, route, or module\n`);
- return;
- }
-
- try {
- out.write(`scaffolding ${type} "${name}"...\n`);
- const r = await generateScaffold(ctx.api, type as ScaffoldType, name);
- for (const f of r.files) {
- out.write(` created: ${theme.bold(f.path)} (${f.content.split("\n").length} lines)\n`);
- }
- out.write(`template: ${theme.dim(r.template_used)}\n`);
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── /purge ─────────────────────────────────────
-
-async function purgeSlash(ctx: AppContext, out: Writable): Promise {
- const registry = getRegistry();
- const { clearedPins, removedFiles } = registry.purge();
-
- // Reset the registry completely
- resetRegistry();
-
- // Clear screen
- out.write("\x1b[2J\x1b[H");
-
- const lines: string[] = [];
- if (clearedPins > 0) lines.push(`${clearedPins} pinned files`);
- if (removedFiles > 0) lines.push(`${removedFiles} temp files`);
- lines.push("UVT cap reset");
- lines.push("context flushed");
-
- out.write(`${theme.cyan("🧹 purged")} ${lines.join(" · ")}\n`);
- out.write(theme.dim(" Session goal preserved. Agent memory reset to lean baseline.\n"));
-}
-
-// ── /port ─────────────────────────────────────
-
-async function portSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const parts = arg.trim().split(/\s+/);
- const targetPath = parts[0];
- const targetLang = parts[1]?.toLowerCase();
-
- if (!targetPath || !targetLang) {
- out.write("usage: /port \n");
- out.write(" /port src/utils.ts rust\n");
- out.write(" /port src/services/ python\n");
- return;
- }
-
- try {
- const files = readSource(targetPath);
- out.write(`reading ${files.length} file(s) from ${targetPath}...\n`);
-
- const r = await portCode(ctx.api, files, targetLang);
- out.write(`translated → ${r.files.length} file(s)\n`);
-
- const outDir = `ported_${targetLang}`;
- const written = writePortedFiles(r.files, outDir);
- for (const w of written) {
- out.write(` ${theme.bold(w)}\n`);
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── /stage-diff ────────────────────────────────
-
-async function stageDiffSlash(ctx: AppContext, out: Writable): Promise {
- try {
- const r = generateDiff();
-
- if (r.files.length === 0) {
- out.write("(working tree clean — nothing to stage)\n");
- return;
- }
-
- out.write(theme.cyan("📋 Stage Diff\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
-
- out.write(` ${r.stats.filesChanged} files +${r.stats.additions} -${r.stats.deletions}\n\n`);
-
- for (const f of r.files.slice(0, 15)) {
- out.write(` ${theme.muted(f)}\n`);
- }
- if (r.files.length > 15) {
- out.write(` ${theme.dim(`... and ${r.files.length - 15} more`)}\n`);
- }
-
- out.write(`\n${theme.bold("Suggested commit:")}\n`);
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
- out.write(r.commitMessage + "\n");
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
-
- const diffLines = r.diff.split("\n").slice(0, 30);
- out.write(`\n${theme.dim("Diff preview (first 30 lines):")}\n`);
- for (const line of diffLines) {
- if (line.startsWith("+")) out.write(theme.dim(line) + "\n");
- else if (line.startsWith("-")) out.write(theme.muted(line) + "\n");
- else out.write(theme.dim(line) + "\n");
- }
-
- if (r.diff.split("\n").length > 30) {
- out.write(theme.dim(" ... (truncated)\n"));
- }
-
- out.write(`\n${theme.dim(" Copy the commit message above and commit when ready.")}\n`);
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── /revert ─────────────────────────────────────
-
-async function revertSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const target = arg.trim();
- if (!target) {
- out.write("usage: /revert surgical rollback\n");
- out.write(" /revert src/core/old.ts revert single file\n");
- out.write(" /revert step-3 revert to checkpoint (coming soon)\n");
- return;
- }
-
- const cwd = process.cwd();
- const gitDir = join(cwd, ".git");
- if (!existsSync(gitDir)) {
- out.write(theme.muted("Not in a git repository.\n"));
- return;
- }
-
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
-
- if (target.startsWith("step-") || target.match(/^\d+$/)) {
- out.write(theme.muted("Step-based revert not yet available. Use /rollback to revert all, or /revert for a single file.\n"));
- out.write(theme.dim(" Tracked step checkpoints planned for future release.\n"));
- return;
- }
-
- try {
- const isTracked = (() => {
- try {
- execSync(`git ls-files --error-unmatch "${target}"`, { cwd, encoding: "utf8", timeout: 3000 });
- return true;
- } catch { return false; }
- })();
-
- if (!isTracked) {
- out.write(`${theme.muted(target)} is not tracked by git.\n`);
- return;
- }
-
- const diffOut = execSync(`git diff --name-only -- "${target}"`, { cwd, encoding: "utf8", timeout: 3000 });
- if (!diffOut.trim()) {
- out.write(`(no uncommitted changes in ${target})\n`);
- return;
- }
-
- const fileDiff = execSync(`git diff -- "${target}"`, { cwd, encoding: "utf8", timeout: 5000 });
- const changes = fileDiff.trim().split("\n").length;
-
- out.write(`${theme.cyan("↩ Reverting")} ${theme.bold(target)} (${changes} line changes)\n`);
- out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
-
- for (const line of fileDiff.split("\n").slice(0, 10)) {
- if (line.startsWith("+")) out.write(theme.dim(line) + "\n");
- else if (line.startsWith("-")) out.write(theme.muted(line) + "\n");
- else out.write(theme.dim(line) + "\n");
- }
-
- const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert ${target} to last commit? [y/N] `));
- if (!ok) {
- out.write("cancelled.\n");
- return;
- }
-
- execSync(`git checkout -- "${target}"`, { cwd, encoding: "utf8", timeout: 10000 });
- out.write(`${theme.cyan("↩ reverted")} ${target} restored to last commit.\n`);
- } catch (err: any) {
- if (err?.stderr?.includes("did not match any file")) {
- out.write(`not found: ${target}\n`);
- } else {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
- }
-}
-
// /help is generated from SLASH_COMMANDS (slash_registry.ts) — a command added
// to the registry is automatically listed here, Tab-completable, and
// did-you-mean'd. test/slash_registry.test.ts pins registry ↔ switch sync.
@@ -881,140 +352,6 @@ const SECTION_ICONS: Record = {
"HUD": "🖥",
};
-// ── /add + /hud handlers ──────────────────────
-
-async function addSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const input = arg.trim().toLowerCase();
-
- if (!input || input === "list" || input === "ls") {
- const reg = getRegistry();
- const active = new Set(reg.hudElements);
- out.write(theme.iceBlue("Available HUD elements:\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────\n"));
- const sorted = Object.values(HUD_ELEMENTS).sort((a, b) => a.priority - b.priority);
- for (const def of sorted) {
- const mark = active.has(def.id) ? theme.cyan("●") : "○";
- const aliases = def.aliases.length ? theme.dim(" aka: " + def.aliases.join(", ")) : "";
- out.write(` ${mark} ${theme.bold(def.label)}${aliases}\n`);
- out.write(` ${theme.dim(def.description)}\n`);
- }
- out.write("\n" + theme.dim("Usage: /add /hud remove /hud clear\n"));
- return;
- }
-
- const resolved = resolveHudElement(input);
- if (!resolved) {
- out.write(`unknown HUD element: "${arg.trim()}". /add list to see available.\n`);
- return;
- }
-
- const reg = getRegistry();
- if (reg.hasHudElement(resolved)) {
- out.write(theme.dim(`HUD already active: ${HUD_ELEMENTS[resolved].label}. /hud remove ${resolved} to remove.\n`));
- return;
- }
-
- const added = reg.addHudElement(resolved);
- if (!added) {
- out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is already active.\n`));
- return;
- }
-
- const def = HUD_ELEMENTS[resolved];
- out.write(theme.cyan("✓") + ` HUD: ${theme.bold(def.label)} added\n`);
- out.write(theme.dim(` ${def.description}\n`));
-
- if (process.stdout.isTTY) {
- const cols = process.stdout.columns ?? 80;
- const state = buildHudState(reg);
- const preview = renderHud(reg.hudElements, state, cols);
- if (preview) out.write("\n" + preview + "\n");
- }
-}
-
-async function hudSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const parts = arg.trim().split(/\s+/);
- const sub = (parts[0] ?? "").toLowerCase();
- const target = parts.slice(1).join(" ").trim();
- const reg = getRegistry();
-
- switch (sub) {
- case "remove":
- case "rm":
- case "del":
- case "delete": {
- if (!target) {
- out.write("usage: /hud remove \n");
- if (reg.hudElements.length > 0) {
- out.write(theme.dim("active: " + reg.hudElements.map((id) => HUD_ELEMENTS[id].label).join(", ") + "\n"));
- }
- return;
- }
- const resolved = resolveHudElement(target);
- if (!resolved) {
- out.write(`unknown HUD element: "${target}". /hud list to see active.\n`);
- return;
- }
- if (!reg.hasHudElement(resolved)) {
- out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is not active.\n`));
- return;
- }
- reg.removeHudElement(resolved);
- out.write(theme.cyan("✓") + ` HUD: ${theme.bold(HUD_ELEMENTS[resolved].label)} removed\n`);
- break;
- }
- case "clear":
- case "reset": {
- const count = reg.hudElements.length;
- if (count === 0) {
- out.write(theme.dim("HUD is already clear.\n"));
- return;
- }
- reg.clearHud();
- out.write(theme.cyan("✓") + ` HUD cleared (${count} element${count !== 1 ? "s" : ""} removed)\n`);
- break;
- }
- case "list":
- case "ls":
- case "show":
- case "": {
- if (reg.hudElements.length === 0) {
- out.write(theme.dim("HUD is empty. /add to add one. /add list to see available.\n"));
- return;
- }
- out.write(theme.iceBlue("Active HUD elements:\n"));
- out.write(theme.dim("──────────────────────────────────────────────────────\n"));
- for (const id of reg.hudElements) {
- const def = HUD_ELEMENTS[id];
- out.write(` ${theme.cyan("●")} ${theme.bold(def.label)} ${theme.dim("(" + def.aliases.join(", ") + ")")}\n`);
- }
- if (process.stdout.isTTY) {
- const cols = process.stdout.columns ?? 80;
- const state = buildHudState(reg);
- const preview = renderHud(reg.hudElements, state, cols);
- if (preview) out.write("\n" + preview + "\n");
- }
- break;
- }
- default:
- out.write("usage: /hud remove | /hud list | /hud clear\n");
- break;
- }
-}
-
-function buildHudState(reg: import("../core/context_registry.js").ContextRegistry): HudRenderState {
- const live = timerLive(reg.hudTimer);
- return {
- tokensUsed: reg.uvtSpent,
- tokensCap: reg.uvtCap ?? 1_000_000_000,
- sessionMs: live.userMs + live.agentMs,
- timer: reg.hudTimer,
- streamedTokens: 0,
- uvtUsed: reg.uvtSpent,
- uvtCap: reg.uvtCap ?? 0,
- };
-}
-
const USAGE_COL = 30;
function printHelp(out: Writable, arg = ""): void {
const name = arg.trim().replace(/^\//, "").toLowerCase();
@@ -1095,7 +432,7 @@ async function showPicker(
: ctx.flags.agent;
out.write(`tier: ${cat.tier}\n`);
items.forEach((m, i) => {
- const mark = m.id === current ? "\u203A" : m.available ? " " : "\uD83D\uDD12";
+ const mark = m.id === current ? "›" : m.available ? " " : "🔒";
const cap = m.monthly_uvt_cap != null ? ` cap ${m.monthly_uvt_cap}` : "";
out.write(`${mark} ${String(i + 1).padStart(2)}. ${m.id}\t${m.label}${cap}\n`);
});
@@ -1114,7 +451,7 @@ async function showPicker(
// Show warning + confirm (same as existing select() logic)
out.write(
theme.dim(
- `\u26A0 Switching ${kind === "model" ? "model" : "orchestrator"} to ${picked.label} will ` +
+ `⚠ Switching ${kind === "model" ? "model" : "orchestrator"} to ${picked.label} will ` +
`restart the session and clear context.\n`,
),
);
@@ -1181,627 +518,3 @@ async function showAudit(ctx: AppContext, out: Writable, arg: string): Promise {
- try {
- const r = await getVaultSnapshot(ctx.api, 800);
- out.write(`vault: ${r.note_count} notes\n`);
- } catch {
- out.write("vault: unreachable\n");
- }
-}
-
-async function vaultContextSlash(ctx: AppContext, out: Writable): Promise {
- try {
- await getVaultSnapshot(ctx.api, 2000);
- out.write("vault context loaded for next agent turn.\n");
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-async function vaultSearchSlash(ctx: AppContext, out: Writable, query: string): Promise {
- if (!query) { out.write("usage: /vault-search \n"); return; }
- try {
- const r = await searchNotes(ctx.api, query, { limit: 10 });
- if (r.results.length === 0) { out.write("no results.\n"); return; }
- for (const n of r.results) {
- out.write(` ${n.title || n.path} (${n.path})\n`);
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-async function vaultRecentSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- try {
- const n = Math.min(parseInt(arg) || 10, 50);
- const r = await searchNotes(ctx.api, "", { limit: n });
- if (r.results.length === 0) { out.write("(empty vault)\n"); return; }
- for (const row of r.results) {
- out.write(` ${row.title || row.path}\n`);
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-async function vaultProjectSlash(ctx: AppContext, out: Writable, name: string): Promise {
- if (!name) { out.write("usage: /vault-project \n"); return; }
- try {
- const r = await searchNotes(ctx.api, "", { project: name, limit: 20 });
- if (r.results.length === 0) { out.write(`no notes for project: ${name}\n`); return; }
- for (const n of r.results) {
- out.write(` ${n.title || n.path} [${n.tags.join(", ")}]\n`);
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-async function vaultTagSlash(ctx: AppContext, out: Writable, tag: string): Promise {
- if (!tag) { out.write("usage: /vault-tag \n"); return; }
- try {
- const r = await notesByTag(ctx.api, tag, 20);
- if (r.results.length === 0) { out.write(`no notes with tag: ${tag}\n`); return; }
- for (const n of r.results) {
- out.write(` ${n.title || n.path} (${n.path})\n`);
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── Workflow slash handlers ─────────────────────
-
-async function workflowSlash(ctx: AppContext, out: Writable): Promise {
- try {
- const workflows = await listWorkflows(ctx.api);
- out.write(`workflow: ${workflows.length} in vault · ${WORKFLOW_TEMPLATES.length} templates available\n`);
- if (workflows.length > 0) {
- for (const w of workflows.slice(0, 5)) out.write(` ${w.name} (${Math.round(w.size/1024)} KB)\n`);
- }
- } catch {
- out.write("workflow: unavailable\n");
- }
-}
-
-async function workflowTemplatesSlash(ctx: AppContext, out: Writable): Promise {
- for (let i = 0; i < WORKFLOW_TEMPLATES.length; i++) {
- const t = WORKFLOW_TEMPLATES[i]!;
- out.write(` ${String(i+1)}. ${t.icon} ${t.name} (${t.category}, ${t.difficulty})\n`);
- }
- out.write("load: /workflow-template \n");
-}
-
-async function workflowTemplateSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const n = parseInt(arg);
- if (isNaN(n) || n < 1 || n > WORKFLOW_TEMPLATES.length) {
- out.write(`invalid: ${arg} (1-${WORKFLOW_TEMPLATES.length})\n`);
- return;
- }
- const t = WORKFLOW_TEMPLATES[n - 1]!;
- out.write(`${t.icon} ${t.name}: ${t.subtitle}\n`);
- out.write(` ${t.workflow.nodes?.length || 0} nodes · ${t.workflow.edges?.length || 0} edges\n`);
- out.write(` save with: aether workflow save ${t.id}\n`);
-}
-
-async function vaultTreeSlash(ctx: AppContext, out: Writable): Promise {
- try {
- const r = await getNotesTree(ctx.api);
- if (r.tree.length === 0) { out.write("(empty vault)\n"); return; }
- for (const e of r.tree) {
- out.write(` ${e.folder || "/"} (${e.count} notes)\n`);
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── Agents slash handler ────────────────────────
-
-async function agentsSlash(ctx: AppContext, out: Writable): Promise {
- try {
- const resp = await ctx.api.getJson<{ agents?: Array<{
- name?: string; status?: string; working_time?: string;
- uvt_streamed?: number; task?: string;
- }> }>(AGENTS_PATH);
- const agents = resp.agents ?? [];
- if (agents.length === 0) {
- out.write("(no active agents)\n");
- return;
- }
- // Columns: name, status, working time, UVT streamed, task
- const nameW = Math.max(20, ...agents.map(a => (a.name ?? "?").length));
- const statusW = 12;
- const timeW = 14;
- const uvtW = 12;
-
- // Header
- const header = " " +
- "name".padEnd(nameW) + " " +
- "status".padEnd(statusW) + " " +
- "time".padEnd(timeW) + " " +
- "UVT".padEnd(uvtW) + " " +
- "task";
- out.write(theme.bold(header) + "\n");
- out.write(theme.dim(" " + "─".repeat(nameW + statusW + timeW + uvtW + 30)) + "\n");
-
- for (const a of agents) {
- const name = (a.name ?? "?").padEnd(nameW);
- const status = (a.status ?? "?").padEnd(statusW);
- const time = (a.working_time ?? "—").padEnd(timeW);
- const uvt = a.uvt_streamed != null
- ? String(a.uvt_streamed).padEnd(uvtW)
- : "—".padEnd(uvtW);
- const task = (a.task ?? "").slice(0, 50);
-
- const statusColor = a.status === "running" ? theme.cyan :
- a.status === "complete" ? theme.dim : theme.muted;
-
- out.write(` ${theme.bold(name)}${statusColor(status)}${theme.dim(time)}${theme.dim(uvt)}${task}\n`);
- }
- } catch {
- out.write("agents: unreachable (are you logged in? aether auth login)\n");
- }
-}
-
-// ── Orchestrator slash handlers ─────────────────
-
-async function delegateSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!requireOrchestrator(ctx, out)) return;
- const parts = arg.split(/\s+/);
- const model = parts[0];
- const task = parts.slice(1).join(" ");
- if (!model || !task) {
- out.write("usage: /delegate \n");
- return;
- }
- try {
- const r = await delegateWorker(ctx.api, ctx.flags.agent!, model, task);
- out.write(`delegated → worker ${r.worker_id} (${r.status}) running ${model}\n`);
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-async function treeSlash(ctx: AppContext, out: Writable): Promise {
- if (!requireOrchestrator(ctx, out)) return;
- try {
- const r = await getOrchTree(ctx.api, ctx.flags.agent!);
- out.write(theme.bold(`orchestrator: ${r.orchestrator}`) + "\n");
- if (r.workers.length === 0) {
- out.write(" (no active sub-agents)\n");
- return;
- }
- // Columns: id, model, step, tokens, UVT
- const idW = Math.max(10, ...r.workers.map(w => w.id.length));
- const modelW = Math.max(10, ...r.workers.map(w => w.model.length));
- const stepW = Math.max(12, ...r.workers.map(w => w.step.length));
- const header = " " +
- "id".padEnd(idW) + " " +
- "model".padEnd(modelW) + " " +
- "step".padEnd(stepW) + " " +
- "tokens".padEnd(10) + " " +
- "UVT";
- out.write(theme.dim(header) + "\n");
- out.write(theme.dim(" " + "─".repeat(idW + modelW + stepW + 30)) + "\n");
- for (const w of r.workers) {
- out.write(
- ` ${theme.bold(w.id.padEnd(idW))} ` +
- `${w.model.padEnd(modelW)} ` +
- `${w.step.padEnd(stepW)} ` +
- `${String(w.tokens).padEnd(10)} ` +
- `${w.uvt}\n`
- );
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-async function broadcastSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!requireOrchestrator(ctx, out)) return;
- const message = arg.trim();
- if (!message) {
- out.write("usage: /broadcast \"\"\n");
- return;
- }
- try {
- const r = await broadcastToAgents(ctx.api, ctx.flags.agent!, message);
- out.write(`broadcast → delivered to ${r.delivered_to} sub-agents\n`);
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ═════════════════════════════════════════════════════════════════════
-// Media pipeline — /photogen /frame /re-frame /videogen /sequence
-// /animate /re-cut /output /storyboard handlers
-// ═════════════════════════════════════════════════════════════════════
-
-// NOTE: gatherSlash was originally defined above and is preserved.
-// It was not removed — only its closing brace block was used as the anchor.
-
-async function gatherSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!requireOrchestrator(ctx, out)) return;
- const workerId = arg.trim();
- if (!workerId) { out.write("usage: /gather \n"); return; }
- try {
- const r = await gatherResults(ctx.api, ctx.flags.agent!, workerId);
- if (r.results.length === 0) { out.write("(no results to gather)\n"); return; }
- for (const res of r.results) {
- out.write(`${theme.bold(res.worker_id)}:\n`);
- if (res.files.length) out.write(` files: ${res.files.join(", ")}\n`);
- if (res.diffs.length) out.write(` diffs: ${res.diffs.length} diff(s)\n`);
- if (res.patches.length) out.write(` patches: ${res.patches.length} patch(es)\n`);
- }
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
-}
-
-function parseSlashFlags(raw: string, _kind: MediaKind): { prompt: string; flags: GenFlags } {
- const flags: GenFlags = {};
- const parts = raw.split(/\s+/);
- const promptParts: string[] = [];
- let inFlags = false;
- for (let i = 0; i < parts.length; i++) {
- const p = parts[i] ?? "";
- if (!inFlags && p.startsWith("--")) { inFlags = true; }
- if (inFlags) {
- const next = parts[i + 1] ?? "";
- if (p === "--model" && next) { flags.model = next; i++; }
- else if (p === "--aspect" && next) { flags.aspect = next; i++; }
- else if ((p === "--count" || p === "--batch") && next) { flags.count = parseInt(next, 10) || 1; i++; }
- else if (p === "--4k") { flags.fourK = true; }
- else if (p === "--vector") { flags.vector = true; }
- else if ((p === "--duration" || p === "--10s") && next) { flags.duration = parseInt(next, 10) || 10; i++; }
- else if (p === "--1080p") { flags.hd1080 = true; }
- else if (p === "--audio") { flags.audio = true; }
- else if (p === "--save-to-vault") { flags.saveToVault = true; }
- else if (p === "--ref" && next) { flags.ref = next; i++; }
- else if (p === "--open") { flags.open = true; }
- } else {
- promptParts.push(p);
- }
- }
- return { prompt: promptParts.join(" "), flags };
-}
-
-const SF = theme.dim;
-
-async function photogenSlash(ctx: AppContext, out: Writable, arg: string, _framed: boolean): Promise {
- const { prompt, flags } = parseSlashFlags(arg, "image");
- if (!prompt) {
- out.write("usage: /photogen [--model nano] [--aspect 16_9] [--count 4] [--4k] [--vector]\n");
- out.write("usage: /frame --aspect \n");
- return;
- }
- const kind: MediaKind = "image";
- const modelKey = flags.model ? resolveModelKey(flags.model) : autoRouteModel(prompt, kind);
- const fullPrompt = buildMediaPrompt(prompt, flags, kind);
- const count = Math.min(10, Math.max(1, flags.count ?? 1));
- const outdir = ensureOutputDir();
- out.write(SF(`generating ${count} image(s) with ${modelKey}: "${prompt}"\n\n`));
- for (let i = 0; i < count; i++) {
- try {
- const vp = count > 1 ? `${fullPrompt} [variant ${i + 1} of ${count}]` : fullPrompt;
- out.write(SF(`[${i + 1}/${count}] `));
- const resp = await dispatchGeneration(ctx.api, vp, modelKey, flags);
- if (resp.media_url) {
- out.write(SF("downloading...\n"));
- const filepath = await downloadMediaFile(ctx.api, resp.media_url, outdir, modelKey, kind, resp.filename);
- const result: GenResult = {
- model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath),
- url: resp.media_url, timestamp: new Date().toISOString(), flags,
- };
- const entry = recordOutput(result);
- out.write(` ${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
- out.write(` ${SF(resp.media_url)}\n\n`);
- _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaPrompt = vp; _lastMediaKind = kind;
- } else {
- out.write(SF(" no media URL returned\n"));
- }
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
- }
-}
-
-async function reframeSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!_lastMediaUrl || _lastMediaKind !== "image") { out.write(SF(" no previous image to edit\n")); return; }
- if (!arg) { out.write("usage: /re-frame \n"); return; }
- const editPrompt = `Edit the previously generated image: ${arg}`;
- const flags: GenFlags = { model: "edit", ref: _lastMediaUrl };
- out.write(SF(`editing with gpt-image-2: "${arg}"\n\n`));
- try {
- const resp = await dispatchGeneration(ctx.api, editPrompt, "vision_gpt_image2", flags);
- if (resp.media_url) {
- const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_gpt_image2", "image", resp.filename);
- const entry = recordOutput({ model: "vision_gpt_image2", prompt: editPrompt, kind: "image", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags });
- out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
- _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_gpt_image2"; _lastMediaPrompt = editPrompt;
- }
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
-}
-
-async function videogenSlash(ctx: AppContext, out: Writable, arg: string, cinematic: boolean): Promise {
- const { prompt, flags } = parseSlashFlags(arg, "video");
- if (!prompt) { out.write("usage: /videogen [--model seedance] [--duration 10] [--1080p] [--audio]\n /sequence \n"); return; }
- const kind: MediaKind = "video";
- const modelKey = flags.model ? resolveModelKey(flags.model) : cinematic ? "seedance" : autoRouteModel(prompt, kind);
- if (!flags.duration) flags.duration = 5;
- const fullPrompt = buildMediaPrompt(prompt, flags, kind);
- out.write(SF(`generating video with ${modelKey}: "${prompt}"\n\n`));
- try {
- const resp = await dispatchGeneration(ctx.api, fullPrompt, modelKey, flags);
- if (resp.media_url) {
- out.write(SF("downloading video...\n"));
- const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, kind, resp.filename);
- const entry = recordOutput({ model: modelKey, prompt: fullPrompt, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags });
- out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n ${SF(resp.media_url)}\n\n`);
- _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaPrompt = fullPrompt; _lastMediaKind = kind;
- }
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
-}
-
-async function animateSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const args = arg.trim().split(/\s+/);
- const ref = args[0];
- const extraPrompt = args.slice(1).join(" ");
- if (!ref) { out.write("usage: /animate [motion description]\n"); return; }
- let refUrl = ref;
- if (/^#?\d+$/.test(ref)) {
- const entry = findOutput(ref.replace("#", ""));
- if (entry) refUrl = entry.url;
- else { out.write(SF(` no output matching "${ref}"\n`)); return; }
- }
- const prompt = `Animate this image into a fluid video sequence${extraPrompt ? ": " + extraPrompt : ""}`;
- out.write(SF(`animating from ${refUrl.slice(0, 50)}...\n\n`));
- try {
- const resp = await dispatchGeneration(ctx.api, prompt, "vision_seedance", { model: "seedance", ref: refUrl, duration: 5 });
- if (resp.media_url) {
- const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video", resp.filename);
- const entry = recordOutput({ model: "vision_seedance", prompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: { model: "seedance", ref: refUrl, duration: 5 } });
- out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
- _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_seedance"; _lastMediaPrompt = prompt; _lastMediaKind = "video";
- }
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
-}
-
-async function recutSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!_lastMediaUrl || _lastMediaKind !== "video") { out.write(SF(" no previous video to edit\n")); return; }
- if (!arg) { out.write("usage: /re-cut \n"); return; }
- const modelKey = _lastMediaModel || "vision_seedance";
- const editPrompt = `Edit the previously generated video: ${arg}`;
- out.write(SF(`editing video: "${arg}"\n\n`));
- try {
- const resp = await dispatchGeneration(ctx.api, editPrompt, modelKey, { model: modelKey, ref: _lastMediaUrl });
- if (resp.media_url) {
- const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, "video", resp.filename);
- const entry = recordOutput({ model: modelKey, prompt: editPrompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} });
- out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
- _lastMediaUrl = resp.media_url; _lastMediaPrompt = editPrompt;
- }
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
-}
-
-async function outputSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const parts = arg.split(/\s+/);
- const sub = parts[0]?.toLowerCase();
- const ref = parts.slice(1).join(" ");
- if (sub === "open" || sub === "o") {
- if (!ref) { out.write("usage: /output open \n"); return; }
- const entry = findOutput(ref);
- if (!entry) { out.write(SF(` no output matching "${ref}"\n`)); return; }
- try { openOutput(entry); out.write(`${theme.iceBlue("→")} opened ${entry.filename}\n`); }
- catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
- return;
- }
- if (sub === "clean" || sub === "clear") { out.write(SF(` cleared ${clearOutput()} entries\n`)); return; }
- const entries = listOutput(10);
- if (!entries.length) { out.write(SF(" (no generations yet — use /photogen or /videogen)\n")); return; }
- out.write(`${theme.iceBlue("📦")} RECENT GENERATIONS\n\n`);
- for (const e of entries) {
- const icon = e.kind === "video" ? "🎬" : e.kind === "3d" ? "🧊" : "🖼";
- const sp = e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt;
- out.write(` ${theme.iceBlue("#" + e.index)} ${icon} ${e.filename}\n ${SF(`${e.model} ${(e.size_bytes/1024/1024).toFixed(1)}MB ${sp}`)}\n`);
- }
- out.write(SF("\n /output open — open in default viewer\n\n"));
-}
-
-async function storyboardSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- const parts = arg.trim().split(/\s+/);
- const sub = parts[0]?.toLowerCase();
- const rest = parts.slice(1).join(" ");
- if (sub === "list" || sub === "ls") {
- const boards = listStoryboards();
- if (!boards.length) { out.write(SF(" (no saved storyboards)\n")); return; }
- out.write(`\n${theme.iceBlue("🎬")} STORYBOARDS\n\n`);
- for (const b of boards) out.write(` ${b.id.padEnd(20)} ${b.title.slice(0, 40)} ${b.scenes} scenes [${b.status}]\n`);
- return;
- }
- if (sub === "view" && rest) {
- const sb = loadStoryboard(rest);
- if (!sb) { out.write(SF(` no storyboard "${rest}"\n`)); return; }
- sbPreview(sb, out);
- return;
- }
- if (sub === "--preview" || sub === "--generate" || sub === "--animate" || sub === "--render") {
- const boards = listStoryboards();
- const id = rest || (boards[0]?.id ?? "");
- const sb = loadStoryboard(id);
- if (!sb) { out.write(SF(" no storyboard found\n")); return; }
- const phase = sub.replace("--", "");
- if (phase === "preview") { sbPreview(sb, out); return; }
- await sbRender(ctx, sb, phase, out);
- return;
- }
- if (!arg.trim()) { out.write("usage: /storyboard [--scenes n] [--style style]\n /storyboard --preview|--generate|--animate|--render [id]\n /storyboard list|view \n"); return; }
- let sourceType: "prompt" | "script_file" = "prompt";
- let source = arg;
- let opts: { scenes?: number; style?: string } = {};
- const firstArg = parts[0] ?? "";
- if (fsExistsSync(firstArg) && /\.(md|txt)$/i.test(firstArg)) {
- sourceType = "script_file"; source = firstArg; opts = sbFlagParse(parts.slice(1).join(" "));
- } else {
- const fi = parts.findIndex(p => p.startsWith("--"));
- if (fi > 0) { source = parts.slice(0, fi).join(" "); opts = sbFlagParse(parts.slice(fi).join(" ")); }
- }
- out.write(SF("parsing storyboard...\n"));
- try {
- const result = await parseStoryboard(ctx.api, source, sourceType, opts);
- const sb = { id: `sb_${Date.now().toString(36)}`, title: result.title, source_type: sourceType, source, style: result.style, total_scenes: result.scenes.length, scenes: result.scenes, created_at: new Date().toISOString(), status: "draft" as const };
- saveStoryboard(sb);
- sbPreview(sb, out);
- out.write(SF("\n/storyboard --generate to generate all keyframes\n"));
- out.write(SF("/storyboard --animate to animate the sequence\n"));
- out.write(SF("/storyboard --render full pipeline\n"));
- } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
-}
-
-function sbFlagParse(raw: string): { scenes?: number; style?: string } {
- const r: { scenes?: number; style?: string } = {};
- const p = raw.split(/\s+/);
- for (let i = 0; i < p.length; i++) {
- const next = p[i + 1] ?? "";
- if ((p[i] === "--scenes" || p[i] === "-n") && next) { r.scenes = parseInt(next, 10); i++; }
- else if (p[i] === "--style" && next) { r.style = next; i++; }
- }
- return r;
-}
-
-function sbPreview(sb: any, out: Writable): void {
- out.write(`\n${theme.iceBlue("🎬")} STORYBOARD: ${sb.title}\n`);
- out.write(SF(` style: ${sb.style} | scenes: ${sb.total_scenes} | status: ${sb.status}\n\n`));
- for (const s of sb.scenes) {
- out.write(
- `${theme.iceBlue("SCENE " + s.index)} ${(s.shot_type||"WIDE").padEnd(8)} | ${(s.camera_movement||"static").padEnd(10)} | ${s.duration_sec}s | ${s.transition}\n` +
- ` ${SF("visual:")} ${(s.keyframe_prompt||"").slice(0,80)}${(s.keyframe_prompt||"").length>80?"...":""}\n` +
- ` ${SF("motion:")} ${(s.animation_prompt||"").slice(0,80)}\n` +
- ` ${SF("palette:")} ${s.color_palette||"natural"} ${SF("light:")} ${s.lighting||"ambient"}\n\n`
- );
- }
-}
-
-async function sbRender(ctx: AppContext, sb: any, phase: string, out: Writable): Promise {
- if (phase === "generate" || phase === "render") {
- out.write(SF(`generating ${sb.total_scenes} keyframes...\n\n`));
- for (const s of sb.scenes) {
- out.write(SF(` scene ${s.index}/${sb.total_scenes}: ${s.shot_type}\n`));
- try {
- const mk = autoRouteModel(s.keyframe_prompt, "image");
- const resp = await dispatchGeneration(ctx.api, s.keyframe_prompt, mk, { aspect: "16_9" });
- if (resp.media_url) {
- const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), mk, "image");
- s.generated_frame_url = resp.media_url; s.generated_frame_path = fp;
- recordOutput({ model: mk, prompt: s.keyframe_prompt, kind: "image", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} });
- out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`);
- }
- } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); }
- }
- sb.status = "keyframes_generated"; saveStoryboard(sb);
- }
- if (phase === "animate" || phase === "render") {
- const anim = sb.scenes.filter((s: any) => s.generated_frame_url);
- out.write(SF(`animating ${anim.length} scenes...\n\n`));
- for (const s of anim) {
- out.write(SF(` scene ${s.index}: ${s.camera_movement} over ${s.duration_sec}s\n`));
- try {
- const resp = await dispatchGeneration(ctx.api, s.animation_prompt, "vision_seedance", { model: "seedance", ref: s.generated_frame_url, duration: s.duration_sec });
- if (resp.media_url) {
- const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video");
- recordOutput({ model: "vision_seedance", prompt: s.animation_prompt, kind: "video", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} });
- out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`);
- }
- } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); }
- }
- sb.status = "animated"; saveStoryboard(sb);
- }
-}
-
-// ── /test-drive ─────────────────────────────────
-
-async function testDriveSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!requireOrchestrator(ctx, out)) return;
-
- const target = arg.trim().replace(/^["']|["']$/g, "");
- if (!target) {
- out.write('usage: /test-drive ""\n');
- out.write(' /test-drive "POST /api/users"\n');
- out.write(' /test-drive "src/utils/validate.ts:validateEmail"\n');
- return;
- }
-
- try {
- out.write(`${theme.cyan("🧪 test-drive")} targeting: ${theme.bold(target)}\n`);
- out.write(theme.dim(" Generating test matrix, running, iterating until green...\n\n"));
-
- const r = await startTestDrive(ctx.api, ctx.flags.agent!, target, process.cwd());
-
- if (r.status === "passed") {
- out.write(`${theme.cyan("✓ all tests pass")} after ${r.iterations} iteration(s)\n`);
- if (r.final_result) {
- out.write(` ${r.final_result.passed} passed · ${r.final_result.failed} failed\n`);
- }
- if (r.patches.length > 0) {
- out.write(` ${r.patches.length} source file(s) modified\n`);
- for (const p of r.patches) {
- out.write(` ${theme.bold(p.file)}\n`);
- }
- }
- } else {
- out.write(`${theme.muted("✗ tests did not converge")} after ${r.iterations} iteration(s)\n`);
- if (r.final_result?.errors.length) {
- for (const e of r.final_result.errors.slice(0, 3)) {
- out.write(` ${theme.muted(e.slice(0, 200))}\n`);
- }
- }
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
-
-// ── /bench ─────────────────────────────────────
-
-async function benchSlash(ctx: AppContext, out: Writable, arg: string): Promise {
- if (!requireOrchestrator(ctx, out)) return;
-
- const target = arg.trim();
- if (!target) {
- out.write("usage: /bench \n");
- out.write(" /bench src/services/search.ts:fullTextSearch\n");
- out.write(" /bench GET /api/search\n");
- return;
- }
-
- try {
- out.write(`${theme.cyan("⚡ benchmarking")} ${theme.bold(target)}...\n`);
-
- const r = await runBenchmark(ctx.api, ctx.flags.agent!, target);
-
- if (r.complexity) {
- out.write(`\n complexity: ${theme.bold(r.complexity)}\n`);
- }
- if (r.bottlenecks.length > 0) {
- out.write(`\n ${theme.muted("bottlenecks:")}\n`);
- for (const b of r.bottlenecks) {
- out.write(` ${theme.muted("•")} ${b}\n`);
- }
- }
- if (r.optimizations.length > 0) {
- out.write(`\n ${theme.cyan("optimizations:")}\n`);
- for (const o of r.optimizations) {
- out.write(` ${theme.cyan("→")} ${o.description} ${theme.dim(`(${o.improvement})`)}\n`);
- }
- }
- if (r.patches.length > 0) {
- out.write(`\n ${r.patches.length} optimization(s) applied\n`);
- for (const p of r.patches) {
- out.write(` ${theme.bold(p.file)}\n`);
- }
- }
- } catch (err) {
- out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
- }
-}
diff --git a/src/commands/slash_codegen.ts b/src/commands/slash_codegen.ts
new file mode 100644
index 0000000..dc70517
--- /dev/null
+++ b/src/commands/slash_codegen.ts
@@ -0,0 +1,159 @@
+// In-REPL codegen/verification slash commands: /scaffold /port /test-drive
+// /bench. Split out of slash.ts (was 1807 lines) to keep each command group
+// under the repo's ~800-line file convention.
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { theme } from "../ui/theme.js";
+import { generateScaffold, isValidScaffoldType, SCAFFOLD_USAGE, type ScaffoldType } from "../core/scaffold.js";
+import { portCode, readSource, writePortedFiles } from "../core/port.js";
+import { startTestDrive } from "../core/test_drive.js";
+import { runBenchmark } from "../core/bench.js";
+import { requireOrchestrator } from "../core/orchestrator.js";
+
+// ── /scaffold ─────────────────────────────────
+
+export async function scaffoldSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const parts = arg.trim().split(/\s+/);
+ const type = parts[0]?.toLowerCase() ?? "";
+ const name = parts.slice(1).join(" ");
+
+ if (!type || !name) {
+ out.write(SCAFFOLD_USAGE + "\n");
+ return;
+ }
+
+ if (!isValidScaffoldType(type)) {
+ out.write(`invalid type: ${type} — use component, route, or module\n`);
+ return;
+ }
+
+ try {
+ out.write(`scaffolding ${type} "${name}"...\n`);
+ const r = await generateScaffold(ctx.api, type as ScaffoldType, name);
+ for (const f of r.files) {
+ out.write(` created: ${theme.bold(f.path)} (${f.content.split("\n").length} lines)\n`);
+ }
+ out.write(`template: ${theme.dim(r.template_used)}\n`);
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+// ── /port ─────────────────────────────────────
+
+export async function portSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const parts = arg.trim().split(/\s+/);
+ const targetPath = parts[0];
+ const targetLang = parts[1]?.toLowerCase();
+
+ if (!targetPath || !targetLang) {
+ out.write("usage: /port \n");
+ out.write(" /port src/utils.ts rust\n");
+ out.write(" /port src/services/ python\n");
+ return;
+ }
+
+ try {
+ const files = readSource(targetPath);
+ out.write(`reading ${files.length} file(s) from ${targetPath}...\n`);
+
+ const r = await portCode(ctx.api, files, targetLang);
+ out.write(`translated → ${r.files.length} file(s)\n`);
+
+ const outDir = `ported_${targetLang}`;
+ const written = writePortedFiles(r.files, outDir);
+ for (const w of written) {
+ out.write(` ${theme.bold(w)}\n`);
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+// ── /test-drive ─────────────────────────────────
+
+export async function testDriveSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!requireOrchestrator(ctx, out)) return;
+
+ const target = arg.trim().replace(/^["']|["']$/g, "");
+ if (!target) {
+ out.write('usage: /test-drive ""\n');
+ out.write(' /test-drive "POST /api/users"\n');
+ out.write(' /test-drive "src/utils/validate.ts:validateEmail"\n');
+ return;
+ }
+
+ try {
+ out.write(`${theme.cyan("🧪 test-drive")} targeting: ${theme.bold(target)}\n`);
+ out.write(theme.dim(" Generating test matrix, running, iterating until green...\n\n"));
+
+ const r = await startTestDrive(ctx.api, ctx.flags.agent!, target, process.cwd());
+
+ if (r.status === "passed") {
+ out.write(`${theme.cyan("✓ all tests pass")} after ${r.iterations} iteration(s)\n`);
+ if (r.final_result) {
+ out.write(` ${r.final_result.passed} passed · ${r.final_result.failed} failed\n`);
+ }
+ if (r.patches.length > 0) {
+ out.write(` ${r.patches.length} source file(s) modified\n`);
+ for (const p of r.patches) {
+ out.write(` ${theme.bold(p.file)}\n`);
+ }
+ }
+ } else {
+ out.write(`${theme.muted("✗ tests did not converge")} after ${r.iterations} iteration(s)\n`);
+ if (r.final_result?.errors.length) {
+ for (const e of r.final_result.errors.slice(0, 3)) {
+ out.write(` ${theme.muted(e.slice(0, 200))}\n`);
+ }
+ }
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+// ── /bench ─────────────────────────────────────
+
+export async function benchSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!requireOrchestrator(ctx, out)) return;
+
+ const target = arg.trim();
+ if (!target) {
+ out.write("usage: /bench \n");
+ out.write(" /bench src/services/search.ts:fullTextSearch\n");
+ out.write(" /bench GET /api/search\n");
+ return;
+ }
+
+ try {
+ out.write(`${theme.cyan("⚡ benchmarking")} ${theme.bold(target)}...\n`);
+
+ const r = await runBenchmark(ctx.api, ctx.flags.agent!, target);
+
+ if (r.complexity) {
+ out.write(`\n complexity: ${theme.bold(r.complexity)}\n`);
+ }
+ if (r.bottlenecks.length > 0) {
+ out.write(`\n ${theme.muted("bottlenecks:")}\n`);
+ for (const b of r.bottlenecks) {
+ out.write(` ${theme.muted("•")} ${b}\n`);
+ }
+ }
+ if (r.optimizations.length > 0) {
+ out.write(`\n ${theme.cyan("optimizations:")}\n`);
+ for (const o of r.optimizations) {
+ out.write(` ${theme.cyan("→")} ${o.description} ${theme.dim(`(${o.improvement})`)}\n`);
+ }
+ }
+ if (r.patches.length > 0) {
+ out.write(`\n ${r.patches.length} optimization(s) applied\n`);
+ for (const p of r.patches) {
+ out.write(` ${theme.bold(p.file)}\n`);
+ }
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
diff --git a/src/commands/slash_context.ts b/src/commands/slash_context.ts
new file mode 100644
index 0000000..888f90e
--- /dev/null
+++ b/src/commands/slash_context.ts
@@ -0,0 +1,300 @@
+// In-REPL context-management slash commands: /pin /drop /snapshot /limit
+// /token-budget /audit-receipt /purge. Split out of slash.ts (was 1807 lines)
+// to keep each command group under the repo's ~800-line file convention.
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { join } from "node:path";
+import { theme } from "../ui/theme.js";
+import { box } from "../ui/box.js";
+import {
+ getRegistry, resetRegistry, saveSnapshot, loadSnapshot, listSnapshots,
+ ContextRegistry, syncToBackend, loadFromBackend,
+} from "../core/context_registry.js";
+import { readCustodyLog } from "../core/custody.js";
+import { fetchTrail } from "../core/audit.js";
+import type { AuditEntry } from "../core/audit.js";
+
+// ── /pin ──────────────────────────────────────
+
+export async function pinSlash(ctx: AppContext, out: Writable, arg: string, _line: string): Promise {
+ if (!arg.trim() || arg.trim() === "list" || arg.trim() === "ls") {
+ const pins = getRegistry().pins;
+ if (pins.length === 0) {
+ out.write("(no pinned files — use /pin [reason] to pin one)\n");
+ return;
+ }
+ out.write(theme.cyan("📌 Pinned Context\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+ for (const p of pins) {
+ out.write(` ${theme.bold(p.label)} ${theme.dim(p.path)} ${theme.muted(p.reason)}\n`);
+ }
+ return;
+ }
+
+ const parts = arg.trim().split(/\s+/);
+ const pth = parts[0]!;
+ const reason = parts.slice(1).join(" ") || "pinned";
+
+ const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth);
+ const label = pth.split("/").pop() || pth;
+
+ const entry = getRegistry().pin(resolved, label, reason);
+ out.write(`${theme.cyan("📌 pinned")} ${theme.bold(entry.label)} ${theme.dim(entry.path)} (${entry.reason})\n`);
+ out.write(theme.dim(" This file will persist in context across /recon and /autonomous-execution loops.\n"));
+ syncAfter(ctx);
+}
+
+// ── /drop ─────────────────────────────────────
+
+export async function dropSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!arg.trim()) {
+ const registry = getRegistry();
+ out.write("usage: /drop evict a file from memory context\n");
+ out.write(" /drop src/core/old.ts\n");
+ if (registry.pins.length > 0) {
+ out.write("\n Pinned files (use /pin list for details):\n");
+ for (const p of registry.pins) {
+ out.write(` ${theme.dim(p.label)}\n`);
+ }
+ }
+ if (registry.drops.length > 0) {
+ out.write("\n Recently dropped:\n");
+ for (const d of registry.drops.slice(-5)) {
+ out.write(` ${theme.dim(d)}\n`);
+ }
+ }
+ return;
+ }
+
+ const pth = arg.trim();
+ const resolved = pth.startsWith("/") ? pth : join(process.cwd(), pth);
+ const wasPinned = getRegistry().isPinned(resolved);
+ getRegistry().drop(resolved);
+
+ if (wasPinned) {
+ out.write(`${theme.cyan("🗑 dropped")} ${theme.bold(pth)} — removed from pinned context\n`);
+ } else {
+ out.write(`${theme.cyan("🗑 evicted")} ${theme.dim(pth)}\n`);
+ out.write(theme.dim(" (wasn't pinned, but will be excluded from future context loads)\n"));
+ }
+ syncAfter(ctx);
+}
+
+// ── /snapshot ─────────────────────────────────
+
+export async function snapshotSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const registry = getRegistry();
+ const sub = arg.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
+
+ if (sub === "resume" || sub === "load") {
+ const id = arg.trim().split(/\s+/).slice(1).join(" ");
+ if (!id) {
+ // Try cloud backend first
+ const cloudLoaded = await loadFromBackend(ctx.api);
+ if (cloudLoaded) {
+ const reg = getRegistry();
+ out.write(`${theme.cyan("☁ loaded from cloud")} ${theme.bold(reg.sessionLabel)} (${reg.pins.length} pins, cap ${reg.uvtCap ?? "none"})\n`);
+ out.write(theme.dim(" Use /pin list to see restored context.\n"));
+ return;
+ }
+ const snaps = listSnapshots();
+ if (snaps.length === 0) {
+ out.write("(no snapshots — use /snapshot to save one)\n");
+ return;
+ }
+ out.write(theme.cyan("💾 Saved Snapshots\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+ for (const s of snaps) {
+ const pinCnt = s.data.pins.length;
+ const cap = s.data.uvtCap ? ` cap ${s.data.uvtCap} UVT` : "";
+ out.write(` ${theme.bold(s.id)} ${theme.dim(s.data.createdAt)} ${s.data.sessionLabel} (${pinCnt} pins${cap})\n`);
+ }
+ out.write(theme.dim("\n /snapshot resume to restore a snapshot\n"));
+ return;
+ }
+ const data = loadSnapshot(id);
+ if (!data) {
+ out.write(`no snapshot: ${id} (use /snapshot to list)\n`);
+ return;
+ }
+ const restored = ContextRegistry.fromSnapshot(data);
+ resetRegistry();
+ Object.assign(getRegistry(), restored);
+ out.write(`${theme.cyan("📂 restored")} ${theme.bold(data.sessionLabel)} (${data.pins.length} pins, cap ${data.uvtCap ?? "none"})\n`);
+ out.write(` cwd: ${data.cwd}\n`);
+ out.write(theme.dim(" Use /pin list to see restored context.\n"));
+ return;
+ }
+
+ if (sub === "list" || sub === "ls") {
+ const snaps = listSnapshots();
+ if (snaps.length === 0) {
+ out.write("(no snapshots)\n");
+ return;
+ }
+ out.write(theme.cyan("💾 Snapshots\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+ for (const s of snaps) {
+ const pinCnt = s.data.pins.length;
+ out.write(` ${theme.bold(s.data.sessionLabel)} ${theme.dim(s.data.createdAt)} (${pinCnt} pins)\n`);
+ }
+ return;
+ }
+
+ const snapPath = saveSnapshot(registry);
+ const basename = snapPath.split("/").pop() || snapPath;
+ out.write(`${theme.cyan("💾 snapshot saved")} ${theme.bold(basename)}\n`);
+ out.write(` ${theme.dim(snapPath)}\n`);
+ out.write(` pins: ${registry.pins.length} UVT cap: ${registry.uvtCap ?? "none"} drops: ${registry.drops.length}\n`);
+ out.write(theme.dim(" Resume with: /snapshot resume \n"));
+ syncAfter(ctx);
+}
+
+// ── /limit ────────────────────────────────────
+
+/** Fire-and-forget backend sync. Never blocks the REPL. */
+function syncAfter(ctx: AppContext): void {
+ void syncToBackend(ctx.api).catch(() => {});
+}
+
+function renderUvtBar(pct: number, width: number): string {
+ const filled = Math.round((pct / 100) * width);
+ const empty = width - filled;
+ const color = pct > 80 ? theme.muted : pct > 50 ? theme.dim : theme.cyan;
+ return color("[" + "█".repeat(filled) + "░".repeat(empty) + "]");
+}
+
+export async function limitSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const registry = getRegistry();
+
+ if (!arg.trim()) {
+ const current = registry.uvtCap;
+ const spent = registry.uvtSpent;
+ if (current == null) {
+ out.write("UVT cap: none (uncapped)\n");
+ } else {
+ const remaining = Math.max(0, current - spent);
+ const pct = current > 0 ? Math.round((spent / current) * 100) : 0;
+ const bar = renderUvtBar(pct, 20);
+ out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`);
+ }
+ out.write(theme.dim(" /limit set cap (e.g., /limit 50000)\n"));
+ out.write(theme.dim(" /limit off remove cap\n"));
+ return;
+ }
+
+ if (arg.trim().toLowerCase() === "off" || arg.trim().toLowerCase() === "none") {
+ registry.uvtCap = null;
+ out.write(theme.cyan("UVT cap removed — session is uncapped.\n"));
+ return;
+ }
+
+ const n = Number(arg.trim());
+ if (!Number.isFinite(n) || n <= 0) {
+ out.write(`invalid: ${arg} — use a positive number (e.g., /limit 50000)\n`);
+ return;
+ }
+
+ registry.setUvtCap(Math.floor(n));
+ out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`);
+ syncAfter(ctx);
+}
+
+// ── /audit-receipt ────────────────────────────
+
+function hashShortCustody(v: unknown): string | null {
+ if (v == null) return null;
+ if (typeof v === "string") return v.slice(0, 12);
+ if (typeof v === "object") {
+ const o = v as Record;
+ const inner = o["hash"] ?? o["env_hash"] ?? o["commitment_hash"] ?? o["digest"];
+ if (inner != null) return String(inner).slice(0, 12);
+ }
+ return "✓";
+}
+
+export async function auditReceiptSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const nArg = Number(arg);
+ const limit = Number.isInteger(nArg) && nArg > 0 ? Math.min(nArg, 100) : 20;
+
+ const custody = readCustodyLog(limit);
+ let serverEntries: AuditEntry[] = [];
+ try {
+ serverEntries = await fetchTrail(ctx.api, { limit });
+ } catch {
+ /* offline — local custody is enough */
+ }
+
+ out.write(theme.cyan("🧾 Audit Receipt\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+
+ const H_TIME = 26;
+ const H_ORDER = 14;
+ const H_EVENT = 14;
+ const H_COMMIT = 14;
+ const H_PATH = 20;
+
+ const header =
+ "time".padEnd(H_TIME) +
+ "order_id".padEnd(H_ORDER) +
+ "event".padEnd(H_EVENT) +
+ "commitment".padEnd(H_COMMIT) +
+ "path";
+ out.write(theme.bold(" " + header) + "\n");
+ out.write(theme.dim(" " + "─".repeat(H_TIME + H_ORDER + H_EVENT + H_COMMIT + H_PATH)) + "\n");
+
+ for (const c of custody) {
+ const ts = c.received_at != null ? new Date(c.received_at).toISOString().padEnd(H_TIME) : "—".padEnd(H_TIME);
+ const oid = (String(c.order_id ?? "—")).slice(0, H_ORDER - 1).padEnd(H_ORDER);
+ const evt = "chat_turn".padEnd(H_EVENT);
+ const comm = (hashShortCustody(c.commitment) ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT);
+ const pathCol = String(c.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH);
+ out.write(` ${theme.dim(ts)}${oid}${theme.cyan(evt)}${theme.dim(comm)}${pathCol}\n`);
+ }
+
+ for (const e of serverEntries) {
+ const ts = String(e.timestamp).padEnd(H_TIME);
+ const oid = e.orderId.slice(0, H_ORDER - 1).padEnd(H_ORDER);
+ const evt = e.eventType.padEnd(H_EVENT);
+ const comm = (e.commitmentHash ?? "—").slice(0, H_COMMIT - 1).padEnd(H_COMMIT);
+ const pathCol = (e.path ?? "—").slice(0, H_PATH - 1).padEnd(H_PATH);
+ out.write(` ${theme.dim(ts)}${theme.dim(oid)}${theme.muted(evt)}${theme.dim(comm)}${pathCol}\n`);
+ }
+
+ const totalEntries = custody.length + serverEntries.length;
+ const uvtTotal = getRegistry().uvtSpent;
+ const boxContent = [
+ "",
+ ` Total entries: ${totalEntries}`,
+ ` UVT spent: ${uvtTotal}`,
+ ` UVT cap: ${getRegistry().uvtCap ?? "none"}`,
+ "",
+ " Export proof: aether receipt ",
+ " Full log: /logs-view",
+ "",
+ ];
+ out.write("\n" + box(boxContent, { width: 60 }) + "\n");
+}
+
+// ── /purge ─────────────────────────────────────
+
+export async function purgeSlash(ctx: AppContext, out: Writable): Promise {
+ const registry = getRegistry();
+ const { clearedPins, removedFiles } = registry.purge();
+
+ // Reset the registry completely
+ resetRegistry();
+
+ // Clear screen
+ out.write("\x1b[2J\x1b[H");
+
+ const lines: string[] = [];
+ if (clearedPins > 0) lines.push(`${clearedPins} pinned files`);
+ if (removedFiles > 0) lines.push(`${removedFiles} temp files`);
+ lines.push("UVT cap reset");
+ lines.push("context flushed");
+
+ out.write(`${theme.cyan("🧹 purged")} ${lines.join(" · ")}\n`);
+ out.write(theme.dim(" Session goal preserved. Agent memory reset to lean baseline.\n"));
+}
diff --git a/src/commands/slash_git_tools.ts b/src/commands/slash_git_tools.ts
new file mode 100644
index 0000000..9f15b24
--- /dev/null
+++ b/src/commands/slash_git_tools.ts
@@ -0,0 +1,179 @@
+// In-REPL git-backed rollback slash commands: /rollback /revert /stage-diff.
+// Split out of slash.ts (was 1807 lines) to keep each command group under
+// the repo's ~800-line file convention.
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { theme } from "../ui/theme.js";
+import { generateDiff } from "../core/stage_diff.js";
+
+// ── /rollback ─────────────────────────────────
+
+export async function rollbackSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const n = parseInt(arg.trim()) || 1;
+ if (n < 1 || n > 50) {
+ out.write("usage: /rollback [n] revert last n filesystem changes (1-50, default 1)\n");
+ return;
+ }
+
+ const cwd = process.cwd();
+ const gitDir = join(cwd, ".git");
+ if (!existsSync(gitDir)) {
+ out.write(theme.muted("Not in a git repository. /rollback requires git for safe undo.\n"));
+ return;
+ }
+
+ const { execSync } = require("node:child_process") as typeof import("node:child_process");
+ try {
+ const status = execSync("git diff --name-only", { cwd, encoding: "utf8", timeout: 5000 });
+ const dirty = status.trim().split("\n").filter(Boolean);
+ if (dirty.length === 0) {
+ out.write("(working tree clean — nothing to rollback)\n");
+ return;
+ }
+
+ out.write(`${theme.cyan("↩ Ready to rollback")} ${dirty.length} files changed\n`);
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+ const show = dirty.slice(0, 20);
+ for (const f of show) {
+ out.write(` ${theme.muted(f)}\n`);
+ }
+ if (dirty.length > 20) {
+ out.write(` ${theme.dim(`... and ${dirty.length - 20} more`)}\n`);
+ }
+
+ const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert all ${dirty.length} uncommitted changes? [y/N] `));
+ if (!ok) {
+ out.write("cancelled.\n");
+ return;
+ }
+
+ execSync("git checkout -- .", { cwd, encoding: "utf8", timeout: 10000 });
+ out.write(`${theme.cyan("↩ rolled back")} ${dirty.length} files restored to last commit.\n`);
+ out.write(theme.dim(" Git reflog untouched — all commits preserved.\n"));
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+// ── /stage-diff ────────────────────────────────
+
+export async function stageDiffSlash(ctx: AppContext, out: Writable): Promise {
+ try {
+ const r = generateDiff();
+
+ if (r.files.length === 0) {
+ out.write("(working tree clean — nothing to stage)\n");
+ return;
+ }
+
+ out.write(theme.cyan("📋 Stage Diff\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+
+ out.write(` ${r.stats.filesChanged} files +${r.stats.additions} -${r.stats.deletions}\n\n`);
+
+ for (const f of r.files.slice(0, 15)) {
+ out.write(` ${theme.muted(f)}\n`);
+ }
+ if (r.files.length > 15) {
+ out.write(` ${theme.dim(`... and ${r.files.length - 15} more`)}\n`);
+ }
+
+ out.write(`\n${theme.bold("Suggested commit:")}\n`);
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+ out.write(r.commitMessage + "\n");
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+
+ const diffLines = r.diff.split("\n").slice(0, 30);
+ out.write(`\n${theme.dim("Diff preview (first 30 lines):")}\n`);
+ for (const line of diffLines) {
+ if (line.startsWith("+")) out.write(theme.dim(line) + "\n");
+ else if (line.startsWith("-")) out.write(theme.muted(line) + "\n");
+ else out.write(theme.dim(line) + "\n");
+ }
+
+ if (r.diff.split("\n").length > 30) {
+ out.write(theme.dim(" ... (truncated)\n"));
+ }
+
+ out.write(`\n${theme.dim(" Copy the commit message above and commit when ready.")}\n`);
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+// ── /revert ─────────────────────────────────────
+
+export async function revertSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const target = arg.trim();
+ if (!target) {
+ out.write("usage: /revert surgical rollback\n");
+ out.write(" /revert src/core/old.ts revert single file\n");
+ out.write(" /revert step-3 revert to checkpoint (coming soon)\n");
+ return;
+ }
+
+ const cwd = process.cwd();
+ const gitDir = join(cwd, ".git");
+ if (!existsSync(gitDir)) {
+ out.write(theme.muted("Not in a git repository.\n"));
+ return;
+ }
+
+ const { execSync } = require("node:child_process") as typeof import("node:child_process");
+
+ if (target.startsWith("step-") || target.match(/^\d+$/)) {
+ out.write(theme.muted("Step-based revert not yet available. Use /rollback to revert all, or /revert for a single file.\n"));
+ out.write(theme.dim(" Tracked step checkpoints planned for future release.\n"));
+ return;
+ }
+
+ try {
+ const isTracked = (() => {
+ try {
+ execSync(`git ls-files --error-unmatch "${target}"`, { cwd, encoding: "utf8", timeout: 3000 });
+ return true;
+ } catch { return false; }
+ })();
+
+ if (!isTracked) {
+ out.write(`${theme.muted(target)} is not tracked by git.\n`);
+ return;
+ }
+
+ const diffOut = execSync(`git diff --name-only -- "${target}"`, { cwd, encoding: "utf8", timeout: 3000 });
+ if (!diffOut.trim()) {
+ out.write(`(no uncommitted changes in ${target})\n`);
+ return;
+ }
+
+ const fileDiff = execSync(`git diff -- "${target}"`, { cwd, encoding: "utf8", timeout: 5000 });
+ const changes = fileDiff.trim().split("\n").length;
+
+ out.write(`${theme.cyan("↩ Reverting")} ${theme.bold(target)} (${changes} line changes)\n`);
+ out.write(theme.dim("──────────────────────────────────────────────────────────────\n"));
+
+ for (const line of fileDiff.split("\n").slice(0, 10)) {
+ if (line.startsWith("+")) out.write(theme.dim(line) + "\n");
+ else if (line.startsWith("-")) out.write(theme.muted(line) + "\n");
+ else out.write(theme.dim(line) + "\n");
+ }
+
+ const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert ${target} to last commit? [y/N] `));
+ if (!ok) {
+ out.write("cancelled.\n");
+ return;
+ }
+
+ execSync(`git checkout -- "${target}"`, { cwd, encoding: "utf8", timeout: 10000 });
+ out.write(`${theme.cyan("↩ reverted")} ${target} restored to last commit.\n`);
+ } catch (err: any) {
+ if (err?.stderr?.includes("did not match any file")) {
+ out.write(`not found: ${target}\n`);
+ } else {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+ }
+}
diff --git a/src/commands/slash_hud.ts b/src/commands/slash_hud.ts
new file mode 100644
index 0000000..8b6c81a
--- /dev/null
+++ b/src/commands/slash_hud.ts
@@ -0,0 +1,145 @@
+// In-REPL HUD slash commands: /add /hud. Split out of slash.ts (was 1807
+// lines) to keep each command group under the repo's ~800-line file
+// convention.
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { theme } from "../ui/theme.js";
+import { getRegistry, type ContextRegistry } from "../core/context_registry.js";
+import {
+ HUD_ELEMENTS, resolveHudElement,
+ renderHud, timerLive,
+ type HudRenderState,
+} from "../core/hud.js";
+
+export async function addSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const input = arg.trim().toLowerCase();
+
+ if (!input || input === "list" || input === "ls") {
+ const reg = getRegistry();
+ const active = new Set(reg.hudElements);
+ out.write(theme.iceBlue("Available HUD elements:\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────\n"));
+ const sorted = Object.values(HUD_ELEMENTS).sort((a, b) => a.priority - b.priority);
+ for (const def of sorted) {
+ const mark = active.has(def.id) ? theme.cyan("●") : "○";
+ const aliases = def.aliases.length ? theme.dim(" aka: " + def.aliases.join(", ")) : "";
+ out.write(` ${mark} ${theme.bold(def.label)}${aliases}\n`);
+ out.write(` ${theme.dim(def.description)}\n`);
+ }
+ out.write("\n" + theme.dim("Usage: /add /hud remove /hud clear\n"));
+ return;
+ }
+
+ const resolved = resolveHudElement(input);
+ if (!resolved) {
+ out.write(`unknown HUD element: "${arg.trim()}". /add list to see available.\n`);
+ return;
+ }
+
+ const reg = getRegistry();
+ if (reg.hasHudElement(resolved)) {
+ out.write(theme.dim(`HUD already active: ${HUD_ELEMENTS[resolved].label}. /hud remove ${resolved} to remove.\n`));
+ return;
+ }
+
+ const added = reg.addHudElement(resolved);
+ if (!added) {
+ out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is already active.\n`));
+ return;
+ }
+
+ const def = HUD_ELEMENTS[resolved];
+ out.write(theme.cyan("✓") + ` HUD: ${theme.bold(def.label)} added\n`);
+ out.write(theme.dim(` ${def.description}\n`));
+
+ if (process.stdout.isTTY) {
+ const cols = process.stdout.columns ?? 80;
+ const state = buildHudState(reg);
+ const preview = renderHud(reg.hudElements, state, cols);
+ if (preview) out.write("\n" + preview + "\n");
+ }
+}
+
+export async function hudSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const parts = arg.trim().split(/\s+/);
+ const sub = (parts[0] ?? "").toLowerCase();
+ const target = parts.slice(1).join(" ").trim();
+ const reg = getRegistry();
+
+ switch (sub) {
+ case "remove":
+ case "rm":
+ case "del":
+ case "delete": {
+ if (!target) {
+ out.write("usage: /hud remove \n");
+ if (reg.hudElements.length > 0) {
+ out.write(theme.dim("active: " + reg.hudElements.map((id) => HUD_ELEMENTS[id].label).join(", ") + "\n"));
+ }
+ return;
+ }
+ const resolved = resolveHudElement(target);
+ if (!resolved) {
+ out.write(`unknown HUD element: "${target}". /hud list to see active.\n`);
+ return;
+ }
+ if (!reg.hasHudElement(resolved)) {
+ out.write(theme.dim(`${HUD_ELEMENTS[resolved].label} is not active.\n`));
+ return;
+ }
+ reg.removeHudElement(resolved);
+ out.write(theme.cyan("✓") + ` HUD: ${theme.bold(HUD_ELEMENTS[resolved].label)} removed\n`);
+ break;
+ }
+ case "clear":
+ case "reset": {
+ const count = reg.hudElements.length;
+ if (count === 0) {
+ out.write(theme.dim("HUD is already clear.\n"));
+ return;
+ }
+ reg.clearHud();
+ out.write(theme.cyan("✓") + ` HUD cleared (${count} element${count !== 1 ? "s" : ""} removed)\n`);
+ break;
+ }
+ case "list":
+ case "ls":
+ case "show":
+ case "": {
+ if (reg.hudElements.length === 0) {
+ out.write(theme.dim("HUD is empty. /add to add one. /add list to see available.\n"));
+ return;
+ }
+ out.write(theme.iceBlue("Active HUD elements:\n"));
+ out.write(theme.dim("──────────────────────────────────────────────────────\n"));
+ for (const id of reg.hudElements) {
+ const def = HUD_ELEMENTS[id];
+ out.write(` ${theme.cyan("●")} ${theme.bold(def.label)} ${theme.dim("(" + def.aliases.join(", ") + ")")}\n`);
+ }
+ if (process.stdout.isTTY) {
+ const cols = process.stdout.columns ?? 80;
+ const state = buildHudState(reg);
+ const preview = renderHud(reg.hudElements, state, cols);
+ if (preview) out.write("\n" + preview + "\n");
+ }
+ break;
+ }
+ default:
+ out.write("usage: /hud remove | /hud list | /hud clear\n");
+ break;
+ }
+}
+
+function buildHudState(reg: ContextRegistry): HudRenderState {
+ const live = timerLive(reg.hudTimer);
+ return {
+ tokensUsed: reg.uvtSpent,
+ tokensCap: reg.uvtCap ?? 1_000_000_000,
+ sessionMs: live.userMs + live.agentMs,
+ timer: reg.hudTimer,
+ streamedTokens: 0,
+ uvtUsed: reg.uvtSpent,
+ uvtCap: reg.uvtCap ?? 0,
+ };
+}
diff --git a/src/commands/slash_media.ts b/src/commands/slash_media.ts
new file mode 100644
index 0000000..d7a6d1e
--- /dev/null
+++ b/src/commands/slash_media.ts
@@ -0,0 +1,303 @@
+// ═════════════════════════════════════════════════════════════════════
+// In-REPL media pipeline slash commands: /photogen /frame /re-frame
+// /videogen /sequence /animate /re-cut /output /storyboard.
+// Split out of slash.ts (was 1807 lines) to keep each command group under
+// the repo's ~800-line file convention.
+// ═════════════════════════════════════════════════════════════════════
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { theme } from "../ui/theme.js";
+import { basename } from "node:path";
+import { existsSync as fsExistsSync } from "node:fs";
+import {
+ resolveModelKey, autoRouteModel,
+ buildMediaPrompt, dispatchGeneration, downloadMediaFile,
+ ensureOutputDir, recordOutput, listOutput, findOutput, openOutput, clearOutput,
+ parseStoryboard, saveStoryboard, loadStoryboard, listStoryboards,
+ type MediaKind, type GenFlags, type GenResult,
+} from "../core/vision.js";
+
+// Media pipeline state — persists across turns in the same REPL session.
+// Cleared on REPL restart. Used by /re-frame and /re-cut.
+let _lastMediaUrl: string | null = null;
+let _lastMediaModel: string | null = null;
+let _lastMediaKind: MediaKind | null = null;
+
+function parseSlashFlags(raw: string, _kind: MediaKind): { prompt: string; flags: GenFlags } {
+ const flags: GenFlags = {};
+ const parts = raw.split(/\s+/);
+ const promptParts: string[] = [];
+ let inFlags = false;
+ for (let i = 0; i < parts.length; i++) {
+ const p = parts[i] ?? "";
+ if (!inFlags && p.startsWith("--")) { inFlags = true; }
+ if (inFlags) {
+ const next = parts[i + 1] ?? "";
+ if (p === "--model" && next) { flags.model = next; i++; }
+ else if (p === "--aspect" && next) { flags.aspect = next; i++; }
+ else if ((p === "--count" || p === "--batch") && next) { flags.count = parseInt(next, 10) || 1; i++; }
+ else if (p === "--4k") { flags.fourK = true; }
+ else if (p === "--vector") { flags.vector = true; }
+ else if ((p === "--duration" || p === "--10s") && next) { flags.duration = parseInt(next, 10) || 10; i++; }
+ else if (p === "--1080p") { flags.hd1080 = true; }
+ else if (p === "--audio") { flags.audio = true; }
+ else if (p === "--save-to-vault") { flags.saveToVault = true; }
+ else if (p === "--ref" && next) { flags.ref = next; i++; }
+ else if (p === "--open") { flags.open = true; }
+ } else {
+ promptParts.push(p);
+ }
+ }
+ return { prompt: promptParts.join(" "), flags };
+}
+
+const SF = theme.dim;
+
+export async function photogenSlash(ctx: AppContext, out: Writable, arg: string, _framed: boolean): Promise {
+ const { prompt, flags } = parseSlashFlags(arg, "image");
+ if (!prompt) {
+ out.write("usage: /photogen [--model nano] [--aspect 16_9] [--count 4] [--4k] [--vector]\n");
+ out.write("usage: /frame --aspect \n");
+ return;
+ }
+ const kind: MediaKind = "image";
+ const modelKey = flags.model ? resolveModelKey(flags.model) : autoRouteModel(prompt, kind);
+ const fullPrompt = buildMediaPrompt(prompt, flags, kind);
+ const count = Math.min(10, Math.max(1, flags.count ?? 1));
+ const outdir = ensureOutputDir();
+ out.write(SF(`generating ${count} image(s) with ${modelKey}: "${prompt}"\n\n`));
+ for (let i = 0; i < count; i++) {
+ try {
+ const vp = count > 1 ? `${fullPrompt} [variant ${i + 1} of ${count}]` : fullPrompt;
+ out.write(SF(`[${i + 1}/${count}] `));
+ const resp = await dispatchGeneration(ctx.api, vp, modelKey, flags);
+ if (resp.media_url) {
+ out.write(SF("downloading...\n"));
+ const filepath = await downloadMediaFile(ctx.api, resp.media_url, outdir, modelKey, kind, resp.filename);
+ const result: GenResult = {
+ model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath),
+ url: resp.media_url, timestamp: new Date().toISOString(), flags,
+ };
+ const entry = recordOutput(result);
+ out.write(` ${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
+ out.write(` ${SF(resp.media_url)}\n\n`);
+ _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaKind = kind;
+ } else {
+ out.write(SF(" no media URL returned\n"));
+ }
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+ }
+}
+
+export async function reframeSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!_lastMediaUrl || _lastMediaKind !== "image") { out.write(SF(" no previous image to edit\n")); return; }
+ if (!arg) { out.write("usage: /re-frame \n"); return; }
+ const editPrompt = `Edit the previously generated image: ${arg}`;
+ const flags: GenFlags = { model: "edit", ref: _lastMediaUrl };
+ out.write(SF(`editing with gpt-image-2: "${arg}"\n\n`));
+ try {
+ const resp = await dispatchGeneration(ctx.api, editPrompt, "vision_gpt_image2", flags);
+ if (resp.media_url) {
+ const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_gpt_image2", "image", resp.filename);
+ const entry = recordOutput({ model: "vision_gpt_image2", prompt: editPrompt, kind: "image", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags });
+ out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
+ _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_gpt_image2";
+ }
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+}
+
+export async function videogenSlash(ctx: AppContext, out: Writable, arg: string, cinematic: boolean): Promise {
+ const { prompt, flags } = parseSlashFlags(arg, "video");
+ if (!prompt) { out.write("usage: /videogen [--model seedance] [--duration 10] [--1080p] [--audio]\n /sequence \n"); return; }
+ const kind: MediaKind = "video";
+ const modelKey = flags.model ? resolveModelKey(flags.model) : cinematic ? "seedance" : autoRouteModel(prompt, kind);
+ if (!flags.duration) flags.duration = 5;
+ const fullPrompt = buildMediaPrompt(prompt, flags, kind);
+ out.write(SF(`generating video with ${modelKey}: "${prompt}"\n\n`));
+ try {
+ const resp = await dispatchGeneration(ctx.api, fullPrompt, modelKey, flags);
+ if (resp.media_url) {
+ out.write(SF("downloading video...\n"));
+ const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, kind, resp.filename);
+ const entry = recordOutput({ model: modelKey, prompt: fullPrompt, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags });
+ out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n ${SF(resp.media_url)}\n\n`);
+ _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaKind = kind;
+ }
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+}
+
+export async function animateSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const args = arg.trim().split(/\s+/);
+ const ref = args[0];
+ const extraPrompt = args.slice(1).join(" ");
+ if (!ref) { out.write("usage: /animate [motion description]\n"); return; }
+ let refUrl = ref;
+ if (/^#?\d+$/.test(ref)) {
+ const entry = findOutput(ref.replace("#", ""));
+ if (entry) refUrl = entry.url;
+ else { out.write(SF(` no output matching "${ref}"\n`)); return; }
+ }
+ const prompt = `Animate this image into a fluid video sequence${extraPrompt ? ": " + extraPrompt : ""}`;
+ out.write(SF(`animating from ${refUrl.slice(0, 50)}...\n\n`));
+ try {
+ const resp = await dispatchGeneration(ctx.api, prompt, "vision_seedance", { model: "seedance", ref: refUrl, duration: 5 });
+ if (resp.media_url) {
+ const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video", resp.filename);
+ const entry = recordOutput({ model: "vision_seedance", prompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: { model: "seedance", ref: refUrl, duration: 5 } });
+ out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
+ _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_seedance"; _lastMediaKind = "video";
+ }
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+}
+
+export async function recutSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!_lastMediaUrl || _lastMediaKind !== "video") { out.write(SF(" no previous video to edit\n")); return; }
+ if (!arg) { out.write("usage: /re-cut \n"); return; }
+ const modelKey = _lastMediaModel || "vision_seedance";
+ const editPrompt = `Edit the previously generated video: ${arg}`;
+ out.write(SF(`editing video: "${arg}"\n\n`));
+ try {
+ const resp = await dispatchGeneration(ctx.api, editPrompt, modelKey, { model: modelKey, ref: _lastMediaUrl });
+ if (resp.media_url) {
+ const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, "video", resp.filename);
+ const entry = recordOutput({ model: modelKey, prompt: editPrompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} });
+ out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`);
+ _lastMediaUrl = resp.media_url;
+ }
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+}
+
+export async function outputSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const parts = arg.split(/\s+/);
+ const sub = parts[0]?.toLowerCase();
+ const ref = parts.slice(1).join(" ");
+ if (sub === "open" || sub === "o") {
+ if (!ref) { out.write("usage: /output open \n"); return; }
+ const entry = findOutput(ref);
+ if (!entry) { out.write(SF(` no output matching "${ref}"\n`)); return; }
+ try { openOutput(entry); out.write(`${theme.iceBlue("→")} opened ${entry.filename}\n`); }
+ catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+ return;
+ }
+ if (sub === "clean" || sub === "clear") { out.write(SF(` cleared ${clearOutput()} entries\n`)); return; }
+ const entries = listOutput(10);
+ if (!entries.length) { out.write(SF(" (no generations yet — use /photogen or /videogen)\n")); return; }
+ out.write(`${theme.iceBlue("📦")} RECENT GENERATIONS\n\n`);
+ for (const e of entries) {
+ const icon = e.kind === "video" ? "🎬" : e.kind === "3d" ? "🧊" : "🖼";
+ const sp = e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt;
+ out.write(` ${theme.iceBlue("#" + e.index)} ${icon} ${e.filename}\n ${SF(`${e.model} ${(e.size_bytes/1024/1024).toFixed(1)}MB ${sp}`)}\n`);
+ }
+ out.write(SF("\n /output open — open in default viewer\n\n"));
+}
+
+export async function storyboardSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const parts = arg.trim().split(/\s+/);
+ const sub = parts[0]?.toLowerCase();
+ const rest = parts.slice(1).join(" ");
+ if (sub === "list" || sub === "ls") {
+ const boards = listStoryboards();
+ if (!boards.length) { out.write(SF(" (no saved storyboards)\n")); return; }
+ out.write(`\n${theme.iceBlue("🎬")} STORYBOARDS\n\n`);
+ for (const b of boards) out.write(` ${b.id.padEnd(20)} ${b.title.slice(0, 40)} ${b.scenes} scenes [${b.status}]\n`);
+ return;
+ }
+ if (sub === "view" && rest) {
+ const sb = loadStoryboard(rest);
+ if (!sb) { out.write(SF(` no storyboard "${rest}"\n`)); return; }
+ sbPreview(sb, out);
+ return;
+ }
+ if (sub === "--preview" || sub === "--generate" || sub === "--animate" || sub === "--render") {
+ const boards = listStoryboards();
+ const id = rest || (boards[0]?.id ?? "");
+ const sb = loadStoryboard(id);
+ if (!sb) { out.write(SF(" no storyboard found\n")); return; }
+ const phase = sub.replace("--", "");
+ if (phase === "preview") { sbPreview(sb, out); return; }
+ await sbRender(ctx, sb, phase, out);
+ return;
+ }
+ if (!arg.trim()) { out.write("usage: /storyboard [--scenes n] [--style style]\n /storyboard --preview|--generate|--animate|--render [id]\n /storyboard list|view \n"); return; }
+ let sourceType: "prompt" | "script_file" = "prompt";
+ let source = arg;
+ let opts: { scenes?: number; style?: string } = {};
+ const firstArg = parts[0] ?? "";
+ if (fsExistsSync(firstArg) && /\.(md|txt)$/i.test(firstArg)) {
+ sourceType = "script_file"; source = firstArg; opts = sbFlagParse(parts.slice(1).join(" "));
+ } else {
+ const fi = parts.findIndex(p => p.startsWith("--"));
+ if (fi > 0) { source = parts.slice(0, fi).join(" "); opts = sbFlagParse(parts.slice(fi).join(" ")); }
+ }
+ out.write(SF("parsing storyboard...\n"));
+ try {
+ const result = await parseStoryboard(ctx.api, source, sourceType, opts);
+ const sb = { id: `sb_${Date.now().toString(36)}`, title: result.title, source_type: sourceType, source, style: result.style, total_scenes: result.scenes.length, scenes: result.scenes, created_at: new Date().toISOString(), status: "draft" as const };
+ saveStoryboard(sb);
+ sbPreview(sb, out);
+ out.write(SF("\n/storyboard --generate to generate all keyframes\n"));
+ out.write(SF("/storyboard --animate to animate the sequence\n"));
+ out.write(SF("/storyboard --render full pipeline\n"));
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+}
+
+function sbFlagParse(raw: string): { scenes?: number; style?: string } {
+ const r: { scenes?: number; style?: string } = {};
+ const p = raw.split(/\s+/);
+ for (let i = 0; i < p.length; i++) {
+ const next = p[i + 1] ?? "";
+ if ((p[i] === "--scenes" || p[i] === "-n") && next) { r.scenes = parseInt(next, 10); i++; }
+ else if (p[i] === "--style" && next) { r.style = next; i++; }
+ }
+ return r;
+}
+
+function sbPreview(sb: any, out: Writable): void {
+ out.write(`\n${theme.iceBlue("🎬")} STORYBOARD: ${sb.title}\n`);
+ out.write(SF(` style: ${sb.style} | scenes: ${sb.total_scenes} | status: ${sb.status}\n\n`));
+ for (const s of sb.scenes) {
+ out.write(
+ `${theme.iceBlue("SCENE " + s.index)} ${(s.shot_type||"WIDE").padEnd(8)} | ${(s.camera_movement||"static").padEnd(10)} | ${s.duration_sec}s | ${s.transition}\n` +
+ ` ${SF("visual:")} ${(s.keyframe_prompt||"").slice(0,80)}${(s.keyframe_prompt||"").length>80?"...":""}\n` +
+ ` ${SF("motion:")} ${(s.animation_prompt||"").slice(0,80)}\n` +
+ ` ${SF("palette:")} ${s.color_palette||"natural"} ${SF("light:")} ${s.lighting||"ambient"}\n\n`
+ );
+ }
+}
+
+async function sbRender(ctx: AppContext, sb: any, phase: string, out: Writable): Promise {
+ if (phase === "generate" || phase === "render") {
+ out.write(SF(`generating ${sb.total_scenes} keyframes...\n\n`));
+ for (const s of sb.scenes) {
+ out.write(SF(` scene ${s.index}/${sb.total_scenes}: ${s.shot_type}\n`));
+ try {
+ const mk = autoRouteModel(s.keyframe_prompt, "image");
+ const resp = await dispatchGeneration(ctx.api, s.keyframe_prompt, mk, { aspect: "16_9" });
+ if (resp.media_url) {
+ const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), mk, "image");
+ s.generated_frame_url = resp.media_url; s.generated_frame_path = fp;
+ recordOutput({ model: mk, prompt: s.keyframe_prompt, kind: "image", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} });
+ out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`);
+ }
+ } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+ }
+ sb.status = "keyframes_generated"; saveStoryboard(sb);
+ }
+ if (phase === "animate" || phase === "render") {
+ const anim = sb.scenes.filter((s: any) => s.generated_frame_url);
+ out.write(SF(`animating ${anim.length} scenes...\n\n`));
+ for (const s of anim) {
+ out.write(SF(` scene ${s.index}: ${s.camera_movement} over ${s.duration_sec}s\n`));
+ try {
+ const resp = await dispatchGeneration(ctx.api, s.animation_prompt, "vision_seedance", { model: "seedance", ref: s.generated_frame_url, duration: s.duration_sec });
+ if (resp.media_url) {
+ const fp = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video");
+ recordOutput({ model: "vision_seedance", prompt: s.animation_prompt, kind: "video", filepath: fp, filename: basename(fp), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} });
+ out.write(` ${theme.iceBlue("✓")} scene ${s.index}\n`);
+ }
+ } catch (err) { out.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+ }
+ sb.status = "animated"; saveStoryboard(sb);
+ }
+}
diff --git a/src/commands/slash_orchestra.ts b/src/commands/slash_orchestra.ts
new file mode 100644
index 0000000..43b8568
--- /dev/null
+++ b/src/commands/slash_orchestra.ts
@@ -0,0 +1,142 @@
+// In-REPL orchestra slash commands: /agents /delegate /tree /broadcast
+// /gather. Split out of slash.ts (was 1807 lines) to keep each command group
+// under the repo's ~800-line file convention.
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { AGENTS_PATH } from "../core/transport.js";
+import { theme } from "../ui/theme.js";
+import { delegateWorker, getOrchTree, broadcastToAgents, gatherResults, requireOrchestrator } from "../core/orchestrator.js";
+
+// ── Agents slash handler ────────────────────────
+
+export async function agentsSlash(ctx: AppContext, out: Writable): Promise {
+ try {
+ const resp = await ctx.api.getJson<{ agents?: Array<{
+ name?: string; status?: string; working_time?: string;
+ uvt_streamed?: number; task?: string;
+ }> }>(AGENTS_PATH);
+ const agents = resp.agents ?? [];
+ if (agents.length === 0) {
+ out.write("(no active agents)\n");
+ return;
+ }
+ // Columns: name, status, working time, UVT streamed, task
+ const nameW = Math.max(20, ...agents.map(a => (a.name ?? "?").length));
+ const statusW = 12;
+ const timeW = 14;
+ const uvtW = 12;
+
+ // Header
+ const header = " " +
+ "name".padEnd(nameW) + " " +
+ "status".padEnd(statusW) + " " +
+ "time".padEnd(timeW) + " " +
+ "UVT".padEnd(uvtW) + " " +
+ "task";
+ out.write(theme.bold(header) + "\n");
+ out.write(theme.dim(" " + "─".repeat(nameW + statusW + timeW + uvtW + 30)) + "\n");
+
+ for (const a of agents) {
+ const name = (a.name ?? "?").padEnd(nameW);
+ const status = (a.status ?? "?").padEnd(statusW);
+ const time = (a.working_time ?? "—").padEnd(timeW);
+ const uvt = a.uvt_streamed != null
+ ? String(a.uvt_streamed).padEnd(uvtW)
+ : "—".padEnd(uvtW);
+ const task = (a.task ?? "").slice(0, 50);
+
+ const statusColor = a.status === "running" ? theme.cyan :
+ a.status === "complete" ? theme.dim : theme.muted;
+
+ out.write(` ${theme.bold(name)}${statusColor(status)}${theme.dim(time)}${theme.dim(uvt)}${task}\n`);
+ }
+ } catch {
+ out.write("agents: unreachable (are you logged in? aether auth login)\n");
+ }
+}
+
+// ── Orchestrator slash handlers ─────────────────
+
+export async function delegateSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!requireOrchestrator(ctx, out)) return;
+ const parts = arg.split(/\s+/);
+ const model = parts[0];
+ const task = parts.slice(1).join(" ");
+ if (!model || !task) {
+ out.write("usage: /delegate \n");
+ return;
+ }
+ try {
+ const r = await delegateWorker(ctx.api, ctx.flags.agent!, model, task);
+ out.write(`delegated → worker ${r.worker_id} (${r.status}) running ${model}\n`);
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function treeSlash(ctx: AppContext, out: Writable): Promise {
+ if (!requireOrchestrator(ctx, out)) return;
+ try {
+ const r = await getOrchTree(ctx.api, ctx.flags.agent!);
+ out.write(theme.bold(`orchestrator: ${r.orchestrator}`) + "\n");
+ if (r.workers.length === 0) {
+ out.write(" (no active sub-agents)\n");
+ return;
+ }
+ // Columns: id, model, step, tokens, UVT
+ const idW = Math.max(10, ...r.workers.map(w => w.id.length));
+ const modelW = Math.max(10, ...r.workers.map(w => w.model.length));
+ const stepW = Math.max(12, ...r.workers.map(w => w.step.length));
+ const header = " " +
+ "id".padEnd(idW) + " " +
+ "model".padEnd(modelW) + " " +
+ "step".padEnd(stepW) + " " +
+ "tokens".padEnd(10) + " " +
+ "UVT";
+ out.write(theme.dim(header) + "\n");
+ out.write(theme.dim(" " + "─".repeat(idW + modelW + stepW + 30)) + "\n");
+ for (const w of r.workers) {
+ out.write(
+ ` ${theme.bold(w.id.padEnd(idW))} ` +
+ `${w.model.padEnd(modelW)} ` +
+ `${w.step.padEnd(stepW)} ` +
+ `${String(w.tokens).padEnd(10)} ` +
+ `${w.uvt}\n`
+ );
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function broadcastSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!requireOrchestrator(ctx, out)) return;
+ const message = arg.trim();
+ if (!message) {
+ out.write("usage: /broadcast \"\"\n");
+ return;
+ }
+ try {
+ const r = await broadcastToAgents(ctx.api, ctx.flags.agent!, message);
+ out.write(`broadcast → delivered to ${r.delivered_to} sub-agents\n`);
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function gatherSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ if (!requireOrchestrator(ctx, out)) return;
+ const workerId = arg.trim();
+ if (!workerId) { out.write("usage: /gather \n"); return; }
+ try {
+ const r = await gatherResults(ctx.api, ctx.flags.agent!, workerId);
+ if (r.results.length === 0) { out.write("(no results to gather)\n"); return; }
+ for (const res of r.results) {
+ out.write(`${theme.bold(res.worker_id)}:\n`);
+ if (res.files.length) out.write(` files: ${res.files.join(", ")}\n`);
+ if (res.diffs.length) out.write(` diffs: ${res.diffs.length} diff(s)\n`);
+ if (res.patches.length) out.write(` patches: ${res.patches.length} patch(es)\n`);
+ }
+ } catch (err) { out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); }
+}
diff --git a/src/commands/slash_vault_workflow.ts b/src/commands/slash_vault_workflow.ts
new file mode 100644
index 0000000..309c949
--- /dev/null
+++ b/src/commands/slash_vault_workflow.ts
@@ -0,0 +1,126 @@
+// In-REPL Vault + Workflow slash commands: /vault-* /workflow*. Split out of
+// slash.ts (was 1807 lines) to keep each command group under the repo's
+// ~800-line file convention.
+
+import type { Writable } from "node:stream";
+import type { AppContext } from "../core/context.js";
+import { getVaultSnapshot, searchNotes, notesByTag, getNotesTree } from "../core/vault.js";
+import { WORKFLOW_TEMPLATES, listWorkflows } from "../core/workflow.js";
+
+// ── Vault slash handlers ────────────────────────
+
+export async function vaultStatusSlash(ctx: AppContext, out: Writable): Promise {
+ try {
+ const r = await getVaultSnapshot(ctx.api, 800);
+ out.write(`vault: ${r.note_count} notes\n`);
+ } catch {
+ out.write("vault: unreachable\n");
+ }
+}
+
+export async function vaultContextSlash(ctx: AppContext, out: Writable): Promise {
+ try {
+ await getVaultSnapshot(ctx.api, 2000);
+ out.write("vault context loaded for next agent turn.\n");
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function vaultSearchSlash(ctx: AppContext, out: Writable, query: string): Promise {
+ if (!query) { out.write("usage: /vault-search \n"); return; }
+ try {
+ const r = await searchNotes(ctx.api, query, { limit: 10 });
+ if (r.results.length === 0) { out.write("no results.\n"); return; }
+ for (const n of r.results) {
+ out.write(` ${n.title || n.path} (${n.path})\n`);
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function vaultRecentSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ try {
+ const n = Math.min(parseInt(arg) || 10, 50);
+ const r = await searchNotes(ctx.api, "", { limit: n });
+ if (r.results.length === 0) { out.write("(empty vault)\n"); return; }
+ for (const row of r.results) {
+ out.write(` ${row.title || row.path}\n`);
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function vaultProjectSlash(ctx: AppContext, out: Writable, name: string): Promise {
+ if (!name) { out.write("usage: /vault-project \n"); return; }
+ try {
+ const r = await searchNotes(ctx.api, "", { project: name, limit: 20 });
+ if (r.results.length === 0) { out.write(`no notes for project: ${name}\n`); return; }
+ for (const n of r.results) {
+ out.write(` ${n.title || n.path} [${n.tags.join(", ")}]\n`);
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function vaultTagSlash(ctx: AppContext, out: Writable, tag: string): Promise {
+ if (!tag) { out.write("usage: /vault-tag \n"); return; }
+ try {
+ const r = await notesByTag(ctx.api, tag, 20);
+ if (r.results.length === 0) { out.write(`no notes with tag: ${tag}\n`); return; }
+ for (const n of r.results) {
+ out.write(` ${n.title || n.path} (${n.path})\n`);
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+export async function vaultTreeSlash(ctx: AppContext, out: Writable): Promise {
+ try {
+ const r = await getNotesTree(ctx.api);
+ if (r.tree.length === 0) { out.write("(empty vault)\n"); return; }
+ for (const e of r.tree) {
+ out.write(` ${e.folder || "/"} (${e.count} notes)\n`);
+ }
+ } catch (err) {
+ out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
+ }
+}
+
+// ── Workflow slash handlers ─────────────────────
+
+export async function workflowSlash(ctx: AppContext, out: Writable): Promise {
+ try {
+ const workflows = await listWorkflows(ctx.api);
+ out.write(`workflow: ${workflows.length} in vault · ${WORKFLOW_TEMPLATES.length} templates available\n`);
+ if (workflows.length > 0) {
+ for (const w of workflows.slice(0, 5)) out.write(` ${w.name} (${Math.round(w.size/1024)} KB)\n`);
+ }
+ } catch {
+ out.write("workflow: unavailable\n");
+ }
+}
+
+export async function workflowTemplatesSlash(ctx: AppContext, out: Writable): Promise {
+ for (let i = 0; i < WORKFLOW_TEMPLATES.length; i++) {
+ const t = WORKFLOW_TEMPLATES[i]!;
+ out.write(` ${String(i+1)}. ${t.icon} ${t.name} (${t.category}, ${t.difficulty})\n`);
+ }
+ out.write("load: /workflow-template \n");
+}
+
+export async function workflowTemplateSlash(ctx: AppContext, out: Writable, arg: string): Promise {
+ const n = parseInt(arg);
+ if (isNaN(n) || n < 1 || n > WORKFLOW_TEMPLATES.length) {
+ out.write(`invalid: ${arg} (1-${WORKFLOW_TEMPLATES.length})\n`);
+ return;
+ }
+ const t = WORKFLOW_TEMPLATES[n - 1]!;
+ out.write(`${t.icon} ${t.name}: ${t.subtitle}\n`);
+ out.write(` ${t.workflow.nodes?.length || 0} nodes · ${t.workflow.edges?.length || 0} edges\n`);
+ out.write(` save with: aether workflow save ${t.id}\n`);
+}
diff --git a/src/commands/vault.ts b/src/commands/vault.ts
index 7cf4ba1..264632d 100644
--- a/src/commands/vault.ts
+++ b/src/commands/vault.ts
@@ -3,6 +3,7 @@
// Upload/download deferred (multipart + raw-binary plumbing needed).
import type { AppContext } from "../core/context.js";
+import { fail as coreFail } from "../core/errors.js";
import {
getVaultList, browseVault,
getSpacesUsage, getSpacesContent, deleteSpacesFile,
@@ -42,11 +43,6 @@ export async function cmdVault(ctx: AppContext, argv: string[]): Promise
}
}
-function notYet(feature: string): Promise {
- process.stderr.write(`${feature} — coming soon (multipart + raw-binary plumbing needed).\n`);
- return Promise.resolve(1);
-}
-
function printVaultHelp(): void {
process.stdout.write([
"aether vault list List vault files/folders",
@@ -278,7 +274,5 @@ function renderSearchResults(r: VaultSearchResponse, query: string): void {
}
function fail(err: unknown): number {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`✗ ${msg}\n (are you logged in? run: aether auth login)\n`);
- return 1;
+ return coreFail(err, "are you logged in? run: aether auth login");
}
\ No newline at end of file
diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts
index f303afe..1cfdc84 100644
--- a/src/commands/workflow.ts
+++ b/src/commands/workflow.ts
@@ -2,6 +2,7 @@
// Follows the exact pattern of aether vault and aether github
import type { AppContext } from "../core/context.js";
+import { fail as coreFail } from "../core/errors.js";
import { buildChatRequest } from "../core/envelope.js";
import { CHAT_STREAM_PATH } from "../core/transport.js";
import { decodeSse } from "../core/stream.js";
@@ -11,8 +12,7 @@ import {
listWorkflows, getWorkflow, deleteWorkflow, saveWorkflow, exportWorkflow,
assessWorkflow, brainstormWorkflow, planWorkflow, finalizeWorkflow,
formatWorkflowSummary, formatWorkflowDetail,
- type Workflow, type TemplateInfo,
- type WorkflowAssessResponse, type WorkflowBrainstormResponse,
+ type Workflow, type WorkflowBrainstormResponse,
} from "../core/workflow.js";
export async function cmdWorkflow(ctx: AppContext, argv: string[]): Promise {
@@ -41,11 +41,6 @@ export async function cmdWorkflow(ctx: AppContext, argv: string[]): Promise {
- process.stderr.write(`${feature} — coming soon (multipart upload/download plumbing needed).\n`);
- return Promise.resolve(1);
-}
-
function printWorkflowHelp(): void {
process.stdout.write([
"aether workflow new Generate a workflow from intent (AI-driven)",
@@ -384,7 +379,5 @@ async function workflowStatus(ctx: AppContext): Promise {
// ── Helpers ──────────────────────────────────────
function fail(err: unknown): number {
- const msg = err instanceof Error ? err.message : String(err);
- process.stderr.write(`✗ ${msg}\n (are you logged in? run: aether auth login)\n`);
- return 1;
+ return coreFail(err, "are you logged in? run: aether auth login");
}
\ No newline at end of file
diff --git a/src/core/errors.ts b/src/core/errors.ts
index dd54391..d760d33 100644
--- a/src/core/errors.ts
+++ b/src/core/errors.ts
@@ -1,5 +1,21 @@
// Error taxonomy for the CLI.
+/** Unwrap any thrown value to a display message. */
+export function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+/**
+ * Standard command-handler failure path: print `✗ ` (with an
+ * optional parenthesized hint line, e.g. "are you logged in? run: ...")
+ * and return the conventional non-zero exit code.
+ */
+export function fail(err: unknown, hint?: string): number {
+ const msg = errorMessage(err);
+ process.stderr.write(`✗ ${msg}\n${hint ? ` (${hint})\n` : ""}`);
+ return 1;
+}
+
/** Thrown by seams that are scaffolded but not yet wired to the the Aether API. */
export class NotWiredError extends Error {
constructor(what: string) {
diff --git a/src/core/workflow.ts b/src/core/workflow.ts
index 83389ea..696ad8b 100644
--- a/src/core/workflow.ts
+++ b/src/core/workflow.ts
@@ -6,7 +6,7 @@ import {
PROJECT_FROM_WORKFLOW_ASSESS_PATH, PROJECT_FROM_WORKFLOW_BRAINSTORM_PATH,
PROJECT_FROM_WORKFLOW_PLAN_PATH, PROJECT_FROM_WORKFLOW_FINALIZE_PATH,
} from "./transport.js";
-import { listSpaces, getSpacesContent, deleteSpacesFile, uploadFile, downloadFile, type VaultSpacesFile } from "./vault.js";
+import { listSpaces, getSpacesContent, deleteSpacesFile, uploadFile, downloadFile } from "./vault.js";
// ── Types ───────────────────────────────────────
diff --git a/web-cloud.png b/web-cloud.png
deleted file mode 100644
index f672043..0000000
Binary files a/web-cloud.png and /dev/null differ